@pindoba/astro-menu 0.0.0-alpha-20260829223243
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/LICENSE +12 -0
- package/index.ts +9 -0
- package/package.json +67 -0
- package/src/__test__/boot-menu.test.ts +917 -0
- package/src/__test__/runtime.test.ts +57 -0
- package/src/boot-menu.ts +598 -0
- package/src/demos/account.astro +55 -0
- package/src/demos/checkbox-radio.astro +77 -0
- package/src/demos/controlled.astro +67 -0
- package/src/demos/danger.astro +50 -0
- package/src/demos/default.astro +151 -0
- package/src/demos/index.ts +7 -0
- package/src/demos/rich-items.astro +105 -0
- package/src/demos/submenu.astro +60 -0
- package/src/env.d.ts +2 -0
- package/src/menu.astro +396 -0
- package/src/runtime.ts +102 -0
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import { bootMenu, bootAllMenus } from "../boot-menu.js";
|
|
4
|
+
import type { MenuConfig, MenuRootElement } from "../runtime.js";
|
|
5
|
+
|
|
6
|
+
// --- Fake popover -----------------------------------------------------------
|
|
7
|
+
// The boot drives every level through `$open` and reads it back in `reconcile`,
|
|
8
|
+
// so the atom must be real-behaving. Floating UI itself is out of scope here.
|
|
9
|
+
interface FakeAtom {
|
|
10
|
+
_value: boolean;
|
|
11
|
+
_listeners: Array<(v: boolean) => void>;
|
|
12
|
+
get(): boolean;
|
|
13
|
+
set(v: boolean): void;
|
|
14
|
+
listen(fn: (v: boolean) => void): () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeAtom(): FakeAtom {
|
|
18
|
+
return {
|
|
19
|
+
_value: false,
|
|
20
|
+
_listeners: [],
|
|
21
|
+
get() {
|
|
22
|
+
return this._value;
|
|
23
|
+
},
|
|
24
|
+
set(v: boolean) {
|
|
25
|
+
if (this._value === v) return;
|
|
26
|
+
this._value = v;
|
|
27
|
+
for (const fn of [...this._listeners]) fn(v);
|
|
28
|
+
},
|
|
29
|
+
listen(fn: (v: boolean) => void) {
|
|
30
|
+
this._listeners.push(fn);
|
|
31
|
+
return () => {
|
|
32
|
+
const i = this._listeners.indexOf(fn);
|
|
33
|
+
if (i >= 0) this._listeners.splice(i, 1);
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface FakePopover {
|
|
40
|
+
options: Record<string, unknown>;
|
|
41
|
+
$open: FakeAtom;
|
|
42
|
+
setDialogElement: ReturnType<typeof vi.fn>;
|
|
43
|
+
setTriggerElement: ReturnType<typeof vi.fn>;
|
|
44
|
+
setAnchorElement: ReturnType<typeof vi.fn>;
|
|
45
|
+
setInsideElements: ReturnType<typeof vi.fn>;
|
|
46
|
+
setDialog: ReturnType<typeof vi.fn>;
|
|
47
|
+
destroy: ReturnType<typeof vi.fn>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let createdPopovers: FakePopover[] = [];
|
|
51
|
+
|
|
52
|
+
const { mockCreatePopover } = vi.hoisted(() => ({
|
|
53
|
+
mockCreatePopover: vi.fn(),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
// Only `createPopover` is faked — the safe-triangle helpers are re-exported
|
|
57
|
+
// through this package, so replacing the whole module would strip them and the
|
|
58
|
+
// pointer gating would throw instead of running.
|
|
59
|
+
vi.mock("@pindoba/core-popover", async (importOriginal) => {
|
|
60
|
+
const actual = await importOriginal<typeof import("@pindoba/core-popover")>();
|
|
61
|
+
return { ...actual, createPopover: mockCreatePopover };
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
function popoverFor(id: string): FakePopover {
|
|
65
|
+
const found = createdPopovers.find((p) => p.options.id === id);
|
|
66
|
+
if (!found) throw new Error(`no popover created for ${id}`);
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- requestAnimationFrame control -----------------------------------------
|
|
71
|
+
let rafQueue: FrameRequestCallback[] = [];
|
|
72
|
+
function drainRaf(maxRounds = 40) {
|
|
73
|
+
let rounds = 0;
|
|
74
|
+
while (rafQueue.length && rounds++ < maxRounds) {
|
|
75
|
+
const q = rafQueue;
|
|
76
|
+
rafQueue = [];
|
|
77
|
+
for (const cb of q) cb(0);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- Dialog manager ---------------------------------------------------------
|
|
82
|
+
function installManager(getInstance = vi.fn((id: string) => ({ id }))) {
|
|
83
|
+
// @ts-expect-error -- partial dialog instance is all `setDialog` receives here
|
|
84
|
+
window.__PindobaDialogManager = { getInstance };
|
|
85
|
+
return getInstance;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- Fixture ----------------------------------------------------------------
|
|
89
|
+
const ROOT_POPOVER = "menu-popover";
|
|
90
|
+
const ROOT_MENU = "menu-list";
|
|
91
|
+
const SUB_QUALIFIED = `${ROOT_MENU}-sub-more`;
|
|
92
|
+
const SUB_POPOVER = `${SUB_QUALIFIED}-popover`;
|
|
93
|
+
const SUB_MENU = `${SUB_QUALIFIED}-menu`;
|
|
94
|
+
const SUB_TRIGGER_ITEM = `${ROOT_MENU}-option-more`;
|
|
95
|
+
|
|
96
|
+
function makeConfig(overrides: Partial<MenuConfig> = {}): MenuConfig {
|
|
97
|
+
return {
|
|
98
|
+
rootId: "menu",
|
|
99
|
+
triggerId: "menu-trigger",
|
|
100
|
+
popoverId: ROOT_POPOVER,
|
|
101
|
+
menuId: ROOT_MENU,
|
|
102
|
+
side: "right",
|
|
103
|
+
placement: "bottom-start",
|
|
104
|
+
levels: [
|
|
105
|
+
{
|
|
106
|
+
id: null,
|
|
107
|
+
popoverId: ROOT_POPOVER,
|
|
108
|
+
menuId: ROOT_MENU,
|
|
109
|
+
depth: 0,
|
|
110
|
+
parentTriggerItemId: null,
|
|
111
|
+
submenus: [
|
|
112
|
+
{
|
|
113
|
+
id: "more",
|
|
114
|
+
qualifiedId: SUB_QUALIFIED,
|
|
115
|
+
triggerItemId: SUB_TRIGGER_ITEM,
|
|
116
|
+
popoverId: SUB_POPOVER,
|
|
117
|
+
menuId: SUB_MENU,
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "more",
|
|
123
|
+
popoverId: SUB_POPOVER,
|
|
124
|
+
menuId: SUB_MENU,
|
|
125
|
+
depth: 1,
|
|
126
|
+
parentTriggerItemId: SUB_TRIGGER_ITEM,
|
|
127
|
+
submenus: [],
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
nodeIndex: {
|
|
131
|
+
save: { type: "action", closeOnSelect: true },
|
|
132
|
+
stay: { type: "action", closeOnSelect: false },
|
|
133
|
+
docs: { type: "link" },
|
|
134
|
+
wrap: { type: "checkbox", checked: false, closeOnSelect: false },
|
|
135
|
+
close: { type: "checkbox", checked: true, closeOnSelect: true },
|
|
136
|
+
more: { type: "submenu", subId: "more" },
|
|
137
|
+
red: { type: "radio", groupId: "color", value: "red" },
|
|
138
|
+
blue: { type: "radio", groupId: "color", value: "blue" },
|
|
139
|
+
},
|
|
140
|
+
radioInitial: { color: "red" },
|
|
141
|
+
...overrides,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface ListboxHandle {
|
|
146
|
+
setItems: ReturnType<typeof vi.fn>;
|
|
147
|
+
}
|
|
148
|
+
type ListboxUl = HTMLElement & { listbox?: ListboxHandle };
|
|
149
|
+
|
|
150
|
+
interface MountOpts {
|
|
151
|
+
config?: MenuConfig;
|
|
152
|
+
/** Replace the serialized payload wholesale (parse-fail / non-array levels). */
|
|
153
|
+
rawConfig?: string;
|
|
154
|
+
/** Drop the `[data-pindoba-menu]` wrapper. */
|
|
155
|
+
orphanTrigger?: boolean;
|
|
156
|
+
/** Skip attaching fake ListBox handles (exercises the un-booted path). */
|
|
157
|
+
withListboxHandles?: boolean;
|
|
158
|
+
/** Mark the dialogs non-modal so the boot relocates them. */
|
|
159
|
+
isModalFalse?: boolean;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function makeList(
|
|
163
|
+
id: string,
|
|
164
|
+
keys: string[],
|
|
165
|
+
withHandle: boolean,
|
|
166
|
+
): { ul: ListboxUl; handle: ListboxHandle | null } {
|
|
167
|
+
const ul: ListboxUl = document.createElement("ul");
|
|
168
|
+
ul.id = id;
|
|
169
|
+
ul.setAttribute(
|
|
170
|
+
"data-listbox-config",
|
|
171
|
+
JSON.stringify({
|
|
172
|
+
items: [
|
|
173
|
+
{ id: "wrap", label: "Wrap", checked: false },
|
|
174
|
+
{
|
|
175
|
+
id: "colors",
|
|
176
|
+
type: "section",
|
|
177
|
+
items: [
|
|
178
|
+
{ id: "red", label: "Red", checked: true },
|
|
179
|
+
{ id: "blue", label: "Blue", checked: false },
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
for (const key of keys) {
|
|
186
|
+
const li = document.createElement("li");
|
|
187
|
+
li.setAttribute("data-key", key);
|
|
188
|
+
li.id = `${id}-option-${key}`;
|
|
189
|
+
li.tabIndex = -1;
|
|
190
|
+
ul.appendChild(li);
|
|
191
|
+
}
|
|
192
|
+
const handle = withHandle ? { setItems: vi.fn() } : null;
|
|
193
|
+
if (handle) ul.listbox = handle;
|
|
194
|
+
return { ul, handle };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface Fixture {
|
|
198
|
+
root: MenuRootElement;
|
|
199
|
+
trigger: HTMLElement;
|
|
200
|
+
rootDialog: HTMLDialogElement;
|
|
201
|
+
subDialog: HTMLDialogElement;
|
|
202
|
+
rootUl: ListboxUl;
|
|
203
|
+
subUl: ListboxUl;
|
|
204
|
+
rootHandle: ListboxHandle | null;
|
|
205
|
+
subHandle: ListboxHandle | null;
|
|
206
|
+
config: MenuConfig;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function mount(opts: MountOpts = {}): Fixture {
|
|
210
|
+
const config = opts.config ?? makeConfig();
|
|
211
|
+
const withHandles = opts.withListboxHandles ?? true;
|
|
212
|
+
|
|
213
|
+
const trigger = document.createElement("button");
|
|
214
|
+
trigger.id = config.triggerId;
|
|
215
|
+
trigger.setAttribute("data-menu-trigger", "");
|
|
216
|
+
trigger.setAttribute(
|
|
217
|
+
"data-menu-config",
|
|
218
|
+
opts.rawConfig ?? JSON.stringify(config),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
let root: HTMLElement;
|
|
222
|
+
if (opts.orphanTrigger) {
|
|
223
|
+
root = document.createElement("div");
|
|
224
|
+
root.appendChild(trigger);
|
|
225
|
+
} else {
|
|
226
|
+
root = document.createElement("div");
|
|
227
|
+
root.setAttribute("data-pindoba-menu", "");
|
|
228
|
+
root.appendChild(trigger);
|
|
229
|
+
}
|
|
230
|
+
document.body.appendChild(root);
|
|
231
|
+
|
|
232
|
+
const rootDialog = document.createElement("dialog");
|
|
233
|
+
rootDialog.id = ROOT_POPOVER;
|
|
234
|
+
const subDialog = document.createElement("dialog");
|
|
235
|
+
subDialog.id = SUB_POPOVER;
|
|
236
|
+
if (opts.isModalFalse) {
|
|
237
|
+
rootDialog.dataset.isModal = "false";
|
|
238
|
+
subDialog.dataset.isModal = "false";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const rootList = makeList(
|
|
242
|
+
ROOT_MENU,
|
|
243
|
+
["save", "stay", "docs", "wrap", "close", "more"],
|
|
244
|
+
withHandles,
|
|
245
|
+
);
|
|
246
|
+
const subList = makeList(SUB_MENU, ["red", "blue"], withHandles);
|
|
247
|
+
rootDialog.appendChild(rootList.ul);
|
|
248
|
+
subDialog.appendChild(subList.ul);
|
|
249
|
+
document.body.append(rootDialog, subDialog);
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
root,
|
|
253
|
+
trigger,
|
|
254
|
+
rootDialog,
|
|
255
|
+
subDialog,
|
|
256
|
+
rootUl: rootList.ul,
|
|
257
|
+
subUl: subList.ul,
|
|
258
|
+
rootHandle: rootList.handle,
|
|
259
|
+
subHandle: subList.handle,
|
|
260
|
+
config,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const keyOn = (el: HTMLElement, key: string) => {
|
|
265
|
+
const event = new KeyboardEvent("keydown", {
|
|
266
|
+
key,
|
|
267
|
+
bubbles: true,
|
|
268
|
+
cancelable: true,
|
|
269
|
+
});
|
|
270
|
+
el.dispatchEvent(event);
|
|
271
|
+
return event;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const action = (ul: HTMLElement, key: string) =>
|
|
275
|
+
ul.dispatchEvent(
|
|
276
|
+
new CustomEvent("listbox:action", { detail: { key }, bubbles: true }),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const pointerAt = (el: HTMLElement, target: HTMLElement, x = 0, y = 0) => {
|
|
280
|
+
const event = new Event("pointermove", { bubbles: true });
|
|
281
|
+
Object.defineProperties(event, {
|
|
282
|
+
clientX: { value: x },
|
|
283
|
+
clientY: { value: y },
|
|
284
|
+
target: { value: target },
|
|
285
|
+
});
|
|
286
|
+
el.dispatchEvent(event);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
function capture(root: HTMLElement, type: string) {
|
|
290
|
+
const seen: unknown[] = [];
|
|
291
|
+
root.addEventListener(type, (e) => seen.push((e as CustomEvent).detail));
|
|
292
|
+
return seen;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let teardowns: Array<() => void> = [];
|
|
296
|
+
|
|
297
|
+
beforeEach(() => {
|
|
298
|
+
createdPopovers = [];
|
|
299
|
+
rafQueue = [];
|
|
300
|
+
teardowns = [];
|
|
301
|
+
mockCreatePopover.mockImplementation((options: Record<string, unknown>) => {
|
|
302
|
+
const popover: FakePopover = {
|
|
303
|
+
options,
|
|
304
|
+
$open: makeAtom(),
|
|
305
|
+
setDialogElement: vi.fn(),
|
|
306
|
+
setTriggerElement: vi.fn(),
|
|
307
|
+
setAnchorElement: vi.fn(),
|
|
308
|
+
setInsideElements: vi.fn(),
|
|
309
|
+
setDialog: vi.fn(),
|
|
310
|
+
destroy: vi.fn(),
|
|
311
|
+
};
|
|
312
|
+
createdPopovers.push(popover);
|
|
313
|
+
return popover;
|
|
314
|
+
});
|
|
315
|
+
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
|
316
|
+
rafQueue.push(cb);
|
|
317
|
+
return rafQueue.length;
|
|
318
|
+
});
|
|
319
|
+
installManager();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
afterEach(() => {
|
|
323
|
+
for (const t of teardowns) t();
|
|
324
|
+
document.body.innerHTML = "";
|
|
325
|
+
delete window.__PindobaDialogManager;
|
|
326
|
+
vi.unstubAllGlobals();
|
|
327
|
+
vi.restoreAllMocks();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
function boot(fixture: Fixture) {
|
|
331
|
+
const result = bootMenu(fixture.trigger);
|
|
332
|
+
if (result) teardowns.push(result.destroy);
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
describe("bootMenu — payload + ownership guards", () => {
|
|
337
|
+
it("bails on a malformed config without marking the trigger", () => {
|
|
338
|
+
const f = mount({ rawConfig: "{not json" });
|
|
339
|
+
expect(bootMenu(f.trigger)).toBeNull();
|
|
340
|
+
expect(f.trigger.hasAttribute("data-menu-initialized")).toBe(false);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("bails when `levels` is not an array", () => {
|
|
344
|
+
const f = mount({ rawConfig: JSON.stringify({ levels: "nope" }) });
|
|
345
|
+
expect(bootMenu(f.trigger)).toBeNull();
|
|
346
|
+
expect(f.trigger.hasAttribute("data-menu-initialized")).toBe(false);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("bails on a trigger outside any `[data-pindoba-menu]` root", () => {
|
|
350
|
+
const f = mount({ orphanTrigger: true });
|
|
351
|
+
expect(bootMenu(f.trigger)).toBeNull();
|
|
352
|
+
expect(f.trigger.hasAttribute("data-menu-initialized")).toBe(false);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("bails (once) on a second boot of the same trigger", () => {
|
|
356
|
+
const f = mount();
|
|
357
|
+
expect(boot(f)).not.toBeNull();
|
|
358
|
+
expect(f.trigger.getAttribute("data-menu-initialized")).toBe("true");
|
|
359
|
+
expect(bootMenu(f.trigger)).toBeNull();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("bails when no root level is described, leaving nothing behind", () => {
|
|
363
|
+
const config = makeConfig();
|
|
364
|
+
config.levels = config.levels.filter((l) => l.id !== null);
|
|
365
|
+
const f = mount({ config });
|
|
366
|
+
|
|
367
|
+
expect(bootMenu(f.trigger)).toBeNull();
|
|
368
|
+
// This bail happens after the per-level popovers were created, so they must
|
|
369
|
+
// be torn down — and the trigger must stay unmarked so a corrected render
|
|
370
|
+
// can still boot.
|
|
371
|
+
for (const popover of createdPopovers) {
|
|
372
|
+
expect(popover.destroy).toHaveBeenCalled();
|
|
373
|
+
}
|
|
374
|
+
expect(f.trigger.hasAttribute("data-menu-initialized")).toBe(false);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe("bootMenu — popover wiring", () => {
|
|
379
|
+
it("creates one popover per level, root anchored to the trigger", () => {
|
|
380
|
+
const f = mount();
|
|
381
|
+
boot(f);
|
|
382
|
+
expect(createdPopovers).toHaveLength(2);
|
|
383
|
+
|
|
384
|
+
const rootPopover = popoverFor(ROOT_POPOVER);
|
|
385
|
+
expect(rootPopover.options).toMatchObject({
|
|
386
|
+
placement: "bottom-start",
|
|
387
|
+
triggerStrategy: "click",
|
|
388
|
+
anchorElement: f.trigger,
|
|
389
|
+
autoFocus: false,
|
|
390
|
+
});
|
|
391
|
+
expect(rootPopover.setTriggerElement).toHaveBeenCalledWith(f.trigger);
|
|
392
|
+
expect(rootPopover.setDialogElement).toHaveBeenCalledWith(f.rootDialog);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("gives each submenu a manual-trigger popover stacked by depth", () => {
|
|
396
|
+
const f = mount();
|
|
397
|
+
boot(f);
|
|
398
|
+
expect(popoverFor(SUB_POPOVER).options).toMatchObject({
|
|
399
|
+
placement: "right-start",
|
|
400
|
+
triggerStrategy: "manual",
|
|
401
|
+
zIndex: 10001,
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("registers the submenu dialogs as inside-elements of the root", () => {
|
|
406
|
+
const f = mount();
|
|
407
|
+
boot(f);
|
|
408
|
+
expect(popoverFor(ROOT_POPOVER).setInsideElements).toHaveBeenCalledWith([
|
|
409
|
+
f.subDialog,
|
|
410
|
+
]);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("relocates non-modal dialogs to <body>", () => {
|
|
414
|
+
const f = mount({ isModalFalse: true });
|
|
415
|
+
const wrapper = document.createElement("div");
|
|
416
|
+
document.body.appendChild(wrapper);
|
|
417
|
+
wrapper.appendChild(f.subDialog);
|
|
418
|
+
|
|
419
|
+
boot(f);
|
|
420
|
+
expect(f.subDialog.parentElement).toBe(document.body);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("keeps a non-modal dialog nested inside a MODAL <dialog> ancestor", () => {
|
|
424
|
+
const f = mount({ isModalFalse: true });
|
|
425
|
+
const modal = document.createElement("dialog");
|
|
426
|
+
modal.setAttribute("data-is-modal", "true");
|
|
427
|
+
document.body.appendChild(modal);
|
|
428
|
+
modal.appendChild(f.subDialog);
|
|
429
|
+
|
|
430
|
+
boot(f);
|
|
431
|
+
expect(f.subDialog.parentElement).toBe(modal);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("hands each popover its dialog instance once the manager is ready", async () => {
|
|
435
|
+
const getInstance = installManager();
|
|
436
|
+
const f = mount();
|
|
437
|
+
boot(f);
|
|
438
|
+
await Promise.resolve();
|
|
439
|
+
await Promise.resolve();
|
|
440
|
+
expect(getInstance).toHaveBeenCalledWith(ROOT_POPOVER);
|
|
441
|
+
expect(popoverFor(ROOT_POPOVER).setDialog).toHaveBeenCalled();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("waits for the manager-ready event when it boots first", async () => {
|
|
445
|
+
delete window.__PindobaDialogManager;
|
|
446
|
+
const f = mount();
|
|
447
|
+
boot(f);
|
|
448
|
+
await Promise.resolve();
|
|
449
|
+
expect(popoverFor(ROOT_POPOVER).setDialog).not.toHaveBeenCalled();
|
|
450
|
+
|
|
451
|
+
const getInstance = installManager();
|
|
452
|
+
document.dispatchEvent(new Event("pindoba:dialog-manager-ready"));
|
|
453
|
+
await Promise.resolve();
|
|
454
|
+
await Promise.resolve();
|
|
455
|
+
expect(getInstance).toHaveBeenCalledWith(ROOT_POPOVER);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
describe("bootMenu — root open state", () => {
|
|
460
|
+
it("mirrors open state onto the trigger and emits openchange", () => {
|
|
461
|
+
const f = mount();
|
|
462
|
+
boot(f);
|
|
463
|
+
const events = capture(f.root, "pindoba:menu:openchange");
|
|
464
|
+
|
|
465
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
466
|
+
expect(f.trigger.getAttribute("aria-expanded")).toBe("true");
|
|
467
|
+
expect(f.trigger.getAttribute("data-state")).toBe("open");
|
|
468
|
+
|
|
469
|
+
popoverFor(ROOT_POPOVER).$open.set(false);
|
|
470
|
+
expect(f.trigger.getAttribute("aria-expanded")).toBe("false");
|
|
471
|
+
expect(f.trigger.getAttribute("data-state")).toBe("closed");
|
|
472
|
+
expect(events).toEqual([
|
|
473
|
+
{ id: "menu", open: true },
|
|
474
|
+
{ id: "menu", open: false },
|
|
475
|
+
]);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("focuses the first row when the root opens", () => {
|
|
479
|
+
const f = mount();
|
|
480
|
+
boot(f);
|
|
481
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
482
|
+
expect(document.activeElement).toBe(
|
|
483
|
+
f.rootUl.querySelector('[data-key="save"]'),
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("collapses every open submenu when the root closes", () => {
|
|
488
|
+
const f = mount();
|
|
489
|
+
boot(f);
|
|
490
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
491
|
+
action(f.rootUl, "more");
|
|
492
|
+
expect(popoverFor(SUB_POPOVER).$open.get()).toBe(true);
|
|
493
|
+
|
|
494
|
+
popoverFor(ROOT_POPOVER).$open.set(false);
|
|
495
|
+
expect(popoverFor(SUB_POPOVER).$open.get()).toBe(false);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("opens on boot when the config asked for it", async () => {
|
|
499
|
+
const f = mount({ config: { ...makeConfig(), open: true } as MenuConfig });
|
|
500
|
+
boot(f);
|
|
501
|
+
await Promise.resolve();
|
|
502
|
+
await Promise.resolve();
|
|
503
|
+
expect(popoverFor(ROOT_POPOVER).$open.get()).toBe(true);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
describe("bootMenu — activation", () => {
|
|
508
|
+
it("emits an action and closes when `closeOnSelect`", () => {
|
|
509
|
+
const f = mount();
|
|
510
|
+
boot(f);
|
|
511
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
512
|
+
const actions = capture(f.root, "pindoba:menu:action");
|
|
513
|
+
|
|
514
|
+
action(f.rootUl, "save");
|
|
515
|
+
expect(actions).toEqual([{ id: "save" }]);
|
|
516
|
+
expect(popoverFor(ROOT_POPOVER).$open.get()).toBe(false);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("keeps the menu open for a non-closing action", () => {
|
|
520
|
+
const f = mount();
|
|
521
|
+
boot(f);
|
|
522
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
523
|
+
action(f.rootUl, "stay");
|
|
524
|
+
expect(popoverFor(ROOT_POPOVER).$open.get()).toBe(true);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("closes the root after a link activation", () => {
|
|
528
|
+
const f = mount();
|
|
529
|
+
boot(f);
|
|
530
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
531
|
+
action(f.rootUl, "docs");
|
|
532
|
+
expect(popoverFor(ROOT_POPOVER).$open.get()).toBe(false);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("ignores keys with no entry in the node index", () => {
|
|
536
|
+
const f = mount();
|
|
537
|
+
boot(f);
|
|
538
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
539
|
+
const actions = capture(f.root, "pindoba:menu:action");
|
|
540
|
+
action(f.rootUl, "ghost");
|
|
541
|
+
expect(actions).toEqual([]);
|
|
542
|
+
expect(popoverFor(ROOT_POPOVER).$open.get()).toBe(true);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("toggles a checkbox, repaints the list and reports the change", () => {
|
|
546
|
+
const f = mount();
|
|
547
|
+
boot(f);
|
|
548
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
549
|
+
const changes = capture(f.root, "pindoba:menu:checkedchange");
|
|
550
|
+
|
|
551
|
+
action(f.rootUl, "wrap");
|
|
552
|
+
expect(changes).toEqual([{ id: "wrap", checked: true }]);
|
|
553
|
+
const painted = f.rootHandle!.setItems.mock.lastCall![0];
|
|
554
|
+
expect(painted[0]).toMatchObject({ id: "wrap", checked: true });
|
|
555
|
+
|
|
556
|
+
action(f.rootUl, "wrap");
|
|
557
|
+
expect(changes[1]).toEqual({ id: "wrap", checked: false });
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("seeds checkbox state from the server-rendered payload", () => {
|
|
561
|
+
const f = mount();
|
|
562
|
+
boot(f);
|
|
563
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
564
|
+
const changes = capture(f.root, "pindoba:menu:checkedchange");
|
|
565
|
+
// `close` was SSR'd checked, so its first toggle must go to false.
|
|
566
|
+
action(f.rootUl, "close");
|
|
567
|
+
expect(changes).toEqual([{ id: "close", checked: false }]);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("selects a radio value and repaints without closing", () => {
|
|
571
|
+
const f = mount();
|
|
572
|
+
boot(f);
|
|
573
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
574
|
+
const changes = capture(f.root, "pindoba:menu:valuechange");
|
|
575
|
+
|
|
576
|
+
action(f.rootUl, "blue");
|
|
577
|
+
expect(changes).toEqual([{ groupId: "color", value: "blue" }]);
|
|
578
|
+
expect(popoverFor(ROOT_POPOVER).$open.get()).toBe(true);
|
|
579
|
+
|
|
580
|
+
const painted = f.rootHandle!.setItems.mock.lastCall![0];
|
|
581
|
+
expect(painted[1].items).toEqual([
|
|
582
|
+
expect.objectContaining({ id: "red", checked: false }),
|
|
583
|
+
expect.objectContaining({ id: "blue", checked: true }),
|
|
584
|
+
]);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("opens the submenu and focuses its first row from an activation", () => {
|
|
588
|
+
const f = mount();
|
|
589
|
+
boot(f);
|
|
590
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
591
|
+
|
|
592
|
+
action(f.rootUl, "more");
|
|
593
|
+
expect(popoverFor(SUB_POPOVER).$open.get()).toBe(true);
|
|
594
|
+
expect(popoverFor(SUB_POPOVER).setAnchorElement).toHaveBeenCalledWith(
|
|
595
|
+
document.getElementById(SUB_TRIGGER_ITEM),
|
|
596
|
+
);
|
|
597
|
+
expect(document.activeElement).toBe(
|
|
598
|
+
f.subUl.querySelector('[data-key="red"]'),
|
|
599
|
+
);
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
describe("bootMenu — keyboard", () => {
|
|
604
|
+
it("opens a submenu with ArrowRight on its trigger row", () => {
|
|
605
|
+
const f = mount();
|
|
606
|
+
boot(f);
|
|
607
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
608
|
+
f.rootUl.querySelector<HTMLElement>('[data-key="more"]')!.focus();
|
|
609
|
+
|
|
610
|
+
const event = keyOn(f.rootDialog, "ArrowRight");
|
|
611
|
+
expect(event.defaultPrevented).toBe(true);
|
|
612
|
+
expect(popoverFor(SUB_POPOVER).$open.get()).toBe(true);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("ignores ArrowRight on a row with no submenu", () => {
|
|
616
|
+
const f = mount();
|
|
617
|
+
boot(f);
|
|
618
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
619
|
+
f.rootUl.querySelector<HTMLElement>('[data-key="save"]')!.focus();
|
|
620
|
+
|
|
621
|
+
const event = keyOn(f.rootDialog, "ArrowRight");
|
|
622
|
+
expect(event.defaultPrevented).toBe(false);
|
|
623
|
+
expect(popoverFor(SUB_POPOVER).$open.get()).toBe(false);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("closes a submenu with ArrowLeft and restores focus to its trigger row", () => {
|
|
627
|
+
const f = mount();
|
|
628
|
+
boot(f);
|
|
629
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
630
|
+
action(f.rootUl, "more");
|
|
631
|
+
|
|
632
|
+
keyOn(f.subDialog, "ArrowLeft");
|
|
633
|
+
expect(popoverFor(SUB_POPOVER).$open.get()).toBe(false);
|
|
634
|
+
expect(document.activeElement).toBe(
|
|
635
|
+
document.getElementById(SUB_TRIGGER_ITEM),
|
|
636
|
+
);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("leaves ArrowLeft alone at the root level", () => {
|
|
640
|
+
const f = mount();
|
|
641
|
+
boot(f);
|
|
642
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
643
|
+
const event = keyOn(f.rootDialog, "ArrowLeft");
|
|
644
|
+
expect(event.defaultPrevented).toBe(false);
|
|
645
|
+
expect(popoverFor(ROOT_POPOVER).$open.get()).toBe(true);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("Escape closes only the topmost level", () => {
|
|
649
|
+
const f = mount();
|
|
650
|
+
boot(f);
|
|
651
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
652
|
+
action(f.rootUl, "more");
|
|
653
|
+
|
|
654
|
+
keyOn(f.subDialog, "Escape");
|
|
655
|
+
expect(popoverFor(SUB_POPOVER).$open.get()).toBe(false);
|
|
656
|
+
expect(popoverFor(ROOT_POPOVER).$open.get()).toBe(true);
|
|
657
|
+
|
|
658
|
+
keyOn(f.rootDialog, "Escape");
|
|
659
|
+
expect(popoverFor(ROOT_POPOVER).$open.get()).toBe(false);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("swallows Escape so the native dialog cancel cannot desync state", () => {
|
|
663
|
+
const f = mount();
|
|
664
|
+
boot(f);
|
|
665
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
666
|
+
expect(keyOn(f.rootDialog, "Escape").defaultPrevented).toBe(true);
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
describe("bootMenu — pointer", () => {
|
|
671
|
+
it("opens a submenu on hover without stealing focus", () => {
|
|
672
|
+
const f = mount();
|
|
673
|
+
boot(f);
|
|
674
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
675
|
+
const saveRow = f.rootUl.querySelector<HTMLElement>('[data-key="save"]')!;
|
|
676
|
+
saveRow.focus();
|
|
677
|
+
|
|
678
|
+
pointerAt(
|
|
679
|
+
f.rootDialog,
|
|
680
|
+
f.rootUl.querySelector<HTMLElement>('[data-key="more"]')!,
|
|
681
|
+
);
|
|
682
|
+
drainRaf();
|
|
683
|
+
|
|
684
|
+
expect(popoverFor(SUB_POPOVER).$open.get()).toBe(true);
|
|
685
|
+
expect(document.activeElement).toBe(saveRow);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("closes the open submenu when a sibling row is hovered", () => {
|
|
689
|
+
const f = mount();
|
|
690
|
+
boot(f);
|
|
691
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
692
|
+
action(f.rootUl, "more");
|
|
693
|
+
|
|
694
|
+
pointerAt(
|
|
695
|
+
f.rootDialog,
|
|
696
|
+
f.rootUl.querySelector<HTMLElement>('[data-key="save"]')!,
|
|
697
|
+
);
|
|
698
|
+
expect(popoverFor(SUB_POPOVER).$open.get()).toBe(false);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it("ignores pointer moves that land outside any row", () => {
|
|
702
|
+
const f = mount();
|
|
703
|
+
boot(f);
|
|
704
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
705
|
+
action(f.rootUl, "more");
|
|
706
|
+
|
|
707
|
+
pointerAt(f.rootDialog, f.rootUl);
|
|
708
|
+
expect(popoverFor(SUB_POPOVER).$open.get()).toBe(true);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("closes the open submenu when the parent level scrolls", () => {
|
|
712
|
+
const f = mount();
|
|
713
|
+
boot(f);
|
|
714
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
715
|
+
action(f.rootUl, "more");
|
|
716
|
+
|
|
717
|
+
f.rootUl.dispatchEvent(new Event("scroll", { bubbles: true }));
|
|
718
|
+
expect(popoverFor(SUB_POPOVER).$open.get()).toBe(false);
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
describe("bootMenu — imperative handle", () => {
|
|
723
|
+
const handleOf = (f: Fixture) => (f.root as MenuRootElement).menu!;
|
|
724
|
+
|
|
725
|
+
it("exposes open / close / toggle / isOpen", () => {
|
|
726
|
+
const f = mount();
|
|
727
|
+
boot(f);
|
|
728
|
+
const menu = handleOf(f);
|
|
729
|
+
|
|
730
|
+
expect(menu.isOpen()).toBe(false);
|
|
731
|
+
menu.open();
|
|
732
|
+
expect(menu.isOpen()).toBe(true);
|
|
733
|
+
menu.toggle();
|
|
734
|
+
expect(menu.isOpen()).toBe(false);
|
|
735
|
+
menu.toggle();
|
|
736
|
+
expect(menu.isOpen()).toBe(true);
|
|
737
|
+
menu.close();
|
|
738
|
+
expect(menu.isOpen()).toBe(false);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("reads the server-seeded radio value and sets a new one without emitting", () => {
|
|
742
|
+
const f = mount();
|
|
743
|
+
boot(f);
|
|
744
|
+
const menu = handleOf(f);
|
|
745
|
+
const changes = capture(f.root, "pindoba:menu:valuechange");
|
|
746
|
+
|
|
747
|
+
expect(menu.getRadioValue("color")).toBe("red");
|
|
748
|
+
menu.setRadioValue("color", "blue");
|
|
749
|
+
expect(menu.getRadioValue("color")).toBe("blue");
|
|
750
|
+
expect(changes).toEqual([]);
|
|
751
|
+
|
|
752
|
+
const painted = f.rootHandle!.setItems.mock.lastCall![0];
|
|
753
|
+
expect(painted[1].items[1]).toMatchObject({ id: "blue", checked: true });
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it("retries the repaint until every level's ListBox has booted", () => {
|
|
757
|
+
const f = mount({ withListboxHandles: false });
|
|
758
|
+
boot(f);
|
|
759
|
+
handleOf(f).setRadioValue("color", "blue");
|
|
760
|
+
|
|
761
|
+
const handle = { setItems: vi.fn() };
|
|
762
|
+
f.rootUl.listbox = handle;
|
|
763
|
+
f.subUl.listbox = { setItems: vi.fn() };
|
|
764
|
+
drainRaf();
|
|
765
|
+
|
|
766
|
+
expect(handle.setItems).toHaveBeenCalled();
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("gives up retrying after a bounded number of frames", () => {
|
|
770
|
+
const f = mount({ withListboxHandles: false });
|
|
771
|
+
boot(f);
|
|
772
|
+
handleOf(f).setRadioValue("color", "blue");
|
|
773
|
+
drainRaf();
|
|
774
|
+
expect(rafQueue).toHaveLength(0);
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
describe("bootMenu — teardown", () => {
|
|
779
|
+
it("destroys every popover, drops the handle and allows a re-boot", () => {
|
|
780
|
+
const f = mount();
|
|
781
|
+
const result = boot(f)!;
|
|
782
|
+
result.destroy();
|
|
783
|
+
|
|
784
|
+
for (const popover of createdPopovers) {
|
|
785
|
+
expect(popover.destroy).toHaveBeenCalled();
|
|
786
|
+
}
|
|
787
|
+
expect((f.root as MenuRootElement).menu).toBeUndefined();
|
|
788
|
+
expect(f.trigger.hasAttribute("data-menu-initialized")).toBe(false);
|
|
789
|
+
expect(bootMenu(f.trigger)).not.toBeNull();
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("stops responding to level events", () => {
|
|
793
|
+
const f = mount();
|
|
794
|
+
const result = boot(f)!;
|
|
795
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
796
|
+
result.destroy();
|
|
797
|
+
|
|
798
|
+
const actions = capture(f.root, "pindoba:menu:action");
|
|
799
|
+
action(f.rootUl, "save");
|
|
800
|
+
expect(actions).toEqual([]);
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
describe("bootAllMenus", () => {
|
|
805
|
+
it("boots every astro-owned trigger and skips foreign ones", () => {
|
|
806
|
+
const f = mount();
|
|
807
|
+
const foreign = document.createElement("button");
|
|
808
|
+
foreign.setAttribute("data-menu-trigger", "");
|
|
809
|
+
document.body.appendChild(foreign);
|
|
810
|
+
|
|
811
|
+
const results = bootAllMenus();
|
|
812
|
+
for (const r of results) teardowns.push(r.destroy);
|
|
813
|
+
|
|
814
|
+
expect(results).toHaveLength(1);
|
|
815
|
+
expect(f.trigger.getAttribute("data-menu-initialized")).toBe("true");
|
|
816
|
+
expect(foreign.hasAttribute("data-menu-initialized")).toBe(false);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it("is safe to run twice (astro:page-load re-fires on the initial load)", () => {
|
|
820
|
+
mount();
|
|
821
|
+
const first = bootAllMenus();
|
|
822
|
+
for (const r of first) teardowns.push(r.destroy);
|
|
823
|
+
expect(bootAllMenus()).toEqual([]);
|
|
824
|
+
});
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
describe("bootMenu — safe-triangle deferral", () => {
|
|
828
|
+
/** Give the submenu dialog a real box so the aim maths has something to hit. */
|
|
829
|
+
function sizeSubmenu(f: Fixture) {
|
|
830
|
+
f.subDialog.getBoundingClientRect = () =>
|
|
831
|
+
({
|
|
832
|
+
left: 250,
|
|
833
|
+
top: 100,
|
|
834
|
+
right: 400,
|
|
835
|
+
bottom: 300,
|
|
836
|
+
width: 150,
|
|
837
|
+
height: 200,
|
|
838
|
+
x: 250,
|
|
839
|
+
y: 100,
|
|
840
|
+
toJSON: () => ({}),
|
|
841
|
+
}) as DOMRect;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
it("keeps the submenu open while the pointer travels toward it", () => {
|
|
845
|
+
// The whole point of the safe triangle: moving diagonally across a sibling
|
|
846
|
+
// row on the way to the open panel must not slam it shut.
|
|
847
|
+
const f = mount();
|
|
848
|
+
boot(f);
|
|
849
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
850
|
+
action(f.rootUl, "more");
|
|
851
|
+
sizeSubmenu(f);
|
|
852
|
+
|
|
853
|
+
const more = f.rootUl.querySelector<HTMLElement>('[data-key="more"]')!;
|
|
854
|
+
const save = f.rootUl.querySelector<HTMLElement>('[data-key="save"]')!;
|
|
855
|
+
|
|
856
|
+
// Two samples heading right, toward the panel at x >= 200.
|
|
857
|
+
pointerAt(f.rootDialog, more, 120, 150);
|
|
858
|
+
// Right+down diagonal toward the panel face, crossing a sibling row.
|
|
859
|
+
pointerAt(f.rootDialog, save, 200, 200);
|
|
860
|
+
|
|
861
|
+
expect(popoverFor(SUB_POPOVER).$open.get()).toBe(true);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it("closes the submenu when the pointer is not heading for it", () => {
|
|
865
|
+
const f = mount();
|
|
866
|
+
boot(f);
|
|
867
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
868
|
+
action(f.rootUl, "more");
|
|
869
|
+
sizeSubmenu(f);
|
|
870
|
+
|
|
871
|
+
const more = f.rootUl.querySelector<HTMLElement>('[data-key="more"]')!;
|
|
872
|
+
const save = f.rootUl.querySelector<HTMLElement>('[data-key="save"]')!;
|
|
873
|
+
|
|
874
|
+
// Straight down onto a sibling: no horizontal progress toward the panel,
|
|
875
|
+
// so the deferral must not engage.
|
|
876
|
+
pointerAt(f.rootDialog, more, 120, 150);
|
|
877
|
+
pointerAt(f.rootDialog, save, 120, 220);
|
|
878
|
+
|
|
879
|
+
expect(popoverFor(SUB_POPOVER).$open.get()).toBe(false);
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
describe("bootMenu — first-row focus retry", () => {
|
|
884
|
+
it("retries across frames until a row can take focus", () => {
|
|
885
|
+
// The rows are `tabindex -1` and the dialog may not be focusable yet on the
|
|
886
|
+
// first frame, so the boot retries rather than giving up silently.
|
|
887
|
+
const f = mount();
|
|
888
|
+
boot(f);
|
|
889
|
+
|
|
890
|
+
const first = f.rootUl.querySelector<HTMLElement>('[data-key="save"]')!;
|
|
891
|
+
let allowed = false;
|
|
892
|
+
const realFocus = first.focus.bind(first);
|
|
893
|
+
first.focus = (opts?: FocusOptions) => {
|
|
894
|
+
if (allowed) realFocus(opts);
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
898
|
+
expect(document.activeElement).not.toBe(first);
|
|
899
|
+
|
|
900
|
+
allowed = true;
|
|
901
|
+
drainRaf();
|
|
902
|
+
expect(document.activeElement).toBe(first);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it("gives up after a bounded number of frames", () => {
|
|
906
|
+
const f = mount();
|
|
907
|
+
boot(f);
|
|
908
|
+
const first = f.rootUl.querySelector<HTMLElement>('[data-key="save"]')!;
|
|
909
|
+
first.focus = () => {};
|
|
910
|
+
|
|
911
|
+
popoverFor(ROOT_POPOVER).$open.set(true);
|
|
912
|
+
drainRaf();
|
|
913
|
+
|
|
914
|
+
// Bounded: the queue drains rather than rescheduling forever.
|
|
915
|
+
expect(rafQueue).toHaveLength(0);
|
|
916
|
+
});
|
|
917
|
+
});
|