@enigmax/primitives 0.21.0 → 0.23.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 (83) hide show
  1. package/dist/chunk-3BQVOOAM.js +388 -0
  2. package/dist/{chunk-QQFNAKMY.js → chunk-45UHLZYT.js} +1 -1
  3. package/dist/chunk-4VUHQFAT.js +130 -0
  4. package/dist/{chunk-D5A2ZMAG.js → chunk-4ZPMP47J.js} +6 -1
  5. package/dist/chunk-DSBYVA7V.js +713 -0
  6. package/dist/{chunk-FWVWX67R.js → chunk-FGBIZDV2.js} +3 -3
  7. package/dist/{chunk-R4ZAEE7V.js → chunk-KEVZ5XQV.js} +1 -1
  8. package/dist/chunk-N6PDHMAX.js +213 -0
  9. package/dist/chunk-NONGREXC.js +248 -0
  10. package/dist/chunk-VKL3DEIQ.js +804 -0
  11. package/dist/chunk-WPTBURIC.js +1627 -0
  12. package/dist/chunk-WSQC3PCC.js +466 -0
  13. package/dist/context-menu-D3FtTn7v.d.ts +174 -0
  14. package/dist/{index-dTdAbOWl.d.ts → index-DQNnohoo.d.ts} +1 -1
  15. package/dist/index.d.ts +5 -1
  16. package/dist/index.js +8 -4
  17. package/dist/keys-D2zJs1uB.d.ts +100 -0
  18. package/dist/next/index.d.ts +12 -5
  19. package/dist/next/index.js +21 -14
  20. package/dist/react/button.d.ts +2 -2
  21. package/dist/react/context-menu.d.ts +202 -0
  22. package/dist/react/context-menu.js +6 -0
  23. package/dist/react/index.d.ts +11 -4
  24. package/dist/react/index.js +20 -13
  25. package/dist/react/input.d.ts +2 -2
  26. package/dist/react/input.js +1 -1
  27. package/dist/react/palette.d.ts +1 -1
  28. package/dist/react/palette.js +3 -3
  29. package/dist/react/search.d.ts +1 -1
  30. package/dist/react/search.js +2 -2
  31. package/dist/react/select.d.ts +217 -0
  32. package/dist/react/select.js +7 -0
  33. package/dist/react/selection.d.ts +104 -0
  34. package/dist/react/selection.js +4 -0
  35. package/dist/react/slot.d.ts +2 -2
  36. package/dist/react/toast.d.ts +165 -32
  37. package/dist/react/toast.js +1 -1
  38. package/dist/react-router/index.d.ts +12 -5
  39. package/dist/react-router/index.js +21 -14
  40. package/dist/search/index.d.ts +2 -2
  41. package/dist/search/index.js +1 -1
  42. package/dist/{search-DXxY8SEH.d.ts → search-DYgqRp37.d.ts} +20 -3
  43. package/dist/{search-UQEXAPQB.js → search-PBZORZ7P.js} +1 -1
  44. package/dist/select-ClSy-J1f.d.ts +100 -0
  45. package/dist/selection-B_pmzHpy.d.ts +150 -0
  46. package/package.json +21 -2
  47. package/recipes/context-menu/styles.css +177 -0
  48. package/recipes/input/styles.css +9 -0
  49. package/recipes/palette/styles.css +3 -0
  50. package/recipes/search/tailwind.tsx +3 -2
  51. package/recipes/select/styles.css +230 -0
  52. package/recipes/toast/styles.css +688 -192
  53. package/registry.json +434 -29
  54. package/src/core/context-menu.ts +694 -0
  55. package/src/core/keys.ts +264 -0
  56. package/src/core/search.ts +25 -1
  57. package/src/core/select.ts +404 -0
  58. package/src/core/selection.ts +648 -0
  59. package/src/index.ts +60 -1
  60. package/src/react/context-menu/context.ts +57 -0
  61. package/src/react/context-menu/index.tsx +94 -0
  62. package/src/react/context-menu/root.tsx +846 -0
  63. package/src/react/context-menu/styles.ts +186 -0
  64. package/src/react/index.ts +70 -2
  65. package/src/react/palette/root.tsx +2 -2
  66. package/src/react/select/context.ts +56 -0
  67. package/src/react/select/index.tsx +96 -0
  68. package/src/react/select/root.tsx +839 -0
  69. package/src/react/select/styles.ts +238 -0
  70. package/src/react/selection/index.tsx +115 -0
  71. package/src/react/selection/use-selection.ts +260 -0
  72. package/src/react/toast/NOTICE +20 -0
  73. package/src/react/toast/assets.tsx +85 -0
  74. package/src/react/toast/cn.ts +10 -0
  75. package/src/react/toast/hooks.ts +13 -0
  76. package/src/react/toast/index.tsx +736 -0
  77. package/src/react/toast/state.ts +207 -0
  78. package/src/react/toast/styles.ts +738 -0
  79. package/src/react/toast/types.ts +193 -0
  80. package/src/react/toaster.tsx +76 -341
  81. package/dist/chunk-JCCL7XKC.js +0 -465
  82. package/src/react/toast-styles.ts +0 -247
  83. /package/dist/{chunk-U3V4EHOB.js → chunk-3HDEZ2E7.js} +0 -0
