@enigmax/primitives 0.17.0 → 0.19.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 (97) 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-UZFEEFMF.js → chunk-D5A2ZMAG.js} +34 -1
  6. package/dist/chunk-DTWZDONY.js +99 -0
  7. package/dist/chunk-F25CGNQC.js +19 -0
  8. package/dist/chunk-FWVWX67R.js +462 -0
  9. package/dist/chunk-HC2ME5PU.js +168 -0
  10. package/dist/chunk-HS3X3XCW.js +43 -0
  11. package/dist/chunk-IXVMRVD4.js +96 -0
  12. package/dist/chunk-KJINGUQN.js +188 -0
  13. package/dist/chunk-MMQPZGSU.js +161 -0
  14. package/dist/chunk-OCMI7R6H.js +79 -0
  15. package/dist/chunk-QQFNAKMY.js +47 -0
  16. package/dist/chunk-QYMUIW5I.js +28 -0
  17. package/dist/chunk-R4ZAEE7V.js +249 -0
  18. package/dist/chunk-S653GLSF.js +17 -0
  19. package/dist/chunk-SNYUBXWQ.js +149 -0
  20. package/dist/chunk-U3V4EHOB.js +41 -0
  21. package/dist/chunk-UOSSNUSC.js +309 -0
  22. package/dist/chunk-XNNQRA35.js +31 -0
  23. package/dist/chunk-XQHCZAPJ.js +102 -0
  24. package/dist/chunk-ZCUFYBPB.js +154 -0
  25. package/dist/chunk-ZWR2EXHQ.js +55 -0
  26. package/dist/flags-BBJc9unY.d.ts +133 -0
  27. package/dist/index-dTdAbOWl.d.ts +144 -0
  28. package/dist/index.d.ts +18 -603
  29. package/dist/index.js +11 -2
  30. package/dist/input-BwXjFenq.d.ts +77 -0
  31. package/dist/marquee-CJ3Uwy3E.d.ts +81 -0
  32. package/dist/network-D2LsBG_k.d.ts +39 -0
  33. package/dist/next/index.d.ts +22 -4
  34. package/dist/next/index.js +25 -4
  35. package/dist/notifications-BpVV6sel.d.ts +70 -0
  36. package/dist/palette-D7iuQh_T.d.ts +86 -0
  37. package/dist/password-3DRQYAYQ.js +2 -0
  38. package/dist/password-C8lG4Zm9.d.ts +71 -0
  39. package/dist/password-FB2CUEKJ.js +1 -0
  40. package/dist/react/button.d.ts +75 -0
  41. package/dist/react/button.js +4 -0
  42. package/dist/react/flag.d.ts +37 -0
  43. package/dist/react/flag.js +3 -0
  44. package/dist/react/index.d.ts +29 -312
  45. package/dist/react/index.js +24 -3
  46. package/dist/react/input.d.ts +4 -0
  47. package/dist/react/input.js +3 -0
  48. package/dist/react/marquee.d.ts +44 -0
  49. package/dist/react/marquee.js +3 -0
  50. package/dist/react/network.d.ts +20 -0
  51. package/dist/react/network.js +3 -0
  52. package/dist/react/notifications.d.ts +17 -0
  53. package/dist/react/notifications.js +3 -0
  54. package/dist/react/palette.d.ts +231 -0
  55. package/dist/react/palette.js +5 -0
  56. package/dist/react/relative-time.d.ts +21 -0
  57. package/dist/react/relative-time.js +3 -0
  58. package/dist/react/search.d.ts +30 -0
  59. package/dist/react/search.js +3 -0
  60. package/dist/react/slot.d.ts +47 -0
  61. package/dist/react/slot.js +2 -0
  62. package/dist/react/toast.d.ts +40 -0
  63. package/dist/react/toast.js +4 -0
  64. package/dist/react-router/index.d.ts +22 -4
  65. package/dist/react-router/index.js +25 -4
  66. package/dist/relative-time-YpRTG7YH.d.ts +106 -0
  67. package/dist/search/index.d.ts +2 -2
  68. package/dist/search/index.js +1 -1
  69. package/dist/{search-CsO3L1Lw.d.ts → search-DXxY8SEH.d.ts} +15 -1
  70. package/dist/search-UQEXAPQB.js +50 -0
  71. package/package.json +54 -3
  72. package/recipes/input/styles.css +60 -20
  73. package/recipes/palette/styles.css +243 -0
  74. package/registry.json +186 -18
  75. package/src/core/flags.ts +88 -42
  76. package/src/core/input-icons.ts +34 -0
  77. package/src/core/input.ts +5 -21
  78. package/src/core/palette.ts +0 -0
  79. package/src/core/search.ts +60 -0
  80. package/src/index.ts +19 -3
  81. package/src/react/button.tsx +70 -4
  82. package/src/react/flag.tsx +18 -6
  83. package/src/react/index.ts +50 -5
  84. package/src/react/input/icon.tsx +17 -0
  85. package/src/react/input/index.tsx +307 -0
  86. package/src/react/input/password.tsx +174 -0
  87. package/src/react/input/search.tsx +82 -0
  88. package/src/react/input/types.ts +146 -0
  89. package/src/react/input/write-value.ts +18 -0
  90. package/src/react/palette/context.ts +60 -0
  91. package/src/react/palette/index.tsx +66 -0
  92. package/src/react/palette/root.tsx +661 -0
  93. package/src/react/slot.tsx +91 -0
  94. package/src/react/use-button.ts +3 -1
  95. package/dist/chunk-4GLYRF4B.js +0 -1259
  96. package/dist/chunk-JFNND6P4.js +0 -749
  97. package/src/react/input.tsx +0 -429
