srcdev-nuxt-components 9.6.19 → 9.6.20

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 (135) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +3 -2
  2. package/.claude/skills/component-compliance-checklist.md +5 -0
  3. package/.claude/skills/components/auto-grid.md +3 -1
  4. package/.claude/skills/components/capture-qr-code.md +9 -5
  5. package/.claude/skills/components/carousel-flip.md +41 -30
  6. package/.claude/skills/components/column-flow-grid.md +2 -0
  7. package/.claude/skills/components/dashboard-quad-grid.md +7 -2
  8. package/.claude/skills/components/dashboard-stats-grid.md +4 -0
  9. package/.claude/skills/components/decode-qr-code.md +7 -2
  10. package/.claude/skills/components/deep-expanding-menu-classic.md +31 -30
  11. package/.claude/skills/components/deep-expanding-menu.md +22 -21
  12. package/.claude/skills/components/grid-stack.md +2 -0
  13. package/.claude/skills/components/layout-grid-by-cols.md +2 -0
  14. package/.claude/skills/components/masonry-grid.md +9 -1
  15. package/.claude/skills/components/page-row.md +3 -0
  16. package/.claude/skills/components/samaritan-prompt.md +24 -8
  17. package/.claude/skills/components/site-header.md +16 -2
  18. package/.claude/skills/index.md +12 -12
  19. package/.claude/skills/storybook-add-story.md +25 -0
  20. package/.claude/skills/testing-add-unit-test.md +26 -0
  21. package/.vscode/srcdev-component-capture-decode-qr-code.code-snippets +11 -2
  22. package/.vscode/srcdev-component-carousel-flip.code-snippets +22 -4
  23. package/.vscode/srcdev-component-deep-expanding-menu-classic.code-snippets +32 -2
  24. package/.vscode/srcdev-component-deep-expanding-menu.code-snippets +31 -1
  25. package/.vscode/srcdev-component-samaritan-prompt.code-snippets +5 -4
  26. package/.vscode/srcdev-component-site-header.code-snippets +13 -1
  27. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/CONSUMER-STYLING.md +12 -2
  28. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/DashboardQuadGrid.vue +19 -17
  29. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/stories/DashboardQuadGrid.stories.ts +42 -0
  30. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/tests/DashboardQuadGrid.spec.ts +31 -44
  31. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/CONSUMER-STYLING.md +13 -1
  32. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/DashboardStatsGrid.vue +40 -31
  33. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/stories/DashboardStatsGrid.stories.ts +47 -0
  34. package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/tests/DashboardStatsGrid.spec.ts +51 -53
  35. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/CONSUMER-STYLING.md +3 -0
  36. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/LayoutGridByCols.vue +4 -0
  37. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/stories/LayoutGridByCols.stories.ts +47 -2
  38. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/tests/LayoutGridByCols.spec.ts +36 -23
  39. package/app/components/01.atoms/grids/auto-grid/AutoGrid.vue +7 -3
  40. package/app/components/01.atoms/grids/auto-grid/CONSUMER-STYLING.md +1 -1
  41. package/app/components/01.atoms/grids/auto-grid/stories/AutoGrid.stories.ts +46 -1
  42. package/app/components/01.atoms/grids/auto-grid/tests/AutoGrid.spec.ts +40 -27
  43. package/app/components/01.atoms/grids/auto-grid/tests/__snapshots__/AutoGrid.spec.ts.snap +1 -1
  44. package/app/components/01.atoms/grids/column-flow-grid/CONSUMER-STYLING.md +9 -0
  45. package/app/components/01.atoms/grids/column-flow-grid/ColumnFlowGrid.vue +3 -2
  46. package/app/components/01.atoms/grids/column-flow-grid/stories/ColumnFlowGrid.stories.ts +46 -0
  47. package/app/components/01.atoms/grids/column-flow-grid/tests/ColumnFlowGrid.spec.ts +24 -11
  48. package/app/components/01.atoms/grids/grid-stack/CONSUMER-STYLING.md +3 -0
  49. package/app/components/01.atoms/grids/grid-stack/GridStack.vue +2 -0
  50. package/app/components/01.atoms/grids/grid-stack/stories/GridStack.stories.ts +41 -0
  51. package/app/components/01.atoms/grids/grid-stack/tests/GridStack.spec.ts +26 -13
  52. package/app/components/01.atoms/grids/masonry-grid/CONSUMER-STYLING.md +15 -0
  53. package/app/components/01.atoms/grids/masonry-grid/MasonryGrid.vue +51 -31
  54. package/app/components/01.atoms/grids/masonry-grid/stories/MasonryGrid.stories.ts +48 -0
  55. package/app/components/01.atoms/grids/masonry-grid/tests/MasonryGrid.spec.ts +84 -29
  56. package/app/components/01.atoms/page-row/CONSUMER-STYLING.md +6 -1
  57. package/app/components/01.atoms/page-row/PageRow.vue +16 -15
  58. package/app/components/01.atoms/page-row/stories/PageRow.stories.ts +23 -0
  59. package/app/components/01.atoms/page-row/tests/PageRow.spec.ts +6 -5
  60. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +63 -44
  61. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +74 -37
  62. package/app/components/02.molecules/navigation/deep-expanding-menu/stories/DeepExpandingMenu.stories.ts +139 -19
  63. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +90 -27
  64. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +8 -8
  65. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +58 -26
  66. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +113 -57
  67. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/stories/DeepExpandingMenuClassic.stories.ts +139 -19
  68. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/DeepExpandingMenuClassic.spec.ts +140 -22
  69. package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/__snapshots__/DeepExpandingMenuClassic.spec.ts.snap +9 -9
  70. package/app/components/02.molecules/qr-code/CONSUMER-STYLING.md +22 -7
  71. package/app/components/02.molecules/qr-code/CaptureQrCode.vue +63 -71
  72. package/app/components/02.molecules/qr-code/DecodeQrCode.vue +47 -10
  73. package/app/components/02.molecules/qr-code/stories/CaptureQrCode.stories.ts +114 -26
  74. package/app/components/02.molecules/qr-code/stories/DecodeQrCode.stories.ts +115 -24
  75. package/app/components/02.molecules/qr-code/tests/CaptureQrCode.spec.ts +124 -51
  76. package/app/components/02.molecules/qr-code/tests/DecodeQrCode.spec.ts +74 -25
  77. package/app/components/02.molecules/samaritan-prompt/CONSUMER-STYLING.md +22 -10
  78. package/app/components/02.molecules/samaritan-prompt/SamaritanPrompt.vue +30 -12
  79. package/app/components/02.molecules/samaritan-prompt/SamaritanPromptMixed.vue +34 -10
  80. package/app/components/02.molecules/samaritan-prompt/stories/SamaritanPrompt.stories.ts +55 -4
  81. package/app/components/02.molecules/samaritan-prompt/stories/SamaritanPromptMixed.stories.ts +67 -7
  82. package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPrompt.spec.ts +113 -29
  83. package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPromptMixed.spec.ts +92 -45
  84. package/app/components/03.organisms/image-galleries/carousel-flip/CONSUMER-STYLING.md +82 -29
  85. package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +176 -113
  86. package/app/components/03.organisms/image-galleries/carousel-flip/stories/CarouselFlip.stories.ts +153 -23
  87. package/app/components/03.organisms/image-galleries/carousel-flip/tests/CarouselFlip.spec.ts +119 -63
  88. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +8 -8
  89. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +4 -4
  90. package/app/components/03.organisms/site-header/CONSUMER-STYLING.md +41 -23
  91. package/app/components/03.organisms/site-header/SiteHeader.vue +16 -21
  92. package/app/components/03.organisms/site-header/stories/SiteHeader.stories.ts +138 -0
  93. package/app/components/03.organisms/site-header/tests/SiteHeader.spec.ts +53 -26
  94. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +2 -2
  95. package/app/types/components/qr-code.d.ts +12 -0
  96. package/app/utils/graphemes.ts +8 -0
  97. package/package.json +1 -1
  98. package/app/components/01.atoms/animations/entry/.nuxt/app.config.mjs +0 -21
  99. package/app/components/01.atoms/animations/entry/.nuxt/components.d.ts +0 -66
  100. package/app/components/01.atoms/animations/entry/.nuxt/imports.d.ts +0 -30
  101. package/app/components/01.atoms/animations/entry/.nuxt/manifest/meta/test.json +0 -1
  102. package/app/components/01.atoms/animations/entry/.nuxt/types/app.config.d.ts +0 -35
  103. package/app/components/01.atoms/animations/entry/.nuxt/types/build.d.ts +0 -23
  104. package/app/components/01.atoms/animations/entry/.nuxt/types/builder-env.d.ts +0 -1
  105. package/app/components/01.atoms/animations/entry/.nuxt/types/components.d.ts +0 -71
  106. package/app/components/01.atoms/animations/entry/.nuxt/types/imports.d.ts +0 -386
  107. package/app/components/01.atoms/animations/entry/.nuxt/types/middleware.d.ts +0 -11
  108. package/app/components/01.atoms/animations/entry/.nuxt/types/modules.d.ts +0 -79
  109. package/app/components/01.atoms/animations/entry/.nuxt/types/nitro-layouts.d.ts +0 -17
  110. package/app/components/01.atoms/animations/entry/.nuxt/types/nitro-nuxt.d.ts +0 -64
  111. package/app/components/01.atoms/animations/entry/.nuxt/types/plugins.d.ts +0 -29
  112. package/app/components/01.atoms/animations/entry/.nuxt/types/runtime-config.d.ts +0 -32
  113. package/app/components/01.atoms/animations/entry/.nuxt/types/shared-imports.d.ts +0 -10
  114. package/app/components/01.atoms/animations/entry/.nuxt/types/vue-shim.d.ts +0 -0
  115. package/app/components/01.atoms/animations/entry/.nuxtrc +0 -1
  116. package/app/components/01.atoms/animations/entry/node_modules/.cache/nuxt/chrome-workspace.json +0 -1
  117. package/app/components/01.atoms/animations/entry/node_modules/.cache/vite/client/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  118. package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +0 -21
  119. package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +0 -66
  120. package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +0 -30
  121. package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +0 -1
  122. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +0 -35
  123. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +0 -23
  124. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +0 -1
  125. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +0 -71
  126. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +0 -386
  127. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +0 -11
  128. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +0 -79
  129. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +0 -17
  130. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +0 -64
  131. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +0 -29
  132. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +0 -32
  133. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +0 -10
  134. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
  135. package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +0 -1
