@fuzdev/fuz_ui 0.202.0 → 0.203.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 (68) hide show
  1. package/dist/ApiIndex.svelte +6 -3
  2. package/dist/ApiIndex.svelte.d.ts +1 -1
  3. package/dist/ApiIndex.svelte.d.ts.map +1 -1
  4. package/dist/ApiModule.svelte +72 -21
  5. package/dist/ApiModule.svelte.d.ts +1 -1
  6. package/dist/ApiModule.svelte.d.ts.map +1 -1
  7. package/dist/ContextmenuEntry.svelte +11 -10
  8. package/dist/ContextmenuEntry.svelte.d.ts +1 -1
  9. package/dist/ContextmenuEntry.svelte.d.ts.map +1 -1
  10. package/dist/ContextmenuLinkEntry.svelte +30 -12
  11. package/dist/ContextmenuLinkEntry.svelte.d.ts +2 -1
  12. package/dist/ContextmenuLinkEntry.svelte.d.ts.map +1 -1
  13. package/dist/ContextmenuMenu.svelte +207 -0
  14. package/dist/ContextmenuMenu.svelte.d.ts +41 -0
  15. package/dist/ContextmenuMenu.svelte.d.ts.map +1 -0
  16. package/dist/ContextmenuRoot.svelte +58 -260
  17. package/dist/ContextmenuRoot.svelte.d.ts +2 -60
  18. package/dist/ContextmenuRoot.svelte.d.ts.map +1 -1
  19. package/dist/ContextmenuRootForSafariCompatibility.svelte +72 -283
  20. package/dist/ContextmenuRootForSafariCompatibility.svelte.d.ts +2 -58
  21. package/dist/ContextmenuRootForSafariCompatibility.svelte.d.ts.map +1 -1
  22. package/dist/ContextmenuSeparator.svelte +2 -1
  23. package/dist/ContextmenuSeparator.svelte.d.ts.map +1 -1
  24. package/dist/ContextmenuSubmenu.svelte +27 -23
  25. package/dist/ContextmenuSubmenu.svelte.d.ts +1 -1
  26. package/dist/ContextmenuSubmenu.svelte.d.ts.map +1 -1
  27. package/dist/ContextmenuTextEntry.svelte +7 -3
  28. package/dist/ContextmenuTextEntry.svelte.d.ts +2 -1
  29. package/dist/ContextmenuTextEntry.svelte.d.ts.map +1 -1
  30. package/dist/DeclarationLink.svelte +15 -2
  31. package/dist/DeclarationLink.svelte.d.ts +7 -0
  32. package/dist/DeclarationLink.svelte.d.ts.map +1 -1
  33. package/dist/DocsLink.svelte +3 -1
  34. package/dist/DocsLink.svelte.d.ts.map +1 -1
  35. package/dist/DocsTertiaryNav.svelte +2 -1
  36. package/dist/DocsTertiaryNav.svelte.d.ts.map +1 -1
  37. package/dist/LibraryDetail.svelte +9 -3
  38. package/dist/LibraryDetail.svelte.d.ts +1 -1
  39. package/dist/LibraryDetail.svelte.d.ts.map +1 -1
  40. package/dist/ModuleLink.svelte +2 -1
  41. package/dist/ModuleLink.svelte.d.ts.map +1 -1
  42. package/dist/TypeLink.svelte +2 -1
  43. package/dist/TypeLink.svelte.d.ts.map +1 -1
  44. package/dist/contextmenu_helpers.d.ts +324 -1
  45. package/dist/contextmenu_helpers.d.ts.map +1 -1
  46. package/dist/contextmenu_helpers.js +403 -2
  47. package/dist/contextmenu_state.svelte.d.ts +61 -15
  48. package/dist/contextmenu_state.svelte.d.ts.map +1 -1
  49. package/dist/contextmenu_state.svelte.js +206 -119
  50. package/dist/declaration.svelte.d.ts +1 -0
  51. package/dist/declaration.svelte.d.ts.map +1 -1
  52. package/dist/declaration.svelte.js +2 -1
  53. package/dist/icons.d.ts +15 -3
  54. package/dist/icons.d.ts.map +1 -1
  55. package/dist/icons.js +17 -3
  56. package/dist/library.svelte.d.ts +30 -3
  57. package/dist/library.svelte.d.ts.map +1 -1
  58. package/dist/library.svelte.js +38 -0
  59. package/dist/module.svelte.d.ts +62 -1
  60. package/dist/module.svelte.d.ts.map +1 -1
  61. package/dist/module.svelte.js +71 -1
  62. package/package.json +2 -2
  63. package/src/lib/contextmenu_helpers.ts +546 -3
  64. package/src/lib/contextmenu_state.svelte.ts +219 -119
  65. package/src/lib/declaration.svelte.ts +2 -1
  66. package/src/lib/icons.ts +19 -3
  67. package/src/lib/library.svelte.ts +43 -1
  68. package/src/lib/module.svelte.ts +97 -2
