srcdev-nuxt-components 9.5.5 → 9.5.7

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.
Files changed (98) hide show
  1. package/.claude/skills/component-compliance-checklist.md +6 -2
  2. package/.claude/skills/components/action-menu.md +21 -15
  3. package/.claude/skills/components/deep-expanding-menu.md +18 -6
  4. package/.claude/skills/components/display-tooltip-defined.md +2 -1
  5. package/.claude/skills/components/display-tooltip.md +10 -4
  6. package/.claude/skills/components/navigation-items.md +1 -0
  7. package/.claude/skills/components/opening-hours.md +12 -0
  8. package/.claude/skills/components/pop-over.md +11 -8
  9. package/.claude/skills/components/price-list.md +10 -1
  10. package/.claude/skills/components/responsive-header.md +25 -0
  11. package/.claude/skills/components/select-menu.md +18 -8
  12. package/.claude/skills/components/site-header.md +2 -0
  13. package/.claude/skills/components/ui-block-decorated.md +7 -0
  14. package/.claude/skills/composable-anchored-popover.md +108 -0
  15. package/.claude/skills/composable-tooltips-guide.md +3 -1
  16. package/.claude/skills/index.md +1 -0
  17. package/.claude/skills/storybook-add-story.md +14 -0
  18. package/.vscode/srcdev-component-responsive-header.code-snippets +3 -1
  19. package/.vscode/srcdev-component-site-header.code-snippets +3 -1
  20. package/app/assets/styles/setup/06.utility-classes/animations/_animation-scroller-x.css +5 -3
  21. package/app/assets/styles/setup/06.utility-classes/animations/_auto-rotate.css +5 -3
  22. package/app/assets/styles/setup/06.utility-classes/animations/_entry-exit-blur.css +5 -3
  23. package/app/assets/styles/setup/06.utility-classes/animations/_entry-slide-in.css +5 -3
  24. package/app/assets/styles/setup/06.utility-classes/animations/_entry-zoom-reveal.css +5 -3
  25. package/app/components/01.atoms/animations/entry/CONSUMER-STYLING.md +6 -2
  26. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +15 -3
  27. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +62 -3
  28. package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +36 -1
  29. package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +1 -1
  30. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +1 -1
  31. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +9 -3
  32. package/app/components/01.atoms/pop-over/PopOver.vue +70 -9
  33. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +53 -0
  34. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +1 -1
  35. package/app/components/01.atoms/ui-block-decorated/CONSUMER-STYLING.md +12 -4
  36. package/app/components/01.atoms/ui-block-decorated/UiBlockDecorated.vue +20 -16
  37. package/app/components/01.atoms/ui-block-decorated/stories/UiBlockDecorated.stories.ts +124 -96
  38. package/app/components/02.molecules/action-menu/ActionMenu.vue +41 -16
  39. package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +16 -3
  40. package/app/components/02.molecules/action-menu/tests/ActionMenu.spec.ts +28 -0
  41. package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenu.spec.ts.snap +3 -3
  42. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +2 -1
  43. package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +7 -4
  44. package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +20 -1
  45. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +1 -1
  46. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +18 -4
  47. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +87 -16
  48. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +83 -1
  49. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +2 -2
  50. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +1 -1
  51. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +1 -1
  52. package/app/components/02.molecules/opening-hours/CONSUMER-STYLING.md +11 -1
  53. package/app/components/02.molecules/opening-hours/OpeningHours.vue +71 -43
  54. package/app/components/02.molecules/opening-hours/stories/OpeningHours.stories.ts +138 -6
  55. package/app/components/02.molecules/opening-hours/tests/OpeningHours.spec.ts +53 -0
  56. package/app/components/02.molecules/opening-hours/tests/__snapshots__/OpeningHours.spec.ts.snap +1 -1
  57. package/app/components/02.molecules/opening-hours/tests/buildOpeningHoursSpecification.spec.ts +37 -1
  58. package/app/components/02.molecules/price-list/CONSUMER-STYLING.md +17 -5
  59. package/app/components/02.molecules/price-list/PriceList.vue +32 -9
  60. package/app/components/02.molecules/price-list/stories/PriceList.stories.ts +125 -29
  61. package/app/components/02.molecules/price-list/tests/PriceList.spec.ts +20 -0
  62. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +17 -5
  63. package/app/components/02.molecules/select-menu/SelectMenu.vue +51 -16
  64. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +36 -0
  65. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +9 -4
  66. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +14 -0
  67. package/app/components/03.organisms/responsive-header/NavigationItems.vue +23 -14
  68. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +58 -30
  69. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +149 -18
  70. package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +21 -0
  71. package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +66 -0
  72. package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +30 -0
  73. package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +1 -0
  74. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +35 -0
  75. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +23 -0
  76. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +1 -0
  77. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +71 -0
  78. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +386 -0
  79. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +11 -0
  80. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +79 -0
  81. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +17 -0
  82. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +64 -0
  83. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +29 -0
  84. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +32 -0
  85. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +10 -0
  86. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
  87. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +16 -0
  88. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +91 -6
  89. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +9 -9
  90. package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +1 -0
  91. package/app/components/03.organisms/site-header/SiteHeader.vue +8 -0
  92. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +1 -1
  93. package/app/composables/tests/useAnchoredPopover.spec.ts +231 -0
  94. package/app/composables/tests/useTooltips.spec.ts +69 -0
  95. package/app/composables/useAnchoredPopover.ts +183 -0
  96. package/app/composables/useTooltips.ts +37 -20
  97. package/app/utils/opening-hours.ts +23 -2
  98. package/package.json +1 -1