@@ -1,6 +1,8 @@
1
+ import { computed, ref } from "vue";
1
2
  import DeepExpandingMenuClassic from "../DeepExpandingMenuClassic.vue";
3
+ import CanvasSwitcher from "../../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
2
4
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
- import type { ResponsiveHeaderNavItem } from "~/types/components";
5
+ import type { MediaCanvas, ResponsiveHeaderNavItem } from "~/types/components";
4
6
 
5
7
  const navLinks: ResponsiveHeaderNavItem[] = [
6
8
  { name: "Home", path: "/" },
@@ -16,63 +18,181 @@ const navLinks: ResponsiveHeaderNavItem[] = [
16
18
  { name: "Contact", path: "/contact" },
17
19
  ];
18
20
 
19
- const meta: Meta<typeof DeepExpandingMenuClassic> = {
21
+ const stressNavLinks: ResponsiveHeaderNavItem[] = [
22
+ { name: "H", path: "/" },
23
+ {
24
+ name: "Dienstleistungsangebotsübersichtsseite",
25
+ childLinksTitle:
26
+ "Unsere vollständige Übersicht aller Dienstleistungen, Behandlungen und Pflegeangebote für Haut und Haar in Northtown",
27
+ childLinks: [
28
+ { name: "Haarschnitt", path: "/services/haircuts" },
29
+ { name: "https://example.com/a/very/long/unbroken/path/that/never/wraps/on/its/own", path: "/long-url" },
30
+ { name: "🎨 Colouring", path: "/services/colouring" },
31
+ { name: "Styling", path: "/services/styling-1" },
32
+ { name: "Styling", path: "/services/styling-2" },
33
+ { name: "<b>Not bold</b>", path: "/services/html" },
34
+ { name: "قص الشعر", path: "/services/rtl" },
35
+ ...Array.from({ length: 24 }, (_, i) => ({ name: `Treatment ${i + 1}`, path: `/services/treatment-${i + 1}` })),
36
+ ],
37
+ },
38
+ { name: "Gruppe ohne Titel", childLinks: [{ name: "Only link", path: "/only" }] },
39
+ { name: "Empty group", childLinksTitle: "Nothing here yet", childLinks: [] },
40
+ { name: "Supercalifragilisticexpialidociousandthensomemoretext", path: "/unbroken" },
41
+ { name: "🙂", path: "/emoji" },
42
+ { name: "Contact", path: "/contact" },
43
+ { name: "Contact", path: "/contact" },
44
+ {
45
+ name: "About us and our story",
46
+ childLinksTitle: "About Alex Morgan",
47
+ childLinks: [
48
+ { name: "Team", path: "/about/team" },
49
+ { name: "History", path: "/about/history" },
50
+ ],
51
+ },
52
+ ];
53
+
54
+ type LineClamp = "none" | "1" | "2" | "3";
55
+
56
+ // Story-only arg: drives a CSS token, not a DeepExpandingMenuClassic prop. See the argTypes entry.
57
+ type StoryArgs = InstanceType<typeof DeepExpandingMenuClassic>["$props"] & {
58
+ headingLineClamp: LineClamp;
59
+ };
60
+
61
+ const meta: Meta<StoryArgs> = {
20
62
  title: "Molecules/DeepExpandingMenuClassic",
21
63
  component: DeepExpandingMenuClassic,
22
64
  argTypes: {
65
+ headingLineClamp: {
66
+ control: "select",
67
+ options: ["none", "1", "2", "3"] satisfies LineClamp[],
68
+ description:
69
+ "**Story control, not a prop.** Sets the `--deep-expanding-menu-classic-panel-heading-line-clamp` CSS token on a wrapper so you can try it here. " +
70
+ "To use it in an app, set the token in your own CSS, e.g. `.site-nav { --deep-expanding-menu-classic-panel-heading-line-clamp: 1; }`. " +
71
+ "`1` is single-line ellipsis, `none` (the default) shows everything.",
72
+ table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "none" } },
73
+ },
23
74
  tag: {
24
75
  control: { type: "select" },
25
- options: ["div", "section", "nav", "ul", "ol"],
76
+ options: ["div", "section", "nav"],
26
77
  description: "Root element tag",
78
+ table: { category: "Basic", defaultValue: { summary: "nav" } },
79
+ },
80
+ ariaLabel: {
81
+ control: "text",
82
+ description: "Accessible name for the root. Set it when the page has more than one `<nav>`",
27
83
  table: { category: "Basic" },
28
84
  },
29
- navLinks: { table: { disable: true } },
85
+ navLinks: {
86
+ control: "object",
87
+ description: "Top-level items: `path` renders a link, `childLinks` renders a toggle and panel",
88
+ table: { category: "Content" },
89
+ },
30
90
  styleClassPassthrough: { table: { disable: true } },
31
91
  },
32
92
  args: {
33
93
  tag: "nav",
34
94
  navLinks,
95
+ headingLineClamp: "none",
35
96
  },
97
+ decorators: [
98
+ (story, context) => ({
99
+ components: { story, CanvasSwitcher },
100
+ setup() {
101
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
102
+ return { canvasName };
103
+ },
104
+ template: `
105
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
106
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
107
+ </div>
108
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
109
+ <story />
110
+ </div>
111
+ `,
112
+ }),
113
+ ],
36
114
  parameters: {
115
+ layout: "fullscreen",
37
116
  docs: {
38
117
  description: {
39
118
  component:
40
- "`<details>`/`<summary>`-based fallback implementation of `DeepExpandingMenu`, using `onClickOutside` to close an open group instead of CSS anchor-positioning/Popover API — use this when the modern implementation's browser support (see its own docs) isn't acceptable.",
119
+ "`<details>`/`<summary>`-based version of `DeepExpandingMenu`: each item is a direct link (`path`) or a native `<details>` group showing its child links (`childLinks`, with an optional `childLinksTitle` heading). " +
120
+ "Groups in one menu are exclusive; outside click, Escape and following a child link close the open group. No Popover API or anchor positioning involved. Props: `navLinks`, `tag`, `ariaLabel`. " +
121
+ "Prefer `DeepExpandingMenu` unless you need this simpler fallback.",
41
122
  },
42
123
  },
43
124
  },
44
125
  };
