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

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
  • Keyboard navigation and selection handling
Report an issue

Create a Combobox Plugin

Block PlaceholderEmoji

On This Page

FeaturesCreate a Combobox PluginInstallationCreate Input PluginCreate Main PluginCreate ComponentAdd to EditorExamplesOptionsTriggerComboboxPluginStateRegistry UI
Build your editor
Production-ready AI template and reusable components.
Get all-access

Installation

pnpm add @platejs/combobox
pnpm add @platejs/combobox

Create Input Plugin

First, create an input plugin that will be inserted when the trigger is activated:

import { defineBasePlugin } from 'platejs';
 
const TagInputPlugin = defineBasePlugin('tagInput', {
  editOnly: true,
  schema: { element: { type: 'tag_input', void: 'inline' } },
});
import { defineBasePlugin } from 'platejs';
 
const TagInputPlugin = defineBasePlugin('tagInput', {
  editOnly: true,
  schema: { element: { type: 'tag_input', void: 'inline' } },
});

Create Main Plugin

Create your main plugin with trigger metadata:

import { defineBasePlugin } from 'platejs';
import type { TriggerComboboxPluginState } from '@platejs/combobox';
 
export type TagPluginState = TriggerComboboxPluginState;
 
const initialState: TagPluginState = {
  trigger: '#',
  triggerPreviousCharPattern: /^\s?$/,
  createComboboxInput: () => ({
    children: [{ text: '' }],
    type: 'tag_input',
  }),
};
 
