SelectEditor is a one-line editor whose value is a list of chips. Each picked label is an inline chip, so the caret moves between chips, Backspace removes one and chips copy and paste like text. The menu filters items by the typed search.
Installing the item adds two files: select-editor.tsx, with the field and its tag rules, and tag.tsx, with the tag element and its chip.
import * as React from 'react';
import { type SelectItem, SelectEditor } from '@/components/editor/select-editor';
const ITEMS: SelectItem[] = [
{ value: 'React' },
{ value: 'TypeScript' },
{ value: 'JavaScript' },
];
export function LabelsField() {
const [value, setValue] = React.useState<SelectItem[]>([ITEMS[0]]);
return (
<SelectEditor
aria-label="Labels"
items={ITEMS}
placeholder="Select labels..."
value={value}
onValueChange={setValue}
/>
);
}import * as React from 'react';
import { type SelectItem, SelectEditor } from '@/components/editor/select-editor';
const ITEMS: SelectItem[] = [
{ value: 'React' },
{ value: 'TypeScript' },
{ value: 'JavaScript' },
];
export function LabelsField() {
const [value, setValue] = React.useState<SelectItem[]>([ITEMS[0]]);
return (
<SelectEditor
onValueChange must update value synchronously. The field writes value back whenever it differs from the chips, so a value set later, such as in a transition, first reverts the change. Pass defaultValue instead of value for an uncontrolled field.
The field takes a form library's ref, onBlur and ARIA props, and passes them to the editable.
'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,
} from '@/components/editor/select-editor';
const LABELS = [
{ url: '/docs/components/editor', value: 'Editor' },
{ url: '/docs/components/select-editor', value: 'Select Editor' },
{ url: '/docs/api/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' },
] satisfies Array<SelectItem & { url: string }>;
const formSchema = z.object({
labels: z
.array(
z.object({
url: z.string().optional(),
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' });
const errorId = React.useId();
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
ref={field.ref}
aria-describedby={fieldState.error ? errorId : undefined}
aria-invalid={fieldState.invalid}
aria-label="Labels"
items={LABELS}
placeholder={readOnly ? 'Empty' : 'Select labels...'}
readOnly={readOnly}
value={field.value}
onBlur={field.onBlur}
onValueChange={readOnly ? undefined : field.onChange}
/>
)}
</div>
{fieldState.error?.message ? (
<p
id={errorId}
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,
} from '@/components/editor/select-editor';
const LABELS = [
{ url: '/docs/components/editor', value:
ArrowUp and ArrowDown move the active option, and Enter picks it.Escape, mod+Enter or a click outside closes the menu and clears the search.getLinkHref accepts its url. A chip whose url it refuses, such as a javascript: address or a bare https://, shows without a link.Edit SelectTagPlugin in the copied select-editor.tsx to change these rules, and the field's key handler to change the keys.
Takes the editable's props, such as placeholder, readOnly, onBlur and aria-*, except the combobox attributes the field sets itself.
The label.
The link a read-only chip renders when getLinkHref accepts it.