45
126
 
46
127
  export default meta;
47
- type Story = StoryObj<typeof DeepExpandingMenuClassic>;
128
+ type Story = StoryObj<StoryArgs>;
129
+
130
+ function useStorySetup(args: StoryArgs) {
131
+ const componentArgs = computed(() => {
132
+ const { headingLineClamp: _headingLineClamp, ...rest } = args;
133
+ return rest;
134
+ });
135
+ const tokenStyles = computed(() => ({
136
+ "--deep-expanding-menu-classic-panel-heading-line-clamp": args.headingLineClamp,
137
+ }));
138
+ return { componentArgs, tokenStyles };
139
+ }
140
+
141
+ const render = (args: StoryArgs) => ({
142
+ components: { DeepExpandingMenuClassic },
143
+ setup() {
144
+ return useStorySetup(args);
145
+ },
146
+ template: `<div :style="tokenStyles"><DeepExpandingMenuClassic v-bind="componentArgs" /></div>`,
147
+ });
48
148
 
49
149
  export const Default: Story = {
50
- render: (args) => ({
51
- components: { DeepExpandingMenuClassic },
52
- setup() {
53
- return { args };
54
- },
55
- template: `<DeepExpandingMenuClassic v-bind="args" style="padding: 4rem;" />`,
56
- }),
150
+ render,
57
151
  };