export const TagPlugin = defineBasePlugin('tag', {



  • 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 {
  PlateElement,
  useEditor,
  useEditorFocused,
  useEditorReadOnly,
  useElementSelected,
} from 'platejs/react';
import {
  InlineCombobox,
  InlineComboboxContent,
  InlineComboboxEmpty,
  InlineComboboxInput,
  InlineComboboxItem,
} from '@/components/editor/inline-combobox';
import { cn } from '@/lib/utils';
 
const tags = [
  { id: 'frontend', name: 'Frontend', color: 'blue' },
  { id: 'backend', name: 'Backend', color: 'green' },
  { id: 






























































Add to Editor

import { PLUGINS } from 'platejs';
import { createPlateEditor } from 'platejs/react';
import { TagPlugin, TagInputPlugin } from './tag-plugin';
import { TagElement, TagInputElement } from './tag-components';
 
const editor = createPlateEditor({
  plugins: [
    // ...otherPlugins,
    TagPlugin.configure({
      component: TagElement,
      initialState: {
        triggerQuery: (editor) => {
          // Disable in code blocks
          const codeBlock = editor.plugin(PLUGINS.codeBlock);
 
          return (








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

Registry UI

The copied inline-combobox component owns input focus, cancellation, keyboard navigation, and undo/redo forwarding. Edit that component to match your product's combobox interaction.

'use client';
 
import {
  Combobox,
  ComboboxGroup,
  ComboboxGroupLabel,
  ComboboxItem,
  ComboboxPopover,
  ComboboxProvider,
  Portal,
  useComboboxContext,
  useComboboxStore,
  useStoreState,
} from '@ariakit/react';
import { filterWords } from '@platejs/combobox';
import type { Anchor, Element, Point } from '@platejs/plite';
import { failInvariant } from '@platejs/plite/internal';
import { cva } from 'class-variance-authority';
import { Hotkeys, isHotkey } from 'platejs';
import {
  useComposedRef,
  useEditor,
  useElementSelected,
  usePath,
} 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 = {
  filter: FilterFn | false;
  inputProps: Required<
    Pick<React.InputHTMLAttributes<HTMLInputElement>, 'onBlur' | 'onKeyDown'>
  >;
  inputRef: React.RefObject<HTMLInputElement | null>;
  removeInput: (focusEditor?: boolean) => void;
  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)
  );
};
 
type InlineComboboxProps = {
  children: React.ReactNode;
  element: Element;
  trigger: string;
  filter?: FilterFn | false;
  hideWhenNoValue?: boolean;
  showTrigger?: boolean;
  value?: string;
  setValue?: (value: string) => void;
};
 
const InlineCombobox = ({
  children,
  element,
  filter = defaultFilter,
  hideWhenNoValue = false,
  setValue: setValueProp,
  showTrigger = true,
  trigger,
  value: valueProp,
}: InlineComboboxProps) => {
  const editor = useEditor();
  const path = usePath();
  const selected = useElementSelected();
  const inputRef = React.useRef<HTMLInputElement>(null);
 
  const [valueState, setValueState] = React.useState('');
  const hasValueProp = valueProp !== undefined;
  const value = hasValueProp ? valueProp : valueState;
 
  // Check if current user is the creator of this element (for Yjs collaboration)
  const isCreator = React.useMemo(() => {
    const elementUserId = element.userId;
    const currentUserId = editor.runtime.userId;
 
    // Inputs without a collaboration owner stay editable.
    if (!elementUserId) return true;
 
    return elementUserId === currentUserId;
  }, [editor.runtime.userId, element]);
 
  const setValue = React.useCallback(
    (newValue: string) => {
      setValueProp?.(newValue);
 
      if (!hasValueProp) {
        setValueState(newValue);
      }
    },
    [setValueProp, hasValueProp]
  );
 
  /**
   * Track the point just before the input element so we know where to
   * insertText if the combobox closes due to a selection change.
   */
  const insertPointAnchor = React.useRef<Anchor<Point> | null>(null);
 
  React.useEffect(() => {
    insertPointAnchor.current?.release();
    insertPointAnchor.current = null;
 
    if (!path) return undefined;
 
    const point = editor.read.points.before(path);
 
    if (!point) return undefined;
 
    const nextPointAnchor = editor.anchor(point, {
      association: 'forward',
      deletion: 'drop',
    });
    insertPointAnchor.current = nextPointAnchor;
 
    return () => {
      if (insertPointAnchor.current === nextPointAnchor) {
        insertPointAnchor.current = null;
      }
      nextPointAnchor.release();
    };
  }, [editor, path]);
 
  const removedRef = React.useRef(false);
  const removeInput = React.useCallback(
    (focusEditor = false) => {
      if (removedRef.current) return;
 
      removedRef.current = true;
      editor.update.nodes.remove({ at: element });
 
      if (focusEditor) editor.api.dom.focus();
    },
    [editor, element]
  );
  const cancelInput = React.useCallback(
    (
      cause:
        | 'arrowLeft'
        | 'arrowRight'
        | 'backspace'
        | 'blur'
        | 'deselect'
        | 'escape',
      focusEditor = false
    ) => {
      if (removedRef.current) return;
 
      removeInput(focusEditor);
 
      if (cause === 'backspace') return;
 
      editor.update((tx) => {
        tx.text.insert(trigger + value, {
          at: insertPointAnchor.current?.resolve() ?? undefined,
        });
 
        if (cause === 'arrowLeft' || cause === 'arrowRight') {
          tx.selection.move({
            distance: 1,
            reverse: cause === 'arrowLeft',
          });
        }
      });
    },
    [editor, removeInput, trigger, value]
  );
 
  React.useEffect(() => {
    if (isCreator) inputRef.current?.focus();
  }, [isCreator]);
 
  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) => {
        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 undo =
          Hotkeys.isUndo(event) && editor.read.history.undos().length > 0;
        const redo =
          Hotkeys.isRedo(event) && editor.read.history.redos().length > 0;
 
        if (undo || redo) {
          event.preventDefault();
          editor.update.history[undo ? 'undo' : 'redo']();
          editor.api.dom.focus();
        }
      },
    }),
    [cancelInput, editor]
  );
 
  const [hasEmpty, setHasEmpty] = React.useState(false);
 
  const contextValue = React.useMemo<InlineComboboxContextValue>(
    () => ({
      filter,
      inputProps,
      inputRef,
      removeInput,
      setHasEmpty,
      showTrigger,
      trigger,
    }),
    [filter, inputProps, inputRef, removeInput, showTrigger, trigger]
  );
 
  const store = useComboboxStore({
    // open: ,
    setValue: (newValue) => {
      React.startTransition(() => setValue(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={
          (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 {
    inputProps,
    inputRef: contextRef,
    showTrigger,
    trigger,
  } = useInlineComboboxContext();
 
  const store =
    useComboboxContext() ?? failInvariant('Expected value to be defined');
  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}
          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,
  ...props
}: Omit<React.HTMLAttributes<HTMLDivElement>, 'value'> & {
  focusEditor?: boolean;
  group?: string;
  keywords?: string[];
  label?: string;
  value: string;
}) => {
  const { value } = props;
 
  const { filter, removeInput } = useInlineComboboxContext();
 
  const store =
    useComboboxContext() ?? failInvariant('Expected value to be defined');
 
  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) => {
        removeInput(focusEditor);
        onClick?.(event);
      }}
      {...props}
    />
  );
};
 
const InlineComboboxEmpty = ({
  children,
  className,
}: React.HTMLAttributes<HTMLDivElement>) => {
  const { setHasEmpty } = useInlineComboboxContext();
  const store =
    useComboboxContext() ?? failInvariant('Expected value to be defined');
  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 { filterWords } from '@platejs/combobox';
import type { Anchor, Element, Point } from '@platejs/plite';
import { failInvariant } from '@platejs/plite/internal';
import { cva } from 'class-variance-authority';
import { Hotkeys, isHotkey } from 'platejs';
import {














































































































































































































































































































































































































































































































dependencies: [TagInputPlugin],
initialState,
schema: { element: { void: 'inline' } },
});
import { defineBasePlugin } from 'platejs';
import type { TriggerComboboxPluginState } from '@platejs/combobox';
 
export type TagPluginState = TriggerComboboxPluginState;
 
const initialState: TagPluginState = {
  trigger: '#',
  triggerPreviousCharPattern: /^\s?$/,
  createComboboxInput: () => ({
    children: [{ text: '' }],
    type: 'tag_input',
  }),
};
 
export const TagPlugin = defineBasePlugin('tag', {
  dependencies: [TagInputPlugin],
  initialState,
  schema: { element: { void: 'inline' } },
});
'design'
, name:
'Design'
, color:
'purple'
},
{ id: 'urgent', name: 'Urgent', color: 'red' },
];
export function TagInputElement({ element, ...props }) {
const editor = useEditor();
return (
<PlateElement as="span" {...props}>
<InlineCombobox element={element} trigger="#">
<InlineComboboxInput />
<InlineComboboxContent>
<InlineComboboxEmpty>No tags found</InlineComboboxEmpty>
{tags.map((tag) => (
<InlineComboboxItem
key={tag.id}
value={tag.name}
onClick={() => {
editor.update((tx) => {
tx.nodes.insert({
children: [{ text: tag.name }],
tagId: tag.id,
type: 'tag',
});
});
}}
>
<span
className={`w-3 h-3 rounded-full bg-${tag.color}-500 mr-2`}
/>
#{tag.name}
</InlineComboboxItem>
))}
</InlineComboboxContent>
</InlineCombobox>
{props.children}
</PlateElement>
);
}
export function TagElement({ element, ...props }) {
const selected = useElementSelected();
const focused = useEditorFocused();
const readOnly = useEditorReadOnly();
return (
<PlateElement
{...props}
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'
)}
attributes={{
...props.attributes,
contentEditable: false,
'data-plite-value': element.value,
}}
>
#{element.value}
{props.children}
</PlateElement>
);
}
import {
  PlateElement,
  useEditor,
  useEditorFocused,
  useEditorReadOnly,
  useElementSelected,
} from 'platejs/react';
import {
  InlineCombobox,
  InlineComboboxContent,
  InlineComboboxEmpty,
  InlineComboboxInput,
  InlineComboboxItem,
} from '@/components/editor/inline-combobox';
import { cn } from '@/lib/utils';
 
const tags = [
  { id: 'frontend', name: 'Frontend', color: 'blue' },
  { id: 'backend', name: 'Backend', color: 'green' },
  { id: 'design', name: 'Design', color: 'purple' },
  { id: 'urgent', name: 'Urgent', color: 'red' },
];
 
export function TagInputElement({ element, ...props }) {
  const editor = useEditor();
 
  return (
    <PlateElement as="span" {...props}>
      <InlineCombobox element={element} trigger="#">
        <InlineComboboxInput />
        <InlineComboboxContent>
          <InlineComboboxEmpty>No tags found</InlineComboboxEmpty>
          {tags.map((tag) => (
            <InlineComboboxItem
              key={tag.id}
              value={tag.name}
              onClick={() => {
                editor.update((tx) => {
                  tx.nodes.insert({
                    children: [{ text: tag.name }],
                    tagId: tag.id,
                    type: 'tag',
                  });
                });
              }}
            >
              <span 
                className={`w-3 h-3 rounded-full bg-${tag.color}-500 mr-2`}
              />
              #{tag.name}
            </InlineComboboxItem>
          ))}
        </InlineComboboxContent>
      </InlineCombobox>
      {props.children}
    </PlateElement>
  );
}
 
export function TagElement({ element, ...props }) {
  const selected = useElementSelected();
  const focused = useEditorFocused();
  const readOnly = useEditorReadOnly();
 
  return (
    <PlateElement
      {...props}
      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'
      )}
      attributes={{
        ...props.attributes,
        contentEditable: false,
        'data-plite-value': element.value,
      }}
    >
      #{element.value}
      {props.children}
    </PlateElement>
  );
}
!codeBlock.installed ||
!editor.read.nodes.some({ type: codeBlock.schema.type })
);
},
},
}),
TagInputPlugin.configure({ component: TagInputElement }),
],
});
import { PLUGINS } from 'platejs';
import { createPlateEditor } from 'platejs/react';
import { TagPlugin, TagInputPlugin } from './tag-plugin';
import { TagElement, TagInputElement } from './tag-components';
 
