- "content": "'use client';\n\nimport * as React from 'react';\n\nimport type { Point, TElement } from 'platejs';\n\nimport {\n type ComboboxItemProps,\n Combobox,\n ComboboxGroup,\n ComboboxGroupLabel,\n ComboboxItem,\n ComboboxPopover,\n ComboboxProvider,\n ComboboxRow,\n Portal,\n useComboboxContext,\n useComboboxStore,\n} from '@ariakit/react';\nimport { filterWords } from '@platejs/combobox';\nimport {\n type UseComboboxInputResult,\n useComboboxInput,\n useHTMLInputCursorState,\n} from '@platejs/combobox/react';\nimport { cva } from 'class-variance-authority';\nimport { useComposedRef, useEditorRef } from 'platejs/react';\n\nimport { cn } from '@/lib/utils';\n\ntype FilterFn = (\n item: { value: string; group?: string; keywords?: string[]; label?: string },\n search: string\n) => boolean;\n\ninterface InlineComboboxContextValue {\n filter: FilterFn | false;\n inputProps: UseComboboxInputResult['props'];\n inputRef: React.RefObject<HTMLInputElement | null>;\n removeInput: UseComboboxInputResult['removeInput'];\n showTrigger: boolean;\n trigger: string;\n setHasEmpty: (hasEmpty: boolean) => void;\n}\n\nconst InlineComboboxContext = React.createContext<InlineComboboxContextValue>(\n null as unknown as InlineComboboxContextValue\n);\n\nconst defaultFilter: FilterFn = (\n { group, keywords = [], label, value },\n search\n) => {\n const uniqueTerms = new Set(\n [value, ...keywords, group, label].filter(Boolean)\n );\n\n return Array.from(uniqueTerms).some((keyword) =>\n filterWords(keyword!, search)\n );\n};\n\ninterface InlineComboboxProps {\n children: React.ReactNode;\n element: TElement;\n trigger: string;\n filter?: FilterFn | false;\n hideWhenNoValue?: boolean;\n showTrigger?: boolean;\n value?: string;\n setValue?: (value: string) => void;\n}\n\nconst InlineCombobox = ({\n children,\n element,\n filter = defaultFilter,\n hideWhenNoValue = false,\n setValue: setValueProp,\n showTrigger = true,\n trigger,\n value: valueProp,\n}: InlineComboboxProps) => {\n const editor = useEditorRef();\n const inputRef = React.useRef<HTMLInputElement>(null);\n const cursorState = useHTMLInputCursorState(inputRef);\n\n const [valueState, setValueState] = React.useState('');\n const hasValueProp = valueProp !== undefined;\n const value = hasValueProp ? valueProp : valueState;\n\n const setValue = React.useCallback(\n (newValue: string) => {\n setValueProp?.(newValue);\n\n if (!hasValueProp) {\n setValueState(newValue);\n }\n },\n [setValueProp, hasValueProp]\n );\n\n /**\n * Track the point just before the input element so we know where to\n * insertText if the combobox closes due to a selection change.\n */\n const insertPoint = React.useRef<Point | null>(null);\n\n React.useEffect(() => {\n const path = editor.api.findPath(element);\n\n if (!path) return;\n\n const point = editor.api.before(path);\n\n if (!point) return;\n\n const pointRef = editor.api.pointRef(point);\n insertPoint.current = pointRef.current;\n\n return () => {\n pointRef.unref();\n };\n }, [editor, element]);\n\n const { props: inputProps, removeInput } = useComboboxInput({\n cancelInputOnBlur: false,\n cursorState,\n ref: inputRef,\n onCancelInput: (cause) => {\n if (cause !== 'backspace') {\n editor.tf.insertText(trigger + value, {\n at: insertPoint?.current ?? undefined,\n });\n }\n if (cause === 'arrowLeft' || cause === 'arrowRight') {\n editor.tf.move({\n distance: 1,\n reverse: cause === 'arrowLeft',\n });\n }\n },\n });\n\n const [hasEmpty, setHasEmpty] = React.useState(false);\n\n const contextValue: InlineComboboxContextValue = React.useMemo(\n () => ({\n filter,\n inputProps,\n inputRef,\n removeInput,\n setHasEmpty,\n showTrigger,\n trigger,\n }),\n [\n trigger,\n showTrigger,\n filter,\n inputRef,\n inputProps,\n removeInput,\n setHasEmpty,\n ]\n );\n\n const store = useComboboxStore({\n // open: ,\n setValue: (newValue) => React.startTransition(() => setValue(newValue)),\n });\n\n const items = store.useState('items');\n\n /**\n * If there is no active ID and the list of items changes, select the first\n * item.\n */\n React.useEffect(() => {\n if (!store.getState().activeId) {\n store.setActiveId(store.first());\n }\n }, [items, store]);\n\n return (\n <span contentEditable={false}>\n <ComboboxProvider\n open={\n (items.length > 0 || hasEmpty) &&\n (!hideWhenNoValue || value.length > 0)\n }\n store={store}\n >\n <InlineComboboxContext.Provider value={contextValue}>\n {children}\n </InlineComboboxContext.Provider>\n </ComboboxProvider>\n </span>\n );\n};\n\nconst InlineComboboxInput = React.forwardRef<\n HTMLInputElement,\n React.HTMLAttributes<HTMLInputElement>\n>(({ className, ...props }, propRef) => {\n const {\n inputProps,\n inputRef: contextRef,\n showTrigger,\n trigger,\n } = React.useContext(InlineComboboxContext);\n\n const store = useComboboxContext()!;\n const value = store.useState('value');\n\n const ref = useComposedRef(propRef, contextRef);\n\n /**\n * To create an auto-resizing input, we render a visually hidden span\n * containing the input value and position the input element on top of it.\n * This works well for all cases except when input exceeds the width of the\n * container.\n */\n\n return (\n <>\n {showTrigger && trigger}\n\n <span className=\"relative min-h-[1lh]\">\n <span\n className=\"invisible overflow-hidden text-nowrap\"\n aria-hidden=\"true\"\n >\n {value || '\\u200B'}\n </span>\n\n <Combobox\n ref={ref}\n className={cn(\n 'absolute top-0 left-0 size-full bg-transparent outline-none',\n className\n )}\n value={value}\n autoSelect\n {...inputProps}\n {...props}\n />\n </span>\n </>\n );\n});\n\nInlineComboboxInput.displayName = 'InlineComboboxInput';\n\nconst InlineComboboxContent: typeof ComboboxPopover = ({\n className,\n ...props\n}) => {\n // Portal prevents CSS from leaking into popover\n return (\n <Portal>\n <ComboboxPopover\n className={cn(\n 'z-500 max-h-[288px] w-[300px] overflow-y-auto rounded-md bg-popover shadow-md',\n className\n )}\n {...props}\n />\n </Portal>\n );\n};\n\nconst comboboxItemVariants = cva(\n 'relative mx-1 flex h-[28px] items-center rounded-sm px-2 text-sm text-foreground outline-none select-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n {\n defaultVariants: {\n interactive: true,\n },\n variants: {\n interactive: {\n false: '',\n true: 'cursor-pointer transition-colors hover:bg-accent hover:text-accent-foreground data-[active-item=true]:bg-accent data-[active-item=true]:text-accent-foreground',\n },\n },\n }\n);\n\nconst InlineComboboxItem = ({\n className,\n focusEditor = true,\n group,\n keywords,\n label,\n onClick,\n ...props\n}: {\n focusEditor?: boolean;\n group?: string;\n keywords?: string[];\n label?: string;\n} & ComboboxItemProps &\n Required<Pick<ComboboxItemProps, 'value'>>) => {\n const { value } = props;\n\n const { filter, removeInput } = React.useContext(InlineComboboxContext);\n\n const store = useComboboxContext()!;\n\n // Optimization: Do not subscribe to value if filter is false\n const search = filter && store.useState('value');\n\n const visible = React.useMemo(\n () =>\n !filter || filter({ group, keywords, label, value }, search as string),\n [filter, group, keywords, label, value, search]\n );\n\n if (!visible) return null;\n\n return (\n <ComboboxItem\n className={cn(comboboxItemVariants(), className)}\n onClick={(event) => {\n removeInput(focusEditor);\n onClick?.(event);\n }}\n {...props}\n />\n );\n};\n\nconst InlineComboboxEmpty = ({\n children,\n className,\n}: React.HTMLAttributes<HTMLDivElement>) => {\n const { setHasEmpty } = React.useContext(InlineComboboxContext);\n const store = useComboboxContext()!;\n const items = store.useState('items');\n\n React.useEffect(() => {\n setHasEmpty(true);\n\n return () => {\n setHasEmpty(false);\n };\n }, [setHasEmpty]);\n\n if (items.length > 0) return null;\n\n return (\n <div\n className={cn(comboboxItemVariants({ interactive: false }), className)}\n >\n {children}\n </div>\n );\n};\n\nconst InlineComboboxRow = ComboboxRow;\n\nfunction InlineComboboxGroup({\n className,\n ...props\n}: React.ComponentProps<typeof ComboboxGroup>) {\n return (\n <ComboboxGroup\n {...props}\n className={cn(\n 'hidden py-1.5 not-last:border-b [&:has([role=option])]:block',\n className\n )}\n />\n );\n}\n\nfunction InlineComboboxGroupLabel({\n className,\n ...props\n}: React.ComponentProps<typeof ComboboxGroupLabel>) {\n return (\n <ComboboxGroupLabel\n {...props}\n className={cn(\n 'mt-1.5 mb-2 px-3 text-xs font-medium text-muted-foreground',\n className\n )}\n />\n );\n}\n\nexport {\n InlineCombobox,\n InlineComboboxContent,\n InlineComboboxEmpty,\n InlineComboboxGroup,\n InlineComboboxGroupLabel,\n InlineComboboxInput,\n InlineComboboxItem,\n InlineComboboxRow,\n};\n",
0 commit comments