58
152
 
59
153
  export const CustomColours: Story = {
154
+ name: "Custom Colours",
60
155
  render: (args) => ({
61
156
  components: { DeepExpandingMenuClassic },
62
157
  setup() {
63
- return { args };
158
+ return useStorySetup(args);
64
159
  },
65
160
  template: `
66
- <DeepExpandingMenuClassic
67
- v-bind="args"
68
- style="padding: 4rem; --deep-expanding-menu-classic-panel-background-colour: #1a1a1a; --deep-expanding-menu-classic-panel-border-colour: transparent; --deep-expanding-menu-classic-group-link-colour: white; --deep-expanding-menu-classic-panel-heading-colour: #aaaaaa;"
69
- />
161
+ <div
162
+ :style="tokenStyles"
163
+ style="--deep-expanding-menu-classic-panel-background-colour: #1a1a1a; --deep-expanding-menu-classic-panel-border-colour: transparent; --deep-expanding-menu-classic-group-link-colour: white; --deep-expanding-menu-classic-group-link-border-colour-hover: white; --deep-expanding-menu-classic-panel-heading-colour: #aaaaaa;"
164
+ >
165
+ <DeepExpandingMenuClassic v-bind="componentArgs" />
166
+ </div>
70
167
  `,
71
168
  }),
72
169
  parameters: {
73
170
  docs: {
74
171
  description: {
75
- story: "Overriding the public tokens via inline `--deep-expanding-menu-classic-*` custom properties.",
172
+ story: "Overriding the public `--deep-expanding-menu-classic-*` tokens on a wrapper element.",
173
+ },
174
+ },
175
+ },
176
+ };
177
+
178
+ export const StressTest: Story = {
179
+ name: "Stress Test (Worst-Case Data)",
180
+ args: {
181
+ navLinks: stressNavLinks,
182
+ },
183
+ render,
184
+ parameters: {
185
+ initialCanvas: "mobileCanvas",
186
+ docs: {
187
+ story: { inline: false, height: "720px" },
188
+ description: {
189
+ story:
190
+ "Hostile nav data at every `CanvasSwitcher` width. Check that: the top-level row wraps instead of overflowing; " +
191
+ "the long German label and the unbroken word break inside the canvas; the caret stays beside a wrapped toggle label; " +
192
+ "the first panel's long heading wraps (try the story-only `headingLineClamp` CSS token control), its unbroken URL link stays inside the panel, " +
193
+ "`<b>Not bold</b>` renders as text, the right-to-left link reads correctly, and its 31 links stay inside the panel; " +
194
+ "duplicate `Styling` and `Contact` items both render; a group without a title shows its list only; " +
195
+ "an empty group opens with its heading and no list; panels are never wider than the menu itself (they open from their toggle, so one near the right edge can still overrun it: `DeepExpandingMenu` flips instead).",
76
196
  },
77
197
  },
78
198
  },
@@ -1,5 +1,6 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
2
  import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { defineComponent, h, nextTick } from "vue";
3
4
  import DeepExpandingMenuClassic from "../DeepExpandingMenuClassic.vue";
4
5
  import type { ResponsiveHeaderNavItem } from "~/types/components";
5
6
 
@@ -16,67 +17,184 @@ const defaultNavLinks: ResponsiveHeaderNavItem[] = [
16
17
  { name: "Contact", path: "/contact" },
17
18
  ];
18
19
 
20
+ const twoGroups: ResponsiveHeaderNavItem[] = [
21
+ { name: "Services", childLinksTitle: "Our services", childLinks: [{ name: "Haircuts", path: "/haircuts" }] },
22
+ { name: "About", childLinksTitle: "About us", childLinks: [{ name: "Team", path: "/team" }] },
23
+ ];
24
+
25
+ const mounted: Awaited<ReturnType<typeof mountSuspended>>[] = [];
26
+
27
+ const mountMenu = async (options: { props?: Record<string, unknown>; attachTo?: HTMLElement } = {}) => {
28
+ const wrapper = await mountSuspended(DeepExpandingMenuClassic, options);
29
+ mounted.push(wrapper);
30
+ return wrapper;
31
+ };
32
+
33
+ afterEach(() => {
34
+ mounted.splice(0).forEach((wrapper) => wrapper.unmount());
35
+ vi.restoreAllMocks();
36
+ });
37
+
38
+ const groups = (wrapper: Awaited<ReturnType<typeof mountMenu>>) =>
39
+ wrapper.findAll("details").map((d) => d.element as HTMLDetailsElement);
40
+
19
41
  describe("DeepExpandingMenuClassic", () => {
20
42
  it("mounts without error", async () => {
21
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, { props: { navLinks: defaultNavLinks } });
43
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks } });
22
44
  expect(wrapper.vm).toBeTruthy();
23
45
  });
