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
@@ -2,65 +2,76 @@
2
2
 
3
3
  ## Public token API
4
4
 
5
+ > Changed 2026-10-21: colour defaults now come from `--theme-*` tokens (were `--blue-10`, `white`,
6
+ > `black`, `--slate-10`), and the structural classes are prefixed (see State hooks).
7
+
8
+ ### Top-level row
9
+
5
10
  | Token | Default | Controls |
6
11
  |---|---|---|
7
- | `--deep-expanding-menu-gap` | `2.4rem` | Gap between top-level items |
8
- | `--deep-expanding-menu-link-border-width` | `0.2rem` | Bottom border width on links and group toggles |
12
+ | `--deep-expanding-menu-gap` | `2.4rem` | Gap between top-level items (row and column, the row wraps) |
13
+ | `--deep-expanding-menu-link-border-width` | `0.2rem` | Bottom border width on links, group toggles and child links |
9
14
  | `--deep-expanding-menu-link-padding-block` | `0.8rem` | Vertical padding on links and group toggles |
10
- | `--deep-expanding-menu-link-border-colour-hover` | `var(--blue-10)` | Bottom border colour on hover/focus |
15
+ | `--deep-expanding-menu-link-border-colour-hover` | `var(--theme-border)` | Bottom border colour on hover/focus |
16
+ | `--deep-expanding-menu-focus-outline-width` | `0.2rem` | Keyboard focus ring width on links and toggles |
17
+ | `--deep-expanding-menu-focus-outline-colour` | `var(--theme-ring, currentcolor)` | Keyboard focus ring colour |
11
18
  | `--deep-expanding-menu-icon-size` | `1.2rem` | Caret icon size |
12
- | `--deep-expanding-menu-panel-width` | `min(100%, 50vw)` | Popover panel width |
13
- | `--deep-expanding-menu-panel-background-colour` | `white` | Popover panel background |
14
- | `--deep-expanding-menu-panel-border-width` | `0.1rem` | Popover panel border width |
15
- | `--deep-expanding-menu-panel-border-colour` | `black` | Popover panel border colour |
16
- | `--deep-expanding-menu-panel-border-radius` | `1.2rem` | Popover panel corner radius |
17
- | `--deep-expanding-menu-panel-shadow` | `0 0 1rem rgba(0, 0, 0, 0.1)` | Popover panel box-shadow |
18
- | `--deep-expanding-menu-panel-padding` | `1.2rem` | Popover panel padding |
19
- | `--deep-expanding-menu-panel-heading-colour` | `var(--slate-10)` | `childLinksTitle` heading colour inside the panel |
20
- | `--deep-expanding-menu-panel-list-gap` | `1.2rem` | Gap between child link grid items |
21
- | `--deep-expanding-menu-group-link-colour` | `var(--slate-10)` | Child link text colour |
22
- | `--deep-expanding-menu-group-link-border-colour-hover` | `var(--slate-10)` | Child link bottom border colour on hover/focus |
23
- | `--deep-expanding-menu-panel-z-index` | `999999` | Panel stacking order in browsers without CSS anchor positioning. Ignored where the panel renders in the top layer |
24
19
 
25
- ```css
26
- .my-page {
27
- --deep-expanding-menu-panel-background-colour: #1a1a1a;
28
- --deep-expanding-menu-panel-border-colour: transparent;
29
- --deep-expanding-menu-group-link-colour: white;
30
- }
31
- ```
20
+ ### Panel
32
21
 
