srcdev-nuxt-components 9.5.7 → 9.6.1

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 (159) hide show
  1. package/.claude/skills/component-compliance-checklist.md +8 -2
  2. package/.claude/skills/component-dynamic-slots.md +3 -3
  3. package/.claude/skills/component-export-types.md +10 -16
  4. package/.claude/skills/component-naming.md +1 -1
  5. package/.claude/skills/components/alert-content.md +9 -1
  6. package/.claude/skills/components/breadcrumb.md +5 -0
  7. package/.claude/skills/components/container-glow.md +21 -8
  8. package/.claude/skills/components/display-avatar.md +13 -7
  9. package/.claude/skills/components/display-chip.md +37 -24
  10. package/.claude/skills/components/display-pill.md +58 -8
  11. package/.claude/skills/components/display-prompt.md +8 -5
  12. package/.claude/skills/components/display-toast.md +21 -4
  13. package/.claude/skills/components/glowing-border.md +20 -2
  14. package/.claude/skills/components/input-button.md +3 -0
  15. package/.claude/skills/components/responsive-header.md +59 -0
  16. package/.claude/skills/components/select-menu.md +54 -5
  17. package/.claude/skills/components/service-detail.md +32 -0
  18. package/.claude/skills/components/service-summary.md +8 -0
  19. package/.claude/skills/components/services-card-grid.md +5 -3
  20. package/.claude/skills/components/services-card.md +22 -12
  21. package/.claude/skills/components/site-header.md +2 -2
  22. package/.claude/skills/components/slider-gallery.md +10 -4
  23. package/.claude/skills/components/tabbed-content.md +300 -0
  24. package/.claude/skills/composable-anchor-scroll.md +16 -15
  25. package/.claude/skills/index.md +4 -5
  26. package/.claude/skills/storybook-add-story.md +53 -0
  27. package/.claude/skills/theming-status-tokens.md +60 -0
  28. package/.vscode/srcdev-component-display-avatar.code-snippets +2 -1
  29. package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
  30. package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
  31. package/.vscode/srcdev-component-responsive-header.code-snippets +16 -0
  32. package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
  33. package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
  34. package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
  35. package/.vscode/srcdev-component-services-card.code-snippets +38 -36
  36. package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
  37. package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
  38. package/app/assets/styles/setup/03.theming/_status.css +24 -0
  39. package/app/assets/styles/setup/03.theming/index.css +1 -0
  40. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
  41. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
  42. package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
  43. package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
  44. package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
  45. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
  46. package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
  47. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
  48. package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
  49. package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
  50. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +78 -9
  51. package/app/components/01.atoms/display-pill/DisplayPill.vue +59 -23
  52. package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
  53. package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
  54. package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
  55. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
  56. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
  57. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
  58. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
  59. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
  60. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
  61. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
  62. package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
  63. package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
  64. package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
  65. package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
  66. package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
  67. package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
  68. package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +7 -5
  69. package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +6 -2
  70. package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +24 -3
  71. package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +19 -0
  72. package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +2 -1
  73. package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +52 -19
  74. package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
  75. package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
  76. package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
  77. package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
  78. package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
  79. package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
  80. package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
  81. package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
  82. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
  83. package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
  84. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
  85. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
  86. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
  87. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
  88. package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
  89. package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
  90. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
  91. package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
  92. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
  93. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
  94. package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
  95. package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
  96. package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
  97. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
  98. package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
  99. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +42 -11
  100. package/app/components/03.organisms/responsive-header/NavigationItems.vue +60 -16
  101. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +86 -24
  102. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +52 -0
  103. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +20 -0
  104. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +40 -0
  105. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
  106. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
  107. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
  108. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
  109. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +5 -4
  110. package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
  111. package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
  112. package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
  113. package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
  114. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +2 -2
  115. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
  116. package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
  117. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
  118. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
  119. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
  120. package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
  121. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
  122. package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
  123. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
  124. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
  125. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +6 -6
  126. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
  127. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
  128. package/app/components/05.forms/input-button/InputButton.vue +3 -1
  129. package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
  130. package/app/composables/useAnchorScroll.ts +1 -1
  131. package/app/composables/useTabs.ts +148 -206
  132. package/app/types/components/display-chip.d.ts +8 -4
  133. package/app/types/components/display-toast.d.ts +2 -0
  134. package/app/types/components/index.ts +0 -1
  135. package/app/types/components/select-menu.d.ts +2 -0
  136. package/app/utils/pausableTimeout.ts +41 -0
  137. package/nuxt.config.ts +1 -1
  138. package/package.json +1 -1
  139. package/.claude/skills/components/site-navigation.md +0 -145
  140. package/.claude/skills/components/tab-navigation.md +0 -272
  141. package/.claude/skills/components/tabs-core.md +0 -187
  142. package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
  143. package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
  144. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
  145. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
  146. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
  147. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
  148. package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
  149. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
  150. package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
  151. package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
  152. package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +0 -30
  153. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -150
  154. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -518
  155. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -340
  156. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -339
  157. package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
  158. package/app/composables/useNavCollapse.ts +0 -86
  159. package/app/types/components/nav-item.d.ts +0 -11
@@ -20,25 +20,51 @@ are documented alongside it since that's where they're consumed.
20
20
  | `--responsive-header-padding-block` / `--responsive-header-padding-inline` | `0` | Root element padding. |
21
21
  | `--responsive-header-max-height` | `none` | Root element max-height. |
22
22
  | `--responsive-header-inline-size` | `100%` | Root element inline-size. |
