@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,713 @@
1
+ import { createContextMenu, isAction } from './chunk-3BQVOOAM.js';
2
+ import { shortcutTokens } from './chunk-4VUHQFAT.js';
3
+ import { Slot } from './chunk-HS3X3XCW.js';
4
+ import { shortenQuery } from './chunk-4ZPMP47J.js';
5
+ import { createPortal } from 'react-dom';
6
+ import { createContext, useContext, useLayoutEffect, useId, useMemo, useRef, useState, useEffect, useCallback } from 'react';
7
+ import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
8
+
9
+ // src/react/context-menu/styles.ts
10
+ var CONTEXT_MENU_STYLES = `
11
+ :root {
12
+ --enigma-menu-bg: #1c1c1c;
13
+ --enigma-menu-border: #333333;
14
+ --enigma-menu-radius: 0.625rem;
15
+ --enigma-menu-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
16
+ --enigma-menu-text: #f5f5f5;
17
+ --enigma-menu-muted: #a3a3a3;
18
+ --enigma-menu-font-size: 0.8125rem;
19
+ --enigma-menu-min-width: 12rem;
20
+ --enigma-menu-max-height: 20rem;
21
+
22
+ --enigma-menu-active-bg: #2a2a2a;
23
+ --enigma-menu-accent: #fbbf24;
24
+ --enigma-menu-danger: #f87171;
25
+ --enigma-menu-danger-bg: rgba(248, 113, 113, 0.12);
26
+ }
27
+
28
+ @media (prefers-color-scheme: light) {
29
+ :root {
30
+ --enigma-menu-bg: #ffffff;
31
+ --enigma-menu-border: #e5e5e5;
32
+ --enigma-menu-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
33
+ --enigma-menu-text: #171717;
34
+ --enigma-menu-muted: #737373;
35
+ --enigma-menu-active-bg: #f5f5f5;
36
+ --enigma-menu-accent: #b45309;
37
+ --enigma-menu-danger: #b91c1c;
38
+ --enigma-menu-danger-bg: rgba(185, 28, 28, 0.08);
39
+ }
40
+ }
41
+
42
+ /* Every panel is positioned in VIEWPORT coordinates, which is what a pointer event reports
43
+ and what a menu opened at the pointer needs. Fixed rather than absolute so a scrolling
44
+ ancestor cannot drag it away from the place it was opened. */
45
+ [data-enigma-menu-panel] {
46
+ position: fixed; z-index: 60;
47
+ box-sizing: border-box;
48
+ min-width: var(--enigma-menu-min-width);
49
+ max-width: min(22rem, calc(100vw - 1rem));
50
+ padding: 0.25rem;
51
+ color: var(--enigma-menu-text);
52
+ background: var(--enigma-menu-bg);
53
+ border: 1px solid var(--enigma-menu-border);
54
+ border-radius: var(--enigma-menu-radius);
55
+ box-shadow: var(--enigma-menu-shadow);
56
+ font-size: var(--enigma-menu-font-size);
57
+ animation: enigma-menu-in 90ms ease-out;
58
+ /* Measured before it is placed, and placed before it is seen: without this the panel is
59
+ painted at 0,0 for one frame and jumps to the pointer on the next. */
60
+ visibility: hidden;
61
+ }
62
+ [data-enigma-menu-panel][data-placed] { visibility: visible; }
63
+ @keyframes enigma-menu-in {
64
+ from { opacity: 0; transform: scale(0.98); }
65
+ to { opacity: 1; transform: none; }
66
+ }
67
+ @media (prefers-reduced-motion: reduce) {
68
+ [data-enigma-menu-panel] { animation: none; }
69
+ }
70
+
71
+ /* The heading: what the menu is acting ON. A long file name is cut rather than allowed to
72
+ set the panel's width, and the full one stays reachable through its title attribute. */
73
+ [data-enigma-menu-title] {
74
+ margin: 0; padding: 0.375rem 0.5rem 0.25rem;
75
+ font-size: 0.6875rem; font-weight: 600;
76
+ color: var(--enigma-menu-muted);
77
+ overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
78
+ }
79
+
80
+ [data-enigma-menu-search] {
81
+ width: 100%; box-sizing: border-box;
82
+ padding: 0.375rem 0.5rem 0.5rem;
83
+ font: inherit; font-size: var(--enigma-menu-font-size);
84
+ color: var(--enigma-menu-text); background: transparent;
85
+ border: 0; border-bottom: 1px solid var(--enigma-menu-border);
86
+ outline: none;
87
+ }
88
+ [data-enigma-menu-search]::-webkit-search-cancel-button,
89
+ [data-enigma-menu-search]::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }
90
+
91
+ [data-enigma-menu-list] {
92
+ max-height: var(--enigma-menu-max-height);
93
+ overflow-y: auto; overscroll-behavior: contain;
94
+ }
95
+
96
+ [data-enigma-menu-item] {
97
+ display: flex; align-items: center; gap: 0.5rem;
98
+ padding: 0.3125rem 0.5rem; border-radius: 0.375rem;
99
+ line-height: 1.4;
100
+ color: var(--enigma-menu-text);
101
+ /* The hand, not the arrow. A native menu uses the arrow because the whole surface is
102
+ drawn by the OS and nothing on a page looks like it; here the rows sit on a web page,
103
+ where the hand is what says "this is a thing you press". */
104
+ cursor: pointer; user-select: none;
105
+ }
106
+ [data-enigma-menu-item][data-active] { background: var(--enigma-menu-active-bg); }
107
+ [data-enigma-menu-item][data-disabled] { opacity: 0.45; cursor: not-allowed; }
108
+ [data-enigma-menu-item][data-destructive] { color: var(--enigma-menu-danger); }
109
+ [data-enigma-menu-item][data-destructive][data-active] { background: var(--enigma-menu-danger-bg); }
110
+
111
+ [data-enigma-menu-item-text] { display: grid; min-width: 0; }
112
+ [data-enigma-menu-item-label] { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
113
+ [data-enigma-menu-item-description] {
114
+ font-size: 0.6875rem; color: var(--enigma-menu-muted);
115
+ overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
116
+ }
117
+
118
+ [data-enigma-menu-icon] { display: inline-flex; align-items: center; flex: none; width: 1rem; max-height: 1.25em; line-height: 0; }
119
+
120
+ /* The shortcut, on the right, the way every desktop menu prints it. margin-left: auto is
121
+ what pushes it there, and flex: none is what stops a long label squashing it. */
122
+ [data-enigma-menu-shortcut] {
123
+ display: flex; align-items: center; gap: 0.125rem;
124
+ flex: none; margin-left: auto; padding-left: 1.5rem;
125
+ font-size: 0.6875rem; color: var(--enigma-menu-muted);
126
+ font-variant-numeric: tabular-nums;
127
+ }
128
+ [data-enigma-menu-shortcut] kbd { font: inherit; }
129
+
130
+ /* The submenu arrow shares the shortcut's slot, so a row never has both pushed apart. */
131
+ [data-enigma-menu-arrow] {
132
+ flex: none; margin-left: auto;
133
+ width: 0.35rem; height: 0.35rem;
134
+ border-right: 1.5px solid currentColor; border-top: 1.5px solid currentColor;
135
+ transform: rotate(45deg);
136
+ opacity: 0.6;
137
+ }
138
+ [data-enigma-menu-shortcut] + [data-enigma-menu-arrow] { margin-left: 0.5rem; }
139
+
140
+ /* Check and radio: drawn rather than loaded, and reserving their space either way so
141
+ choosing a row does not shift the label under the pointer. */
142
+ [data-enigma-menu-check] {
143
+ flex: none; width: 1rem; height: 1rem;
144
+ display: grid; place-items: center;
145
+ color: var(--enigma-menu-accent);
146
+ }
147
+ [data-enigma-menu-check]::after { content: ""; width: 0.35rem; height: 0.6rem; opacity: 0; }
148
+ [data-enigma-menu-item][aria-checked="true"] [data-enigma-menu-check]::after {
149
+ opacity: 1;
150
+ border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
151
+ transform: rotate(45deg) translate(-0.05rem, -0.05rem);
152
+ }
153
+ [data-enigma-menu-item][role="menuitemradio"] [data-enigma-menu-check]::after {
154
+ width: 0.4rem; height: 0.4rem; border-radius: 999px; background: currentColor;
155
+ transform: none; border: 0;
156
+ }
157
+
158
+ [data-enigma-menu-separator] {
159
+ height: 1px; margin: 0.25rem 0.25rem;
160
+ background: var(--enigma-menu-border);
161
+ }
162
+
163
+ [data-enigma-menu-label] {
164
+ margin: 0.375rem 0 0.125rem; padding: 0 0.5rem;
165
+ font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
166
+ color: var(--enigma-menu-muted);
167
+ }
168
+
169
+ [data-enigma-menu-status] {
170
+ margin: 0; padding: 0.625rem 0.5rem;
171
+ font-size: var(--enigma-menu-font-size); color: var(--enigma-menu-muted);
172
+ /* The quoted query is cut in the text as well, but a pasted string with no spaces has no
173
+ break opportunity at all - so this is what stops it stretching the panel. */
174
+ overflow-wrap: anywhere;
175
+ }
176
+ [data-enigma-menu-status][data-error] { color: var(--enigma-menu-danger); }
177
+
178
+ /* The end-of-window marker: one pixel, so it has a box to be scrolled into. */
179
+ [data-enigma-menu-more] { height: 1px; }
180
+ `;
181
+ var ContextMenuContext = createContext(null);
182
+ function useContextMenuContext(part) {
183
+ const value = useContext(ContextMenuContext);
184
+ if (!value) throw new Error(`<${part}> must be rendered inside <ContextMenu.Root>.`);
185
+ return value;
186
+ }
187
+ var injected = false;
188
+ function injectStyles() {
189
+ if (injected || typeof document === "undefined") return;
190
+ injected = true;
191
+ if (document.querySelector("[data-enigma-menu-styles]")) return;
192
+ const element = document.createElement("style");
193
+ element.setAttribute("data-enigma-menu-styles", "");
194
+ element.textContent = CONTEXT_MENU_STYLES;
195
+ document.head.prepend(element);
196
+ }
197
+ var MOVE_KEYS = {
198
+ ArrowDown: "ArrowDown",
199
+ ArrowUp: "ArrowUp",
200
+ Home: "Home",
201
+ End: "End",
202
+ PageDown: "PageDown",
203
+ PageUp: "PageUp"
204
+ };
205
+ var OPEN_DELAY = 140;
206
+ var CLOSE_DELAY = 260;
207
+ var TOUCH_SLOP = 10;
208
+ var LONG_PRESS_MS = 500;
209
+ var MARGIN = 8;
210
+ function ContextMenuRoot(props) {
211
+ const {
212
+ items,
213
+ title,
214
+ disabled = false,
215
+ fuse,
216
+ fuseOptions,
217
+ matcher,
218
+ searchKeys,
219
+ cacheMs,
220
+ openDelay = OPEN_DELAY,
221
+ closeDelay = CLOSE_DELAY,
222
+ renderItem,
223
+ loadingLabel = "Loading...",
224
+ emptyLabel = "Nothing here.",
225
+ styles = true,
226
+ children
227
+ } = props;
228
+ useLayoutEffect(() => {
229
+ if (styles) injectStyles();
230
+ }, [styles]);
231
+ const id = useId();
232
+ const ids = useMemo(() => ({ trigger: `${id}-trigger` }), [id]);
233
+ const triggerRef = useRef(null);
234
+ const closing = useRef(0);
235
+ const latest = useRef(props);
236
+ latest.current = props;
237
+ const instance = useMemo(() => createContextMenu({
238
+ items,
239
+ title,
240
+ fuse,
241
+ fuseOptions,
242
+ matcher,
243
+ searchKeys,
244
+ cacheMs,
245
+ onSelect: (item, path) => latest.current.onSelect?.(item, path),
246
+ onOpenChange: (open2) => latest.current.onOpenChange?.(open2)
247
+ // Built once: rebuilding it would drop the open branch and the highlight on every
248
+ // render. Every option below is pushed in through update().
249
+ // eslint-disable-next-line react-hooks/exhaustive-deps
250
+ }), []);
251
+ const [state, setState] = useState(() => instance.state);
252
+ useEffect(() => {
253
+ const unsubscribe = instance.subscribe(setState);
254
+ setState(instance.state);
255
+ return () => {
256
+ unsubscribe();
257
+ instance.destroy();
258
+ };
259
+ }, [instance]);
260
+ const signature = useMemo(() => {
261
+ const seen = /* @__PURE__ */ new WeakSet();
262
+ try {
263
+ return JSON.stringify(items, (key, value) => {
264
+ if (key === "icon") return void 0;
265
+ if (typeof value === "function") return "fn";
266
+ if (typeof value === "bigint") return String(value);
267
+ if (typeof value === "object" && value !== null) {
268
+ if (seen.has(value)) return "[circular]";
269
+ seen.add(value);
270
+ }
271
+ return value;
272
+ });
273
+ } catch {
274
+ return "[unserializable]";
275
+ }
276
+ }, [items]);
277
+ const currentItems = useRef(items);
278
+ currentItems.current = items;
279
+ useEffect(() => {
280
+ instance.update({ items: currentItems.current, title });
281
+ }, [instance, signature, title]);
282
+ const cancelClose = useCallback(() => {
283
+ window.clearTimeout(closing.current);
284
+ }, []);
285
+ const scheduleClose = useCallback((level) => {
286
+ window.clearTimeout(closing.current);
287
+ closing.current = window.setTimeout(() => instance.closeBelow(level), closeDelay);
288
+ }, [instance, closeDelay]);
289
+ useEffect(() => cancelClose, [cancelClose]);
290
+ const open = useCallback((point) => {
291
+ if (disabled) return false;
292
+ cancelClose();
293
+ return instance.open(point);
294
+ }, [instance, disabled, cancelClose]);
295
+ const close = useCallback(() => {
296
+ cancelClose();
297
+ instance.close();
298
+ triggerRef.current?.focus?.();
299
+ }, [instance, cancelClose]);
300
+ useEffect(() => {
301
+ if (!state.open) return;
302
+ const inside = (target) => Boolean(target?.closest?.("[data-enigma-menu-panel]"));
303
+ const onPointerDown = (event) => {
304
+ if (inside(event.target)) return;
305
+ instance.close();
306
+ };
307
+ const onContextMenu = (event) => {
308
+ if (!inside(event.target)) instance.close();
309
+ };
310
+ const onScroll = (event) => {
311
+ if (inside(event.target)) return;
312
+ instance.close();
313
+ };
314
+ const onBlur = () => instance.close();
315
+ document.addEventListener("pointerdown", onPointerDown, true);
316
+ document.addEventListener("contextmenu", onContextMenu, true);
317
+ document.addEventListener("scroll", onScroll, true);
318
+ window.addEventListener("resize", onBlur);
319
+ window.addEventListener("blur", onBlur);
320
+ return () => {
321
+ document.removeEventListener("pointerdown", onPointerDown, true);
322
+ document.removeEventListener("contextmenu", onContextMenu, true);
323
+ document.removeEventListener("scroll", onScroll, true);
324
+ window.removeEventListener("resize", onBlur);
325
+ window.removeEventListener("blur", onBlur);
326
+ };
327
+ }, [state.open, instance]);
328
+ const onMenuKeyDown = useCallback((event) => {
329
+ const deepest = instance.state.levels.length - 1;
330
+ const level = instance.state.levels[deepest];
331
+ if (!level) return;
332
+ const field = event.target?.matches?.("[data-enigma-menu-search]") ?? false;
333
+ const move = MOVE_KEYS[event.key];
334
+ if (move) {
335
+ event.preventDefault();
336
+ instance.move(move);
337
+ return;
338
+ }
339
+ if (event.key === "Enter") {
340
+ event.preventDefault();
341
+ instance.selectActive();
342
+ return;
343
+ }
344
+ if (event.key === "Escape") {
345
+ event.preventDefault();
346
+ event.stopPropagation();
347
+ if (deepest > 0) instance.leaveSubmenu();
348
+ else close();
349
+ return;
350
+ }
351
+ if (event.key === "ArrowRight" && !field) {
352
+ event.preventDefault();
353
+ instance.enterSubmenu();
354
+ return;
355
+ }
356
+ if (event.key === "ArrowLeft" && !field) {
357
+ event.preventDefault();
358
+ instance.leaveSubmenu();
359
+ return;
360
+ }
361
+ if (event.key === "Tab") {
362
+ event.preventDefault();
363
+ close();
364
+ return;
365
+ }
366
+ if (event.key === " " && !field) {
367
+ event.preventDefault();
368
+ instance.selectActive();
369
+ return;
370
+ }
371
+ if (!field && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
372
+ instance.typeahead(event.key);
373
+ }
374
+ }, [instance, close]);
375
+ const context = useMemo(() => ({
376
+ instance,
377
+ state,
378
+ open,
379
+ close,
380
+ ids,
381
+ panelId: (level) => `${id}-panel-${level}`,
382
+ itemId: (level, index) => `${id}-item-${level}-${index}`,
383
+ triggerRef,
384
+ onMenuKeyDown,
385
+ delays: { open: openDelay, close: closeDelay },
386
+ cancelClose,
387
+ scheduleClose,
388
+ renderItem,
389
+ loadingLabel,
390
+ emptyLabel
391
+ }), [instance, state, open, close, ids, id, onMenuKeyDown, openDelay, closeDelay, cancelClose, scheduleClose, renderItem, loadingLabel, emptyLabel]);
392
+ return /* @__PURE__ */ jsx(ContextMenuContext.Provider, { value: context, children });
393
+ }
394
+ function ContextMenuTrigger({ asChild = false, longPress = true, children, ...props }) {
395
+ const menu = useContextMenuContext("ContextMenu.Trigger");
396
+ const Tag = asChild ? Slot : "div";
397
+ const press = useRef(null);
398
+ const cancelPress = useCallback(() => {
399
+ if (press.current) window.clearTimeout(press.current.timer);
400
+ press.current = null;
401
+ }, []);
402
+ useEffect(() => cancelPress, [cancelPress]);
403
+ return /* @__PURE__ */ jsx(
404
+ Tag,
405
+ {
406
+ ...props,
407
+ ref: menu.triggerRef,
408
+ id: menu.ids.trigger,
409
+ tabIndex: props.tabIndex ?? 0,
410
+ "data-enigma-menu-trigger": "",
411
+ "data-open": menu.state.open ? "" : void 0,
412
+ "aria-haspopup": "menu",
413
+ "aria-expanded": menu.state.open,
414
+ onContextMenu: (event) => {
415
+ props.onContextMenu?.(event);
416
+ if (event.defaultPrevented) return;
417
+ if (menu.open({ x: event.clientX, y: event.clientY })) event.preventDefault();
418
+ },
419
+ onPointerDown: (event) => {
420
+ props.onPointerDown?.(event);
421
+ if (event.defaultPrevented || !longPress || event.pointerType !== "touch") return;
422
+ const { clientX: x, clientY: y } = event;
423
+ cancelPress();
424
+ press.current = { x, y, timer: window.setTimeout(() => {
425
+ press.current = null;
426
+ menu.open({ x, y });
427
+ }, LONG_PRESS_MS) };
428
+ },
429
+ onPointerMove: (event) => {
430
+ props.onPointerMove?.(event);
431
+ const held = press.current;
432
+ if (held && (Math.abs(event.clientX - held.x) > TOUCH_SLOP || Math.abs(event.clientY - held.y) > TOUCH_SLOP)) cancelPress();
433
+ },
434
+ onPointerUp: (event) => {
435
+ props.onPointerUp?.(event);
436
+ cancelPress();
437
+ },
438
+ onPointerCancel: (event) => {
439
+ props.onPointerCancel?.(event);
440
+ cancelPress();
441
+ },
442
+ onKeyDown: (event) => {
443
+ props.onKeyDown?.(event);
444
+ if (event.defaultPrevented) return;
445
+ const wanted = event.key === "ContextMenu" || event.key === "F10" && event.shiftKey;
446
+ if (!wanted) return;
447
+ const rect = event.currentTarget.getBoundingClientRect();
448
+ if (menu.open({ x: rect.left + 8, y: rect.top + 8 })) event.preventDefault();
449
+ },
450
+ children
451
+ }
452
+ );
453
+ }
454
+ function ContextMenuContent({ chunk = 40, portal = true, ...props }) {
455
+ const menu = useContextMenuContext("ContextMenu.Content");
456
+ const [mounted, setMounted] = useState(false);
457
+ useEffect(() => {
458
+ setMounted(true);
459
+ }, []);
460
+ if (!menu.state.open || menu.state.levels.length === 0) return null;
461
+ const panels = /* @__PURE__ */ jsx(Fragment, { children: menu.state.levels.map((_, level) => /* @__PURE__ */ jsx(ContextMenuPanel, { level, chunk, ...props }, level)) });
462
+ if (!portal) return panels;
463
+ if (!mounted || typeof document === "undefined") return null;
464
+ return createPortal(panels, document.body);
465
+ }
466
+ function ContextMenuPanel({ level, chunk = 40, ...props }) {
467
+ const menu = useContextMenuContext("ContextMenu.Panel");
468
+ const state = menu.state.levels[level];
469
+ const ref = useRef(null);
470
+ const [placed, setPlaced] = useState(null);
471
+ const point = menu.state.point;
472
+ const rows = state?.visible.length ?? 0;
473
+ const loading = state?.loading ?? false;
474
+ useLayoutEffect(() => {
475
+ const panel = ref.current;
476
+ if (!panel) return;
477
+ const width = panel.offsetWidth;
478
+ const height = panel.offsetHeight;
479
+ const vw = window.innerWidth;
480
+ const vh = window.innerHeight;
481
+ let left;
482
+ let top;
483
+ if (level === 0) {
484
+ left = point?.x ?? MARGIN;
485
+ top = point?.y ?? MARGIN;
486
+ if (left + width > vw - MARGIN) left = Math.max(MARGIN, left - width);
487
+ if (top + height > vh - MARGIN) top = Math.max(MARGIN, top - height);
488
+ } else {
489
+ const parent = document.getElementById(menu.panelId(level - 1))?.getBoundingClientRect();
490
+ const row = document.getElementById(menu.itemId(level - 1, menu.state.levels[level - 1]?.active ?? -1))?.getBoundingClientRect();
491
+ const anchor = parent ?? { left: point?.x ?? MARGIN, right: point?.x ?? MARGIN, top: point?.y ?? MARGIN };
492
+ left = anchor.right - 2;
493
+ if (left + width > vw - MARGIN) left = Math.max(MARGIN, anchor.left - width + 2);
494
+ top = (row?.top ?? anchor.top) - 4;
495
+ if (top + height > vh - MARGIN) top = Math.max(MARGIN, vh - MARGIN - height);
496
+ }
497
+ setPlaced({ left: Math.max(MARGIN, Math.round(left)), top: Math.max(MARGIN, Math.round(top)) });
498
+ }, [level, point?.x, point?.y, rows, loading, menu, menu.state.levels.length]);
499
+ const isPlaced = placed !== null;
500
+ const isDeepest = menu.state.levels.length - 1 === level;
501
+ useEffect(() => {
502
+ const panel = ref.current;
503
+ if (!panel || !isPlaced || !isDeepest) return;
504
+ const field = panel.querySelector("[data-enigma-menu-search]");
505
+ (field ?? panel).focus({ preventScroll: true });
506
+ }, [level, state?.searchable, isPlaced, isDeepest]);
507
+ if (!state) return null;
508
+ return /* @__PURE__ */ jsxs(
509
+ "div",
510
+ {
511
+ ...props,
512
+ ref,
513
+ id: menu.panelId(level),
514
+ role: "menu",
515
+ tabIndex: -1,
516
+ "aria-label": state.title ?? void 0,
517
+ "aria-activedescendant": state.active >= 0 ? menu.itemId(level, state.active) : void 0,
518
+ "data-enigma-menu-panel": "",
519
+ "data-level": level,
520
+ "data-placed": placed ? "" : void 0,
521
+ style: { ...placed, ...props.style },
522
+ onKeyDown: (event) => {
523
+ props.onKeyDown?.(event);
524
+ if (!event.defaultPrevented) menu.onMenuKeyDown(event);
525
+ },
526
+ onPointerEnter: (event) => {
527
+ props.onPointerEnter?.(event);
528
+ menu.cancelClose();
529
+ },
530
+ onPointerLeave: (event) => {
531
+ props.onPointerLeave?.(event);
532
+ if (level !== menu.state.levels.length - 1 || level === 0) return;
533
+ menu.scheduleClose(level - 1);
534
+ },
535
+ children: [
536
+ state.title && /* @__PURE__ */ jsx("p", { "data-enigma-menu-title": "", title: state.title, children: state.title }),
537
+ state.searchable && /* @__PURE__ */ jsx(ContextMenuSearch, { level }),
538
+ /* @__PURE__ */ jsx(ContextMenuList, { level, chunk })
539
+ ]
540
+ }
541
+ );
542
+ }
543
+ function ContextMenuSearch({ level, placeholder = "Search", onKeyDown, ...props }) {
544
+ const menu = useContextMenuContext("ContextMenu.Search");
545
+ const state = menu.state.levels[level];
546
+ if (!state) return null;
547
+ return /* @__PURE__ */ jsx(
548
+ "input",
549
+ {
550
+ ...props,
551
+ type: "search",
552
+ role: "combobox",
553
+ "aria-expanded": true,
554
+ "aria-controls": menu.panelId(level),
555
+ "aria-autocomplete": "list",
556
+ "aria-activedescendant": state.active >= 0 ? menu.itemId(level, state.active) : void 0,
557
+ "aria-label": props["aria-label"] ?? placeholder,
558
+ autoComplete: "off",
559
+ spellCheck: false,
560
+ placeholder,
561
+ "data-enigma-menu-search": "",
562
+ value: state.query,
563
+ onChange: (event) => menu.instance.setQuery(level, event.target.value),
564
+ onKeyDown: (event) => {
565
+ onKeyDown?.(event);
566
+ if (!event.defaultPrevented) menu.onMenuKeyDown(event);
567
+ }
568
+ }
569
+ );
570
+ }
571
+ var OVERSCAN = 10;
572
+ function ContextMenuList({ level, chunk = 40, ...props }) {
573
+ const menu = useContextMenuContext("ContextMenu.List");
574
+ const state = menu.state.levels[level];
575
+ const sentinel = useRef(null);
576
+ const [limit, setLimit] = useState(chunk);
577
+ const query = state?.query ?? "";
578
+ useEffect(() => {
579
+ setLimit(chunk);
580
+ }, [query, chunk]);
581
+ const total = state?.visible.length ?? 0;
582
+ const active = state?.active ?? -1;
583
+ const shown = Math.min(total, Math.max(limit, active + 1 + OVERSCAN));
584
+ const rest = total - shown;
585
+ useEffect(() => {
586
+ const target = sentinel.current;
587
+ if (!target) return;
588
+ if (typeof IntersectionObserver === "undefined") {
589
+ setLimit(Number.POSITIVE_INFINITY);
590
+ return;
591
+ }
592
+ const observer = new IntersectionObserver((entries) => {
593
+ if (entries.some((entry) => entry.isIntersecting)) setLimit((current) => current + chunk);
594
+ }, { root: target.parentElement, rootMargin: "120px" });
595
+ observer.observe(target);
596
+ return () => observer.disconnect();
597
+ }, [chunk, rest]);
598
+ if (!state) return null;
599
+ if (state.loading) return /* @__PURE__ */ jsx("div", { ...props, "data-enigma-menu-list": "", children: /* @__PURE__ */ jsx("p", { "data-enigma-menu-status": "", children: menu.loadingLabel }) });
600
+ if (state.error) {
601
+ return /* @__PURE__ */ jsx("div", { ...props, "data-enigma-menu-list": "", children: /* @__PURE__ */ jsx("p", { "data-enigma-menu-status": "", "data-error": "", role: "alert", children: state.error.message }) });
602
+ }
603
+ if (total === 0) {
604
+ return /* @__PURE__ */ jsx("div", { ...props, "data-enigma-menu-list": "", children: /* @__PURE__ */ jsx("p", { "data-enigma-menu-status": "", children: state.query.trim() ? `Nothing matches "${shortenQuery(state.query)}".` : menu.emptyLabel }) });
605
+ }
606
+ return /* @__PURE__ */ jsxs("div", { ...props, "data-enigma-menu-list": "", children: [
607
+ state.visible.slice(0, shown).map((entry, index) => /* @__PURE__ */ jsx(ContextMenuRow, { level, index, entry }, entryKey(entry, index))),
608
+ rest > 0 && // The end of what is rendered. Reaching it renders the next chunk, so the list
609
+ // appears endless while the document holds a screenful of it.
610
+ /* @__PURE__ */ jsx("div", { ref: sentinel, "data-enigma-menu-more": "", "aria-hidden": "true" })
611
+ ] });
612
+ }
613
+ function entryKey(entry, index) {
614
+ return isAction(entry) ? entry.id : `${entry.type}-${entry.id ?? index}`;
615
+ }
616
+ function ContextMenuRow({ level, index, entry, children, ...props }) {
617
+ const menu = useContextMenuContext("ContextMenu.Item");
618
+ const ref = useRef(null);
619
+ const timers = useRef({ open: 0 });
620
+ const state = menu.state.levels[level];
621
+ const isActive = state?.active === index;
622
+ const item = isAction(entry) ? entry : null;
623
+ const submenu = Boolean(item && (item.loadItems || item.items && item.items.some((child) => isAction(child))));
624
+ const expanded = submenu && menu.state.levels[level + 1]?.path[level] === item?.id;
625
+ useEffect(() => {
626
+ if (isActive) ref.current?.scrollIntoView({ block: "nearest" });
627
+ }, [isActive]);
628
+ useEffect(() => {
629
+ const held = timers.current;
630
+ return () => {
631
+ window.clearTimeout(held.open);
632
+ };
633
+ }, []);
634
+ if (!item) {
635
+ if (entry.type === "separator") return /* @__PURE__ */ jsx("div", { ...props, role: "separator", "data-enigma-menu-separator": "" });
636
+ return /* @__PURE__ */ jsx("p", { ...props, "data-enigma-menu-label": "", "aria-hidden": "true", children: entry.label });
637
+ }
638
+ const checkable = item.checked !== void 0;
639
+ const role = checkable ? item.group ? "menuitemradio" : "menuitemcheckbox" : "menuitem";
640
+ return /* @__PURE__ */ jsx(
641
+ "div",
642
+ {
643
+ ...props,
644
+ ref,
645
+ id: menu.itemId(level, index),
646
+ role,
647
+ "aria-disabled": item.disabled || void 0,
648
+ "aria-haspopup": submenu ? "menu" : void 0,
649
+ "aria-expanded": submenu ? expanded : void 0,
650
+ "aria-checked": checkable ? Boolean(item.checked) : void 0,
651
+ "aria-keyshortcuts": item.shortcut,
652
+ "data-enigma-menu-item": "",
653
+ "data-active": isActive ? "" : void 0,
654
+ "data-disabled": item.disabled ? "" : void 0,
655
+ "data-destructive": item.destructive ? "" : void 0,
656
+ "data-submenu": submenu ? "" : void 0,
657
+ onPointerEnter: () => {
658
+ window.clearTimeout(timers.current.open);
659
+ menu.cancelClose();
660
+ if (!item.disabled) menu.instance.setActive(level, index);
661
+ if (!item.disabled && submenu) {
662
+ timers.current.open = window.setTimeout(() => menu.instance.openSubmenu(level, index), menu.delays.open);
663
+ } else if (menu.state.levels.length > level + 1) {
664
+ menu.scheduleClose(level);
665
+ }
666
+ },
667
+ onPointerLeave: () => {
668
+ window.clearTimeout(timers.current.open);
669
+ },
670
+ onPointerUp: (event) => {
671
+ props.onPointerUp?.(event);
672
+ if (event.defaultPrevented || item.disabled || event.button !== 0) return;
673
+ menu.instance.select(level, index);
674
+ },
675
+ onClick: (event) => {
676
+ props.onClick?.(event);
677
+ if (event.defaultPrevented || item.disabled || event.detail !== 0) return;
678
+ menu.instance.select(level, index);
679
+ },
680
+ children: children ?? menu.renderItem?.(item, level, index) ?? /* @__PURE__ */ jsx(ContextMenuRowContent, { item, submenu })
681
+ }
682
+ );
683
+ }
684
+ function ContextMenuRowContent({ item, submenu }) {
685
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
686
+ item.checked !== void 0 && /* @__PURE__ */ jsx("span", { "data-enigma-menu-check": "", "aria-hidden": "true" }),
687
+ item.icon ? /* @__PURE__ */ jsx("span", { "data-enigma-menu-icon": "", children: item.icon }) : null,
688
+ /* @__PURE__ */ jsxs("span", { "data-enigma-menu-item-text": "", children: [
689
+ /* @__PURE__ */ jsx("span", { "data-enigma-menu-item-label": "", children: item.label }),
690
+ item.description && /* @__PURE__ */ jsx("span", { "data-enigma-menu-item-description": "", children: item.description })
691
+ ] }),
692
+ item.shortcut && // One `<kbd>` per key, so `Ctrl` and `C` can be spaced apart - and written for
693
+ // THIS platform, because a menu that says Ctrl on a Mac is a hint that misleads.
694
+ /* @__PURE__ */ jsx("span", { "data-enigma-menu-shortcut": "", "aria-hidden": "true", children: shortcutTokens(item.shortcut).map((token, index) => /* @__PURE__ */ jsx("kbd", { children: token }, index)) }),
695
+ submenu && /* @__PURE__ */ jsx("span", { "data-enigma-menu-arrow": "", "aria-hidden": "true" })
696
+ ] });
697
+ }
698
+ function ContextMenu(props) {
699
+ const { chunk, longPress, triggerProps, contentProps, children, ...rest } = props;
700
+ return /* @__PURE__ */ jsxs(ContextMenuRoot, { ...rest, children: [
701
+ /* @__PURE__ */ jsx(ContextMenuTrigger, { longPress, ...triggerProps, children }),
702
+ /* @__PURE__ */ jsx(ContextMenuContent, { chunk, ...contentProps })
703
+ ] });
704
+ }
705
+ ContextMenu.Root = ContextMenuRoot;
706
+ ContextMenu.Trigger = ContextMenuTrigger;
707
+ ContextMenu.Content = ContextMenuContent;
708
+ ContextMenu.Panel = ContextMenuPanel;
709
+ ContextMenu.Search = ContextMenuSearch;
710
+ ContextMenu.List = ContextMenuList;
711
+ ContextMenu.Item = ContextMenuRow;
712
+
713
+ export { CONTEXT_MENU_STYLES, ContextMenu, ContextMenuContent, ContextMenuList, ContextMenuPanel, ContextMenuRoot, ContextMenuRow, ContextMenuSearch, ContextMenuTrigger, useContextMenuContext };