24
46
 
25
47
  it("renders correct HTML structure", async () => {
26
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, { props: { navLinks: defaultNavLinks } });
48
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks } });
27
49
  expect(wrapper.html()).toMatchSnapshot();
28
50
  });
29
51
 
30
52
  it("renders a <nav> by default", async () => {
31
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, { props: { navLinks: defaultNavLinks } });
53
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks } });
32
54
  expect(wrapper.element.tagName.toLowerCase()).toBe("nav");
33
55
  });
34
56
 
35
57
  it("renders the given tag", async () => {
36
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, {
37
- props: { navLinks: defaultNavLinks, tag: "div" },
38
- });
58
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks, tag: "div" } });
39
59
  expect(wrapper.element.tagName.toLowerCase()).toBe("div");
40
60
  });
41
61
 
62
+ it("labels the root when ariaLabel is set, and leaves it unlabelled otherwise", async () => {
63
+ const unlabelled = await mountMenu({ props: { navLinks: defaultNavLinks } });
64
+ expect(unlabelled.attributes("aria-label")).toBeUndefined();
65
+
66
+ const labelled = await mountMenu({ props: { navLinks: defaultNavLinks, ariaLabel: "Main" } });
67
+ expect(labelled.attributes("aria-label")).toBe("Main");
68
+ });
69
+
42
70
  it("renders a direct link for items with a path", async () => {
43
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, { props: { navLinks: defaultNavLinks } });
44
- const links = wrapper.findAll(".navigation-link");
71
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks } });
72
+ const links = wrapper.findAll(".deep-expanding-menu-classic-link");
45
73
  expect(links.map((l) => l.text())).toEqual(["Home", "Contact"]);
46
74
  });
47
75
 
