First, create an input plugin that will be inserted when the trigger is activated:
import { property } from 'platejs';
import { BaseComboboxPlugin } from 'platejs/combobox';
import { definePlugin } from 'platejs/react';
export const TagInputPlugin = definePlugin('tagInput', {
dependencies: [BaseComboboxPlugin],
editOnly: true,
schema: {
element: {
type: 'tag_input',
void: 'inline',
properties: {
trigger: property.string(),
userId: property.string(),
},
},
},
});Create your main plugin with trigger metadata:
import { property } from 'platejs';
import { triggerCombobox, type TriggerComboboxPluginState } from 'platejs/combobox';
import { definePlugin } from 'platejs/react';
import { TagInputPlugin } from './tag-input-plugin';
const initialState: TriggerComboboxPluginState = {
trigger: '#',
triggerPreviousCharPattern: /^\s?$/,
createComboboxInput: () => ({
children: [{ text: '' }],
type: 'tag_input',
}),
};
export const
schema.element: Defines the plugin's element model behaviorschema.element.void: 'inline': Makes the tag an inline void and prevents editing inside itinitialState.trigger: Character that triggers the combobox (in this case #)initialState.triggerPreviousCharPattern: RegExp pattern that must match the character before the trigger. /^\s?$/ allows the trigger at the start of a line or after whitespaceinitialState.createComboboxInput: Function that creates the input element node when the trigger is activatedCreate the input element component using InlineCombobox:
import {
type EditorElementProps,
EditorElement,
useEditorFocused,
useEditorReadOnly,
useElementSelected,
} from 'platejs/react';
import {
InlineCombobox,
InlineComboboxContent,
InlineComboboxEmpty,
InlineComboboxInput,
InlineComboboxItem,
} from '@/components/editor/inline-combobox';
import { cn } from '@/lib/utils';
import { TagInputPlugin } from './tag-input-plugin';
import { TagPlugin } from './tag-plugin';
const tags = ['Frontend', 'Backend',
import { BaseCodeBlockPlugin } from 'platejs';
import { createEditor } from 'platejs/react';
import { TagPlugin } from './tag-plugin';
import { TagInputPlugin } from './tag-input-plugin';
import { TagElement, TagInputElement } from './tag-components';
const editor = createEditor({
plugins: [
TagPlugin.configure({
component: TagElement,
initialState: {
triggerQuery: (editor) => {
const codeBlock = editor.plugin(BaseCodeBlockPlugin);
return !codeBlock.installed
initialState.triggerQuery: Optional function to conditionally enable/disable the trigger based on editor stateConfiguration options for trigger-based combobox plugins.
Function to create the input node when trigger is activated.
Character(s) that trigger the combobox. Can be:
Pattern to match the character before trigger.
/^\s?$/ matches start of line or spaceCustom query function to control when trigger is active.
Mention, slash, emoji and footnote inputs install BaseComboboxPlugin as a
dependency. Custom input plugins declare the same dependency. Resolve the
mounted input's NodeKey with editor.key(element) and keep that key for its
event callbacks.
| Method | Behavior |
|---|---|
read.canEdit(input) | Check that the input still exists, is editable and belongs to the current user. |
api.cancel(input, { text, select }) | Remove the input and restore literal text in one history entry. Omit text for Backspace; select accepts 'start' or 'end', or stays omitted to preserve an outside selection. |
api.commit(input, callback) | Remove the input, select its live location and run a synchronous transaction callback. A thrown callback rolls back the change. |
api.undo(input) / api.redo(input) | Apply available history only for an eligible input. |
Completion commands return false for stale or foreign input keys. They never
restore text at an unrelated current selection. The node key also identifies
inputs in named document roots.
The copied inline-combobox component owns query state, filtering, popup
navigation, composition events and DOM focus. Insert the chosen feature in
InlineComboboxItem.onSelect, using its inferred transaction. Use onClick
for a UI action after successful completion, such as opening another popover.
'use client';
import {
Combobox,
ComboboxGroup,
ComboboxGroupLabel,
ComboboxItem,
ComboboxPopover,
ComboboxProvider,
Portal,
useComboboxContext,
useComboboxStore,
useStoreState,
} from '@ariakit/react';
import { cva } from 'class-variance-authority';
import {
Hotkeys,
isHotkey,
type Element,
type PluginTransaction,
} from 'platejs';
import { BaseComboboxPlugin, filterWords } from 'platejs/combobox';
import { useComposedRef, useEditor, useElementSelected } from 'platejs/react';
import * as React from 'react';
import { cn } from '@/lib/utils';
type FilterFn = (
item: { value: string; group?: string; keywords?: string[]; label?: string },
search: string
) => boolean;
type InlineComboboxContextValue = {
autoFocus: boolean;
filter: FilterFn | false;
inputProps: Required<
Pick<React.InputHTMLAttributes<HTMLInputElement>, 'onBlur' | 'onKeyDown'>
>;
inputRef: React.RefObject<HTMLInputElement | null>;
commit: (
callback: (tx: PluginTransaction) => void,
focusEditor?: boolean
) => boolean;
showTrigger: boolean;
trigger: string;
setHasEmpty: (hasEmpty: boolean) => void;
};
const InlineComboboxContext =
React.createContext<InlineComboboxContextValue | null>(null);
const useInlineComboboxContext = () => {
const context = React.useContext(InlineComboboxContext);
if (!context) {
throw new Error('Inline combobox components require InlineCombobox');
}
return context;
};
const defaultFilter: FilterFn = (
{ group, keywords = [], label, value },
search
) => {
const uniqueTerms = new Set(
[value, ...keywords, group, label].flatMap((term) =>
typeof term === 'string' ? [term] : []
)
);
return Array.from(uniqueTerms).some((keyword) =>
filterWords(keyword, search)
);
};
const InlineCombobox = ({
children,
element,
filter = defaultFilter,
hideWhenNoValue = false,
setValue: setValueProp,
showTrigger = true,
trigger,
value: valueProp,
}: {
children: React.ReactNode;
element: Element;
trigger: string;
filter?: FilterFn | false;
hideWhenNoValue?: boolean;
showTrigger?: boolean;
value?: string;
setValue?: (value: string) => void;
}) => {
const editor = useEditor();
const combobox = editor.plugin(BaseComboboxPlugin);
const inputKey = React.useMemo(() => editor.key(element), [editor, element]);
const selected = useElementSelected();
const inputRef = React.useRef<HTMLInputElement>(null);
const [valueState, setValueState] = React.useState('');
const hasValueProp = valueProp !== undefined;
const value = hasValueProp ? valueProp : valueState;
const canEdit = combobox.read.canEdit(inputKey);
const commit = React.useCallback(
(callback: (tx: PluginTransaction) => void, focusEditor = false) => {
const completed = combobox.api.commit(inputKey, callback);
if (completed && focusEditor) editor.api.dom.focus();
return completed;
},
[combobox, editor, inputKey]
);
const cancelInput = React.useCallback(
(
cause:
| 'arrowLeft'
| 'arrowRight'
| 'backspace'
| 'blur'
| 'deselect'
| 'escape',
focusEditor = false
) => {
const completed = combobox.api.cancel(inputKey, {
text:
cause === 'backspace'
? ''
: trigger + (inputRef.current?.value ?? value),
select:
cause === 'arrowLeft' ? 'start' : focusEditor ? 'end' : undefined,
});
if (completed && focusEditor) editor.api.dom.focus();
},
[combobox, editor, inputKey, trigger, value]
);
const previousSelected = React.useRef(selected);
React.useEffect(() => {
if (previousSelected.current && !selected) cancelInput('deselect');
previousSelected.current = selected;
}, [cancelInput, selected]);
const inputProps = React.useMemo<InlineComboboxContextValue['inputProps']>(
() => ({
onBlur: () => {
cancelInput('blur');
},
onKeyDown: (event) => {
// oxlint-disable-next-line typescript/no-deprecated -- Safari can clear isComposing before the final IME key event.
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
const {
selectionEnd,
selectionStart,
value: inputValue,
} = event.currentTarget;
const cursorCollapsed = selectionStart === selectionEnd;
const cursorAtStart = cursorCollapsed && selectionStart === 0;
const cursorAtEnd =
cursorCollapsed && selectionEnd === inputValue.length;
const cancelCause = isHotkey('escape')(event)
? 'escape'
: cursorAtStart && isHotkey('backspace')(event)
? 'backspace'
: cursorAtStart && isHotkey('arrowleft')(event)
? 'arrowLeft'
: cursorAtEnd && isHotkey('arrowright')(event)
? 'arrowRight'
: null;
if (cancelCause) {
event.preventDefault();
event.stopPropagation();
cancelInput(cancelCause, true);
return;
}
const handled = Hotkeys.isUndo(event)
? combobox.api.undo(inputKey)
: Hotkeys.isRedo(event)
? combobox.api.redo(inputKey)
: false;
if (handled) {
event.preventDefault();
event.stopPropagation();
editor.api.dom.focus();
}
},
}),
[cancelInput, combobox, editor, inputKey]
);
const [hasEmpty, setHasEmpty] = React.useState(false);
const contextValue = React.useMemo<InlineComboboxContextValue>(
() => ({
autoFocus: canEdit,
commit,
filter,
inputProps,
inputRef,
setHasEmpty,
showTrigger,
trigger,
}),
[canEdit, commit, filter, inputProps, showTrigger, trigger]
);
const store = useComboboxStore({
setValue: (newValue) => {
React.startTransition(() => {
setValueProp?.(newValue);
if (!hasValueProp) setValueState(newValue);
});
},
});
const items = useStoreState(store, 'items');
/**
* If there is no active ID and the list of items changes, select the first
* item.
*/
React.useEffect(() => {
if (!store.getState().activeId) {
store.setActiveId(store.first());
}
}, [items, store]);
return (
<span contentEditable={false}>
<ComboboxProvider
open={
canEdit &&
(items.length > 0 || hasEmpty) &&
(!hideWhenNoValue || value.length > 0)
}
store={store}
>
<InlineComboboxContext value={contextValue}>
{children}
</InlineComboboxContext>
</ComboboxProvider>
</span>
);
};
function InlineComboboxInput({
className,
ref: propRef,
...props
}: React.HTMLAttributes<HTMLInputElement> & {
ref?: React.RefObject<HTMLInputElement | null>;
}) {
const {
autoFocus,
inputProps,
inputRef: contextRef,
showTrigger,
trigger,
} = useInlineComboboxContext();
const store = useComboboxContext();
if (store == null) {
throw new Error('InlineComboboxInput requires a Combobox store');
}
const value = useStoreState(store, 'value');
const ref = useComposedRef(propRef, contextRef);
/**
* To create an auto-resizing input, we render a visually hidden span
* containing the input value and position the input element on top of it.
* This works well for all cases except when input exceeds the width of the
* container.
*/
return (
<>
{showTrigger && trigger}
<span className="relative min-h-[1lh]">
<span
className="invisible overflow-hidden text-nowrap"
aria-hidden="true"
>
{value || '\u200B'}
</span>
<Combobox
ref={ref}
autoFocus={autoFocus}
disabled={!autoFocus}
className={cn(
'absolute top-0 left-0 size-full bg-transparent outline-none',
className
)}
value={value}
autoSelect
{...inputProps}
{...props}
/>
</span>
</>
);
}
const InlineComboboxContent = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => {
// Portal prevents CSS from leaking into popover
const store = useComboboxContext();
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (!store) return;
const state = store.getState();
const { items, activeId } = state;
if (!items.length) return;
const currentIndex = items.findIndex((item) => item.id === activeId);
if (event.key === 'ArrowUp' && currentIndex <= 0) {
event.preventDefault();
store.setActiveId(store.last());
} else if (event.key === 'ArrowDown' && currentIndex >= items.length - 1) {
event.preventDefault();
store.setActiveId(store.first());
}
}
return (
<Portal>
<ComboboxPopover
className={cn(
'cn-command cn-command-list z-500 w-[300px] overflow-x-hidden overflow-y-auto shadow-md',
className
)}
onKeyDownCapture={handleKeyDown}
{...props}
/>
</Portal>
);
};
const comboboxItemVariants = cva(
'cn-command-item mx-1 h-7 cursor-pointer text-foreground transition-colors hover:bg-accent hover:text-accent-foreground data-[active-item=true]:bg-accent data-[active-item=true]:text-accent-foreground'
);
const InlineComboboxItem = ({
className,
focusEditor = true,
group,
keywords,
label,
onClick,
onSelect,
...props
}: Omit<React.HTMLAttributes<HTMLDivElement>, 'onSelect' | 'value'> & {
focusEditor?: boolean;
group?: string;
keywords?: string[];
label?: string;
value: string;
onSelect?: (tx: PluginTransaction) => void;
}) => {
const { value } = props;
const { commit, filter } = useInlineComboboxContext();
const store = useComboboxContext();
if (store == null) {
throw new Error('InlineComboboxItem requires a Combobox store');
}
const search = useStoreState(store, 'value');
const visible = React.useMemo(
() => !filter || filter({ group, keywords, label, value }, search),
[filter, group, keywords, label, value, search]
);
if (!visible) return null;
return (
<ComboboxItem
className={cn(comboboxItemVariants(), className)}
onClick={(event) => {
if (commit((tx) => onSelect?.(tx), focusEditor)) onClick?.(event);
}}
{...props}
/>
);
};
const InlineComboboxEmpty = ({
children,
className,
}: React.HTMLAttributes<HTMLDivElement>) => {
const { setHasEmpty } = useInlineComboboxContext();
const store = useComboboxContext();
if (store == null) {
throw new Error('InlineComboboxEmpty requires a Combobox store');
}
const items = useStoreState(store, 'items');
React.useEffect(() => {
setHasEmpty(true);
return () => {
setHasEmpty(false);
};
}, [setHasEmpty]);
if (items.length > 0) return null;
return <div className={cn('cn-command-empty', className)}>{children}</div>;
};
function InlineComboboxGroup({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<ComboboxGroup
{...props}
className={cn(
'cn-command-group hidden not-last:border-b [&:has([role=option])]:block',
className
)}
/>
);
}
function InlineComboboxGroupLabel({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<ComboboxGroupLabel
{...props}
className={cn(
'mt-1.5 mb-2 px-3 font-medium text-muted-foreground text-xs',
className
)}
/>
);
}
export {
InlineCombobox,
InlineComboboxContent,
InlineComboboxEmpty,
InlineComboboxGroup,
InlineComboboxGroupLabel,
InlineComboboxInput,
InlineComboboxItem,
};'use client';
import {
Combobox,
ComboboxGroup,
ComboboxGroupLabel,
ComboboxItem,
ComboboxPopover,
ComboboxProvider,
Portal,
useComboboxContext,
useComboboxStore,
useStoreState,
} from '@ariakit/react';
import { cva } from 'class-variance-authority';
import {
Hotkeys,
isHotkey,
type Element,
type PluginTransaction,
} from 'platejs';
import { BaseComboboxPlugin, filterWords } from 'platejs/combobox';
import { useComposedRef, useEditor, useElementSelected }
import { property } from 'platejs';
import { BaseComboboxPlugin } from 'platejs/combobox';
import { definePlugin } from 'platejs/react';
export const TagInputPlugin = definePlugin('tagInput', {
dependencies: [BaseComboboxPlugin],
editOnly: true,
schema: {
element: {
type: 'tag_input',
void: 'inline',
properties: {
trigger: property.string(),
userId: property.string(),
},
},
},
});import { property } from 'platejs';
import { triggerCombobox, type TriggerComboboxPluginState } from 'platejs/combobox';
import { definePlugin } from 'platejs/react';
import { TagInputPlugin } from './tag-input-plugin';
const initialState: TriggerComboboxPluginState = {
trigger: '#',
triggerPreviousCharPattern: /^\s?$/,
createComboboxInput: () => ({
children: [{ text: '' }],
type: 'tag_input',
}),
};
export const TagPlugin = definePlugin('customTag', {
dependencies: [TagInputPlugin],
initialState,
schema: {
element: {
type: 'tag',
void: 'inline',
properties: { value: property.string({ required: true }) },
},
},
update: ({ tx, schema: { type } }) => ({
insert: (value: string) => {
tx.nodes.insert({ children: [{ text: '' }], type, value });
tx.selection.move({ unit: 'offset' });
},
}),
}).extend(({ editor, store }) => ({
commands: (context) => triggerCombobox(context, {
editor,
getState: () => store.get(),
type: editor.plugin(TagInputPlugin).schema.type,
}),
}));import {
type EditorElementProps,
EditorElement,
useEditorFocused,
useEditorReadOnly,
useElementSelected,
} from 'platejs/react';
import {
InlineCombobox,
InlineComboboxContent,
InlineComboboxEmpty,
InlineComboboxInput,
InlineComboboxItem,
} from '@/components/editor/inline-combobox';
import { cn } from '@/lib/utils';
import { TagInputPlugin } from './tag-input-plugin';
import { TagPlugin } from './tag-plugin';
const tags = ['Frontend', 'Backend', 'Design', 'Urgent'];
export function TagInputElement(props: EditorElementProps<typeof TagInputPlugin>) {
return (
<EditorElement {...props} as="span">
<InlineCombobox element={props.element} trigger="#">
<InlineComboboxInput />
<InlineComboboxContent>
<InlineComboboxEmpty>No tags found</InlineComboboxEmpty>
{tags.map((tag) => (
<InlineComboboxItem
key={tag}
value={tag}
onSelect={(tx) => tx.plugin(TagPlugin.name).insert(tag)}
>
#{tag}
</InlineComboboxItem>
))}
</InlineComboboxContent>
</InlineCombobox>
{props.children}
</EditorElement>
);
}
export function TagElement(props: EditorElementProps<typeof TagPlugin>) {
const selected = useElementSelected();
const focused = useEditorFocused();
const readOnly = useEditorReadOnly();
return (
<EditorElement
{...props}
as="span"
className={cn(
'inline-block rounded-md bg-primary/10 px-1.5 py-0.5 align-baseline text-sm font-medium text-primary',
!readOnly && 'cursor-pointer',
selected && focused && 'ring-2 ring-ring'
)}
>
#{props.element.value}
{props.children}
</EditorElement>
);
}import { BaseCodeBlockPlugin } from 'platejs';
import { createEditor } from 'platejs/react';
import { TagPlugin } from './tag-plugin';
import { TagInputPlugin } from './tag-input-plugin';
import { TagElement, TagInputElement } from './tag-components';
const editor = createEditor({
plugins: [
TagPlugin.configure({
component: TagElement,
initialState: {
triggerQuery: (editor) => {
const codeBlock = editor.plugin(BaseCodeBlockPlugin);
return !codeBlock.installed || !editor.read.nodes.some({ type: codeBlock.schema.type });
},
},
}),
TagInputPlugin.configure({ component: TagInputElement }),
],
});