33
- Or scope to a single instance via `styleClassPassthrough`:
34
-
35
- ```vue
36
- <DeepExpandingMenu style-class-passthrough="main-nav">...</DeepExpandingMenu>
37
- ```
38
-
39
- ---
40
-
41
- ## Not tokenised
22
+ | Token | Default | Controls |
23
+ |---|---|---|
24
+ | `--deep-expanding-menu-panel-width` | `min(100%, 50vw)` | Panel width |
25
+ | `--deep-expanding-menu-panel-max-height` | `70dvh` | Panel max height; longer lists scroll inside it |
26
+ | `--deep-expanding-menu-panel-text-colour` | `var(--theme-text)` | Panel text colour |
27
+ | `--deep-expanding-menu-panel-background-colour` | `var(--theme-surface-subtle)` | Panel background |
28
+ | `--deep-expanding-menu-panel-border-width` | `0.1rem` | Panel border width |
29
+ | `--deep-expanding-menu-panel-border-colour` | `var(--theme-border)` | Panel border colour |
30
+ | `--deep-expanding-menu-panel-border-radius` | `1.2rem` | Panel corner radius |
31
+ | `--deep-expanding-menu-panel-shadow` | `0 0 1rem rgba(0, 0, 0, 0.1)` | Panel box-shadow |
32
+ | `--deep-expanding-menu-panel-padding` | `1.2rem` | Panel padding |
33
+ | `--deep-expanding-menu-panel-heading-colour` | `var(--theme-text)` | `childLinksTitle` heading colour |
34
+ | `--deep-expanding-menu-panel-heading-line-clamp` | `none` | Lines of `childLinksTitle` before an ellipsis. `1` is single-line ellipsis, `none` shows everything |
35
+ | `--deep-expanding-menu-panel-list-gap` | `1.2rem` | Gap between child link grid items |
36
+ | `--deep-expanding-menu-group-link-colour` | `var(--theme-text)` | Child link text colour |
37
+ | `--deep-expanding-menu-group-link-border-colour-hover` | `var(--theme-text)` | Child link bottom border colour on hover/focus |
38
+ | `--deep-expanding-menu-panel-z-index` | `999999` | Panel stacking order in browsers without CSS anchor positioning. Ignored where the panel renders in the top layer |
42
39
 
43
- - The `1rem` gap between a toggle and its panel.
44
- - Anchor names and popover target ids are generated per-instance via `useId()` and are not
45
- consumer-configurable — they only need to be unique, not meaningful.
40
+ Private tokens (not public API): `--_anchor-name` (per-group anchor, set inline),
41
+ `--_icon-transform` (caret flip), and `--_anchor-bottom`, `--_anchor-left`,
42
+ `--_anchor-top-inverse` (written by `useAnchoredPopover` in the fallback).
46
43
 
47
- ---
44
+ Not tokenised: the `1rem` gap between a toggle and its panel, and the `12rem` minimum child link
45
+ column. Anchor names and popover target ids come from `useId()`.
48
46
 
49
- ## Open state and older browsers
47
+ ## State hooks
50
48
 
51
49
  | Hook | Meaning |
52
50
  |---|---|
53
- | `.navigation-group-toggle[aria-expanded="true"]` | Group open, every browser (flips the caret) |
54
- | `.navigation-group-panel:popover-open` | Panel open, Popover API browsers |
51
+ | `.deep-expanding-menu-group-toggle[aria-expanded="true"]` | Group open, every browser (flips the caret) |
52
+ | `.deep-expanding-menu-group-panel:popover-open` | Panel open, Popover API browsers |
55
53
  | `.deep-expanding-menu-panel-open` | Panel open, browsers without the Popover API (Safari 16 and older). Style it in a **separate rule** from `:popover-open`: a selector list containing `:popover-open` is dropped whole where it's unsupported |
56
- | `.navigation-group-panel[data-placement="top"]` | Flipped above the toggle, browsers without CSS anchor positioning only |
54
+ | `.deep-expanding-menu-group-panel[data-placement="top"]` | Flipped above the toggle, browsers without CSS anchor positioning only |
55
+
56
+ Inner elements: `.deep-expanding-menu-inner` (the flex row), `.deep-expanding-menu-link` (direct
57
+ link), `.deep-expanding-menu-group`, `.deep-expanding-menu-group-toggle`,
58
+ `.deep-expanding-menu-group-label`, `.deep-expanding-menu-group-icon`,
59
+ `.deep-expanding-menu-group-panel`, `.deep-expanding-menu-group-heading`,
60
+ `.deep-expanding-menu-group-list`, `.deep-expanding-menu-group-item`,
61
+ `.deep-expanding-menu-group-link`.
57
62
 