48
76
  it("renders a <details> group for items with childLinks", async () => {
49
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, { props: { navLinks: defaultNavLinks } });
50
- expect(wrapper.find("details.navigation-group").exists()).toBe(true);
51
- expect(wrapper.find(".navigation-group-toggle").text()).toContain("Services");
77
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks } });
78
+ expect(wrapper.find("details.deep-expanding-menu-classic-group").exists()).toBe(true);
79
+ expect(wrapper.find(".deep-expanding-menu-classic-group-toggle").text()).toContain("Services");
52
80
  });
53
81
 
54
82
  it("renders each child link inside the group panel", async () => {
55
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, { props: { navLinks: defaultNavLinks } });
56
- const childLinks = wrapper.findAll(".navigation-group-link");
83
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks } });
84
+ const childLinks = wrapper.findAll(".deep-expanding-menu-classic-group-link");
57
85
  expect(childLinks.map((l) => l.text())).toEqual(["Haircuts", "Colouring"]);
58
86
  });
59
87
 
60
88
  it("renders the childLinksTitle heading", async () => {
61
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, { props: { navLinks: defaultNavLinks } });
62
- expect(wrapper.find(".navigation-group-panel h4").text()).toBe("Our services");
89
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks } });
90
+ expect(wrapper.find(".deep-expanding-menu-classic-group-heading").text()).toBe("Our services");
63
91
  });
64
92
 
65
- it("applies styleClassPassthrough classes", async () => {
66
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, {
67
- props: { navLinks: defaultNavLinks, styleClassPassthrough: ["custom-class"] },
93
+ it("omits the heading when childLinksTitle is missing or empty", async () => {
94
+ const navLinks: ResponsiveHeaderNavItem[] = [
95
+ { name: "A", childLinks: [{ name: "A1", path: "/a1" }] },
96
+ { name: "B", childLinksTitle: "", childLinks: [{ name: "B1", path: "/b1" }] },
97
+ ];
98
+ const wrapper = await mountMenu({ props: { navLinks } });
99
+ expect(wrapper.find(".deep-expanding-menu-classic-group-heading").exists()).toBe(false);
100
+ });
101
+
102
+ it("omits the list when a group has no child links", async () => {
103
+ const navLinks: ResponsiveHeaderNavItem[] = [{ name: "Empty", childLinksTitle: "Empty", childLinks: [] }];
104
+ const wrapper = await mountMenu({ props: { navLinks } });
105
+ expect(wrapper.find(".deep-expanding-menu-classic-group-list").exists()).toBe(false);
106
+ });
107
+
108
+ it("gives the child link list a list role and hides the caret from assistive technology", async () => {
109
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks } });
110
+ expect(wrapper.find(".deep-expanding-menu-classic-group-list").attributes("role")).toBe("list");
111
+ expect(wrapper.find(".deep-expanding-menu-classic-group-icon").attributes("aria-hidden")).toBe("true");
112
+ });
113
+
114
+ it("keeps child links with duplicate names in step when the list is reordered", async () => {
115
+ const childLinks = [
116
+ { name: "Same", path: "/one" },
117
+ { name: "Same", path: "/two" },
118
+ { name: "Other", path: "/three" },
119
+ ];
120
+ const wrapper = await mountMenu({ props: { navLinks: [{ name: "Group", childLinks }] } });
121
+ await wrapper.setProps({ navLinks: [{ name: "Group", childLinks: [childLinks[2]!, childLinks[0]!] }] });
122
+ const links = wrapper.findAll(".deep-expanding-menu-classic-group-link");
123
+ expect(links.map((l) => l.attributes("href"))).toEqual(["/three", "/one"]);
124
+ });
125
+
126
+ it("shares a details name within one menu but not across menus", async () => {
127
+ const Host = defineComponent({
128
+ setup: () => () => [
129
+ h(DeepExpandingMenuClassic, { navLinks: twoGroups }),
130
+ h(DeepExpandingMenuClassic, { navLinks: twoGroups }),
131
+ ],
68
132
  });
133
+ const wrapper = await mountSuspended(Host);
134
+ mounted.push(wrapper);
135
+ const names = wrapper.findAll("details").map((d) => d.attributes("name"));
136
+ expect(names[0]).toBe(names[1]);
137
+ expect(names[2]).toBe(names[3]);
138
+ expect(names[0]).not.toBe(names[2]);
139
+ });
140
+
141
+ it("applies styleClassPassthrough classes", async () => {
142
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks, styleClassPassthrough: ["custom-class"] } });
69
143
  expect(wrapper.classes()).toContain("custom-class");
70
144
  });
71
145
 
