SurfSense/surfsense_web/components/ui/inline-combobox.tsx
DESKTOP-RTLN3BA\$punk 634f6f24bf chore: linting
2026-02-20 22:44:56 -08:00

404 lines
9.7 KiB
TypeScript

"use client";
import {
Combobox,
ComboboxGroup,
ComboboxGroupLabel,
ComboboxItem,
type ComboboxItemProps,
ComboboxPopover,
ComboboxProvider,
ComboboxRow,
Portal,
useComboboxContext,
useComboboxStore,
} from "@ariakit/react";
import { filterWords } from "@platejs/combobox";
import {
type UseComboboxInputResult,
useComboboxInput,
useHTMLInputCursorState,
} from "@platejs/combobox/react";
import { cva } from "class-variance-authority";
import type { Point, TElement } from "platejs";
import { useComposedRef, useEditorRef } from "platejs/react";
import * as React from "react";
import { cn } from "@/lib/utils";
function useRequiredComboboxContext() {
const context = useComboboxContext();
if (!context) {
throw new Error("InlineCombobox compound components must be rendered within InlineCombobox");
}
return context;
}
type FilterFn = (
item: { value: string; group?: string; keywords?: string[]; label?: string },
search: string
) => boolean;
type InlineComboboxContextValue = {
filter: FilterFn | false;
inputProps: UseComboboxInputResult["props"];
inputRef: React.RefObject<HTMLInputElement | null>;
removeInput: UseComboboxInputResult["removeInput"];
showTrigger: boolean;
trigger: string;
setHasEmpty: (hasEmpty: boolean) => void;
};
const InlineComboboxContext = React.createContext<InlineComboboxContextValue>(
null as unknown as InlineComboboxContextValue
);
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 as string, search));
};
type InlineComboboxProps = {
children: React.ReactNode;
element: TElement;
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 = useEditorRef();
const inputRef = React.useRef<HTMLInputElement>(null);
const cursorState = useHTMLInputCursorState(inputRef);
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 as Record<string, unknown>).userId;
const currentUserId = editor.meta.userId;
// If no userId (backwards compatibility or non-Yjs), allow
if (!elementUserId) return true;
return elementUserId === currentUserId;
}, [editor.meta.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 insertPoint = React.useRef<Point | null>(null);
React.useEffect(() => {
const path = editor.api.findPath(element);
if (!path) return;
const point = editor.api.before(path);
if (!point) return;
const pointRef = editor.api.pointRef(point);
insertPoint.current = pointRef.current;
return () => {
pointRef.unref();
};
}, [editor, element]);
const { props: inputProps, removeInput } = useComboboxInput({
cancelInputOnBlur: true,
cursorState,
autoFocus: isCreator,
ref: inputRef,
onCancelInput: (cause) => {
if (cause !== "backspace") {
editor.tf.insertText(trigger + value, {
at: insertPoint?.current ?? undefined,
});
}
if (cause === "arrowLeft" || cause === "arrowRight") {
editor.tf.move({
distance: 1,
reverse: cause === "arrowLeft",
});
}
},
});
const [hasEmpty, setHasEmpty] = React.useState(false);
const contextValue: InlineComboboxContextValue = React.useMemo(
() => ({
filter,
inputProps,
inputRef,
removeInput,
setHasEmpty,
showTrigger,
trigger,
}),
[trigger, showTrigger, filter, inputProps, removeInput]
);
const store = useComboboxStore({
// open: ,
setValue: (newValue) => React.startTransition(() => setValue(newValue)),
});
const items = store.useState("items");
/**
* If there is no active ID and the list of items changes, select the first
* item.
*/
React.useEffect(() => {
if (items.length === 0) return;
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 = ({
className,
ref: propRef,
...props
}: React.HTMLAttributes<HTMLInputElement> & {
ref?: React.RefObject<HTMLInputElement | null>;
}) => {
const {
inputProps,
inputRef: contextRef,
showTrigger,
trigger,
} = React.useContext(InlineComboboxContext);
const store = useRequiredComboboxContext();
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 top-0 left-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
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(
"z-500 max-h-[288px] w-[300px] overflow-y-auto rounded-md bg-popover shadow-md",
className
)}
onKeyDownCapture={handleKeyDown}
{...props}
/>
</Portal>
);
};
const comboboxItemVariants = cva(
"relative mx-1 flex h-[28px] select-none items-center rounded-sm px-2 text-foreground text-sm 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 dark:hover:bg-neutral-700 dark:data-[active-item=true]:bg-neutral-700",
},
},
}
);
const InlineComboboxItem = ({
className,
focusEditor = true,
group,
keywords,
label,
onClick,
...props
}: {
focusEditor?: boolean;
group?: string;
keywords?: string[];
label?: string;
} & ComboboxItemProps &
Required<Pick<ComboboxItemProps, "value">>) => {
const { value } = props;
const { filter, removeInput } = React.useContext(InlineComboboxContext);
const store = useRequiredComboboxContext();
// Always call hook unconditionally; only use value if filter is active
const storeValue = store.useState("value");
const search = filter ? storeValue : "";
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 } = React.useContext(InlineComboboxContext);
const store = useRequiredComboboxContext();
const items = store.useState("items");
React.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;
function InlineComboboxGroup({ className, ...props }: React.ComponentProps<typeof ComboboxGroup>) {
return (
<ComboboxGroup
{...props}
className={cn("hidden not-last:border-b py-1.5 [&:has([role=option])]:block", className)}
/>
);
}
function InlineComboboxGroupLabel({
className,
...props
}: React.ComponentProps<typeof ComboboxGroupLabel>) {
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,
InlineComboboxRow,
};