Command

Commandは、任意項目を一覧表示し、選択かつ処理実行できるコンポーネントです。

プロパティ

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を使うときは、以下のコンポーネントもダウンロードが必要です。

使い方


サンプル

Default

特に操作が行われていない、デフォルトの状態です。

Disabled

選択不可の選択肢が含まれている状態です。

Empty

メニューが空の状態です。

onClick

コールバック関数で値を受け取ることができます。