Closed45

shadcn/uiでmulti selectなcomboboxが欲しい

hajimismhajimism

どうせ世界中のみんなも同じ想いだろうと思ったらやっぱりそうだったし、なんならshadcnも同じ想いだった
https://github.com/shadcn/ui/issues/66

hajimismhajimism

Downshiftというライブラリを使えっていうアドバイスがある
https://www.downshift-js.com/use-multiple-selection/

The library offers a couple of solutions. The first solution, which is the one we recommend you to try first, is a set of React hooks. Each hook provides the stateful logic needed to make the corresponding component functional and accessible. Navigate to the documentation for each by using the links in the list below.

いい感じのhooksを提供してくれるみたい。shadcn/uiのComboboxはcmdkを使っていて、カニバリ具合がちょっと気になる

hajimismhajimism

そもそもshadcn/uiのComboboxも検索ができるだけで新規作成はできないからなー

hajimismhajimism
hajimismhajimism

ロジックのところはざっくりこんな感じ

  • inputRefとinputValueってなんで別々に管理しているんやろ
  • openいつ使うんやろ
  • handleUnselectは明確に選択解除だろうし、selectableは選択肢だろうし、handleKeyDownには細かい操作が詰まってんだろうな
hajimismhajimism

Viewの部分は大きく

入力部と

選択肢部

に別れている。shadcn/uiのComboboxだと選択肢部の表示にPopoverを使っていたけれど、こっちは自前で管理している。

hajimismhajimism

ロジックの方から読むか。

ゆーても気になるところはhandleKeyDownしかなく

  const handleKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
    const input = inputRef.current
    if (input) {
      if (e.key === "Delete" || e.key === "Backspace") {
        if (input.value === "") {
          setSelected(prev => {
            const newSelected = [...prev];
            newSelected.pop();
            return newSelected;
          })
        }
      }
      // This is not a default behaviour of the <input /> field
      if (e.key === "Escape") {
        input.blur();
      }
    }
  }, []);
  • keydownを観測するためにinputRefを使っている
  • delete keyで最新追加されたものを削除
  • escape keyでfocus out
hajimismhajimism

View

  • 入力部は、selectされたBadgeとInputが並んでいるだけ
  • 選択肢部はCommandItemが並んでいるだけ

という感じ。シンプルだな。

hajimismhajimism

手を動かしていこう。

Fancyをほぼ丸パクリさせてもらって、多少の抽象化をほどこした。

/**
 * ref: https://craft.mxkaske.dev/post/fancy-multi-select
 */

"use client";

import { Command as CommandPrimitive } from "cmdk";
import { X } from "lucide-react";
import {
  useRef,
  useState,
  useCallback,
  KeyboardEvent,
  FC,
  MouseEventHandler,
} from "react";

import { Badge } from "@/components/ui/badge";
import { Command, CommandGroup, CommandItem } from "@/components/ui/command";

import { Item } from "./type";

type Props = {
  items: Item[];
  placeholder?: string;
};

export const MultiSelect: FC<Props> = ({
  items,
  placeholder = "Select items...",
}) => {
  const inputRef = useRef<HTMLInputElement>(null);
  const [open, setOpen] = useState(false);
  const [selected, setSelected] = useState<typeof items>([]);
  const [inputValue, setInputValue] = useState("");

  const handleSelect = useCallback((item: Item) => {
    setInputValue("");
    setSelected((prev) => [...prev, item]);
  }, []);

  const handleUnselect = useCallback((item: Item) => {
    setSelected((prev) => prev.filter((s) => s.value !== item.value));
  }, []);

  const handleKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
    const input = inputRef.current;
    if (input) {
      if (e.key === "Delete" || e.key === "Backspace") {
        if (input.value === "") {
          setSelected((prev) => {
            const newSelected = [...prev];
            newSelected.pop();
            return newSelected;
          });
        }
      }
      // This is not a default behaviour of the <input /> field
      if (e.key === "Escape") {
        input.blur();
      }
    }
  }, []);

  const haltEvent: MouseEventHandler = (e) => {
    e.preventDefault();
    e.stopPropagation();
  };

  const selectables = items.filter((item) => !selected.includes(item));

  return (
    <Command
      onKeyDown={handleKeyDown}
      className="overflow-visible bg-transparent"
    >
      <div className="group rounded-md border border-input px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
        <div className="flex flex-wrap gap-1">
          {selected.map((item) => {
            return (
              <Badge key={item.value} variant="secondary">
                {item.label}
                <button
                  className="ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
                  onKeyDown={(e) => {
                    if (e.key === "Enter") {
                      handleUnselect(item);
                    }
                  }}
                  onMouseDown={haltEvent}
                  onClick={() => handleUnselect(item)}
                >
                  <X className="h-3 w-3 text-secondary-foreground transition-colors hover:text-muted-foreground" />
                </button>
              </Badge>
            );
          })}
          {/* Avoid having the "Search" Icon */}
          <CommandPrimitive.Input
            ref={inputRef}
            value={inputValue}
            onValueChange={setInputValue}
            onBlur={() => setOpen(false)}
            onFocus={() => setOpen(true)}
            placeholder={placeholder}
            className="ml-2 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
          />
        </div>
      </div>
      <div className="relative mt-2">
        {open && selectables.length > 0 && (
          <div className="absolute top-0 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
            <CommandGroup className="h-full overflow-auto">
              {selectables.map((item) => {
                return (
                  <CommandItem
                    key={item.value}
                    onSelect={() => handleSelect(item)}
                    onMouseDown={haltEvent}
                  >
                    {item.label}
                  </CommandItem>
                );
              })}
            </CommandGroup>
          </div>
        )}
      </div>
    </Command>
  );
};

