返回 presentation-ai
inline-combobox.tsx
根目录 / src / components / plate / ui / inline-combobox.tsx
1 "use client";
2
3 import {
4 Combobox,
5 ComboboxGroup,
6 ComboboxGroupLabel,
7 ComboboxItem,
8 ComboboxPopover,
9 ComboboxProvider,
10 ComboboxRow,
11 Portal,
12 useComboboxContext,
13 useComboboxStore,
14 type ComboboxItemProps,
15 } from "@ariakit/react";
16 import { filterWords } from "@platejs/combobox";
17 import {
18 useComboboxInput,
19 useHTMLInputCursorState,
20 type UseComboboxInputResult,
21 } from "@platejs/combobox/react";
22 import { cva } from "class-variance-authority";
23 import { type Point, type TElement } from "platejs";
24 import { useComposedRef, useEditorRef } from "platejs/react";
25 import * as React from "react";
26
27 import { cn } from "@/lib/utils";
28
29 type FilterFn = (
30 item: { value: string; group?: string; keywords?: string[]; label?: string },
31 search: string,
32 ) => boolean;
33
34 interface InlineComboboxContextValue {
35 filter: FilterFn | false;
36 inputProps: UseComboboxInputResult["props"];
37 inputRef: React.RefObject<HTMLInputElement | null>;
38 removeInput: UseComboboxInputResult["removeInput"];
39 showTrigger: boolean;
40 trigger: string;
41 setHasEmpty: (hasEmpty: boolean) => void;
42 }
43
44 const InlineComboboxContext = React.createContext<InlineComboboxContextValue>(
45 null as unknown as InlineComboboxContextValue,
46 );
47
48 const defaultFilter: FilterFn = (
49 { group, keywords = [], label, value },
50 search,
51 ) => {
52 const uniqueTerms = new Set(
53 [value, ...keywords, group, label].filter(Boolean),
54 );
55
56 return Array.from(uniqueTerms).some((keyword) =>
57 filterWords(keyword!, search),
58 );
59 };
60
61 interface InlineComboboxProps {
62 children: React.ReactNode;
63 element: TElement;
64 trigger: string;
65 filter?: FilterFn | false;
66 hideWhenNoValue?: boolean;
67 showTrigger?: boolean;
68 value?: string;
69 setValue?: (value: string) => void;
70 }
71
72 const InlineCombobox = ({
73 children,
74 element,
75 filter = defaultFilter,
76 hideWhenNoValue = false,
77 setValue: setValueProp,
78 showTrigger = true,
79 trigger,
80 value: valueProp,
81 }: InlineComboboxProps) => {
82 const editor = useEditorRef();
83 const inputRef = React.useRef<HTMLInputElement>(null);
84 const cursorState = useHTMLInputCursorState(inputRef);
85
86 const [valueState, setValueState] = React.useState("");
87 const hasValueProp = valueProp !== undefined;
88 const value = hasValueProp ? valueProp : valueState;
89
90 const setValue = React.useCallback(
91 (newValue: string) => {
92 setValueProp?.(newValue);
93
94 if (!hasValueProp) {
95 setValueState(newValue);
96 }
97 },
98 [setValueProp, hasValueProp],
99 );
100
101 /**
102 * Track the point just before the input element so we know where to
103 * insertText if the combobox closes due to a selection change.
104 */
105 const insertPoint = React.useRef<Point | null>(null);
106
107 React.useEffect(() => {
108 const path = editor.api.findPath(element);
109
110 if (!path) return;
111
112 const point = editor.api.before(path);
113
114 if (!point) return;
115
116 const pointRef = editor.api.pointRef(point);
117 insertPoint.current = pointRef.current;
118
119 return () => {
120 pointRef.unref();
121 };
122 }, [editor, element]);
123
124 const { props: inputProps, removeInput } = useComboboxInput({
125 cancelInputOnBlur: true,
126 cursorState,
127 ref: inputRef,
128 onCancelInput: (cause) => {
129 if (cause !== "backspace") {
130 editor.tf.insertText(trigger + value, {
131 at: insertPoint?.current ?? undefined,
132 });
133 }
134 if (cause === "arrowLeft" || cause === "arrowRight") {
135 editor.tf.move({
136 distance: 1,
137 reverse: cause === "arrowLeft",
138 });
139 }
140 },
141 });
142
143 const [hasEmpty, setHasEmpty] = React.useState(false);
144
145 const contextValue: InlineComboboxContextValue = React.useMemo(
146 () => ({
147 filter,
148 inputProps,
149 inputRef,
150 removeInput,
151 setHasEmpty,
152 showTrigger,
153 trigger,
154 }),
155 [
156 trigger,
157 showTrigger,
158 filter,
159 inputRef,
160 inputProps,
161 removeInput,
162 setHasEmpty,
163 ],
164 );
165
166 const store = useComboboxStore({
167 // open: ,
168 setValue: (newValue) => React.startTransition(() => setValue(newValue)),
169 });
170
171 const items = store.useState("items");
172
173 /**
174 * If there is no active ID and the list of items changes, select the first
175 * item.
176 */
177 React.useEffect(() => {
178 if (!store.getState().activeId) {
179 store.setActiveId(store.first());
180 }
181 }, [items, store]);
182
183 return (
184 <span contentEditable={false}>
185 <ComboboxProvider
186 open={
187 (items.length > 0 || hasEmpty) &&
188 (!hideWhenNoValue || value.length > 0)
189 }
190 store={store}
191 >
192 <InlineComboboxContext.Provider value={contextValue}>
193 {children}
194 </InlineComboboxContext.Provider>
195 </ComboboxProvider>
196 </span>
197 );
198 };
199
200 const InlineComboboxInput = React.forwardRef<
201 HTMLInputElement,
202 React.HTMLAttributes<HTMLInputElement>
203 >(({ className, ...props }, propRef) => {
204 const {
205 inputProps,
206 inputRef: contextRef,
207 showTrigger,
208 trigger,
209 } = React.useContext(InlineComboboxContext);
210
211 const store = useComboboxContext()!;
212 const value = store.useState("value");
213
214 const ref = useComposedRef(propRef, contextRef);
215
216 /**
217 * To create an auto-resizing input, we render a visually hidden span
218 * containing the input value and position the input element on top of it.
219 * This works well for all cases except when input exceeds the width of the
220 * container.
221 */
222
223 return (
224 <>
225 {showTrigger && trigger}
226
227 <span className="relative min-h-lh">
228 <span
229 className="invisible overflow-hidden text-nowrap"
230 aria-hidden="true"
231 >
232 {value || "\u200B"}
233 </span>
234
235 <Combobox
236 ref={ref}
237 className={cn(
238 "absolute top-0 left-0 size-full bg-transparent outline-hidden",
239 className,
240 )}
241 value={value}
242 autoSelect
243 {...inputProps}
244 {...props}
245 />
246 </span>
247 </>
248 );
249 });
250
251 InlineComboboxInput.displayName = "InlineComboboxInput";
252
253 const InlineComboboxContent: typeof ComboboxPopover = ({
254 className,
255 ...props
256 }) => {
257 // Portal prevents CSS from leaking into popover
258 return (
259 <Portal>
260 <ComboboxPopover
261 className={cn(
262 "z-500 max-h-72 w-75 overflow-y-auto rounded-md bg-popover shadow-md",
263 className,
264 )}
265 {...props}
266 />
267 </Portal>
268 );
269 };
270
271 const comboboxItemVariants = cva(
272 "relative mx-1 flex h-7 items-center rounded-sm px-2 text-sm text-foreground outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
273 {
274 defaultVariants: {
275 interactive: true,
276 },
277 variants: {
278 interactive: {
279 false: "",
280 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",
281 },
282 },
283 },
284 );
285
286 const InlineComboboxItem = ({
287 className,
288 focusEditor = true,
289 group,
290 keywords,
291 label,
292 onClick,
293 ...props
294 }: {
295 focusEditor?: boolean;
296 group?: string;
297 keywords?: string[];
298 label?: string;
299 } & ComboboxItemProps &
300 Required<Pick<ComboboxItemProps, "value">>) => {
301 const { value } = props;
302
303 const { filter, removeInput } = React.useContext(InlineComboboxContext);
304
305 const store = useComboboxContext()!;
306
307 // Optimization: Do not subscribe to value if filter is false
308 // biome-ignore lint/correctness/useHookAtTopLevel: This code is at top level
309 const search = filter && store.useState("value");
310
311 const visible = React.useMemo(
312 () =>
313 !filter || filter({ group, keywords, label, value }, search as string),
314 [filter, group, keywords, label, value, search],
315 );
316
317 if (!visible) return null;
318
319 return (
320 <ComboboxItem
321 className={cn(comboboxItemVariants(), className)}
322 onClick={(event) => {
323 removeInput(focusEditor);
324 onClick?.(event);
325 }}
326 {...props}
327 />
328 );
329 };
330
331 const InlineComboboxEmpty = ({
332 children,
333 className,
334 }: React.HTMLAttributes<HTMLDivElement>) => {
335 const { setHasEmpty } = React.useContext(InlineComboboxContext);
336 const store = useComboboxContext()!;
337 const items = store.useState("items");
338
339 React.useEffect(() => {
340 setHasEmpty(true);
341
342 return () => {
343 setHasEmpty(false);
344 };
345 }, [setHasEmpty]);
346
347 if (items.length > 0) return null;
348
349 return (
350 <div
351 className={cn(comboboxItemVariants({ interactive: false }), className)}
352 >
353 {children}
354 </div>
355 );
356 };
357
358 const InlineComboboxRow = ComboboxRow;
359
360 function InlineComboboxGroup({
361 className,
362 ...props
363 }: React.ComponentProps<typeof ComboboxGroup>) {
364 return (
365 <ComboboxGroup
366 {...props}
367 className={cn(
368 "hidden py-1.5 not-last:border-b [&:has([role=option])]:block",
369 className,
370 )}
371 />
372 );
373 }
374
375 function InlineComboboxGroupLabel({
376 className,
377 ...props
378 }: React.ComponentProps<typeof ComboboxGroupLabel>) {
379 return (
380 <ComboboxGroupLabel
381 {...props}
382 className={cn(
383 "mt-1.5 mb-2 px-3 text-xs font-medium text-muted-foreground",
384 className,
385 )}
386 />
387 );
388 }
389
390 export {
391 InlineCombobox,
392 InlineComboboxContent,
393 InlineComboboxEmpty,
394 InlineComboboxGroup,
395 InlineComboboxGroupLabel,
396 InlineComboboxInput,
397 InlineComboboxItem,
398 InlineComboboxRow,
399 };
400
400 lines Plain Text