Command
Commandは、任意項目を一覧表示し、選択かつ処理実行できるコンポーネントです。
<script lang="ts">
import Command, {
type CommandMenu,
type CommandSection,
} from '$lib/components/ui/modules/Command.svelte';
import { Check, Copy, Download, MoonStar, UndoIcon } from '@lucide/svelte';
let sections: CommandSection[] = [
{
label: 'ラベル',
menus: [
{ id: '1', label: 'メニュー1', shortcutText: '⇧⌘E' },
{ id: '2', label: 'メニュー2', shortcutText: '⇧⌘E' },
{ id: '3', label: 'メニュー3' },
{ id: '4', label: 'メニュー4' },
{ id: '5', label: 'メニュー5' },
],
},
{
label: 'ラベル',
menus: [
{ id: '6', label: 'メニュー6' },
{ id: '7', label: 'メニュー7', shortcutText: '⇧⌘E' },
{ id: '8', label: 'メニュー8', shortcutText: '⇧⌘E' },
{ id: '9', label: 'メニュー9', shortcutText: '⇧⌘E' },
],
},
];
const iconMap = {
1: Copy,
2: Download,
3: Check,
4: UndoIcon,
5: MoonStar,
};
</script>
<div>
<Command class="w-93.25" {sections}>
{#snippet startContent(item: CommandMenu)}
{@const Icon = item.id && iconMap[item.id]}
{#if Icon}
<div class="flex items-center justify-center shrink-0">
<Icon
class="pointer-events-none text-base-foreground-default"
size="1rem"
/>
</div>
{/if}
{/snippet}
</Command>
</div>
プロパティ
Commandは、以下のプロパティをサポートしています。
| 名前 | 型 | デフォルト値 | 説明 |
|---|---|---|---|
class |
string |
追加したいカスタムクラスを渡します。 | |
sections |
CommandSection[] |
階層の配列を渡します。 | |
emptyView |
Snippet<[]> |
表示するメニューが空の場合に表示するレイアウトを渡します。 | |
onClick |
(item: CommandMenu, section: CommandSection) => void |
メニューが選択されたときのコールバック関数を渡します。 | |
startContent |
Snippet<[CommandMenu, CommandSection]> |
各メニューの左側に配置するコンテンツを渡します。 | |
autofocus |
boolean |
false |
Inputをオートフォーカスするかどうか。 |
CommandSection
CommandSectionは、各階層の情報を表すオブジェクトです。
| 名前 | 型 | デフォルト値 | 説明 |
|---|---|---|---|
label |
string |
セクションごとのラベルです。 | |
menus |
CommandMenu[] |
セクションごとの選択肢です。 |
CommandMenu
CommandMenuは、メニューの情報を表すオブジェクトです。
| 名前 | 型 | デフォルト値 | 説明 |
|---|---|---|---|
id |
any |
メニューの一意な識別子です。 | |
label |
string |
メニューのラベルです。 | |
disabled |
boolean |
false | 操作できるかどうか。 |
shortcutText |
string |
メニュー右側に表示するテキスト。 |
インストールの手順
以下のコンポーネントのコードを、使いたいプロジェクトにコピー&ペーストします。
パスは実際のプロジェクトの構成にあわせて更新します。
modules/Command.svelte
<!--
@component
## 概要
- 渡したセクションを一覧表示し、選択実行できるコンポーネントです
## 機能
- 渡したセクションを一覧表示する
- セクションごとに設定した選択肢を表示できる
## Props
- class: 追加したいカスタムクラス
- sections: セクションとして表示する値の配列
- emptyView: 表示するメニューが空の場合に表示するレイアウト
- onClick: メニューが選択されたときのコールバック関数
- startContent: 各メニューの左側に配置するコンテンツ
- autofocus: オートフォーカスするかどうか
## Usage
```svelte
<Command {sections} />
```
-->
<script module lang="ts">
import type { Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import { cva, type VariantProps } from 'class-variance-authority';
export const CommandVariants = cva('relative flex flex-col bg-base-container-default border border-base-stroke-default rounded-md shadow-md');
export const menuVariants = cva('flex items-center justify-between w-full gap-2 px-2 py-1.5 rounded-xs text-left outline-primary align-middle cursor-pointer menu hover:bg-base-container-accent/90 data-[command-active="true"]:bg-base-container-accent/90 focus-visible:outline-[0.125rem] data-[command-active="true"]:outline-none focus-visible:outline-offset-[0.125rem] focus-visible:outline-primary', {
variants: {
/** 操作できるかどうか */
disabled: {
true: ['opacity-50 pointer-events-none'],
false: [],
},
},
});
export interface CommandMenu {
id?: any;
/** メニューのラベル */
label: string;
/** 操作できるかどうか */
disabled?: boolean;
/** 右側に表示するテキスト(矢印アイコンを表示している場合表示できない) */
shortcutText?: string;
}
export interface CommandSection {
/** メニューセクションのラベル */
label?: string;
/** メニューとして表示する値 */
menus?: CommandMenu[];
}
export type CommandVariants = VariantProps<typeof CommandVariants>;
export interface CommandProps extends CommandVariants {
/** 入力された値 */
value?: string;
/** クラス */
class?: ClassValue;
/** オートフォーカスするかどうか */
autofocus?: boolean;
/** メニューとして表示したい要素の配列 */
sections: CommandSection[];
/** 表示メニューが空の場合の表示 */
emptyView?: Snippet<[]>;
/** メニューが選択されたときのコールバック関数 */
onClick?: (item: CommandMenu, section: CommandSection) => void;
/** 左側に配置するコンテンツ */
startContent?: Snippet<[CommandMenu, CommandSection]>;
}
</script>
<script lang="ts">
import Input from '$lib/components/ui/atoms/Input.svelte';
import Separator from '$lib/components/ui/atoms/Separator.svelte';
import { Search } from '@lucide/svelte';
import { onDestroy, tick } from 'svelte';
let { class: className = '', value = $bindable(''), sections, autofocus = false, emptyView, onClick, startContent }: CommandProps = $props();
let commandElement = $state<HTMLElement>();
let menuItems = $state<HTMLButtonElement[]>([]);
let currentFocusIndex = $state<number>(-1);
let menuScrollContainer = $state<HTMLDivElement>();
let navigableMenus = $derived.by(() =>
sections.flatMap((section) =>
(section.menus ?? []).filter((menu) => !menu.disabled).map((menu) => ({ menu, section })),
),
);
let commandVariantClass = $derived(CommandVariants({ class: className }));
onDestroy(() => {
value = '';
});
$effect(() => {
sections;
syncMenuItems();
});
function updateActiveMenu() {
menuItems.forEach((item, index) => {
if (currentFocusIndex === index) {
item.dataset.commandActive = 'true';
}
else {
delete item.dataset.commandActive;
}
});
}
function syncMenuItems() {
const menu_element = commandElement?.querySelectorAll<HTMLButtonElement>('button:not([disabled])');
const next_items = menu_element ? Array.from(menu_element) : [];
const same_length = menuItems.length === next_items.length;
const same_order = same_length && menuItems.every((item, index) => item === next_items[index]);
if (same_order) return;
menuItems = next_items;
if (menuItems.length === 0) {
currentFocusIndex = -1;
updateActiveMenu();
return;
}
if (currentFocusIndex >= menuItems.length) {
currentFocusIndex = menuItems.length - 1;
updateActiveMenu();
}
if (currentFocusIndex === -1) {
setActiveMenu(0, { focusElement: false, scrollIntoView: false });
}
else {
updateActiveMenu();
}
}
function setActiveMenu(index: number, options: { focusElement?: boolean; scrollIntoView?: boolean } = {}) {
if (menuItems.length === 0) {
currentFocusIndex = -1;
updateActiveMenu();
return;
}
const { focusElement = true, scrollIntoView = true } = options;
let normalized_index = index;
if (normalized_index < 0) {
normalized_index = ((normalized_index % menuItems.length) + menuItems.length) % menuItems.length;
}
else {
normalized_index = normalized_index % menuItems.length;
}
currentFocusIndex = normalized_index;
updateActiveMenu();
const target = menuItems[normalized_index];
if (!target) return;
if (focusElement) {
target.focus({ preventScroll: true });
}
if (scrollIntoView) {
target.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
}
function moveFocus(
direction: number,
options: {
focusElement?: boolean;
scrollIntoView?: boolean;
} = {},
) {
if (menuItems.length === 0) return;
let next_index = currentFocusIndex;
if (next_index === -1) {
next_index = direction > 0 ? 0 : menuItems.length - 1;
}
else {
next_index += direction;
}
if (next_index < 0) {
next_index = 0;
}
else if (next_index >= menuItems.length) {
next_index = menuItems.length - 1;
}
setActiveMenu(next_index, options);
}
function onKeyDownMenu(e: KeyboardEvent, menu: CommandMenu, section: CommandSection) {
if (e.isComposing) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
moveFocus(1);
}
else if (e.key === 'ArrowUp') {
e.preventDefault();
moveFocus(-1);
}
else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.(menu, section);
}
}
function onKeyDownInput(e: KeyboardEvent) {
if (e.isComposing) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (menuItems.length === 0) return;
moveFocus(1, { focusElement: false });
}
else if (e.key === 'ArrowUp') {
e.preventDefault();
if (menuItems.length === 0) return;
moveFocus(-1, { focusElement: false });
}
else if (e.key === 'Enter') {
e.preventDefault();
const active = navigableMenus[currentFocusIndex];
if (active) {
onClick?.(active.menu, active.section);
}
}
}
function onInput() {
menuScrollContainer?.scrollTo({ top: 0, behavior: 'auto' });
setActiveMenu(0, { focusElement: false, scrollIntoView: false });
}
async function onFocus() {
await tick();
syncMenuItems();
menuScrollContainer?.scrollTo({ top: 0, behavior: 'auto' });
if (menuItems.length === 0) {
currentFocusIndex = -1;
updateActiveMenu();
return;
}
setActiveMenu(0, { focusElement: false, scrollIntoView: false });
}
</script>
<div class={commandVariantClass} bind:this={commandElement}>
<div class="px-2 py-2">
<Input type="text" placeholder="プレースホルダー" bind:value {autofocus} onkeydown={onKeyDownInput} onfocus={onFocus} oninput={onInput}>
{#snippet startContent()}
<Search class="text-base-foreground-muted pointer-events-none" size="1rem" />
{/snippet}
</Input>
</div>
<div class="flex-1 min-h-0 pb-2 px-2 overflow-auto" bind:this={menuScrollContainer}>
{#each sections as section, index}
<div class="w-full">
{#if index !== 0}
<div class="px-2 py-1">
<Separator />
</div>
{/if}
{#if section.menus}
{#if section.label}
<div class="px-2 py-1.5 leading-normal text-base-foreground-subtle">
{section.label}
</div>
{/if}
{#each section.menus as menu}
<div class="relative w-full">
<button class={menuVariants({ disabled: menu.disabled })} type="button" tabindex={menu.disabled ? -1 : 0} onclick={() => onClick?.(menu, section)} onkeydown={(event) => onKeyDownMenu(event, menu, section)} disabled={menu.disabled}>
{#if startContent}
<div class="shrink-0 empty:hidden">
{@render startContent(menu, section)}
</div>
{/if}
<div class="w-full leading-normal text-sm text-base-foreground-default whitespace-nowrap">
{menu.label}
</div>
{#if menu.shortcutText}
<div class="flex items-center justify-center shrink-0 text-base-foreground-muted text-xs ml-2">
{menu.shortcutText}
</div>
{/if}
</button>
</div>
{/each}
{/if}
</div>
{:else}
<div class="grid place-content-center h-full min-h-32.25 px-2">
{#if emptyView}
{@render emptyView()}
{:else}
<div class="flex flex-col items-center justify-center gap-2 px-4 py-7">
<Search class="text-base-foreground-subtle" size="1.5rem" />
<p class="text-base-foreground-muted text-sm">候補が見つかりませんでした</p>
</div>
{/if}
</div>
{/each}
</div>
</div>
依存コンポーネント
Commandを使うときは、以下のコンポーネントもダウンロードが必要です。
使い方
<script lang="ts">
import Command, {
type CommandMenu,
type CommandSection,
} from '$lib/components/ui/modules/Command.svelte';
import { Check, Copy, Download, MoonStar, UndoIcon } from '@lucide/svelte';
let sections: CommandSection[] = [
{
label: 'ラベル',
menus: [
{ id: '1', label: 'メニュー1', shortcutText: '⇧⌘E' },
{ id: '2', label: 'メニュー2', shortcutText: '⇧⌘E' },
{ id: '3', label: 'メニュー3' },
{ id: '4', label: 'メニュー4' },
{ id: '5', label: 'メニュー5' },
],
},
{
label: 'ラベル',
menus: [
{ id: '6', label: 'メニュー6' },
{ id: '7', label: 'メニュー7', shortcutText: '⇧⌘E' },
{ id: '8', label: 'メニュー8', shortcutText: '⇧⌘E' },
{ id: '9', label: 'メニュー9', shortcutText: '⇧⌘E' },
],
},
];
const iconMap = {
1: Copy,
2: Download,
3: Check,
4: UndoIcon,
5: MoonStar,
};
</script>
<div>
<Command class="w-93.25" {sections}>
{#snippet startContent(item: CommandMenu)}
{@const Icon = item.id && iconMap[item.id]}
{#if Icon}
<div class="flex items-center justify-center shrink-0">
<Icon
class="pointer-events-none text-base-foreground-default"
size="1rem"
/>
</div>
{/if}
{/snippet}
</Command>
</div>
サンプル
Default
特に操作が行われていない、デフォルトの状態です。
<script lang="ts">
import Command, {
type CommandMenu,
type CommandSection,
} from '$lib/components/ui/modules/Command.svelte';
import { Check, Copy, Download, MoonStar, UndoIcon } from '@lucide/svelte';
let sections: CommandSection[] = [
{
label: 'ラベル',
menus: [
{ id: '1', label: 'メニュー1', shortcutText: '⇧⌘E' },
{ id: '2', label: 'メニュー2', shortcutText: '⇧⌘E' },
{ id: '3', label: 'メニュー3' },
{ id: '4', label: 'メニュー4' },
{ id: '5', label: 'メニュー5' },
],
},
{
label: 'ラベル',
menus: [
{ id: '6', label: 'メニュー6' },
{ id: '7', label: 'メニュー7', shortcutText: '⇧⌘E' },
{ id: '8', label: 'メニュー8', shortcutText: '⇧⌘E' },
{ id: '9', label: 'メニュー9', shortcutText: '⇧⌘E' },
],
},
];
const iconMap = {
1: Copy,
2: Download,
3: Check,
4: UndoIcon,
5: MoonStar,
};
</script>
<div>
<Command class="w-93.25" {sections}>
{#snippet startContent(item: CommandMenu)}
{@const Icon = item.id && iconMap[item.id]}
{#if Icon}
<div class="flex items-center justify-center shrink-0">
<Icon
class="pointer-events-none text-base-foreground-default"
size="1rem"
/>
</div>
{/if}
{/snippet}
</Command>
</div>
Disabled
選択不可の選択肢が含まれている状態です。
<script lang="ts">
import Command from '$lib/components/ui/modules/Command.svelte';
let sections = [
{
label: 'ラベル',
menus: [
{ id: '1', label: 'メニュー1' },
{ id: '2', label: 'メニュー2', disabled: true },
{ id: '3', label: 'メニュー3', shortcutText: '⇧⌘E' },
{ id: '4', label: 'メニュー4', shortcutText: '⇧⌘E', disabled: true },
],
},
];
</script>
<div>
<Command class="w-93.25" {sections}></Command>
</div>
Empty
メニューが空の状態です。
<script lang="ts">
import Command from '$lib/components/ui/modules/Command.svelte';
import { Search } from '@lucide/svelte';
let sections = [];
</script>
<div>
<Command class="w-93.25" {sections}>
{#snippet emptyView()}
<div class="flex flex-col items-center py-1.5 px-4 text-base-foreground-muted">
<Search class="mb-2" size="1.5rem" />
<div class="leading-normal">候補が見つかりませんでした</div>
</div>
{/snippet}
</Command>
</div>
onClick
コールバック関数で値を受け取ることができます。
<script lang="ts">
import DebugConsole from '$lib/components/ui/atoms/DebugConsole.svelte';
import Command, {
type CommandMenu,
type CommandSection,
} from '$lib/components/ui/modules/Command.svelte';
import { Check, Copy, Download, MoonStar, UndoIcon } from '@lucide/svelte';
let selectedValue = $state();
let sections: CommandSection[] = [
{
label: 'ラベル',
menus: [
{ id: '1', label: 'メニュー1', shortcutText: '⇧⌘E' },
{ id: '2', label: 'メニュー2', shortcutText: '⇧⌘E' },
{ id: '3', label: 'メニュー3' },
{ id: '4', label: 'メニュー4' },
{ id: '5', label: 'メニュー5', disabled: true },
],
},
{
label: 'ラベル',
menus: [
{ id: '6', label: 'メニュー6' },
{ id: '7', label: 'メニュー7', shortcutText: '⇧⌘E' },
{ id: '8', label: 'メニュー8', shortcutText: '⇧⌘E', disabled: true },
{ id: '9', label: 'メニュー9', shortcutText: '⇧⌘E' },
],
},
];
const iconMap = {
1: Copy,
2: Download,
3: Check,
4: UndoIcon,
5: MoonStar,
};
function onClick(item, section) {
selectedValue = item;
}
</script>
<div class="w-full">
<Command class="w-93.25 mx-auto" {sections} {onClick}>
{#snippet startContent(item: CommandMenu)}
{@const Icon = item.id && iconMap[item.id]}
{#if Icon}
<div class="flex items-center justify-center shrink-0">
<Icon class="pointer-events-none text-base-foreground-default" size="1rem" />
</div>
{/if}
{/snippet}
</Command>
<DebugConsole class="mt-4" data={selectedValue} />
</div>