hajimismhajimism

ここからやりたいこととしては「selectablesが無いときに、inputを新しく選択肢として加えるCommandを追加」くらいか?

hajimismhajimism

いろいろ試して、単純にこれを挿入しておくだけで「選択肢がないときにinputを新しく選択肢として加えるCommandを表示」ができるとわかった。たぶんCommandはItemのchildrenを見ているため、childrenにinputValueが入ってれば常に選択肢に出る。

              <CommandItem onMouseDown={haltEvent}>
                Create {inputValue}!
              </CommandItem>
hajimismhajimism

逆に空文字のときに出てこられても困るということで

              {inputValue && (
                <CommandItem onMouseDown={haltEvent}>
                  Create {inputValue}!
                </CommandItem>
              )}
hajimismhajimism

handleSelectで抽象化しているおかげで、これだけで良かったわ

  const createNew = () => {
    const newItem = { value: inputValue, label: inputValue };
    handleSelect(newItem);
  };
              {inputValue && (
                <CommandItem
                  key="new"
                  onSelect={createNew}
                  onMouseDown={haltEvent}
                >
                  Create {inputValue}!
                </CommandItem>
              )}

newItemのvalueについては諸説ありそう、単純な文字列として持てばよくね、みたいな

hajimismhajimism

あとはあれだな、親から渡しているitemsにnewItemsを加えたい

  • フロントだけで状態を管理する
  • いちいちサーバーを叩きに行って云々

みたいなことが考えられる。いずれにせよフロントで状態管理したい気がする。

hajimismhajimism

とりあえずJotaiで適当に管理してみる。一応期待通りに動く。

"use client";

import { useAtom } from "jotai";

import { flameworksAtom } from "./atom";
import { MultiSelect } from "./component";
import { Item } from "./type";

export default function Page() {
  const [flameworks, setFlameWorks] = useAtom(flameworksAtom);
  const onCreateNew = (newItem: Item) =>
    setFlameWorks((current) => [...current, newItem]);

  return (
    <div>
      <MultiSelect items={flameworks} onCreateNew={onCreateNew} />
    </div>
  );
}

MultiSelectのcreateNew

  const createNew = () => {
    const newItem = { value: inputValue, label: inputValue };
    onCreateNew?.(newItem);
    handleSelect(newItem);
  };
hajimismhajimism

いっそこういう抽象化もありかなと思う。利用者(開発者)の関心としてはこういうことだと思う。

"use client";

import { FRAMEWORKS } from "./data";
import { useMultiSelect } from "./hook";

export default function Page() {
  const MultiFlameworksSelect = useMultiSelect(FRAMEWORKS);

  return (
    <div>
      <MultiFlameworksSelect />
    </div>
  );
}
hajimismhajimism

だからまあ、全部のせでこんな感じよ。Jotaiへの依存はない。

"use client";

import { Command as CommandPrimitive } from "cmdk";
import { X } from "lucide-react";
import {
  useRef,
  useState,
  useCallback,
  KeyboardEvent,
  FC,
  MouseEventHandler,
} from "react";

import { Badge } from "@/components/ui/badge";
import { Command, CommandGroup, CommandItem } from "@/components/ui/command";

import { Item } from "./type";

