@enigmax/primitives 0.22.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 (67) 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-WSQC3PCC.js +466 -0
  12. package/dist/context-menu-D3FtTn7v.d.ts +174 -0
  13. package/dist/{index-BHpOZncw.d.ts → index-DQNnohoo.d.ts} +1 -1
  14. package/dist/index.d.ts +5 -1
  15. package/dist/index.js +8 -4
  16. package/dist/keys-D2zJs1uB.d.ts +100 -0
  17. package/dist/next/index.d.ts +9 -2
  18. package/dist/next/index.js +20 -13
  19. package/dist/react/context-menu.d.ts +202 -0
  20. package/dist/react/context-menu.js +6 -0
  21. package/dist/react/index.d.ts +10 -3
  22. package/dist/react/index.js +19 -12
  23. package/dist/react/input.d.ts +2 -2
  24. package/dist/react/input.js +1 -1
  25. package/dist/react/palette.d.ts +1 -1
  26. package/dist/react/palette.js +3 -3
  27. package/dist/react/search.d.ts +1 -1
  28. package/dist/react/search.js +2 -2
  29. package/dist/react/select.d.ts +217 -0
  30. package/dist/react/select.js +7 -0
  31. package/dist/react/selection.d.ts +104 -0
  32. package/dist/react/selection.js +4 -0
  33. package/dist/react-router/index.d.ts +9 -2
  34. package/dist/react-router/index.js +20 -13
  35. package/dist/search/index.d.ts +2 -2
  36. package/dist/search/index.js +1 -1
  37. package/dist/{search-BD9-5O5U.d.ts → search-DYgqRp37.d.ts} +13 -1
  38. package/dist/{search-UQEXAPQB.js → search-PBZORZ7P.js} +1 -1
  39. package/dist/select-ClSy-J1f.d.ts +100 -0
  40. package/dist/selection-B_pmzHpy.d.ts +150 -0
  41. package/package.json +21 -2
  42. package/recipes/context-menu/styles.css +177 -0
  43. package/recipes/input/styles.css +9 -0
  44. package/recipes/palette/styles.css +3 -0
  45. package/recipes/search/tailwind.tsx +3 -2
  46. package/recipes/select/styles.css +230 -0
  47. package/recipes/toast/styles.css +1 -1
  48. package/registry.json +358 -1
  49. package/src/core/context-menu.ts +694 -0
  50. package/src/core/keys.ts +264 -0
  51. package/src/core/search.ts +19 -0
  52. package/src/core/select.ts +404 -0
  53. package/src/core/selection.ts +648 -0
  54. package/src/index.ts +60 -1
  55. package/src/react/context-menu/context.ts +57 -0
  56. package/src/react/context-menu/index.tsx +94 -0
  57. package/src/react/context-menu/root.tsx +846 -0
  58. package/src/react/context-menu/styles.ts +186 -0
  59. package/src/react/index.ts +68 -1
  60. package/src/react/palette/root.tsx +2 -2
  61. package/src/react/select/context.ts +56 -0
  62. package/src/react/select/index.tsx +96 -0
  63. package/src/react/select/root.tsx +839 -0
  64. package/src/react/select/styles.ts +238 -0
  65. package/src/react/selection/index.tsx +115 -0
  66. package/src/react/selection/use-selection.ts +260 -0
  67. /package/dist/{chunk-U3V4EHOB.js → chunk-3HDEZ2E7.js} +0 -0