@@ -0,0 +1,661 @@
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, subsequenceMatcher, 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
+ /**
150
+ * A palette ranks by SUBSEQUENCE unless told otherwise: "qgate" has to find "Quality
151
+ * gate" and "plyg" has to find "Playground", which a substring filter cannot do. A plain
152
+ * search field keeps the substring matcher, where a typo should fail rather than quietly
153
+ * match something four words away.
154
+ *
155
+ * Computed ONCE and used by both the constructor and the update below. Passing the raw
156
+ * prop to `update` instead is how the first version lost it: the effect overwrote the
157
+ * default with `undefined` on the very next render, and the palette silently went back
158
+ * to substring matching.
159
+ */
160
+ const ranking = useMemo(
161
+ () => matcher ?? (fuse ? undefined : subsequenceMatcher<Item>(keys ?? [])),
162
+ [matcher, fuse, keys]
163
+ );
164
+
165
+ const engine = useMemo<SearchInstance<Item>>(() => createSearch<Item>({
166
+ items,
167
+ keys,
168
+ fuse,
169
+ fuseOptions,
170
+ matcher: ranking,
171
+ debounce: delay,
172
+ limit,
173
+ onResults: (next) => {
174
+ setResults(next);
175
+ setBusy(false);
176
+ }
177
+ // Built once: the engine indexes on construction, so rebuilding it per render would
178
+ // re-index the whole corpus on every keystroke.
179
+ // eslint-disable-next-line react-hooks/exhaustive-deps
180
+ }), []);
181
+
182
+ useEffect(() => () => engine.destroy(), [engine]);
183
+ useEffect(() => { engine.setItems(items ?? []); }, [engine, items]);
184
+ useEffect(() => { engine.update({ keys, fuse, fuseOptions, matcher: ranking, debounce: delay, limit }); }, [engine, keys, fuse, fuseOptions, ranking, delay, limit]);
185
+
186
+ /* -------- opening and closing -------- */
187
+
188
+ // Read on OPEN rather than on mount: storage is shared with every other tab, and a
189
+ // palette that read it once would show a list that is already out of date.
190
+ //
191
+ // Opening also CLEARS the query. A palette that comes back holding the last search is
192
+ // a palette you have to empty before you can use it, and it hides the one thing an
193
+ // empty query is for - what was searched before, which is the shortcut on the second
194
+ // visit. The engine is cleared with it, or the old results would outlive their query.
195
+ useEffect(() => {
196
+ if (!open) return;
197
+ setRemembered(recents ? store.list() : []);
198
+ setQuery("");
199
+ engine.searchNow("");
200
+ setActive(0);
201
+ }, [open, recents, store, engine]);
202
+
203
+ useEffect(() => {
204
+ if (shortcut === null || typeof window === "undefined") return;
205
+ const onKeyDown = (event: KeyboardEvent): void => {
206
+ if (!isPaletteShortcut(event, shortcut)) return;
207
+ // Taken from the browser deliberately: Ctrl+K is a browser shortcut in some
208
+ // builds, and a palette that only sometimes opens is worse than one that never
209
+ // does. The page has the focus, so this is the page's key.
210
+ event.preventDefault();
211
+ setOpen(!open);
212
+ };
213
+ window.addEventListener("keydown", onKeyDown);
214
+ return () => window.removeEventListener("keydown", onKeyDown);
215
+ }, [shortcut, open, setOpen]);
216
+
217
+ /* -------- rows -------- */
218
+
219
+ const rows = useMemo<PaletteRow<Item>[]>(() => {
220
+ const trimmed = query.trim();
221
+ const out: PaletteRow<Item>[] = [];
222
+
223
+ if (!trimmed && recents && remembered.length) {
224
+ remembered.slice(0, RECENTS_SHOWN).forEach((entry, index) => {
225
+ out.push({
226
+ id: `recent-${index}`,
227
+ kind: "recent",
228
+ group: recentsLabel,
229
+ recent: entry,
230
+ label: entry.label ?? entry.term
231
+ });
232
+ });
233
+ }
234
+
235
+ for (const section of sections) {
236
+ const pool = section.always || !trimmed
237
+ ? section.items
238
+ : section.items.filter((item) => labelOf(item).toLowerCase().includes(trimmed.toLowerCase()));
239
+ pool.forEach((item, index) => {
240
+ out.push({
241
+ id: `section-${section.label}-${index}`,
242
+ kind: "action",
243
+ group: section.label,
244
+ item,
245
+ label: labelOf(item),
246
+ description: descriptionOf?.(item)
247
+ });
248
+ });
249
+ }
250
+
251
+ results.forEach((match, index) => {
252
+ out.push({
253
+ id: `result-${index}`,
254
+ kind: "item",
255
+ group: groupBy?.(match.item) ?? resultsLabel,
256
+ item: match.item,
257
+ match,
258
+ label: labelOf(match.item),
259
+ description: descriptionOf?.(match.item)
260
+ });
261
+ });
262
+
263
+ return out;
264
+ }, [query, recents, remembered, recentsLabel, sections, results, groupBy, labelOf, descriptionOf, resultsLabel]);
265
+
266
+ // A shorter list must never leave the highlight past its end, or Enter opens nothing.
267
+ useEffect(() => {
268
+ setActive((current) => (current < rows.length ? current : 0));
269
+ }, [rows.length]);
270
+
271
+ const select = useCallback((row: PaletteRow<Item> | undefined) => {
272
+ if (!row) return;
273
+
274
+ if (row.kind === "recent" && row.recent) {
275
+ // A remembered query goes back in the field and runs again; a remembered RESULT
276
+ // is opened. The difference is whether it had somewhere to go.
277
+ if (!row.recent.href) {
278
+ setQuery(row.recent.term);
279
+ setBusy(true);
280
+ engine.search(row.recent.term);
281
+ fieldRef.current?.focus();
282
+ return;
283
+ }
284
+ }
285
+
286
+ const stay = row.onSelect ? row.onSelect() : row.item !== undefined ? onSelect?.(row.item) : undefined;
287
+ if (recents) {
288
+ setRemembered(store.remember({
289
+ term: query.trim(),
290
+ label: row.label,
291
+ scope: row.group
292
+ }));
293
+ }
294
+ if (stay !== false) setOpen(false);
295
+ }, [engine, onSelect, query, recents, setOpen, store]);
296
+
297
+ const handleQuery = useCallback((next: string) => {
298
+ setQuery(next);
299
+ setActive(0);
300
+ setBusy(Boolean(next.trim()));
301
+ engine.search(next);
302
+ }, [engine]);
303
+
304
+ const value = useMemo(() => ({
305
+ open,
306
+ setOpen,
307
+ query,
308
+ setQuery: handleQuery,
309
+ rows,
310
+ active,
311
+ setActive,
312
+ select,
313
+ clearRecents: () => { store.clear(); setRemembered([]); },
314
+ forgetRecent: (entry: RecentEntry) => setRemembered(store.forget(entry)),
315
+ busy,
316
+ ids,
317
+ shortcutLabel: shortcutLabel(shortcut ?? "k"),
318
+ triggerRef,
319
+ fieldRef,
320
+ rowId: (row: PaletteRow<Item>) => `${ids.list}-${row.id}`
321
+ }), [open, setOpen, query, handleQuery, rows, active, select, busy, ids, shortcut, store]);
322
+
323
+ return <PaletteContext.Provider value={value as never}>{children}</PaletteContext.Provider>;
324
+ }
325
+
326
+ /* ------------------------------------------------------------------ parts */
327
+
328
+ export interface PaletteTriggerProps extends ComponentPropsWithoutRef<"button"> {
329
+ asChild?: boolean;
330
+ }
331
+
332
+ /** Opens the palette. Carries the shortcut in `aria-keyshortcuts`, so it is announced. */
333
+ export function PaletteTrigger({ asChild = false, children, onClick, ...props }: PaletteTriggerProps): ReactNode {
334
+ const palette = usePaletteContext("SearchPalette.Trigger");
335
+ const Tag = asChild ? Slot : "button";
336
+ return (
337
+ <Tag
338
+ {...(asChild ? {} : { type: "button" as const })}
339
+ {...props}
340
+ ref={palette.triggerRef as never}
341
+ data-enigma-palette-trigger=""
342
+ aria-haspopup="dialog"
343
+ aria-expanded={palette.open}
344
+ aria-keyshortcuts="Control+K Meta+K"
345
+ onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
346
+ onClick?.(event);
347
+ if (!event.defaultPrevented) palette.setOpen(true);
348
+ }}
349
+ >
350
+ {children ?? (
351
+ <>
352
+ <span data-enigma-palette-trigger-label="">Search</span>
353
+ <kbd data-enigma-palette-trigger-key="">{palette.shortcutLabel}</kbd>
354
+ </>
355
+ )}
356
+ </Tag>
357
+ );
358
+ }
359
+
360
+ export interface PaletteContentProps extends ComponentPropsWithoutRef<"div"> {
361
+ /** Accessible name for the dialog. Rendered for screen readers only. */
362
+ title?: string;
363
+ /** Render into `document.body`. On by default: a palette inside a clipped or
364
+ * transformed ancestor is a panel nobody can see. */
365
+ portal?: boolean;
366
+ /** Rendered behind the panel. Pass null for no overlay of ours. */
367
+ overlayProps?: ComponentPropsWithoutRef<"div"> | null;
368
+ /**
369
+ * How long the closing animation is given before the panel leaves the DOM, in ms.
370
+ *
371
+ * It exists because a component that unmounts on close can only ever animate IN: the
372
+ * element is gone before a leaving animation has a frame to run. `data-state="closed"`
373
+ * is set first, the stylesheet animates it, and only then does it unmount. 0 removes it
374
+ * immediately, which is also what a reader with reduced motion gets.
375
+ */
376
+ closeDuration?: number;
377
+ }
378
+
379
+ /**
380
+ * The panel: overlay, focus trap, Escape, scroll lock, and focus handed back to the trigger.
381
+ *
382
+ * Mounted only while open, so nothing of the palette is in the document (or in the tab
383
+ * order) the rest of the time.
384
+ */
385
+ export function PaletteContent({ title = "Search", portal = true, overlayProps, closeDuration = 160, children, ...props }: PaletteContentProps): ReactNode {
386
+ const palette = usePaletteContext("SearchPalette.Content");
387
+ const panelRef = useRef<HTMLDivElement | null>(null);
388
+ const [mounted, setMounted] = useState(false);
389
+ /** Stays true through the closing animation, so the panel has frames to leave in. */
390
+ const [present, setPresent] = useState(palette.open);
391
+
392
+ // A portal has no server render: `document` does not exist there, and rendering the
393
+ // panel into the tree instead would put it in the wrong place for one frame.
394
+ useEffect(() => setMounted(true), []);
395
+
396
+ useEffect(() => {
397
+ if (palette.open) {
398
+ setPresent(true);
399
+ return;
400
+ }
401
+ if (!present) return;
402
+ const reduced = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
403
+ const timer = setTimeout(() => setPresent(false), reduced ? 0 : closeDuration);
404
+ return () => clearTimeout(timer);
405
+ }, [palette.open, present, closeDuration]);
406
+
407
+ useEffect(() => {
408
+ if (!palette.open || typeof document === "undefined") return;
409
+
410
+ const previous = document.activeElement as HTMLElement | null;
411
+ const body = document.body;
412
+ const overflow = body.style.overflow;
413
+ // The page behind a modal must not scroll under it, and it must not shift either:
414
+ // hiding the scrollbar without compensating for its width moves the whole layout.
415
+ const gap = window.innerWidth - document.documentElement.clientWidth;
416
+ const padding = body.style.paddingRight;
417
+ body.style.overflow = "hidden";
418
+ if (gap > 0) body.style.paddingRight = `${gap}px`;
419
+
420
+ const onKeyDown = (event: KeyboardEvent): void => {
421
+ if (event.key === "Escape") {
422
+ event.preventDefault();
423
+ palette.setOpen(false);
424
+ return;
425
+ }
426
+ if (event.key !== "Tab") return;
427
+ // The trap: Tab cycles inside the panel. Without it the next Tab lands on the
428
+ // page behind, where a click does nothing and nothing says why.
429
+ const focusable = panelRef.current?.querySelectorAll<HTMLElement>(
430
+ 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
431
+ );
432
+ if (!focusable || focusable.length === 0) return;
433
+ const first = focusable[0];
434
+ const last = focusable[focusable.length - 1];
435
+ if (!event.shiftKey && document.activeElement === last) {
436
+ event.preventDefault();
437
+ first.focus();
438
+ } else if (event.shiftKey && document.activeElement === first) {
439
+ event.preventDefault();
440
+ last.focus();
441
+ }
442
+ };
443
+
444
+ document.addEventListener("keydown", onKeyDown);
445
+ return () => {
446
+ document.removeEventListener("keydown", onKeyDown);
447
+ body.style.overflow = overflow;
448
+ body.style.paddingRight = padding;
449
+ // Back where it came from, so closing with Escape does not drop the visitor at
450
+ // the top of the document.
451
+ (palette.triggerRef.current ?? previous)?.focus?.();
452
+ };
453
+ }, [palette.open, palette.setOpen, palette.triggerRef]);
454
+
455
+ if (!present) return null;
456
+
457
+ const panel = (
458
+ <div data-enigma-palette-portal="" data-state={palette.open ? "open" : "closed"}>
459
+ {overlayProps !== null && (
460
+ <div
461
+ {...overlayProps}
462
+ data-enigma-palette-overlay=""
463
+ data-state={palette.open ? "open" : "closed"}
464
+ // A click outside is a dismiss, and it is not a keyboard event, so it
465
+ // never reaches the Escape handler.
466
+ onClick={(event) => {
467
+ overlayProps?.onClick?.(event);
468
+ if (!event.defaultPrevented) palette.setOpen(false);
469
+ }}
470
+ />
471
+ )}
472
+ <div
473
+ {...props}
474
+ ref={panelRef}
475
+ role="dialog"
476
+ aria-modal="true"
477
+ aria-labelledby={palette.ids.title}
478
+ data-enigma-palette-content=""
479
+ data-state={palette.open ? "open" : "closed"}
480
+ >
481
+ <h2 id={palette.ids.title} data-enigma-palette-title="">{title}</h2>
482
+ {children}
483
+ </div>
484
+ </div>
485
+ );
486
+
487
+ if (!portal) return panel;
488
+ if (!mounted || typeof document === "undefined") return null;
489
+ return createPortal(panel, document.body);
490
+ }
491
+
492
+ export interface PaletteFieldProps extends Omit<ComponentPropsWithoutRef<"input">, "value" | "onChange"> {
493
+ asChild?: boolean;
494
+ }
495
+
496
+ /**
497
+ * The query field.
498
+ *
499
+ * A `combobox` that keeps the caret while the arrows move a highlight somewhere else -
500
+ * which is exactly what `aria-activedescendant` is for. Without it a screen reader hears
501
+ * nothing move, because focus never leaves the field.
502
+ */
503
+ export function PaletteField({ asChild = false, onKeyDown, ...props }: PaletteFieldProps): ReactNode {
504
+ const palette = usePaletteContext("SearchPalette.Field");
505
+ const Tag = asChild ? Slot : "input";
506
+ const activeRow = palette.rows[palette.active];
507
+
508
+ return (
509
+ <Tag
510
+ {...props}
511
+ ref={palette.fieldRef as never}
512
+ id={palette.ids.field}
513
+ type="search"
514
+ value={palette.query}
515
+ // The palette is opened by a keystroke and closed by one; landing anywhere but
516
+ // the field would make the first thing typed go missing.
517
+ autoFocus
518
+ autoComplete="off"
519
+ autoCorrect="off"
520
+ autoCapitalize="none"
521
+ spellCheck={false}
522
+ enterKeyHint="go"
523
+ role="combobox"
524
+ aria-expanded
525
+ aria-autocomplete="list"
526
+ aria-controls={palette.ids.list}
527
+ aria-activedescendant={activeRow ? palette.rowId(activeRow) : undefined}
528
+ data-enigma-palette-field=""
529
+ onChange={(event: React.ChangeEvent<HTMLInputElement>) => palette.setQuery(event.target.value)}
530
+ onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
531
+ onKeyDown?.(event);
532
+ if (event.defaultPrevented) return;
533
+ const key = event.key as PaletteKey | "Enter";
534
+ if (key === "Enter") {
535
+ event.preventDefault();
536
+ palette.select(palette.rows[palette.active]);
537
+ return;
538
+ }
539
+ if (["ArrowDown", "ArrowUp", "Home", "End", "PageDown", "PageUp"].includes(key)) {
540
+ event.preventDefault();
541
+ palette.setActive(moveActive(palette.active, palette.rows.length, key as PaletteKey));
542
+ }
543
+ }}
544
+ />
545
+ );
546
+ }
547
+
548
+ export interface PaletteListProps<Item> extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
549
+ /** Render one row. The default prints its label, which is enough to be usable. */
550
+ children?: (row: PaletteRow<Item>, state: { active: boolean; index: number; }) => ReactNode;
551
+ /** Rendered when there is nothing to show. */
552
+ empty?: ReactNode;
553
+ /** The group heading. Pass null for a flat list with no headings. */
554
+ heading?: ((label: string) => ReactNode) | null;
555
+ }
556
+
557
+ /** The results, grouped, with one flat keyboard sequence running through them. */
558
+ export function PaletteList<Item>({ children, empty, heading, ...props }: PaletteListProps<Item>): ReactNode {
559
+ const palette = usePaletteContext<Item>("SearchPalette.List");
560
+ const listRef = useRef<HTMLDivElement | null>(null);
561
+ const groups = useMemo(() => groupRows(palette.rows, (row) => row.group), [palette.rows]);
562
+
563
+ // Keeps the highlight in view when it is moved by the keyboard. `nearest` so the list
564
+ // does not jump a whole panel for a row that was already half visible.
565
+ useEffect(() => {
566
+ listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: "nearest" });
567
+ }, [palette.active, palette.rows]);
568
+
569
+ return (
570
+ <div
571
+ {...props}
572
+ ref={listRef}
573
+ id={palette.ids.list}
574
+ role="listbox"
575
+ aria-label="Results"
576
+ data-enigma-palette-list=""
577
+ >
578
+ {palette.rows.length === 0
579
+ ? empty ?? <p data-enigma-palette-empty="">{palette.query.trim() ? `Nothing matches "${palette.query.trim()}".` : "Type to search."}</p>
580
+ : groups.map((group) => (
581
+ <div key={group.label} role="group" aria-label={group.label} data-enigma-palette-group="">
582
+ {heading !== null && (
583
+ heading?.(group.label) ?? (
584
+ // The group carries the name already, so announcing the
585
+ // heading again would only repeat it.
586
+ <p data-enigma-palette-group-label="" aria-hidden="true">{group.label}</p>
587
+ )
588
+ )}
589
+ {group.rows.map(({ row, position }) => (
590
+ <PaletteItem
591
+ key={row.id}
592
+ row={row}
593
+ index={position}
594
+ >
595
+ {children?.(row, { active: position === palette.active, index: position })}
596
+ </PaletteItem>
597
+ ))}
598
+ </div>
599
+ ))}
600
+ </div>
601
+ );
602
+ }
603
+
604
+ export interface PaletteItemProps<Item> extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
605
+ row: PaletteRow<Item>;
606
+ index: number;
607
+ children?: ReactNode;
608
+ }
609
+
610
+ /**
611
+ * One row.
612
+ *
613
+ * The pointer MOVES the highlight rather than running a second one of its own: two
614
+ * highlights on screen is the thing that makes a palette feel unpredictable, because Enter
615
+ * then opens the row the mouse is not on.
616
+ */
617
+ export function PaletteItem<Item>({ row, index, children, ...props }: PaletteItemProps<Item>): ReactNode {
618
+ const palette = usePaletteContext<Item>("SearchPalette.Item");
619
+ const active = index === palette.active;
620
+ return (
621
+ <div
622
+ {...props}
623
+ id={palette.rowId(row)}
624
+ role="option"
625
+ aria-selected={active}
626
+ data-enigma-palette-item=""
627
+ data-kind={row.kind}
628
+ data-active={active ? "true" : undefined}
629
+ onMouseMove={() => { if (!active) palette.setActive(index); }}
630
+ onClick={() => palette.select(row)}
631
+ >
632
+ {children ?? (
633
+ <>
634
+ <span data-enigma-palette-item-label="">{row.label}</span>
635
+ {row.description && <span data-enigma-palette-item-description="">{row.description}</span>}
636
+ </>
637
+ )}
638
+ </div>
639
+ );
640
+ }
641
+
642
+ export interface PaletteFooterProps extends ComponentPropsWithoutRef<"div"> {
643
+ /** Replace the hints. The default names the three keys that actually matter. */
644
+ hints?: ReactNode;
645
+ }
646
+
647
+ /** The strip along the bottom that says which keys do what. */
648
+ export function PaletteFooter({ hints, children, ...props }: PaletteFooterProps): ReactNode {
649
+ usePaletteContext("SearchPalette.Footer");
650
+ return (
651
+ <div {...props} data-enigma-palette-footer="">
652
+ {children ?? hints ?? (
653
+ <>
654
+ <span data-enigma-palette-hint=""><kbd>up</kbd><kbd>down</kbd> to move</span>
655
+ <span data-enigma-palette-hint=""><kbd>enter</kbd> to open</span>
656
+ <span data-enigma-palette-hint=""><kbd>esc</kbd> to close</span>
657
+ </>
658
+ )}
659
+ </div>
660
+ );
661
+ }