srcdev-nuxt-components 9.1.51 → 9.1.52
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/.claude/settings.json +8 -27
- package/.claude/settings.local.json +24 -52
- package/.claude/skills/components/action-menu.md +141 -0
- package/.claude/skills/components/stepper-list.md +52 -18
- package/.claude/skills/css-nesting-conventions.md +121 -0
- package/.claude/skills/index.md +3 -0
- package/.claude/skills/pull-request-description.md +48 -0
- package/.claude/skills/testing-add-unit-test.md +40 -2
- package/README.md +26 -5
- package/app/components/02.molecules/action-menu/ActionMenu.vue +218 -0
- package/app/components/02.molecules/action-menu/ActionMenuItemCore.vue +123 -0
- package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +125 -0
- package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +326 -0
- package/app/components/02.molecules/action-menu/tests/ActionMenu.spec.ts +458 -0
- package/app/components/02.molecules/action-menu/tests/ActionMenuItemCore.spec.ts +199 -0
- package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenu.spec.ts.snap +28 -0
- package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenuItemCore.spec.ts.snap +27 -0
- package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +5 -1
- package/app/components/02.molecules/stepper-list/CONSUMER-STYLING.md +138 -0
- package/app/components/02.molecules/stepper-list/StepperList.vue +50 -24
- package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +5 -1
- package/app/components/05.forms/input-button/InputButtonCore.vue +1 -1
- package/app/components/carousels/tests/CarouselFlip.spec.ts +1 -0
- package/app/components/responsive-header/NavigationItems.vue +302 -81
- package/app/components/responsive-header/ResponsiveHeader.vue +360 -56
- package/app/layouts/default.vue +43 -352
- package/app/layouts/site-navigation-demo.vue +36 -34
- package/app/pages/page-hero-highlights.vue +3 -3
- package/app/pages/ui/scroll-reveal-image.vue +3 -3
- package/app/pages/ui/simple-grid.vue +9 -20
- package/modules/colour-scheme.ts +1 -1
- package/nuxt.config.ts +11 -2
- package/package.json +17 -16
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import { nextTick } from "vue";
|
|
4
|
+
import ActionMenu from "../ActionMenu.vue";
|
|
5
|
+
|
|
6
|
+
// --- Types ---
|
|
7
|
+
interface ActionMenuInstance {
|
|
8
|
+
menuId: string;
|
|
9
|
+
anchorName: string;
|
|
10
|
+
closeMenu: () => void;
|
|
11
|
+
handleToggle: (event: Event) => void;
|
|
12
|
+
handleKeydown: (event: KeyboardEvent) => void;
|
|
13
|
+
getMenuItems: () => HTMLElement[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// --- Helpers ---
|
|
17
|
+
const createWrapper = async (props: Record<string, unknown> = {}, slots: Record<string, string> = {}) => {
|
|
18
|
+
return mountSuspended(ActionMenu, { props, slots });
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("ActionMenu", () => {
|
|
22
|
+
let wrapper: Awaited<ReturnType<typeof createWrapper>>;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({} as never);
|
|
26
|
+
|
|
27
|
+
// Popover API is not implemented in jsdom — define stubs so we can test
|
|
28
|
+
Object.defineProperty(HTMLElement.prototype, "hidePopover", {
|
|
29
|
+
value: vi.fn(),
|
|
30
|
+
writable: true,
|
|
31
|
+
configurable: true,
|
|
32
|
+
});
|
|
33
|
+
Object.defineProperty(HTMLElement.prototype, "showPopover", {
|
|
34
|
+
value: vi.fn(),
|
|
35
|
+
writable: true,
|
|
36
|
+
configurable: true,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
wrapper?.unmount();
|
|
42
|
+
vi.restoreAllMocks();
|
|
43
|
+
// Object.defineProperty stubs are not touched by vi.restoreAllMocks() — remove explicitly
|
|
44
|
+
delete (HTMLElement.prototype as unknown as Record<string, unknown>)["hidePopover"];
|
|
45
|
+
delete (HTMLElement.prototype as unknown as Record<string, unknown>)["showPopover"];
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// -------------------------
|
|
49
|
+
// Snapshots
|
|
50
|
+
// -------------------------
|
|
51
|
+
describe("Snapshots", () => {
|
|
52
|
+
it("default (no items)", async () => {
|
|
53
|
+
wrapper = await createWrapper();
|
|
54
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("with slot content", async () => {
|
|
58
|
+
wrapper = await createWrapper(
|
|
59
|
+
{},
|
|
60
|
+
{
|
|
61
|
+
"item-0": '<button role="menuitem">Edit</button>',
|
|
62
|
+
"item-1": '<button role="menuitem">Delete</button>',
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("with custom label", async () => {
|
|
69
|
+
wrapper = await createWrapper({ label: "Record actions" });
|
|
70
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// -------------------------
|
|
75
|
+
// Trigger button
|
|
76
|
+
// -------------------------
|
|
77
|
+
describe("Trigger button", () => {
|
|
78
|
+
it("renders a trigger button", async () => {
|
|
79
|
+
wrapper = await createWrapper();
|
|
80
|
+
expect(wrapper.find(".action-menu-trigger").exists()).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("trigger button has type='button'", async () => {
|
|
84
|
+
wrapper = await createWrapper();
|
|
85
|
+
expect(wrapper.find(".action-menu-trigger").attributes("type")).toBe("button");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("trigger button has aria-haspopup='menu'", async () => {
|
|
89
|
+
wrapper = await createWrapper();
|
|
90
|
+
expect(wrapper.find(".action-menu-trigger").attributes("aria-haspopup")).toBe("menu");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("trigger button has default aria-label", async () => {
|
|
94
|
+
wrapper = await createWrapper();
|
|
95
|
+
expect(wrapper.find(".action-menu-trigger").attributes("aria-label")).toBe("Open actions menu");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("trigger button uses custom label prop for aria-label", async () => {
|
|
99
|
+
wrapper = await createWrapper({ label: "User actions" });
|
|
100
|
+
expect(wrapper.find(".action-menu-trigger").attributes("aria-label")).toBe("User actions");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("trigger button has popovertarget attribute linking to the popover", async () => {
|
|
104
|
+
wrapper = await createWrapper();
|
|
105
|
+
const vm = wrapper.vm as unknown as ActionMenuInstance;
|
|
106
|
+
const triggerTarget = wrapper.find(".action-menu-trigger").attributes("popovertarget");
|
|
107
|
+
expect(triggerTarget).toBe(vm.menuId);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("trigger button has popovertargetaction='toggle'", async () => {
|
|
111
|
+
wrapper = await createWrapper();
|
|
112
|
+
expect(wrapper.find(".action-menu-trigger").attributes("popovertargetaction")).toBe("toggle");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// -------------------------
|
|
117
|
+
// Popover
|
|
118
|
+
// -------------------------
|
|
119
|
+
describe("Popover element", () => {
|
|
120
|
+
it("renders the popover element", async () => {
|
|
121
|
+
wrapper = await createWrapper();
|
|
122
|
+
expect(wrapper.find(".action-menu-popover").exists()).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("popover id matches the trigger's popovertarget", async () => {
|
|
126
|
+
wrapper = await createWrapper();
|
|
127
|
+
const vm = wrapper.vm as unknown as ActionMenuInstance;
|
|
128
|
+
expect(wrapper.find(".action-menu-popover").attributes("id")).toBe(vm.menuId);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("popover has the popover attribute", async () => {
|
|
132
|
+
wrapper = await createWrapper();
|
|
133
|
+
// jsdom renders boolean attributes as empty string
|
|
134
|
+
expect(wrapper.find(".action-menu-popover").attributes("popover")).toBeDefined();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("menu list has role='menu'", async () => {
|
|
138
|
+
wrapper = await createWrapper();
|
|
139
|
+
expect(wrapper.find(".action-menu-list").attributes("role")).toBe("menu");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("menu list aria-label matches label prop", async () => {
|
|
143
|
+
wrapper = await createWrapper({ label: "Row options" });
|
|
144
|
+
expect(wrapper.find(".action-menu-list").attributes("aria-label")).toBe("Row options");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// -------------------------
|
|
149
|
+
// Dynamic item slots
|
|
150
|
+
// -------------------------
|
|
151
|
+
describe("Dynamic item slots", () => {
|
|
152
|
+
it("renders zero list items when no slots are provided", async () => {
|
|
153
|
+
wrapper = await createWrapper();
|
|
154
|
+
expect(wrapper.findAll(".action-menu-list-item")).toHaveLength(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("renders the correct number of list items from provided slots", async () => {
|
|
158
|
+
wrapper = await createWrapper(
|
|
159
|
+
{},
|
|
160
|
+
{
|
|
161
|
+
"item-0": '<button role="menuitem">A</button>',
|
|
162
|
+
"item-1": '<button role="menuitem">B</button>',
|
|
163
|
+
"item-2": '<button role="menuitem">C</button>',
|
|
164
|
+
}
|
|
165
|
+
);
|
|
166
|
+
expect(wrapper.findAll(".action-menu-list-item")).toHaveLength(3);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("each list item has role='none'", async () => {
|
|
170
|
+
wrapper = await createWrapper(
|
|
171
|
+
{},
|
|
172
|
+
{
|
|
173
|
+
"item-0": '<button role="menuitem">A</button>',
|
|
174
|
+
"item-1": '<button role="menuitem">B</button>',
|
|
175
|
+
}
|
|
176
|
+
);
|
|
177
|
+
const items = wrapper.findAll(".action-menu-list-item");
|
|
178
|
+
items.forEach((item) => {
|
|
179
|
+
expect(item.attributes("role")).toBe("none");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("renders slot content inside the correct list item", async () => {
|
|
184
|
+
wrapper = await createWrapper(
|
|
185
|
+
{},
|
|
186
|
+
{
|
|
187
|
+
"item-0": '<span data-testid="item-zero">First</span>',
|
|
188
|
+
"item-1": '<span data-testid="item-one">Second</span>',
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
const items = wrapper.findAll(".action-menu-list-item");
|
|
192
|
+
expect(items[0]!.find('[data-testid="item-zero"]').exists()).toBe(true);
|
|
193
|
+
expect(items[1]!.find('[data-testid="item-one"]').exists()).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// -------------------------
|
|
198
|
+
// Anchor name
|
|
199
|
+
// -------------------------
|
|
200
|
+
describe("Anchor name CSS variable", () => {
|
|
201
|
+
it("sets a unique --_anchor-name on the root element style", async () => {
|
|
202
|
+
wrapper = await createWrapper();
|
|
203
|
+
const style = wrapper.attributes("style") ?? "";
|
|
204
|
+
expect(style).toContain("--_anchor-name: --action-menu-anchor-");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("anchor name starts with -- (valid dashed-ident for CSS anchor positioning)", async () => {
|
|
208
|
+
wrapper = await createWrapper();
|
|
209
|
+
const vm = wrapper.vm as unknown as ActionMenuInstance;
|
|
210
|
+
expect(vm.anchorName).toMatch(/^--action-menu-anchor-/);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// -------------------------
|
|
215
|
+
// closeMenu
|
|
216
|
+
// -------------------------
|
|
217
|
+
describe("closeMenu", () => {
|
|
218
|
+
it("calls hidePopover on the popover element when a list item is clicked", async () => {
|
|
219
|
+
wrapper = await createWrapper(
|
|
220
|
+
{},
|
|
221
|
+
{ "item-0": '<button role="menuitem">Action</button>' }
|
|
222
|
+
);
|
|
223
|
+
const listItem = wrapper.find(".action-menu-list-item");
|
|
224
|
+
const popoverEl = wrapper.find(".action-menu-popover").element as HTMLElement;
|
|
225
|
+
const hidePopoverSpy = vi.spyOn(popoverEl, "hidePopover");
|
|
226
|
+
|
|
227
|
+
await listItem.trigger("click");
|
|
228
|
+
expect(hidePopoverSpy).toHaveBeenCalledOnce();
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// -------------------------
|
|
233
|
+
// handleToggle — focus management
|
|
234
|
+
// -------------------------
|
|
235
|
+
describe("handleToggle focus management", () => {
|
|
236
|
+
it("focuses the first menuitem when the popover opens", async () => {
|
|
237
|
+
wrapper = await createWrapper(
|
|
238
|
+
{},
|
|
239
|
+
{ "item-0": '<button role="menuitem" data-testid="first-item">Action</button>' }
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const firstItem = wrapper.find('[data-testid="first-item"]').element as HTMLElement;
|
|
243
|
+
const focusSpy = vi.spyOn(firstItem, "focus");
|
|
244
|
+
|
|
245
|
+
const popoverEl = wrapper.find(".action-menu-popover").element;
|
|
246
|
+
popoverEl.dispatchEvent(
|
|
247
|
+
new Event("toggle", { bubbles: false })
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Manually call handleToggle with newState='open'
|
|
251
|
+
const vm = wrapper.vm as unknown as ActionMenuInstance;
|
|
252
|
+
vm.handleToggle(Object.assign(new Event("toggle"), { newState: "open" }));
|
|
253
|
+
|
|
254
|
+
await nextTick();
|
|
255
|
+
expect(focusSpy).toHaveBeenCalledOnce();
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// -------------------------
|
|
260
|
+
// Keyboard navigation
|
|
261
|
+
// -------------------------
|
|
262
|
+
describe("Keyboard navigation (handleKeydown)", () => {
|
|
263
|
+
/**
|
|
264
|
+
* Mount with three real button menuitems and return focused test helpers.
|
|
265
|
+
*
|
|
266
|
+
* Because the popover is hidden (display:none) in jsdom, calling .focus()
|
|
267
|
+
* on children does not update document.activeElement. Instead we:
|
|
268
|
+
* • spy on each item's .focus() to assert the right element is targeted
|
|
269
|
+
* • mock document.activeElement to simulate the current focused position
|
|
270
|
+
*/
|
|
271
|
+
const mountWithItems = async () => {
|
|
272
|
+
const w = await mountSuspended(ActionMenu, {
|
|
273
|
+
props: { label: "Actions" },
|
|
274
|
+
slots: {
|
|
275
|
+
"item-0": '<button role="menuitem" data-idx="0">First</button>',
|
|
276
|
+
"item-1": '<button role="menuitem" data-idx="1">Second</button>',
|
|
277
|
+
"item-2": '<button role="menuitem" data-idx="2">Third</button>',
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
const vm = w.vm as unknown as ActionMenuInstance;
|
|
281
|
+
const items = vm.getMenuItems();
|
|
282
|
+
const focusSpies = items.map((el) => vi.spyOn(el, "focus"));
|
|
283
|
+
|
|
284
|
+
/** Fire a keydown event directly through the handler. */
|
|
285
|
+
const fire = (key: string) =>
|
|
286
|
+
vm.handleKeydown(
|
|
287
|
+
Object.assign(new KeyboardEvent("keydown", { key, bubbles: true }), {
|
|
288
|
+
preventDefault: vi.fn(),
|
|
289
|
+
})
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Mock document.activeElement so the handler calculates currentIndex
|
|
294
|
+
* from the supplied item index rather than the real (unusable) DOM state.
|
|
295
|
+
*/
|
|
296
|
+
const setActiveItem = (index: number) =>
|
|
297
|
+
vi.spyOn(document, "activeElement", "get").mockReturnValue(items[index]!);
|
|
298
|
+
|
|
299
|
+
return { w, vm, items, focusSpies, fire, setActiveItem };
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
it("ArrowDown moves focus to the next item", async () => {
|
|
303
|
+
const { w, focusSpies, fire, setActiveItem } = await mountWithItems();
|
|
304
|
+
const getter = setActiveItem(0);
|
|
305
|
+
fire("ArrowDown");
|
|
306
|
+
expect(focusSpies[1]).toHaveBeenCalledOnce();
|
|
307
|
+
getter.mockRestore();
|
|
308
|
+
w.unmount();
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("ArrowDown wraps from last to first item", async () => {
|
|
312
|
+
const { w, focusSpies, fire, setActiveItem } = await mountWithItems();
|
|
313
|
+
const getter = setActiveItem(2);
|
|
314
|
+
fire("ArrowDown");
|
|
315
|
+
expect(focusSpies[0]).toHaveBeenCalledOnce();
|
|
316
|
+
getter.mockRestore();
|
|
317
|
+
w.unmount();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("ArrowUp moves focus to the previous item", async () => {
|
|
321
|
+
const { w, focusSpies, fire, setActiveItem } = await mountWithItems();
|
|
322
|
+
const getter = setActiveItem(2);
|
|
323
|
+
fire("ArrowUp");
|
|
324
|
+
expect(focusSpies[1]).toHaveBeenCalledOnce();
|
|
325
|
+
getter.mockRestore();
|
|
326
|
+
w.unmount();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("ArrowUp wraps from first to last item", async () => {
|
|
330
|
+
const { w, focusSpies, fire, setActiveItem } = await mountWithItems();
|
|
331
|
+
const getter = setActiveItem(0);
|
|
332
|
+
fire("ArrowUp");
|
|
333
|
+
expect(focusSpies[2]).toHaveBeenCalledOnce();
|
|
334
|
+
getter.mockRestore();
|
|
335
|
+
w.unmount();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("ArrowDown focuses the first item when focus is outside the menu (currentIndex = -1)", async () => {
|
|
339
|
+
const { w, focusSpies, fire } = await mountWithItems();
|
|
340
|
+
// No setActiveItem call — document.activeElement is <body>, indexOf returns -1
|
|
341
|
+
fire("ArrowDown");
|
|
342
|
+
expect(focusSpies[0]).toHaveBeenCalledOnce();
|
|
343
|
+
w.unmount();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("ArrowUp focuses the last item when focus is outside the menu (currentIndex = -1)", async () => {
|
|
347
|
+
const { w, focusSpies, fire } = await mountWithItems();
|
|
348
|
+
// No setActiveItem call — document.activeElement is <body>, indexOf returns -1
|
|
349
|
+
fire("ArrowUp");
|
|
350
|
+
expect(focusSpies[2]).toHaveBeenCalledOnce();
|
|
351
|
+
w.unmount();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("Home moves focus to the first item regardless of starting position", async () => {
|
|
355
|
+
const { w, focusSpies, fire, setActiveItem } = await mountWithItems();
|
|
356
|
+
const getter = setActiveItem(2);
|
|
357
|
+
fire("Home");
|
|
358
|
+
expect(focusSpies[0]).toHaveBeenCalledOnce();
|
|
359
|
+
getter.mockRestore();
|
|
360
|
+
w.unmount();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("End moves focus to the last item regardless of starting position", async () => {
|
|
364
|
+
const { w, focusSpies, fire, setActiveItem } = await mountWithItems();
|
|
365
|
+
const getter = setActiveItem(0);
|
|
366
|
+
fire("End");
|
|
367
|
+
expect(focusSpies[2]).toHaveBeenCalledOnce();
|
|
368
|
+
getter.mockRestore();
|
|
369
|
+
w.unmount();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("Tab calls hidePopover without returning focus to the trigger", async () => {
|
|
373
|
+
const { w, fire } = await mountWithItems();
|
|
374
|
+
const popoverEl = w.find(".action-menu-popover").element as HTMLElement;
|
|
375
|
+
const hidePopoverSpy = vi.spyOn(popoverEl, "hidePopover");
|
|
376
|
+
const triggerEl = w.find(".action-menu-trigger").element as HTMLElement;
|
|
377
|
+
const focusSpy = vi.spyOn(triggerEl, "focus");
|
|
378
|
+
|
|
379
|
+
fire("Tab");
|
|
380
|
+
|
|
381
|
+
expect(hidePopoverSpy).toHaveBeenCalledOnce();
|
|
382
|
+
expect(focusSpy).not.toHaveBeenCalled();
|
|
383
|
+
w.unmount();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("navigation keys call preventDefault; Tab does not", async () => {
|
|
387
|
+
const { w, vm, setActiveItem } = await mountWithItems();
|
|
388
|
+
const getter = setActiveItem(0);
|
|
389
|
+
const preventDefaultSpy = vi.fn();
|
|
390
|
+
const makeEvent = (key: string) =>
|
|
391
|
+
Object.assign(new KeyboardEvent("keydown", { key, bubbles: true }), {
|
|
392
|
+
preventDefault: preventDefaultSpy,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
vm.handleKeydown(makeEvent("ArrowDown"));
|
|
396
|
+
vm.handleKeydown(makeEvent("ArrowUp"));
|
|
397
|
+
vm.handleKeydown(makeEvent("Home"));
|
|
398
|
+
vm.handleKeydown(makeEvent("End"));
|
|
399
|
+
vm.handleKeydown(makeEvent("Tab"));
|
|
400
|
+
|
|
401
|
+
// Arrow/Home/End prevent default scroll; Tab does not
|
|
402
|
+
expect(preventDefaultSpy).toHaveBeenCalledTimes(4);
|
|
403
|
+
getter.mockRestore();
|
|
404
|
+
w.unmount();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("does nothing when there are no menuitems", async () => {
|
|
408
|
+
const w = await mountSuspended(ActionMenu, { props: {} });
|
|
409
|
+
const vm = w.vm as unknown as ActionMenuInstance;
|
|
410
|
+
expect(() =>
|
|
411
|
+
vm.handleKeydown(new KeyboardEvent("keydown", { key: "ArrowDown" }))
|
|
412
|
+
).not.toThrow();
|
|
413
|
+
w.unmount();
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// -------------------------
|
|
418
|
+
// closeMenu — focus return
|
|
419
|
+
// -------------------------
|
|
420
|
+
describe("closeMenu — focus return", () => {
|
|
421
|
+
it("returns focus to the trigger button after closing", async () => {
|
|
422
|
+
wrapper = await mountSuspended(ActionMenu, {
|
|
423
|
+
props: {},
|
|
424
|
+
slots: { "item-0": '<button role="menuitem">Action</button>' },
|
|
425
|
+
});
|
|
426
|
+
const triggerEl = wrapper.find(".action-menu-trigger").element as HTMLElement;
|
|
427
|
+
const focusSpy = vi.spyOn(triggerEl, "focus");
|
|
428
|
+
const popoverEl = wrapper.find(".action-menu-popover").element as HTMLElement;
|
|
429
|
+
vi.spyOn(popoverEl, "hidePopover");
|
|
430
|
+
|
|
431
|
+
const vm = wrapper.vm as unknown as ActionMenuInstance;
|
|
432
|
+
vm.closeMenu();
|
|
433
|
+
|
|
434
|
+
expect(focusSpy).toHaveBeenCalledOnce();
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// -------------------------
|
|
439
|
+
// styleClassPassthrough
|
|
440
|
+
// -------------------------
|
|
441
|
+
describe("styleClassPassthrough", () => {
|
|
442
|
+
it("applies a string class to the root element", async () => {
|
|
443
|
+
wrapper = await createWrapper({ styleClassPassthrough: "custom-menu" });
|
|
444
|
+
expect(wrapper.classes()).toContain("custom-menu");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("applies an array of classes to the root element", async () => {
|
|
448
|
+
wrapper = await createWrapper({ styleClassPassthrough: ["menu-a", "menu-b"] });
|
|
449
|
+
expect(wrapper.classes()).toContain("menu-a");
|
|
450
|
+
expect(wrapper.classes()).toContain("menu-b");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("root element always has the 'action-menu' class", async () => {
|
|
454
|
+
wrapper = await createWrapper({ styleClassPassthrough: "extra-class" });
|
|
455
|
+
expect(wrapper.classes()).toContain("action-menu");
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import { nextTick } from "vue";
|
|
4
|
+
import ActionMenuItemCore from "../ActionMenuItemCore.vue";
|
|
5
|
+
|
|
6
|
+
// --- Helpers ---
|
|
7
|
+
const createWrapper = async (props: Record<string, unknown> = {}, slots: Record<string, string> = {}) => {
|
|
8
|
+
return mountSuspended(ActionMenuItemCore, {
|
|
9
|
+
props: { label: "Edit item", ...props },
|
|
10
|
+
slots,
|
|
11
|
+
});
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("ActionMenuItemCore", () => {
|
|
15
|
+
let wrapper: Awaited<ReturnType<typeof createWrapper>>;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({} as never);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
wrapper?.unmount();
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// -------------------------
|
|
27
|
+
// Snapshots
|
|
28
|
+
// -------------------------
|
|
29
|
+
describe("Snapshots", () => {
|
|
30
|
+
it("default button (no href)", async () => {
|
|
31
|
+
wrapper = await createWrapper();
|
|
32
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("with icon slot", async () => {
|
|
36
|
+
wrapper = await createWrapper({}, { icon: '<span data-testid="test-icon">⚡</span>' });
|
|
37
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("as external link", async () => {
|
|
41
|
+
wrapper = await createWrapper({ href: "https://example.com" });
|
|
42
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("as internal link", async () => {
|
|
46
|
+
wrapper = await createWrapper({ href: "/about" });
|
|
47
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("with styleClassPassthrough", async () => {
|
|
51
|
+
wrapper = await createWrapper({ styleClassPassthrough: ["custom-class", "another-class"] });
|
|
52
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// -------------------------
|
|
57
|
+
// Rendering — root element
|
|
58
|
+
// -------------------------
|
|
59
|
+
describe("Root element", () => {
|
|
60
|
+
it("renders as <button> when no href is provided", async () => {
|
|
61
|
+
wrapper = await createWrapper();
|
|
62
|
+
expect(wrapper.element.tagName).toBe("BUTTON");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("sets type='button' on the button element", async () => {
|
|
66
|
+
wrapper = await createWrapper();
|
|
67
|
+
expect(wrapper.attributes("type")).toBe("button");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("renders as <a> when an external href is provided", async () => {
|
|
71
|
+
wrapper = await createWrapper({ href: "https://example.com" });
|
|
72
|
+
expect(wrapper.element.tagName).toBe("A");
|
|
73
|
+
expect(wrapper.attributes("href")).toBe("https://example.com");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("renders as NuxtLink (resolves to <a>) when an internal href is provided", async () => {
|
|
77
|
+
wrapper = await createWrapper({ href: "/about" });
|
|
78
|
+
// NuxtLink resolves to <a> in test environment
|
|
79
|
+
expect(wrapper.element.tagName).toBe("A");
|
|
80
|
+
expect(wrapper.attributes("href")).toBe("/about");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does not set type on link elements", async () => {
|
|
84
|
+
wrapper = await createWrapper({ href: "https://example.com" });
|
|
85
|
+
expect(wrapper.attributes("type")).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does not set href on button elements", async () => {
|
|
89
|
+
wrapper = await createWrapper();
|
|
90
|
+
expect(wrapper.attributes("href")).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("has role='menuitem'", async () => {
|
|
94
|
+
wrapper = await createWrapper();
|
|
95
|
+
expect(wrapper.attributes("role")).toBe("menuitem");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("has class 'action-menu-item-core'", async () => {
|
|
99
|
+
wrapper = await createWrapper();
|
|
100
|
+
expect(wrapper.classes()).toContain("action-menu-item-core");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// -------------------------
|
|
105
|
+
// Label
|
|
106
|
+
// -------------------------
|
|
107
|
+
describe("Label", () => {
|
|
108
|
+
it("renders the label text", async () => {
|
|
109
|
+
wrapper = await createWrapper({ label: "Delete record" });
|
|
110
|
+
expect(wrapper.find(".action-menu-item-label").text()).toBe("Delete record");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// -------------------------
|
|
115
|
+
// Icon slot
|
|
116
|
+
// -------------------------
|
|
117
|
+
describe("Icon slot", () => {
|
|
118
|
+
it("renders the icon slot when provided", async () => {
|
|
119
|
+
wrapper = await createWrapper({}, { icon: '<span data-testid="test-icon">⚡</span>' });
|
|
120
|
+
const iconWrapper = wrapper.find(".action-menu-item-icon");
|
|
121
|
+
expect(iconWrapper.exists()).toBe(true);
|
|
122
|
+
expect(iconWrapper.find('[data-testid="test-icon"]').exists()).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("does not render icon wrapper when icon slot is absent", async () => {
|
|
126
|
+
wrapper = await createWrapper();
|
|
127
|
+
expect(wrapper.find(".action-menu-item-icon").exists()).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("icon wrapper has aria-hidden='true'", async () => {
|
|
131
|
+
wrapper = await createWrapper({}, { icon: '<span>⚡</span>' });
|
|
132
|
+
expect(wrapper.find(".action-menu-item-icon").attributes("aria-hidden")).toBe("true");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// -------------------------
|
|
137
|
+
// Arrow
|
|
138
|
+
// -------------------------
|
|
139
|
+
describe("Arrow", () => {
|
|
140
|
+
it("always renders the arrow element", async () => {
|
|
141
|
+
wrapper = await createWrapper();
|
|
142
|
+
expect(wrapper.find(".action-menu-item-arrow").exists()).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("arrow element has aria-hidden='true'", async () => {
|
|
146
|
+
wrapper = await createWrapper();
|
|
147
|
+
expect(wrapper.find(".action-menu-item-arrow").attributes("aria-hidden")).toBe("true");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// -------------------------
|
|
152
|
+
// Events
|
|
153
|
+
// -------------------------
|
|
154
|
+
describe("Events", () => {
|
|
155
|
+
it("emits 'click' when the button is clicked", async () => {
|
|
156
|
+
wrapper = await createWrapper();
|
|
157
|
+
await wrapper.trigger("click");
|
|
158
|
+
expect(wrapper.emitted("click")).toHaveLength(1);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("emits 'click' with the MouseEvent as payload", async () => {
|
|
162
|
+
wrapper = await createWrapper();
|
|
163
|
+
await wrapper.trigger("click");
|
|
164
|
+
const emitted = wrapper.emitted("click");
|
|
165
|
+
expect(emitted![0]![0]).toBeInstanceOf(MouseEvent);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("emits 'click' when rendered as a link", async () => {
|
|
169
|
+
wrapper = await createWrapper({ href: "https://example.com" });
|
|
170
|
+
await wrapper.trigger("click");
|
|
171
|
+
expect(wrapper.emitted("click")).toHaveLength(1);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// -------------------------
|
|
176
|
+
// styleClassPassthrough
|
|
177
|
+
// -------------------------
|
|
178
|
+
describe("styleClassPassthrough", () => {
|
|
179
|
+
it("applies a string class", async () => {
|
|
180
|
+
wrapper = await createWrapper({ styleClassPassthrough: "my-custom-class" });
|
|
181
|
+
expect(wrapper.classes()).toContain("my-custom-class");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("applies an array of classes", async () => {
|
|
185
|
+
wrapper = await createWrapper({ styleClassPassthrough: ["class-a", "class-b"] });
|
|
186
|
+
expect(wrapper.classes()).toContain("class-a");
|
|
187
|
+
expect(wrapper.classes()).toContain("class-b");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("updates classes when styleClassPassthrough prop changes", async () => {
|
|
191
|
+
wrapper = await createWrapper({ styleClassPassthrough: "initial-class" });
|
|
192
|
+
expect(wrapper.classes()).toContain("initial-class");
|
|
193
|
+
|
|
194
|
+
await wrapper.setProps({ styleClassPassthrough: "updated-class" });
|
|
195
|
+
await nextTick();
|
|
196
|
+
expect(wrapper.classes()).toContain("updated-class");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`ActionMenu > Snapshots > default (no items) 1`] = `
|
|
4
|
+
"<div class="action-menu" style="--_anchor-name: --action-menu-anchor-v-0-0;"><button popovertarget="action-menu-v-0-0" popovertargetaction="toggle" type="button" class="action-menu-trigger" aria-label="Open actions menu" aria-haspopup="menu"><span class="iconify i-lucide:ellipsis action-menu-trigger-icon" aria-hidden="true"></span></button>
|
|
5
|
+
<div id="action-menu-v-0-0" popover="" class="action-menu-popover">
|
|
6
|
+
<ul class="action-menu-list" role="menu" aria-label="Open actions menu"></ul>
|
|
7
|
+
</div>
|
|
8
|
+
</div>"
|
|
9
|
+
`;
|
|
10
|
+
|
|
11
|
+
exports[`ActionMenu > Snapshots > with custom label 1`] = `
|
|
12
|
+
"<div class="action-menu" style="--_anchor-name: --action-menu-anchor-v-0-0;"><button popovertarget="action-menu-v-0-0" popovertargetaction="toggle" type="button" class="action-menu-trigger" aria-label="Record actions" aria-haspopup="menu"><span class="iconify i-lucide:ellipsis action-menu-trigger-icon" aria-hidden="true"></span></button>
|
|
13
|
+
<div id="action-menu-v-0-0" popover="" class="action-menu-popover">
|
|
14
|
+
<ul class="action-menu-list" role="menu" aria-label="Record actions"></ul>
|
|
15
|
+
</div>
|
|
16
|
+
</div>"
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
exports[`ActionMenu > Snapshots > with slot content 1`] = `
|
|
20
|
+
"<div class="action-menu" style="--_anchor-name: --action-menu-anchor-v-0-0;"><button popovertarget="action-menu-v-0-0" popovertargetaction="toggle" type="button" class="action-menu-trigger" aria-label="Open actions menu" aria-haspopup="menu"><span class="iconify i-lucide:ellipsis action-menu-trigger-icon" aria-hidden="true"></span></button>
|
|
21
|
+
<div id="action-menu-v-0-0" popover="" class="action-menu-popover">
|
|
22
|
+
<ul class="action-menu-list" role="menu" aria-label="Open actions menu">
|
|
23
|
+
<li class="action-menu-list-item" role="none"><button role="menuitem">Edit</button></li>
|
|
24
|
+
<li class="action-menu-list-item" role="none"><button role="menuitem">Delete</button></li>
|
|
25
|
+
</ul>
|
|
26
|
+
</div>
|
|
27
|
+
</div>"
|
|
28
|
+
`;
|