export const useMultiSelect = (
  initialItems: Item[],
  placeholder = "Select items..."
) => {
  const [items, setItems] = useState(initialItems);
  const onCreateNew = (newItem: Item) =>
    setItems((current) => [...current, newItem]);

  const MultiSelect: FC = () => {
    const inputRef = useRef<HTMLInputElement>(null);
    const [open, setOpen] = useState(false);
    const [selected, setSelected] = useState<Item[]>([]);
    const [inputValue, setInputValue] = useState("");

    const createNew = () => {
      const newItem = { value: inputValue, label: inputValue };
      onCreateNew(newItem);
      handleSelect(newItem);
    };

    const handleSelect = useCallback((item: Item) => {
      setInputValue("");
      setSelected((prev) => [...prev, item]);
    }, []);

    const handleUnselect = useCallback((item: Item) => {
      setSelected((prev) => prev.filter((s) => s.value !== item.value));
    }, []);

    const handleKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
      const input = inputRef.current;
      if (input) {
        if (e.key === "Delete" || e.key === "Backspace") {
          if (input.value === "") {
            setSelected((prev) => {
              const newSelected = [...prev];
              newSelected.pop();
              return newSelected;
            });
          }
        }
        // This is not a default behaviour of the <input /> field
        if (e.key === "Escape") {
          input.blur();
        }
      }
    }, []);

    const haltEvent: MouseEventHandler = (e) => {
      e.preventDefault();
      e.stopPropagation();
    };

    const selectables = items.filter((item) => !selected.includes(item));

    return (
      <Command
        onKeyDown={handleKeyDown}
        className="overflow-visible bg-transparent"
      >
        <div className="group rounded-md border border-input px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
          <div className="flex flex-wrap gap-1">
            {selected.map((item) => {
              return (
                <Badge key={item.value} variant="secondary">
                  {item.label}
                  <button
                    className="ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
                    onKeyDown={(e) => {
                      if (e.key === "Enter") {
                        handleUnselect(item);
                      }
                    }}
                    onMouseDown={haltEvent}
                    onClick={() => handleUnselect(item)}
                  >
                    <X className="h-3 w-3 text-secondary-foreground transition-colors hover:text-muted-foreground" />
                  </button>
                </Badge>
              );
            })}
            {/* Avoid having the "Search" Icon */}
            <CommandPrimitive.Input
              ref={inputRef}
              value={inputValue}
              onValueChange={setInputValue}
              onBlur={() => setOpen(false)}
              onFocus={() => setOpen(true)}
              placeholder={placeholder}
              className="ml-2 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
            />
          </div>
        </div>
        <div className="relative mt-2">
          {open && selectables.length > 0 && (
            <div className="absolute top-0 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
              <CommandGroup className="h-full overflow-auto">
                {selectables.map((item) => {
                  return (
                    <CommandItem
                      key={item.value}
                      onSelect={() => handleSelect(item)}
                      onMouseDown={haltEvent}
                    >
                      {item.label}
                    </CommandItem>
                  );
                })}
                {inputValue && (
                  <CommandItem
                    key="new"
                    onSelect={createNew}
                    onMouseDown={haltEvent}
                  >
                    Create {inputValue}!
                  </CommandItem>
                )}
              </CommandGroup>
            </div>
          )}
        </div>
      </Command>
    );
  };

  return MultiSelect;
};

hajimismhajimism

あーでも↑じゃダメだな、setItemsが更新されるたびにMultiSelectの中のStateが初期化されてしまうので。

hajimismhajimism

Stateをリフトアップしたり、selectedが更新されるたびにfocusを当てなおしたりすると、多分期待通りに動いている。

"use client";

import { Command as CommandPrimitive } from "cmdk";
import { X } from "lucide-react";
import {
  useRef,
  useState,
  useCallback,
  KeyboardEvent,
  MouseEventHandler,
  memo,
  useEffect,
} from "react";

import { Badge } from "@/components/ui/badge";
import { Command, CommandGroup, CommandItem } from "@/components/ui/command";

import { Item } from "./type";