@@ -0,0 +1,804 @@
1
+ import { createSelect } from './chunk-NONGREXC.js';
2
+ import { Slot } from './chunk-HS3X3XCW.js';
3
+ import { groupRows } from './chunk-DTWZDONY.js';
4
+ import { shortenQuery } from './chunk-4ZPMP47J.js';
5
+ import { createContext, useContext, useLayoutEffect, useId, useMemo, useRef, useState, useEffect, useCallback } from 'react';
6
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
7
+
8
+ // src/react/select/styles.ts
9
+ var SELECT_STYLES = `
10
+ :root {
11
+ --enigma-select-bg: #171717;
12
+ --enigma-select-border: #404040;
13
+ --enigma-select-border-focus: #a3a3a3;
14
+ --enigma-select-text: #f5f5f5;
15
+ --enigma-select-muted: #a3a3a3;
16
+ --enigma-select-radius: 0.5rem;
17
+ --enigma-select-font-size: 0.875rem;
18
+ --enigma-select-padding: 0.5rem 0.75rem;
19
+
20
+ --enigma-select-panel-bg: #1c1c1c;
21
+ --enigma-select-panel-border: #333333;
22
+ --enigma-select-panel-radius: 0.625rem;
23
+ --enigma-select-panel-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
24
+ --enigma-select-panel-width: 100%;
25
+ --enigma-select-list-height: 15rem;
26
+
27
+ --enigma-select-active-bg: #2a2a2a;
28
+ --enigma-select-accent: #fbbf24;
29
+ --enigma-select-tag-bg: #2a2a2a;
30
+ }
31
+
32
+ @media (prefers-color-scheme: light) {
33
+ :root {
34
+ --enigma-select-bg: #ffffff;
35
+ --enigma-select-border: #d4d4d4;
36
+ --enigma-select-border-focus: #737373;
37
+ --enigma-select-text: #171717;
38
+ --enigma-select-muted: #737373;
39
+ --enigma-select-panel-bg: #ffffff;
40
+ --enigma-select-panel-border: #e5e5e5;
41
+ --enigma-select-panel-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
42
+ --enigma-select-active-bg: #f5f5f5;
43
+ --enigma-select-accent: #b45309;
44
+ --enigma-select-tag-bg: #f0f0f0;
45
+ }
46
+ }
47
+
48
+ [data-enigma-select-root] { position: relative; display: inline-block; min-width: 0; }
49
+
50
+ [data-enigma-select-trigger] {
51
+ display: flex; align-items: center; gap: 0.5rem;
52
+ width: 100%; min-width: 0; box-sizing: border-box;
53
+ padding: var(--enigma-select-padding);
54
+ font: inherit; font-size: var(--enigma-select-font-size); text-align: left;
55
+ color: var(--enigma-select-text); background: var(--enigma-select-bg);
56
+ border: 1px solid var(--enigma-select-border); border-radius: var(--enigma-select-radius);
57
+ cursor: pointer;
58
+ }
59
+ [data-enigma-select-trigger]:focus-visible { outline: 2px solid var(--enigma-select-border-focus); outline-offset: 1px; }
60
+ [data-enigma-select-trigger][data-open] { border-color: var(--enigma-select-border-focus); }
61
+ [data-enigma-select-trigger]:disabled,
62
+ [data-enigma-select-trigger][aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; }
63
+ /* Nothing to choose, or nothing yet: the control reads as unavailable and the caret fades,
64
+ because a caret at full strength promises a panel that will never open. Not the disabled
65
+ look, which would say the field is off rather than that the list is empty. */
66
+ [data-enigma-select-trigger][data-empty],
67
+ [data-enigma-select-trigger][data-loading] { opacity: 1; cursor: default; }
68
+ [data-enigma-select-trigger][data-empty] [data-enigma-select-caret],
69
+ [data-enigma-select-trigger][data-loading] [data-enigma-select-caret] { opacity: 0.25; }
70
+
71
+ /* The indicator: one slot, holding the caret and the \xD7 stacked in the same cell. Side by
72
+ side they would be two targets a pixel apart - one of which throws away your choice - and
73
+ the pair would change the trigger's width the moment a value appeared. */
74
+ [data-enigma-select-indicator] {
75
+ display: grid; place-items: center; flex: none;
76
+ margin-left: auto; width: 1rem; height: 1rem;
77
+ }
78
+ [data-enigma-select-indicator] > * { grid-area: 1 / 1; }
79
+
80
+ /* The caret, drawn rather than loaded: an icon font or an SVG file is a request, and this
81
+ is two borders. */
82
+ [data-enigma-select-caret] {
83
+ width: 0.4rem; height: 0.4rem;
84
+ /* Decoration, and it must not be the hit target: the rotation gives it a stacking
85
+ context, which paints it OVER the \xD7 sharing its cell and swallows the click. */
86
+ pointer-events: none;
87
+ border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
88
+ transform: translateY(-0.1rem) rotate(45deg);
89
+ opacity: 0.6; transition: transform 120ms ease-out, opacity 120ms ease-out;
90
+ }
91
+ [data-enigma-select-trigger][data-open] [data-enigma-select-caret] { transform: translateY(0.1rem) rotate(225deg); }
92
+
93
+ [data-enigma-select-value] {
94
+ display: flex; align-items: center; gap: 0.375rem;
95
+ /* min-width: 0 is what lets the ellipsis happen: a flex item's default minimum is its
96
+ content, so without it a long label pushes the caret out of the button. */
97
+ min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
98
+ }
99
+ [data-enigma-select-value][data-placeholder] { color: var(--enigma-select-muted); }
100
+ [data-enigma-select-value][data-tags] { flex-wrap: wrap; white-space: normal; }
101
+
102
+ [data-enigma-select-tag] {
103
+ display: inline-flex; align-items: center; gap: 0.25rem;
104
+ padding: 0.125rem 0.25rem 0.125rem 0.5rem;
105
+ font-size: 0.8125rem; line-height: 1.4;
106
+ background: var(--enigma-select-tag-bg); border-radius: 999px;
107
+ }
108
+ [data-enigma-select-tag][data-rest] { padding: 0.125rem 0.5rem; color: var(--enigma-select-muted); }
109
+
110
+ [data-enigma-select-tag-remove] {
111
+ display: grid; place-items: center;
112
+ width: 1rem; height: 1rem; border-radius: 999px;
113
+ color: var(--enigma-select-muted); font-size: 0.875rem; line-height: 1; cursor: pointer;
114
+ }
115
+ [data-enigma-select-tag-remove]:hover { color: var(--enigma-select-text); background: var(--enigma-select-active-bg); }
116
+
117
+ /* The \xD7, in the caret's place and only while the control is under the pointer or holds
118
+ focus. ":focus" and not ":focus-visible": a tap focuses the button, and on a touch screen
119
+ there is no hover to reveal it with. */
120
+ [data-enigma-select-clear] {
121
+ display: grid; place-items: center;
122
+ width: 1rem; height: 1rem; border-radius: 999px;
123
+ color: var(--enigma-select-muted); font-size: 0.875rem; line-height: 1; cursor: pointer;
124
+ opacity: 0; pointer-events: none; transition: opacity 120ms ease-out;
125
+ }
126
+ [data-enigma-select-trigger]:hover [data-enigma-select-clear],
127
+ [data-enigma-select-trigger]:focus [data-enigma-select-clear] { opacity: 1; pointer-events: auto; }
128
+ /* Scoped to a trigger that HAS a \xD7 - otherwise hovering a select with nothing to clear
129
+ would hide its caret and leave an empty slot. */
130
+ [data-enigma-select-trigger][data-clearable]:hover [data-enigma-select-caret],
131
+ [data-enigma-select-trigger][data-clearable]:focus [data-enigma-select-caret] { opacity: 0; }
132
+ [data-enigma-select-clear]:hover { color: var(--enigma-select-text); background: var(--enigma-select-active-bg); }
133
+
134
+ [data-enigma-select-content] {
135
+ position: absolute; z-index: 50;
136
+ top: calc(100% + 0.25rem); left: 0;
137
+ min-width: var(--enigma-select-panel-width); box-sizing: border-box;
138
+ padding: 0.25rem;
139
+ background: var(--enigma-select-panel-bg);
140
+ border: 1px solid var(--enigma-select-panel-border);
141
+ border-radius: var(--enigma-select-panel-radius);
142
+ box-shadow: var(--enigma-select-panel-shadow);
143
+ animation: enigma-select-in 120ms ease-out;
144
+ /* A bound, because the content is arbitrary: a long option label or a query typed into
145
+ the filter would otherwise widen the panel with it. min-width still wins for a
146
+ trigger wider than this, which is what it is for. */
147
+ max-width: min(28rem, calc(100vw - 2rem));
148
+ }
149
+ /* Opening upwards is not a variant, it is the same panel measured against the window: near
150
+ the bottom of the screen the list would otherwise be unreachable. */
151
+ [data-enigma-select-content][data-side="top"] { top: auto; bottom: calc(100% + 0.25rem); }
152
+ [data-enigma-select-content][data-state="closed"] { animation: enigma-select-out 120ms ease-in forwards; pointer-events: none; }
153
+
154
+ @keyframes enigma-select-in {
155
+ from { opacity: 0; transform: translateY(-0.25rem); }
156
+ to { opacity: 1; transform: none; }
157
+ }
158
+ @keyframes enigma-select-out {
159
+ from { opacity: 1; }
160
+ to { opacity: 0; transform: translateY(-0.125rem); }
161
+ }
162
+ @media (prefers-reduced-motion: reduce) {
163
+ [data-enigma-select-content],
164
+ [data-enigma-select-content][data-state="closed"] { animation: none; }
165
+ [data-enigma-select-trigger]::after { transition: none; }
166
+ }
167
+
168
+ [data-enigma-select-search] {
169
+ width: 100%; box-sizing: border-box;
170
+ padding: 0.375rem 0.5rem 0.5rem;
171
+ font: inherit; font-size: var(--enigma-select-font-size);
172
+ color: var(--enigma-select-text); background: transparent;
173
+ border: 0; border-bottom: 1px solid var(--enigma-select-panel-border);
174
+ outline: none;
175
+ }
176
+ /* WebKit's own clear cross, gone: it is drawn on no other engine, so leaving it in gives
177
+ half the visitors a control the other half never sees. */
178
+ [data-enigma-select-search]::-webkit-search-cancel-button,
179
+ [data-enigma-select-search]::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }
180
+
181
+ [data-enigma-select-list] {
182
+ max-height: var(--enigma-select-list-height);
183
+ margin-top: 0.25rem;
184
+ overflow-y: auto; overscroll-behavior: contain;
185
+ }
186
+
187
+ [data-enigma-select-group-label] {
188
+ margin: 0.375rem 0 0.125rem; padding: 0 0.5rem;
189
+ font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
190
+ color: var(--enigma-select-muted);
191
+ }
192
+
193
+ [data-enigma-select-option] {
194
+ display: flex; align-items: center; gap: 0.5rem;
195
+ padding: 0.4375rem 0.5rem; border-radius: 0.375rem;
196
+ font-size: var(--enigma-select-font-size); color: var(--enigma-select-text);
197
+ cursor: pointer;
198
+ }
199
+ [data-enigma-select-option][data-active] { background: var(--enigma-select-active-bg); }
200
+ [data-enigma-select-option][data-disabled] { opacity: 0.45; cursor: not-allowed; }
201
+
202
+ [data-enigma-select-option-text] { display: grid; min-width: 0; }
203
+ [data-enigma-select-option-label] { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
204
+ [data-enigma-select-option-description] {
205
+ font-size: 0.75rem; color: var(--enigma-select-muted);
206
+ overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
207
+ }
208
+
209
+ [data-enigma-select-icon] { display: inline-flex; align-items: center; flex: none; max-height: 1.25em; line-height: 0; }
210
+
211
+ /* The check, drawn with two borders and shown only where it belongs. Reserved space either
212
+ way, so choosing a row does not shift the label under the pointer. */
213
+ [data-enigma-select-check] {
214
+ flex: none; margin-left: auto;
215
+ width: 0.35rem; height: 0.6rem;
216
+ border-right: 2px solid var(--enigma-select-accent); border-bottom: 2px solid var(--enigma-select-accent);
217
+ transform: rotate(45deg) translate(-0.05rem, -0.05rem);
218
+ opacity: 0;
219
+ }
220
+ [data-enigma-select-option][data-selected] [data-enigma-select-check] { opacity: 1; }
221
+
222
+ /* The end-of-window marker: one pixel, so it has a box to be scrolled into. */
223
+ [data-enigma-select-more] { height: 1px; }
224
+
225
+ [data-enigma-select-empty] {
226
+ margin: 0; padding: 0.75rem 0.5rem;
227
+ font-size: var(--enigma-select-font-size); color: var(--enigma-select-muted);
228
+ /* The quoted query is cut in the text as well, but a pasted string with no spaces has
229
+ no break opportunity at all - so this is what stops it stretching the panel. */
230
+ overflow-wrap: anywhere;
231
+ }
232
+ `;
233
+ var SelectContext = createContext(null);
234
+ function useSelectContext(part) {
235
+ const value = useContext(SelectContext);
236
+ if (!value) throw new Error(`<${part}> must be rendered inside <Select.Root>.`);
237
+ return value;
238
+ }
239
+ var injected = false;
240
+ function injectStyles() {
241
+ if (injected || typeof document === "undefined") return;
242
+ injected = true;
243
+ if (document.querySelector("[data-enigma-select-styles]")) return;
244
+ const element = document.createElement("style");
245
+ element.setAttribute("data-enigma-select-styles", "");
246
+ element.textContent = SELECT_STYLES;
247
+ document.head.prepend(element);
248
+ }
249
+ var MOVE_KEYS = {
250
+ ArrowDown: "ArrowDown",
251
+ ArrowUp: "ArrowUp",
252
+ Home: "Home",
253
+ End: "End",
254
+ PageDown: "PageDown",
255
+ PageUp: "PageUp"
256
+ };
257
+ var SEARCHABLE_FROM = 8;
258
+ function SelectRoot(props) {
259
+ const {
260
+ options,
261
+ multiple = false,
262
+ searchable = "auto",
263
+ disabled = false,
264
+ name,
265
+ required,
266
+ open: openProp,
267
+ defaultOpen = false,
268
+ onOpenChange,
269
+ loading = false,
270
+ emptyLabel = "No options",
271
+ loadingLabel = "Loading...",
272
+ styles = true,
273
+ clearable,
274
+ className,
275
+ style,
276
+ closeOnSelect,
277
+ fuse,
278
+ fuseOptions,
279
+ matcher,
280
+ searchKeys,
281
+ children
282
+ } = props;
283
+ useLayoutEffect(() => {
284
+ if (styles) injectStyles();
285
+ }, [styles]);
286
+ const isSearchable = searchable === "auto" ? options.length >= SEARCHABLE_FROM : searchable;
287
+ const isEmpty = options.length === 0 && !loading;
288
+ const controlledValue = props.value === void 0 ? void 0 : props.value ?? (multiple ? [] : null);
289
+ const id = useId();
290
+ const ids = useMemo(() => ({
291
+ trigger: `${id}-trigger`,
292
+ list: `${id}-list`,
293
+ field: `${id}-field`
294
+ }), [id]);
295
+ const triggerRef = useRef(null);
296
+ const fieldRef = useRef(null);
297
+ const rootRef = useRef(null);
298
+ const latest = useRef(props);
299
+ latest.current = props;
300
+ const instance = useMemo(() => createSelect({
301
+ options,
302
+ multiple,
303
+ searchable: isSearchable,
304
+ closeOnSelect,
305
+ fuse,
306
+ fuseOptions,
307
+ matcher,
308
+ searchKeys,
309
+ value: props.defaultValue ?? controlledValue ?? (multiple ? [] : null),
310
+ onValueChange: (next, chosen) => {
311
+ const current = latest.current;
312
+ if (current.multiple) current.onValueChange?.(next, chosen);
313
+ else current.onValueChange?.(next, chosen[0] ?? null);
314
+ }
315
+ // Built once: rebuilding it would drop the open panel and the highlight on every
316
+ // render. Every option below is pushed in through update().
317
+ // eslint-disable-next-line react-hooks/exhaustive-deps
318
+ }), []);
319
+ const [state, setState] = useState(() => instance.state);
320
+ useEffect(() => {
321
+ const unsubscribe = instance.subscribe(setState);
322
+ setState(instance.state);
323
+ return () => {
324
+ unsubscribe();
325
+ instance.destroy();
326
+ };
327
+ }, [instance]);
328
+ const signature = useMemo(() => JSON.stringify(options.map((option) => [option.value, option.label, option.description, option.group, option.disabled, option.keywords])), [options]);
329
+ const filterSignature = JSON.stringify([searchKeys ?? null, fuseOptions ?? null]);
330
+ const currentOptions = useRef(options);
331
+ currentOptions.current = options;
332
+ const currentFilter = useRef({ fuseOptions, searchKeys });
333
+ currentFilter.current = { fuseOptions, searchKeys };
334
+ useEffect(() => {
335
+ const { fuseOptions: currentFuseOptions, searchKeys: currentKeys } = currentFilter.current;
336
+ instance.update({
337
+ options: currentOptions.current,
338
+ multiple,
339
+ searchable: isSearchable,
340
+ closeOnSelect,
341
+ fuse,
342
+ matcher,
343
+ fuseOptions: currentFuseOptions,
344
+ searchKeys: currentKeys
345
+ });
346
+ }, [instance, signature, filterSignature, multiple, isSearchable, closeOnSelect, fuse, matcher]);
347
+ useEffect(() => {
348
+ if (controlledValue === void 0) return;
349
+ const wanted = (controlledValue === null ? [] : Array.isArray(controlledValue) ? controlledValue : [controlledValue]).filter((entry) => entry !== "");
350
+ const current = instance.state.value;
351
+ if (current.length === wanted.length && current.every((entry, index) => entry === wanted[index])) return;
352
+ instance.update({ value: [...wanted] });
353
+ });
354
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
355
+ const open = openProp ?? uncontrolledOpen;
356
+ const setOpen = useCallback((next) => {
357
+ if ((disabled || isEmpty || loading) && next) return;
358
+ if (openProp === void 0) setUncontrolledOpen(next);
359
+ instance.setOpen(next);
360
+ onOpenChange?.(next);
361
+ }, [instance, disabled, isEmpty, loading, openProp, onOpenChange]);
362
+ useEffect(() => {
363
+ instance.setOpen(open);
364
+ }, [instance, open]);
365
+ const wasOpen = useRef(state.open);
366
+ useEffect(() => {
367
+ const closedItself = wasOpen.current && !state.open;
368
+ wasOpen.current = state.open;
369
+ if (!closedItself || !open) return;
370
+ const held = rootRef.current?.contains(document.activeElement);
371
+ setOpen(false);
372
+ if (held) triggerRef.current?.focus();
373
+ }, [state.open, open, setOpen]);
374
+ const close = useCallback(() => {
375
+ setOpen(false);
376
+ triggerRef.current?.focus();
377
+ }, [setOpen]);
378
+ useEffect(() => {
379
+ if (!open) return;
380
+ const onPointerDown = (event) => {
381
+ if (rootRef.current?.contains(event.target)) return;
382
+ setOpen(false);
383
+ };
384
+ document.addEventListener("pointerdown", onPointerDown, true);
385
+ return () => document.removeEventListener("pointerdown", onPointerDown, true);
386
+ }, [open, setOpen]);
387
+ const onListKeyDown = useCallback((event) => {
388
+ const move = MOVE_KEYS[event.key];
389
+ if (move) {
390
+ event.preventDefault();
391
+ if (!open) {
392
+ setOpen(true);
393
+ return;
394
+ }
395
+ instance.move(move);
396
+ return;
397
+ }
398
+ if (event.key === "Enter") {
399
+ if (!open) {
400
+ event.preventDefault();
401
+ setOpen(true);
402
+ return;
403
+ }
404
+ event.preventDefault();
405
+ instance.selectActive();
406
+ return;
407
+ }
408
+ if (event.key === "Escape") {
409
+ if (!open) return;
410
+ event.preventDefault();
411
+ event.stopPropagation();
412
+ close();
413
+ return;
414
+ }
415
+ if (event.key === "Tab" && open) {
416
+ setOpen(false);
417
+ return;
418
+ }
419
+ if ((event.key === "Backspace" || event.key === "Delete") && event.currentTarget === triggerRef.current) {
420
+ const chosen = instance.state.value;
421
+ if (chosen.length === 0) return;
422
+ event.preventDefault();
423
+ if (instance.state.multiple) instance.remove(chosen[chosen.length - 1]);
424
+ else instance.clear();
425
+ return;
426
+ }
427
+ if (event.key === " " && event.currentTarget === triggerRef.current) {
428
+ event.preventDefault();
429
+ if (open) instance.selectActive();
430
+ else setOpen(true);
431
+ return;
432
+ }
433
+ if (!fieldRef.current && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
434
+ if (!open) setOpen(true);
435
+ instance.typeahead(event.key);
436
+ }
437
+ }, [instance, open, setOpen, close]);
438
+ const context = useMemo(() => ({
439
+ instance,
440
+ state,
441
+ setOpen,
442
+ disabled,
443
+ clearable: clearable ?? multiple,
444
+ searchable: isSearchable,
445
+ empty: isEmpty,
446
+ loading,
447
+ emptyLabel,
448
+ loadingLabel,
449
+ ids,
450
+ optionId: (index) => `${id}-option-${index}`,
451
+ triggerRef,
452
+ fieldRef,
453
+ close,
454
+ onListKeyDown
455
+ }), [instance, state, setOpen, disabled, clearable, multiple, isSearchable, isEmpty, loading, emptyLabel, loadingLabel, ids, id, close, onListKeyDown]);
456
+ return /* @__PURE__ */ jsx(SelectContext.Provider, { value: context, children: /* @__PURE__ */ jsxs(
457
+ "div",
458
+ {
459
+ ref: rootRef,
460
+ className,
461
+ style,
462
+ "data-enigma-select-root": "",
463
+ "data-open": open ? "" : void 0,
464
+ "data-disabled": disabled ? "" : void 0,
465
+ "data-empty": isEmpty ? "" : void 0,
466
+ "data-loading": loading ? "" : void 0,
467
+ "data-multiple": multiple ? "" : void 0,
468
+ children: [
469
+ children,
470
+ name && state.value.map((entry) => /* @__PURE__ */ jsx("input", { type: "hidden", name: multiple ? `${name}[]` : name, value: entry }, entry)),
471
+ name && state.value.length === 0 && required && // Required with nothing chosen: a field the browser can refuse to
472
+ // submit, kept out of the tab order and off the screen readers.
473
+ /* @__PURE__ */ jsx(
474
+ "input",
475
+ {
476
+ tabIndex: -1,
477
+ required: true,
478
+ "aria-hidden": "true",
479
+ "data-enigma-select-validity": "",
480
+ style: { position: "absolute", width: 0, height: 0, padding: 0, border: 0, opacity: 0, pointerEvents: "none" },
481
+ value: "",
482
+ onChange: () => {
483
+ },
484
+ onFocus: () => triggerRef.current?.focus()
485
+ }
486
+ )
487
+ ]
488
+ }
489
+ ) });
490
+ }
491
+ function SelectTrigger({ asChild = false, indicator = true, children, onClick, onKeyDown, ...props }) {
492
+ const select = useSelectContext("Select.Trigger");
493
+ const Tag = asChild ? Slot : "button";
494
+ const active = select.state.active;
495
+ const showClear = select.clearable && select.state.value.length > 0 && !select.disabled;
496
+ const unavailable = select.disabled || select.empty || select.loading;
497
+ return /* @__PURE__ */ jsxs(
498
+ Tag,
499
+ {
500
+ ...props,
501
+ ref: select.triggerRef,
502
+ id: select.ids.trigger,
503
+ type: asChild ? void 0 : "button",
504
+ role: select.searchable ? void 0 : "combobox",
505
+ "aria-haspopup": "listbox",
506
+ "aria-expanded": select.state.open,
507
+ "aria-controls": select.state.open ? select.ids.list : void 0,
508
+ "aria-activedescendant": !select.searchable && select.state.open && active >= 0 ? select.optionId(active) : void 0,
509
+ "aria-disabled": unavailable || void 0,
510
+ "aria-busy": select.loading || void 0,
511
+ disabled: asChild ? void 0 : select.disabled,
512
+ "data-enigma-select-trigger": "",
513
+ "data-open": select.state.open ? "" : void 0,
514
+ "data-empty": select.empty ? "" : void 0,
515
+ "data-loading": select.loading ? "" : void 0,
516
+ "data-placeholder": select.state.value.length === 0 ? "" : void 0,
517
+ "data-clearable": showClear ? "" : void 0,
518
+ onClick: (event) => {
519
+ onClick?.(event);
520
+ if (event.defaultPrevented || unavailable) return;
521
+ select.setOpen(!select.state.open);
522
+ },
523
+ onKeyDown: (event) => {
524
+ onKeyDown?.(event);
525
+ if (!event.defaultPrevented) select.onListKeyDown(event);
526
+ },
527
+ children: [
528
+ children,
529
+ !asChild && indicator && /**
530
+ * One slot, two glyphs, stacked.
531
+ *
532
+ * The × and the caret occupy the SAME cell and swap on hover, the way a
533
+ * select with a clear button is normally drawn: side by side they are two
534
+ * targets a pixel apart, one of which dismisses your choice, and the pair
535
+ * changes the trigger's width the moment a value appears.
536
+ */
537
+ /* @__PURE__ */ jsxs("span", { "data-enigma-select-indicator": "", children: [
538
+ /* @__PURE__ */ jsx("span", { "data-enigma-select-caret": "", "aria-hidden": "true" }),
539
+ showClear && // A span with a button's role: this is inside the trigger button,
540
+ // and a button inside a button is markup the browser unnests -
541
+ // which drops the handler with it. Backspace does the same thing
542
+ // from the keyboard, since it cannot be a tab stop of its own.
543
+ /* @__PURE__ */ jsx(
544
+ "span",
545
+ {
546
+ role: "button",
547
+ tabIndex: -1,
548
+ "aria-label": "Clear selection",
549
+ "data-enigma-select-clear": "",
550
+ onPointerDown: (event) => {
551
+ event.preventDefault();
552
+ event.stopPropagation();
553
+ select.instance.clear();
554
+ },
555
+ children: "\xD7"
556
+ }
557
+ )
558
+ ] })
559
+ ]
560
+ }
561
+ );
562
+ }
563
+ function SelectValue({ placeholder = "Select", tags, maxTags = 3, children, ...props }) {
564
+ const select = useSelectContext("Select.Value");
565
+ const selected = select.state.selected;
566
+ const showTags = tags ?? select.state.multiple;
567
+ if (select.loading) return /* @__PURE__ */ jsx("span", { ...props, "data-enigma-select-value": "", "data-placeholder": "", "data-loading": "", children: select.loadingLabel });
568
+ if (select.empty) return /* @__PURE__ */ jsx("span", { ...props, "data-enigma-select-value": "", "data-placeholder": "", "data-empty": "", children: select.emptyLabel });
569
+ if (typeof children === "function") return /* @__PURE__ */ jsx("span", { ...props, "data-enigma-select-value": "", children: children(selected) });
570
+ if (children) return /* @__PURE__ */ jsx("span", { ...props, "data-enigma-select-value": "", children });
571
+ if (selected.length === 0) {
572
+ return /* @__PURE__ */ jsx("span", { ...props, "data-enigma-select-value": "", "data-placeholder": "", children: placeholder });
573
+ }
574
+ if (!showTags) {
575
+ const [first] = selected;
576
+ return /* @__PURE__ */ jsxs("span", { ...props, "data-enigma-select-value": "", children: [
577
+ first.icon ? /* @__PURE__ */ jsx("span", { "data-enigma-select-icon": "", children: first.icon }) : null,
578
+ selected.length > 1 ? `${first.label} +${selected.length - 1}` : first.label
579
+ ] });
580
+ }
581
+ const shown = selected.slice(0, maxTags);
582
+ const rest = selected.length - shown.length;
583
+ return /* @__PURE__ */ jsxs("span", { ...props, "data-enigma-select-value": "", "data-tags": "", children: [
584
+ shown.map((option) => /* @__PURE__ */ jsxs("span", { "data-enigma-select-tag": "", children: [
585
+ option.icon ? /* @__PURE__ */ jsx("span", { "data-enigma-select-icon": "", children: option.icon }) : null,
586
+ option.label,
587
+ /* @__PURE__ */ jsx(
588
+ "span",
589
+ {
590
+ role: "button",
591
+ tabIndex: -1,
592
+ "aria-label": `Remove ${option.label}`,
593
+ "data-enigma-select-tag-remove": "",
594
+ onPointerDown: (event) => {
595
+ event.preventDefault();
596
+ event.stopPropagation();
597
+ select.instance.remove(option.value);
598
+ },
599
+ children: "\xD7"
600
+ }
601
+ )
602
+ ] }, option.value)),
603
+ rest > 0 && /* @__PURE__ */ jsxs("span", { "data-enigma-select-tag": "", "data-rest": "", children: [
604
+ "+",
605
+ rest
606
+ ] })
607
+ ] });
608
+ }
609
+ function SelectContent({ closeDuration = 120, children, ...props }) {
610
+ const select = useSelectContext("Select.Content");
611
+ const ref = useRef(null);
612
+ const [mounted, setMounted] = useState(select.state.open);
613
+ const [side, setSide] = useState("bottom");
614
+ useEffect(() => {
615
+ if (select.state.open) {
616
+ setMounted(true);
617
+ return;
618
+ }
619
+ const timer = setTimeout(() => setMounted(false), closeDuration);
620
+ return () => clearTimeout(timer);
621
+ }, [select.state.open, closeDuration]);
622
+ useLayoutEffect(() => {
623
+ if (!select.state.open || !ref.current) return;
624
+ const trigger = select.triggerRef.current?.getBoundingClientRect();
625
+ if (!trigger) return;
626
+ const height = ref.current.offsetHeight;
627
+ const below = window.innerHeight - trigger.bottom;
628
+ setSide(below < height && trigger.top > below ? "top" : "bottom");
629
+ }, [select.state.open, select.state.visible.length, select.triggerRef]);
630
+ if (!mounted) return null;
631
+ return /* @__PURE__ */ jsx(
632
+ "div",
633
+ {
634
+ ...props,
635
+ ref,
636
+ "data-enigma-select-content": "",
637
+ "data-state": select.state.open ? "open" : "closed",
638
+ "data-side": side,
639
+ onKeyDown: (event) => {
640
+ props.onKeyDown?.(event);
641
+ if (!event.defaultPrevented) select.onListKeyDown(event);
642
+ },
643
+ children
644
+ }
645
+ );
646
+ }
647
+ function SelectSearch({ placeholder = "Search", onKeyDown, ...props }) {
648
+ const select = useSelectContext("Select.Search");
649
+ const { instance, state, ids, fieldRef } = select;
650
+ useEffect(() => {
651
+ if (state.open) fieldRef.current?.focus();
652
+ }, [state.open, fieldRef]);
653
+ return /* @__PURE__ */ jsx(
654
+ "input",
655
+ {
656
+ ...props,
657
+ ref: fieldRef,
658
+ id: ids.field,
659
+ type: "search",
660
+ role: "combobox",
661
+ "aria-expanded": state.open,
662
+ "aria-controls": ids.list,
663
+ "aria-autocomplete": "list",
664
+ "aria-activedescendant": state.active >= 0 ? select.optionId(state.active) : void 0,
665
+ "aria-label": props["aria-label"] ?? placeholder,
666
+ autoComplete: "off",
667
+ spellCheck: false,
668
+ placeholder,
669
+ "data-enigma-select-search": "",
670
+ value: state.query,
671
+ onChange: (event) => instance.setQuery(event.target.value),
672
+ onKeyDown: (event) => {
673
+ onKeyDown?.(event);
674
+ if (!event.defaultPrevented) select.onListKeyDown(event);
675
+ }
676
+ }
677
+ );
678
+ }
679
+ var OVERSCAN = 10;
680
+ function SelectList({ children, empty, chunk = 40, ...props }) {
681
+ const select = useSelectContext("Select.List");
682
+ const { state } = select;
683
+ const sentinel = useRef(null);
684
+ const [limit, setLimit] = useState(chunk);
685
+ const shown = Math.min(state.visible.length, Math.max(limit, state.active + 1 + OVERSCAN));
686
+ const visible = useMemo(() => state.visible.slice(0, shown), [state.visible, shown]);
687
+ const rest = state.visible.length - shown;
688
+ useEffect(() => {
689
+ setLimit(chunk);
690
+ }, [state.query, chunk]);
691
+ useEffect(() => {
692
+ const target = sentinel.current;
693
+ if (!target) return;
694
+ if (typeof IntersectionObserver === "undefined") {
695
+ setLimit(Number.POSITIVE_INFINITY);
696
+ return;
697
+ }
698
+ const observer = new IntersectionObserver((entries) => {
699
+ if (entries.some((entry) => entry.isIntersecting)) setLimit((current) => current + chunk);
700
+ }, { root: target.parentElement, rootMargin: "120px" });
701
+ observer.observe(target);
702
+ return () => observer.disconnect();
703
+ }, [chunk, rest]);
704
+ const groups = useMemo(
705
+ () => groupRows(visible, (option) => option.group ?? ""),
706
+ [visible]
707
+ );
708
+ return /* @__PURE__ */ jsxs(
709
+ "div",
710
+ {
711
+ ...props,
712
+ id: select.ids.list,
713
+ role: "listbox",
714
+ "aria-multiselectable": state.multiple || void 0,
715
+ "aria-labelledby": select.ids.trigger,
716
+ "data-enigma-select-list": "",
717
+ children: [
718
+ state.visible.length === 0 ? empty ?? /* @__PURE__ */ jsx("p", { "data-enigma-select-empty": "", children: state.query.trim() ? `Nothing matches "${shortenQuery(state.query)}".` : "Nothing to choose." }) : groups.map((group) => /* @__PURE__ */ jsxs("div", { role: "group", "aria-label": group.label || void 0, "data-enigma-select-group": "", children: [
719
+ group.label && /* @__PURE__ */ jsx("p", { "data-enigma-select-group-label": "", "aria-hidden": "true", children: group.label }),
720
+ group.rows.map(({ row, position }) => /* @__PURE__ */ jsx(SelectOptionRow, { option: row, index: position, children: children?.(row, position) }, row.value))
721
+ ] }, group.label)),
722
+ rest > 0 && // The end of what is rendered. Reaching it renders the next chunk, so the
723
+ // list appears endless while the document holds a screenful of it.
724
+ /* @__PURE__ */ jsx("div", { ref: sentinel, "data-enigma-select-more": "", "aria-hidden": "true" })
725
+ ]
726
+ }
727
+ );
728
+ }
729
+ function SelectOptionRow({ option, index, children, ...props }) {
730
+ const select = useSelectContext("Select.Option");
731
+ const ref = useRef(null);
732
+ const isActive = select.state.active === index;
733
+ const isSelected = select.state.value.includes(option.value);
734
+ useEffect(() => {
735
+ if (isActive) ref.current?.scrollIntoView({ block: "nearest" });
736
+ }, [isActive]);
737
+ return /* @__PURE__ */ jsx(
738
+ "div",
739
+ {
740
+ ...props,
741
+ ref,
742
+ id: select.optionId(index),
743
+ role: "option",
744
+ "aria-selected": isSelected,
745
+ "aria-disabled": option.disabled || void 0,
746
+ "data-enigma-select-option": "",
747
+ "data-active": isActive ? "" : void 0,
748
+ "data-selected": isSelected ? "" : void 0,
749
+ "data-disabled": option.disabled ? "" : void 0,
750
+ onPointerDown: (event) => {
751
+ event.preventDefault();
752
+ select.instance.select(option.value);
753
+ },
754
+ onPointerMove: () => {
755
+ if (!option.disabled) select.instance.setActive(index);
756
+ },
757
+ children: children ?? /* @__PURE__ */ jsxs(Fragment, { children: [
758
+ option.icon ? /* @__PURE__ */ jsx("span", { "data-enigma-select-icon": "", children: option.icon }) : null,
759
+ /* @__PURE__ */ jsxs("span", { "data-enigma-select-option-text": "", children: [
760
+ /* @__PURE__ */ jsx("span", { "data-enigma-select-option-label": "", children: option.label }),
761
+ option.description && /* @__PURE__ */ jsx("span", { "data-enigma-select-option-description": "", children: option.description })
762
+ ] }),
763
+ /* @__PURE__ */ jsx("span", { "data-enigma-select-check": "", "aria-hidden": "true" })
764
+ ] })
765
+ }
766
+ );
767
+ }
768
+ function Select(props) {
769
+ const {
770
+ placeholder = "Select",
771
+ searchPlaceholder = "Search",
772
+ tags,
773
+ maxTags,
774
+ empty,
775
+ renderOption,
776
+ className,
777
+ triggerProps,
778
+ contentProps,
779
+ children,
780
+ ...rest
781
+ } = props;
782
+ const root = rest;
783
+ return /* @__PURE__ */ jsxs(SelectRoot, { ...root, className, children: [
784
+ /* @__PURE__ */ jsx(SelectTrigger, { ...triggerProps, children: /* @__PURE__ */ jsx(SelectValue, { placeholder, tags, maxTags }) }),
785
+ /* @__PURE__ */ jsxs(SelectContent, { ...contentProps, children: [
786
+ /* @__PURE__ */ jsx(SearchSlot, { placeholder: searchPlaceholder }),
787
+ /* @__PURE__ */ jsx(SelectList, { empty, children: renderOption })
788
+ ] }),
789
+ children
790
+ ] });
791
+ }
792
+ function SearchSlot({ placeholder }) {
793
+ const { searchable } = useSelectContext("Select");
794
+ return searchable ? /* @__PURE__ */ jsx(SelectSearch, { placeholder }) : null;
795
+ }
796
+ Select.Root = SelectRoot;
797
+ Select.Trigger = SelectTrigger;
798
+ Select.Value = SelectValue;
799
+ Select.Content = SelectContent;
800
+ Select.Search = SelectSearch;
801
+ Select.List = SelectList;
802
+ Select.Option = SelectOptionRow;
803
+
804
+ export { SELECT_STYLES, Select, SelectContent, SelectList, SelectOptionRow, SelectRoot, SelectSearch, SelectTrigger, SelectValue, useSelectContext };