@fuzdev/fuz_ui 0.201.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.
- package/dist/ApiIndex.svelte +6 -3
- package/dist/ApiIndex.svelte.d.ts +1 -1
- package/dist/ApiIndex.svelte.d.ts.map +1 -1
- package/dist/ApiModule.svelte +72 -21
- package/dist/ApiModule.svelte.d.ts +1 -1
- package/dist/ApiModule.svelte.d.ts.map +1 -1
- package/dist/ContextmenuEntry.svelte +11 -10
- package/dist/ContextmenuEntry.svelte.d.ts +1 -1
- package/dist/ContextmenuEntry.svelte.d.ts.map +1 -1
- package/dist/ContextmenuLinkEntry.svelte +30 -12
- package/dist/ContextmenuLinkEntry.svelte.d.ts +2 -1
- package/dist/ContextmenuLinkEntry.svelte.d.ts.map +1 -1
- package/dist/ContextmenuMenu.svelte +207 -0
- package/dist/ContextmenuMenu.svelte.d.ts +41 -0
- package/dist/ContextmenuMenu.svelte.d.ts.map +1 -0
- package/dist/ContextmenuRoot.svelte +58 -260
- package/dist/ContextmenuRoot.svelte.d.ts +2 -60
- package/dist/ContextmenuRoot.svelte.d.ts.map +1 -1
- package/dist/ContextmenuRootForSafariCompatibility.svelte +72 -283
- package/dist/ContextmenuRootForSafariCompatibility.svelte.d.ts +2 -58
- package/dist/ContextmenuRootForSafariCompatibility.svelte.d.ts.map +1 -1
- package/dist/ContextmenuSeparator.svelte +2 -1
- package/dist/ContextmenuSeparator.svelte.d.ts.map +1 -1
- package/dist/ContextmenuSubmenu.svelte +27 -23
- package/dist/ContextmenuSubmenu.svelte.d.ts +1 -1
- package/dist/ContextmenuSubmenu.svelte.d.ts.map +1 -1
- package/dist/ContextmenuTextEntry.svelte +7 -3
- package/dist/ContextmenuTextEntry.svelte.d.ts +2 -1
- package/dist/ContextmenuTextEntry.svelte.d.ts.map +1 -1
- package/dist/CopyToClipboard.svelte +5 -2
- package/dist/CopyToClipboard.svelte.d.ts.map +1 -1
- package/dist/DeclarationLink.svelte +15 -2
- package/dist/DeclarationLink.svelte.d.ts +7 -0
- package/dist/DeclarationLink.svelte.d.ts.map +1 -1
- package/dist/DialogContent.svelte +3 -1
- package/dist/DialogContent.svelte.d.ts.map +1 -1
- package/dist/Docs.svelte +11 -8
- package/dist/Docs.svelte.d.ts.map +1 -1
- package/dist/DocsLink.svelte +3 -1
- package/dist/DocsLink.svelte.d.ts.map +1 -1
- package/dist/DocsSearch.svelte +4 -1
- package/dist/DocsSearch.svelte.d.ts.map +1 -1
- package/dist/DocsTertiaryNav.svelte +2 -1
- package/dist/DocsTertiaryNav.svelte.d.ts.map +1 -1
- package/dist/LibraryDetail.svelte +9 -3
- package/dist/LibraryDetail.svelte.d.ts +1 -1
- package/dist/LibraryDetail.svelte.d.ts.map +1 -1
- package/dist/ModuleLink.svelte +2 -1
- package/dist/ModuleLink.svelte.d.ts.map +1 -1
- package/dist/Svg.svelte +46 -9
- package/dist/Svg.svelte.d.ts.map +1 -1
- package/dist/TypeLink.svelte +2 -1
- package/dist/TypeLink.svelte.d.ts.map +1 -1
- package/dist/contextmenu_helpers.d.ts +324 -1
- package/dist/contextmenu_helpers.d.ts.map +1 -1
- package/dist/contextmenu_helpers.js +403 -2
- package/dist/contextmenu_state.svelte.d.ts +61 -15
- package/dist/contextmenu_state.svelte.d.ts.map +1 -1
- package/dist/contextmenu_state.svelte.js +206 -119
- package/dist/declaration.svelte.d.ts +1 -0
- package/dist/declaration.svelte.d.ts.map +1 -1
- package/dist/declaration.svelte.js +2 -1
- package/dist/icons.d.ts +469 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +578 -0
- package/dist/library.svelte.d.ts +30 -3
- package/dist/library.svelte.d.ts.map +1 -1
- package/dist/library.svelte.js +38 -0
- package/dist/logos.d.ts +1 -3
- package/dist/logos.d.ts.map +1 -1
- package/dist/logos.js +1 -1
- package/dist/module.svelte.d.ts +62 -1
- package/dist/module.svelte.d.ts.map +1 -1
- package/dist/module.svelte.js +71 -1
- package/dist/svg.d.ts +69 -10
- package/dist/svg.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/lib/contextmenu_helpers.ts +546 -3
- package/src/lib/contextmenu_state.svelte.ts +219 -119
- package/src/lib/declaration.svelte.ts +2 -1
- package/src/lib/icons.ts +671 -0
- package/src/lib/library.svelte.ts +43 -1
- package/src/lib/logos.ts +1 -1
- package/src/lib/module.svelte.ts +97 -2
- package/src/lib/svg.ts +70 -9
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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(
|
|
144
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
.
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
*
|
|
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.
|
|
322
|
-
// TODO messy, runs more than needed
|
|
426
|
+
menu.add_item(entry);
|
|
323
427
|
onDestroy(() => {
|
|
324
|
-
menu.
|
|
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.
|
|
439
|
+
menu.add_item(submenu);
|
|
336
440
|
contextmenu_submenu_context.set(submenu);
|
|
337
|
-
// TODO messy, runs more than needed
|
|
338
441
|
onDestroy(() => {
|
|
339
|
-
menu.
|
|
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
|
|
350
|
-
|
|
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
|
-
|
|
358
|
-
params:
|
|
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
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
|
392
|
-
* @param y - the
|
|
393
|
-
* @param contextmenu - the contextmenu
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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(
|
|
451
|
-
(params ??= []).push(...
|
|
548
|
+
if (Array.isArray(registered)) {
|
|
549
|
+
(params ??= []).push(...registered);
|
|
452
550
|
} else {
|
|
453
|
-
(params ??= []).push(
|
|
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
|
|
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(
|
|
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.
|