72
146
  it("resets classes when styleClassPassthrough prop changes", async () => {
73
- const wrapper = await mountSuspended(DeepExpandingMenuClassic, {
74
- props: { navLinks: defaultNavLinks, styleClassPassthrough: ["initial-class"] },
75
- });
147
+ const wrapper = await mountMenu({ props: { navLinks: defaultNavLinks, styleClassPassthrough: ["initial-class"] } });
76
148
  expect(wrapper.classes()).toContain("initial-class");
77
149
 
78
150
  await wrapper.setProps({ styleClassPassthrough: ["updated-class"] });
79
151
  expect(wrapper.classes()).not.toContain("initial-class");
80
152
  expect(wrapper.classes()).toContain("updated-class");
81
153
  });
154
+
155
+ describe("closing", () => {
156
+ it("closes an open group when something else in the menu is clicked", async () => {
157
+ const wrapper = await mountMenu({ props: { navLinks: twoGroups } });
158
+ const [first] = groups(wrapper);
159
+ first!.open = true;
160
+
161
+ await wrapper.findAll("summary")[1]!.trigger("click");
162
+ expect(first!.open).toBe(false);
163
+ });
164
+
165
+ it("keeps a group open when its own panel is clicked, and closes it when a child link is followed", async () => {
166
+ const wrapper = await mountMenu({ props: { navLinks: twoGroups } });
167
+ const [first] = groups(wrapper);
168
+ first!.open = true;
169
+
170
+ await wrapper.find(".deep-expanding-menu-classic-group-heading").trigger("click");
171
+ expect(first!.open).toBe(true);
172
+
173
+ await wrapper.find(".deep-expanding-menu-classic-group-link").trigger("click");
174
+ expect(first!.open).toBe(false);
175
+ });
176
+
177
+ it("closes the group on Escape and returns focus to its toggle", async () => {
178
+ const wrapper = await mountMenu({ props: { navLinks: twoGroups }, attachTo: document.body });
179
+ const [first] = groups(wrapper);
180
+ first!.open = true;
181
+
182
+ const link = wrapper.find(".deep-expanding-menu-classic-group-link");
183
+ await link.trigger("keydown", { key: "Escape" });
184
+ expect(first!.open).toBe(false);
185
+ expect(document.activeElement).toBe(wrapper.findAll("summary")[0]!.element);
186
+ });
187
+
188
+ it("closes open groups on a click outside the menu", async () => {
189
+ const wrapper = await mountMenu({ props: { navLinks: twoGroups }, attachTo: document.body });
190
+ const [first] = groups(wrapper);
191
+ first!.open = true;
192
+ vi.advanceTimersByTime(1);
193
+
194
+ document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
195
+ document.body.dispatchEvent(new MouseEvent("click", { bubbles: true }));
196
+ await nextTick();
197
+ expect(first!.open).toBe(false);
198
+ });
199
+ });
82
200
  });
@@ -2,17 +2,17 @@
2
2
 
3
3
  exports[`DeepExpandingMenuClassic > renders correct HTML structure 1`] = `
4
4
  "<nav class="deep-expanding-menu-classic">
5
- <div class="inner"><a href="/" class="navigation-link">Home</a>
6
- <details class="navigation-group" name="navigation-group">
7
- <summary class="navigation-group-toggle"><span>Services</span><span class="iconify i-bi:caret-down-fill icon" aria-hidden="true"></span></summary>
8
- <div class="navigation-group-panel">
9
- <h4 class="page-heading-4 mb-6">Our services</h4>
10
- <ul class="navigation-group-list">
11
- <li class="navigation-group-item"><a href="/services/haircuts" class="navigation-group-link">Haircuts</a></li>
12
- <li class="navigation-group-item"><a href="/services/colouring" class="navigation-group-link">Colouring</a></li>
5
+ <div class="deep-expanding-menu-classic-inner"><a href="/" class="deep-expanding-menu-classic-link">Home</a>
6
+ <details class="deep-expanding-menu-classic-group" name="deep-expanding-menu-classic-v-0-0">
7
+ <summary class="deep-expanding-menu-classic-group-toggle"><span class="deep-expanding-menu-classic-group-label">Services</span><span class="iconify i-bi:caret-down-fill deep-expanding-menu-classic-group-icon" aria-hidden="true"></span></summary>
8
+ <div class="deep-expanding-menu-classic-group-panel">
9
+ <h4 class="deep-expanding-menu-classic-group-heading page-heading-4 mb-6">Our services</h4>
10
+ <ul class="deep-expanding-menu-classic-group-list" role="list">
11
+ <li class="deep-expanding-menu-classic-group-item"><a href="/services/haircuts" class="deep-expanding-menu-classic-group-link">Haircuts</a></li>
12
+ <li class="deep-expanding-menu-classic-group-item"><a href="/services/colouring" class="deep-expanding-menu-classic-group-link">Colouring</a></li>
13
13
  </ul>
14
14
  </div>
15
- </details><a href="/contact" class="navigation-link">Contact</a>
15
+ </details><a href="/contact" class="deep-expanding-menu-classic-link">Contact</a>
16
16
  </div>
17
17
  </nav>"
18
18
  `;
@@ -8,6 +8,7 @@
8
8
  |---|---|---|