@@ -0,0 +1,64 @@
1
+
2
+ /// <reference path="nitro-layouts.d.ts" />
3
+ /// <reference path="app.config.d.ts" />
4
+ /// <reference path="runtime-config.d.ts" />
5
+ /// <reference path="../../../../../../../node_modules/@nuxt/nitro-server/dist/index.d.mts" />
6
+ /// <reference path="middleware.d.ts" />
7
+
8
+ import type { RuntimeConfig } from 'nuxt/schema'
9
+ import type { H3Event } from 'h3'
10
+ import type { LogObject } from 'consola'
11
+ import type { NuxtIslandContext, NuxtIslandResponse, NuxtRenderHTMLContext } from 'nuxt/app'
12
+
13
+ declare module 'nitropack' {
14
+ interface NitroRuntimeConfigApp {
15
+ buildAssetsDir: string
16
+ cdnURL: string
17
+ }
18
+ interface NitroRuntimeConfig extends RuntimeConfig {}
19
+ interface NitroRouteConfig {
20
+ ssr?: boolean
21
+ noScripts?: boolean
22
+ /** @deprecated Use `noScripts` instead */
23
+ experimentalNoScripts?: boolean
24
+ }
25
+ interface NitroRouteRules {
26
+ ssr?: boolean
27
+ noScripts?: boolean
28
+ /** @deprecated Use `noScripts` instead */
29
+ experimentalNoScripts?: boolean
30
+ appMiddleware?: Record<string, boolean>
31
+ appLayout?: string | false
32
+ }
33
+ interface NitroRuntimeHooks {
34
+ 'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>
35
+ 'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise<void>
36
+ 'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise<void>
37
+ }
38
+ }
39
+ declare module 'nitropack/types' {
40
+ interface NitroRuntimeConfigApp {
41
+ buildAssetsDir: string
42
+ cdnURL: string
43
+ }
44
+ interface NitroRuntimeConfig extends RuntimeConfig {}
45
+ interface NitroRouteConfig {
46
+ ssr?: boolean
47
+ noScripts?: boolean
48
+ /** @deprecated Use `noScripts` instead */
49
+ experimentalNoScripts?: boolean
50
+ }
51
+ interface NitroRouteRules {
52
+ ssr?: boolean
53
+ noScripts?: boolean
54
+ /** @deprecated Use `noScripts` instead */
55
+ experimentalNoScripts?: boolean
56
+ appMiddleware?: Record<string, boolean>
57
+ appLayout?: string | false
58
+ }
59
+ interface NitroRuntimeHooks {
60
+ 'dev:ssr-logs': (ctx: { logs: LogObject[], path: string }) => void | Promise<void>
61
+ 'render:html': (htmlContext: NuxtRenderHTMLContext, context: { event: H3Event }) => void | Promise<void>
62
+ 'render:island': (islandResponse: NuxtIslandResponse, context: { event: H3Event, islandContext: NuxtIslandContext }) => void | Promise<void>
63
+ }
64
+ }
@@ -0,0 +1,29 @@
1
+ // Generated by Nuxt'
2
+ import type { Plugin } from '#app'
3
+
4
+ type Decorate<T extends Record<string, any>> = { [K in keyof T as K extends string ? `$${K}` : never]: T[K] }
5
+
6
+ type InjectionType<A extends Plugin> = A extends {default: Plugin<infer T>} ? Decorate<T> : unknown
7
+
8
+ type NuxtAppInjections =
9
+ InjectionType<typeof import("../../../../../../../node_modules/nuxt/dist/app/plugins/revive-payload.client.js")> &
10
+ InjectionType<typeof import("../../../../../../../node_modules/nuxt/dist/head/runtime/plugins/unhead.js")> &
11
+ InjectionType<typeof import("../../../../../../../node_modules/nuxt/dist/app/plugins/router.js")> &
12
+ InjectionType<typeof import("../../../../../../../node_modules/nuxt/dist/app/plugins/navigation-repaint.client.js")> &
13
+ InjectionType<typeof import("../../../../../../../node_modules/nuxt/dist/app/plugins/check-outdated-build.client.js")> &
14
+ InjectionType<typeof import("../../../../../../../node_modules/nuxt/dist/app/plugins/revive-payload.server.js")> &
15
+ InjectionType<typeof import("../../../../../../../node_modules/nuxt/dist/app/plugins/chunk-reload.client.js")>
16
+
17
+ declare module '#app' {
18
+ interface NuxtApp extends NuxtAppInjections { }
19
+
20
+ interface NuxtAppLiterals {
21
+ pluginName: 'nuxt:revive-payload:client' | 'nuxt:head' | 'nuxt:router' | 'nuxt:revive-payload:server' | 'nuxt:chunk-reload' | 'nuxt:global-components'
22
+ }
23
+ }
24
+
25
+ declare module 'vue' {
26
+ interface ComponentCustomProperties extends NuxtAppInjections { }
27
+ }
28
+
29
+ export { }
@@ -0,0 +1,32 @@
1
+ import { RuntimeConfig as UserRuntimeConfig, PublicRuntimeConfig as UserPublicRuntimeConfig } from 'nuxt/schema'
2
+ interface SharedRuntimeConfig {
3
+ app: {
4
+ buildId: string,
5
+
6
+ baseURL: string,
7
+
8
+ buildAssetsDir: string,
9
+
10
+ cdnURL: string,
11
+ },
12
+
13
+ nitro: {
14
+ envPrefix: string,
15
+ },
16
+ }
17
+ interface SharedPublicRuntimeConfig {
18
+
19
+ }
20
+ declare module '@nuxt/schema' {
21
+ interface RuntimeConfig extends UserRuntimeConfig {}
22
+ interface PublicRuntimeConfig extends UserPublicRuntimeConfig {}
23
+ }
24
+ declare module 'nuxt/schema' {
25
+ interface RuntimeConfig extends SharedRuntimeConfig {}
26
+ interface PublicRuntimeConfig extends SharedPublicRuntimeConfig {}
27
+ }
28
+ declare module 'vue' {
29
+ interface ComponentCustomProperties {
30
+ $config: UserRuntimeConfig
31
+ }
32
+ }
@@ -0,0 +1,10 @@
1
+ // Generated by auto imports
2
+ export {}
3
+ declare global {
4
+ const useRuntimeConfig: (event?: import('h3').H3Event) => import('nuxt/schema').RuntimeConfig
5
+ const useAppConfig: () => import('nuxt/schema').AppConfig
6
+ const defineAppConfig: <C extends import('nuxt/schema').AppConfigInput>(config: C) => C
7
+ const createError: typeof import('h3')['createError']
8
+ const setResponseStatus: typeof import('h3')['setResponseStatus']
9
+
10
+ }
@@ -146,6 +146,22 @@ describe("NavigationItems", () => {
146
146
  expect(details.attributes("open")).toBe("");
147
147
  });
