Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Comments
  • Discussion
  • Suggestions
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Details
  • 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
    • Drag & Drop
    • Navigation Feedback
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • CSV
    • DOCX
    • HTML
    • Markdown

Combobox

PreviousNext

Utilities for adding combobox to your editor.

Inline Combobox

Mention

Insert mentions for users, pages, or any reference with @

Slash Command

Quick access to editor commands and blocks with /

Emoji

Insert emojis with autocomplete using :

Features

  • Utilities for creating trigger-based combobox functionality
  • Configurable trigger characters and patterns
  • Atomic input completion, cancellation and history commands
Report an issue

Create a combobox plugin

Block PlaceholderEmoji

On This Page

FeaturesCreate a combobox pluginInstallationCreate input pluginCreate main pluginCreate componentAdd to editorExamplesOptionsTriggerComboboxPluginStateInput commandsRegistry UI
Build your editor
Production-ready AI template and reusable components.
Get all-access

Installation

pnpm add platejs
npx shadcn@latest add @plate/inline-combobox
pnpm add platejs
npx shadcn@latest add @plate/inline-combobox

Create input plugin

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 main plugin

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 behavior
  • schema.element.void: 'inline': Makes the tag an inline void and prevents editing inside it
  • initialState.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 whitespace
  • initialState.createComboboxInput: Function that creates the input element node when the trigger is activated

Create component

Create 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', 











































Add to editor

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 state

Examples

Loading…
Loading…
Loading…

Options

TriggerComboboxPluginState

Configuration options for trigger-based combobox plugins.

Options

    Function to create the input node when trigger is activated.

    Character(s) that trigger the combobox. Can be:

    • A single character (e.g. '@')
    • An array of characters
    • A regular expression

    Pattern to match the character before trigger.

    • Example: /^\s?$/ matches start of line or space

    Custom query function to control when trigger is active.

Input commands

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.

MethodBehavior
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.

Registry UI

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(),
      },
    },
  },
});
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 { 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,
  }),
}));
'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 {
  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>
  );
}
||
!
editor.read.nodes.
some
({ type: codeBlock.schema.type });
},
},
}),
TagInputPlugin.configure({ component: TagInputElement }),
],
});
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 }),
  ],
});
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,
};