@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,694 @@
1
+ /**
2
+ * The parts of a context menu that are not rendering: which branch is open, where the
3
+ * highlight is inside it, what a submenu's rows are once they have been fetched, and which
4
+ * of those rows survived the filter.
5
+ *
6
+ * Framework-agnostic like every other core here, and for the same reason: the arithmetic of
7
+ * a menu is not React. What IS specific to this one is that a menu is a TREE and a select is
8
+ * a list, so everything below is keyed by a PATH - the list of item ids from the root down to
9
+ * the open submenu - rather than by an index.
10
+ *
11
+ * The behaviour it copies is the Windows one, deliberately: a right-click opens it at the
12
+ * pointer, a submenu opens after a beat of hovering and closes after a beat of leaving,
13
+ * arrows walk the rows and skip everything that cannot be chosen, Right opens a submenu and
14
+ * Left goes back, and typing jumps to a row.
15
+ */
16
+
17
+ import { typeaheadStep } from "@/core/keys";
18
+ import { createSearch, type FuseConstructor, type SearchOptions, type SearchInstance } from "@/core/search";
19
+
20
+ /** A row that does something. The default kind, so `type` can be left off. */
21
+ export interface ContextMenuAction {
22
+ type?: "item";
23
+ /** Unique among its SIBLINGS. The path down the tree is built from these. */
24
+ id: string;
25
+ label: string;
26
+ /** A second line under the label, for a row whose consequence is not obvious. */
27
+ description?: string;
28
+ /** Anything the renderer can draw - a node, a URL, a name. `unknown` because a core cannot know. */
29
+ icon?: unknown;
30
+ /**
31
+ * Printed on the right, the way every desktop menu prints it: `"Mod+C"`, `"F2"`,
32
+ * `"Delete"`. Written by `shortcutTokens` for the platform it is rendered on, so one
33
+ * spec is `⌘C` on a Mac and `Ctrl+C` elsewhere.
34
+ */
35
+ shortcut?: string;
36
+ /** Listed, announced as unavailable, never highlighted and never invoked. */
37
+ disabled?: boolean;
38
+ /** Deletes something. Rendered in the destructive colour and reported as such. */
39
+ destructive?: boolean;
40
+ /** A checkable row: a tick, or a radio dot when `group` is set. */
41
+ checked?: boolean;
42
+ /** Rows carrying the same group render together under its name, and check as a radio set. */
43
+ group?: string;
44
+ /** Extra words the filter should match. */
45
+ keywords?: string[];
46
+ /** A heading over this row's submenu. See `ContextMenuOptions.title` for what it is for. */
47
+ title?: string;
48
+ /** A submenu, known up front. An empty list is not a submenu: the row keeps no arrow. */
49
+ items?: readonly ContextMenuEntry[];
50
+ /**
51
+ * A submenu fetched on demand, once. What it returns is cached under this row's path for
52
+ * `cacheMs`, so reopening the branch shows the rows immediately instead of spinning
53
+ * again - which is the whole reason a slow submenu is bearable.
54
+ */
55
+ loadItems?: () => Promise<readonly ContextMenuEntry[]>;
56
+ /** Put a filter in this submenu. `"auto"` (the default) adds one from `SEARCHABLE_FROM` rows. */
57
+ searchable?: boolean | "auto";
58
+ /** Anything the caller wants back in `onSelect`. */
59
+ data?: unknown;
60
+ }
61
+
62
+ /** A rule between two blocks of rows. Never highlighted, never counted by the keyboard. */
63
+ export interface ContextMenuSeparator {
64
+ type: "separator";
65
+ id?: string;
66
+ }
67
+
68
+ /** A caption over a block of rows. Same rules as a separator: visible, unreachable. */
69
+ export interface ContextMenuLabel {
70
+ type: "label";
71
+ id?: string;
72
+ label: string;
73
+ }
74
+
75
+ export type ContextMenuEntry = ContextMenuAction | ContextMenuSeparator | ContextMenuLabel;
76
+
77
+ /** Whether an entry is a row the keyboard and the pointer can land on. */
78
+ export function isAction(entry: ContextMenuEntry): entry is ContextMenuAction {
79
+ return (entry.type ?? "item") === "item";
80
+ }
81
+
82
+ /** The fields the filter reads when the caller does not say. */
83
+ export const CONTEXT_MENU_SEARCH_KEYS = ["label", "description", "group", "keywords"];
84
+
85
+ /** Filtering six rows is worth a field; filtering three is a bigger panel for nothing. */
86
+ export const SEARCHABLE_FROM = 8;
87
+
88
+ /** Where a menu was opened. Viewport coordinates, which is what a pointer event reports. */
89
+ export interface ContextMenuPoint {
90
+ x: number;
91
+ y: number;
92
+ }
93
+
94
+ /** One open level: the root, then one per submenu below it. */
95
+ export interface ContextMenuLevel {
96
+ /** Ids from the root down to the item that opened this level. Empty for the root. */
97
+ path: string[];
98
+ /** A heading over the rows, or null. Also the panel's accessible name. */
99
+ title: string | null;
100
+ /** Every entry at this level, separators and labels included. */
101
+ entries: ContextMenuEntry[];
102
+ /** What the panel shows: the entries, or what survived the filter. */
103
+ visible: ContextMenuEntry[];
104
+ /** Index into `visible`. -1 when nothing is highlighted. */
105
+ active: number;
106
+ query: string;
107
+ /** Whether this level draws a filter field. */
108
+ searchable: boolean;
109
+ /** An awaited submenu that has not answered yet. */
110
+ loading: boolean;
111
+ /** What the load rejected with, so the level can say so instead of staying empty. */
112
+ error: Error | null;
113
+ }
114
+
115
+ export interface ContextMenuState {
116
+ open: boolean;
117
+ /** Where it was opened, in viewport coordinates. Null while closed. */
118
+ point: ContextMenuPoint | null;
119
+ /** The root level first, then one per open submenu. */
120
+ levels: ContextMenuLevel[];
121
+ }
122
+
123
+ export interface ContextMenuOptions {
124
+ items?: readonly ContextMenuEntry[];
125
+ /**
126
+ * A heading over the rows, naming what the menu is acting ON - the file that was
127
+ * right-clicked, or "12 items selected". A menu opened at the pointer is the one surface
128
+ * with no context around it, so without this the rows are the only clue about what they
129
+ * will happen to.
130
+ */
131
+ title?: string;
132
+ /** Fuse.js's constructor, for fuzzy filtering. Omit it for the built-in matcher. */
133
+ fuse?: FuseConstructor;
134
+ fuseOptions?: Record<string, unknown>;
135
+ matcher?: SearchOptions<ContextMenuEntry>["matcher"];
136
+ searchKeys?: string[];
137
+ /** How long a fetched submenu stays cached. 0 refetches every time. Default 5 minutes. */
138
+ cacheMs?: number;
139
+ /** Every state change. */
140
+ onChange?: (state: ContextMenuState) => void;
141
+ /** A row was invoked. The menu has already closed unless the row keeps it open. */
142
+ onSelect?: (item: ContextMenuAction, path: string[]) => void;
143
+ onOpenChange?: (open: boolean) => void;
144
+ }
145
+
146
+ export interface ContextMenuInstance {
147
+ readonly state: ContextMenuState;
148
+ /**
149
+ * Open at a point, or reopen somewhere else - a second right-click MOVES the menu.
150
+ *
151
+ * Returns whether it opened. A menu with no rows to show does NOT open: an empty box at
152
+ * the pointer says nothing, and refusing here is what lets the trigger leave the press
153
+ * alone so the browser's own menu appears instead of nothing at all.
154
+ */
155
+ open(point: ContextMenuPoint): boolean;
156
+ close(): void;
157
+ /** Move the highlight inside the deepest open level, skipping what cannot be chosen. */
158
+ move(key: ContextMenuMoveKey): void;
159
+ setActive(level: number, index: number): void;
160
+ setQuery(level: number, query: string): void;
161
+ /**
162
+ * Open the submenu of the row at `index` in `level`, closing any deeper branch.
163
+ *
164
+ * `focus` highlights its first row, which is what the KEYBOARD needs and what a pointer
165
+ * must not do: a row that looks hovered before the pointer arrives reads as the menu
166
+ * having chosen for you. Default false, so hovering is the plain case.
167
+ */
168
+ openSubmenu(level: number, index: number, focus?: boolean): void;
169
+ /** Close every level below `level`. */
170
+ closeBelow(level: number): void;
171
+ /** Invoke a row: reports it, and closes unless it is disabled or opens a submenu. */
172
+ select(level: number, index: number): void;
173
+ /** Invoke whatever is highlighted in the deepest open level. */
174
+ selectActive(): void;
175
+ /** Right on a row with a submenu opens it; on any other row it does nothing. */
176
+ enterSubmenu(): void;
177
+ /** Left closes the deepest submenu and puts the highlight back on the row that opened it. */
178
+ leaveSubmenu(): void;
179
+ /** Jump to the row starting with what was typed, the way a desktop menu does. */
180
+ typeahead(character: string): void;
181
+ /** Drop what a fetched submenu returned, so the next open asks again. */
182
+ invalidate(path?: string[]): void;
183
+ update(options: Partial<ContextMenuOptions>): void;
184
+ subscribe(listener: (state: ContextMenuState) => void): () => void;
185
+ destroy(): void;
186
+ }
187
+
188
+ export type ContextMenuMoveKey = "ArrowDown" | "ArrowUp" | "Home" | "End" | "PageDown" | "PageUp";
189
+
190
+ const PAGE = 5;
191
+ const DEFAULT_CACHE_MS = 5 * 60 * 1000;
192
+
193
+ /** "Añadir" must be reachable by typing "anadir" - here as in the search core. */
194
+ function fold(value: string): string {
195
+ return value.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
196
+ }
197
+
198
+ /** One fetched submenu, and when it was fetched. */
199
+ interface CacheEntry {
200
+ at: number;
201
+ entries?: readonly ContextMenuEntry[];
202
+ /** The request itself while it is in flight, so two opens never fetch twice. */
203
+ pending?: Promise<readonly ContextMenuEntry[]>;
204
+ }
205
+
206
+ export function createContextMenu(options: ContextMenuOptions = {}): ContextMenuInstance {
207
+ let opts: ContextMenuOptions = { ...options };
208
+ let root: readonly ContextMenuEntry[] = opts.items ?? [];
209
+ let open = false;
210
+ let point: ContextMenuPoint | null = null;
211
+ let levels: ContextMenuLevel[] = [];
212
+ let typed = "";
213
+ let typedAt = 0;
214
+ let destroyed = false;
215
+ const listeners = new Set<(state: ContextMenuState) => void>();
216
+ /** Fetched submenus, keyed by their path. Survives a close: that is what makes it a cache. */
217
+ const cache = new Map<string, CacheEntry>();
218
+ /** Paths whose submenu was opened by the KEYBOARD, and so arrives with a row highlighted. */
219
+ const wantsFocus = new Set<string>();
220
+ /**
221
+ * One filter engine per level, kept while its rows are the same ones.
222
+ *
223
+ * The engine indexes on construction, so building it inside the filter would re-index the
224
+ * whole level on every letter - which is exactly the case a filter exists for, a submenu
225
+ * with hundreds of rows in it.
226
+ */
227
+ const engines = new Map<string, { items: readonly ContextMenuEntry[]; engine: SearchInstance<ContextMenuEntry>; }>();
228
+
229
+ function cacheMs(): number {
230
+ return opts.cacheMs ?? DEFAULT_CACHE_MS;
231
+ }
232
+
233
+ function keyOf(path: string[]): string {
234
+ return path.join("\u0000");
235
+ }
236
+
237
+ function searchableFor(item: ContextMenuAction | null, entries: readonly ContextMenuEntry[]): boolean {
238
+ const asked = item?.searchable ?? "auto";
239
+ if (asked !== "auto") return asked;
240
+ return entries.filter(isAction).length >= SEARCHABLE_FROM;
241
+ }
242
+
243
+ /**
244
+ * The rows a level shows.
245
+ *
246
+ * Filtering a menu is not filtering a list: a separator between two rows that both
247
+ * disappeared is a rule with nothing on either side of it, and a caption over an empty
248
+ * block is a caption for nothing. So the filter runs over the ACTIONS and the trim below
249
+ * puts back only the furniture that still has rows around it.
250
+ */
251
+ function filtered(level: ContextMenuLevel): ContextMenuEntry[] {
252
+ if (!level.searchable || !level.query.trim()) return [...level.entries];
253
+
254
+ const actions = level.entries.filter(isAction);
255
+ const matched = new Set(engineFor(level, actions).searchNow(level.query).map((match) => match.item));
256
+ return trimFurniture(level.entries.filter((entry) => !isAction(entry) || matched.has(entry)));
257
+ }
258
+
259
+ function engineFor(level: ContextMenuLevel, actions: ContextMenuEntry[]): SearchInstance<ContextMenuEntry> {
260
+ const id = keyOf(level.path);
261
+ const held = engines.get(id);
262
+ // By identity of the rows: a level whose entries were replaced needs a new index, and
263
+ // a level being typed into hands over the same array every keystroke.
264
+ if (held && held.items.length === actions.length && held.items.every((item, index) => item === actions[index])) return held.engine;
265
+ held?.engine.destroy();
266
+ const engine = createSearch<ContextMenuEntry>({
267
+ items: actions,
268
+ keys: opts.searchKeys ?? CONTEXT_MENU_SEARCH_KEYS,
269
+ fuse: opts.fuse,
270
+ fuseOptions: opts.fuseOptions,
271
+ matcher: opts.matcher,
272
+ // A menu filters as you type: the rows are in memory, and a delay is felt as the
273
+ // panel lagging behind the field.
274
+ debounce: 0,
275
+ empty: "all"
276
+ });
277
+ engines.set(id, { items: actions, engine });
278
+ return engine;
279
+ }
280
+
281
+ /**
282
+ * Furniture only survives if it still divides something.
283
+ *
284
+ * A filter that leaves a separator between two rows that both disappeared draws a rule
285
+ * with nothing on either side of it, and a caption over an emptied block introduces
286
+ * nothing. So furniture is held back until a row arrives to justify it: at most one
287
+ * separator and one caption per gap, the caption being the last one written, and a
288
+ * separator only where there is something above it to separate from.
289
+ */
290
+ function trimFurniture(entries: ContextMenuEntry[]): ContextMenuEntry[] {
291
+ const result: ContextMenuEntry[] = [];
292
+ let separator: ContextMenuEntry | null = null;
293
+ let caption: ContextMenuEntry | null = null;
294
+
295
+ for (const entry of entries) {
296
+ if (!isAction(entry)) {
297
+ if (entry.type === "separator") separator = entry;
298
+ else caption = entry;
299
+ continue;
300
+ }
301
+ if (separator && result.length > 0) result.push(separator);
302
+ if (caption) result.push(caption);
303
+ separator = null;
304
+ caption = null;
305
+ result.push(entry);
306
+ }
307
+ // Whatever is still pending had no row after it, so it introduced nothing.
308
+ return result;
309
+ }
310
+
311
+ /** Positive modulo: the walk goes backwards as often as forwards. */
312
+ function wrap(index: number, length: number): number {
313
+ return ((index % length) + length) % length;
314
+ }
315
+
316
+ /** The next row that can be highlighted, walking at most once around the level. */
317
+ function enabledIndex(level: ContextMenuLevel, from: number, step: number): number {
318
+ const length = level.visible.length;
319
+ if (length === 0) return -1;
320
+ for (let attempt = 0; attempt < length; attempt++) {
321
+ const index = wrap(from + step * attempt, length);
322
+ const entry = level.visible[index];
323
+ if (entry && isAction(entry) && !entry.disabled) return index;
324
+ }
325
+ return -1;
326
+ }
327
+
328
+ function makeLevel(path: string[], item: ContextMenuAction | null, entries: readonly ContextMenuEntry[], loading = false, error: Error | null = null): ContextMenuLevel {
329
+ const level: ContextMenuLevel = {
330
+ path,
331
+ title: (item ? item.title : opts.title) ?? null,
332
+ entries: [...entries],
333
+ visible: [],
334
+ active: -1,
335
+ query: "",
336
+ searchable: searchableFor(item, entries),
337
+ loading,
338
+ error
339
+ };
340
+ level.visible = filtered(level);
341
+ return level;
342
+ }
343
+
344
+ function snapshot(): ContextMenuState {
345
+ return {
346
+ open,
347
+ point,
348
+ // Copied one level deep: a subscriber that keeps a snapshot must not see the next
349
+ // state mutate the one it is holding.
350
+ levels: levels.map((level) => ({ ...level, path: [...level.path], entries: [...level.entries], visible: [...level.visible] }))
351
+ };
352
+ }
353
+
354
+ function emit(): void {
355
+ const state = snapshot();
356
+ opts.onChange?.(state);
357
+ for (const listener of listeners) listener(state);
358
+ }
359
+
360
+ function deepest(): ContextMenuLevel | undefined {
361
+ return levels[levels.length - 1];
362
+ }
363
+
364
+ /** The action at a position, or null when it is furniture or out of range. */
365
+ function actionAt(level: ContextMenuLevel | undefined, index: number): ContextMenuAction | null {
366
+ const entry = level?.visible[index];
367
+ return entry && isAction(entry) ? entry : null;
368
+ }
369
+
370
+ /** An `items: []` is not a submenu: the row keeps no arrow and opens no empty panel. */
371
+ function hasSubmenu(item: ContextMenuAction): boolean {
372
+ return Boolean(item.loadItems || (item.items && item.items.some(isAction)));
373
+ }
374
+
375
+ /** Whether a list holds anything the pointer or the keyboard could land on. */
376
+ function hasActions(entries: readonly ContextMenuEntry[]): boolean {
377
+ return entries.some(isAction);
378
+ }
379
+
380
+ /**
381
+ * Open a submenu, from the cache when it has one.
382
+ *
383
+ * A fetched branch renders in three states and each is a real one: cached rows appear
384
+ * immediately, a cold fetch shows the level as loading rather than as empty, and a
385
+ * rejection says so rather than leaving a panel with nothing in it and no reason.
386
+ */
387
+ function pushSubmenu(parent: number, index: number, focus: boolean): void {
388
+ const level = levels[parent];
389
+ const item = actionAt(level, index);
390
+ if (!item || item.disabled || !hasSubmenu(item)) return;
391
+
392
+ const path = [...level.path, item.id];
393
+ const id = keyOf(path);
394
+ const shown = levels[parent + 1];
395
+ // Asking for the branch that is already open is not a reason to rebuild it. A hover
396
+ // timer landing after the row was clicked, or the pointer passing back over the
397
+ // parent on its way out, would otherwise throw away the submenu's filter, its deeper
398
+ // levels and whatever was highlighted in it - and restart a fetch that already ran.
399
+ if (shown && keyOf(shown.path) === id) {
400
+ level.active = index;
401
+ // Arrowing into a branch the pointer opened still takes it over.
402
+ if (focus) {
403
+ wantsFocus.add(id);
404
+ if (shown.active < 0) focusFirst(parent + 1);
405
+ }
406
+ emit();
407
+ return;
408
+ }
409
+
410
+ levels = levels.slice(0, parent + 1);
411
+ level.active = index;
412
+ // Remembered per path, because a fetched branch is highlighted when it ARRIVES rather
413
+ // than when it was asked for, and by then nobody knows which opened it.
414
+ if (focus) wantsFocus.add(id);
415
+ else wantsFocus.delete(id);
416
+
417
+ if (item.items) {
418
+ levels.push(makeLevel(path, item, item.items));
419
+ if (focus) focusFirst(levels.length - 1);
420
+ emit();
421
+ return;
422
+ }
423
+
424
+ const entry = cache.get(id);
425
+ const fresh = entry?.entries && (cacheMs() <= 0 ? false : Date.now() - entry.at < cacheMs());
426
+ if (fresh && entry?.entries) {
427
+ levels.push(makeLevel(path, item, entry.entries));
428
+ if (focus) focusFirst(levels.length - 1);
429
+ emit();
430
+ return;
431
+ }
432
+
433
+ levels.push(makeLevel(path, item, [], true));
434
+ emit();
435
+
436
+ // One request per path, shared: hovering in and out of a slow branch twice must not
437
+ // ask twice, and the second open has to resolve from the same promise.
438
+ const request = entry?.pending ?? Promise.resolve().then(() => item.loadItems!());
439
+ cache.set(id, { at: entry?.at ?? Date.now(), entries: entry?.entries, pending: request });
440
+
441
+ request.then(
442
+ (entries) => {
443
+ cache.set(id, { at: Date.now(), entries });
444
+ settle(id, path, item, entries, null);
445
+ },
446
+ (reason: unknown) => {
447
+ // Not cached: a failure that stuck would leave the branch broken until the
448
+ // page reloads, and the retry is the next hover.
449
+ cache.delete(id);
450
+ settle(id, path, item, [], reason instanceof Error ? reason : new Error(String(reason)));
451
+ }
452
+ );
453
+ }
454
+
455
+ /** Put a resolved submenu on screen, but only if that branch is still the open one. */
456
+ function settle(id: string, path: string[], item: ContextMenuAction, entries: readonly ContextMenuEntry[], error: Error | null): void {
457
+ if (destroyed) return;
458
+ const index = levels.findIndex((level) => keyOf(level.path) === id);
459
+ // The pointer has moved on and this branch is closed. The cache above still holds the
460
+ // rows, so the next open is instant - that is the point of answering late at all.
461
+ if (index === -1) return;
462
+ levels = levels.slice(0, index);
463
+ levels.push(makeLevel(path, item, entries, false, error));
464
+ // Only if the keyboard is what opened it. A branch the POINTER opened must arrive with
465
+ // nothing highlighted, whenever it arrives.
466
+ if (wantsFocus.has(id)) focusFirst(index);
467
+ emit();
468
+ }
469
+
470
+ /**
471
+ * Highlight the first row a submenu can land on.
472
+ *
473
+ * Only where the KEYBOARD opened it. Hovering a row that has a submenu highlights nothing
474
+ * inside it, the way every desktop menu behaves: a row that looks hovered before the
475
+ * pointer has reached it reads as the menu having chosen something on your behalf.
476
+ */
477
+ function focusFirst(index: number): void {
478
+ const level = levels[index];
479
+ if (!level) return;
480
+ level.active = enabledIndex(level, 0, 1);
481
+ }
482
+
483
+ function refilter(level: ContextMenuLevel): void {
484
+ const current = level.active >= 0 ? level.visible[level.active] : undefined;
485
+ level.visible = filtered(level);
486
+ const kept = current ? level.visible.indexOf(current) : -1;
487
+ const entry = kept >= 0 ? level.visible[kept] : undefined;
488
+ level.active = entry && isAction(entry) && !entry.disabled ? kept : enabledIndex(level, 0, 1);
489
+ }
490
+
491
+ return {
492
+ get state() { return snapshot(); },
493
+
494
+ open(next: ContextMenuPoint) {
495
+ if (destroyed) return false;
496
+ // Nothing to show, so nothing opens. Checked before anything else changes, so a
497
+ // menu already on screen is not closed by a press that could not open one.
498
+ if (!hasActions(root)) return false;
499
+ const wasOpen = open;
500
+ open = true;
501
+ point = { x: next.x, y: next.y };
502
+ // A fresh tree every open: a menu that comes back with a submenu still expanded
503
+ // and a filter still typed is one you have to reset before using.
504
+ levels = [makeLevel([], null, root)];
505
+ typed = "";
506
+ wantsFocus.clear();
507
+ if (!wasOpen) opts.onOpenChange?.(true);
508
+ emit();
509
+ return true;
510
+ },
511
+
512
+ close() {
513
+ if (destroyed || !open) return;
514
+ open = false;
515
+ point = null;
516
+ levels = [];
517
+ typed = "";
518
+ wantsFocus.clear();
519
+ opts.onOpenChange?.(false);
520
+ emit();
521
+ },
522
+
523
+ move(key: ContextMenuMoveKey) {
524
+ if (destroyed) return;
525
+ const level = deepest();
526
+ if (!level || level.visible.length === 0) return;
527
+ const from = level.active < 0 ? -1 : level.active;
528
+ switch (key) {
529
+ // Wrapping, because a menu is a short list and the row after the last one is
530
+ // the first: clamping leaves the arrow key doing nothing, which reads as a
531
+ // frozen panel.
532
+ case "ArrowDown": level.active = enabledIndex(level, from + 1, 1); break;
533
+ // From the LAST row when nothing is highlighted, which is how a menu opened at
534
+ // the pointer starts: `from - 1` there is the row before the last one.
535
+ case "ArrowUp": level.active = enabledIndex(level, from < 0 ? level.visible.length - 1 : from - 1 + level.visible.length, -1); break;
536
+ case "Home": level.active = enabledIndex(level, 0, 1); break;
537
+ case "End": level.active = enabledIndex(level, level.visible.length - 1, -1); break;
538
+ case "PageDown": level.active = enabledIndex(level, Math.min(level.visible.length - 1, from + PAGE), 1); break;
539
+ case "PageUp": level.active = enabledIndex(level, Math.max(0, from - PAGE), -1); break;
540
+ }
541
+ emit();
542
+ },
543
+
544
+ setActive(levelIndex: number, index: number) {
545
+ const level = levels[levelIndex];
546
+ // The row already highlighted is not a change: this arrives from `pointermove`,
547
+ // which fires on every pixel, and emitting there redraws the panel sixty times a
548
+ // second.
549
+ if (destroyed || !level || level.active === index) return;
550
+ if (index < 0 || index >= level.visible.length) return;
551
+ const item = actionAt(level, index);
552
+ if (!item || item.disabled) return;
553
+ level.active = index;
554
+ // The deeper levels are left alone on purpose. Pointing at another row does
555
+ // eventually close them, but only after the beat every desktop menu waits: the
556
+ // way OUT of a submenu passes over its siblings, so closing on the first crossing
557
+ // is what makes a nested menu impossible to reach diagonally. The renderer owns
558
+ // that timing and calls closeBelow when it elapses.
559
+ emit();
560
+ },
561
+
562
+ setQuery(levelIndex: number, query: string) {
563
+ const level = levels[levelIndex];
564
+ if (destroyed || !level) return;
565
+ level.query = query;
566
+ // Filtering a level invalidates everything under it: those rows came from a row
567
+ // that may not be on screen any more.
568
+ levels = levels.slice(0, levelIndex + 1);
569
+ refilter(level);
570
+ emit();
571
+ },
572
+
573
+ openSubmenu(levelIndex: number, index: number, focus = false) {
574
+ if (destroyed) return;
575
+ pushSubmenu(levelIndex, index, focus);
576
+ },
577
+
578
+ closeBelow(levelIndex: number) {
579
+ if (destroyed || levels.length <= levelIndex + 1) return;
580
+ levels = levels.slice(0, levelIndex + 1);
581
+ emit();
582
+ },
583
+
584
+ select(levelIndex: number, index: number) {
585
+ if (destroyed) return;
586
+ const level = levels[levelIndex];
587
+ const item = actionAt(level, index);
588
+ // A disabled row is listed and announced, never invoked - including by a click
589
+ // that got through, which is the one path a renderer tends to forget.
590
+ if (!item || item.disabled) return;
591
+ // Choosing a row that HAS a submenu opens it, and that press came from a pointer
592
+ // or from Enter - either way the reader is now looking at it, so it gets a row.
593
+ if (hasSubmenu(item)) { pushSubmenu(levelIndex, index, true); return; }
594
+
595
+ const path = [...(level?.path ?? []), item.id];
596
+ // Closed BEFORE the handler runs: the handler usually opens a dialog or moves
597
+ // focus, and a menu still on screen underneath it steals the next Escape.
598
+ open = false;
599
+ point = null;
600
+ levels = [];
601
+ typed = "";
602
+ wantsFocus.clear();
603
+ opts.onOpenChange?.(false);
604
+ emit();
605
+ opts.onSelect?.(item, path);
606
+ },
607
+
608
+ selectActive() {
609
+ const index = levels.length - 1;
610
+ const level = levels[index];
611
+ if (level && level.active >= 0) this.select(index, level.active);
612
+ },
613
+
614
+ enterSubmenu() {
615
+ const index = levels.length - 1;
616
+ const level = levels[index];
617
+ if (!level || level.active < 0) return;
618
+ const item = actionAt(level, level.active);
619
+ if (item && hasSubmenu(item)) pushSubmenu(index, level.active, true);
620
+ },
621
+
622
+ leaveSubmenu() {
623
+ if (destroyed || levels.length <= 1) return;
624
+ levels = levels.slice(0, levels.length - 1);
625
+ emit();
626
+ },
627
+
628
+ typeahead(character: string) {
629
+ if (destroyed || character.length !== 1) return;
630
+ const level = deepest();
631
+ if (!level || level.visible.length === 0) return;
632
+
633
+ const step = typeaheadStep({ typed, at: typedAt }, character);
634
+ typed = step.typed;
635
+ typedAt = step.at;
636
+
637
+ const needle = fold(step.needle);
638
+ // A cycling press searches from the row AFTER this one, so pressing the letter
639
+ // again walks through everything starting with it; a word searches from the
640
+ // current row, so refining it does not jump off what it already found. With
641
+ // nothing highlighted yet - which is how a menu opens at the pointer - the walk
642
+ // starts AT the first row instead, or the first letter would skip it.
643
+ const from = step.cycle && level.active >= 0 ? 1 : 0;
644
+ for (let step = from; step < level.visible.length + from; step++) {
645
+ const index = wrap(Math.max(level.active, 0) + step, level.visible.length);
646
+ const entry = level.visible[index];
647
+ if (!entry || !isAction(entry) || entry.disabled) continue;
648
+ if (fold(entry.label).startsWith(needle)) {
649
+ level.active = index;
650
+ emit();
651
+ return;
652
+ }
653
+ }
654
+ },
655
+
656
+ invalidate(path?: string[]) {
657
+ if (!path) { cache.clear(); return; }
658
+ cache.delete(keyOf(path));
659
+ },
660
+
661
+ update(next: Partial<ContextMenuOptions>) {
662
+ if (destroyed) return;
663
+ const hadItems = next.items !== undefined;
664
+ opts = { ...opts, ...next };
665
+ if (hadItems) {
666
+ root = next.items ?? [];
667
+ // Only the root is rebuilt: a submenu's rows came from an item in the tree,
668
+ // and rebuilding the open branch under the pointer would move it.
669
+ const level = levels[0];
670
+ if (level) {
671
+ level.entries = [...root];
672
+ level.title = opts.title ?? null;
673
+ level.searchable = searchableFor(null, root);
674
+ refilter(level);
675
+ emit();
676
+ }
677
+ }
678
+ },
679
+
680
+ subscribe(listener) {
681
+ listeners.add(listener);
682
+ return () => { listeners.delete(listener); };
683
+ },
684
+
685
+ destroy() {
686
+ destroyed = true;
687
+ listeners.clear();
688
+ cache.clear();
689
+ wantsFocus.clear();
690
+ for (const held of engines.values()) held.engine.destroy();
691
+ engines.clear();
692
+ }
693
+ };
694
+ }