@@ -1,7 +1,7 @@
1
1
  import {onDestroy, type Snippet} from 'svelte';
2
2
  import type {Result} from '@fuzdev/fuz_util/result.js';
3
3
  import {is_promise} from '@fuzdev/fuz_util/async.js';
4
- import {BROWSER} from 'esm-env';
4
+ import {BROWSER, DEV} from 'esm-env';
5
5
  import type {SvelteHTMLElements} from 'svelte/elements';
6
6
  import {EMPTY_OBJECT} from '@fuzdev/fuz_util/object.js';
7
7
  import type {Attachment} from 'svelte/attachments';
@@ -9,6 +9,8 @@ import type {Attachment} from 'svelte/attachments';
9
9
  import {Dimensions} from './dimensions.svelte.js';
10
10
  import {create_context} from './context_helpers.js';
11
11
  import {url_to_root_relative} from './library_helpers.js';
12
+ import {icon_copy} from './icons.js';
13
+ import type {SvgData} from './svg.js';
12
14
 
13
15
  export const contextmenu_context = create_context<() => ContextmenuState>();
14
16
 
@@ -20,7 +22,7 @@ export type ContextmenuParams =
20
22
  | Snippet
21
23
  // TODO maybe this should be generic?
22
24
  | {snippet: 'link'; props: {href: string; icon?: string}}
23
- | {snippet: 'text'; props: {content: string; icon: string; run: ContextmenuRun}}
25
+ | {snippet: 'text'; props: {content: string; icon: SvgData | string; run: ContextmenuRun}}
24
26
  | {snippet: 'separator'; props: SvelteHTMLElements['li']};
25
27
 
26
28
  export type ContextmenuActivateResult =
@@ -53,36 +55,85 @@ export class EntryState {
53
55
  }
54
56
  }
55
57
 