23
- | `--responsive-header-sub-nav-bg` / `-border` / `-border-radius` / `-padding` | `Canvas` / `1px solid #efefef75` / `8px` / `12px` | Top-bar dropdown panel (`.main-navigation-sub-nav`). |
23
+ | `--responsive-header-link-color-hover` | `--responsive-header-link-color` | Top-bar link/summary colour on the hovered item. Unset, an active item keeps its active colour. |
24
+ | `--responsive-header-link-color-active` | `--responsive-header-link-color` | Top-bar link/summary colour on the active item (current route or hash). |
25
+ | `--responsive-header-sub-nav-bg` / `-border` / `-border-radius` / `-padding` | `var(--page-bg, var(--theme-surface-subtle))` / `1px solid color-mix(in oklch, var(--theme-text) 20%, transparent)` / `8px` / `12px` | Top-bar dropdown panel (`.main-navigation-sub-nav`). |
24
26
  | `--responsive-header-sub-nav-max-inline-size` | `min(48rem, calc(100vw - 3.2rem))` | Widest the top-bar dropdown panel gets; long child names wrap inside it. |
25
27
  | `--responsive-header-sub-nav-max-block-size` | `70vh` | Tallest the top-bar dropdown panel gets before it scrolls, so a long child list stays reachable under a sticky header. |
26
- | `--responsive-header-overflow-btn-bg` / `-size` / `-border` / `-outline` / `-icon-color` / `-hover-outline` | `Canvas` / `20px` / `1px solid #ffffff90` / `1px solid #ffffff10` / `inherit` / `1px solid #ffffff` | Overflow burger button. |
27
- | `--responsive-header-overflow-nav-bg` / `-border` / `-border-radius` / `-padding-block` | `Canvas` / `1px solid #ffffff90` / `8px` / `12px` | Overflow panel container. |
28
+ | `--responsive-header-overflow-btn-bg` / `-size` / `-border` / `-outline` / `-icon-color` / `-hover-outline` | `transparent` / `20px` / `1px solid color-mix(in oklch, var(--theme-text) 20%, transparent)` / `1px solid transparent` / `inherit` / `1px solid var(--theme-border-focus)` | Overflow burger button. |
29
+ | `--responsive-header-overflow-nav-bg` / `-border` / `-border-radius` / `-padding-block` | `var(--page-bg, var(--theme-surface-subtle))` / `1px solid color-mix(in oklch, var(--theme-text) 20%, transparent)` / `8px` / `12px` | Overflow panel container. |
28
30
  | `--responsive-header-overflow-nav-max-block-size` | `70vh` | Tallest the overflow panel gets before it scrolls. |
29
- | `--overflow-nav-max-inline-size` | `calc(100vw - 3.2rem)` | Widest the overflow panel's content gets (set on `NavigationItems`); long names wrap inside it. |
31
+ | `--responsive-nav-decorator-indicator-color` | `currentColor` | Active-item indicator bar/underline. |
32
+ | `--responsive-nav-decorator-hovered-indicator-color` | inherits the above | Indicator colour while hovering. |
33
+ | `--responsive-nav-decorator-hovered-bg` | `color-mix(in oklch, var(--theme-text) 8%, transparent)` | Hover highlight background behind the hovered item. |
34
+
35
+ ### Overflow panel content (`NavigationItems`)
36
+
37
+ | Token | Default | Controls |
38
+ |---|---|---|
39
+ | `--overflow-nav-link-color` | `inherit` | Link/summary colour in the overflow panel. |
40
+ | `--overflow-nav-link-color-hover` | `--overflow-nav-link-color` | Colour on the hovered item. Unset, an active item keeps its active colour. |
41
+ | `--overflow-nav-link-color-active` | `--overflow-nav-link-color` | Colour on the active item. |
42
+ | `--overflow-nav-link-border-color` | `color-mix(in oklch, var(--theme-text) 12%, transparent)` | Divider under each row. |
43
+ | `--overflow-nav-sub-item-color` / `--overflow-nav-sub-item-font-size` | `inherit` / `inherit` | Child links inside a group. |
44
+ | `--overflow-nav-decorator-indicator-color` | `currentColor` | Active-row indicator bar. |
45
+ | `--overflow-nav-decorator-hovered-indicator-color` | inherits the above | Indicator colour while hovering. |
46
+ | `--overflow-nav-decorator-hovered-bg` | `color-mix(in oklch, var(--theme-text) 6%, transparent)` | Hover highlight behind a row. |
47
+ | `--overflow-nav-padding-inline` / `--overflow-nav-items-padding-block` / `--overflow-nav-items-gap` | `0.8rem` / `0.8rem` / `0px` | Row spacing. |
48
+ | `--overflow-nav-max-inline-size` | `calc(100vw - 3.2rem)` | Widest the overflow panel's content gets; long names wrap inside it. |
49
+
50
+ Private: `--_link-color` / `--_overflow-link-color` carry the per-state (hover/active) link colour
51
+ from the item to its link and summary. Not public API.
30
52
 
31
53
  Link and dropdown text is never line-clamped: it names where the link goes. Long unbroken names
32
54
  wrap inside the dropdown and overflow panels; in the top bar, an item too wide to fit moves to the
33
55
  overflow menu.
34
56
 
57
+ > **Changed 2026-10-07**: the defaults assumed a dark bar: `Canvas` panel and button backgrounds,
58
+ > translucent white borders (`#ffffff90`, `#efefef75`), a white button outline/hover outline and
59
+ > white-tint hover highlights, all near-invisible on a light header. They now come from the
60
+ > `--theme-*` slots and `--page-bg` (see the tables). A dark header sets these tokens. Also added
61
+ > the `-link-color-hover` / `-link-color-active` tokens (top bar and overflow panel), which
62
+ > TabNavigation had and ResponsiveHeader lacked.
63
+
35
64
  > **Changed 2026-10-05**: the dropdown and overflow panels had no maximum size, so a long child
