'use client'; import React, { type HTMLAttributes, type ReactNode, type RefObject, createContext, forwardRef, startTransition, useCallback, useContext, useEffect, useMemo, useState, } from 'react'; import type { PointRef } from 'slate'; import { type ComboboxItemProps, Combobox, ComboboxGroup, ComboboxGroupLabel, ComboboxItem, ComboboxPopover, ComboboxProvider, ComboboxRow, Portal, useComboboxContext, useComboboxStore, } from '@ariakit/react'; import { cn, withCn } from '@udecode/cn'; import { filterWords } from '@udecode/plate-combobox'; import { type UseComboboxInputResult, useComboboxInput, useHTMLInputCursorState, } from '@udecode/plate-combobox/react'; import { type TElement, createPointRef, getPointBefore, insertText, moveSelection, } from '@udecode/plate-common'; import { findPath, useComposedRef, useEditorRef, } from '@udecode/plate-common/react'; import { cva } from 'class-variance-authority'; type FilterFn = ( item: { value: string; group?: string; keywords?: string[]; label?: string }, search: string ) => boolean; interface InlineComboboxContextValue { filter: FilterFn | false; inputProps: UseComboboxInputResult['props']; inputRef: RefObject<HTMLInputElement | null>; removeInput: UseComboboxInputResult['removeInput']; setHasEmpty: (hasEmpty: boolean) => void; showTrigger: boolean; trigger: string; } const InlineComboboxContext = createContext<InlineComboboxContextValue>( null as any ); export const defaultFilter: FilterFn = ( { group, keywords = [], label, value }, search ) => { const uniqueTerms = new Set( [value, ...keywords, group, label].filter(Boolean) ); return Array.from(uniqueTerms).some((keyword) => filterWords(keyword!, search) ); }; interface InlineComboboxProps { children: ReactNode; element: TElement; trigger: string; filter?: FilterFn | false; hideWhenNoValue?: boolean; setValue?: (value: string) => void; showTrigger?: boolean; value?: string; } const InlineCombobox = ({ children, element, filter = defaultFilter, hideWhenNoValue = false, setValue: setValueProp, showTrigger = true, trigger, value: valueProp, }: InlineComboboxProps) => { const editor = useEditorRef(); const inputRef = React.useRef<HTMLInputElement>(null); const cursorState = useHTMLInputCursorState(inputRef); const [valueState, setValueState] = useState(''); const hasValueProp = valueProp !== undefined; const value = hasValueProp ? valueProp : valueState; const setValue = 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 [insertPoint, setInsertPoint] = useState<PointRef | null>(null); useEffect(() => { const path = findPath(editor, element); if (!path) return; const point = getPointBefore(editor, path); if (!point) return; const pointRef = createPointRef(editor, point); setInsertPoint(pointRef); return () => { pointRef.unref(); }; }, [editor, element]); const { props: inputProps, removeInput } = useComboboxInput({ cancelInputOnBlur: false, cursorState, ref: inputRef, onCancelInput: (cause) => { if (cause !== 'backspace') { insertText(editor, trigger + value, { at: insertPoint?.current ?? undefined, }); } if (cause === 'arrowLeft' || cause === 'arrowRight') { moveSelection(editor, { distance: 1, reverse: cause === 'arrowLeft', }); } }, }); const [hasEmpty, setHasEmpty] = useState(false); const contextValue: InlineComboboxContextValue = useMemo( () => ({ filter, inputProps, inputRef, removeInput, setHasEmpty, showTrigger, trigger, }), [ trigger, showTrigger, filter, inputRef, inputProps, removeInput, setHasEmpty, ] ); const store = useComboboxStore({ // open: , setValue: (newValue) => startTransition(() => setValue(newValue)), }); const items = store.useState('items'); /** * If there is no active ID and the list of items changes, select the first * item. */ useEffect(() => { if (!store.getState().activeId) { store.setActiveId(store.first()); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [items, store]); return ( <span contentEditable={false}> <ComboboxProvider open={ (items.length > 0 || hasEmpty) && (!hideWhenNoValue || value.length > 0) } store={store} > <InlineComboboxContext.Provider value={contextValue}> {children} </InlineComboboxContext.Provider> </ComboboxProvider> </span> ); }; const InlineComboboxInput = forwardRef< HTMLInputElement, HTMLAttributes<HTMLInputElement> >(({ className, ...props }, propRef) => { const { inputProps, inputRef: contextRef, showTrigger, trigger, } = useContext(InlineComboboxContext); const store = useComboboxContext()!; const value = store.useState('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 left-0 top-0 size-full bg-transparent outline-none', className )} value={value} autoSelect {...inputProps} {...props} /> </span> </> ); }); InlineComboboxInput.displayName = 'InlineComboboxInput'; const InlineComboboxContent: typeof ComboboxPopover = ({ className, ...props }) => { // Portal prevents CSS from leaking into popover return ( <Portal> <ComboboxPopover className={cn( 'z-[500] max-h-[288px] w-[300px] overflow-y-auto rounded-md bg-white shadow-md dark:bg-zinc-950', className )} {...props} /> </Portal> ); }; const comboboxItemVariants = cva( 'relative mx-1 flex h-[28px] select-none items-center rounded-sm px-2 text-sm text-zinc-950 outline-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:text-zinc-50', { defaultVariants: { interactive: true, }, variants: { interactive: { false: '', true: 'cursor-pointer transition-colors hover:bg-zinc-100 hover:text-zinc-900 data-[active-item=true]:bg-zinc-100 data-[active-item=true]:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:data-[active-item=true]:bg-zinc-800 dark:data-[active-item=true]:text-zinc-50', }, }, } ); export type InlineComboboxItemProps = { focusEditor?: boolean; group?: string; keywords?: string[]; label?: string; } & ComboboxItemProps & Required<Pick<ComboboxItemProps, 'value'>>; const InlineComboboxItem = ({ className, focusEditor = true, group, keywords, label, onClick, ...props }: InlineComboboxItemProps) => { const { value } = props; const { filter, removeInput } = useContext(InlineComboboxContext); const store = useComboboxContext()!; // Optimization: Do not subscribe to value if filter is false const search = filter && store.useState('value'); const visible = useMemo( () => !filter || filter({ group, keywords, label, value }, search as string), [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, }: HTMLAttributes<HTMLDivElement>) => { const { setHasEmpty } = useContext(InlineComboboxContext); const store = useComboboxContext()!; const items = store.useState('items'); useEffect(() => { setHasEmpty(true); return () => { setHasEmpty(false); }; }, [setHasEmpty]); if (items.length > 0) return null; return ( <div className={cn(comboboxItemVariants({ interactive: false }), className)} > {children} </div> ); }; const InlineComboboxRow = ComboboxRow; const InlineComboboxGroup = withCn( ComboboxGroup, 'hidden py-1.5 [&:has([role=option])]:block [&:not(:last-child)]:border-b' ); const InlineComboboxGroupLabel = withCn( ComboboxGroupLabel, 'mb-2 mt-1.5 px-3 text-xs font-medium text-zinc-500 dark:text-zinc-400' ); export { InlineCombobox, InlineComboboxContent, InlineComboboxEmpty, InlineComboboxGroup, InlineComboboxGroupLabel, InlineComboboxInput, InlineComboboxItem, InlineComboboxRow, };