@enigmax/primitives 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/button-CaXaqG_K.d.ts +63 -0
  2. package/dist/chunk-2QFTRNAZ.js +88 -0
  3. package/dist/chunk-6BGBYUSZ.js +114 -0
  4. package/dist/chunk-AU3H5WIY.js +107 -0
  5. package/dist/chunk-F25CGNQC.js +19 -0
  6. package/dist/chunk-HC2ME5PU.js +168 -0
  7. package/dist/chunk-HS3X3XCW.js +43 -0
  8. package/dist/chunk-IIT6U7LM.js +249 -0
  9. package/dist/chunk-IXVMRVD4.js +96 -0
  10. package/dist/chunk-KJINGUQN.js +188 -0
  11. package/dist/chunk-LKA2UG4P.js +542 -0
  12. package/dist/chunk-MMQPZGSU.js +161 -0
  13. package/dist/chunk-OCMI7R6H.js +79 -0
  14. package/dist/chunk-QYMUIW5I.js +28 -0
  15. package/dist/chunk-S653GLSF.js +17 -0
  16. package/dist/chunk-SNYUBXWQ.js +149 -0
  17. package/dist/chunk-U3V4EHOB.js +41 -0
  18. package/dist/chunk-UOSSNUSC.js +309 -0
  19. package/dist/chunk-WSB655JB.js +47 -0
  20. package/dist/chunk-XNNQRA35.js +31 -0
  21. package/dist/chunk-XQHCZAPJ.js +102 -0
  22. package/dist/chunk-ZCUFYBPB.js +154 -0
  23. package/dist/chunk-ZWR2EXHQ.js +55 -0
  24. package/dist/flags-BBJc9unY.d.ts +133 -0
  25. package/dist/index-D-ARvWpQ.d.ts +306 -0
  26. package/dist/index-DNXPtY9X.d.ts +144 -0
  27. package/dist/index.d.ts +16 -496
  28. package/dist/index.js +9 -1
  29. package/dist/input-BwXjFenq.d.ts +77 -0
  30. package/dist/marquee-CJ3Uwy3E.d.ts +81 -0
  31. package/dist/network-D2LsBG_k.d.ts +39 -0
  32. package/dist/next/index.d.ts +20 -3
  33. package/dist/next/index.js +23 -3
  34. package/dist/notifications-BpVV6sel.d.ts +70 -0
  35. package/dist/password-3DRQYAYQ.js +2 -0
  36. package/dist/password-C8lG4Zm9.d.ts +71 -0
  37. package/dist/password-FB2CUEKJ.js +1 -0
  38. package/dist/react/button.d.ts +75 -0
  39. package/dist/react/button.js +4 -0
  40. package/dist/react/flag.d.ts +37 -0
  41. package/dist/react/flag.js +3 -0
  42. package/dist/react/index.d.ts +28 -286
  43. package/dist/react/index.js +22 -2
  44. package/dist/react/input.d.ts +4 -0
  45. package/dist/react/input.js +3 -0
  46. package/dist/react/marquee.d.ts +44 -0
  47. package/dist/react/marquee.js +3 -0
  48. package/dist/react/network.d.ts +20 -0
  49. package/dist/react/network.js +3 -0
  50. package/dist/react/notifications.d.ts +17 -0
  51. package/dist/react/notifications.js +3 -0
  52. package/dist/react/palette.d.ts +3 -0
  53. package/dist/react/palette.js +4 -0
  54. package/dist/react/relative-time.d.ts +21 -0
  55. package/dist/react/relative-time.js +3 -0
  56. package/dist/react/search.d.ts +30 -0
  57. package/dist/react/search.js +3 -0
  58. package/dist/react/slot.d.ts +47 -0
  59. package/dist/react/slot.js +2 -0
  60. package/dist/react/toast.d.ts +40 -0
  61. package/dist/react/toast.js +4 -0
  62. package/dist/react-router/index.d.ts +20 -3
  63. package/dist/react-router/index.js +23 -3
  64. package/dist/relative-time-YpRTG7YH.d.ts +106 -0
  65. package/dist/search-P4OHCRXD.js +50 -0
  66. package/package.json +53 -1
  67. package/registry.json +235 -16
  68. package/src/core/flags.ts +266 -0
  69. package/src/core/input-icons.ts +34 -0
  70. package/src/core/input.ts +5 -21
  71. package/src/core/palette.ts +0 -0
  72. package/src/index.ts +18 -0
  73. package/src/react/button.tsx +70 -4
  74. package/src/react/flag.tsx +70 -0
  75. package/src/react/index.ts +52 -5
  76. package/src/react/input/icon.tsx +17 -0
  77. package/src/react/input/index.tsx +307 -0
  78. package/src/react/input/password.tsx +174 -0
  79. package/src/react/input/search.tsx +82 -0
  80. package/src/react/input/types.ts +146 -0
  81. package/src/react/input/write-value.ts +18 -0
  82. package/src/react/palette/context.ts +60 -0
  83. package/src/react/palette/index.tsx +66 -0
  84. package/src/react/palette/root.tsx +621 -0
  85. package/src/react/slot.tsx +91 -0
  86. package/src/react/use-button.ts +3 -1
  87. package/dist/chunk-53DQY6LP.js +0 -1164
  88. package/dist/chunk-U2KYYMBJ.js +0 -724
  89. package/src/react/input.tsx +0 -429