36
- > name or a long child list ran off-screen. They are now capped by the four tokens above.
65
+ > name or a long child list ran off-screen. They are now capped by the max-size tokens above.
37
66
  > `--_overflow-drop-down-width`, a private token nothing ever set, is gone (`min-width: fit-content`
38
67
  > directly).
39
- | `--responsive-nav-decorator-indicator-color` | `currentColor` | Active-item indicator bar/underline. |
40
- | `--responsive-nav-decorator-hovered-indicator-color` | inherits the above | Indicator colour while hovering. |
41
- | `--responsive-nav-decorator-hovered-bg` | `oklch(100% 0 0 / 8%)` | Hover highlight background behind the hovered item. |
42
68
 
43
69
  ---
44
70
 
@@ -50,8 +76,9 @@ tokens on a scope class matching the one passed via `styleClassPassthrough`:
50
76
  ```css
51
77
  /* assets/styles/setup/07.components/responsive-header.css */
52
78
  .site-header-nav {
53
- --responsive-header-link-color: var(--slate-00);
54
- --responsive-header-bg: #efefef05;
79
+ --responsive-header-link-color: var(--theme-text);
80
+ --responsive-header-link-color-hover: var(--theme-accent);
81
+ --responsive-header-link-color-active: var(--theme-accent);
55
82
  --responsive-header-padding-inline: 1.2rem;
56
83
 
57
84
  /* Fixed, NOT a fluid var(--step-*) token — see the token table above. */
@@ -80,12 +107,16 @@ Set the tokens above on an element you own (a page or section class, or a class
80
107
 
81
108
  ## Notes
82
109
 
110
+ - Same-page `#anchor` items (since 2026-10-06) render as plain `<a>` with the same classes as route
111
+ links (`.main-navigation-link`, `.overflow-navigation-link`, the sub-nav link classes), so every token
112
+ and selector here applies to them. Their `li.is-active` follows the current hash rather than the route.
113
+
83
114
  - Anything with async-loaded content (an `iconName` icon on a nav item, a new decorator inside
84
115
  a nav link) must reserve its own `width`/`height` in CSS — an unsized element measures at
85
116
  `0` during the initial geometry pass and then pops in afterward with nothing to detect it.
86
117
  See `.decorator-icon`/the chevron `.icon` in the component's own `<style>` block for the
87
118
  pattern (`1.35em` square, `flex-shrink: 0`).
88
- - `NavigationItems`' own tokens (`--overflow-nav-*`) are documented in its own skill doc since
119
+ - `NavigationItems`' own tokens (`--overflow-nav-*`) have their own table above since
89
120
  they style the overflow-panel's *content*, distinct from `ResponsiveHeader`'s own tokens
90
121
  above which style the top bar and the overflow *button*/*container*.
91
122
 
@@ -34,7 +34,16 @@
34
34
  role="none"
35
35
  @mouseenter="hoveredItemKey = `${String(groupKey)}-${localIndex}`"
36
36
  >
37
- <NuxtLink class="overflow-navigation-link" :to="link.path" role="menuitem">
37
+ <a
38
+ v-if="isAnchorPath(link.path)"
39
+ class="overflow-navigation-link"
40
+ :href="link.path"
41
+ role="menuitem"
42
+ @click="emit('anchorClick', $event, link.path)"
43
+ >
44
+ <span class="overflow-navigation-text">{{ link.name }}</span>
45
+ </a>
46
+ <NuxtLink v-else class="overflow-navigation-link" :to="link.path" role="menuitem">
38
47
  <span class="overflow-navigation-text">{{ link.name }}</span>
39
48
  </NuxtLink>
40
49
  </li>
@@ -87,7 +96,16 @@
87
96
  }"
88
97
  @mouseenter="hoveredChildKey = `${String(groupKey)}-${localIndex}-${childIndex}`"
89
98
  >
90
- <NuxtLink :to="childLink.path" class="overflow-navigation-sub-nav-link" role="menuitem">
99
+ <a
100
+ v-if="childLink.path && isAnchorPath(childLink.path)"
101
+ :href="childLink.path"
102
+ class="overflow-navigation-sub-nav-link"
103
+ role="menuitem"
104
+ @click="emit('anchorClick', $event, childLink.path)"
105
+ >
106
+ <span class="overflow-navigation-sub-nav-text">{{ childLink.name }}</span>
107
+ </a>
108
+ <NuxtLink v-else :to="childLink.path" class="overflow-navigation-sub-nav-link" role="menuitem">
91
109
  <span class="overflow-navigation-sub-nav-text">{{ childLink.name }}</span>
92
110
  </NuxtLink>
93
111
  </li>
@@ -117,6 +135,8 @@ interface Props {
117
135
  ariaLabel?: string;
118
136
  /** aria-label on each submenu summary; {title} is replaced with the item's title. */
119
137
  submenuAriaLabel?: string;
138
+ /** The active "#anchor" path, owned by ResponsiveHeader so both menus agree. */
139
+ activeHash?: string;
120
140
  }
121
141
 
