Unlike traditional input-based multi-selects, this component is built on top of Plate editor, providing:
import { MultiSelectPlugin } from '@platejs/tag/react';
import { createPlateEditor } from 'platejs/react';
const editor = createPlateEditor({
plugins: [
// ...otherPlugins,
MultiSelectPlugin, // Multi-select editor with tag functionality
],
});import { MultiSelectPlugin } from '@platejs/tag/react';
import { createPlateEditor } from 'platejs/react';
const editor = createPlateEditor({
plugins: [
// ...otherPlugins,
MultiSelectPlugin, // Multi-select editor with tag functionality
],
});import { MultiSelectPlugin } from '@platejs/tag/react';
import { createPlateEditor } from 'platejs/react';
import { TagElement } from '@/components/editor/tag';
const editor = createPlateEditor({
plugins: [
// ...otherPlugins,
MultiSelectPlugin.configure({ component: TagElement }),
],
});import { MultiSelectPlugin } from '@platejs/tag/react';
import
MultiSelectPlugin: Extends TagPlugin and constrains the editor to only contain tag elementscomponent: Assigns TagElement to render tag componentsimport { MultiSelectPlugin } from '@platejs/tag/react';
import { TagElement } from '@/components/editor/tag';
import {
SelectEditor,
SelectEditorContent,
SelectEditorInput,
SelectEditorCombobox,
type SelectItem,
} from '@/components/editor/select-editor';
// Define your items
const ITEMS: SelectItem[] = [
{ value: 'React' },
{ value: 'TypeScript' },
{ value: 'JavaScript' },
];
export default function MySelectEditor() {
const [
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { CheckIcon, PlusIcon } from 'lucide-react';
import * as React from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
type SelectItem,
SelectEditor,
SelectEditorCombobox,
SelectEditorContent,
SelectEditorInput,
} from '@/components/editor/select-editor';
Inline void element plugin for individual tag functionality.
Extension of TagPlugin that constrains the editor to only contain tag elements, enabling multi-select behavior with automatic text cleanup and duplicate prevention.
Inserts new multi-select element at current selection.
Gets all tag items in the editor.
Utility function to compare two sets of tags for equality, ignoring order.
The copied select-editor component owns search filtering, new-item creation,
combobox cleanup, and value notifications. Edit that file when your product
needs different tag-selection behavior.
'use client';
import { MultiSelectPlugin } from '@platejs/tag/react';
import { Command as CommandPrimitive, useCommandActions } from '@udecode/cmdk';
import { Fzf } from 'fzf';
import { PlusIcon } from 'lucide-react';
import { isHotkey, TextApi } from 'platejs';
import {
Plate,
useEditor,
useEditorSelector,
usePlateEditor,
usePlateValue,
} from 'platejs/react';
import * as React from 'react';
import { cn } from '@/lib/utils';
import {
FloatingPopover,
FloatingPopoverAnchor,
FloatingPopoverContent,
} from '@/components/editor/floating-popover';
import { Editor, EditorContainer } from './editor';
import { TagElement } from './tag';
export type SelectItem = {
value: string;
isNew?: boolean;
};
const EMPTY_SELECT_ITEMS: SelectItem[] = [];
const areSelectItemsEqual = (
previous: SelectItem[] | null | undefined,
next: SelectItem[] | undefined
) => {
const previousItems = previous ?? EMPTY_SELECT_ITEMS;
const nextItems = next ?? EMPTY_SELECT_ITEMS;
return (
previousItems.length === nextItems.length &&
previousItems.every((item, index) => item.value === nextItems[index]?.value)
);
};
type SelectEditorContextValue = {
controlled: boolean;
hasSelectableItemsRef: React.RefObject<boolean>;
items: SelectItem[];
open: boolean;
setOpen: (open: boolean) => void;
defaultValue?: SelectItem[];
value?: SelectItem[];
setValue: (items: SelectItem[]) => void;
onValueChange?: (items: SelectItem[]) => void;
};
const SelectEditorContext = React.createContext<
SelectEditorContextValue | undefined
>(undefined);
const useSelectEditorContext = () => {
const context = React.useContext(SelectEditorContext);
if (!context) {
throw new Error('useSelectEditor must be used within SelectEditor');
}
return context;
};
export function SelectEditor({
children,
defaultValue,
items = EMPTY_SELECT_ITEMS,
value,
onValueChange,
}: {
children: React.ReactNode;
defaultValue?: SelectItem[];
items?: SelectItem[];
value?: SelectItem[];
onValueChange?: (items: SelectItem[]) => void;
}) {
const [open, setOpen] = React.useState(false);
const [internalValue, setInternalValue] = React.useState(defaultValue);
const hasSelectableItemsRef = React.useRef(false);
const contextValue = React.useMemo(
() => ({
controlled: value !== undefined,
hasSelectableItemsRef,
items,
open,
setOpen,
setValue: setInternalValue,
value: value ?? internalValue,
onValueChange,
}),
[internalValue, items, onValueChange, open, value]
);
return (
<SelectEditorContext value={contextValue}>
<Command
className="overflow-visible bg-transparent has-data-readonly:w-fit"
shouldFilter={false}
loop
>
{children}
</Command>
</SelectEditorContext>
);
}
export function SelectEditorContent({
children,
}: {
children: React.ReactNode;
}) {
const { controlled, value } = useSelectEditorContext();
const { setSearch } = useCommandActions();
const editor = usePlateEditor(
{
plugins: [MultiSelectPlugin.configure({ component: TagElement })],
initialValue: createEditorValue(value),
},
[]
);
return (
<Plate
onValueChange={({ editor: innerEditor }) => {
setSearch(innerEditor.read.text.string([]));
}}
editor={editor}
>
<SelectEditorValueSync controlled={controlled} value={value} />
<EditorContainer variant="select">{children}</EditorContainer>
</Plate>
);
}
function SelectEditorValueSync({
controlled,
value,
}: {
controlled: boolean;
value?: SelectItem[];
}) {
const editor = useEditor();
const selectedItems = useEditorSelector(
(innerEditor2) =>
innerEditor2.plugin(MultiSelectPlugin).read.getSelectedItems(),
{
equalityFn: (previous, next) =>
!!previous &&
previous.length === next.length &&
previous.every((item, index) => item.value === next[index]?.value),
}
);
const valueRef = React.useRef(value);
React.useEffect(() => {
valueRef.current = value;
}, [value]);
React.useEffect(() => {
if (!controlled || editor.plugin(MultiSelectPlugin).read.isEqual(value)) {
return undefined;
}
const timeout = globalThis.setTimeout(() => {
const currentValue = valueRef.current;
if (!editor.plugin(MultiSelectPlugin).read.isEqual(currentValue)) {
editor.update({ history: 'skip' }).value.replace({
children: createEditorValue(currentValue),
});
}
}, 0);
return () => {
globalThis.clearTimeout(timeout);
};
}, [controlled, editor, selectedItems, value]);
return null;
}
export const SelectEditorInput = ({
ref,
onBlur,
onFocusCapture,
onKeyDown,
...editorProps
}: React.ComponentPropsWithoutRef<typeof Editor> & {
ref?: React.RefObject<HTMLDivElement | null>;
}) => {
const editor = useEditor();
const { hasSelectableItemsRef, setOpen } = useSelectEditorContext();
const { selectCurrentItem, selectFirstItem } = useCommandActions();
return (
<Editor
ref={ref}
variant="select"
autoFocusOnEditable
{...editorProps}
onBlur={(event) => {
setOpen(false);
onBlur?.(event);
}}
onFocusCapture={(event) => {
setOpen(true);
selectFirstItem();
onFocusCapture?.(event);
}}
onKeyDown={(e) => {
if (isHotkey('mod+z', e)) {
e.preventDefault();
return true;
}
if (isHotkey('enter', e)) {
e.preventDefault();
if (hasSelectableItemsRef.current) {
selectCurrentItem();
editor.update({ history: 'skip' }).nodes.remove({
at: [],
match: (node) => TextApi.isText(node) && node.text.length > 0,
});
}
return true;
}
if (isHotkey('escape', e) || isHotkey('mod+enter', e)) {
e.preventDefault();
e.currentTarget.blur();
return true;
}
return onKeyDown?.(e);
}}
/>
);
};
export function SelectEditorCombobox() {
const editor = useEditor();
const containerRef = usePlateValue('containerRef');
const {
controlled,
hasSelectableItemsRef,
items,
open,
onValueChange,
setValue,
value,
} = useSelectEditorContext();
const { selectFirstItem } = useCommandActions();
const onValueChangeRef = React.useRef(onValueChange);
const previousValueRef = React.useRef(value);
const selectedItems =
useEditorSelector(
(innerEditor3) =>
innerEditor3.plugin(MultiSelectPlugin).read.getSelectedItems(),
{
equalityFn: areSelectItemsEqual,
}
) ?? EMPTY_SELECT_ITEMS;
const search = useEditorSelector((innerEditor4) =>
innerEditor4.read.text.string([])
);
const selectableItems = React.useMemo(() => {
const seenValues = new Set<string>();
const uniqueItems = items.filter((item) => {
const innerValue = item.value.toLowerCase();
if (seenValues.has(innerValue)) return false;
seenValues.add(innerValue);
return true;
});
const trimmedSearch = search.trim().replaceAll(/\s+/g, ' ');
const newItems: SelectItem[] =
trimmedSearch.length >= 2 &&
!uniqueItems.some(
(item) => item.value.toLowerCase() === trimmedSearch.toLowerCase()
)
? [{ isNew: true, value: trimmedSearch }]
: [];
const availableItems = [...uniqueItems, ...newItems].filter(
(item) =>
!selectedItems.some(
(selected) =>
selected.value.toLowerCase() === item.value.toLowerCase()
)
);
return trimmedSearch
? availableItems.filter((item) => fzfFilter(item.value, trimmedSearch))
: availableItems;
}, [items, search, selectedItems]);
React.useLayoutEffect(() => {
hasSelectableItemsRef.current = open && selectableItems.length > 0;
return () => {
hasSelectableItemsRef.current = false;
};
}, [hasSelectableItemsRef, open, selectableItems.length]);
React.useEffect(() => {
if (!open) {
editor.update({ history: 'skip' }, (tx) => {
tx.nodes.remove({
at: [],
match: (node) => TextApi.isText(node) && node.text.length > 0,
});
const end = tx.points.end([]);
if (end) tx.selection.set(end);
});
}
}, [editor, open]);
React.useEffect(() => {
selectFirstItem();
}, [search, selectFirstItem]);
React.useEffect(() => {
onValueChangeRef.current = onValueChange;
}, [onValueChange]);
React.useEffect(() => {
const valueChanged = !areSelectItemsEqual(previousValueRef.current, value);
previousValueRef.current = value;
if (valueChanged || areSelectItemsEqual(selectedItems, value)) return;
if (!controlled) setValue([...selectedItems]);
onValueChangeRef.current?.(selectedItems);
}, [controlled, selectedItems, setValue, value]);
const virtualAnchor = React.useMemo(
() => ({
getBoundingClientRect: () =>
containerRef.current?.getBoundingClientRect() ?? new DOMRect(),
}),
[containerRef]
);
if (!open || selectableItems.length === 0) return null;
return (
<FloatingPopover open={open}>
<FloatingPopoverAnchor element={virtualAnchor} />
<FloatingPopoverContent
className="p-0 data-[state=open]:animate-none"
style={{
width: 'calc(var(--floating-popover-anchor-width) + 8px)',
}}
onFinalFocus={(e) => {
e.preventDefault();
}}
onInitialFocus={(e) => {
e.preventDefault();
}}
align="start"
alignOffset={-4}
sideOffset={8}
>
<CommandList>
<CommandGroup>
{selectableItems.map((item) => (
<CommandItem
key={item.value}
className="cursor-pointer gap-2"
onMouseDown={(e) => {
e.preventDefault();
}}
onSelect={() => {
editor
.plugin(MultiSelectPlugin)
.update({ history: 'skip' })
.insert(item);
editor.update({ history: 'skip' }, (tx) => {
tx.nodes.remove({
at: [],
match: (node) =>
TextApi.isText(node) && node.text.length > 0,
});
const end = tx.points.end([]);
if (end) tx.selection.set(end);
});
}}
>
{item.isNew ? (
<div className="flex items-center gap-1">
<PlusIcon className="size-4 text-foreground" />
Create new label:
<span className="text-gray-600">"{item.value}"</span>
</div>
) : (
item.value
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</FloatingPopoverContent>
</FloatingPopover>
);
}
const createEditorValue = (value?: SelectItem[]) => [
{
children: [
{ text: '' },
...(value?.flatMap((item) => [
{
children: [{ text: '' }],
type: 'tag',
value: item.value,
},
{
text: '',
},
]) ?? []),
],
type: 'paragraph',
},
];
const fzfFilter = (value: string, search: string): boolean => {
if (!search) return true;
const fzf = new Fzf([value], {
casing: 'case-insensitive',
selector: (v: string) => v,
});
return fzf.find(search).length > 0;
};
/**
* You could replace this with import from '@/components/ui/command' + replace
* 'cmdk' import with '@udecode/cmdk'
*/
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
data-slot="command"
{...props}
/>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
className={cn(
'max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden',
className
)}
data-slot="command-list"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:text-xs',
className
)}
data-slot="command-group"
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
data-slot="command-item"
{...props}
/>
);
}'use client';
import { MultiSelectPlugin } from '@platejs/tag/react';
import { Command as CommandPrimitive, useCommandActions } from '@udecode/cmdk';
import { Fzf } from 'fzf';
import { PlusIcon } from 'lucide-react';
import { isHotkey, TextApi } from 'platejs';
import {
Plate,
useEditor,
useEditorSelector,
usePlateEditor,
usePlateValue,
} from 'platejs/react';
import * as React from 'react';
import { cn } from '@/lib/utils'
import type { TagElement } from '@platejs/tag';import type { TagElement } from '@platejs/tag';TagElement is derived from BaseTagPlugin and requires a string value.
import type { TagItem } from '@platejs/tag';import type { TagItem } from '@platejs/tag';import { MultiSelectPlugin } from '@platejs/tag/react';
import { TagElement } from '@/components/editor/tag';
import {
SelectEditor,
SelectEditorContent,
SelectEditorInput,
SelectEditorCombobox,
type SelectItem,
} from '@/components/editor/select-editor';
// Define your items
const ITEMS: SelectItem[] = [
{ value: 'React' },
{ value: 'TypeScript' },
{ value: 'JavaScript' },
];
export default function MySelectEditor() {
const [value, setValue] = React.useState<SelectItem[]>([ITEMS[0]]);
return (
<SelectEditor
value={value}
onValueChange={setValue}
items={ITEMS}
>
<SelectEditorContent>
<SelectEditorInput placeholder="Select items..." />
<SelectEditorCombobox />
</SelectEditorContent>
</SelectEditor>
);
}'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { CheckIcon, PlusIcon } from 'lucide-react';
import * as React from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
type SelectItem,
SelectEditor,
SelectEditorCombobox,
SelectEditorContent,
SelectEditorInput,
} from '@/components/editor/select-editor';
const LABELS = [
{ url: '/docs/components/editor', value: 'Editor' },
{ url: '/docs/components/select-editor', value: 'Select Editor' },
{ url: '/docs/plite/api/locations/selection', value: 'Node Selection' },
{ url: '/docs/components/button', value: 'Button' },
{ url: '/docs/components/command', value: 'Command' },
{ url: '/docs/components/dialog', value: 'Dialog' },
{ url: '/docs/components/field', value: 'Field' },
{ url: '/docs/components/input', value: 'Input' },
{ url: '/docs/components/label', value: 'Label' },
{ url: '/docs/components/popover', value: 'Popover' },
{ url: '/docs/components/tag', value: 'Tag Element' },
] satisfies Array<SelectItem & { url: string }>;
const formSchema = z.object({
labels: z
.array(
z.object({
value: z.string(),
})
)
.min(1, 'Select at least one label')
.max(10, 'Select up to 10 labels'),
});
type FormValues = z.infer<typeof formSchema>;
export default function EditorSelectForm() {
const [readOnly, setReadOnly] = React.useState(false);
const form = useForm<FormValues>({
defaultValues: {
labels: [LABELS[0]],
},
resolver: zodResolver(formSchema),
});
const labels = useWatch({ control: form.control, name: 'labels' });
return (
<div className="mx-auto w-full max-w-2xl space-y-8 p-11 pt-24 pl-2">
<div className="space-y-6">
<Controller
name="labels"
control={form.control}
render={({ field, fieldState }) => (
<div data-invalid={fieldState.invalid}>
<div className="flex items-start gap-2">
<Button
variant="ghost"
className="h-10"
onClick={() => {
setReadOnly(!readOnly);
}}
type="button"
>
{readOnly ? (
<PlusIcon className="size-4" />
) : (
<CheckIcon className="size-4" />
)}
</Button>
{readOnly && labels.length === 0 ? (
<Button
size="lg"
variant="ghost"
className="h-10"
onClick={() => {
setReadOnly(false);
}}
type="button"
>
Add labels
</Button>
) : (
<SelectEditor
value={field.value}
onValueChange={readOnly ? undefined : field.onChange}
items={LABELS}
>
<SelectEditorContent>
<SelectEditorInput
readOnly={readOnly}
placeholder={readOnly ? 'Empty' : 'Select labels...'}
/>
{!readOnly && <SelectEditorCombobox />}
</SelectEditorContent>
</SelectEditor>
)}
</div>
{fieldState.error?.message ? (
<p className="text-sm text-destructive" role="alert">
{fieldState.error.message}
</p>
) : null}
</div>
)}
/>
</div>
</div>
);
}