shadcn/uiでmulti selectなcomboboxが欲しい
- 複数選択可
- 既存の選択肢を検索可
- 既存の選択肢に無かったらその場で作成可
なセレクトボックスがshadcn/uiで(というかRadix UIで)欲しい。
NotionのMulti Select的な
どうせ世界中のみんなも同じ想いだろうと思ったらやっぱりそうだったし、なんならshadcnも同じ想いだった
Radix UIでもめっちゃ議論されてるけど中の人が全然顔だしてない
Downshiftというライブラリを使えっていうアドバイスがある
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を使っていて、カニバリ具合がちょっと気になる
そもそもshadcn/uiのComboboxも検索ができるだけで新規作成はできないからなー
このFancy Multi Selectはかなりイメージに近くて、これに「新規追加」的なUIがあったら完璧
Fancy Multi Selectを読む
ロジックのところはざっくりこんな感じ
- inputRefとinputValueってなんで別々に管理しているんやろ
- openいつ使うんやろ
- handleUnselectは明確に選択解除だろうし、selectableは選択肢だろうし、handleKeyDownには細かい操作が詰まってんだろうな
Viewの部分は大きく
入力部と
選択肢部
に別れている。shadcn/uiのComboboxだと選択肢部の表示にPopoverを使っていたけれど、こっちは自前で管理している。
ロジックの方から読むか。
ゆーても気になるところは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
あとはcmdkの中に隠蔽されている
View
- 入力部は、selectされたBadgeとInputが並んでいるだけ
- 選択肢部はCommandItemが並んでいるだけ
という感じ。シンプルだな。
手を動かしていこう。
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>
);
};
ここからやりたいこととしては「selectablesが無いときに、inputを新しく選択肢として加えるCommandを追加」くらいか?
いろいろ試して、単純にこれを挿入しておくだけで「選択肢がないときにinputを新しく選択肢として加えるCommandを表示」ができるとわかった。たぶんCommandはItemのchildrenを見ているため、childrenにinputValueが入ってれば常に選択肢に出る。
<CommandItem onMouseDown={haltEvent}>
Create {inputValue}!
</CommandItem>
逆に空文字のときに出てこられても困るということで
{inputValue && (
<CommandItem onMouseDown={haltEvent}>
Create {inputValue}!
</CommandItem>
)}
handleSelectで抽象化しているおかげで、これだけで良かったわ
const createNew = () => {
const newItem = { value: inputValue, label: inputValue };
handleSelect(newItem);
};
{inputValue && (
<CommandItem
key="new"
onSelect={createNew}
onMouseDown={haltEvent}
>
Create {inputValue}!
</CommandItem>
)}
newItemのvalueについては諸説ありそう、単純な文字列として持てばよくね、みたいな
あとはあれだな、親から渡しているitemsにnewItemsを加えたい
- フロントだけで状態を管理する
- いちいちサーバーを叩きに行って云々
みたいなことが考えられる。いずれにせよフロントで状態管理したい気がする。
とりあえず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);
};
いっそこういう抽象化もありかなと思う。利用者(開発者)の関心としてはこういうことだと思う。
"use client";
import { FRAMEWORKS } from "./data";
import { useMultiSelect } from "./hook";
export default function Page() {
const MultiFlameworksSelect = useMultiSelect(FRAMEWORKS);
return (
<div>
<MultiFlameworksSelect />
</div>
);
}
だからまあ、全部のせでこんな感じよ。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;
};
あーでも↑じゃダメだな、setItemsが更新されるたびにMultiSelectの中のStateが初期化されてしまうので。
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;
};
そんなことなかった、Escapeできなくなってる
あとinputをクリックしても一発でfocusが当たらない
あ、inputにfocusが当たらないのにopenしているからescapeできないみたいに感じるんだ
inputにfocusが当たっているときはふつうにescapeできる
ので
useEffect(() => {
if (open) {
const input = inputRef.current;
input?.focus();
}
}, []);
を追加したらたぶんいけた
あー、openがfalseになったらinputが消えてしまう問題もある。めんどくせーーーー
ここらへん全部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);
Itemの重複を避けたかったので、
const alreadyExist = items.map(({ label }) => lebel).includes(inputValue);
を作って!alreadyExist
ならCreateNewが表示されないようにした。
ほんでこうした
{alreadyExist && (
<CommandItem className="aria-selected:bg-background aria-selected:text-red-10">
{inputValue} is already selected!
</CommandItem>
)}
こいつも必要でした
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>
)}
選択肢が増えたときに選択肢を提示するところも無限に大きくなるので、max-hを与えて制御した
max-h-[310px] overflow-y-auto
310pxで9.5個見える
どう考えても選択したItemを外でどうにかしたいと思うので、hookからitemも返すようにした
return [MultiSelect, items];
今気づいた、これ返すべきなのselectedやん
create newを用意したら 選択肢Viewの表示にselectables.length > 0 &&
の制約は必要なくなったので消した。
同じロジックを使っているはずなのにJotaiの方では{inputValue} is already selected!
が表示されない
どうやらここが期待通りではないらしい?ちょうどitemsにJotai使ってるしな
const alreadyExist = items.map(({ label }) => label).includes(inputValue);
いやー、よくわからん
{alreadyExist && alreadySelected && (
<CommandItem className="aria-selected:bg-background aria-selected:text-red-10">
{inputValue} is already selected!
</CommandItem>
)}
openはしてる
わからん、一旦Closeして離れよ