@@ -0,0 +1,621 @@
1
+ "use client";
2
+
3
+ import { Slot } from "@/react/slot";
4
+ import { createPortal } from "react-dom";
5
+ import { PaletteContext, usePaletteContext, type PaletteRow } from "@/react/palette/context";
6
+ import { createSearch, type SearchInstance, type SearchMatch, type SearchOptions } from "@/core/search";
7
+ import { createRecentStore, groupRows, moveActive, shortcutLabel, isPaletteShortcut, type RecentEntry, type PaletteKey } from "@/core/palette";
8
+ import {
9
+ useCallback, useEffect, useId, useMemo, useRef, useState,
10
+ type ComponentPropsWithoutRef, type KeyboardEvent as ReactKeyboardEvent, type ReactNode
11
+ } from "react";
12
+
13
+ /**
14
+ * The command palette: the panel that opens on Ctrl/Cmd+K, searches as you type, remembers
15
+ * what was searched before, and is driven entirely from the keyboard.
16
+ *
17
+ * It is a DIALOG, which is why it is its own component rather than a prop on `<Input>`: a
18
+ * palette is a trigger, an overlay, a focus trap, a listbox and a footer, and the thing that
19
+ * makes those usable together is composition. Radix draws the line in the same place, and
20
+ * for the same reason - one `<input>` is a component, a widget made of parts is an anatomy:
21
+ *
22
+ * ```tsx
23
+ * <SearchPalette.Root items={docs} keys={["title"]} onSelect={open}>
24
+ * <SearchPalette.Trigger />
25
+ * <SearchPalette.Content>
26
+ * <SearchPalette.Field placeholder="Search the docs" />
27
+ * <SearchPalette.List />
28
+ * <SearchPalette.Footer />
29
+ * </SearchPalette.Content>
30
+ * </SearchPalette.Root>
31
+ * ```
32
+ *
33
+ * `<SearchPalette>` on its own renders exactly that, for the case that needs no arguing
34
+ * with. Every part takes `asChild`, so any of them can be your own element instead.
35
+ *
36
+ * The keyboard sequence is ONE flat list across every group - a group boundary is invisible
37
+ * to the arrow keys - and the highlight wraps, because in a short list the row after the
38
+ * last one is the first, and a key that does nothing at the end reads as a frozen panel.
39
+ */
40
+
41
+ export interface PaletteSection<Item> {
42
+ label: string;
43
+ items: Item[];
44
+ /** Shown whatever the query, e.g. a list of commands. Off by default. */
45
+ always?: boolean;
46
+ }
47
+
48
+ export interface PaletteRootProps<Item> {
49
+ /** What to search. */
50
+ items?: Item[];
51
+ /** Fields to read. Dotted paths work. */
52
+ keys?: SearchOptions<Item>["keys"];
53
+ /** Fuse.js's constructor, for fuzzy matching. Without it, a substring matcher runs. */
54
+ fuse?: SearchOptions<Item>["fuse"];
55
+ fuseOptions?: SearchOptions<Item>["fuseOptions"];
56
+ /** Replaces the engine outright. */
57
+ matcher?: SearchOptions<Item>["matcher"];
58
+ /** ms after the last keystroke. Default 120: a palette should feel immediate. */
59
+ delay?: number;
60
+ limit?: number;
61
+ /** Which group a result belongs under. One group when this is left out. */
62
+ groupBy?: (item: Item) => string;
63
+ /** The row's text. Falls back to the first string field. */
64
+ labelOf?: (item: Item) => string;
65
+ descriptionOf?: (item: Item) => string | undefined;
66
+ /** Rows the app always offers - commands, shortcuts, "create new". */
67
+ sections?: PaletteSection<Item>[];
68
+ /** What running a row does. Closing afterwards is the default; return false to stay. */
69
+ onSelect?: (item: Item) => void | boolean;
70
+ /** Remember what was searched, in this browser. On by default. */
71
+ recents?: boolean;
72
+ recentsKey?: string;
73
+ recentsLimit?: number;
74
+ /** The key that opens it, with Ctrl or Cmd. `null` binds nothing. Default "k". */
75
+ shortcut?: string | null;
76
+ /** Controlled open state. Leave both out for an uncontrolled palette. */
77
+ open?: boolean;
78
+ onOpenChange?: (open: boolean) => void;
79
+ defaultOpen?: boolean;
80
+ /** Wording for the empty group heading of ungrouped results. Default "Results". */
81
+ resultsLabel?: string;
82
+ recentsLabel?: string;
83
+ children?: ReactNode;
84
+ }
85
+
86
+ const RECENTS_SHOWN = 5;
87
+
88
+ function firstString(item: unknown): string {
89
+ if (typeof item === "string") return item;
90
+ if (item && typeof item === "object") {
91
+ for (const value of Object.values(item as Record<string, unknown>)) {
92
+ if (typeof value === "string" && value.trim()) return value;
93
+ }
94
+ }
95
+ return "";
96
+ }
97
+
98
+ export function PaletteRoot<Item>({
99
+ items,
100
+ keys,
101
+ fuse,
102
+ fuseOptions,
103
+ matcher,
104
+ delay = 120,
105
+ limit = 40,
106
+ groupBy,
107
+ labelOf = firstString,
108
+ descriptionOf,
109
+ sections = [],
110
+ onSelect,
111
+ recents = true,
112
+ recentsKey,
113
+ recentsLimit = 8,
114
+ shortcut = "k",
115
+ open: openProp,
116
+ onOpenChange,
117
+ defaultOpen = false,
118
+ resultsLabel = "Results",
119
+ recentsLabel = "Recent",
120
+ children
121
+ }: PaletteRootProps<Item>): ReactNode {
122
+ const [ownOpen, setOwnOpen] = useState(defaultOpen);
123
+ const controlled = openProp !== undefined;
124
+ const open = controlled ? openProp : ownOpen;
125
+
126
+ const [query, setQuery] = useState("");
127
+ const [active, setActive] = useState(0);
128
+ const [results, setResults] = useState<SearchMatch<Item>[]>([]);
129
+ const [remembered, setRemembered] = useState<RecentEntry[]>([]);
130
+ const [busy, setBusy] = useState(false);
131
+
132
+ const triggerRef = useRef<HTMLElement | null>(null);
133
+ const fieldRef = useRef<HTMLInputElement | null>(null);
134
+ const base = useId();
135
+ const ids = useMemo(() => ({ field: `${base}-field`, list: `${base}-list`, title: `${base}-title` }), [base]);
136
+
137
+ const store = useMemo(
138
+ () => createRecentStore({ key: recentsKey, limit: recentsLimit }),
139
+ [recentsKey, recentsLimit]
140
+ );
141
+
142
+ const setOpen = useCallback((next: boolean) => {
143
+ if (!controlled) setOwnOpen(next);
144
+ onOpenChange?.(next);
145
+ }, [controlled, onOpenChange]);
146
+
147
+ /* -------- the engine -------- */
148
+
149
+ const engine = useMemo<SearchInstance<Item>>(() => createSearch<Item>({
150
+ items,
151
+ keys,
152
+ fuse,
153
+ fuseOptions,
154
+ matcher,
155
+ debounce: delay,
156
+ limit,
157
+ onResults: (next) => {
158
+ setResults(next);
159
+ setBusy(false);
160
+ }
161
+ // Built once: the engine indexes on construction, so rebuilding it per render would
162
+ // re-index the whole corpus on every keystroke.
163
+ // eslint-disable-next-line react-hooks/exhaustive-deps
164
+ }), []);
165
+
166
+ useEffect(() => () => engine.destroy(), [engine]);
167
+ useEffect(() => { engine.setItems(items ?? []); }, [engine, items]);
168
+ useEffect(() => { engine.update({ keys, fuse, fuseOptions, matcher, debounce: delay, limit }); }, [engine, keys, fuse, fuseOptions, matcher, delay, limit]);
169
+
170
+ /* -------- opening and closing -------- */
171
+
172
+ // Read on OPEN rather than on mount: storage is shared with every other tab, and a
173
+ // palette that read it once would show a list that is already out of date.
174
+ //
175
+ // Opening also CLEARS the query. A palette that comes back holding the last search is
176
+ // a palette you have to empty before you can use it, and it hides the one thing an
177
+ // empty query is for - what was searched before, which is the shortcut on the second
178
+ // visit. The engine is cleared with it, or the old results would outlive their query.
179
+ useEffect(() => {
180
+ if (!open) return;
181
+ setRemembered(recents ? store.list() : []);
182
+ setQuery("");
183
+ engine.searchNow("");
184
+ setActive(0);
185
+ }, [open, recents, store, engine]);
186
+
187
+ useEffect(() => {
188
+ if (shortcut === null || typeof window === "undefined") return;
189
+ const onKeyDown = (event: KeyboardEvent): void => {
190
+ if (!isPaletteShortcut(event, shortcut)) return;
191
+ // Taken from the browser deliberately: Ctrl+K is a browser shortcut in some
192
+ // builds, and a palette that only sometimes opens is worse than one that never
193
+ // does. The page has the focus, so this is the page's key.
194
+ event.preventDefault();
195
+ setOpen(!open);
196
+ };
197
+ window.addEventListener("keydown", onKeyDown);
198
+ return () => window.removeEventListener("keydown", onKeyDown);
199
+ }, [shortcut, open, setOpen]);
200
+
201
+ /* -------- rows -------- */
202
+
203
+ const rows = useMemo<PaletteRow<Item>[]>(() => {
204
+ const trimmed = query.trim();
205
+ const out: PaletteRow<Item>[] = [];
206
+
207
+ if (!trimmed && recents && remembered.length) {
208
+ remembered.slice(0, RECENTS_SHOWN).forEach((entry, index) => {
209
+ out.push({
210
+ id: `recent-${index}`,
211
+ kind: "recent",
212
+ group: recentsLabel,
213
+ recent: entry,
214
+ label: entry.label ?? entry.term
215
+ });
216
+ });
217
+ }
218
+
219
+ for (const section of sections) {
220
+ const pool = section.always || !trimmed
221
+ ? section.items
222
+ : section.items.filter((item) => labelOf(item).toLowerCase().includes(trimmed.toLowerCase()));
223
+ pool.forEach((item, index) => {
224
+ out.push({
225
+ id: `section-${section.label}-${index}`,
226
+ kind: "action",
227
+ group: section.label,
228
+ item,
229
+ label: labelOf(item),
230
+ description: descriptionOf?.(item)
231
+ });
232
+ });
233
+ }
234
+
235
+ results.forEach((match, index) => {
236
+ out.push({
237
+ id: `result-${index}`,
238
+ kind: "item",
239
+ group: groupBy?.(match.item) ?? resultsLabel,
240
+ item: match.item,
241
+ match,
242
+ label: labelOf(match.item),
243
+ description: descriptionOf?.(match.item)
244
+ });
245
+ });
246
+
247
+ return out;
248
+ }, [query, recents, remembered, recentsLabel, sections, results, groupBy, labelOf, descriptionOf, resultsLabel]);
249
+
250
+ // A shorter list must never leave the highlight past its end, or Enter opens nothing.
251
+ useEffect(() => {
252
+ setActive((current) => (current < rows.length ? current : 0));
253
+ }, [rows.length]);
254
+
255
+ const select = useCallback((row: PaletteRow<Item> | undefined) => {
256
+ if (!row) return;
257
+
258
+ if (row.kind === "recent" && row.recent) {
259
+ // A remembered query goes back in the field and runs again; a remembered RESULT
260
+ // is opened. The difference is whether it had somewhere to go.
261
+ if (!row.recent.href) {
262
+ setQuery(row.recent.term);
263
+ setBusy(true);
264
+ engine.search(row.recent.term);
265
+ fieldRef.current?.focus();
266
+ return;
267
+ }
268
+ }
269
+
270
+ const stay = row.onSelect ? row.onSelect() : row.item !== undefined ? onSelect?.(row.item) : undefined;
271
+ if (recents) {
272
+ setRemembered(store.remember({
273
+ term: query.trim(),
274
+ label: row.label,
275
+ scope: row.group
276
+ }));
277
+ }
278
+ if (stay !== false) setOpen(false);
279
+ }, [engine, onSelect, query, recents, setOpen, store]);
280
+
281
+ const handleQuery = useCallback((next: string) => {
282
+ setQuery(next);
283
+ setActive(0);
284
+ setBusy(Boolean(next.trim()));
285
+ engine.search(next);
286
+ }, [engine]);
287
+
288
+ const value = useMemo(() => ({
289
+ open,
290
+ setOpen,
291
+ query,
292
+ setQuery: handleQuery,
293
+ rows,
294
+ active,
295
+ setActive,
296
+ select,
297
+ clearRecents: () => { store.clear(); setRemembered([]); },
298
+ forgetRecent: (entry: RecentEntry) => setRemembered(store.forget(entry)),
299
+ busy,
300
+ ids,
301
+ shortcutLabel: shortcutLabel(shortcut ?? "k"),
302
+ triggerRef,
303
+ fieldRef,
304
+ rowId: (row: PaletteRow<Item>) => `${ids.list}-${row.id}`
305
+ }), [open, setOpen, query, handleQuery, rows, active, select, busy, ids, shortcut, store]);
306
+
307
+ return <PaletteContext.Provider value={value as never}>{children}</PaletteContext.Provider>;
308
+ }
309
+
310
+ /* ------------------------------------------------------------------ parts */
311
+
312
+ export interface PaletteTriggerProps extends ComponentPropsWithoutRef<"button"> {
313
+ asChild?: boolean;
314
+ }
315
+
316
+ /** Opens the palette. Carries the shortcut in `aria-keyshortcuts`, so it is announced. */
317
+ export function PaletteTrigger({ asChild = false, children, onClick, ...props }: PaletteTriggerProps): ReactNode {
318
+ const palette = usePaletteContext("SearchPalette.Trigger");
319
+ const Tag = asChild ? Slot : "button";
320
+ return (
321
+ <Tag
322
+ {...(asChild ? {} : { type: "button" as const })}
323
+ {...props}
324
+ ref={palette.triggerRef as never}
325
+ data-enigma-palette-trigger=""
326
+ aria-haspopup="dialog"
327
+ aria-expanded={palette.open}
328
+ aria-keyshortcuts="Control+K Meta+K"
329
+ onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
330
+ onClick?.(event);
331
+ if (!event.defaultPrevented) palette.setOpen(true);
332
+ }}
333
+ >
334
+ {children ?? (
335
+ <>
336
+ <span data-enigma-palette-trigger-label="">Search</span>
337
+ <kbd data-enigma-palette-trigger-key="">{palette.shortcutLabel}</kbd>
338
+ </>
339
+ )}
340
+ </Tag>
341
+ );
342
+ }
343
+
344
+ export interface PaletteContentProps extends ComponentPropsWithoutRef<"div"> {
345
+ /** Accessible name for the dialog. Rendered for screen readers only. */
346
+ title?: string;
347
+ /** Render into `document.body`. On by default: a palette inside a clipped or
348
+ * transformed ancestor is a panel nobody can see. */
349
+ portal?: boolean;
350
+ /** Rendered behind the panel. Pass null for no overlay of ours. */
351
+ overlayProps?: ComponentPropsWithoutRef<"div"> | null;
352
+ }
353
+
354
+ /**
355
+ * The panel: overlay, focus trap, Escape, scroll lock, and focus handed back to the trigger.
356
+ *
357
+ * Mounted only while open, so nothing of the palette is in the document (or in the tab
358
+ * order) the rest of the time.
359
+ */
360
+ export function PaletteContent({ title = "Search", portal = true, overlayProps, children, ...props }: PaletteContentProps): ReactNode {
361
+ const palette = usePaletteContext("SearchPalette.Content");
362
+ const panelRef = useRef<HTMLDivElement | null>(null);
363
+ const [mounted, setMounted] = useState(false);
364
+
365
+ // A portal has no server render: `document` does not exist there, and rendering the
366
+ // panel into the tree instead would put it in the wrong place for one frame.
367
+ useEffect(() => setMounted(true), []);
368
+
369
+ useEffect(() => {
370
+ if (!palette.open || typeof document === "undefined") return;
371
+
372
+ const previous = document.activeElement as HTMLElement | null;
373
+ const body = document.body;
374
+ const overflow = body.style.overflow;
375
+ // The page behind a modal must not scroll under it, and it must not shift either:
376
+ // hiding the scrollbar without compensating for its width moves the whole layout.
377
+ const gap = window.innerWidth - document.documentElement.clientWidth;
378
+ const padding = body.style.paddingRight;
379
+ body.style.overflow = "hidden";
380
+ if (gap > 0) body.style.paddingRight = `${gap}px`;
381
+
382
+ const onKeyDown = (event: KeyboardEvent): void => {
383
+ if (event.key === "Escape") {
384
+ event.preventDefault();
385
+ palette.setOpen(false);
386
+ return;
387
+ }
388
+ if (event.key !== "Tab") return;
389
+ // The trap: Tab cycles inside the panel. Without it the next Tab lands on the
390
+ // page behind, where a click does nothing and nothing says why.
391
+ const focusable = panelRef.current?.querySelectorAll<HTMLElement>(
392
+ 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
393
+ );
394
+ if (!focusable || focusable.length === 0) return;
395
+ const first = focusable[0];
396
+ const last = focusable[focusable.length - 1];
397
+ if (!event.shiftKey && document.activeElement === last) {
398
+ event.preventDefault();
399
+ first.focus();
400
+ } else if (event.shiftKey && document.activeElement === first) {
401
+ event.preventDefault();
402
+ last.focus();
403
+ }
404
+ };
405
+
406
+ document.addEventListener("keydown", onKeyDown);
407
+ return () => {
408
+ document.removeEventListener("keydown", onKeyDown);
409
+ body.style.overflow = overflow;
410
+ body.style.paddingRight = padding;
411
+ // Back where it came from, so closing with Escape does not drop the visitor at
412
+ // the top of the document.
413
+ (palette.triggerRef.current ?? previous)?.focus?.();
414
+ };
415
+ }, [palette.open, palette.setOpen, palette.triggerRef]);
416
+
417
+ if (!palette.open) return null;
418
+
419
+ const panel = (
420
+ <div data-enigma-palette-portal="">
421
+ {overlayProps !== null && (
422
+ <div
423
+ {...overlayProps}
424
+ data-enigma-palette-overlay=""
425
+ // A click outside is a dismiss, and it is not a keyboard event, so it
426
+ // never reaches the Escape handler.
427
+ onClick={(event) => {
428
+ overlayProps?.onClick?.(event);
429
+ if (!event.defaultPrevented) palette.setOpen(false);
430
+ }}
431
+ />
432
+ )}
433
+ <div
434
+ {...props}
435
+ ref={panelRef}
436
+ role="dialog"
437
+ aria-modal="true"
438
+ aria-labelledby={palette.ids.title}
439
+ data-enigma-palette-content=""
440
+ >
441
+ <h2 id={palette.ids.title} data-enigma-palette-title="">{title}</h2>
442
+ {children}
443
+ </div>
444
+ </div>
445
+ );
446
+
447
+ if (!portal) return panel;
448
+ if (!mounted || typeof document === "undefined") return null;
449
+ return createPortal(panel, document.body);
450
+ }
451
+
452
+ export interface PaletteFieldProps extends Omit<ComponentPropsWithoutRef<"input">, "value" | "onChange"> {
453
+ asChild?: boolean;
454
+ }
455
+
456
+ /**
457
+ * The query field.
458
+ *
459
+ * A `combobox` that keeps the caret while the arrows move a highlight somewhere else -
460
+ * which is exactly what `aria-activedescendant` is for. Without it a screen reader hears
461
+ * nothing move, because focus never leaves the field.
462
+ */
463
+ export function PaletteField({ asChild = false, onKeyDown, ...props }: PaletteFieldProps): ReactNode {
464
+ const palette = usePaletteContext("SearchPalette.Field");
465
+ const Tag = asChild ? Slot : "input";
466
+ const activeRow = palette.rows[palette.active];
467
+
468
+ return (
469
+ <Tag
470
+ {...props}
471
+ ref={palette.fieldRef as never}
472
+ id={palette.ids.field}
473
+ type="search"
474
+ value={palette.query}
475
+ // The palette is opened by a keystroke and closed by one; landing anywhere but
476
+ // the field would make the first thing typed go missing.
477
+ autoFocus
478
+ autoComplete="off"
479
+ autoCorrect="off"
480
+ autoCapitalize="none"
481
+ spellCheck={false}
482
+ enterKeyHint="go"
483
+ role="combobox"
484
+ aria-expanded
485
+ aria-autocomplete="list"
486
+ aria-controls={palette.ids.list}
487
+ aria-activedescendant={activeRow ? palette.rowId(activeRow) : undefined}
488
+ data-enigma-palette-field=""
489
+ onChange={(event: React.ChangeEvent<HTMLInputElement>) => palette.setQuery(event.target.value)}
490
+ onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
491
+ onKeyDown?.(event);
492
+ if (event.defaultPrevented) return;
493
+ const key = event.key as PaletteKey | "Enter";
494
+ if (key === "Enter") {
495
+ event.preventDefault();
496
+ palette.select(palette.rows[palette.active]);
497
+ return;
498
+ }
499
+ if (["ArrowDown", "ArrowUp", "Home", "End", "PageDown", "PageUp"].includes(key)) {
500
+ event.preventDefault();
501
+ palette.setActive(moveActive(palette.active, palette.rows.length, key as PaletteKey));
502
+ }
503
+ }}
504
+ />
505
+ );
506
+ }
507
+
508
+ export interface PaletteListProps<Item> extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
509
+ /** Render one row. The default prints its label, which is enough to be usable. */
510
+ children?: (row: PaletteRow<Item>, state: { active: boolean; index: number; }) => ReactNode;
511
+ /** Rendered when there is nothing to show. */
512
+ empty?: ReactNode;
513
+ /** The group heading. Pass null for a flat list with no headings. */
514
+ heading?: ((label: string) => ReactNode) | null;
515
+ }
516
+
517
+ /** The results, grouped, with one flat keyboard sequence running through them. */
518
+ export function PaletteList<Item>({ children, empty, heading, ...props }: PaletteListProps<Item>): ReactNode {
519
+ const palette = usePaletteContext<Item>("SearchPalette.List");
520
+ const listRef = useRef<HTMLDivElement | null>(null);
521
+ const groups = useMemo(() => groupRows(palette.rows, (row) => row.group), [palette.rows]);
522
+
523
+ // Keeps the highlight in view when it is moved by the keyboard. `nearest` so the list
524
+ // does not jump a whole panel for a row that was already half visible.
525
+ useEffect(() => {
526
+ listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: "nearest" });
527
+ }, [palette.active, palette.rows]);
528
+
529
+ return (
530
+ <div
531
+ {...props}
532
+ ref={listRef}
533
+ id={palette.ids.list}
534
+ role="listbox"
535
+ aria-label="Results"
536
+ data-enigma-palette-list=""
537
+ >
538
+ {palette.rows.length === 0
539
+ ? empty ?? <p data-enigma-palette-empty="">{palette.query.trim() ? `Nothing matches "${palette.query.trim()}".` : "Type to search."}</p>
540
+ : groups.map((group) => (
541
+ <div key={group.label} role="group" aria-label={group.label} data-enigma-palette-group="">
542
+ {heading !== null && (
543
+ heading?.(group.label) ?? (
544
+ // The group carries the name already, so announcing the
545
+ // heading again would only repeat it.
546
+ <p data-enigma-palette-group-label="" aria-hidden="true">{group.label}</p>
547
+ )
548
+ )}
549
+ {group.rows.map(({ row, position }) => (
550
+ <PaletteItem
551
+ key={row.id}
552
+ row={row}
553
+ index={position}
554
+ >
555
+ {children?.(row, { active: position === palette.active, index: position })}
556
+ </PaletteItem>
557
+ ))}
558
+ </div>
559
+ ))}
560
+ </div>
561
+ );
562
+ }
563
+
564
+ export interface PaletteItemProps<Item> extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
565
+ row: PaletteRow<Item>;
566
+ index: number;
567
+ children?: ReactNode;
568
+ }
569
+
570
+ /**
571
+ * One row.
572
+ *
573
+ * The pointer MOVES the highlight rather than running a second one of its own: two
574
+ * highlights on screen is the thing that makes a palette feel unpredictable, because Enter
575
+ * then opens the row the mouse is not on.
576
+ */
577
+ export function PaletteItem<Item>({ row, index, children, ...props }: PaletteItemProps<Item>): ReactNode {
578
+ const palette = usePaletteContext<Item>("SearchPalette.Item");
579
+ const active = index === palette.active;
580
+ return (
581
+ <div
582
+ {...props}
583
+ id={palette.rowId(row)}
584
+ role="option"
585
+ aria-selected={active}
586
+ data-enigma-palette-item=""
587
+ data-kind={row.kind}
588
+ data-active={active ? "true" : undefined}
589
+ onMouseMove={() => { if (!active) palette.setActive(index); }}
590
+ onClick={() => palette.select(row)}
591
+ >
592
+ {children ?? (
593
+ <>
594
+ <span data-enigma-palette-item-label="">{row.label}</span>
595
+ {row.description && <span data-enigma-palette-item-description="">{row.description}</span>}
596
+ </>
597
+ )}
598
+ </div>
599
+ );
600
+ }
601
+
602
+ export interface PaletteFooterProps extends ComponentPropsWithoutRef<"div"> {
603
+ /** Replace the hints. The default names the three keys that actually matter. */
604
+ hints?: ReactNode;
605
+ }
606
+
607
+ /** The strip along the bottom that says which keys do what. */
608
+ export function PaletteFooter({ hints, children, ...props }: PaletteFooterProps): ReactNode {
609
+ usePaletteContext("SearchPalette.Footer");
610
+ return (
611
+ <div {...props} data-enigma-palette-footer="">
612
+ {children ?? hints ?? (
613
+ <>
614
+ <span data-enigma-palette-hint=""><kbd>up</kbd><kbd>down</kbd> to move</span>
615
+ <span data-enigma-palette-hint=""><kbd>enter</kbd> to open</span>
616
+ <span data-enigma-palette-hint=""><kbd>esc</kbd> to close</span>
617
+ </>
618
+ )}
619
+ </div>
620
+ );
621
+ }