58
- Without CSS anchor positioning (e.g. Safari 17–18) the panel is placed with `position: fixed` from
59
- the toggle's measured position. Without the Popover API (Safari 16) the panel also opens and closes
60
- in JS and isn't in the top layer, so `--deep-expanding-menu-panel-z-index` applies, and an ancestor
61
- with `transform`, `filter` or `contain` becomes its containing block and can misplace it.
63
+ > Changed 2026-10-21: these were `.inner`, `.navigation-link`, `.navigation-group`,
64
+ > `.navigation-group-toggle`, `.icon`, `.navigation-group-panel`, `.navigation-group-list`,
65
+ > `.navigation-group-item` and `.navigation-group-link`. The heading had no class of its own.
62
66
 
63
- ---
67
+ ## Browser support and placement
68
+
69
+ Where CSS anchor positioning is supported, a panel that would overflow the right edge flips to
70
+ align with its toggle's right edge (`position-try-fallbacks: flip-inline`). Without anchor
71
+ positioning (e.g. Safari 17–18) the panel is placed with `position: fixed` from the toggle's
72
+ measured position. Without the Popover API (Safari 16) the panel also opens and closes in JS and
73
+ isn't in the top layer, so `--deep-expanding-menu-panel-z-index` applies, and an ancestor with
74
+ `transform`, `filter` or `contain` becomes its containing block and can misplace it.
64
75
 
65
76
  ## Local overrides
66
77
 