export const useMultiSelect = (
  initialItems: Item[],
  placeholder = "Select items..."
) => {
  const [items, setItems] = useState(initialItems);
  const [selected, setSelected] = useState<Item[]>([]);
  const onCreateNew = (newItem: Item) =>
    setItems((current) => [...current, newItem]);
  const [open, setOpen] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  const MultiSelect = memo(() => {
    const [inputValue, setInputValue] = useState("");

    const createNew = () => {
      const newItem = { value: inputValue, label: inputValue };
      onCreateNew(newItem);
      handleSelect(newItem);
    };

    const handleSelect = useCallback((item: Item) => {
      setInputValue("");
      setSelected((prev) => [...prev, item]);
    }, []);

    const handleUnselect = useCallback((item: Item) => {
      setSelected((prev) => prev.filter((s) => s.value !== item.value));
    }, []);

    const handleKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
      const input = inputRef.current;
      if (input) {
        if (e.key === "Delete" || e.key === "Backspace") {
          if (input.value === "") {
            setSelected((prev) => {
              const newSelected = [...prev];
              newSelected.pop();
              return newSelected;
            });
          }
        }
        // This is not a default behaviour of the <input /> field
        if (e.key === "Escape") {
          input.blur();
        }
      }
    }, []);

    useEffect(() => {
      const input = inputRef.current;
      input?.focus();
    }, [selected]);

    const haltEvent: MouseEventHandler = (e) => {
      e.preventDefault();
      e.stopPropagation();
    };

    const selectables = items.filter((item) => !selected.includes(item));

    return (
      <Command
        onKeyDown={handleKeyDown}
        className="overflow-visible bg-transparent"
      >
        <div className="group rounded-md border border-input px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
          <div className="flex flex-wrap gap-1">
            {selected.map((item) => {
              return (
                <Badge key={item.value} variant="secondary">
                  {item.label}
                  <button
                    className="ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
                    onKeyDown={(e) => {
                      if (e.key === "Enter") {
                        handleUnselect(item);
                      }
                    }}
                    onMouseDown={haltEvent}
                    onClick={() => handleUnselect(item)}
                  >
                    <X className="h-3 w-3 text-secondary-foreground transition-colors hover:text-muted-foreground" />
                  </button>
                </Badge>
              );
            })}
            {/* Avoid having the "Search" Icon */}
            <CommandPrimitive.Input
              ref={inputRef}
              value={inputValue}
              onValueChange={setInputValue}
              onBlur={() => setOpen(false)}
              onFocus={() => setOpen(true)}
              placeholder={placeholder}
              className="ml-2 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
            />
          </div>
        </div>
        <div className="relative mt-2">
          {open && selectables.length > 0 && (
            <div className="absolute top-0 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
              <CommandGroup className="h-full overflow-auto">
                {selectables.map((item) => {
                  return (
                    <CommandItem
                      key={item.value}
                      onSelect={() => handleSelect(item)}
                      onMouseDown={haltEvent}
                    >
                      {item.label}
                    </CommandItem>
                  );
                })}
                {inputValue && (
                  <CommandItem
                    key="new"
                    onSelect={createNew}
                    onMouseDown={haltEvent}
                  >
                    Create {inputValue}!
                  </CommandItem>
                )}
              </CommandGroup>
            </div>
          )}
        </div>
      </Command>
    );
  });
  MultiSelect.displayName = "MultiSelect";

  return MultiSelect;
};
hajimismhajimism

あ、inputにfocusが当たらないのにopenしているからescapeできないみたいに感じるんだ
inputにfocusが当たっているときはふつうにescapeできる

hajimismhajimism

ので

    useEffect(() => {
      if (open) {
        const input = inputRef.current;
        input?.focus();
      }
    }, []);

を追加したらたぶんいけた

hajimismhajimism

あー、openがfalseになったらinputが消えてしまう問題もある。めんどくせーーーー

hajimismhajimism

ここらへん全部Componentの外にもってってOKだった。↑も解決した

  const [items, setItems] = useState(initialItems);
  const [open, setOpen] = useState(false);
  const [selected, setSelected] = useState<Item[]>([]);
  const [inputValue, setInputValue] = useState("");
  const inputRef = useRef<HTMLInputElement>(null);
hajimismhajimism

Itemの重複を避けたかったので、

    const alreadyExist = items.map(({ label }) => lebel).includes(inputValue);

を作って!alreadyExistならCreateNewが表示されないようにした。

hajimismhajimism

ほんでこうした

                {alreadyExist && (
                  <CommandItem className="aria-selected:bg-background aria-selected:text-red-10">
                    {inputValue} is already selected!
                  </CommandItem>
                )}

hajimismhajimism

こいつも必要でした

  const alreadySelected = selected
    .map(({ label }) => label)
    .includes(inputValue);
                {alreadyExist && alreadySelected && (
                  <CommandItem className="aria-selected:bg-background aria-selected:text-red-10">
                    {inputValue} is already selected!
                  </CommandItem>
                )}
hajimismhajimism

選択肢が増えたときに選択肢を提示するところも無限に大きくなるので、max-hを与えて制御した
max-h-[310px] overflow-y-auto
310pxで9.5個見える

hajimismhajimism

どう考えても選択したItemを外でどうにかしたいと思うので、hookからitemも返すようにした

  return [MultiSelect, items];
hajimismhajimism

create newを用意したら 選択肢Viewの表示にselectables.length > 0 &&の制約は必要なくなったので消した。

hajimismhajimism

同じロジックを使っているはずなのにJotaiの方では{inputValue} is already selected!が表示されない

hajimismhajimism

どうやらここが期待通りではないらしい?ちょうどitemsにJotai使ってるしな

  const alreadyExist = items.map(({ label }) => label).includes(inputValue);
hajimismhajimism

いやー、よくわからん

                {alreadyExist && alreadySelected && (
                  <CommandItem className="aria-selected:bg-background aria-selected:text-red-10">
                    {inputValue} is already selected!
                  </CommandItem>
                )}

このスクラップは2023/06/17にクローズされました