const editor = createPlateEditor({
  plugins: [
    // ...otherPlugins,
    TagPlugin.configure({
      component: TagElement,
      initialState: {
        triggerQuery: (editor) => {
          // Disable in code blocks
          const codeBlock = editor.plugin(PLUGINS.codeBlock);
 
          return (
            !codeBlock.installed ||
            !editor.read.nodes.some({ type: codeBlock.schema.type })
          );
        },
      },
    }),
    TagInputPlugin.configure({ component: TagInputElement }),
  ],
});
useComposedRef,
useEditor,
useElementSelected,
usePath,
} 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 = {
filter: FilterFn | false;
inputProps: Required<
Pick<React.InputHTMLAttributes<HTMLInputElement>, 'onBlur' | 'onKeyDown'>
>;
inputRef: React.RefObject<HTMLInputElement | null>;
removeInput: (focusEditor?: boolean) => void;
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)
);
};
type InlineComboboxProps = {
children: React.ReactNode;
element: Element;
trigger: string;
filter?: FilterFn | false;
hideWhenNoValue?: boolean;
showTrigger?: boolean;
value?: string;
setValue?: (value: string) => void;
};
const InlineCombobox = ({
children,
element,
filter = defaultFilter,
hideWhenNoValue = false,
setValue: setValueProp,
showTrigger = true,
trigger,
value: valueProp,
}: InlineComboboxProps) => {
const editor = useEditor();
const path = usePath();
const selected = useElementSelected();
const inputRef = React.useRef<HTMLInputElement>(null);
const [valueState, setValueState] = React.useState('');
const hasValueProp = valueProp !== undefined;
const value = hasValueProp ? valueProp : valueState;
// Check if current user is the creator of this element (for Yjs collaboration)
const isCreator = React.useMemo(() => {
const elementUserId = element.userId;
const currentUserId = editor.runtime.userId;
// Inputs without a collaboration owner stay editable.
if (!elementUserId) return true;
return elementUserId === currentUserId;
}, [editor.runtime.userId, element]);
const setValue = React.useCallback(
(newValue: string) => {
setValueProp?.(newValue);
if (!hasValueProp) {
setValueState(newValue);
}
},
[setValueProp, hasValueProp]
);
/**
* Track the point just before the input element so we know where to
* insertText if the combobox closes due to a selection change.
*/
const insertPointAnchor = React.useRef<Anchor<Point> | null>(null);
React.useEffect(() => {
insertPointAnchor.current?.release();
insertPointAnchor.current = null;
if (!path) return undefined;
const point = editor.read.points.before(path);
if (!point) return undefined;
const nextPointAnchor = editor.anchor(point, {
association: 'forward',
deletion: 'drop',
});
insertPointAnchor.current = nextPointAnchor;
return () => {
if (insertPointAnchor.current === nextPointAnchor) {
insertPointAnchor.current = null;
}
nextPointAnchor.release();
};
}, [editor, path]);
const removedRef = React.useRef(false);
const removeInput = React.useCallback(
(focusEditor = false) => {
if (removedRef.current) return;
removedRef.current = true;
editor.update.nodes.remove({ at: element });
if (focusEditor) editor.api.dom.focus();
},
[editor, element]
);
const cancelInput = React.useCallback(
(
cause:
| 'arrowLeft'
| 'arrowRight'
| 'backspace'
| 'blur'
| 'deselect'
| 'escape',
focusEditor = false
) => {
if (removedRef.current) return;
removeInput(focusEditor);
if (cause === 'backspace') return;
editor.update((tx) => {
tx.text.insert(trigger + value, {
at: insertPointAnchor.current?.resolve() ?? undefined,
});
if (cause === 'arrowLeft' || cause === 'arrowRight') {
tx.selection.move({
distance: 1,
reverse: cause === 'arrowLeft',
});
}
});
},
[editor, removeInput, trigger, value]
);
React.useEffect(() => {
if (isCreator) inputRef.current?.focus();
}, [isCreator]);
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) => {
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 undo =
Hotkeys.isUndo(event) && editor.read.history.undos().length > 0;
const redo =
Hotkeys.isRedo(event) && editor.read.history.redos().length > 0;
if (undo || redo) {
event.preventDefault();
editor.update.history[undo ? 'undo' : 'redo']();
editor.api.dom.focus();
}
},
}),
[cancelInput, editor]
);
const [hasEmpty, setHasEmpty] = React.useState(false);
const contextValue = React.useMemo<InlineComboboxContextValue>(
() => ({
filter,
inputProps,
inputRef,
removeInput,
setHasEmpty,
showTrigger,
trigger,
}),
[filter, inputProps, inputRef, removeInput, showTrigger, trigger]
);
const store = useComboboxStore({
// open: ,
setValue: (newValue) => {
React.startTransition(() => setValue(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={
(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 {
inputProps,
inputRef: contextRef,
showTrigger,
trigger,
} = useInlineComboboxContext();
const store =
useComboboxContext() ?? failInvariant('Expected value to be defined');
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}
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,
...props
}: Omit<React.HTMLAttributes<HTMLDivElement>, 'value'> & {
focusEditor?: boolean;
group?: string;
keywords?: string[];
label?: string;
value: string;
}) => {
const { value } = props;
const { filter, removeInput } = useInlineComboboxContext();
const store =
useComboboxContext() ?? failInvariant('Expected value to be defined');
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) => {
removeInput(focusEditor);
onClick?.(event);
}}
{...props}
/>
);
};
const InlineComboboxEmpty = ({
children,
className,
}: React.HTMLAttributes<HTMLDivElement>) => {
const { setHasEmpty } = useInlineComboboxContext();
const store =
useComboboxContext() ?? failInvariant('Expected value to be defined');
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,
};