@joewinke/jatui 0.31.0 → 0.31.2
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.
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
// Per-instance id source for the menu panel (see `menuId` below).
|
|
3
|
+
let menuSeq = 0;
|
|
4
|
+
</script>
|
|
5
|
+
|
|
1
6
|
<script lang="ts">
|
|
2
7
|
/**
|
|
3
8
|
* ContextMenu Component
|
|
@@ -93,6 +98,21 @@
|
|
|
93
98
|
let submenuX = $state(0);
|
|
94
99
|
let submenuY = $state(0);
|
|
95
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Unique id for THIS menu panel. The roving-focus queries below target
|
|
103
|
+
* `[data-context-menu="…"] > button` instead of `:scope > button` because
|
|
104
|
+
* jsdom's selector engine (nwsapi) expands `:scope` to `tag + .classList[0]`
|
|
105
|
+
* — and this panel's first class is the Tailwind arbitrary value
|
|
106
|
+
* `min-w-[200px]`, which nwsapi then cannot parse (`[200px]` looks like an
|
|
107
|
+
* attribute selector to it). Real browsers are fine either way; avoiding
|
|
108
|
+
* `:scope` keeps every jsdom test suite free of the resulting unhandled
|
|
109
|
+
* errors. The unique id preserves the direct-children semantics of
|
|
110
|
+
* `:scope >`: a nested submenu panel is rendered INSIDE this panel and
|
|
111
|
+
* carries its own id, so it can never leak its items into this level's
|
|
112
|
+
* query.
|
|
113
|
+
*/
|
|
114
|
+
const menuId = `cm-${++menuSeq}`;
|
|
115
|
+
|
|
96
116
|
function closeSubmenu() {
|
|
97
117
|
if (submenuIndex === null) return;
|
|
98
118
|
submenuIndex = null;
|
|
@@ -179,9 +199,13 @@
|
|
|
179
199
|
function menuItemButtons(): HTMLButtonElement[] {
|
|
180
200
|
if (!menuEl) return [];
|
|
181
201
|
// Only DIRECT children — don't include nested submenu items when nav-ing
|
|
182
|
-
// this level.
|
|
202
|
+
// this level. The menu panel carries a unique data-context-menu id, so
|
|
203
|
+
// this can't be matched by a nested panel (see `menuId` for why this
|
|
204
|
+
// isn't a `:scope >` selector — nwsapi/jsdom).
|
|
183
205
|
return Array.from(
|
|
184
|
-
menuEl.querySelectorAll(
|
|
206
|
+
menuEl.querySelectorAll(
|
|
207
|
+
`[data-context-menu="${menuId}"] > button[role="menuitem"]:not(:disabled)`
|
|
208
|
+
)
|
|
185
209
|
);
|
|
186
210
|
}
|
|
187
211
|
|
|
@@ -190,11 +214,24 @@
|
|
|
190
214
|
const activeBtn = document.activeElement as HTMLElement | null;
|
|
191
215
|
if (!activeBtn) return -1;
|
|
192
216
|
const buttons = Array.from(
|
|
193
|
-
menuEl.querySelectorAll(
|
|
217
|
+
menuEl.querySelectorAll(`[data-context-menu="${menuId}"] > button[role="menuitem"]`)
|
|
194
218
|
) as HTMLButtonElement[];
|
|
195
219
|
return buttons.indexOf(activeBtn as HTMLButtonElement);
|
|
196
220
|
}
|
|
197
221
|
|
|
222
|
+
// First focusable item of the OPEN nested submenu. The submenu's panel is
|
|
223
|
+
// a direct child of this menu's panel (nested in-DOM so click-outside
|
|
224
|
+
// treats it as inside — see the SelfContextMenu render below); finding it
|
|
225
|
+
// by traversal instead of a `:scope >` selector keeps nwsapi (jsdom) out
|
|
226
|
+
// of the picture entirely.
|
|
227
|
+
function firstSubmenuItem(): HTMLButtonElement | null {
|
|
228
|
+
if (!menuEl) return null;
|
|
229
|
+
const panel = Array.from(menuEl.children).find(
|
|
230
|
+
(c) => c.getAttribute('role') === 'menu'
|
|
231
|
+
);
|
|
232
|
+
return (panel?.querySelector('button[role="menuitem"]:not(:disabled)') ?? null) as HTMLButtonElement | null;
|
|
233
|
+
}
|
|
234
|
+
|
|
198
235
|
function isEditableTarget(target: EventTarget | null): boolean {
|
|
199
236
|
const el = target as HTMLElement | null;
|
|
200
237
|
if (!el) return false;
|
|
@@ -230,12 +267,8 @@
|
|
|
230
267
|
// ArrowRight is an explicit "enter the submenu" gesture — focus
|
|
231
268
|
// its first item. Hover/onfocus open the submenu without moving
|
|
232
269
|
// focus (see autoFocus prop on the recursive instance).
|
|
233
|
-
const el = menuEl;
|
|
234
270
|
requestAnimationFrame(() => {
|
|
235
|
-
|
|
236
|
-
':scope > [role="menu"] > button[role="menuitem"]:not(:disabled)'
|
|
237
|
-
) as HTMLButtonElement | null;
|
|
238
|
-
first?.focus();
|
|
271
|
+
firstSubmenuItem()?.focus();
|
|
239
272
|
});
|
|
240
273
|
}
|
|
241
274
|
return;
|
|
@@ -274,12 +307,8 @@
|
|
|
274
307
|
if (item.submenu?.length) {
|
|
275
308
|
const buttons = menuItemButtons();
|
|
276
309
|
openSubmenuAt(buttons[idx], idx);
|
|
277
|
-
const el = menuEl;
|
|
278
310
|
requestAnimationFrame(() => {
|
|
279
|
-
|
|
280
|
-
':scope > [role="menu"] > button[role="menuitem"]:not(:disabled)'
|
|
281
|
-
) as HTMLButtonElement | null;
|
|
282
|
-
first?.focus();
|
|
311
|
+
firstSubmenuItem()?.focus();
|
|
283
312
|
});
|
|
284
313
|
} else {
|
|
285
314
|
handleItemClick(item, idx, e);
|
|
@@ -305,12 +334,8 @@
|
|
|
305
334
|
// by the time this effect runs, so focus-return-on-close still works.
|
|
306
335
|
$effect(() => {
|
|
307
336
|
if (open && menuEl && autoFocus) {
|
|
308
|
-
const el = menuEl;
|
|
309
337
|
requestAnimationFrame(() => {
|
|
310
|
-
|
|
311
|
-
el.querySelectorAll(':scope > button[role="menuitem"]:not(:disabled)')
|
|
312
|
-
) as HTMLButtonElement[];
|
|
313
|
-
buttons[0]?.focus();
|
|
338
|
+
menuItemButtons()[0]?.focus();
|
|
314
339
|
});
|
|
315
340
|
}
|
|
316
341
|
});
|
|
@@ -326,6 +351,7 @@
|
|
|
326
351
|
bind:this={menuEl}
|
|
327
352
|
role="menu"
|
|
328
353
|
tabindex="-1"
|
|
354
|
+
data-context-menu={menuId}
|
|
329
355
|
class="min-w-[200px] bg-base-200 border border-base-300 rounded-md py-1 {className}"
|
|
330
356
|
style="z-index: {zIndex};"
|
|
331
357
|
use:floating={{ anchor, placement: 'bottom-start', flip: true, shift: true, offset: 2, onDismiss: close }}
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
const p = player ?? getSoundPlayer();
|
|
72
72
|
// A preview click is a real gesture: unlock here too, so a first-ever
|
|
73
73
|
// preview on iOS actually sounds instead of resuming a dead context.
|
|
74
|
-
void p.unlock();
|
|
74
|
+
void p.unlock?.();
|
|
75
75
|
previewing = p.play(name, { preview: true, loop: false });
|
|
76
76
|
previewTimer = setTimeout(stopPreview, PREVIEW_MAX_MS);
|
|
77
77
|
}
|
package/dist/sound/player.d.ts
CHANGED
|
@@ -53,15 +53,17 @@ export interface SoundPlayer {
|
|
|
53
53
|
/**
|
|
54
54
|
* Unlock audio output from inside a user-gesture handler (iOS/Safari
|
|
55
55
|
* keeps a context suspended unless the gesture created/resumed it —
|
|
56
|
-
* see `sound/unlock.ts`).
|
|
57
|
-
*
|
|
56
|
+
* see `sound/unlock.ts`). OPTIONAL: a custom player without one is
|
|
57
|
+
* assumed fine — unlock resolves and `unlocked` reads undefined, which
|
|
58
|
+
* hosts treat as "no unlock concept". A cheap no-op once unlocked.
|
|
58
59
|
*/
|
|
59
|
-
unlock(): Promise<void>;
|
|
60
|
+
unlock?(): Promise<void>;
|
|
60
61
|
/**
|
|
61
|
-
* True when the backend reports a running output context;
|
|
62
|
-
*
|
|
62
|
+
* True when the player's backend reports a running output context;
|
|
63
|
+
* false before any unlock, undefined when the player has no unlock
|
|
64
|
+
* concept.
|
|
63
65
|
*/
|
|
64
|
-
readonly unlocked
|
|
66
|
+
readonly unlocked?: boolean;
|
|
65
67
|
/** Decode clips ahead of time (defaults to every semantic name). */
|
|
66
68
|
preload(names?: readonly SoundId[]): Promise<void>;
|
|
67
69
|
stopAll(): void;
|
package/dist/sound/unlock.js
CHANGED
|
@@ -30,7 +30,7 @@ import { getSoundPlayer } from './player';
|
|
|
30
30
|
* is a cheap no-op once the context is running.
|
|
31
31
|
*/
|
|
32
32
|
export async function unlockSound() {
|
|
33
|
-
await getSoundPlayer().unlock();
|
|
33
|
+
await getSoundPlayer().unlock?.();
|
|
34
34
|
}
|
|
35
35
|
const GESTURES = ['pointerup', 'touchend', 'click', 'keydown'];
|
|
36
36
|
/** The target `armSoundUnlock` installed its one-shot listener on, if any. */
|