@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,846 @@
1
+ "use client";
2
+
3
+ import { Slot } from "@/react/slot";
4
+ import { createPortal } from "react-dom";
5
+ import { shortenQuery } from "@/core/search";
6
+ import { shortcutTokens } from "@/core/keys";
7
+ import { CONTEXT_MENU_STYLES } from "@/react/context-menu/styles";
8
+ import { ContextMenuContext, useContextMenuContext, type ContextMenuItem, type ContextMenuNode } from "@/react/context-menu/context";
9
+ import {
10
+ useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState,
11
+ type ComponentPropsWithoutRef, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode
12
+ } from "react";
13
+ import {
14
+ createContextMenu, isAction,
15
+ type ContextMenuEntry, type ContextMenuInstance, type ContextMenuMoveKey,
16
+ type ContextMenuOptions, type ContextMenuPoint, type ContextMenuState
17
+ } from "@/core/context-menu";
18
+
19
+ /**
20
+ * The context menu, as parts.
21
+ *
22
+ * ```tsx
23
+ * <ContextMenu.Root items={rows} onSelect={run}>
24
+ * <ContextMenu.Trigger>{children}</ContextMenu.Trigger>
25
+ * <ContextMenu.Content />
26
+ * </ContextMenu.Root>
27
+ * ```
28
+ *
29
+ * `<ContextMenu>` in the entry next to this file is exactly that composition, and the reason
30
+ * to come here is a menu whose trigger is not the thing it wraps - a "..." button that opens
31
+ * it at its own corner, a canvas that opens it wherever a shape was pressed.
32
+ *
33
+ * WHY EVERY PANEL IS PORTALED AND FIXED. The menu is placed in VIEWPORT coordinates, because
34
+ * that is what a pointer event reports. Left in the tree it inherits any ancestor's
35
+ * `overflow: hidden`, any `transform` (which makes `fixed` resolve against that ancestor
36
+ * rather than the window) and any stacking context - so the menu ends up clipped by the row
37
+ * that opened it. Rendered into `<body>`, it is subject to none of them.
38
+ */
39
+
40
+ let injected = false;
41
+
42
+ /**
43
+ * The baseline look, injected once.
44
+ *
45
+ * A popup cannot ship naked the way a button can: unstyled, a menu is transparent text lying
46
+ * on top of whatever it was opened over - not plain, illegible. So the sheet is injected and
47
+ * PREPENDED to `<head>`, where anything the document already has outranks it by source order
48
+ * without one `!important`; `styles={false}` turns it off, and
49
+ * `@enigmax/primitives/context-menu.css` is the same sheet for anyone who would rather import it.
50
+ */
51
+ function injectStyles(): void {
52
+ if (injected || typeof document === "undefined") return;
53
+ injected = true;
54
+ if (document.querySelector("[data-enigma-menu-styles]")) return;
55
+ const element = document.createElement("style");
56
+ element.setAttribute("data-enigma-menu-styles", "");
57
+ element.textContent = CONTEXT_MENU_STYLES;
58
+ document.head.prepend(element);
59
+ }
60
+
61
+ /** Keys the open panel owns wherever focus happens to be inside it. */
62
+ const MOVE_KEYS: Record<string, ContextMenuMoveKey> = {
63
+ ArrowDown: "ArrowDown",
64
+ ArrowUp: "ArrowUp",
65
+ Home: "Home",
66
+ End: "End",
67
+ PageDown: "PageDown",
68
+ PageUp: "PageUp"
69
+ };
70
+
71
+ /** How long the pointer rests on a row before its submenu opens. Windows waits about this long. */
72
+ const OPEN_DELAY = 140;
73
+ /**
74
+ * How long a submenu survives the pointer leaving its row.
75
+ *
76
+ * This is the number that makes a nested menu usable: the way OUT of a submenu passes over
77
+ * its siblings, so closing on the first crossing means the branch disappears under the
78
+ * pointer on the way to it. Long enough to cross, short enough not to feel stuck.
79
+ */
80
+ const CLOSE_DELAY = 260;
81
+
82
+ /** How far the finger may travel before a long press stops being one. */
83
+ const TOUCH_SLOP = 10;
84
+ /** How long a finger rests before a long press is a right-click. */
85
+ const LONG_PRESS_MS = 500;
86
+
87
+ /** Kept clear of the window edge, so a menu never sits flush against it. */
88
+ const MARGIN = 8;
89
+
90
+ export interface ContextMenuRootProps {
91
+ /** The rows. A list with nothing selectable in it opens no menu at all. */
92
+ items: readonly ContextMenuNode[];
93
+ /** A heading over the rows, naming what the menu is acting on. */
94
+ title?: string;
95
+ /** A row was invoked. The menu has already closed by the time this runs. */
96
+ onSelect?: (item: ContextMenuItem, path: string[]) => void;
97
+ onOpenChange?: (open: boolean) => void;
98
+ /** Nothing opens, and the press is left to the browser. For a disabled row or a read-only view. */
99
+ disabled?: boolean;
100
+ /** Fuse.js's constructor, for fuzzy filtering. Omit it for the built-in matcher. */
101
+ fuse?: ContextMenuOptions["fuse"];
102
+ fuseOptions?: Record<string, unknown>;
103
+ matcher?: ContextMenuOptions["matcher"];
104
+ searchKeys?: string[];
105
+ /** How long a fetched submenu stays cached. 0 refetches every time. Default 5 minutes. */
106
+ cacheMs?: number;
107
+ /** Hover timings, in ms. Leave them alone unless the design genuinely needs otherwise. */
108
+ openDelay?: number;
109
+ closeDelay?: number;
110
+ /** Draw a row instead of the default icon / label / shortcut. */
111
+ renderItem?: (item: ContextMenuItem, level: number, index: number) => ReactNode;
112
+ loadingLabel?: ReactNode;
113
+ emptyLabel?: ReactNode;
114
+ /** Inject the baseline stylesheet. See the note above. */
115
+ styles?: boolean;
116
+ children?: ReactNode;
117
+ }
118
+
119
+ export function ContextMenuRoot(props: ContextMenuRootProps): ReactNode {
120
+ const {
121
+ items,
122
+ title,
123
+ disabled = false,
124
+ fuse,
125
+ fuseOptions,
126
+ matcher,
127
+ searchKeys,
128
+ cacheMs,
129
+ openDelay = OPEN_DELAY,
130
+ closeDelay = CLOSE_DELAY,
131
+ renderItem,
132
+ loadingLabel = "Loading...",
133
+ emptyLabel = "Nothing here.",
134
+ styles = true,
135
+ children
136
+ } = props;
137
+
138
+ // Before paint: a sheet applied after the first frame shows the menu unstyled first.
139
+ useLayoutEffect(() => { if (styles) injectStyles(); }, [styles]);
140
+
141
+ const id = useId();
142
+ const ids = useMemo(() => ({ trigger: `${id}-trigger` }), [id]);
143
+ const triggerRef = useRef<HTMLElement | null>(null);
144
+ /**
145
+ * The pending "close this branch" beat, one for the whole menu rather than one per panel.
146
+ *
147
+ * Whoever the pointer arrives at has to be able to cancel it, and only a timer held here
148
+ * can be: one owned by the panel the pointer LEFT outlives the row it came back to, and
149
+ * the branch that row reopens is then shut by a timer nothing on screen can reach.
150
+ */
151
+ const closing = useRef(0);
152
+
153
+ // Kept in a ref so the instance - built once - always calls the CURRENT props rather than
154
+ // the ones it closed over on the first render.
155
+ const latest = useRef(props);
156
+ latest.current = props;
157
+
158
+ const instance = useMemo<ContextMenuInstance>(() => createContextMenu({
159
+ items: items as readonly ContextMenuEntry[],
160
+ title,
161
+ fuse,
162
+ fuseOptions,
163
+ matcher,
164
+ searchKeys,
165
+ cacheMs,
166
+ onSelect: (item, path) => latest.current.onSelect?.(item as ContextMenuItem, path),
167
+ onOpenChange: (open) => latest.current.onOpenChange?.(open)
168
+ // Built once: rebuilding it would drop the open branch and the highlight on every
169
+ // render. Every option below is pushed in through update().
170
+ // eslint-disable-next-line react-hooks/exhaustive-deps
171
+ }), []);
172
+
173
+ const [state, setState] = useState<ContextMenuState>(() => instance.state);
174
+
175
+ useEffect(() => {
176
+ const unsubscribe = instance.subscribe(setState);
177
+ setState(instance.state);
178
+ return () => {
179
+ unsubscribe();
180
+ instance.destroy();
181
+ };
182
+ }, [instance]);
183
+
184
+ /**
185
+ * What the rows ARE, as a string.
186
+ *
187
+ * The effect below cannot depend on the array: `items={[{ id: "copy", ... }]}` is a new
188
+ * array of new objects on every render, so pushing it in would emit a new state, render
189
+ * again, and build another array - a loop, and inline items are how everyone writes them.
190
+ * React nodes are left out of the signature on purpose: an icon is a fresh element object
191
+ * every render and no two are ever equal. Functions are left out for the same reason,
192
+ * which is why `loadItems` is keyed by the row's id and not by its identity.
193
+ *
194
+ * `data` is whatever the caller wants back in `onSelect` - a record, a DOM node, a class
195
+ * instance that points back at the thing holding it. A cycle in there is not a mistake,
196
+ * so it is written as a marker rather than followed, and anything else that cannot be
197
+ * written leaves the tree unsignable instead of throwing out of a render.
198
+ */
199
+ const signature = useMemo(() => {
200
+ const seen = new WeakSet<object>();
201
+ try {
202
+ return JSON.stringify(items, (key, value) => {
203
+ if (key === "icon") return undefined;
204
+ if (typeof value === "function") return "fn";
205
+ if (typeof value === "bigint") return String(value);
206
+ if (typeof value === "object" && value !== null) {
207
+ if (seen.has(value)) return "[circular]";
208
+ seen.add(value);
209
+ }
210
+ return value as unknown;
211
+ });
212
+ } catch {
213
+ return "[unserializable]";
214
+ }
215
+ }, [items]);
216
+
217
+ const currentItems = useRef(items);
218
+ currentItems.current = items;
219
+
220
+ useEffect(() => {
221
+ instance.update({ items: currentItems.current as readonly ContextMenuEntry[], title });
222
+ }, [instance, signature, title]);
223
+
224
+ const cancelClose = useCallback(() => { window.clearTimeout(closing.current); }, []);
225
+
226
+ const scheduleClose = useCallback((level: number) => {
227
+ window.clearTimeout(closing.current);
228
+ closing.current = window.setTimeout(() => instance.closeBelow(level), closeDelay);
229
+ }, [instance, closeDelay]);
230
+
231
+ useEffect(() => cancelClose, [cancelClose]);
232
+
233
+ const open = useCallback((point: ContextMenuPoint): boolean => {
234
+ if (disabled) return false;
235
+ cancelClose();
236
+ return instance.open(point);
237
+ }, [instance, disabled, cancelClose]);
238
+
239
+ const close = useCallback(() => {
240
+ cancelClose();
241
+ instance.close();
242
+ // Focus goes back to the trigger rather than to the body: the menu is gone, and a
243
+ // keyboard visitor left standing on nothing has to tab from the top of the page.
244
+ triggerRef.current?.focus?.();
245
+ }, [instance, cancelClose]);
246
+
247
+ /**
248
+ * What dismisses it, other than choosing something.
249
+ *
250
+ * A press outside is handled on the way DOWN, before whatever was pressed runs, so the
251
+ * click that dismisses the menu does not also activate what is under it. Scrolling and
252
+ * resizing close it outright rather than moving it: the menu was opened at a point on the
253
+ * page that is no longer under the pointer, and every desktop menu does the same.
254
+ */
255
+ useEffect(() => {
256
+ if (!state.open) return;
257
+
258
+ const inside = (target: EventTarget | null) => Boolean((target as Element | null)?.closest?.("[data-enigma-menu-panel]"));
259
+ const onPointerDown = (event: globalThis.PointerEvent) => {
260
+ if (inside(event.target)) return;
261
+ // Not `close()`: focus belongs wherever the press is going, not back on the
262
+ // trigger. Taking it would move the caret out of the field the user just clicked.
263
+ instance.close();
264
+ };
265
+ const onContextMenu = (event: globalThis.MouseEvent) => {
266
+ // A right-click elsewhere opens THAT menu; ours has to be gone before it does.
267
+ if (!inside(event.target)) instance.close();
268
+ };
269
+ const onScroll = (event: Event) => {
270
+ if (inside(event.target)) return;
271
+ instance.close();
272
+ };
273
+ const onBlur = () => instance.close();
274
+
275
+ document.addEventListener("pointerdown", onPointerDown, true);
276
+ document.addEventListener("contextmenu", onContextMenu, true);
277
+ // Capture, because a scroll inside a container does not bubble to the window.
278
+ document.addEventListener("scroll", onScroll, true);
279
+ window.addEventListener("resize", onBlur);
280
+ window.addEventListener("blur", onBlur);
281
+ return () => {
282
+ document.removeEventListener("pointerdown", onPointerDown, true);
283
+ document.removeEventListener("contextmenu", onContextMenu, true);
284
+ document.removeEventListener("scroll", onScroll, true);
285
+ window.removeEventListener("resize", onBlur);
286
+ window.removeEventListener("blur", onBlur);
287
+ };
288
+ }, [state.open, instance]);
289
+
290
+ const onMenuKeyDown = useCallback((event: KeyboardEvent) => {
291
+ const deepest = instance.state.levels.length - 1;
292
+ const level = instance.state.levels[deepest];
293
+ if (!level) return;
294
+ const field = (event.target as HTMLElement | null)?.matches?.("[data-enigma-menu-search]") ?? false;
295
+
296
+ const move = MOVE_KEYS[event.key];
297
+ if (move) {
298
+ event.preventDefault();
299
+ instance.move(move);
300
+ return;
301
+ }
302
+ if (event.key === "Enter") {
303
+ event.preventDefault();
304
+ instance.selectActive();
305
+ return;
306
+ }
307
+ if (event.key === "Escape") {
308
+ event.preventDefault();
309
+ // Stopped here so one Escape closes the menu and not the dialog around it.
310
+ event.stopPropagation();
311
+ // A submenu closes back to its parent; the root closes the menu. Anything else
312
+ // would make a three-deep menu take one keystroke to dismiss and lose your place.
313
+ if (deepest > 0) instance.leaveSubmenu();
314
+ else close();
315
+ return;
316
+ }
317
+ if (event.key === "ArrowRight" && !field) {
318
+ event.preventDefault();
319
+ instance.enterSubmenu();
320
+ return;
321
+ }
322
+ if (event.key === "ArrowLeft" && !field) {
323
+ event.preventDefault();
324
+ instance.leaveSubmenu();
325
+ return;
326
+ }
327
+ if (event.key === "Tab") {
328
+ // A menu is not part of the page's tab order: tabbing out of it dismisses it,
329
+ // which is what every desktop menu does and what stops focus escaping into the
330
+ // portal's siblings.
331
+ event.preventDefault();
332
+ close();
333
+ return;
334
+ }
335
+ // Space chooses where there is no field to type into; inside the filter it is a
336
+ // space, and taking it would make phrases unsearchable.
337
+ if (event.key === " " && !field) {
338
+ event.preventDefault();
339
+ instance.selectActive();
340
+ return;
341
+ }
342
+ // Typeahead, the way a desktop menu does it - and only where there is no field,
343
+ // because there the letters ARE the filter.
344
+ if (!field && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
345
+ instance.typeahead(event.key);
346
+ }
347
+ }, [instance, close]);
348
+
349
+ const context = useMemo(() => ({
350
+ instance,
351
+ state,
352
+ open,
353
+ close,
354
+ ids,
355
+ panelId: (level: number) => `${id}-panel-${level}`,
356
+ itemId: (level: number, index: number) => `${id}-item-${level}-${index}`,
357
+ triggerRef,
358
+ onMenuKeyDown,
359
+ delays: { open: openDelay, close: closeDelay },
360
+ cancelClose,
361
+ scheduleClose,
362
+ renderItem,
363
+ loadingLabel,
364
+ emptyLabel
365
+ }), [instance, state, open, close, ids, id, onMenuKeyDown, openDelay, closeDelay, cancelClose, scheduleClose, renderItem, loadingLabel, emptyLabel]);
366
+
367
+ return <ContextMenuContext.Provider value={context}>{children}</ContextMenuContext.Provider>;
368
+ }
369
+
370
+ export interface ContextMenuTriggerProps extends ComponentPropsWithoutRef<"div"> {
371
+ /** Put the behaviour on your own element instead of a wrapper div. */
372
+ asChild?: boolean;
373
+ /** Open on a long press as well, which is what a right-click is on a touch screen. */
374
+ longPress?: boolean;
375
+ }
376
+
377
+ /**
378
+ * The area a right-click opens the menu over.
379
+ *
380
+ * It is `tabIndex={0}` and answers Shift+F10 and the Menu key, because a context menu reached
381
+ * only by right-clicking is one a keyboard user cannot open at all - and those two are the
382
+ * shortcuts the platform already assigns to it. Opened that way it appears at the element's
383
+ * own corner rather than at a pointer that was never there.
384
+ */
385
+ export function ContextMenuTrigger({ asChild = false, longPress = true, children, ...props }: ContextMenuTriggerProps): ReactNode {
386
+ const menu = useContextMenuContext("ContextMenu.Trigger");
387
+ const Tag = asChild ? Slot : "div";
388
+ const press = useRef<{ timer: number; x: number; y: number; } | null>(null);
389
+
390
+ const cancelPress = useCallback(() => {
391
+ if (press.current) window.clearTimeout(press.current.timer);
392
+ press.current = null;
393
+ }, []);
394
+
395
+ useEffect(() => cancelPress, [cancelPress]);
396
+
397
+ return (
398
+ <Tag
399
+ {...props}
400
+ ref={menu.triggerRef as never}
401
+ id={menu.ids.trigger}
402
+ tabIndex={props.tabIndex ?? 0}
403
+ data-enigma-menu-trigger=""
404
+ data-open={menu.state.open ? "" : undefined}
405
+ aria-haspopup="menu"
406
+ aria-expanded={menu.state.open}
407
+ onContextMenu={(event) => {
408
+ props.onContextMenu?.(event);
409
+ if (event.defaultPrevented) return;
410
+ // The default is prevented only when a menu actually opened. With nothing to
411
+ // show, the browser's own menu is better than none - and than an empty box.
412
+ if (menu.open({ x: event.clientX, y: event.clientY })) event.preventDefault();
413
+ }}
414
+ onPointerDown={(event: PointerEvent<HTMLDivElement>) => {
415
+ props.onPointerDown?.(event);
416
+ if (event.defaultPrevented || !longPress || event.pointerType !== "touch") return;
417
+ const { clientX: x, clientY: y } = event;
418
+ cancelPress();
419
+ press.current = { x, y, timer: window.setTimeout(() => { press.current = null; menu.open({ x, y }); }, LONG_PRESS_MS) };
420
+ }}
421
+ onPointerMove={(event: PointerEvent<HTMLDivElement>) => {
422
+ props.onPointerMove?.(event);
423
+ // A finger that travels is a scroll or a drag, not a press. Without this the
424
+ // menu opens in the middle of flicking the list.
425
+ const held = press.current;
426
+ if (held && (Math.abs(event.clientX - held.x) > TOUCH_SLOP || Math.abs(event.clientY - held.y) > TOUCH_SLOP)) cancelPress();
427
+ }}
428
+ onPointerUp={(event: PointerEvent<HTMLDivElement>) => { props.onPointerUp?.(event); cancelPress(); }}
429
+ onPointerCancel={(event: PointerEvent<HTMLDivElement>) => { props.onPointerCancel?.(event); cancelPress(); }}
430
+ onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
431
+ props.onKeyDown?.(event);
432
+ if (event.defaultPrevented) return;
433
+ // Shift+F10 and the Menu key: what the platform gives a keyboard user to open
434
+ // a context menu with, on every desktop there is.
435
+ const wanted = event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey);
436
+ if (!wanted) return;
437
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
438
+ // At the element's own corner, inset a little: a menu opened by key has no
439
+ // pointer to appear under, and the corner is where every platform puts it.
440
+ if (menu.open({ x: rect.left + 8, y: rect.top + 8 })) event.preventDefault();
441
+ }}
442
+ >
443
+ {children}
444
+ </Tag>
445
+ );
446
+ }
447
+
448
+ export interface ContextMenuContentProps extends ComponentPropsWithoutRef<"div"> {
449
+ /** How many rows to put in the document at once. `Infinity` renders the lot. */
450
+ chunk?: number;
451
+ /** Render the panels where they are instead of in `<body>`. See the note on Root. */
452
+ portal?: boolean;
453
+ }
454
+
455
+ /** The whole open branch: the root panel, then one panel per open submenu. */
456
+ export function ContextMenuContent({ chunk = 40, portal = true, ...props }: ContextMenuContentProps): ReactNode {
457
+ const menu = useContextMenuContext("ContextMenu.Content");
458
+ const [mounted, setMounted] = useState(false);
459
+
460
+ // The portal cannot exist during a server render and must not be created on the first
461
+ // client render either, or the two trees disagree.
462
+ useEffect(() => { setMounted(true); }, []);
463
+
464
+ if (!menu.state.open || menu.state.levels.length === 0) return null;
465
+
466
+ const panels = (
467
+ <>
468
+ {menu.state.levels.map((_, level) => (
469
+ <ContextMenuPanel key={level} level={level} chunk={chunk} {...props} />
470
+ ))}
471
+ </>
472
+ );
473
+
474
+ if (!portal) return panels;
475
+ if (!mounted || typeof document === "undefined") return null;
476
+ return createPortal(panels, document.body);
477
+ }
478
+
479
+ export interface ContextMenuPanelProps extends ComponentPropsWithoutRef<"div"> {
480
+ level: number;
481
+ chunk?: number;
482
+ }
483
+
484
+ /**
485
+ * One level: its heading, its filter and its rows, placed against the thing that opened it.
486
+ *
487
+ * The root panel is placed at the pointer; a submenu is placed against the ROW that opened
488
+ * it. Both flip rather than overflow - a menu opened near the right edge of the window opens
489
+ * leftwards, and one near the bottom opens upwards, because the alternative is a panel whose
490
+ * rows are off the screen and unreachable.
491
+ */
492
+ export function ContextMenuPanel({ level, chunk = 40, ...props }: ContextMenuPanelProps): ReactNode {
493
+ const menu = useContextMenuContext("ContextMenu.Panel");
494
+ const state = menu.state.levels[level];
495
+ const ref = useRef<HTMLDivElement | null>(null);
496
+ const [placed, setPlaced] = useState<CSSProperties | null>(null);
497
+
498
+ const point = menu.state.point;
499
+ const rows = state?.visible.length ?? 0;
500
+ const loading = state?.loading ?? false;
501
+
502
+ useLayoutEffect(() => {
503
+ const panel = ref.current;
504
+ if (!panel) return;
505
+ // Measured with the placement cleared, so a panel that shrank is not measured against
506
+ // the width it had while it was longer.
507
+ const width = panel.offsetWidth;
508
+ const height = panel.offsetHeight;
509
+ const vw = window.innerWidth;
510
+ const vh = window.innerHeight;
511
+
512
+ let left: number;
513
+ let top: number;
514
+
515
+ if (level === 0) {
516
+ left = point?.x ?? MARGIN;
517
+ top = point?.y ?? MARGIN;
518
+ // Flipped rather than clamped: a menu whose left edge is dragged back to fit
519
+ // would sit UNDER the pointer, and the first row would be chosen by the release.
520
+ if (left + width > vw - MARGIN) left = Math.max(MARGIN, left - width);
521
+ if (top + height > vh - MARGIN) top = Math.max(MARGIN, top - height);
522
+ } else {
523
+ const parent = document.getElementById(menu.panelId(level - 1))?.getBoundingClientRect();
524
+ const row = document.getElementById(menu.itemId(level - 1, menu.state.levels[level - 1]?.active ?? -1))?.getBoundingClientRect();
525
+ const anchor = parent ?? { left: point?.x ?? MARGIN, right: point?.x ?? MARGIN, top: point?.y ?? MARGIN } as DOMRect;
526
+ // Overlapped by a couple of pixels on purpose: a gap between a row and its
527
+ // submenu is a strip of page that closes the branch when the pointer crosses it.
528
+ left = anchor.right - 2;
529
+ if (left + width > vw - MARGIN) left = Math.max(MARGIN, anchor.left - width + 2);
530
+ top = (row?.top ?? anchor.top) - 4;
531
+ if (top + height > vh - MARGIN) top = Math.max(MARGIN, vh - MARGIN - height);
532
+ }
533
+
534
+ setPlaced({ left: Math.max(MARGIN, Math.round(left)), top: Math.max(MARGIN, Math.round(top)) });
535
+ // Re-placed whenever the panel's own size can have changed: a filter that shortens the
536
+ // list, a fetched submenu that arrived, another chunk of a long one.
537
+ }, [level, point?.x, point?.y, rows, loading, menu, menu.state.levels.length]);
538
+
539
+ /**
540
+ * The panel takes focus so the keyboard has somewhere to be - the search field when there
541
+ * is one, the panel itself otherwise. Without it the keys keep going to the trigger, and
542
+ * every arrow press looks like the menu ignoring it.
543
+ *
544
+ * AFTER it has been placed, which is the part that is easy to get wrong: the panel is
545
+ * `visibility: hidden` until then so it cannot be seen at 0,0 for a frame, and `focus()`
546
+ * on a hidden element does nothing at all - silently, with the panel on screen a moment
547
+ * later looking exactly as if it had worked.
548
+ *
549
+ * And whenever this panel becomes the DEEPEST one again, not only when it mounts: closing
550
+ * a submenu unmounts the element that held focus, which leaves it on the body - and the
551
+ * next Escape then reaches nothing at all.
552
+ */
553
+ const isPlaced = placed !== null;
554
+ const isDeepest = menu.state.levels.length - 1 === level;
555
+ useEffect(() => {
556
+ const panel = ref.current;
557
+ if (!panel || !isPlaced || !isDeepest) return;
558
+ const field = panel.querySelector<HTMLInputElement>("[data-enigma-menu-search]");
559
+ (field ?? panel).focus({ preventScroll: true });
560
+ }, [level, state?.searchable, isPlaced, isDeepest]);
561
+
562
+ if (!state) return null;
563
+
564
+ return (
565
+ <div
566
+ {...props}
567
+ ref={ref}
568
+ id={menu.panelId(level)}
569
+ role="menu"
570
+ tabIndex={-1}
571
+ aria-label={state.title ?? undefined}
572
+ aria-activedescendant={state.active >= 0 ? menu.itemId(level, state.active) : undefined}
573
+ data-enigma-menu-panel=""
574
+ data-level={level}
575
+ data-placed={placed ? "" : undefined}
576
+ style={{ ...placed, ...props.style }}
577
+ onKeyDown={(event) => {
578
+ props.onKeyDown?.(event);
579
+ if (!event.defaultPrevented) menu.onMenuKeyDown(event);
580
+ }}
581
+ onPointerEnter={(event) => {
582
+ props.onPointerEnter?.(event);
583
+ // Coming back into a panel cancels the close scheduled on the way out of one.
584
+ // Without this, crossing a sibling on the way to a submenu closes it.
585
+ menu.cancelClose();
586
+ }}
587
+ onPointerLeave={(event) => {
588
+ props.onPointerLeave?.(event);
589
+ // Leaving the DEEPEST panel closes it after a beat, so the pointer can pass
590
+ // over the parent's other rows on its way somewhere without the branch
591
+ // vanishing under it.
592
+ if (level !== menu.state.levels.length - 1 || level === 0) return;
593
+ menu.scheduleClose(level - 1);
594
+ }}
595
+ >
596
+ {state.title && <p data-enigma-menu-title="" title={state.title}>{state.title}</p>}
597
+ {state.searchable && <ContextMenuSearch level={level} />}
598
+ <ContextMenuList level={level} chunk={chunk} />
599
+ </div>
600
+ );
601
+ }
602
+
603
+ export interface ContextMenuSearchProps extends Omit<ComponentPropsWithoutRef<"input">, "value" | "onChange" | "type"> {
604
+ level: number;
605
+ placeholder?: string;
606
+ }
607
+
608
+ export function ContextMenuSearch({ level, placeholder = "Search", onKeyDown, ...props }: ContextMenuSearchProps): ReactNode {
609
+ const menu = useContextMenuContext("ContextMenu.Search");
610
+ const state = menu.state.levels[level];
611
+ if (!state) return null;
612
+
613
+ return (
614
+ <input
615
+ {...props}
616
+ // `search` and not `text`: it is a search field, and the platform knows what that
617
+ // means for the keyboard's enter key and for autofill.
618
+ type="search"
619
+ role="combobox"
620
+ aria-expanded
621
+ aria-controls={menu.panelId(level)}
622
+ aria-autocomplete="list"
623
+ aria-activedescendant={state.active >= 0 ? menu.itemId(level, state.active) : undefined}
624
+ aria-label={props["aria-label"] ?? placeholder}
625
+ autoComplete="off"
626
+ spellCheck={false}
627
+ placeholder={placeholder}
628
+ data-enigma-menu-search=""
629
+ value={state.query}
630
+ onChange={(event) => menu.instance.setQuery(level, event.target.value)}
631
+ onKeyDown={(event) => {
632
+ onKeyDown?.(event);
633
+ if (!event.defaultPrevented) menu.onMenuKeyDown(event);
634
+ }}
635
+ />
636
+ );
637
+ }
638
+
639
+ /** Rows kept ahead of the highlight, so arrowing down never runs into an unrendered row. */
640
+ const OVERSCAN = 10;
641
+
642
+ export interface ContextMenuListProps extends ComponentPropsWithoutRef<"div"> {
643
+ level: number;
644
+ chunk?: number;
645
+ }
646
+
647
+ /**
648
+ * The rows of one level, rendered a window at a time.
649
+ *
650
+ * A menu is usually short and this costs nothing there. It stops costing nothing the moment a
651
+ * submenu is a list of files, a branch, a tag or a device - which is exactly the submenu that
652
+ * is fetched, and the one that would otherwise put five hundred subtrees in the document for
653
+ * the eight rows anybody sees.
654
+ */
655
+ export function ContextMenuList({ level, chunk = 40, ...props }: ContextMenuListProps): ReactNode {
656
+ const menu = useContextMenuContext("ContextMenu.List");
657
+ const state = menu.state.levels[level];
658
+ const sentinel = useRef<HTMLDivElement | null>(null);
659
+ const [limit, setLimit] = useState(chunk);
660
+
661
+ const query = state?.query ?? "";
662
+ // A new filter is a new list: keeping the old window would leave a short result set
663
+ // rendering rows it no longer has, and a long one starting halfway down.
664
+ useEffect(() => { setLimit(chunk); }, [query, chunk]);
665
+
666
+ const total = state?.visible.length ?? 0;
667
+ const active = state?.active ?? -1;
668
+ const shown = Math.min(total, Math.max(limit, active + 1 + OVERSCAN));
669
+ const rest = total - shown;
670
+
671
+ useEffect(() => {
672
+ const target = sentinel.current;
673
+ if (!target) return;
674
+ // Without IntersectionObserver the whole list renders rather than a window of it:
675
+ // slower to open beats unreachable rows.
676
+ if (typeof IntersectionObserver === "undefined") { setLimit(Number.POSITIVE_INFINITY); return; }
677
+ const observer = new IntersectionObserver((entries) => {
678
+ if (entries.some((entry) => entry.isIntersecting)) setLimit((current) => current + chunk);
679
+ }, { root: target.parentElement, rootMargin: "120px" });
680
+ observer.observe(target);
681
+ return () => observer.disconnect();
682
+ }, [chunk, rest]);
683
+
684
+ if (!state) return null;
685
+
686
+ if (state.loading) return <div {...props} data-enigma-menu-list=""><p data-enigma-menu-status="">{menu.loadingLabel}</p></div>;
687
+ if (state.error) {
688
+ return (
689
+ <div {...props} data-enigma-menu-list="">
690
+ {/* The reason, not a blank panel: a branch that came back empty and one that
691
+ failed look identical otherwise, and only one of them is worth retrying. */}
692
+ <p data-enigma-menu-status="" data-error="" role="alert">{state.error.message}</p>
693
+ </div>
694
+ );
695
+ }
696
+ if (total === 0) {
697
+ return (
698
+ <div {...props} data-enigma-menu-list="">
699
+ <p data-enigma-menu-status="">
700
+ {state.query.trim() ? `Nothing matches "${shortenQuery(state.query)}".` : menu.emptyLabel}
701
+ </p>
702
+ </div>
703
+ );
704
+ }
705
+
706
+ return (
707
+ <div {...props} data-enigma-menu-list="">
708
+ {state.visible.slice(0, shown).map((entry, index) => (
709
+ <ContextMenuRow key={entryKey(entry, index)} level={level} index={index} entry={entry} />
710
+ ))}
711
+ {rest > 0 && (
712
+ // The end of what is rendered. Reaching it renders the next chunk, so the list
713
+ // appears endless while the document holds a screenful of it.
714
+ <div ref={sentinel} data-enigma-menu-more="" aria-hidden="true" />
715
+ )}
716
+ </div>
717
+ );
718
+ }
719
+
720
+ /** Furniture has no id of its own, and two separators in one menu are not the same node. */
721
+ function entryKey(entry: ContextMenuEntry, index: number): string {
722
+ return isAction(entry) ? entry.id : `${entry.type}-${entry.id ?? index}`;
723
+ }
724
+
725
+ export interface ContextMenuRowProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
726
+ level: number;
727
+ /** Its position in the level's VISIBLE list, which is what the keyboard moves through. */
728
+ index: number;
729
+ entry: ContextMenuEntry;
730
+ children?: ReactNode;
731
+ }
732
+
733
+ export function ContextMenuRow({ level, index, entry, children, ...props }: ContextMenuRowProps): ReactNode {
734
+ const menu = useContextMenuContext("ContextMenu.Item");
735
+ const ref = useRef<HTMLDivElement | null>(null);
736
+ const timers = useRef({ open: 0 });
737
+
738
+ const state = menu.state.levels[level];
739
+ const isActive = state?.active === index;
740
+ const item = isAction(entry) ? (entry as ContextMenuItem) : null;
741
+ const submenu = Boolean(item && (item.loadItems || (item.items && item.items.some((child) => isAction(child as ContextMenuEntry)))));
742
+ const expanded = submenu && menu.state.levels[level + 1]?.path[level] === item?.id;
743
+
744
+ // The highlight can move by key onto a row that is scrolled out of sight, and a highlight
745
+ // nobody can see is the same as no highlight.
746
+ useEffect(() => {
747
+ if (isActive) ref.current?.scrollIntoView({ block: "nearest" });
748
+ }, [isActive]);
749
+
750
+ // A row that opened a submenu by key must open it there too: the keyboard path goes
751
+ // through instance.enterSubmenu, so this only cleans up the pointer's timers.
752
+ useEffect(() => {
753
+ const held = timers.current;
754
+ return () => { window.clearTimeout(held.open); };
755
+ }, []);
756
+
757
+ if (!item) {
758
+ if (entry.type === "separator") return <div {...props} role="separator" data-enigma-menu-separator="" />;
759
+ return <p {...props} data-enigma-menu-label="" aria-hidden="true">{entry.label}</p>;
760
+ }
761
+
762
+ const checkable = item.checked !== undefined;
763
+ // A checkable row inside a group is a radio: choosing one is choosing INSTEAD of its
764
+ // siblings, and a screen reader announces the two differently.
765
+ const role = checkable ? (item.group ? "menuitemradio" : "menuitemcheckbox") : "menuitem";
766
+
767
+ return (
768
+ <div
769
+ {...props}
770
+ ref={ref}
771
+ id={menu.itemId(level, index)}
772
+ role={role}
773
+ aria-disabled={item.disabled || undefined}
774
+ aria-haspopup={submenu ? "menu" : undefined}
775
+ aria-expanded={submenu ? expanded : undefined}
776
+ aria-checked={checkable ? Boolean(item.checked) : undefined}
777
+ // The printed shortcut is decoration (`aria-hidden`), so the announced one is
778
+ // this - and it is announced in the platform-neutral spelling, which is what the
779
+ // attribute is defined to take.
780
+ aria-keyshortcuts={item.shortcut}
781
+ data-enigma-menu-item=""
782
+ data-active={isActive ? "" : undefined}
783
+ data-disabled={item.disabled ? "" : undefined}
784
+ data-destructive={item.destructive ? "" : undefined}
785
+ data-submenu={submenu ? "" : undefined}
786
+ onPointerEnter={() => {
787
+ window.clearTimeout(timers.current.open);
788
+ menu.cancelClose();
789
+ if (!item.disabled) menu.instance.setActive(level, index);
790
+ if (!item.disabled && submenu) {
791
+ timers.current.open = window.setTimeout(() => menu.instance.openSubmenu(level, index), menu.delays.open);
792
+ } else if (menu.state.levels.length > level + 1) {
793
+ // Resting on a plain row closes the branch a sibling had open - after the
794
+ // same beat, so passing over it on the way somewhere costs nothing.
795
+ menu.scheduleClose(level);
796
+ }
797
+ }}
798
+ onPointerLeave={() => {
799
+ window.clearTimeout(timers.current.open);
800
+ }}
801
+ // Up rather than down: the press that OPENED the menu is a `contextmenu`, and the
802
+ // release of that same press lands on whichever row is under the pointer. Choosing
803
+ // on the way down would invoke it before the menu was ever seen.
804
+ onPointerUp={(event) => {
805
+ props.onPointerUp?.(event);
806
+ // The primary button only: a right-click inside a panel deliberately leaves
807
+ // the menu open, so its release over a row would invoke that row - and one of
808
+ // them is usually the destructive one.
809
+ if (event.defaultPrevented || item.disabled || event.button !== 0) return;
810
+ menu.instance.select(level, index);
811
+ }}
812
+ onClick={(event) => {
813
+ props.onClick?.(event);
814
+ // A click with no pointer behind it - a screen reader, a synthetic press.
815
+ // `detail` is 0 exactly then, and the pointerup path has already handled the
816
+ // rest, so this cannot double-fire.
817
+ if (event.defaultPrevented || item.disabled || event.detail !== 0) return;
818
+ menu.instance.select(level, index);
819
+ }}
820
+ >
821
+ {children ?? menu.renderItem?.(item, level, index) ?? <ContextMenuRowContent item={item} submenu={submenu} />}
822
+ </div>
823
+ );
824
+ }
825
+
826
+ /** The default row: what a desktop menu draws, in the order it draws it. */
827
+ function ContextMenuRowContent({ item, submenu }: { item: ContextMenuItem; submenu: boolean; }): ReactNode {
828
+ return (
829
+ <>
830
+ {item.checked !== undefined && <span data-enigma-menu-check="" aria-hidden="true" />}
831
+ {item.icon ? <span data-enigma-menu-icon="">{item.icon}</span> : null}
832
+ <span data-enigma-menu-item-text="">
833
+ <span data-enigma-menu-item-label="">{item.label}</span>
834
+ {item.description && <span data-enigma-menu-item-description="">{item.description}</span>}
835
+ </span>
836
+ {item.shortcut && (
837
+ // One `<kbd>` per key, so `Ctrl` and `C` can be spaced apart - and written for
838
+ // THIS platform, because a menu that says Ctrl on a Mac is a hint that misleads.
839
+ <span data-enigma-menu-shortcut="" aria-hidden="true">
840
+ {shortcutTokens(item.shortcut).map((token, index) => <kbd key={index}>{token}</kbd>)}
841
+ </span>
842
+ )}
843
+ {submenu && <span data-enigma-menu-arrow="" aria-hidden="true" />}
844
+ </>
845
+ );
846
+ }