From zbeyens. The source code is available on GitHub.

Plate
PlatePliteEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Discussion
    • Comments
    • Suggestion
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • List Classic
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Toggle
  • Marks
    • Bold
    • Italic
    • Underline
    • Code
    • Highlight
    • Keyboard Input
    • Strikethrough
    • Subscript
    • Superscript
      • Font
      • Line Height
      • Text Align
    • Indent
    • List
      • Exit Break
      • Single Block
      • Trailing Block
    • Autoformat
    • Block Menu
    • Block Placeholder
    • Combobox
      • Emoji
      • MentionElement
      • Slash Command
    • Cursor Overlay
    • Drag & Drop
    • Navigation Feedback
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • CSV
    • DOCX
    • HTML
    • Markdown

Multi Select

PreviousNext

A rich multi-select editor.

TagSelect Editor
Loading…
YjsCSV

On This Page

FeaturesManual UsageInstallationAdd PluginsConfigure PluginsAdd SelectEditorBasic ExampleForm ExamplePluginsTagPluginMultiSelectPluginAPIeditor.plugin(MultiSelectPlugin).update.inserteditor.plugin(MultiSelectPlugin).read.getSelectedItems()editor.plugin(MultiSelectPlugin).read.isEqual(tags)Registry UITypesTagElementTagItem
Build your editor
Production-ready AI template and reusable components.
Get all-access

Features

Unlike traditional input-based multi-selects, this component is built on top of Plate editor, providing:

  • Full history support (undo/redo)
  • Native cursor navigation between and within tags
  • Select one to many tags
  • Copy/paste tags
  • Drag and drop to reorder tags
  • Read-only mode
  • Duplicate tags prevention
  • Create new tags with case insensitive matching
  • Search text cleanup and whitespace trimming
  • Fuzzy search powered by cmdk
Report an issue

Manual Usage

Installation

pnpm add @platejs/tag
pnpm add @platejs/tag

Add Plugins

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
  ],
});

Configure Plugins

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 elements
  • component: Assigns TagElement to render tag components

Add SelectEditor

npx shadcn@latest add @plate/select-editor

Basic Example

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 [













Form Example

'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';
 





































































































Plugins

TagPlugin

Inline void element plugin for individual tag functionality.

MultiSelectPlugin

Extension of TagPlugin that constrains the editor to only contain tag elements, enabling multi-select behavior with automatic text cleanup and duplicate prevention.

API

editor.plugin(MultiSelectPlugin).update.insert

Inserts new multi-select element at current selection.

Parameters

    Properties for multi-select element.

OptionsTagItem

    Unique value of multi-select element.

editor.plugin(MultiSelectPlugin).read.getSelectedItems()

Gets all tag items in the editor.

ReturnsTagItem[]

    Array of tag items in editor.

editor.plugin(MultiSelectPlugin).read.isEqual(tags)

Utility function to compare two sets of tags for equality, ignoring order.

Parameters

    New tags to compare against current editor tags.

Returnsboolean

    Whether both sets contain same values.

Registry UI

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'
















































































































































































































































































































































































































































































































Types

TagElement

import type { TagElement } from '@platejs/tag';
import type { TagElement } from '@platejs/tag';

TagElement is derived from BaseTagPlugin and requires a string value.

TagItem

import type { TagItem } from '@platejs/tag';
import type { TagItem } from '@platejs/tag';
{ createPlateEditor }
from
'platejs/react'
;
import { TagElement } from '@/components/editor/tag';
const editor = createPlateEditor({
plugins: [
// ...otherPlugins,
MultiSelectPlugin.configure({ component: TagElement }),
],
});
value
,
setValue
]
=
React.
useState
<
SelectItem
[]>([
ITEMS
[
0
]]);
return (
<SelectEditor
value={value}
onValueChange={setValue}
items={ITEMS}
>
<SelectEditorContent>
<SelectEditorInput placeholder="Select items..." />
<SelectEditorCombobox />
</SelectEditorContent>
</SelectEditor>
);
}
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>
  );
}
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>
);
}
'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>
  );
}
;
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}
/>
);
}