mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-07 02:58:11 +00:00
405 lines
9 KiB
TypeScript
405 lines
9 KiB
TypeScript
'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 {
|
|
findNodePath,
|
|
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 = findNodePath(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());
|
|
}
|
|
|
|
}, [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-popover shadow-md',
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
</Portal>
|
|
);
|
|
};
|
|
|
|
const comboboxItemVariants = cva(
|
|
'relative mx-1 flex h-[28px] select-none items-center rounded-sm px-2 text-sm text-foreground outline-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
|
{
|
|
defaultVariants: {
|
|
interactive: true,
|
|
},
|
|
variants: {
|
|
interactive: {
|
|
false: '',
|
|
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',
|
|
},
|
|
},
|
|
}
|
|
);
|
|
|
|
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-muted-foreground'
|
|
);
|
|
|
|
export {
|
|
InlineCombobox,
|
|
InlineComboboxContent,
|
|
InlineComboboxEmpty,
|
|
InlineComboboxGroup,
|
|
InlineComboboxGroupLabel,
|
|
InlineComboboxInput,
|
|
InlineComboboxItem,
|
|
InlineComboboxRow,
|
|
};
|