148
148
 
149
+ it("builds the submenu aria-label from submenuAriaLabel, falling back to name", async () => {
150
+ const wrapper = await mountSuspended(NavigationItems, {
151
+ props: {
152
+ submenuAriaLabel: "Untermenü {title}",
153
+ mainNavigationState: {
154
+ clonedNavLinks: { firstNav: [{ name: "Services", childLinks: [{ name: "A", path: "/a" }] }] },
155
+ navListVisibility: { firstNav: false },
156
+ hasSecondNav: false,
157
+ },
158
+ },
159
+ });
160
+ const text = wrapper.find(".overflow-navigation-details .overflow-navigation-text");
161
+ expect(text.text()).toBe("Services");
162
+ expect(text.attributes("aria-label")).toBe("Untermenü Services");
163
+ });
164
+
149
165
  it("renders correct HTML structure with default props", async () => {
150
166
  const wrapper = await mountSuspended(NavigationItems, {
151
167
  props: { mainNavigationState },
@@ -34,6 +34,19 @@ const triggerGeometryPass = async () => {
34
34
  }
35
35
  };
36
36
 
37
+ // Fake layout: the main nav ends at mainNavRight and each item is 100px wide, laid out in
38
+ // document order, so item n (0-based) ends at (n + 1) * 100.
39
+ const mockGeometry = (mainNavRight: number) =>
40
+ vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
41
+ let right = 0;
42
+ if (this.classList.contains("main-navigation")) right = mainNavRight;
43
+ if (this.classList.contains("main-navigation-item")) {
44
+ const items = Array.from(this.closest(".main-navigation")?.querySelectorAll(".main-navigation-item") ?? []);
45
+ right = (items.indexOf(this) + 1) * 100;
46
+ }
47
+ return { left: 0, top: 0, bottom: 0, x: 0, y: 0, height: 0, right, width: right, toJSON: () => ({}) } as DOMRect;
48
+ });
49
+
37
50
  const navLinks: ResponsiveHeaderProp = {
38
51
  firstNav: [
39
52
  { name: "Home", path: "/" },
@@ -296,18 +309,90 @@ describe("ResponsiveHeader", () => {
296
309
  expect(overflowNav.text()).toContain("UI Components");
297
310
  });
298
311
 
299
- it("completes the geometry pass and marks all items visually-hidden under jsdom's zero-size layout", async () => {
312
+ it("hides only the items that end past the main nav's right edge", async () => {
313
+ const geometry = mockGeometry(250);
300
314
  const wrapper = await mountSuspended(ResponsiveHeader, {
301
315
  props: { responsiveNavLinks: navLinks },
302
316
  });
303
317
  await triggerGeometryPass();
304
318
 
305
- // jsdom reports every rect as 0×0, so `rect.right + margin + gap < wrapperRect.right`
306
- // (0 + 0 + 12 < 0) is always false — every item correctly collapses into overflow.
307
319
  expect(wrapper.find(".navigation").classes()).toContain("geometry-ready");
308
- const items = wrapper.findAll(".main-navigation-item");
309
- expect(items.length).toBeGreaterThan(0);
310
- items.forEach((item) => expect(item.classes()).toContain("visually-hidden"));
320
+ const hidden = wrapper.findAll(".main-navigation-item").map((item) => item.classes().includes("visually-hidden"));
321
+ expect(hidden).toEqual([false, false, true, true]);
322
+ expect(wrapper.find(".overflow-details").classes()).not.toContain("visually-hidden");
323
+ geometry.mockRestore();
324
+ });
325
+
326
+ it("keeps an item that ends exactly at the main nav's right edge, so a flush-right last item isn't always hidden", async () => {
327
+ const geometry = mockGeometry(400);
328
+ const wrapper = await mountSuspended(ResponsiveHeader, {
329
+ props: { responsiveNavLinks: navLinks },
330
+ });
331
+ await triggerGeometryPass();
332
+
333
+ wrapper.findAll(".main-navigation-item").forEach((item) => expect(item.classes()).not.toContain("visually-hidden"));
334
+ expect(wrapper.find(".overflow-details").classes()).toContain("visually-hidden");
335
+ geometry.mockRestore();
336
+ });
337
+
338
+ it("gives the icon-only overflow button an accessible name", async () => {
339
+ const wrapper = await mountSuspended(ResponsiveHeader, {
340
+ props: { responsiveNavLinks: navLinks },
341
+ });
342
+ expect(wrapper.find(".overflow-details-summary").attributes("aria-label")).toBe("More navigation");
343
+ await wrapper.setProps({ overflowButtonLabel: "Menü" });
344
+ expect(wrapper.find(".overflow-details-summary").attributes("aria-label")).toBe("Menü");
345
+ });
346
+
347
+ it("builds the submenu aria-label from submenuAriaLabel", async () => {
348
+ const wrapper = await mountSuspended(ResponsiveHeader, {
349
+ props: { responsiveNavLinks: navLinks, submenuAriaLabel: "Untermenü {title}" },
350
+ });
351
+ expect(wrapper.find(".main-navigation-details-summary").attributes("aria-label")).toBe("Untermenü UI Components");
352
+ });
353
+
354
+ it("falls back to name for a dropdown with no childLinksTitle", async () => {
355
+ const wrapper = await mountSuspended(ResponsiveHeader, {
356
+ props: { responsiveNavLinks: { firstNav: [{ name: "Services", childLinks: [{ name: "A", path: "/a" }] }] } },
357
+ });
358
+ const summary = wrapper.find(".main-navigation-details-summary");
359
+ expect(summary.text()).toBe("Services");
360
+ expect(summary.attributes("aria-label")).toBe("Services submenu");
361
+ });
362
+
363
+ it("renders child links with duplicate names without key clashes", async () => {
364
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
365
+ const wrapper = await mountSuspended(ResponsiveHeader, {
366
+ props: {
367
+ responsiveNavLinks: {
368
+ firstNav: [
369
+ { name: "Dup", childLinksTitle: "Dup", childLinks: [{ name: "Same", path: "/a" }, { name: "Same", path: "/b" }] },
370
+ ],
371
+ },
372
+ },
373
+ });
374
+ expect(wrapper.findAll(".main-navigation-sub-nav-link")).toHaveLength(2);
375
+ expect(warn.mock.calls.some(([msg]) => String(msg).includes("Duplicate keys"))).toBe(false);
376
+ warn.mockRestore();
377
+ });
378
+
379
+ it("shows the overflow button for any group key once an item is hidden", async () => {
380
+ const geometry = mockGeometry(150);
381
+ const wrapper = await mountSuspended(ResponsiveHeader, {
382
+ props: { responsiveNavLinks: { main: [{ name: "Home", path: "/" }, { name: "About", path: "/about" }] } },
383
+ });
384
+ await triggerGeometryPass();
385
+ expect(wrapper.find(".overflow-details").classes()).not.toContain("visually-hidden");
386
+ geometry.mockRestore();
387
+ });
388
+
389
+ it("keeps the overflow button hidden when there are no nav items", async () => {
390
+ const wrapper = await mountSuspended(ResponsiveHeader, {
391
+ props: { responsiveNavLinks: {} },
392
+ });
393
+ await triggerGeometryPass();
394
+ expect(wrapper.find(".navigation").classes()).toContain("geometry-ready");
395
+ expect(wrapper.find(".overflow-details").classes()).toContain("visually-hidden");
311
396
  });
312
397
 
313
398
  it("renders correct HTML structure with default props", async () => {
@@ -4,11 +4,11 @@ exports[`ResponsiveHeader > renders correct HTML structure with default props 1`
4
4
  "<div class="navigation loaded" role="banner">
5
5
  <nav class="main-navigation is-animated" aria-label="Main navigation">
6
6
  <ul class="main-navigation-list">
7
- <li class="main-navigation-item visually-hidden is-active" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="0"><a href="/" class="main-navigation-link">
7
+ <li class="main-navigation-item visually-hidden is-active" data-group-key="firstNav" data-local-index="0"><a href="/" class="main-navigation-link">
8
8
  <!--v-if--> Home
9
9
  </a></li>
10
- <li class="main-navigation-item visually-hidden" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="1"><a href="/about" class="main-navigation-link has-icon"><span class="iconify i-mdi:home decorator-icon" aria-hidden="true"></span> About</a></li>
11
- <li class="main-navigation-item visually-hidden" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="2">
10
+ <li class="main-navigation-item visually-hidden" data-group-key="firstNav" data-local-index="1"><a href="/about" class="main-navigation-link has-icon"><span class="iconify i-mdi:home decorator-icon" aria-hidden="true"></span> About</a></li>
11
+ <li class="main-navigation-item visually-hidden" data-group-key="firstNav" data-local-index="2">
12
12
  <details class="main-navigation-details" name="navigation-group">
13
13
  <summary class="main-navigation-details-summary has-toggle-icon" aria-label="UI Components submenu"><span class="iconify i-mdi:chevron-down icon" aria-hidden="true"></span>
14
14
  <!--v-if--> UI Components
@@ -23,7 +23,7 @@ exports[`ResponsiveHeader > renders correct HTML structure with default props 1`
23
23
  </li>
24
24
  </ul>
25
25
  <ul class="main-navigation-list">
26
- <li class="main-navigation-item visually-hidden" style="--_main-navigation-item-width: 0px;" data-group-key="secondNav" data-local-index="0"><a href="/contact" class="main-navigation-link">
26
+ <li class="main-navigation-item visually-hidden" data-group-key="secondNav" data-local-index="0"><a href="/contact" class="main-navigation-link">
27
27
  <!--v-if--> Contact
28
28
  </a></li>
29
29
  </ul>
@@ -32,13 +32,13 @@ exports[`ResponsiveHeader > renders correct HTML structure with default props 1`
32
32
  </nav>
33
33
  <nav class="secondary-navigation" aria-label="Secondary navigation">
34
34
  <details class="overflow-details visually-hidden" name="overflow-group">
35
- <summary class="overflow-details-summary has-toggle-icon"><span class="iconify i-gravity-ui:ellipsis icon show" aria-hidden="true"></span><span class="iconify i-gravity-ui:bars icon" aria-hidden="true"></span></summary>
35
+ <summary class="overflow-details-summary has-toggle-icon" aria-label="More navigation"><span class="iconify i-gravity-ui:ellipsis icon show" aria-hidden="true"></span><span class="iconify i-gravity-ui:bars icon" aria-hidden="true"></span></summary>
36
36
  <div class="overflow-details-nav" role="menu">
37
37
  <div class="overflow-navigation-wrapper" role="menu" aria-label="Overflow navigation menu">
38
38
  <ul class="overflow-navigation-list visible" style="--_overflow-navigation-list-min-width: 0px;" role="none">
39
- <li class="overflow-navigation-item visible is-active" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="0" role="none"><a href="/" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">Home</span></a></li>
40
- <li class="overflow-navigation-item visible" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="1" role="none"><a href="/about" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">About</span></a></li>
41
- <li class="overflow-navigation-item visible" style="--_main-navigation-item-width: 0px;" data-group-key="firstNav" data-local-index="2" role="none">
39
+ <li class="overflow-navigation-item visible is-active" data-group-key="firstNav" data-local-index="0" role="none"><a href="/" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">Home</span></a></li>
40
+ <li class="overflow-navigation-item visible" data-group-key="firstNav" data-local-index="1" role="none"><a href="/about" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">About</span></a></li>
41
+ <li class="overflow-navigation-item visible" data-group-key="firstNav" data-local-index="2" role="none">
42
42
  <div class="expanding-panel-classic overflow-navigation-details">
43
43
  <details class="expanding-panel-classic-details" name="overflow-navigation-group">
44
44
  <summary id="id-overflow-navigation-group-trigger" class="expanding-panel-classic-summary" aria-controls="id-overflow-navigation-group-content" aria-expanded="false">
@@ -61,7 +61,7 @@ exports[`ResponsiveHeader > renders correct HTML structure with default props 1`
61
61
  </li>
62
62
  </ul>
63
63
  <ul class="overflow-navigation-list visible" style="--_overflow-navigation-list-min-width: 0px;" role="none">
64
- <li class="overflow-navigation-item visible" style="--_main-navigation-item-width: 0px;" data-group-key="secondNav" data-local-index="0" role="none"><a href="/contact" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">Contact</span></a></li>
64
+ <li class="overflow-navigation-item visible" data-group-key="secondNav" data-local-index="0" role="none"><a href="/contact" class="overflow-navigation-link" role="menuitem"><span class="overflow-navigation-text">Contact</span></a></li>
65
65
  </ul>
66
66
  <div aria-hidden="true" class="overflow-nav-indicator-hovered"></div>
67
67
  <div aria-hidden="true" class="overflow-nav-indicator-active"></div>
@@ -0,0 +1 @@
1
+ {"uuid":"21163df5-50d7-4cfc-ab56-45d835fb0c72"}
@@ -20,6 +20,8 @@
20
20
  :main-nav-aria-label="mainNavAriaLabel"
21
21
  :secondary-nav-aria-label="secondaryNavAriaLabel"
22
22
  :overflow-menu-aria-label="overflowMenuAriaLabel"
23
+ :overflow-button-label="overflowButtonLabel"
24
+ :submenu-aria-label="submenuAriaLabel"
23
25
  >
24
26
  <template v-if="slots.secondaryNavigation" #secondaryNavigation>
25
27
  <slot name="secondaryNavigation"></slot>
@@ -54,6 +56,10 @@ interface Props {
54
56
  secondaryNavAriaLabel?: string;
55
57
  /** Forwarded to ResponsiveHeader's overflowMenuAriaLabel — override for localisation. */
56
58
  overflowMenuAriaLabel?: string;
59
+ /** Forwarded to ResponsiveHeader's overflowButtonLabel, the burger button's accessible name. */
60
+ overflowButtonLabel?: string;
61
+ /** Forwarded to ResponsiveHeader's submenuAriaLabel; {title} is replaced with the item's title. */
62
+ submenuAriaLabel?: string;
57
63
  }
58
64
 
59
65
  withDefaults(defineProps<Props>(), {
@@ -73,6 +79,8 @@ withDefaults(defineProps<Props>(), {
73
79
  mainNavAriaLabel: "Main navigation",
74
80
  secondaryNavAriaLabel: "Secondary navigation",
75
81
  overflowMenuAriaLabel: "Overflow navigation menu",
82
+ overflowButtonLabel: "More navigation",
83
+ submenuAriaLabel: "{title} submenu",
76
84
  });
77
85
 
78
86
  const slots = useSlots();
@@ -9,7 +9,7 @@ exports[`SiteHeader > renders correct HTML structure with default props 1`] = `
9
9
  <nav class="skip-links__nav" aria-label="Skip navigation"><a href="#main-content" class="skip-links__link">Skip to main content</a><a href="#footer-content" class="skip-links__link">Skip to footer</a></nav>
10
10
  </div>
11
11
  </nav>
12
- <div class="responsive-header-stub" main-nav-aria-label="Main navigation" secondary-nav-aria-label="Secondary navigation" overflow-menu-aria-label="Overflow navigation menu"></div>
12
+ <div class="responsive-header-stub" main-nav-aria-label="Main navigation" secondary-nav-aria-label="Secondary navigation" overflow-menu-aria-label="Overflow navigation menu" overflow-button-label="More navigation" submenu-aria-label="{title} submenu"></div>
13
13
  </header>
14
14
  </div>"
15
15
  `;
@@ -0,0 +1,231 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { defineComponent, h, nextTick, ref } from "vue";
4
+ import { useAnchoredPopover, type AnchoredPopoverSide } from "../useAnchoredPopover";
5
+
6
+ type Support = { popover: boolean; anchor: boolean };
7
+
8
+ const setSupport = ({ popover, anchor }: Support) => {
9
+ Object.defineProperty(HTMLElement.prototype, "showPopover", {
10
+ value: popover ? vi.fn() : undefined,
11
+ writable: true,
12
+ configurable: true,
13
+ });
14
+ Object.defineProperty(HTMLElement.prototype, "hidePopover", {
15
+ value: popover ? vi.fn() : undefined,
16
+ writable: true,
17
+ configurable: true,
18
+ });
19
+ // happy-dom's CSS.supports can't be spied on, so swap the whole global and restore it in afterEach.
20
+ Object.defineProperty(globalThis, "CSS", { value: { supports: () => anchor }, writable: true, configurable: true });
21
+ };
22
+
23
+ const originalCSS = globalThis.CSS;
24
+
25
+ const setViewport = (width: number, height: number) => {
26
+ Object.defineProperty(document.documentElement, "clientWidth", { value: width, configurable: true });
27
+ Object.defineProperty(document.documentElement, "clientHeight", { value: height, configurable: true });
28
+ };
29
+
30
+ const mountHost = (side: AnchoredPopoverSide = "bottom") => {
31
+ const onOpen = vi.fn();
32
+ const mounted = mountSuspended(
33
+ defineComponent({
34
+ setup() {
35
+ const rootRef = ref<HTMLElement | null>(null);
36
+ const triggerRef = ref<HTMLElement | null>(null);
37
+ const popoverRef = ref<HTMLElement | null>(null);
38
+ return { rootRef, triggerRef, popoverRef, ...useAnchoredPopover({ rootRef, triggerRef, popoverRef, side, onOpen }) };
39
+ },
40
+ render() {
41
+ return h("div", [
42
+ h("div", { ref: "rootRef", class: "root" }, [
43
+ h("button", { ref: "triggerRef", class: "trigger", onClick: this.handleTriggerClick }),
44
+ h("div", { ref: "popoverRef", class: "popover" }),
45
+ ]),
46
+ h("button", { class: "outside" }),
47
+ ]);
48
+ },
49
+ }),
50
+ { attachTo: document.body }
51
+ );
52
+ return { mounted, onOpen };
53
+ };
54
+
55
+ const mockTriggerRect = (wrapper: Awaited<ReturnType<typeof mountHost>["mounted"]>, rect: Partial<DOMRect>) => {
56
+ const trigger = wrapper.find(".trigger").element as HTMLElement;
57
+ vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ top: 0, bottom: 0, left: 0, right: 0, ...rect } as DOMRect);
58
+ };
59
+
60
+ const mockPopoverSize = (wrapper: Awaited<ReturnType<typeof mountHost>["mounted"]>, width: number, height: number) => {
61
+ const popover = wrapper.find(".popover").element;
62
+ Object.defineProperty(popover, "offsetWidth", { value: width, configurable: true });
63
+ Object.defineProperty(popover, "offsetHeight", { value: height, configurable: true });
64
+ };
65
+
66
+ describe("useAnchoredPopover", () => {
67
+ beforeEach(() => {
68
+ setViewport(1000, 800);
69
+ });
70
+
71
+ afterEach(() => {
72
+ vi.restoreAllMocks();
73
+ delete (HTMLElement.prototype as unknown as Record<string, unknown>)["showPopover"];
74
+ delete (HTMLElement.prototype as unknown as Record<string, unknown>)["hidePopover"];
75
+ delete (document.documentElement as unknown as Record<string, unknown>)["clientWidth"];
76
+ delete (document.documentElement as unknown as Record<string, unknown>)["clientHeight"];
77
+ Object.defineProperty(globalThis, "CSS", { value: originalCSS, writable: true, configurable: true });
78
+ document.body.innerHTML = "";
79
+ });
80
+
81
+ describe("native Popover API and anchor positioning", () => {
82
+ beforeEach(() => setSupport({ popover: true, anchor: true }));
83
+
84
+ it("uses neither fallback", async () => {
85
+ const wrapper = await mountHost().mounted;
86
+ expect(wrapper.vm.usesFallbackPopover).toBe(false);
87
+ expect(wrapper.vm.needsPositioning).toBe(false);
88
+ expect(wrapper.vm.positionStyle).toBeUndefined();
89
+ expect(wrapper.vm.popoverPlacement).toBeUndefined();
90
+ });
91
+
92
+ it("show and hide call the native methods", async () => {
93
+ const wrapper = await mountHost().mounted;
94
+ const popover = wrapper.find(".popover").element as HTMLElement;
95
+ wrapper.vm.show();
96
+ wrapper.vm.hide();
97
+ expect(popover.showPopover).toHaveBeenCalledOnce();
98
+ expect(popover.hidePopover).toHaveBeenCalledOnce();
99
+ });
100
+
101
+ it("trigger click is left to popovertarget", async () => {
102
+ const wrapper = await mountHost().mounted;
103
+ await wrapper.find(".trigger").trigger("click");
104
+ expect(wrapper.vm.isOpen).toBe(false);
105
+ });
106
+
107
+ it("tracks isOpen and calls onOpen from the toggle event", async () => {
108
+ const { mounted, onOpen } = mountHost();
109
+ const wrapper = await mounted;
110
+ wrapper.vm.handleToggle(Object.assign(new Event("toggle"), { newState: "open" }));
111
+ expect(wrapper.vm.isOpen).toBe(true);
112
+ expect(onOpen).toHaveBeenCalledOnce();
113
+
114
+ wrapper.vm.handleToggle(Object.assign(new Event("toggle"), { newState: "closed" }));
115
+ expect(wrapper.vm.isOpen).toBe(false);
116
+ });
117
+ });
118
+
119
+ describe("without the Popover API", () => {
120
+ beforeEach(() => setSupport({ popover: false, anchor: true }));
121
+
122
+ it("detects the fallback on mount", async () => {
123
+ const wrapper = await mountHost().mounted;
124
+ expect(wrapper.vm.usesFallbackPopover).toBe(true);
125
+ });
126
+
127
+ it("toggles open and closed from the trigger, calling onOpen after render", async () => {
128
+ const { mounted, onOpen } = mountHost();
129
+ const wrapper = await mounted;
130
+
131
+ await wrapper.find(".trigger").trigger("click");
132
+ expect(wrapper.vm.isOpen).toBe(true);
133
+ await nextTick();
134
+ expect(onOpen).toHaveBeenCalledOnce();
135
+
136
+ await wrapper.find(".trigger").trigger("click");
137
+ expect(wrapper.vm.isOpen).toBe(false);
138
+ });
139
+
140
+ it("closes on a pointerdown outside the root, not inside it", async () => {
141
+ const wrapper = await mountHost().mounted;
142
+ wrapper.vm.show();
143
+ await nextTick();
144
+
145
+ wrapper.find(".popover").element.dispatchEvent(new Event("pointerdown", { bubbles: true }));
146
+ expect(wrapper.vm.isOpen).toBe(true);
147
+
148
+ wrapper.find(".outside").element.dispatchEvent(new Event("pointerdown", { bubbles: true }));
149
+ expect(wrapper.vm.isOpen).toBe(false);
150
+ });
151
+
152
+ it("closes on Escape and returns focus to the trigger", async () => {
153
+ const wrapper = await mountHost().mounted;
154
+ const focusSpy = vi.spyOn(wrapper.find(".trigger").element as HTMLElement, "focus");
155
+ wrapper.vm.show();
156
+ await nextTick();
157
+
158
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
159
+ expect(wrapper.vm.isOpen).toBe(false);
160
+ expect(focusSpy).toHaveBeenCalledOnce();
161
+ });
162
+
163
+ it("stops listening once closed", async () => {
164
+ const wrapper = await mountHost().mounted;
165
+ const focusSpy = vi.spyOn(wrapper.find(".trigger").element as HTMLElement, "focus");
166
+ wrapper.vm.show();
167
+ await nextTick();
168
+ wrapper.vm.hide();
169
+
170
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
171
+ expect(focusSpy).not.toHaveBeenCalled();
172
+ });
173
+ });
174
+
175
+ describe("without anchor positioning", () => {
176
+ beforeEach(() => setSupport({ popover: true, anchor: false }));
177
+
178
+ const openWith = async (side: AnchoredPopoverSide, rect: Partial<DOMRect>, size = { width: 100, height: 200 }) => {
179
+ const wrapper = await mountHost(side).mounted;
180
+ mockTriggerRect(wrapper, rect);
181
+ mockPopoverSize(wrapper, size.width, size.height);
182
+ wrapper.vm.handleToggle(Object.assign(new Event("toggle"), { newState: "open" }));
183
+ return wrapper;
184
+ };
185
+
186
+ it("writes every trigger edge, plain and measured from the viewport's far edge", async () => {
187
+ const wrapper = await mountHost().mounted;
188
+ mockTriggerRect(wrapper, { top: 100, bottom: 140, left: 50, right: 250 });
189
+
190
+ wrapper.vm.handleBeforeToggle(Object.assign(new Event("beforetoggle"), { newState: "open" }));
191
+ expect(wrapper.vm.needsPositioning).toBe(true);
192
+ expect(wrapper.vm.positionStyle).toEqual({
193
+ "--_anchor-top": "100px",
194
+ "--_anchor-bottom": "140px",
195
+ "--_anchor-left": "50px",
196
+ "--_anchor-right": "250px",
197
+ "--_anchor-top-inverse": "700px",
198
+ "--_anchor-bottom-inverse": "660px",
199
+ "--_anchor-left-inverse": "950px",
200
+ "--_anchor-right-inverse": "750px",
201
+ });
202
+ });
203
+
204
+ it("ignores beforetoggle when closing", async () => {
205
+ const wrapper = await mountHost().mounted;
206
+ mockTriggerRect(wrapper, { top: 100, bottom: 140, left: 50, right: 250 });
207
+
208
+ wrapper.vm.handleBeforeToggle(Object.assign(new Event("beforetoggle"), { newState: "closed" }));
209
+ expect(wrapper.vm.positionStyle).toMatchObject({ "--_anchor-top": "0px" });
210
+ });
211
+
212
+ it.each([
213
+ ["bottom", { top: 100, bottom: 140, left: 400, right: 450 }, "bottom"],
214
+ ["bottom", { top: 700, bottom: 740, left: 400, right: 450 }, "top"],
215
+ ["top", { top: 300, bottom: 340, left: 400, right: 450 }, "top"],
216
+ ["top", { top: 100, bottom: 140, left: 400, right: 450 }, "bottom"],
217
+ ["right", { top: 300, bottom: 340, left: 400, right: 450 }, "right"],
218
+ ["right", { top: 300, bottom: 340, left: 850, right: 950 }, "left"],
219
+ ["left", { top: 300, bottom: 340, left: 400, right: 450 }, "left"],
220
+ ["left", { top: 300, bottom: 340, left: 50, right: 100 }, "right"],
221
+ ] as const)("prefers %s and resolves to %s for %o", async (side, rect, expected) => {
222
+ const wrapper = await openWith(side, rect);
223
+ expect(wrapper.vm.popoverPlacement).toBe(expected);
224
+ });
225
+
226
+ it("keeps the preferred side when neither side has room", async () => {
227
+ const wrapper = await openWith("bottom", { top: 100, bottom: 140, left: 50, right: 250 }, { width: 100, height: 900 });
228
+ expect(wrapper.vm.popoverPlacement).toBe("bottom");
229
+ });
230
+ });
231
+ });