@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(':scope > button[role="menuitem"]:not(:disabled)')
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(':scope > button[role="menuitem"]')
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
- const first = el?.querySelector(
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
- const first = el?.querySelector(
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
- const buttons = Array.from(
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
  }
@@ -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`). Backends without an unlock concept resolve
57
- * immediately. A cheap no-op once unlocked.
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; a backend
62
- * with no unlock concept always reports true. False before any unlock.
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: boolean;
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;
@@ -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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.31.0",
3
+ "version": "0.31.2",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",