122
142
  const props = withDefaults(defineProps<Props>(), {
@@ -128,8 +148,13 @@ const props = withDefaults(defineProps<Props>(), {
128
148
  styleClassPassthrough: () => [],
129
149
  ariaLabel: "Overflow navigation menu",
130
150
  submenuAriaLabel: "{title} submenu",
151
+ activeHash: "",
131
152
  });
132
153
 
154
+ const emit = defineEmits<{
155
+ anchorClick: [event: MouseEvent, href: string];
156
+ }>();
157
+
133
158
  const submenuLabel = (link: ResponsiveHeaderNavItem) =>
134
159
  props.submenuAriaLabel.replace("{title}", link.childLinksTitle ?? link.name);
135
160
 
@@ -162,9 +187,13 @@ onUnmounted(() => {
162
187
 
163
188
  const route = useRoute();
164
189
 
190
+ const isAnchorPath = (path?: string) => Boolean(path?.startsWith("#"));
191
+ const isCurrentPath = (path?: string) =>
192
+ Boolean(path) && (isAnchorPath(path) ? path === props.activeHash : path === route.path);
193
+
165
194
  const isActiveNavItem = (link: ResponsiveHeaderNavItem): boolean => {
166
- if (link.path) return route.path === link.path;
167
- if (link.childLinks) return link.childLinks.some((child) => child.path && route.path === child.path);
195
+ if (link.path) return isCurrentPath(link.path);
196
+ if (link.childLinks) return link.childLinks.some((child) => isCurrentPath(child.path));
168
197
  return false;
169
198
  };
170
199
  // const DETAILS_ANIMATION_DURATION_STRING = `${DETAILS_ANIMATION_DURATION}ms` as const;
@@ -211,13 +240,15 @@ watch(
211
240
  --overflow-nav-items-padding-block (default: 0.8rem)
212
241
 
213
242
  --overflow-nav-link-color (default: inherit)
214
- --overflow-nav-link-border-color (default: #efefef75)
243
+ --overflow-nav-link-color-hover (default: --overflow-nav-link-color)
244
+ --overflow-nav-link-color-active (default: --overflow-nav-link-color)
245
+ --overflow-nav-link-border-color (default: color-mix(in oklch, var(--theme-text) 12%, transparent))
215
246
  --overflow-nav-sub-item-color (default: inherit)
216
247
  --overflow-nav-sub-item-font-size (default: inherit)
217
248
 
218
249
  --overflow-nav-decorator-indicator-color (default: currentColor)
219
250
  --overflow-nav-decorator-hovered-indicator-color (default: inherits --overflow-nav-decorator-indicator-color)
220
- --overflow-nav-decorator-hovered-bg (default: oklch(100% 0 0 / 6%))
251
+ --overflow-nav-decorator-hovered-bg (default: color-mix(in oklch, var(--theme-text) 6%, transparent))
221
252
  ──────────────────────────────────────────────────────────────────────── */
222
253
 
223
254
  --_max-inline-size: var(--overflow-nav-max-inline-size, calc(100vw - 3.2rem));
@@ -247,13 +278,26 @@ watch(
247
278
  display: block;
248
279
  }
249
280
 
281
+ /* Per-state link colour; hover falls back to the item's current state. Unset means the resting colour. */
282
+ &.is-active {
283
+ --_overflow-link-color: var(--overflow-nav-link-color-active);
284
+ }
285
+
286
+ &.is-hovered {
287
+ --_overflow-link-color: var(--overflow-nav-link-color-hover, var(--overflow-nav-link-color-active));
288
+ }
289
+
290
+ &.is-hovered:not(.is-active) {
291
+ --_overflow-link-color: var(--overflow-nav-link-color-hover);
292
+ }
293
+
250
294
  .overflow-navigation-link {
251
295
  text-decoration: none;
252
- color: var(--overflow-nav-link-color, inherit);
296
+ color: var(--_overflow-link-color, var(--overflow-nav-link-color, inherit));
253
297
  padding-block: var(--overflow-nav-items-padding-block, 0.8rem);
254
298
  padding-inline: var(--overflow-nav-padding-inline, 0.8rem);
255
299
  display: flex;
256
- border-bottom: 0.1rem solid var(--overflow-nav-link-border-color, #efefef75);
300
+ border-bottom: 0.1rem solid var(--overflow-nav-link-border-color, color-mix(in oklch, var(--theme-text) 12%, transparent));
257
301
  }
258
302
 
259
303
  .overflow-navigation-details {
@@ -271,8 +315,8 @@ watch(
271
315
  padding-block: var(--overflow-nav-items-padding-block, 0.8rem);
272
316
  padding-inline: var(--overflow-nav-padding-inline, 0.8rem);
273
317
  gap: 1rem;
274
- color: var(--overflow-nav-link-color, inherit);
275
- border-bottom: 0.1rem solid var(--overflow-nav-link-border-color, #efefef75);
318
+ color: var(--_overflow-link-color, var(--overflow-nav-link-color, inherit));
319
+ border-bottom: 0.1rem solid var(--overflow-nav-link-border-color, color-mix(in oklch, var(--theme-text) 12%, transparent));
276
320
 
277
321
  .label-wrapper {
278
322
  min-inline-size: 0;
@@ -287,7 +331,7 @@ watch(
287
331
  border-bottom: 0.1rem solid transparent;
288
332
  }
289
333
  + .expanding-panel-content {
290
- border-bottom: 0.1rem solid var(--overflow-nav-link-border-color, #efefef75);
334
+ border-bottom: 0.1rem solid var(--overflow-nav-link-border-color, color-mix(in oklch, var(--theme-text) 12%, transparent));
291
335
  .overflow-navigation-sub-nav-inner {
292
336
  margin-top: var(--overflow-nav-items-gap, 0px);
293
337
  }
@@ -332,8 +376,8 @@ watch(
332
376
  padding-block: var(--overflow-nav-items-padding-block, 0.8rem);
333
377
  padding-inline: var(--overflow-nav-padding-inline, 0.8rem);
334
378
  gap: 1rem;
335
- color: var(--overflow-nav-link-color, inherit);
336
- border-bottom: 0.1rem solid var(--overflow-nav-link-border-color, #efefef75);
379
+ color: var(--_overflow-link-color, var(--overflow-nav-link-color, inherit));
380
+ border-bottom: 0.1rem solid var(--overflow-nav-link-border-color, color-mix(in oklch, var(--theme-text) 12%, transparent));
337
381
 
338
382
  .label-wrapper {
339
383
  min-inline-size: 0;
@@ -348,7 +392,7 @@ watch(
348
392
  border-bottom: 0.1rem solid transparent;
349
393
  }
350
394
  + .expanding-panel-classic-content {
351
- border-bottom: 0.1rem solid var(--overflow-nav-link-border-color, #efefef75);
395
+ border-bottom: 0.1rem solid var(--overflow-nav-link-border-color, color-mix(in oklch, var(--theme-text) 12%, transparent));
352
396
  .overflow-navigation-sub-nav-inner {
353
397
  margin-top: var(--overflow-nav-items-gap, 0px);
354
398
  }
@@ -457,7 +501,7 @@ watch(
457
501
  right: 0;
458
502
  top: anchor(top);
459
503
  bottom: anchor(bottom);
460
- background: var(--overflow-nav-decorator-hovered-bg, oklch(100% 0 0 / 6%));
504
+ background: var(--overflow-nav-decorator-hovered-bg, color-mix(in oklch, var(--theme-text) 6%, transparent));
461
505
  z-index: 1;
462
506
  opacity: 0;
463
507
  transition:
@@ -523,7 +567,7 @@ watch(
523
567
  right: 0;
524
568
  top: anchor(top);
525
569
  bottom: anchor(bottom);
526
- background: var(--overflow-nav-decorator-hovered-bg, oklch(100% 0 0 / 6%));
570
+ background: var(--overflow-nav-decorator-hovered-bg, color-mix(in oklch, var(--theme-text) 6%, transparent));
527
571
  border-inline-start: 2px solid var(
528
572
  --overflow-nav-decorator-hovered-indicator-color,
529
573
  var(--overflow-nav-decorator-indicator-color, currentColor)
@@ -39,8 +39,18 @@
39
39
  @mouseenter="handleNavigationItemHover(`${String(groupKey)}-${localIndex}`)"
40
40
  @focusin="handleNavigationItemHover(`${String(groupKey)}-${localIndex}`)"
41
41
  >
42
+ <a
43
+ v-if="link.path && isAnchorPath(link.path)"
44
+ class="main-navigation-link"
45
+ :class="{ 'has-icon': link.iconName }"
46
+ :href="link.path"
47
+ @click="handleAnchorClick($event, link.path)"
48
+ >
49
+ <Icon v-if="link.iconName" :name="link.iconName" class="decorator-icon" aria-hidden="true" />
50
+ {{ link.name }}
51
+ </a>
42
52
  <NuxtLink
43
- v-if="link.path"
53
+ v-else-if="link.path"
44
54
  class="main-navigation-link"
45
55
  :class="{ 'has-icon': link.iconName }"
46
56
  :to="link.path"
@@ -69,7 +79,16 @@
69
79
  :key="childIndex"
70
80
  class="main-navigation-sub-nav-item"
71
81
  >
72
- <NuxtLink :to="childLink.path" class="main-navigation-sub-nav-link" role="menuitem">
82
+ <a
83
+ v-if="childLink.path && isAnchorPath(childLink.path)"
84
+ :href="childLink.path"
85
+ class="main-navigation-sub-nav-link"
86
+ role="menuitem"
87
+ @click="handleAnchorClick($event, childLink.path)"
88
+ >
89
+ {{ childLink.name }}
90
+ </a>
91
+ <NuxtLink v-else :to="childLink.path" class="main-navigation-sub-nav-link" role="menuitem">
73
92
  {{ childLink.name }}
74
93
  </NuxtLink>
75
94
  </li>
@@ -108,6 +127,8 @@
108
127
  :panel-variant="panelVariant"
109
128
  :aria-label="overflowMenuAriaLabel"
110
129
  :submenu-aria-label="submenuAriaLabel"
130
+ :active-hash="activeHash"
131
+ @anchor-click="handleAnchorClick"
111
132
  />
112
133
  </div>
113
134
  </details>
@@ -144,6 +165,11 @@ interface Props {
144
165
  overflowButtonLabel?: string;
145
166
  /** aria-label on each dropdown summary; {title} is replaced with the item's title. Forwarded to NavigationItems. */
146
167
  submenuAriaLabel?: string;
168
+ /**
169
+ * Pixels to leave above a section when a same-page "#anchor" link scrolls to it (e.g. a sticky
170
+ * header's height). Pass a getter to read it at click time.
171
+ */
172
+ anchorScrollOffset?: number | (() => number);
147
173
  }
148
174
 
149
175
  const props = withDefaults(defineProps<Props>(), {
@@ -165,6 +191,7 @@ const props = withDefaults(defineProps<Props>(), {
165
191
  overflowMenuAriaLabel: "Overflow navigation menu",
166
192
  overflowButtonLabel: "More navigation",
167
193
  submenuAriaLabel: "{title} submenu",
194
+ anchorScrollOffset: undefined,
168
195
  });
169
196
 
170
197
  const submenuLabel = (link: ResponsiveHeaderNavItem) =>
@@ -315,12 +342,32 @@ const hoveredItemKey = ref<string | null>(null);
315
342
 
316
343
  const route = useRoute();
317
344
 
345
+ // "#anchor" paths are same-page section links: plain <a>, smooth-scrolled, active by hash.
346
+ const { handleNavClick, activeHash } = useAnchorScroll({ offset: props.anchorScrollOffset });
347
+ const isAnchorPath = (path?: string) => Boolean(path?.startsWith("#"));
348
+ const isCurrentPath = (path?: string) =>
349
+ Boolean(path) && (isAnchorPath(path) ? path === activeHash.value : path === route.path);
350
+
318
351
  const isActiveNavItem = (link: ResponsiveHeaderNavItem): boolean => {
319
- if (link.path) return route.path === link.path;
320
- if (link.childLinks) return link.childLinks.some((child) => child.path && route.path === child.path);
352
+ if (link.path) return isCurrentPath(link.path);
353
+ if (link.childLinks) return link.childLinks.some((child) => isCurrentPath(child.path));
321
354
  return false;
322
355
  };
323
356
 
357
+ const handleAnchorClick = (event: MouseEvent, href: string) => {
358
+ handleNavClick(event, href);
359
+ closeAllNavigationDetails();
360
+ };
361
+
362
+ onMounted(() => {
363
+ if (activeHash.value) return;
364
+ const firstAnchor = Object.values(props.responsiveNavLinks)
365
+ .flat()
366
+ .flatMap((link) => [link, ...(link.childLinks ?? [])])
367
+ .find((link) => isAnchorPath(link.path));
368
+ if (firstAnchor?.path) activeHash.value = firstAnchor.path;
369
+ });
370
+
324
371
  const isAnimated = ref(true);
325
372
 
326
373
  // Local (non-useState) flag — resets to false on every mount.
@@ -704,30 +751,32 @@ watch(
704
751
 
705
752
  --responsive-header-color (default: inherit)
706
753
  --responsive-header-link-color (default: inherit)
754
+ --responsive-header-link-color-hover (default: --responsive-header-link-color)
755
+ --responsive-header-link-color-active (default: --responsive-header-link-color)
707
756
 
708
- --responsive-header-sub-nav-bg (default: Canvas)
709
- --responsive-header-sub-nav-border (default: 1px solid #efefef75)
757
+ --responsive-header-sub-nav-bg (default: var(--page-bg, var(--theme-surface-subtle)))
758
+ --responsive-header-sub-nav-border (default: 1px solid color-mix(in oklch, var(--theme-text) 20%, transparent))
710
759
  --responsive-header-sub-nav-border-radius (default: 8px)
711
760
  --responsive-header-sub-nav-padding (default: 12px)
712
761
  --responsive-header-sub-nav-max-inline-size (default: min(48rem, calc(100vw - 3.2rem)))
713
762
  --responsive-header-sub-nav-max-block-size (default: 70vh)
714
763
 
715
- --responsive-header-overflow-btn-bg (default: Canvas)
764
+ --responsive-header-overflow-btn-bg (default: transparent)
716
765
  --responsive-header-overflow-btn-size (default: 20px)
717
- --responsive-header-overflow-btn-border (default: 1px solid #ffffff90)
718
- --responsive-header-overflow-btn-outline (default: 1px solid #ffffff10)
766
+ --responsive-header-overflow-btn-border (default: 1px solid color-mix(in oklch, var(--theme-text) 20%, transparent))
767
+ --responsive-header-overflow-btn-outline (default: 1px solid transparent)
719
768
  --responsive-header-overflow-btn-icon-color (default: inherit)
720
- --responsive-header-overflow-btn-hover-outline (default: 1px solid #ffffff)
769
+ --responsive-header-overflow-btn-hover-outline (default: 1px solid var(--theme-border-focus))
721
770
 
722
- --responsive-header-overflow-nav-bg (default: Canvas)
723
- --responsive-header-overflow-nav-border (default: 1px solid #ffffff90)
771
+ --responsive-header-overflow-nav-bg (default: var(--page-bg, var(--theme-surface-subtle)))
772
+ --responsive-header-overflow-nav-border (default: 1px solid color-mix(in oklch, var(--theme-text) 20%, transparent))
724
773
  --responsive-header-overflow-nav-border-radius (default: 8px)
725
774
  --responsive-header-overflow-nav-padding-block (default: 12px)
726
775
  --responsive-header-overflow-nav-max-block-size (default: 70vh)
727
776
 
728
777
  --responsive-nav-decorator-indicator-color (default: currentColor)
729
778
  --responsive-nav-decorator-hovered-indicator-color (default: inherits --responsive-nav-decorator-indicator-color)
730
- --responsive-nav-decorator-hovered-bg (default: oklch(100% 0 0 / 8%))
779
+ --responsive-nav-decorator-hovered-bg (default: color-mix(in oklch, var(--theme-text) 8%, transparent))
731
780
 
732
781
  --responsive-header-link-font-size (default: inherit)
733
782
  --responsive-header-main-nav-justify-content (default: space-between)
@@ -807,7 +856,7 @@ watch(
807
856
  gap: 6px;
808
857
  white-space: nowrap;
809
858
  font-size: var(--responsive-header-link-font-size, inherit);
810
- color: var(--responsive-header-link-color, inherit);
859
+ color: var(--_link-color, var(--responsive-header-link-color, inherit));
811
860
  text-decoration: none;
812
861
  cursor: pointer;
813
862
  margin-inline-start: 0;
@@ -869,7 +918,7 @@ watch(
869
918
  position: relative;
870
919
  z-index: 4;
871
920
  font-size: var(--responsive-header-link-font-size, inherit);
872
- color: var(--responsive-header-link-color, inherit);
921
+ color: var(--_link-color, var(--responsive-header-link-color, inherit));
873
922
 
874
923
  &::-webkit-details-marker,
875
924
  &::marker {
@@ -885,9 +934,9 @@ watch(
885
934
  position: absolute;
886
935
 
887
936
  padding: var(--responsive-header-sub-nav-padding, 12px);
888
- border: var(--responsive-header-sub-nav-border, 1px solid #efefef75);
937
+ border: var(--responsive-header-sub-nav-border, 1px solid color-mix(in oklch, var(--theme-text) 20%, transparent));
889
938
  border-radius: var(--responsive-header-sub-nav-border-radius, 8px);
890
- background-color: var(--responsive-header-sub-nav-bg, Canvas);
939
+ background-color: var(--responsive-header-sub-nav-bg, var(--page-bg, var(--theme-surface-subtle)));
891
940
  translate: 0 12px;
892
941
 
893
942
  min-width: var(--_main-navigation-item-width);
@@ -933,6 +982,19 @@ watch(
933
982
  margin-inline-start: var(--_main-navigation-item-width);
934
983
  }
935
984
  }
985
+
986
+ /* Per-state link colour; hover falls back to the item's current state. Unset means the resting colour. */
987
+ &.is-active {
988
+ --_link-color: var(--responsive-header-link-color-active);
989
+ }
990
+
991
+ &.is-hovered {
992
+ --_link-color: var(--responsive-header-link-color-hover, var(--responsive-header-link-color-active));
993
+ }
994
+
995
+ &.is-hovered:not(.is-active) {
996
+ --_link-color: var(--responsive-header-link-color-hover);
997
+ }
936
998
  }
937
999
 
938
1000
  &:last-child {
@@ -1024,9 +1086,9 @@ watch(
1024
1086
 
1025
1087
  aspect-ratio: 1;
1026
1088
  border-radius: 4px;
1027
- border: var(--responsive-header-overflow-btn-border, 1px solid #ffffff90);
1028
- outline: var(--responsive-header-overflow-btn-outline, 1px solid #ffffff10);
1029
- background-color: var(--responsive-header-overflow-btn-bg, Canvas);
1089
+ border: var(--responsive-header-overflow-btn-border, 1px solid color-mix(in oklch, var(--theme-text) 20%, transparent));
1090
+ outline: var(--responsive-header-overflow-btn-outline, 1px solid transparent);
1091
+ background-color: var(--responsive-header-overflow-btn-bg, transparent);
1030
1092
 
1031
1093
  width: var(--_icon-size);
1032
1094
  overflow: hidden;
@@ -1042,7 +1104,7 @@ watch(
1042
1104
  &:hover,
1043
1105
  &:focus-visible {
1044
1106
  --_icon-zoom: 1.2;
1045
- outline: var(--responsive-header-overflow-btn-hover-outline, 1px solid #ffffff);
1107
+ outline: var(--responsive-header-overflow-btn-hover-outline, 1px solid var(--theme-border-focus));
1046
1108
  }
1047
1109
 
1048
1110
  .icon {
@@ -1068,8 +1130,8 @@ watch(
1068
1130
  position: absolute;
1069
1131
  top: 135%;
1070
1132
  right: 0;
1071
- background-color: var(--responsive-header-overflow-nav-bg, Canvas);
1072
- border: var(--responsive-header-overflow-nav-border, 1px solid #ffffff90);
1133
+ background-color: var(--responsive-header-overflow-nav-bg, var(--page-bg, var(--theme-surface-subtle)));
1134
+ border: var(--responsive-header-overflow-nav-border, 1px solid color-mix(in oklch, var(--theme-text) 20%, transparent));
1073
1135
  border-radius: var(--responsive-header-overflow-nav-border-radius, 8px);
1074
1136
  padding-block: var(--responsive-header-overflow-nav-padding-block, 12px);
1075
1137
  margin: 0;
@@ -1125,7 +1187,7 @@ watch(
1125
1187
  right: anchor(right);
1126
1188
  top: anchor(top);
1127
1189
  bottom: anchor(bottom);
1128
- background: var(--responsive-nav-decorator-hovered-bg, oklch(100% 0 0 / 8%));
1190
+ background: var(--responsive-nav-decorator-hovered-bg, color-mix(in oklch, var(--theme-text) 8%, transparent));
1129
1191
  border-radius: 4px;
1130
1192
  z-index: 1;
1131
1193
  opacity: 0;
@@ -82,6 +82,11 @@ const meta: Meta<StoryArgs> = {
82
82
  description: "aria-label on each dropdown summary; {title} is replaced with the item's title",
83
83
  table: { category: "Labels", defaultValue: { summary: "{title} submenu" } },
84
84
  },
85
+ anchorScrollOffset: {
86
+ control: { type: "number", min: 0, step: 10 },
87
+ description: "Pixels left above a section when a #anchor link scrolls to it (e.g. a sticky header's height)",
88
+ table: { category: "Behaviour", defaultValue: { summary: "0" } },
89
+ },
85
90
  styleClassPassthrough: {
86
91
  control: "object",
87
92
  description: "Extra CSS classes applied to the root element",
@@ -200,6 +205,53 @@ export const MainNavAlignment: Story = {
200
205
  },
201
206
  };
202
207
 
208
+ export const SamePageAnchorLinks: Story = {
209
+ name: "Same-Page Anchor Links",
210
+ args: {
211
+ responsiveNavLinks: {
212
+ main: [
213
+ { name: "Home", path: "#rh-home" },
214
+ { name: "About", path: "#rh-about" },
215
+ { name: "Testimonials", path: "#rh-testimonials" },
216
+ {
217
+ name: "More",
218
+ childLinks: [
219
+ { name: "Prices", path: "#rh-prices" },
220
+ { name: "Blog (a route)", path: "/blog" },
221
+ ],
222
+ },
223
+ { name: "Contact", path: "#rh-contact" },
224
+ ],
225
+ },
226
+ anchorScrollOffset: 80,
227
+ },
228
+ render: renderWith(`
229
+ <div style="position: sticky; top: 0; z-index: 2; background: var(--page-bg, white); border-block-end: 1px solid currentColor;">
230
+ <ResponsiveHeader v-bind="componentArgs" />
231
+ </div>
232
+ <section
233
+ v-for="id in ['rh-home', 'rh-about', 'rh-testimonials', 'rh-prices', 'rh-contact']"
234
+ :id="id"
235
+ :key="id"
236
+ style="min-block-size: 70vh; padding: 2.4rem; border-block-end: 1px dashed currentColor;"
237
+ >
238
+ <h2 style="margin: 0;">#{{ id }}</h2>
239
+ </section>
240
+ `),
241
+ parameters: {
242
+ docs: {
243
+ description: {
244
+ story:
245
+ "Items whose `path` starts with `#` are same-page section links: they render as plain `<a>` (not NuxtLink), " +
246
+ "smooth-scroll to the section (an instant jump under reduced motion), and are active by hash rather than by " +
247
+ "route; the first anchor is active on load. `anchorScrollOffset` (80 here) leaves room for the sticky header. " +
248
+ "Route links like Blog mix in as normal. Narrow the canvas so items move into the overflow menu: anchor links " +
249
+ "there scroll the same way and close the menu.",
250
+ },
251
+ },
252
+ },
253
+ };
254
+
203
255
  // ─── Stress test ──────────────────────────────────────────────────────────────
204
256
 
205
257
  const stressNavLinks: ResponsiveHeaderProp = {
@@ -168,4 +168,24 @@ describe("NavigationItems", () => {
168
168
  });
169
169
  expect(wrapper.html()).toMatchSnapshot();
170
170
  });
171
+
172
+ // ─── Same-page anchor links ─────────────────────────────────────────────
173
+
174
+ it("renders #anchor items as plain links, emits anchorClick and follows activeHash", async () => {
175
+ const wrapper = await mountSuspended(NavigationItems, {
176
+ props: {
177
+ activeHash: "#about",
178
+ mainNavigationState: {
179
+ clonedNavLinks: { firstNav: [{ name: "Home", path: "#home" }, { name: "About", path: "#about" }] },
180
+ navListVisibility: { firstNav: false },
181
+ hasSecondNav: false,
182
+ },
183
+ },
184
+ });
185
+ const links = wrapper.findAll(".overflow-navigation-link");
186
+ expect(links[0]!.attributes("href")).toBe("#home");
187
+ expect(wrapper.findAll(".overflow-navigation-item")[1]!.classes()).toContain("is-active");
188
+ await links[0]!.trigger("click");
189
+ expect(wrapper.emitted("anchorClick")?.[0]?.[1]).toBe("#home");
190
+ });
171
191
  });
@@ -401,4 +401,44 @@ describe("ResponsiveHeader", () => {
401
401
  });
402
402
  expect(wrapper.html()).toMatchSnapshot();
403
403
  });
404
+
405
+ // ─── Same-page anchor links ─────────────────────────────────────────────
406
+
407
+ describe("anchor links", () => {
408
+ const anchorLinks: ResponsiveHeaderProp = {
409
+ firstNav: [
410
+ { name: "Home", path: "#home" },
411
+ { name: "About", path: "#about" },
412
+ { name: "Blog", path: "/blog" },
413
+ ],
414
+ };
415
+
416
+ it("renders #anchor paths as plain links and route paths as NuxtLinks", async () => {
417
+ const wrapper = await mountSuspended(ResponsiveHeader, { props: { responsiveNavLinks: anchorLinks } });
418
+ const links = wrapper.findAll(".main-navigation-link");
419
+ expect(links[0]!.attributes("href")).toBe("#home");
420
+ expect(links[2]!.attributes("href")).toBe("/blog");
421
+ });
422
+
423
+ it("marks the first anchor item active on load", async () => {
424
+ const wrapper = await mountSuspended(ResponsiveHeader, { props: { responsiveNavLinks: anchorLinks } });
425
+ await nextTick();
426
+ const items = wrapper.findAll(".main-navigation-item");
427
+ expect(items[0]!.classes()).toContain("is-active");
428
+ expect(items[1]!.classes()).not.toContain("is-active");
429
+ });
430
+
431
+ it("moves the active item to a clicked anchor and scrolls instead of jumping", async () => {
432
+ vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: false }));
433
+ const section = document.createElement("section");
434
+ section.id = "about";
435
+ section.scrollIntoView = vi.fn();
436
+ document.body.appendChild(section);
437
+ const wrapper = await mountSuspended(ResponsiveHeader, { props: { responsiveNavLinks: anchorLinks } });
438
+ await wrapper.findAll(".main-navigation-link")[1]!.trigger("click");
439
+ expect(section.scrollIntoView).toHaveBeenCalled();
440
+ expect(wrapper.findAll(".main-navigation-item")[1]!.classes()).toContain("is-active");
441
+ section.remove();
442
+ });
443
+ });
404
444
  });