56
- export class SubmenuState {
58
+ /**
59
+ * Shared `items` state for `SubmenuState` and `RootMenuState`.
60
+ *
61
+ * The source of truth is a plain non-reactive array because items register and
62
+ * unregister from component lifecycles that can interleave within a single batch -
63
+ * effect teardowns can observe stale signal values mid-batch, so read-copy-write
64
+ * against the reactive array can lose concurrent registrations (e.g. reopening the
65
+ * menu while it's open). Every mutation publishes an immutable snapshot, preserving
66
+ * the reassignment (not mutation) contract for readers.
67
+ */
68
+ export class ContextmenuItemsState {
69
+ #items_source: Array<ItemState> = [];
70
+ #items: ReadonlyArray<ItemState> = $state.raw([]);
71
+
72
+ get items(): ReadonlyArray<ItemState> {
73
+ return this.#items;
74
+ }
75
+
76
+ set items(value: ReadonlyArray<ItemState>) {
77
+ // copy for the published snapshot too, so callers mutating
78
+ // their own array can't mutate it in readers' hands
79
+ this.#items_source = [...value];
80
+ this.#items = [...value];
81
+ }
82
+
83
+ /**
84
+ * Appends `item`, publishing a new immutable `items`.
85
+ */
86
+ add_item(item: ItemState): void {
87
+ this.#items_source.push(item);
88
+ this.#items = [...this.#items_source];
89
+ }
90
+
91
+ /**
92
+ * Removes `item` if present, publishing a new immutable `items`.
93
+ * Safe to call from effect teardown - it reads only the non-reactive source array.
94
+ */
95
+ remove_item(item: ItemState): void {
96
+ const index = this.#items_source.indexOf(item);
97
+ if (index === -1) return;
98
+ this.#items_source.splice(index, 1);
99
+ this.#items = [...this.#items_source];
100
+ }
101
+ }
102
+
103
+ export class SubmenuState extends ContextmenuItemsState {
57
104
  readonly is_menu = true;
58
105
  readonly menu: SubmenuState | RootMenuState;
59
106
  readonly depth: number;
60
107
 
61
108
  selected: boolean = $state.raw(false);
62
- items: ReadonlyArray<ItemState> = $state.raw([]);
63
109
 
64
110
  constructor(menu: SubmenuState | RootMenuState, depth: number) {
111
+ super();
65
112
  this.menu = menu;
66
113
  this.depth = depth;
67
114
  }
68
115
  }
69
116
 
70
- export class RootMenuState {
117
+ export class RootMenuState extends ContextmenuItemsState {
71
118
  readonly is_menu = true;
72
119
  readonly menu = null;
73
120
  readonly depth = 1;
74
-
75
- items: ReadonlyArray<ItemState> = $state.raw([]);
76
121
  }
77
122
 
78
123
  export type ContextmenuRun = () => ContextmenuActivateResult | Promise<ContextmenuActivateResult>;
79
124
 
125
+ /** Extracts a string `message` property from a thrown value or failed result, if present. */
126
+ const to_error_message = (value: unknown): string | undefined => {
127
+ const message = (value as {message?: unknown} | null | undefined)?.message;
128
+ return typeof message === 'string' ? message : undefined;
129
+ };
130
+
80
131
  export interface ContextmenuStateOptions {
81
132
  layout?: Dimensions; // TODO consider making this a prop on `ContextmenuRoot`, and being assigned here
82
133
  }
83
134
 
84
135
  /**
85
- * Creates a contextmenu store.
136
+ * Manages contextmenu state.
86
137
  * See usage with `ContextmenuRoot.svelte` and `Contextmenu.svelte`.
87
138
  *
88
139
  * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event}
@@ -102,10 +153,16 @@ export class ContextmenuState {
102
153
  params: ReadonlyArray<ContextmenuParams> = $state.raw([]);
103
154
  error: string | undefined = $state.raw();
104
155
 
105
- // These arrays use immutable updates (reassignment, not mutation).
106
- // If you need reactivity, use `$contextmenu` in a reactive statement to react to all changes, and
107
- // then access the immutable `contextmenu.root_menu` and `contextmenu.selections`.
108
- // See `ContextmenuEntry.svelte` and `ContextmenuSubmenu.svelte` for reactive usage examples.
156
+ /**
157
+ * The element the menu was opened from, while opened, else `undefined`.
158
+ * Resolves the popover host when the menu opens inside a modal `<dialog>` -
159
+ * see `contextmenu_resolve_popover_host`. Deliberately not reactive -
160
+ * read it in event handlers and attachments, not in templates.
161
+ */
162
+ target: HTMLElement | SVGElement | undefined = undefined;
163
+
164
+ // These arrays use immutable updates (reassignment, not mutation) - readers can depend
165
+ // on snapshot identity, and `ContextmenuItemsState` documents the publication contract.
109
166
  readonly root_menu: RootMenuState = new RootMenuState();
110
167
  selections: ReadonlyArray<ItemState> = $state.raw([]);
111
168
 
@@ -116,12 +173,11 @@ export class ContextmenuState {
116
173
  return !!selected?.is_menu && selected.items.length > 0;
117
174
  });
118
175
 
119
- can_select_next = $derived.by(() => {
120
- const menu = this.selections.at(-1)?.menu ?? this.root_menu;
121
- return menu.items.length > 1;
122
- });
123
-
124
- can_select_previous = $derived.by(() => {
176
+ /**
177
+ * Whether `select_next` and `select_previous` can move the selection -
178
+ * the selected menu has more than one item.
179
+ */
180
+ can_select_sibling = $derived.by(() => {
125
181
  const menu = this.selections.at(-1)?.menu ?? this.root_menu;
126
182
  return menu.items.length > 1;
127
183
  });
@@ -129,6 +185,9 @@ export class ContextmenuState {
129
185
  can_activate = $derived.by(() => {
130
186
  const selected = this.selections.at(-1);
131
187
  if (!selected) return false;
188
+ // the selected item can unregister out from under the selection
189
+ // (e.g. its component unmounted) - its action must not run
190
+ if (!selected.menu.items.includes(selected)) return false;
132
191
  if (selected.is_menu) return selected.items.length > 0;
133
192
  return !selected.disabled();
134
193
  });
@@ -140,18 +199,38 @@ export class ContextmenuState {
140
199
  this.layout = layout ?? new Dimensions();
141
200
  }
142
201
 
143
- open(params: Array<ContextmenuParams>, x: number, y: number): void {
144
- this.selections = [];
202
+ open(
203
+ params: Array<ContextmenuParams>,
204
+ x: number,
205
+ y: number,
206
+ target?: HTMLElement | SVGElement,
207
+ ): void {
208
+ this.#clear_selections();
145
209
  this.opened = true;
210
+ this.error = undefined;
146
211
  this.x = x;
147
212
  this.y = y;
148
213
  this.params = params;
214
+ this.target = target;
149
215
  }
150
216
 
151
217
  close(): void {
152
218
  if (!this.opened) return;
153
219
  this.reset_items(this.root_menu.items);
220
+ this.#clear_selections();
154
221
  this.opened = false;
222
+ this.target = undefined;
223
+ }
224
+
225
+ /**
226
+ * Deselects every selected item and empties `selections`. Items can outlive
227
+ * a selection (e.g. surviving a reopen-while-open), so clearing the array
228
+ * without deselecting would leave ghost highlights.
229
+ */
230
+ #clear_selections(): void {
231
+ if (!this.selections.length) return;
232
+ for (const s of this.selections) s.selected = false;
233
+ this.selections = [];
155
234
  }
156
235
 
157
236
  reset_items(items: ReadonlyArray<ItemState>): void {
@@ -166,78 +245,94 @@ export class ContextmenuState {
166
245
  }
167
246
  }
168
247
 
169
- activate(item: ItemState): boolean | Promise<ContextmenuActivateResult> {
170
- if (item.is_menu) {
171
- this.expand_selected();
172
- } else {
173
- if (item.disabled()) return false;
174
- let returned;
175
- try {
176
- returned = item.run()();
177
- } catch (error) {
178
- const message = typeof error?.message === 'string' ? error.message : undefined;
179
- item.error_message = message ?? 'unknown error';
180
- this.error = message;
181
- }
182
- if (is_promise(returned)) {
183
- item.pending = true;
184
- item.error_message = null;
185
- const promise = (item.promise = returned
186
- .then(
187
- (result) => {
188
- if (promise !== item.promise) return;
189
- if (typeof result?.ok === 'boolean') {
190
- if (result.ok) {
191
- if (result.close !== false) {
192
- this.close();
193
- }
194
- } else {
195
- const message = typeof result.message === 'string' ? result.message : undefined;
196
- item.error_message = message ?? 'unknown error';
197
- this.error = message;
198
- }
199
- } else {
200
- // void or undefined - default behavior is to close
201
- this.close();
202
- }
203
- return result;
204
- },
205
- (err) => {
206
- if (promise !== item.promise) return;
207
- const message = typeof err?.message === 'string' ? err.message : undefined;
208
- item.error_message = message ?? 'unknown error';
209
- this.error = message;
210
- },
211
- )
212
- .finally(() => {
213
- if (promise !== item.promise) return;
214
- item.pending = false;
215
- item.promise = null;
216
- }));
217
- return item.promise; // async path
218
- }
219
- // synchronous path
220
- if (typeof returned?.ok === 'boolean') {
221
- if (returned.ok) {
222
- if (returned.close !== false) {
223
- this.close();
224
- }
225
- } else {
226
- const message = typeof returned.message === 'string' ? returned.message : undefined;
227
- item.error_message = message ?? 'unknown error';
228
- this.error = message;
248
+ /**
249
+ * Sets the error state for a failed activation.
250
+ * `error_message` falls back to `'unknown error'` so the entry always displays something,
251
+ * while `error` keeps the raw message so external consumers can detect its absence.
252
+ */
253
+ #handle_error(item: EntryState, message: string | undefined): void {
254
+ item.error_message = message ?? 'unknown error';
255
+ this.error = message;
256
+ }
257
+
258
+ /**
259
+ * Applies an activation result, returning `false` when it failed.
260
+ */
261
+ #handle_result(item: EntryState, result: ContextmenuActivateResult): boolean {
262
+ if (typeof result?.ok === 'boolean') {
263
+ if (result.ok) {
264
+ if (result.close !== false) {
265
+ this.close();
229
266
  }
230
267
  } else {
231
- // void or undefined - default behavior is to close
232
- this.close();
268
+ this.#handle_error(item, to_error_message(result));
269
+ return false;
233
270
  }
271
+ } else {
272
+ // void or undefined - default behavior is to close
273
+ this.close();
234
274
  }
235
275
  return true;
236
276
  }
237
277
 
278
+ /**
279
+ * Activates `item` - expanding submenus, running entries.
280
+ *
281
+ * @returns for async runs, the activation promise; for sync runs, `false` when the
282
+ * activation didn't run (disabled) or failed (a throw or `{ok: false}`), else `true`
283
+ */
284
+ activate(item: ItemState): boolean | Promise<ContextmenuActivateResult> {
285
+ if (item.is_menu) {
286
+ // select before expanding - `expand_selected` operates on the selection
287
+ // tail, and callers aren't required to have selected (hovered) `item` first
288
+ this.select(item);
289
+ this.expand_selected();
290
+ return true;
291
+ }
292
+ if (item.disabled()) return false;
293
+ let returned;
294
+ try {
295
+ returned = item.run()();
296
+ } catch (error) {
297
+ // keep the menu open so the entry displays the error, matching the async failure path
298
+ this.#handle_error(item, to_error_message(error));
299
+ return false;
300
+ }
301
+ if (is_promise(returned)) {
302
+ item.pending = true;
303
+ item.error_message = null;
304
+ const promise = (item.promise = returned
305
+ .then(
306
+ (result) => {
307
+ if (promise !== item.promise) return;
308
+ this.#handle_result(item, result);
309
+ return result;
310
+ },
311
+ (err) => {
312
+ if (promise !== item.promise) return;
313
+ this.#handle_error(item, to_error_message(err));
314
+ },
315
+ )
316
+ .finally(() => {
317
+ if (promise !== item.promise) return;
318
+ item.pending = false;
319
+ item.promise = null;
320
+ }));
321
+ return item.promise; // async path
322
+ }
323
+ // synchronous path
324
+ return this.#handle_result(item, returned);
325
+ }
326
+
327
+ /**
328
+ * Activates the selected item, or if none is selected, selects the first.
329
+ * Returns `false` without activating when the selected item has unregistered
330
+ * out from under the selection (e.g. its component unmounted).
331
+ */
238
332
  activate_selected(): void | boolean | Promise<ContextmenuActivateResult> {
239
333
  const selected = this.selections.at(-1);
240
334
  if (selected) {
335
+ if (!selected.menu.items.includes(selected)) return false;
241
336
  return this.activate(selected);
242
337
  }
243
338
  this.select_first();
@@ -248,7 +343,7 @@ export class ContextmenuState {
248
343
  // Could be improved but it's fine because we're using mutation and the N is very small,
249
344
  // and it allows us to have a single code path for the various selection methods.
250
345
  /**
251
- * Activates the selected entry, or if none, selects the first.
346
+ * Selects `item` and its ancestor menus, deselecting everything else.
252
347
  */
253
348
  // TODO implement focus management per APG: call .focus() on the selected item's DOM element (requires storing element refs in EntryState/SubmenuState)
254
349
  select(item: ItemState): void {
@@ -286,6 +381,11 @@ export class ContextmenuState {
286
381
  }
287
382
  const item = this.selections.at(-1)!;
288
383
  const index = item.menu.items.indexOf(item);
384
+ if (index === -1) {
385
+ // the selected item unregistered out from under the selection - restart
386
+ this.select_first();
387
+ return;
388
+ }
289
389
  this.select(item.menu.items[index === item.menu.items.length - 1 ? 0 : index + 1]!);
290
390
  }
291
391
 
@@ -296,6 +396,11 @@ export class ContextmenuState {
296
396
  }
297
397
  const item = this.selections.at(-1)!;
298
398
  const index = item.menu.items.indexOf(item);
399
+ if (index === -1) {
400
+ // the selected item unregistered out from under the selection - restart
401
+ this.select_last();
402
+ return;
403
+ }
299
404
  this.select(item.menu.items[index === 0 ? item.menu.items.length - 1 : index - 1]!);
300
405
  }
301
406
 
@@ -318,10 +423,9 @@ export class ContextmenuState {
318
423
  add_entry(run: () => ContextmenuRun, disabled: () => boolean = () => false): EntryState {
319
424
  const menu = contextmenu_submenu_context.get_maybe() ?? this.root_menu;
320
425
  const entry = new EntryState(menu, run, disabled);
321
- menu.items = [...menu.items, entry];
322
- // TODO messy, runs more than needed
426
+ menu.add_item(entry);
323
427
  onDestroy(() => {
324
- menu.items = [];
428
+ menu.remove_item(entry);
325
429
  });
326
430
  return entry;
327
431
  }
@@ -332,46 +436,43 @@ export class ContextmenuState {
332
436
  add_submenu(): SubmenuState {
333
437
  const menu = contextmenu_submenu_context.get_maybe() ?? this.root_menu;
334
438
  const submenu = new SubmenuState(menu, menu.depth + 1);
335
- menu.items = [...menu.items, submenu];
439
+ menu.add_item(submenu);
336
440
  contextmenu_submenu_context.set(submenu);
337
- // TODO messy, runs more than needed
338
441
  onDestroy(() => {
339
- menu.items = [];
442
+ menu.remove_item(submenu);
340
443
  });
341
444
  return submenu;
342
445
  }
343
446
  }
344
447
 
448
+ // The dataset attribute is only a crawl marker for `contextmenu_query_params` -
449
+ // the params themselves live in `contextmenu_params_by_element`, keyed by element.
345
450
  // The dataset key must not have capital letters or dashes or it'll differ between JS and DOM:
346
451
  // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset
347
452
  const CONTEXTMENU_DATASET_KEY = 'contextmenu';
348
453
  const CONTEXTMENU_DOM_QUERY = `a,[data-${CONTEXTMENU_DATASET_KEY}]`;
349
- const contextmenu_cache: Map<string, ContextmenuParams | Array<ContextmenuParams>> = new Map();
350
- let cache_key_counter = 0;
454
+ const contextmenu_params_by_element: WeakMap<
455
+ Element,
456
+ ContextmenuParams | Array<ContextmenuParams>
457
+ > = new WeakMap();
351
458
 
352
459
  /**
353
460
  * Creates an attachment that sets up contextmenu behavior on an element.
354
461
  * @param params - contextmenu parameters or nullish to disable
355
462
  */
356
463
  export const contextmenu_attachment =
357
- <T extends ContextmenuParams, U extends T | Array<T>>(
358
- params: U | null | undefined,
464
+ (
465
+ params: ContextmenuParams | Array<ContextmenuParams> | null | undefined,
359
466
  ): Attachment<HTMLElement | SVGElement> =>
360
467
  (el): undefined | (() => void) => {
361
- // TODO could clean up the dataset attr, maybe use a weakmap?
362
468
  if (params == null) return;
363
469
 
364
- // Only create key once per element, reuse on updates
365
- let key = el.dataset[CONTEXTMENU_DATASET_KEY];
366
- if (!key) {
367
- key = cache_key_counter++ + '';
368
- el.dataset[CONTEXTMENU_DATASET_KEY] = key;
369
- }
370
-
371
- contextmenu_cache.set(key, params);
470
+ el.dataset[CONTEXTMENU_DATASET_KEY] = '';
471
+ contextmenu_params_by_element.set(el, params);
372
472
 
373
473
  return () => {
374
- contextmenu_cache.delete(key);
474
+ contextmenu_params_by_element.delete(el);
475
+ delete el.dataset[CONTEXTMENU_DATASET_KEY]; // eslint-disable-line @typescript-eslint/no-dynamic-delete
375
476
  };
376
477
  };
377
478
 
@@ -388,9 +489,9 @@ export interface ContextmenuOpenOptions {
388
489
  * Opens the contextmenu, if appropriate,
389
490
  * querying the menu items from the DOM starting at the event target.
390
491
  * @param target - the leaf element from which to open the contextmenu
391
- * @param x - the page X coordinate at which to open the contextmenu, typically the mouse `pageX`
392
- * @param y - the page Y coordinate at which to open the contextmenu, typically the mouse `pageY`
393
- * @param contextmenu - the contextmenu store
492
+ * @param x - the client (viewport) X coordinate at which to open the contextmenu, typically the mouse `clientX` - the menu positions with `fixed`
493
+ * @param y - the client (viewport) Y coordinate at which to open the contextmenu, typically the mouse `clientY` - the menu positions with `fixed`
494
+ * @param contextmenu - the contextmenu state
394
495
  * @param options - optional configuration for filtering entries and haptic feedback
395
496
  * @returns a boolean indicating if the menu was opened or not
396
497
  */
@@ -419,7 +520,7 @@ export const contextmenu_open = (
419
520
  // No-op if empty
420
521
  if (!params?.length) return false;
421
522
 
422
- contextmenu.open(params, x, y);
523
+ contextmenu.open(params, x, y, target);
423
524
 
424
525
  // `navigator.vibrate()` works with `ContextmenuRoot` but gets blocked by some browsers
425
526
  // when used with `ContextmenuRootForSafariCompatibility` because its longpress
@@ -438,19 +539,16 @@ const contextmenu_query_params = (
438
539
  let params: null | Array<ContextmenuParams> = null;
439
540
  // crawl DOM for contextmenu entries
440
541
  let el: HTMLElement | SVGElement | null | undefined = target;
441
- let cache_key: string, cached: ContextmenuParams | Array<ContextmenuParams> | undefined;
442
542
  while ((el = el?.closest(CONTEXTMENU_DOM_QUERY))) {
443
- if ((cache_key = el.dataset[CONTEXTMENU_DATASET_KEY]!)) {
444
- params ??= [];
445
- cached = contextmenu_cache.get(cache_key);
446
- if (cached === undefined) {
447
- continue;
448
- }
543
+ // Params may be missing when the element matched as a bare `<a>`, or when a
544
+ // stale marker was left behind - treat the element as having no registered entries.
545
+ const registered = contextmenu_params_by_element.get(el);
546
+ if (registered !== undefined) {
449
547
  // preserve bubbling order
450
- if (Array.isArray(cached)) {
451
- (params ??= []).push(...cached);
548
+ if (Array.isArray(registered)) {
549
+ (params ??= []).push(...registered);
452
550
  } else {
453
- (params ??= []).push(cached);
551
+ (params ??= []).push(registered);
454
552
  }
455
553
  }
456
554
  if (el.tagName === 'A') {
@@ -470,7 +568,7 @@ const contextmenu_query_params = (
470
568
  snippet: 'text',
471
569
  props: {
472
570
  content: 'copy text',
473
- icon: '📋',
571
+ icon: icon_copy,
474
572
  run: async () => {
475
573
  await navigator.clipboard.writeText(text);
476
574
  },
@@ -486,11 +584,13 @@ const non_scoped_roots: Set<symbol> = new Set();
486
584
 
487
585
  /**
488
586
  * Registers a contextmenu root and warns if multiple non-scoped roots are detected.
489
- * Only active in development mode. Automatically handles cleanup on unmount.
587
+ * Only active in development mode - `DEV` is a build-time constant, so production
588
+ * bundles eliminate the check. Automatically handles cleanup on unmount.
490
589
  *
491
590
  * @param get_scoped - getter function that returns the current scoped value
492
591
  */
493
592
  export const contextmenu_check_global_root = (get_scoped: () => boolean): void => {
593
+ if (!DEV) return;
494
594
  $effect(() => {
495
595
  const id = Symbol('contextmenu_root');
496
596
 
@@ -145,8 +145,9 @@ export class Declaration {
145
145
  /**
146
146
  * Other modules that also export this declaration (re-export paths
147
147
  * relative to src/lib). Absent when only exported from its defining module.
148
+ * The back-link of those modules' `Module.re_exports` edges.
148
149
  */
149
- also_exported_from = $derived(field<Array<string>>(this.declaration_json, 'alsoExportedFrom'));
150
+ also_exported_from = $derived(this.declaration_json.alsoExportedFrom);
150
151
 
151
152
  /**
152
153
  * Mutation documentation from `@mutates` tags, mapping parameter names to descriptions.
package/src/lib/icons.ts CHANGED
@@ -183,6 +183,13 @@ export const icon_pause = {
183
183
  ],
184
184
  } satisfies SvgData;
185
185
 
186
+ export const icon_stop = {
187
+ label: 'stop',
188
+ paths: [
189
+ {d: 'M30 23H70A7 7 0 0 1 77 30V70A7 7 0 0 1 70 77H30A7 7 0 0 1 23 70V30A7 7 0 0 1 30 23Z'},
190
+ ],
191
+ } satisfies SvgData;
192
+
186
193
  // --- Validation ---
187
194
 
188
195
  export const icon_checkmark = {
@@ -590,7 +597,7 @@ export const icon_session = {
590
597
 
591
598
  // --- Action Types ---
592
599
 
593
- export const icon_action_type_local_call = {
600
+ export const icon_action_local_call = {
594
601
  label: 'local call, lightning bolt to target',
595
602
  paths: [
596
603
  {
@@ -602,7 +609,7 @@ export const icon_action_type_local_call = {
602
609
  ],
603
610
  } satisfies SvgData;
604
611
 
605
- export const icon_action_type_remote_notification = {
612
+ export const icon_action_remote_notification = {
606
613
  label: 'remote notification, lightning bolt with signal',
607
614
  paths: [
608
615
  {
@@ -613,7 +620,7 @@ export const icon_action_type_remote_notification = {
613
620
  ],
614
621
  } satisfies SvgData;
615
622
 
616
- export const icon_action_type_request_response = {
623
+ export const icon_action_request_response = {
617
624
  label: 'request response, opposing lightning bolts',
618
625
  paths: [
619
626
  {
@@ -624,6 +631,15 @@ export const icon_action_type_request_response = {
624
631
 
625
632
  // --- Links ---
626
633
 
634
+ export const icon_link = {
635
+ label: 'link, diagonal chain',
636
+ paths: [
637
+ {
638
+ d: 'M34 53A20 20 0 1 1 53 34L44 33.6A11 11 0 1 0 33.6 44ZM66 47A20 20 0 1 1 47 66L56 66.4A11 11 0 1 0 66.4 56ZM34.4 41.5L58.5 65.6L65.6 58.5L41.5 34.4Z',
639
+ },
640
+ ],
641
+ } satisfies SvgData;
642
+
627
643
  export const icon_external_link = {
628
644
  label: 'external link, arrow out of box',
629
645
  paths: [
@@ -15,7 +15,49 @@ import {
15
15
  url_npm_package,
16
16
  } from '@fuzdev/fuz_util/package_helpers.js';
17
17
 
18
- export const library_context = create_context<Library>();
18
+ /**
19
+ * Holds a getter to the active `Library` for the current subtree.
20
+ *
21
+ * The getter form keeps consumers reactive when the library changes without
22
+ * requiring a remount — set it with a closure over reactive state, e.g.
23
+ * `library_context.set(() => library)`. Components that accept a `library`
24
+ * prop (`LibraryDetail`, `ApiIndex`, `ApiModule`) project the prop into this
25
+ * context for their subtree, so descendants like `ModuleLink`,
26
+ * `DeclarationLink`, and TSDoc-rendered `DocsLink` resolve against the same
27
+ * library — including when an aggregator renders a foreign library that
28
+ * differs from the site-level context.
29
+ */
30
+ export const library_context = create_context<() => Library>();
31
+
32
+ /**
33
+ * Sets `library_context` for the component's subtree to a getter that prefers
34
+ * the component's `library` prop and falls back to the ancestor's value,
35
+ * returning the resolved getter.
36
+ *
37
+ * Pass `get_library_prop` as a getter (not a snapshot) so prop changes remain
38
+ * reactive. The ancestor lookup happens once at component init, before the
39
+ * projection — the fallback sees the parent value, not the projection.
40
+ *
41
+ * @param get_library_prop - Getter for the component's `library` prop. A thunk so the prop stays reactive.
42
+ * @param component_name - Used in the error when neither the prop nor the context provides a library.
43
+ * @returns getter for the resolved library; read it lazily, e.g. `$derived(get_library())`
44
+ * @initializes
45
+ */
46
+ export const set_library_context_with_fallback = (
47
+ get_library_prop: () => Library | undefined,
48
+ component_name: string,
49
+ ): (() => Library) => {
50
+ const get_outer = library_context.get_maybe();
51
+ const get_library = (): Library => {
52
+ const value = get_library_prop() ?? get_outer?.();
53
+ if (!value) {
54
+ throw Error(`${component_name} requires a \`library\` prop or a set \`library_context\``);
55
+ }
56
+ return value;
57
+ };
58
+ library_context.set(get_library);
59
+ return get_library;
60
+ };
19
61
 
20
62
  /**
21
63
  * Normalizes a URL prefix: ensures leading `/`, strips trailing `/`, returns `''` for falsy and non-string values.