@joewinke/jatui 0.31.0 → 0.31.1
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 }}
|