@@ -0,0 +1,839 @@
1
+ "use client";
2
+
3
+ import { Slot } from "@/react/slot";
4
+ import { groupRows } from "@/core/palette";
5
+ import { shortenQuery } from "@/core/search";
6
+ import { SELECT_STYLES } from "@/react/select/styles";
7
+ import { SelectContext, useSelectContext, type SelectItem } from "@/react/select/context";
8
+ import { createSelect, type SelectInstance, type SelectMoveKey, type SelectOptions, type SelectState } from "@/core/select";
9
+ import {
10
+ useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState,
11
+ type ComponentPropsWithoutRef, type CSSProperties, type KeyboardEvent, type ReactNode
12
+ } from "react";
13
+
14
+ /**
15
+ * The select, as parts.
16
+ *
17
+ * ```tsx
18
+ * <Select.Root options={countries} value={value} onValueChange={setValue}>
19
+ * <Select.Trigger><Select.Value placeholder="Country" /></Select.Trigger>
20
+ * <Select.Content>
21
+ * <Select.Search />
22
+ * <Select.List />
23
+ * </Select.Content>
24
+ * </Select.Root>
25
+ * ```
26
+ *
27
+ * `<Select>` in the entry next to this file is exactly that composition, and the reason to
28
+ * come here is a select whose parts are not in that order - a trigger that is a table cell,
29
+ * a panel with a footer, a list you render row by row.
30
+ *
31
+ * WHY IT IS NOT A `<select>`. The native element cannot hold an icon, a second line, a
32
+ * checkbox or a tag, and its popup is drawn by the operating system: not stylable, not
33
+ * themable, different on every platform. So this is a listbox - and everything the native
34
+ * element gives you for free (typeahead, the keyboard, the form value, the announcement)
35
+ * has to be given back deliberately, which is what the rest of this file is.
36
+ */
37
+
38
+ let injected = false;
39
+
40
+ /**
41
+ * The baseline look, injected once.
42
+ *
43
+ * Every other component here ships naked and looks plain until you style it. A popup does
44
+ * not have that option: unstyled, `<Select.Content>` is transparent text lying on top of
45
+ * the page - not plain, broken. So the sheet is injected and PREPENDED to `<head>`, where
46
+ * anything the document already has outranks it by source order without one `!important`;
47
+ * `styles={false}` turns it off, and `@enigmax/primitives/select.css` is the same sheet for
48
+ * anyone who would rather import it.
49
+ */
50
+ function injectStyles(): void {
51
+ if (injected || typeof document === "undefined") return;
52
+ injected = true;
53
+ if (document.querySelector("[data-enigma-select-styles]")) return;
54
+ const element = document.createElement("style");
55
+ element.setAttribute("data-enigma-select-styles", "");
56
+ element.textContent = SELECT_STYLES;
57
+ document.head.prepend(element);
58
+ }
59
+
60
+ /** Keys the list owns wherever focus happens to be. */
61
+ const MOVE_KEYS: Record<string, SelectMoveKey> = {
62
+ ArrowDown: "ArrowDown",
63
+ ArrowUp: "ArrowUp",
64
+ Home: "Home",
65
+ End: "End",
66
+ PageDown: "PageDown",
67
+ PageUp: "PageUp"
68
+ };
69
+
70
+ /** Filtering a list of eight is worth a field; filtering a list of three is a bigger panel. */
71
+ const SEARCHABLE_FROM = 8;
72
+
73
+ interface SelectRootBase extends Omit<SelectOptions, "options" | "value" | "onValueChange" | "onChange" | "multiple" | "searchable"> {
74
+ options: SelectItem[];
75
+ /** `"auto"` (the default) puts a filter on a list of eight or more. */
76
+ searchable?: boolean | "auto";
77
+ disabled?: boolean;
78
+ /**
79
+ * Submit with a plain HTML form: a hidden input per chosen value, so the select works
80
+ * in a form that posts, not only in one wired to state.
81
+ */
82
+ name?: string;
83
+ required?: boolean;
84
+ /** Controlled panel. Omit both for a panel that manages itself. */
85
+ open?: boolean;
86
+ defaultOpen?: boolean;
87
+ onOpenChange?: (open: boolean) => void;
88
+ /**
89
+ * A × that empties the selection. It takes the caret's place while the pointer is on
90
+ * the control - see the trigger below for why it is not a second glyph beside it.
91
+ * Default: on when many values are allowed.
92
+ */
93
+ clearable?: boolean;
94
+ /**
95
+ * The options have not arrived yet. Distinct from having none: a select that is still
96
+ * loading and one that will never have anything look identical otherwise, and only one of
97
+ * them is worth waiting for.
98
+ */
99
+ loading?: boolean;
100
+ /** What the trigger says with no options at all. */
101
+ emptyLabel?: ReactNode;
102
+ loadingLabel?: ReactNode;
103
+ /** Inject the baseline stylesheet. See the note above. */
104
+ styles?: boolean;
105
+ /**
106
+ * On the ROOT, which is the element with a size: the trigger fills it, so this is what
107
+ * a `width` belongs on. `triggerProps.className` dresses the button itself.
108
+ */
109
+ className?: string;
110
+ style?: CSSProperties;
111
+ children?: ReactNode;
112
+ }
113
+
114
+ /** One value: `value` is a string, and so is what the change reports. */
115
+ export interface SelectSingleProps extends SelectRootBase {
116
+ multiple?: false;
117
+ value?: string | null;
118
+ defaultValue?: string | null;
119
+ onValueChange?: (value: string, option: SelectItem | null) => void;
120
+ }
121
+
122
+ /** Many values: everything that was one string is a list, checked by the compiler. */
123
+ export interface SelectMultipleProps extends SelectRootBase {
124
+ multiple: true;
125
+ value?: string[] | null;
126
+ defaultValue?: string[] | null;
127
+ onValueChange?: (value: string[], options: SelectItem[]) => void;
128
+ }
129
+
130
+ export type SelectRootProps = SelectSingleProps | SelectMultipleProps;
131
+
132
+ export function SelectRoot(props: SelectRootProps): ReactNode {
133
+ const {
134
+ options,
135
+ multiple = false,
136
+ searchable = "auto",
137
+ disabled = false,
138
+ name,
139
+ required,
140
+ open: openProp,
141
+ defaultOpen = false,
142
+ onOpenChange,
143
+ loading = false,
144
+ emptyLabel = "No options",
145
+ loadingLabel = "Loading...",
146
+ styles = true,
147
+ clearable,
148
+ className,
149
+ style,
150
+ closeOnSelect,
151
+ fuse,
152
+ fuseOptions,
153
+ matcher,
154
+ searchKeys,
155
+ children
156
+ } = props;
157
+
158
+ // Before paint: a sheet applied after the first frame shows the panel unstyled first.
159
+ useLayoutEffect(() => { if (styles) injectStyles(); }, [styles]);
160
+
161
+ const isSearchable = searchable === "auto" ? options.length >= SEARCHABLE_FROM : searchable;
162
+ // Nothing to choose from, which is not the same as a filter that matched nothing: the
163
+ // panel would hold one line of apology, so the TRIGGER says it instead and never opens.
164
+ const isEmpty = options.length === 0 && !loading;
165
+ const controlledValue = props.value === undefined ? undefined : props.value ?? (multiple ? [] : null);
166
+
167
+ const id = useId();
168
+ const ids = useMemo(() => ({
169
+ trigger: `${id}-trigger`,
170
+ list: `${id}-list`,
171
+ field: `${id}-field`
172
+ }), [id]);
173
+
174
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
175
+ const fieldRef = useRef<HTMLInputElement | null>(null);
176
+ const rootRef = useRef<HTMLDivElement | null>(null);
177
+
178
+ // Kept in a ref so the instance - built once - always calls the CURRENT props rather
179
+ // than the ones it closed over on the first render.
180
+ const latest = useRef(props);
181
+ latest.current = props;
182
+
183
+ const instance = useMemo<SelectInstance>(() => createSelect({
184
+ options,
185
+ multiple,
186
+ searchable: isSearchable,
187
+ closeOnSelect,
188
+ fuse,
189
+ fuseOptions,
190
+ matcher,
191
+ searchKeys,
192
+ value: props.defaultValue ?? controlledValue ?? (multiple ? [] : null),
193
+ onValueChange: (next, chosen) => {
194
+ const current = latest.current;
195
+ if (current.multiple) current.onValueChange?.(next as string[], chosen as SelectItem[]);
196
+ else current.onValueChange?.(next as string, (chosen as SelectItem[])[0] ?? null);
197
+ }
198
+ // Built once: rebuilding it would drop the open panel and the highlight on every
199
+ // render. Every option below is pushed in through update().
200
+ // eslint-disable-next-line react-hooks/exhaustive-deps
201
+ }), []);
202
+
203
+ const [state, setState] = useState<SelectState>(() => instance.state);
204
+
205
+ useEffect(() => {
206
+ const unsubscribe = instance.subscribe(setState);
207
+ setState(instance.state);
208
+ return () => {
209
+ unsubscribe();
210
+ instance.destroy();
211
+ };
212
+ }, [instance]);
213
+
214
+ /**
215
+ * What the options ARE, as a string.
216
+ *
217
+ * The effect below cannot depend on the array: `options={[{ value: "es", ... }]}` is a
218
+ * new array of new objects on every render, so pushing it in would emit a new state,
219
+ * render again, and build another array - a loop, and inline options are how everyone
220
+ * writes them. Comparing the content instead makes the effect run when something
221
+ * actually changed. React nodes are left out of the signature on purpose: an icon is a
222
+ * fresh element object every render and no two are ever equal.
223
+ */
224
+ // Built only when the array itself is new, because the root re-renders on every
225
+ // keystroke and serialising a list of 250 countries per render is work nobody asked
226
+ // for: a stable array pays for this once.
227
+ const signature = useMemo(() => JSON.stringify(options.map((option) =>
228
+ [option.value, option.label, option.description, option.group, option.disabled, option.keywords])), [options]);
229
+
230
+ /**
231
+ * What the FILTER is, as a string.
232
+ *
233
+ * The same problem as the options, one prop over: `fuseOptions={{ threshold: 0.3 }}`
234
+ * and `searchKeys={["label"]}` are the documented way to write them and a new object on
235
+ * every render, so depending on their identity pushes them in each time - and that
236
+ * rebuilds Fuse's index over the whole list for a filter nobody changed. They are plain
237
+ * data, so their content is comparable; `fuse` and `matcher` are functions and stay on
238
+ * identity, where a rebuild is what a genuinely different one needs.
239
+ */
240
+ const filterSignature = JSON.stringify([searchKeys ?? null, fuseOptions ?? null]);
241
+
242
+ // The newest values, pushed in whenever a signature says something is different - so
243
+ // the instance holds the current objects and not the ones from the first render.
244
+ const currentOptions = useRef(options);
245
+ currentOptions.current = options;
246
+ const currentFilter = useRef({ fuseOptions, searchKeys });
247
+ currentFilter.current = { fuseOptions, searchKeys };
248
+
249
+ useEffect(() => {
250
+ const { fuseOptions: currentFuseOptions, searchKeys: currentKeys } = currentFilter.current;
251
+ instance.update({
252
+ options: currentOptions.current, multiple, searchable: isSearchable, closeOnSelect,
253
+ fuse, matcher, fuseOptions: currentFuseOptions, searchKeys: currentKeys
254
+ });
255
+ }, [instance, signature, filterSignature, multiple, isSearchable, closeOnSelect, fuse, matcher]);
256
+
257
+ /**
258
+ * A controlled value is the caller's, always.
259
+ *
260
+ * Compared after EVERY render rather than when the prop changes, because the case this
261
+ * exists for is the prop NOT changing: a parent that validates a choice and keeps its
262
+ * own value has already had the instance move underneath it, and an effect keyed on the
263
+ * prop would never run to put it back. The comparison is by content - `value={[...]}`
264
+ * is a new array every render - so the two settle in one pass: the push renders once
265
+ * more, that render finds them equal, and nothing further is pushed.
266
+ */
267
+ useEffect(() => {
268
+ if (controlledValue === undefined) return;
269
+ // The same rule the core applies: an empty string is nothing chosen.
270
+ const wanted = (controlledValue === null ? [] : Array.isArray(controlledValue) ? controlledValue : [controlledValue])
271
+ .filter((entry) => entry !== "");
272
+ const current = instance.state.value;
273
+ if (current.length === wanted.length && current.every((entry, index) => entry === wanted[index])) return;
274
+ instance.update({ value: [...wanted] });
275
+ });
276
+
277
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
278
+ const open = openProp ?? uncontrolledOpen;
279
+
280
+ const setOpen = useCallback((next: boolean) => {
281
+ if ((disabled || isEmpty || loading) && next) return;
282
+ if (openProp === undefined) setUncontrolledOpen(next);
283
+ // The instance is opened HERE and not only from the effect below, so that whatever
284
+ // the same handler does next - a typeahead, a move - runs against a panel that is
285
+ // already open. Through the effect it would run first and be overwritten by the
286
+ // open, which is a highlight that lands on the wrong row.
287
+ instance.setOpen(next);
288
+ onOpenChange?.(next);
289
+ }, [instance, disabled, isEmpty, loading, openProp, onOpenChange]);
290
+
291
+ useEffect(() => { instance.setOpen(open); }, [instance, open]);
292
+
293
+ // The core closes itself after a choice; the React copy of that fact has to follow, or
294
+ // the next click on the trigger reopens a panel React still believes is open.
295
+ //
296
+ // Only on the TRANSITION. Comparing the two flags directly reads the one render where
297
+ // React has already opened and the instance has not caught up yet - and closes the panel
298
+ // a frame after it opened, which is the bug this comment exists to keep fixed.
299
+ const wasOpen = useRef(state.open);
300
+ useEffect(() => {
301
+ const closedItself = wasOpen.current && !state.open;
302
+ wasOpen.current = state.open;
303
+ if (!closedItself || !open) return;
304
+ // Whatever closed it was inside the panel - a row, or Enter in the search field -
305
+ // and the panel is about to unmount with the focus still in it. Focus goes back to
306
+ // the trigger rather than to the body, exactly as Escape does it.
307
+ const held = rootRef.current?.contains(document.activeElement);
308
+ setOpen(false);
309
+ if (held) triggerRef.current?.focus();
310
+ }, [state.open, open, setOpen]);
311
+
312
+ const close = useCallback(() => {
313
+ setOpen(false);
314
+ // Focus goes back to the trigger rather than to the body: the panel is gone, and a
315
+ // keyboard visitor left standing on nothing has to tab from the top of the page.
316
+ triggerRef.current?.focus();
317
+ }, [setOpen]);
318
+
319
+ // A click anywhere else closes it. `pointerdown` rather than `click` so it closes on
320
+ // the way down, before whatever was clicked runs.
321
+ useEffect(() => {
322
+ if (!open) return;
323
+ const onPointerDown = (event: PointerEvent) => {
324
+ if (rootRef.current?.contains(event.target as Node)) return;
325
+ setOpen(false);
326
+ };
327
+ document.addEventListener("pointerdown", onPointerDown, true);
328
+ return () => document.removeEventListener("pointerdown", onPointerDown, true);
329
+ }, [open, setOpen]);
330
+
331
+ const onListKeyDown = useCallback((event: KeyboardEvent) => {
332
+ const move = MOVE_KEYS[event.key];
333
+ if (move) {
334
+ event.preventDefault();
335
+ if (!open) { setOpen(true); return; }
336
+ instance.move(move);
337
+ return;
338
+ }
339
+ if (event.key === "Enter") {
340
+ if (!open) { event.preventDefault(); setOpen(true); return; }
341
+ event.preventDefault();
342
+ instance.selectActive();
343
+ return;
344
+ }
345
+ if (event.key === "Escape") {
346
+ if (!open) return;
347
+ event.preventDefault();
348
+ // Stopped here so one Escape closes the select and not the dialog around it.
349
+ event.stopPropagation();
350
+ close();
351
+ return;
352
+ }
353
+ if (event.key === "Tab" && open) {
354
+ setOpen(false);
355
+ return;
356
+ }
357
+ // Backspace and Delete take a value off, and only on the trigger: in the search
358
+ // field they edit the query. The × that does this with a pointer sits INSIDE the
359
+ // trigger button, where it cannot be a tab stop of its own, so without this a
360
+ // clearable select is one no keyboard can empty.
361
+ if ((event.key === "Backspace" || event.key === "Delete") && event.currentTarget === triggerRef.current) {
362
+ const chosen = instance.state.value;
363
+ if (chosen.length === 0) return;
364
+ event.preventDefault();
365
+ // Many values drop the last one, the way removing a tag does; one value has
366
+ // nothing to drop but itself.
367
+ if (instance.state.multiple) instance.remove(chosen[chosen.length - 1]);
368
+ else instance.clear();
369
+ return;
370
+ }
371
+ // Space chooses on the trigger, where it is not text; inside the search field it is
372
+ // a space, and taking it would make phrases unsearchable.
373
+ if (event.key === " " && event.currentTarget === triggerRef.current) {
374
+ event.preventDefault();
375
+ if (open) instance.selectActive();
376
+ else setOpen(true);
377
+ return;
378
+ }
379
+ // Typeahead, the way the native control does it - and only where there is no field
380
+ // to type into, because there the letters ARE the filter.
381
+ if (!fieldRef.current && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
382
+ if (!open) setOpen(true);
383
+ instance.typeahead(event.key);
384
+ }
385
+ }, [instance, open, setOpen, close]);
386
+
387
+ const context = useMemo(() => ({
388
+ instance,
389
+ state,
390
+ setOpen,
391
+ disabled,
392
+ clearable: clearable ?? multiple,
393
+ searchable: isSearchable,
394
+ empty: isEmpty,
395
+ loading,
396
+ emptyLabel,
397
+ loadingLabel,
398
+ ids,
399
+ optionId: (index: number) => `${id}-option-${index}`,
400
+ triggerRef,
401
+ fieldRef,
402
+ close,
403
+ onListKeyDown
404
+ }), [instance, state, setOpen, disabled, clearable, multiple, isSearchable, isEmpty, loading, emptyLabel, loadingLabel, ids, id, close, onListKeyDown]);
405
+
406
+ return (
407
+ <SelectContext.Provider value={context}>
408
+ <div
409
+ ref={rootRef}
410
+ className={className}
411
+ style={style}
412
+ data-enigma-select-root=""
413
+ data-open={open ? "" : undefined}
414
+ data-disabled={disabled ? "" : undefined}
415
+ data-empty={isEmpty ? "" : undefined}
416
+ data-loading={loading ? "" : undefined}
417
+ data-multiple={multiple ? "" : undefined}
418
+ >
419
+ {children}
420
+ {/* The form half. A select that only exists in React state cannot be
421
+ submitted by the form it sits in, and that is where it usually sits. */}
422
+ {name && state.value.map((entry) => (
423
+ <input key={entry} type="hidden" name={multiple ? `${name}[]` : name} value={entry} />
424
+ ))}
425
+ {name && state.value.length === 0 && required && (
426
+ // Required with nothing chosen: a field the browser can refuse to
427
+ // submit, kept out of the tab order and off the screen readers.
428
+ <input
429
+ tabIndex={-1}
430
+ required
431
+ aria-hidden="true"
432
+ data-enigma-select-validity=""
433
+ // Inline rather than in the sheet: `display: none` is exempt from
434
+ // constraint validation, so it has to be RENDERED and invisible -
435
+ // and it has to stay invisible when the sheet is turned off.
436
+ style={{ position: "absolute", width: 0, height: 0, padding: 0, border: 0, opacity: 0, pointerEvents: "none" }}
437
+ value=""
438
+ onChange={() => { /* never typed into; the select owns the value */ }}
439
+ onFocus={() => triggerRef.current?.focus()}
440
+ />
441
+ )}
442
+ </div>
443
+ </SelectContext.Provider>
444
+ );
445
+ }
446
+
447
+ export interface SelectTriggerProps extends Omit<ComponentPropsWithoutRef<"button">, "value"> {
448
+ /** Put the behaviour on your own element instead of ours. */
449
+ asChild?: boolean;
450
+ /**
451
+ * Draw the caret, and the clear × in its place. On by default, and never through a slot:
452
+ * the child owns its own markup there.
453
+ */
454
+ indicator?: boolean;
455
+ }
456
+
457
+ export function SelectTrigger({ asChild = false, indicator = true, children, onClick, onKeyDown, ...props }: SelectTriggerProps): ReactNode {
458
+ const select = useSelectContext("Select.Trigger");
459
+ const Tag = asChild ? Slot : "button";
460
+ const active = select.state.active;
461
+ const showClear = select.clearable && select.state.value.length > 0 && !select.disabled;
462
+ // Announced as unavailable rather than removed from the tab order: the reason it cannot be
463
+ // opened is written inside it, and a `disabled` button takes that text out of reach of the
464
+ // keyboard and the screen reader that need it most.
465
+ const unavailable = select.disabled || select.empty || select.loading;
466
+
467
+ return (
468
+ <Tag
469
+ {...props}
470
+ ref={select.triggerRef}
471
+ id={select.ids.trigger}
472
+ type={asChild ? undefined : "button"}
473
+ // The combobox is the trigger only while there is no field to type in: with a
474
+ // search field open, THAT is the combobox and this is the button that opened it.
475
+ role={select.searchable ? undefined : "combobox"}
476
+ aria-haspopup="listbox"
477
+ aria-expanded={select.state.open}
478
+ aria-controls={select.state.open ? select.ids.list : undefined}
479
+ aria-activedescendant={!select.searchable && select.state.open && active >= 0 ? select.optionId(active) : undefined}
480
+ aria-disabled={unavailable || undefined}
481
+ aria-busy={select.loading || undefined}
482
+ disabled={asChild ? undefined : select.disabled}
483
+ data-enigma-select-trigger=""
484
+ data-open={select.state.open ? "" : undefined}
485
+ data-empty={select.empty ? "" : undefined}
486
+ data-loading={select.loading ? "" : undefined}
487
+ data-placeholder={select.state.value.length === 0 ? "" : undefined}
488
+ // The stylesheet reads this to hide the caret under the ×. Without it the caret
489
+ // would disappear on hover over a select that has nothing to clear.
490
+ data-clearable={showClear ? "" : undefined}
491
+ onClick={(event) => {
492
+ onClick?.(event);
493
+ if (event.defaultPrevented || unavailable) return;
494
+ select.setOpen(!select.state.open);
495
+ }}
496
+ onKeyDown={(event) => {
497
+ onKeyDown?.(event);
498
+ if (!event.defaultPrevented) select.onListKeyDown(event);
499
+ }}
500
+ >
501
+ {children}
502
+ {!asChild && indicator && (
503
+ /**
504
+ * One slot, two glyphs, stacked.
505
+ *
506
+ * The × and the caret occupy the SAME cell and swap on hover, the way a
507
+ * select with a clear button is normally drawn: side by side they are two
508
+ * targets a pixel apart, one of which dismisses your choice, and the pair
509
+ * changes the trigger's width the moment a value appears.
510
+ */
511
+ <span data-enigma-select-indicator="">
512
+ <span data-enigma-select-caret="" aria-hidden="true" />
513
+ {showClear && (
514
+ // A span with a button's role: this is inside the trigger button,
515
+ // and a button inside a button is markup the browser unnests -
516
+ // which drops the handler with it. Backspace does the same thing
517
+ // from the keyboard, since it cannot be a tab stop of its own.
518
+ <span
519
+ role="button"
520
+ tabIndex={-1}
521
+ aria-label="Clear selection"
522
+ data-enigma-select-clear=""
523
+ onPointerDown={(event) => {
524
+ // Down and stopped: the trigger opens the panel on click,
525
+ // and clearing must not also open it.
526
+ event.preventDefault();
527
+ event.stopPropagation();
528
+ select.instance.clear();
529
+ }}
530
+ >&times;</span>
531
+ )}
532
+ </span>
533
+ )}
534
+ </Tag>
535
+ );
536
+ }
537
+
538
+ export interface SelectValueProps extends Omit<ComponentPropsWithoutRef<"span">, "children"> {
539
+ placeholder?: ReactNode;
540
+ /**
541
+ * Show each chosen option as a removable tag. On by default when many values are
542
+ * allowed, because a comma-separated line of eight labels is not something you can
543
+ * take one item out of.
544
+ */
545
+ tags?: boolean;
546
+ /** How many tags before the rest collapse into "+N". */
547
+ maxTags?: number;
548
+ /** Draw the value yourself, from what is chosen. */
549
+ children?: ReactNode | ((selected: SelectItem[]) => ReactNode);
550
+ }
551
+
552
+ export function SelectValue({ placeholder = "Select", tags, maxTags = 3, children, ...props }: SelectValueProps): ReactNode {
553
+ const select = useSelectContext("Select.Value");
554
+ const selected = select.state.selected as SelectItem[];
555
+ const showTags = tags ?? select.state.multiple;
556
+
557
+ // Before anything else, because both states mean there is nothing chosen AND nothing to
558
+ // choose - and "Select" over an empty list is the misleading version of that.
559
+ if (select.loading) return <span {...props} data-enigma-select-value="" data-placeholder="" data-loading="">{select.loadingLabel}</span>;
560
+ if (select.empty) return <span {...props} data-enigma-select-value="" data-placeholder="" data-empty="">{select.emptyLabel}</span>;
561
+
562
+ if (typeof children === "function") return <span {...props} data-enigma-select-value="">{children(selected)}</span>;
563
+ if (children) return <span {...props} data-enigma-select-value="">{children}</span>;
564
+
565
+ if (selected.length === 0) {
566
+ return <span {...props} data-enigma-select-value="" data-placeholder="">{placeholder}</span>;
567
+ }
568
+
569
+ if (!showTags) {
570
+ const [first] = selected;
571
+ return (
572
+ <span {...props} data-enigma-select-value="">
573
+ {first.icon ? <span data-enigma-select-icon="">{first.icon}</span> : null}
574
+ {selected.length > 1 ? `${first.label} +${selected.length - 1}` : first.label}
575
+ </span>
576
+ );
577
+ }
578
+
579
+ const shown = selected.slice(0, maxTags);
580
+ const rest = selected.length - shown.length;
581
+
582
+ return (
583
+ <span {...props} data-enigma-select-value="" data-tags="">
584
+ {shown.map((option) => (
585
+ <span key={option.value} data-enigma-select-tag="">
586
+ {option.icon ? <span data-enigma-select-icon="">{option.icon}</span> : null}
587
+ {option.label}
588
+ {/* A span, not a button: this is already inside the trigger button, and
589
+ a button inside a button is invalid markup that browsers unnest. */}
590
+ <span
591
+ role="button"
592
+ tabIndex={-1}
593
+ aria-label={`Remove ${option.label}`}
594
+ data-enigma-select-tag-remove=""
595
+ onPointerDown={(event) => {
596
+ // Down, not click: the trigger toggles the panel on click, and
597
+ // removing a tag must not also open it.
598
+ event.preventDefault();
599
+ event.stopPropagation();
600
+ select.instance.remove(option.value);
601
+ }}
602
+ >×</span>
603
+ </span>
604
+ ))}
605
+ {rest > 0 && <span data-enigma-select-tag="" data-rest="">+{rest}</span>}
606
+ </span>
607
+ );
608
+ }
609
+
610
+ export interface SelectContentProps extends ComponentPropsWithoutRef<"div"> {
611
+ /** Keep the panel mounted while it animates out. ms. */
612
+ closeDuration?: number;
613
+ }
614
+
615
+ export function SelectContent({ closeDuration = 120, children, ...props }: SelectContentProps): ReactNode {
616
+ const select = useSelectContext("Select.Content");
617
+ const ref = useRef<HTMLDivElement | null>(null);
618
+ const [mounted, setMounted] = useState(select.state.open);
619
+ const [side, setSide] = useState<"top" | "bottom">("bottom");
620
+
621
+ useEffect(() => {
622
+ if (select.state.open) { setMounted(true); return; }
623
+ // Unmounted a beat later, so the closing animation has something to animate.
624
+ const timer = setTimeout(() => setMounted(false), closeDuration);
625
+ return () => clearTimeout(timer);
626
+ }, [select.state.open, closeDuration]);
627
+
628
+ // Which way it opens is measured, not assumed: a select near the bottom of the window
629
+ // opens upwards, or its list is off the screen and unreachable.
630
+ useLayoutEffect(() => {
631
+ if (!select.state.open || !ref.current) return;
632
+ const trigger = select.triggerRef.current?.getBoundingClientRect();
633
+ if (!trigger) return;
634
+ const height = ref.current.offsetHeight;
635
+ const below = window.innerHeight - trigger.bottom;
636
+ setSide(below < height && trigger.top > below ? "top" : "bottom");
637
+ }, [select.state.open, select.state.visible.length, select.triggerRef]);
638
+
639
+ if (!mounted) return null;
640
+
641
+ return (
642
+ <div
643
+ {...props}
644
+ ref={ref}
645
+ data-enigma-select-content=""
646
+ data-state={select.state.open ? "open" : "closed"}
647
+ data-side={side}
648
+ onKeyDown={(event) => {
649
+ props.onKeyDown?.(event);
650
+ if (!event.defaultPrevented) select.onListKeyDown(event);
651
+ }}
652
+ >
653
+ {children}
654
+ </div>
655
+ );
656
+ }
657
+
658
+ export interface SelectSearchProps extends Omit<ComponentPropsWithoutRef<"input">, "value" | "onChange" | "type"> {
659
+ placeholder?: string;
660
+ }
661
+
662
+ export function SelectSearch({ placeholder = "Search", onKeyDown, ...props }: SelectSearchProps): ReactNode {
663
+ const select = useSelectContext("Select.Search");
664
+ const { instance, state, ids, fieldRef } = select;
665
+
666
+ // Focus lands in the field the moment the panel opens, so the first letter typed is
667
+ // part of the filter rather than lost.
668
+ useEffect(() => {
669
+ if (state.open) fieldRef.current?.focus();
670
+ }, [state.open, fieldRef]);
671
+
672
+ return (
673
+ <input
674
+ {...props}
675
+ ref={fieldRef}
676
+ id={ids.field}
677
+ // `search` and not `text`: it is a search field, and the platform knows what
678
+ // that means for the keyboard's enter key and for autofill.
679
+ type="search"
680
+ role="combobox"
681
+ aria-expanded={state.open}
682
+ aria-controls={ids.list}
683
+ aria-autocomplete="list"
684
+ aria-activedescendant={state.active >= 0 ? select.optionId(state.active) : undefined}
685
+ aria-label={props["aria-label"] ?? placeholder}
686
+ autoComplete="off"
687
+ spellCheck={false}
688
+ placeholder={placeholder}
689
+ data-enigma-select-search=""
690
+ value={state.query}
691
+ onChange={(event) => instance.setQuery(event.target.value)}
692
+ onKeyDown={(event) => {
693
+ onKeyDown?.(event);
694
+ if (!event.defaultPrevented) select.onListKeyDown(event);
695
+ }}
696
+ />
697
+ );
698
+ }
699
+
700
+ export interface SelectListProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
701
+ /** Render one option. Default: icon, label, description and the check. */
702
+ children?: (option: SelectItem, index: number) => ReactNode;
703
+ /** What to show when the filter matches nothing. */
704
+ empty?: ReactNode;
705
+ /**
706
+ * How many rows to put in the document at once. The rest arrive a chunk at a time as
707
+ * the list is scrolled - see the note below. `Infinity` renders the lot.
708
+ */
709
+ chunk?: number;
710
+ }
711
+
712
+ /** Rows kept ahead of the highlight, so arrowing down never runs into an unrendered row. */
713
+ const OVERSCAN = 10;
714
+
715
+ export function SelectList({ children, empty, chunk = 40, ...props }: SelectListProps): ReactNode {
716
+ const select = useSelectContext("Select.List");
717
+ const { state } = select;
718
+ const sentinel = useRef<HTMLDivElement | null>(null);
719
+ const [limit, setLimit] = useState(chunk);
720
+
721
+ /**
722
+ * Only what can be seen, plus a screenful.
723
+ *
724
+ * A select of every country is 250 rows, and with a flag on each one that is 250 images
725
+ * and 250 subtrees built before the panel can be painted - for the seven rows anybody
726
+ * sees. So the list renders a chunk and grows: on scroll, through the sentinel below,
727
+ * and immediately whenever the highlight is heading past the end, because a keyboard
728
+ * reaches row 200 without ever scrolling.
729
+ */
730
+ const shown = Math.min(state.visible.length, Math.max(limit, state.active + 1 + OVERSCAN));
731
+ const visible = useMemo(() => state.visible.slice(0, shown) as SelectItem[], [state.visible, shown]);
732
+ const rest = state.visible.length - shown;
733
+
734
+ // A new filter is a new list: keeping the old window would leave a short result set
735
+ // rendering rows it no longer has, and a long one starting halfway down.
736
+ useEffect(() => { setLimit(chunk); }, [state.query, chunk]);
737
+
738
+ useEffect(() => {
739
+ const target = sentinel.current;
740
+ if (!target) return;
741
+ // Without IntersectionObserver the whole list renders rather than a third of it:
742
+ // slower to open beats unreachable rows.
743
+ if (typeof IntersectionObserver === "undefined") { setLimit(Number.POSITIVE_INFINITY); return; }
744
+ const observer = new IntersectionObserver((entries) => {
745
+ if (entries.some((entry) => entry.isIntersecting)) setLimit((current) => current + chunk);
746
+ }, { root: target.parentElement, rootMargin: "120px" });
747
+ observer.observe(target);
748
+ return () => observer.disconnect();
749
+ }, [chunk, rest]);
750
+
751
+ // Grouped with the flat position kept, so the arrow keys move through ONE sequence and
752
+ // a group heading is invisible to them.
753
+ const groups = useMemo(
754
+ () => groupRows(visible, (option) => option.group ?? ""),
755
+ [visible]
756
+ );
757
+
758
+ return (
759
+ <div
760
+ {...props}
761
+ id={select.ids.list}
762
+ role="listbox"
763
+ aria-multiselectable={state.multiple || undefined}
764
+ aria-labelledby={select.ids.trigger}
765
+ data-enigma-select-list=""
766
+ >
767
+ {state.visible.length === 0
768
+ ? empty ?? <p data-enigma-select-empty="">{state.query.trim() ? `Nothing matches "${shortenQuery(state.query)}".` : "Nothing to choose."}</p>
769
+ : groups.map((group) => (
770
+ <div key={group.label} role="group" aria-label={group.label || undefined} data-enigma-select-group="">
771
+ {group.label && <p data-enigma-select-group-label="" aria-hidden="true">{group.label}</p>}
772
+ {group.rows.map(({ row, position }) => (
773
+ <SelectOptionRow key={row.value} option={row} index={position}>
774
+ {children?.(row, position)}
775
+ </SelectOptionRow>
776
+ ))}
777
+ </div>
778
+ ))}
779
+ {rest > 0 && (
780
+ // The end of what is rendered. Reaching it renders the next chunk, so the
781
+ // list appears endless while the document holds a screenful of it.
782
+ <div ref={sentinel} data-enigma-select-more="" aria-hidden="true" />
783
+ )}
784
+ </div>
785
+ );
786
+ }
787
+
788
+ export interface SelectOptionProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
789
+ option: SelectItem;
790
+ /** Its position in the FLAT list, which is what the keyboard moves through. */
791
+ index: number;
792
+ children?: ReactNode;
793
+ }
794
+
795
+ export function SelectOptionRow({ option, index, children, ...props }: SelectOptionProps): ReactNode {
796
+ const select = useSelectContext("Select.Option");
797
+ const ref = useRef<HTMLDivElement | null>(null);
798
+ const isActive = select.state.active === index;
799
+ const isSelected = select.state.value.includes(option.value);
800
+
801
+ // The highlight can move by key onto a row that is scrolled out of sight, and a
802
+ // highlight nobody can see is the same as no highlight.
803
+ useEffect(() => {
804
+ if (isActive) ref.current?.scrollIntoView({ block: "nearest" });
805
+ }, [isActive]);
806
+
807
+ return (
808
+ <div
809
+ {...props}
810
+ ref={ref}
811
+ id={select.optionId(index)}
812
+ role="option"
813
+ aria-selected={isSelected}
814
+ aria-disabled={option.disabled || undefined}
815
+ data-enigma-select-option=""
816
+ data-active={isActive ? "" : undefined}
817
+ data-selected={isSelected ? "" : undefined}
818
+ data-disabled={option.disabled ? "" : undefined}
819
+ // Down rather than click: the panel's own pointerdown handler closes on an
820
+ // outside press, and a mouseup that lands after a scroll should not choose.
821
+ onPointerDown={(event) => {
822
+ event.preventDefault();
823
+ select.instance.select(option.value);
824
+ }}
825
+ onPointerMove={() => { if (!option.disabled) select.instance.setActive(index); }}
826
+ >
827
+ {children ?? (
828
+ <>
829
+ {option.icon ? <span data-enigma-select-icon="">{option.icon}</span> : null}
830
+ <span data-enigma-select-option-text="">
831
+ <span data-enigma-select-option-label="">{option.label}</span>
832
+ {option.description && <span data-enigma-select-option-description="">{option.description}</span>}
833
+ </span>
834
+ <span data-enigma-select-check="" aria-hidden="true" />
835
+ </>
836
+ )}
837
+ </div>
838
+ );
839
+ }