9
9
  | `--capture-qr-code-aspect-ratio` | `1 / 1` | Root aspect ratio (the camera frame) |
10
10
  | `--capture-qr-code-error-gap` | `1.2rem` | Gap between the error message and the reset button |
11
+ | `--capture-qr-code-results-indent` | `2rem` | Inline-start padding of the results list (bullet indent) |
11
12
 
12
13
  The reset button is `InputButton` (`variant="secondary"`), styled by its own tokens.
13
14
 
@@ -20,13 +21,19 @@ The reset button is `InputButton` (`variant="secondary"`), styled by its own tok
20
21
  | `--decode-qr-code-dropzone-min-height` | `3rem` | Drop zone minimum height |
21
22
  | `--decode-qr-code-dropzone-padding` | `1.2rem` | Drop zone padding |
22
23
  | `--decode-qr-code-dropzone-border-radius` | `0.5rem` | Drop zone corner rounding |
23
- | `--decode-qr-code-dropzone-border-width` | `2px` | Drop zone dashed border width |
24
- | `--decode-qr-code-dropzone-border-colour` | `gray` | Drop zone border colour |
24
+ | `--decode-qr-code-dropzone-border-width` | `0.2rem` | Drop zone dashed border width |
25
+ | `--decode-qr-code-dropzone-border-colour` | `var(--theme-border)` | Drop zone border colour |
25
26
  | `--decode-qr-code-dropzone-border-colour-active` | the border colour | Border colour while an image is dragged over |
26
- | `--decode-qr-code-dropzone-background-active` | `transparent` | Background while an image is dragged over |
27
+ | `--decode-qr-code-dropzone-background-active` | `var(--theme-surface-subtle)` | Background while an image is dragged over |
28
+ | `--decode-qr-code-results-indent` | `2rem` | Inline-start padding of the results list (bullet indent) |
27
29
 
28
- Private (not public API): `--_dropzone-border-colour`.
30
+ Private (not public API): `--_decode-qr-code-dropzone-border-colour`.
29
31
 
32
+ > Changed 2026-10-21: the drop zone border defaults to `var(--theme-border)` (was `gray`), its
33
+ > width to `0.2rem` (was `2px`), and the active background to `var(--theme-surface-subtle)` (was
34
+ > `transparent`, so dragging gave no feedback beyond the border). The private token was renamed
35
+ > from `--_dropzone-border-colour`.
36
+ >
30
37
  > Changed 2026-09-28: all of these were hardcoded or missing. DecodeQrCode's root is no longer
31
38
  > forced to `aspect-ratio: 1 / 1`.
32
39
 
@@ -38,14 +45,22 @@ Private (not public API): `--_dropzone-border-colour`.
38
45
  |---|---|---|
39
46
  | `.capture-qr-code-stopped` | Capture | Camera stopped (tab hidden, route leave), no error |
40
47
  | `.capture-qr-code-error` (`role="alert"`) | Capture | Camera error; replaceable via the `#error` slot |
41
- | `.decode-qr-code-dropzone.is-dropping` | Decode | An image is dragged over the drop zone |
48
+ | `.decode-qr-code-dropzone[data-dropping]` | Decode | An image is dragged over the drop zone |
49
+ | `.decode-qr-code-status` | Decode | No QR code found (`noResultLabel`), or with `[data-error]` the file couldn't be read (`readErrorLabel`) |
42
50
 
43
- Inner classes: Capture `.capture-qr-code-camera`, `.capture-qr-code-results`; Decode
51
+ Inner classes: Capture `.capture-qr-code-camera`, `.capture-qr-code-results`,
52
+ `.capture-qr-code-results-list`, `.capture-qr-code-result`, `.capture-qr-code-error-message`; Decode
44
53
  `.decode-qr-code-upload` (label), `.decode-qr-code-upload-label`, `.decode-qr-code-capture` (file
45
- input), `.decode-qr-code-dropzone`, `.decode-qr-code-dropzone-label`, `.decode-qr-code-results`.
54
+ input), `.decode-qr-code-dropzone`, `.decode-qr-code-dropzone-label`, `.decode-qr-code-results`,
55
+ `.decode-qr-code-results-list`, `.decode-qr-code-result`. Results, error messages and labels wrap
56
+ (`overflow-wrap: anywhere`), so a long decoded URL stays inside the component.
46
57
  Both results wrappers are polite live regions and always rendered; the `<ul>` inside appears once a
47
58
  code is decoded.
48
59
 
60
+ > Changed 2026-10-21: `.decode-qr-code-dropzone.is-dropping` is now `[data-dropping]` (a bare state
61
+ > class could collide with a consumer's own `.is-dropping`), and each result `<li>` lost its inner
62
+ > `<span>` and gained a class.
63
+ >
49
64
  > Changed 2026-09-28: renamed from `.capture-qr-stream` (root), `.camera-stopped`, `.camera-error`,
50
65
  > `.scanned-results`, `.qr-code-capture` and `.qr-code-dropzone`.
51
66