@@ -69,3 +80,11 @@ Set the tokens above on an element you own (a page or section class, or a class
69
80
  `@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
70
81
  `<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
71
82
  `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
83
+
84
+ **Caveat:** in browsers with the Popover API the panel renders in the top layer but stays a DOM
85
+ descendant of the menu, so tokens set on an ancestor still reach it.
86
+
87
+ ## Class passthrough
88
+
89
+ `style-class-passthrough` adds classes to the root element (the `<nav>` by default). It's the
90
+ usual place to scope token overrides to one instance.
@@ -1,26 +1,26 @@
1
1
  <template>
2
- <component :is="tag" ref="rootRef" class="deep-expanding-menu" :class="[elementClasses]">
3
- <div class="inner">
2
+ <component :is="tag" ref="rootRef" class="deep-expanding-menu" :class="[elementClasses]" :aria-label="ariaLabel">
3
+ <div class="deep-expanding-menu-inner">
4
4
  <template v-for="(link, key) in navLinks" :key="key">
5
- <NuxtLink v-if="link.path" :to="link.path" class="navigation-link">{{ link.name }}</NuxtLink>
5
+ <NuxtLink v-if="link.path" :to="link.path" class="deep-expanding-menu-link">{{ link.name }}</NuxtLink>
6
6
 
7
- <div v-else class="navigation-group" :style="`--_anchor-name: --anchor-${uid}-${key};`">
7
+ <div v-else class="deep-expanding-menu-group" :style="`--_anchor-name: --anchor-${uid}-${key};`">
8
8
  <button
9
9
  :ref="(el) => setElement(triggerEls, key, el)"
10
10
  type="button"
11
11
  :popovertarget="`popovertarget-${uid}-${key}`"
12
- class="navigation-group-toggle"
12
+ class="deep-expanding-menu-group-toggle"
13
13
  :aria-expanded="isOpen && activeKey === key"
14
14
  @click="handleGroupClick(key)"
15
15
  >
16
- <span>{{ link.name }}</span>
17
- <Icon name="bi:caret-down-fill" class="icon" />
16
+ <span class="deep-expanding-menu-group-label">{{ link.name }}</span>
17
+ <Icon name="bi:caret-down-fill" class="deep-expanding-menu-group-icon" aria-hidden="true" />
18
18
  </button>
19
19
 
20
20
  <div
21
21
  :id="`popovertarget-${uid}-${key}`"
22
22
  :ref="(el) => setElement(panelEls, key, el)"
23
- class="navigation-group-panel"
23
+ class="deep-expanding-menu-group-panel"
24
24
  :class="{ 'deep-expanding-menu-panel-open': usesFallbackPopover && isOpen && activeKey === key }"
25
25
  :style="activeKey === key ? positionStyle : undefined"
26
26
  :data-placement="activeKey === key ? popoverPlacement : undefined"
@@ -28,10 +28,16 @@
28
28
  @beforetoggle="handlePanelBeforeToggle(key, $event)"
29
29
  @toggle="handlePanelToggle(key, $event)"
30
30
  >
31
- <h4 class="page-heading-4 mb-6">{{ link.childLinksTitle }}</h4>
32
- <ul class="navigation-group-list">
33
- <li v-for="childLink in link.childLinks" :key="childLink.name" class="navigation-group-item">
34
- <NuxtLink :to="childLink.path" class="navigation-group-link">{{ childLink.name }}</NuxtLink>
31
+ <h4 v-if="link.childLinksTitle" class="deep-expanding-menu-group-heading page-heading-4 mb-6">
32
+ {{ link.childLinksTitle }}
33
+ </h4>
34
+ <ul v-if="link.childLinks?.length" class="deep-expanding-menu-group-list" role="list">
35
+ <li
36
+ v-for="(childLink, childIndex) in link.childLinks"
37
+ :key="`${childIndex}-${childLink.name}`"
38
+ class="deep-expanding-menu-group-item"
39
+ >
40
+ <NuxtLink :to="childLink.path" class="deep-expanding-menu-group-link">{{ childLink.name }}</NuxtLink>
35
41
  </li>
36
42
  </ul>
37
43
  </div>
@@ -41,19 +47,22 @@
41
47
  </component>
42
48
  </template>
43
49
 
50
+
44
51
  <script setup lang="ts">
45
52
  import type { ComponentPublicInstance } from "vue";
46
53
  import type { ResponsiveHeaderNavItem } from "~/types/components";
47
54
 
48
55
  interface Props {
49
- tag?: "div" | "section" | "nav" | "ul" | "ol";
56
+ tag?: "div" | "section" | "nav";
50
57
  navLinks?: ResponsiveHeaderNavItem[];
58
+ ariaLabel?: string;
51
59
  styleClassPassthrough?: string | string[];
52
60
  }
53
61
 
54
62
  const props = withDefaults(defineProps<Props>(), {
55
63
  tag: "nav",
56
64
  navLinks: () => [],
65
+ ariaLabel: undefined,
57
66
  styleClassPassthrough: () => [],
58
67
  });
59
68
 
@@ -126,15 +135,19 @@ watch(
126
135
  display: grid;
127
136
  grid-template-areas: "element-stack";
128
137
 
129
- .inner {
138
+ .deep-expanding-menu-inner {
130
139
  grid-area: element-stack;
131
140
  display: flex;
141
+ flex-wrap: wrap;
132
142
  gap: var(--deep-expanding-menu-gap, 2.4rem);
133
143
  align-items: center;
144
+ min-inline-size: 0;
134
145
 
135
- .navigation-link,
136
- .navigation-group-toggle {
146
+ .deep-expanding-menu-link,
147
+ .deep-expanding-menu-group-toggle {
137
148
  all: unset;
149
+ min-inline-size: 0;
150
+ overflow-wrap: anywhere;
138
151
  border-bottom: var(--deep-expanding-menu-link-border-width, 0.2rem) solid transparent;
139
152
  padding-block: var(--deep-expanding-menu-link-padding-block, 0.8rem);
140
153
 
@@ -144,26 +157,35 @@ watch(
144
157
  &:focus,
145
158
  &:focus-visible {
146
159
  cursor: pointer;
147
- border-color: var(
148
- --deep-expanding-menu-link-border-colour-hover,
149
- var(--blue-10)
150
- );
160
+ border-color: var(--deep-expanding-menu-link-border-colour-hover, var(--theme-border));
161
+ }
162
+
163
+ &:focus-visible {
164
+ outline: var(--deep-expanding-menu-focus-outline-width, 0.2rem) solid
165
+ var(--deep-expanding-menu-focus-outline-colour, var(--theme-ring, currentcolor));
166
+ outline-offset: 0.2rem;
151
167
  }
152
168
  }
153
169
 
154
- .navigation-group {
170
+ .deep-expanding-menu-group {
155
171
  --_icon-transform: scaleY(1);
156
172
  position: relative;
173
+ min-inline-size: 0;
157
174
 
158
- .navigation-group-toggle {
175
+ .deep-expanding-menu-group-toggle {
159
176
  anchor-name: var(--_anchor-name);
160
177
 
161
178
  display: flex;
162
179
  align-items: center;
163
180
  gap: 1.2rem;
164
181
 
165
- .icon {
182
+ .deep-expanding-menu-group-label {
183
+ min-inline-size: 0;
184
+ }
185
+
186
+ .deep-expanding-menu-group-icon {
166
187
  display: block;
188
+ flex-shrink: 0;
167
189
  font-size: var(--deep-expanding-menu-icon-size, 1.2rem);
168
190
 
169
191
  transform: var(--_icon-transform);
@@ -171,10 +193,11 @@ watch(
171
193
  }
172
194
  }
173
195
 
174
- .navigation-group-panel {
196
+ .deep-expanding-menu-group-panel {
175
197
  display: none;
176
198
  position: absolute;
177
199
  position-anchor: var(--_anchor-name);
200
+ position-try-fallbacks: flip-inline;
178
201
  margin: 0;
179
202
  top: calc(anchor(bottom) + 1rem);
180
203
  left: calc(anchor(left) + 0rem);
@@ -187,14 +210,17 @@ watch(
187
210
  transition-behavior: allow-discrete;
188
211
 
189
212
  width: var(--deep-expanding-menu-panel-width, min(100%, 50vw));
213
+ max-block-size: var(--deep-expanding-menu-panel-max-height, 70dvh);
190
214
 
191
- background-color: var(--deep-expanding-menu-panel-background-colour, white);
215
+ color: var(--deep-expanding-menu-panel-text-colour, var(--theme-text));
216
+ background-color: var(--deep-expanding-menu-panel-background-colour, var(--theme-surface-subtle));
192
217
  border: var(--deep-expanding-menu-panel-border-width, 0.1rem) solid
193
- var(--deep-expanding-menu-panel-border-colour, black);
218
+ var(--deep-expanding-menu-panel-border-colour, var(--theme-border));
194
219
  border-radius: var(--deep-expanding-menu-panel-border-radius, 1.2rem);
195
220
  box-shadow: var(--deep-expanding-menu-panel-shadow, 0 0 1rem rgba(0, 0, 0, 0.1));
196
221
  padding: var(--deep-expanding-menu-panel-padding, 1.2rem);
197
- overflow: clip;
222
+ overflow-x: clip;
223
+ overflow-y: auto;
198
224
 
199
225
  &:popover-open {
200
226
  display: block;
@@ -224,23 +250,33 @@ watch(
224
250
  }
225
251
  }
226
252
 
227
- h4 {
228
- color: var(--deep-expanding-menu-panel-heading-colour, var(--slate-10));
253
+ .deep-expanding-menu-group-heading {
254
+ color: var(--deep-expanding-menu-panel-heading-colour, var(--theme-text));
255
+ overflow-wrap: anywhere;
256
+ display: -webkit-box;
257
+ -webkit-box-orient: vertical;
258
+ overflow: hidden;
259
+ -webkit-line-clamp: var(--deep-expanding-menu-panel-heading-line-clamp, none);
260
+ line-clamp: var(--deep-expanding-menu-panel-heading-line-clamp, none);
229
261
  }
230
262
 
231
- .navigation-group-list {
263
+ .deep-expanding-menu-group-list {
232
264
  display: grid;
233
- grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
265
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 12rem), 1fr));
234
266
  gap: var(--deep-expanding-menu-panel-list-gap, 1.2rem);
267
+ list-style: none;
235
268
  padding-inline-start: 0;
236
- margin-block-end: 0.8rem;
269
+ margin-block: 0 0.8rem;
237
270
 
238
- .navigation-group-item {
271
+ .deep-expanding-menu-group-item {
239
272
  display: block;
273
+ min-inline-size: 0;
240
274
 
241
- a.navigation-group-link {
275
+ a.deep-expanding-menu-group-link {
242
276
  display: inline-block;
243
- color: var(--deep-expanding-menu-group-link-colour, var(--slate-10));
277
+ max-inline-size: 100%;
278
+ overflow-wrap: anywhere;
279
+ color: var(--deep-expanding-menu-group-link-colour, var(--theme-text));
244
280
  text-decoration: none;
245
281
  padding-block: 0.8rem;
246
282
 
@@ -251,14 +287,14 @@ watch(
251
287
  &:hover,
252
288
  &:focus-visible {
253
289
  cursor: pointer;
254
- border-color: var(--deep-expanding-menu-group-link-border-colour-hover, var(--slate-10));
290
+ border-color: var(--deep-expanding-menu-group-link-border-colour-hover, var(--theme-text));
255
291
  }
256
292
  }
257
293
  }
258
294
  }
259
295
  }
260
296
 
261
- &:has(> .navigation-group-toggle[aria-expanded="true"]) {
297
+ &:has(> .deep-expanding-menu-group-toggle[aria-expanded="true"]) {
262
298
  --_icon-transform: scaleY(-1);
263
299
  }
264
300
  }
@@ -267,3 +303,4 @@ watch(
267
303
  }
268
304
  }
269
305
  </style>
306
+
@@ -1,6 +1,8 @@
1
+ import { computed, ref } from "vue";
1
2
  import DeepExpandingMenu from "../DeepExpandingMenu.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 DeepExpandingMenu> = {
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 DeepExpandingMenu prop. See the argTypes entry.
57
+ type StoryArgs = InstanceType<typeof DeepExpandingMenu>["$props"] & {
58
+ headingLineClamp: LineClamp;
59
+ };
60
+
61
+ const meta: Meta<StoryArgs> = {
20
62
  title: "Molecules/DeepExpandingMenu",
21
63
  component: DeepExpandingMenu,
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-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-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
- "Modern implementation of a top-level nav with expanding child-link panels, using CSS anchor-positioning and the Popover API. See `DeepExpandingMenuClassic` for a `<details>`-based fallback with wider browser support.",
119
+ "Top-level nav row where each item is a direct link (`path`) or a toggle that opens a panel of child links (`childLinks`, with an optional `childLinksTitle` heading). " +
120
+ "Built on CSS anchor positioning and the Popover API, with a JS fallback for Safari 16-18. Props: `navLinks`, `tag`, `ariaLabel`. " +
121
+ "See `DeepExpandingMenuClassic` for the `<details>`-based alternative.",
41
122
  },
42
123
  },
43
124
  },
44
125
  };
45
126
 
46
127
  export default meta;
47
- type Story = StoryObj<typeof DeepExpandingMenu>;
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-panel-heading-line-clamp": args.headingLineClamp,
137
+ }));
138
+ return { componentArgs, tokenStyles };
139
+ }
140
+
141
+ const render = (args: StoryArgs) => ({
142
+ components: { DeepExpandingMenu },
143
+ setup() {
144
+ return useStorySetup(args);
145
+ },
146
+ template: `<div :style="tokenStyles"><DeepExpandingMenu v-bind="componentArgs" /></div>`,
147
+ });
48
148
 
49
149
  export const Default: Story = {
50
- render: (args) => ({
51
- components: { DeepExpandingMenu },
52
- setup() {
53
- return { args };
54
- },
55
- template: `<DeepExpandingMenu 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: { DeepExpandingMenu },
62
157
  setup() {
63
- return { args };
158
+ return useStorySetup(args);
64
159
  },
65
160
  template: `
66
- <DeepExpandingMenu
67
- v-bind="args"
68
- style="padding: 4rem; --deep-expanding-menu-panel-background-colour: #1a1a1a; --deep-expanding-menu-panel-border-colour: transparent; --deep-expanding-menu-group-link-colour: white; --deep-expanding-menu-panel-heading-colour: #aaaaaa;"
69
- />
161
+ <div
162
+ :style="tokenStyles"
163
+ style="--deep-expanding-menu-panel-background-colour: #1a1a1a; --deep-expanding-menu-panel-border-colour: transparent; --deep-expanding-menu-group-link-colour: white; --deep-expanding-menu-group-link-border-colour-hover: white; --deep-expanding-menu-panel-heading-colour: #aaaaaa;"
164
+ >
165
+ <DeepExpandingMenu 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-*` custom properties.",
172
+ story: "Overriding the public `--deep-expanding-menu-*` 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 scroll 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; the last group's panel flips to stay on screen near the right edge.",
76
196
  },
77
197
  },
78
198
  },