srcdev-nuxt-components 9.5.5 → 9.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.claude/skills/component-compliance-checklist.md +6 -2
  2. package/.claude/skills/components/action-menu.md +21 -15
  3. package/.claude/skills/components/deep-expanding-menu.md +18 -6
  4. package/.claude/skills/components/display-tooltip-defined.md +2 -1
  5. package/.claude/skills/components/display-tooltip.md +10 -4
  6. package/.claude/skills/components/navigation-items.md +1 -0
  7. package/.claude/skills/components/opening-hours.md +12 -0
  8. package/.claude/skills/components/pop-over.md +11 -8
  9. package/.claude/skills/components/price-list.md +10 -1
  10. package/.claude/skills/components/responsive-header.md +25 -0
  11. package/.claude/skills/components/select-menu.md +18 -8
  12. package/.claude/skills/components/site-header.md +2 -0
  13. package/.claude/skills/components/ui-block-decorated.md +7 -0
  14. package/.claude/skills/composable-anchored-popover.md +108 -0
  15. package/.claude/skills/composable-tooltips-guide.md +3 -1
  16. package/.claude/skills/index.md +1 -0
  17. package/.claude/skills/storybook-add-story.md +14 -0
  18. package/.vscode/srcdev-component-responsive-header.code-snippets +3 -1
  19. package/.vscode/srcdev-component-site-header.code-snippets +3 -1
  20. package/app/assets/styles/setup/06.utility-classes/animations/_animation-scroller-x.css +5 -3
  21. package/app/assets/styles/setup/06.utility-classes/animations/_auto-rotate.css +5 -3
  22. package/app/assets/styles/setup/06.utility-classes/animations/_entry-exit-blur.css +5 -3
  23. package/app/assets/styles/setup/06.utility-classes/animations/_entry-slide-in.css +5 -3
  24. package/app/assets/styles/setup/06.utility-classes/animations/_entry-zoom-reveal.css +5 -3
  25. package/app/components/01.atoms/animations/entry/CONSUMER-STYLING.md +6 -2
  26. package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +15 -3
  27. package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +62 -3
  28. package/app/components/01.atoms/display-tooltip/tests/DisplayTooltip.spec.ts +36 -1
  29. package/app/components/01.atoms/display-tooltip/tests/__snapshots__/DisplayTooltip.spec.ts.snap +1 -1
  30. package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +1 -1
  31. package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +9 -3
  32. package/app/components/01.atoms/pop-over/PopOver.vue +70 -9
  33. package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +53 -0
  34. package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +1 -1
  35. package/app/components/01.atoms/ui-block-decorated/CONSUMER-STYLING.md +12 -4
  36. package/app/components/01.atoms/ui-block-decorated/UiBlockDecorated.vue +20 -16
  37. package/app/components/01.atoms/ui-block-decorated/stories/UiBlockDecorated.stories.ts +124 -96
  38. package/app/components/02.molecules/action-menu/ActionMenu.vue +41 -16
  39. package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +16 -3
  40. package/app/components/02.molecules/action-menu/tests/ActionMenu.spec.ts +28 -0
  41. package/app/components/02.molecules/action-menu/tests/__snapshots__/ActionMenu.spec.ts.snap +3 -3
  42. package/app/components/02.molecules/display-tooltip-defined/DisplayTooltipDefined.vue +2 -1
  43. package/app/components/02.molecules/display-tooltip-defined/stories/DisplayTooltipDefined.stories.ts +7 -4
  44. package/app/components/02.molecules/display-tooltip-defined/tests/DisplayTooltipDefined.spec.ts +20 -1
  45. package/app/components/02.molecules/display-tooltip-defined/tests/__snapshots__/DisplayTooltipDefined.spec.ts.snap +1 -1
  46. package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +18 -4
  47. package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +87 -16
  48. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +83 -1
  49. package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +2 -2
  50. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +1 -1
  51. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +1 -1
  52. package/app/components/02.molecules/opening-hours/CONSUMER-STYLING.md +11 -1
  53. package/app/components/02.molecules/opening-hours/OpeningHours.vue +71 -43
  54. package/app/components/02.molecules/opening-hours/stories/OpeningHours.stories.ts +138 -6
  55. package/app/components/02.molecules/opening-hours/tests/OpeningHours.spec.ts +53 -0
  56. package/app/components/02.molecules/opening-hours/tests/__snapshots__/OpeningHours.spec.ts.snap +1 -1
  57. package/app/components/02.molecules/opening-hours/tests/buildOpeningHoursSpecification.spec.ts +37 -1
  58. package/app/components/02.molecules/price-list/CONSUMER-STYLING.md +17 -5
  59. package/app/components/02.molecules/price-list/PriceList.vue +32 -9
  60. package/app/components/02.molecules/price-list/stories/PriceList.stories.ts +125 -29
  61. package/app/components/02.molecules/price-list/tests/PriceList.spec.ts +20 -0
  62. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +17 -5
  63. package/app/components/02.molecules/select-menu/SelectMenu.vue +51 -16
  64. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +36 -0
  65. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +9 -4
  66. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +14 -0
  67. package/app/components/03.organisms/responsive-header/NavigationItems.vue +23 -14
  68. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +58 -30
  69. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +149 -18
  70. package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +21 -0
  71. package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +66 -0
  72. package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +30 -0
  73. package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +1 -0
  74. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +35 -0
  75. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +23 -0
  76. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +1 -0
  77. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +71 -0
  78. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +386 -0
  79. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +11 -0
  80. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +79 -0
  81. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +17 -0
  82. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +64 -0
  83. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +29 -0
  84. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +32 -0
  85. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +10 -0
  86. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
  87. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +16 -0
  88. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +91 -6
  89. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +9 -9
  90. package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +1 -0
  91. package/app/components/03.organisms/site-header/SiteHeader.vue +8 -0
  92. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +1 -1
  93. package/app/composables/tests/useAnchoredPopover.spec.ts +231 -0
  94. package/app/composables/tests/useTooltips.spec.ts +69 -0
  95. package/app/composables/useAnchoredPopover.ts +183 -0
  96. package/app/composables/useTooltips.ts +37 -20
  97. package/app/utils/opening-hours.ts +23 -2
  98. package/package.json +1 -1
@@ -37,6 +37,7 @@ global token so it matches the rest of the design system out of the box.
37
37
  | `--select-menu-popover-min-width` | `18rem` | Minimum popover width |
38
38
  | `--select-menu-popover-max-height` | `32rem` | Maximum popover height before scrolling |
39
39
  | `--select-menu-popover-shadow` | `0 0.4rem 1.6rem rgb(0 0 0 / 12%)` | Popover drop shadow |
40
+ | `--select-menu-popover-z-index` | `999999` | Stacking order in browsers without CSS anchor positioning (see Notes). Ignored where the popover renders in the top layer |
40
41
  | `--select-menu-transition-duration` | `200ms` | Open/close fade and chevron-rotate duration |
41
42
 
42
43
  ### Options
@@ -69,7 +70,13 @@ global token so it matches the rest of the design system out of the box.
69
70
  | Hook | Where | Meaning |
70
71
  |---|---|---|
71
72
  | `.normal` / `.underlined` | `.select-menu` root | The `inputVariant` prop. `underlined` removes the trigger's top and side borders and its corner radius, matching `InputSelect`'s underlined variant. |
72
- | `:has(.select-menu-popover:popover-open)` | `.select-menu` root | Menu open (rotates the chevron). |
73
+ | `.select-menu-trigger[aria-expanded="true"]` | trigger | Menu open (rotates the chevron). Works with and without the Popover API. |
74
+ | `.select-menu-popover:popover-open` | popover | Menu open, Popover API browsers. |
75
+ | `.select-menu-popover-open` | popover | Menu open, browsers without the Popover API (Safari 16 and older). Style both open hooks in **separate rules**: a selector list containing `:popover-open` is dropped whole where it's unsupported. |
76
+ | `[data-placement="top"]` | popover | Flipped above the trigger, browsers without CSS anchor positioning only. |
77
+
78
+ > Changed 2026-10-05: the chevron hook moved from `:has(.select-menu-popover:popover-open)` on the
79
+ > root to the trigger's `aria-expanded`, so it also works without the Popover API.
73
80
 
74
81
  ---
75
82
 
@@ -186,10 +193,15 @@ story.
186
193
 
187
194
  ## Notes
188
195
 
189
- - **Popover API + CSS anchor positioning** — same mechanism as `ActionMenu`. Broadly supported
190
- (Chrome 114+, Firefox 125+, Safari 17+). No polyfill is included.
196
+ - **Popover API + CSS anchor positioning, with fallbacks** — same mechanism as `ActionMenu`, via the
197
+ shared `useAnchoredPopover` composable. Without anchor positioning (e.g. Safari 17–18), the
198
+ popover is placed with `position: fixed` from the trigger's measured position, re-measured on
199
+ scroll and resize. Without the Popover API (Safari 16 and older), the menu also opens and
200
+ closes in JS, with outside-click and Escape dismissal. In that mode it isn't in the top layer, so
201
+ `--select-menu-popover-z-index` matters, and an ancestor with `transform`, `filter` or
202
+ `contain` becomes its containing block and can misplace it.
191
203
  - **Popover left-aligns with the trigger** by default (`left: anchor(left)`), unlike `ActionMenu`
192
204
  which right-aligns — matches native `<select>` dropdown behaviour. Flips above the trigger near
193
205
  the bottom of the viewport (`position-try-fallbacks: flip-block`).
194
- - **Chevron rotation** is pure CSS via `:has(.select-menu-popover:popover-open)` — no JS state
195
- needed for the visual, though `isOpen` is still tracked internally to drive `aria-expanded`.
206
+ - **Chevron rotation** is driven by the trigger's `aria-expanded`, which tracks the open state in
207
+ every browser.
@@ -1,5 +1,10 @@
1
1
  <template>
2
- <div class="select-menu" :class="[inputVariant, elementClasses]" :style="`--_anchor-name: ${anchorName}`">
2
+ <div
3
+ ref="rootRef"
4
+ class="select-menu"
5
+ :class="[inputVariant, elementClasses]"
6
+ :style="`--_anchor-name: ${anchorName}`"
7
+ >
3
8
  <button
4
9
  ref="triggerRef"
5
10
  :popovertarget="menuId"
@@ -9,6 +14,7 @@
9
14
  :aria-label="label"
10
15
  aria-haspopup="listbox"
11
16
  :aria-expanded="isOpen"
17
+ @click="handleTriggerClick"
12
18
  >
13
19
  <Icon
14
20
  v-if="showIcon && !multiple && selectedOption?.icon"
@@ -30,6 +36,10 @@
30
36
  ref="popoverRef"
31
37
  popover
32
38
  class="select-menu-popover"
39
+ :class="{ 'select-menu-popover-open': usesFallbackPopover && isOpen }"
40
+ :style="positionStyle"
41
+ :data-placement="popoverPlacement"
42
+ @beforetoggle="handleBeforeToggle"
33
43
  @toggle="handleToggle"
34
44
  @keydown="handleKeydown"
35
45
  >
@@ -103,9 +113,9 @@ const id = useId();
103
113
  const menuId = `select-menu-${id}`;
104
114
  const anchorName = `--select-menu-anchor-${id}`;
105
115
 
116
+ const rootRef = ref<HTMLDivElement | null>(null);
106
117
  const triggerRef = ref<HTMLButtonElement | null>(null);
107
118
  const popoverRef = ref<HTMLDivElement | null>(null);
108
- const isOpen = ref(false);
109
119
 
110
120
  const selectedValues = computed(() => (props.multiple && Array.isArray(modelValue.value) ? modelValue.value : []));
111
121
  const selectedOption = computed(() =>
@@ -137,6 +147,23 @@ const isSelected = (option: SelectMenuOption): boolean =>
137
147
  const getMenuItems = (): HTMLElement[] =>
138
148
  Array.from(popoverRef.value?.querySelectorAll<HTMLElement>('[role="option"]') ?? []);
139
149
 
150
+ const focusInitialItem = () => {
151
+ const items = getMenuItems();
152
+ const selectedIndex = items.findIndex((el) => el.getAttribute("aria-selected") === "true");
153
+ items[selectedIndex === -1 ? 0 : selectedIndex]?.focus();
154
+ };
155
+
156
+ const {
157
+ isOpen,
158
+ usesFallbackPopover,
159
+ positionStyle,
160
+ popoverPlacement,
161
+ hide,
162
+ handleTriggerClick,
163
+ handleBeforeToggle,
164
+ handleToggle,
165
+ } = useAnchoredPopover({ rootRef, triggerRef, popoverRef, onOpen: focusInitialItem });
166
+
140
167
  const selectOption = (option: SelectMenuOption) => {
141
168
  if (props.multiple) {
142
169
  const next = selectedValues.value.includes(option.value)
@@ -146,20 +173,10 @@ const selectOption = (option: SelectMenuOption) => {
146
173
  return;
147
174
  }
148
175
  modelValue.value = option.value;
149
- popoverRef.value?.hidePopover();
176
+ hide();
150
177
  triggerRef.value?.focus();
151
178
  };
152
179
 
153
- const handleToggle = (event: Event) => {
154
- const toggleEvent = event as ToggleEvent;
155
- isOpen.value = toggleEvent.newState === "open";
156
- if (isOpen.value) {
157
- const items = getMenuItems();
158
- const selectedIndex = items.findIndex((el) => el.getAttribute("aria-selected") === "true");
159
- items[selectedIndex === -1 ? 0 : selectedIndex]?.focus();
160
- }
161
- };
162
-
163
180
  /**
164
181
  * Keyboard navigation following the WAI-ARIA listbox pattern.
165
182
  *
@@ -168,7 +185,7 @@ const handleToggle = (event: Event) => {
168
185
  * Enter / Space — select the focused option. Closes the menu in single-select mode;
169
186
  * toggles the checkbox and keeps the menu open in multi-select mode.
170
187
  * Tab — close the menu; let the browser Tab naturally.
171
- * Escape — handled natively by the Popover API.
188
+ * Escape — native light dismiss, or the fallback listener without the Popover API.
172
189
  */
173
190
  const handleKeydown = (event: KeyboardEvent) => {
174
191
  const items = getMenuItems();
@@ -201,7 +218,7 @@ const handleKeydown = (event: KeyboardEvent) => {
201
218
  break;
202
219
  }
203
220
  case "Tab":
204
- popoverRef.value?.hidePopover();
221
+ hide();
205
222
  break;
206
223
  }
207
224
  };
@@ -288,7 +305,7 @@ watch(
288
305
  border-radius: 0;
289
306
  }
290
307
 
291
- &:has(.select-menu-popover:popover-open) .select-menu-trigger-chevron {
308
+ .select-menu-trigger[aria-expanded="true"] .select-menu-trigger-chevron {
292
309
  transform: rotate(180deg);
293
310
  }
294
311
 
@@ -329,6 +346,24 @@ watch(
329
346
  }
330
347
  }
331
348
 
349
+ /* Kept apart from :popover-open, which would invalidate a shared selector list where unsupported. */
350
+ &.select-menu-popover-open {
351
+ display: block;
352
+ opacity: 1;
353
+ }
354
+
355
+ @supports not (anchor-name: --a) {
356
+ position: fixed;
357
+ top: calc(var(--_anchor-bottom, 0px) + var(--select-menu-block-distance, 0.4rem));
358
+ left: var(--_anchor-left, 0px);
359
+ z-index: var(--select-menu-popover-z-index, 999999);
360
+
361
+ &[data-placement="top"] {
362
+ top: auto;
363
+ bottom: calc(var(--_anchor-top-inverse, 0px) + var(--select-menu-block-distance, 0.4rem));
364
+ }
365
+ }
366
+
332
367
  .select-menu-list {
333
368
  display: grid;
334
369
  grid-template-columns: auto auto 1fr;
@@ -482,4 +482,40 @@ describe("SelectMenu", () => {
482
482
  expect(wrapper.classes()).toContain("select-menu");
483
483
  });
484
484
  });
485
+
486
+ // -------------------------
487
+ // Without the Popover API (Safari 16 and older)
488
+ // -------------------------
489
+ describe("Without the Popover API", () => {
490
+ beforeEach(() => {
491
+ Object.defineProperty(HTMLElement.prototype, "showPopover", { value: undefined, writable: true, configurable: true });
492
+ });
493
+
494
+ it("opens from the trigger with the fallback open class and aria-expanded", async () => {
495
+ wrapper = await createWrapper();
496
+ const popover = wrapper.find(".select-menu-popover");
497
+ expect(popover.classes()).not.toContain("select-menu-popover-open");
498
+
499
+ await wrapper.find(".select-menu-trigger").trigger("click");
500
+ expect(popover.classes()).toContain("select-menu-popover-open");
501
+ expect(wrapper.find(".select-menu-trigger").attributes("aria-expanded")).toBe("true");
502
+ });
503
+
504
+ it("closes when a single-select option is chosen", async () => {
505
+ wrapper = await createWrapper();
506
+ await wrapper.find(".select-menu-trigger").trigger("click");
507
+ await wrapper.findAll('[role="option"]')[1]!.trigger("click");
508
+
509
+ expect(wrapper.emitted("update:modelValue")?.[0]).toEqual(["fr"]);
510
+ expect(wrapper.find(".select-menu-popover").classes()).not.toContain("select-menu-popover-open");
511
+ });
512
+
513
+ it("stays open while toggling options in multiple mode", async () => {
514
+ wrapper = await createWrapper({ multiple: true, modelValue: [] });
515
+ await wrapper.find(".select-menu-trigger").trigger("click");
516
+ await wrapper.findAll('[role="option"]')[1]!.trigger("click");
517
+
518
+ expect(wrapper.find(".select-menu-popover").classes()).toContain("select-menu-popover-open");
519
+ });
520
+ });
485
521
  });
@@ -54,14 +54,19 @@ const meta: Meta<StoryArgs> = {
54
54
  authorLineClamp: {
55
55
  control: "select",
56
56
  options: lineClampOptions,
57
- description: "Story control for --google-reviews-author-line-clamp (1 = single-line ellipsis)",
58
- table: { category: "CSS tokens" },
57
+ description:
58
+ "**Story control, not a prop.** Sets the `--google-reviews-author-line-clamp` CSS token on a wrapper. " +
59
+ "To use it in an app, set the token in your own CSS, e.g. `.reviews { --google-reviews-author-line-clamp: 1; }`. " +
60
+ "`1` is single-line ellipsis.",
61
+ table: { category: "CSS tokens (story only, set in your CSS)" },
59
62
  },
60
63
  textLineClamp: {
61
64
  control: "select",
62
65
  options: lineClampOptions,
63
- description: "Story control for --google-reviews-text-line-clamp",
64
- table: { category: "CSS tokens" },
66
+ description:
67
+ "**Story control, not a prop.** Sets the `--google-reviews-text-line-clamp` CSS token on a wrapper. " +
68
+ "To use it in an app, set the token in your own CSS, e.g. `.reviews { --google-reviews-text-line-clamp: 3; }`.",
69
+ table: { category: "CSS tokens (story only, set in your CSS)" },
65
70
  },
66
71
  data: { control: "object", description: "GoogleReviewsData, usually from useGoogleReviews()" },
67
72
  minRating: { control: { type: "range", min: 0, max: 5, step: 1 }, description: "Hide reviews below this rating; shows the filter notice" },
@@ -11,6 +11,7 @@ are documented alongside it since that's where they're consumed.
11
11
  |---|---|---|
12
12
  | `--responsive-header-link-font-size` | `inherit` | Nav-link font-size. **Always set this to a fixed value.** Leaving it `inherit` means a fluid ancestor font-size (`clamp()`/`vw`) can drift nav-item text width without ever re-triggering the overflow-collapse measurement — see the component skill doc's "measurement pipeline" section for the full mechanism. |
13
13
  | `--responsive-header-link-color` | `inherit` | Link/summary text colour. |
14
+ | `--responsive-header-main-nav-justify-content` | `space-between` | How the main nav's groups are spread along the bar. Use `flex-start`, `safe center` or `safe end`; plain `center`/`end` let items that don't fit spill off the start edge, where they're clipped. (Safari 16 ignores `safe` values and falls back to start alignment.) |
14
15
  | `--responsive-header-color` | `inherit` | Root element text colour. |
15
16
  | `--responsive-header-margin` | `0` | Root element margin. |
16
17
  | `--responsive-header-bg` | `transparent` | Root element background. |
@@ -20,8 +21,21 @@ are documented alongside it since that's where they're consumed.
20
21
  | `--responsive-header-max-height` | `none` | Root element max-height. |
21
22
  | `--responsive-header-inline-size` | `100%` | Root element inline-size. |
22
23
  | `--responsive-header-sub-nav-bg` / `-border` / `-border-radius` / `-padding` | `Canvas` / `1px solid #efefef75` / `8px` / `12px` | Top-bar dropdown panel (`.main-navigation-sub-nav`). |
24
+ | `--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
+ | `--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. |
23
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. |
24
27
  | `--responsive-header-overflow-nav-bg` / `-border` / `-border-radius` / `-padding-block` | `Canvas` / `1px solid #ffffff90` / `8px` / `12px` | Overflow panel container. |
28
+ | `--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. |
30
+
31
+ Link and dropdown text is never line-clamped: it names where the link goes. Long unbroken names
32
+ wrap inside the dropdown and overflow panels; in the top bar, an item too wide to fit moves to the
33
+ overflow menu.
34
+
35
+ > **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.
37
+ > `--_overflow-drop-down-width`, a private token nothing ever set, is gone (`min-width: fit-content`
38
+ > directly).
25
39
  | `--responsive-nav-decorator-indicator-color` | `currentColor` | Active-item indicator bar/underline. |
26
40
  | `--responsive-nav-decorator-hovered-indicator-color` | inherits the above | Indicator colour while hovering. |
27
41
  | `--responsive-nav-decorator-hovered-bg` | `oklch(100% 0 0 / 8%)` | Hover highlight background behind the hovered item. |
@@ -27,8 +27,7 @@
27
27
  'is-active': isActiveNavItem(link),
28
28
  }"
29
29
  :style="{
30
- '--_main-navigation-item-width':
31
- mainNavigationState.clonedNavLinks?.[groupKey]?.[localIndex]?.config?.width + 'px',
30
+ '--_main-navigation-item-width': itemWidth(mainNavigationState.clonedNavLinks?.[groupKey]?.[localIndex]),
32
31
  }"
33
32
  :data-group-key="groupKey"
34
33
  :data-local-index="localIndex"
@@ -48,8 +47,7 @@
48
47
  'is-active': isActiveNavItem(link),
49
48
  }"
50
49
  :style="{
51
- '--_main-navigation-item-width':
52
- mainNavigationState.clonedNavLinks?.[groupKey]?.[localIndex]?.config?.width + 'px',
50
+ '--_main-navigation-item-width': itemWidth(mainNavigationState.clonedNavLinks?.[groupKey]?.[localIndex]),
53
51
  }"
54
52
  :data-group-key="groupKey"
55
53
  :data-local-index="localIndex"
@@ -66,11 +64,11 @@
66
64
  <template #summary>
67
65
  <span
68
66
  class="overflow-navigation-text"
69
- :aria-label="`${link.childLinksTitle} submenu`"
67
+ :aria-label="submenuLabel(link)"
70
68
  role="menuitem"
71
69
  :aria-haspopup="true"
72
70
  >
73
- {{ link.childLinksTitle }}
71
+ {{ link.childLinksTitle ?? link.name }}
74
72
  </span>
75
73
  </template>
76
74
  <template #icon>
@@ -81,7 +79,7 @@
81
79
  <ul class="overflow-navigation-sub-nav-list" @mouseleave="hoveredChildKey = null">
82
80
  <li
83
81
  v-for="(childLink, childIndex) in link.childLinks"
84
- :key="childLink.name"
82
+ :key="childIndex"
85
83
  class="overflow-navigation-sub-nav-item"
86
84
  :class="{
87
85
  'is-hovered': hoveredChildKey === `${String(groupKey)}-${localIndex}-${childIndex}`,
@@ -117,6 +115,8 @@ interface Props {
117
115
  styleClassPassthrough?: string | string[];
118
116
  /** aria-label on the overflow menu — override for localisation. */
119
117
  ariaLabel?: string;
118
+ /** aria-label on each submenu summary; {title} is replaced with the item's title. */
119
+ submenuAriaLabel?: string;
120
120
  }
121
121
 
122
122
  const props = withDefaults(defineProps<Props>(), {
@@ -127,8 +127,12 @@ const props = withDefaults(defineProps<Props>(), {
127
127
  panelVariant: "classic",
128
128
  styleClassPassthrough: () => [],
129
129
  ariaLabel: "Overflow navigation menu",
130
+ submenuAriaLabel: "{title} submenu",
130
131
  });
131
132
 
133
+ const submenuLabel = (link: ResponsiveHeaderNavItem) =>
134
+ props.submenuAriaLabel.replace("{title}", link.childLinksTitle ?? link.name);
135
+
132
136
  const panelComponent = computed(() => (props.panelVariant === "modern" ? ExpandingPanel : ExpandingPanelClassic));
133
137
 
134
138
  const hoveredItemKey = ref<string | null>(null);
@@ -178,6 +182,9 @@ const widestNavLinkWidthInMainNavigationState = computed(() => {
178
182
  }, 0);
179
183
  });
180
184
 
185
+ const itemWidth = (link?: ResponsiveHeaderNavItem) =>
186
+ link?.config?.width === undefined ? undefined : `${link.config.width}px`;
187
+
181
188
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
182
189
 
183
190
  // Performance: Use immediate flag and more efficient watching
@@ -199,6 +206,7 @@ watch(
199
206
  on this element to avoid cascade conflicts with ancestor overrides.
200
207
 
201
208
  --overflow-nav-padding-inline (default: 0.8rem)
209
+ --overflow-nav-max-inline-size (default: calc(100vw - 3.2rem))
202
210
  --overflow-nav-items-gap (default: 0px)
203
211
  --overflow-nav-items-padding-block (default: 0.8rem)
204
212
 
@@ -212,10 +220,15 @@ watch(
212
220
  --overflow-nav-decorator-hovered-bg (default: oklch(100% 0 0 / 6%))
213
221
  ──────────────────────────────────────────────────────────────────────── */
214
222
 
223
+ --_max-inline-size: var(--overflow-nav-max-inline-size, calc(100vw - 3.2rem));
224
+
215
225
  display: flex;
216
226
  flex-direction: column;
217
227
  gap: var(--overflow-nav-items-gap, 0px);
218
228
  position: relative;
229
+ inline-size: max-content;
230
+ max-inline-size: var(--_max-inline-size);
231
+ overflow-wrap: anywhere;
219
232
 
220
233
  .overflow-navigation-list {
221
234
  display: none;
@@ -224,7 +237,7 @@ watch(
224
237
  display: flex;
225
238
  flex-direction: column;
226
239
  gap: var(--overflow-nav-items-gap, 0px);
227
- min-width: var(--_overflow-navigation-list-min-width, auto);
240
+ min-width: min(var(--_overflow-navigation-list-min-width, 0px), var(--_max-inline-size));
228
241
  }
229
242
 
230
243
  .overflow-navigation-item {
@@ -262,9 +275,7 @@ watch(
262
275
  border-bottom: 0.1rem solid var(--overflow-nav-link-border-color, #efefef75);
263
276
 
264
277
  .label-wrapper {
265
- .overflow-navigation-text {
266
- text-wrap: nowrap;
267
- }
278
+ min-inline-size: 0;
268
279
  }
269
280
  .icon-wrapper {
270
281
  padding: 0;
@@ -325,9 +336,7 @@ watch(
325
336
  border-bottom: 0.1rem solid var(--overflow-nav-link-border-color, #efefef75);
326
337
 
327
338
  .label-wrapper {
328
- .overflow-navigation-text {
329
- text-wrap: nowrap;
330
- }
339
+ min-inline-size: 0;
331
340
  }
332
341
  .icon-wrapper {
333
342
  padding: 0;
@@ -32,8 +32,7 @@
32
32
  'is-active': isActiveNavItem(link),
33
33
  }"
34
34
  :style="{
35
- '--_main-navigation-item-width':
36
- mainNavigationState.clonedNavLinks?.[groupKey]?.[localIndex]?.config?.width + 'px',
35
+ '--_main-navigation-item-width': itemWidth(mainNavigationState.clonedNavLinks?.[groupKey]?.[localIndex]),
37
36
  }"
38
37
  :data-group-key="groupKey"
39
38
  :data-local-index="localIndex"
@@ -52,7 +51,7 @@
52
51
  <details v-else ref="navigationDetails" class="main-navigation-details" name="navigation-group">
53
52
  <summary
54
53
  class="main-navigation-details-summary has-toggle-icon"
55
- :aria-label="`${link.childLinksTitle} submenu`"
54
+ :aria-label="submenuLabel(link)"
56
55
  @mouseenter="handleSummaryHover($event)"
57
56
  @focusin="handleSummaryHover($event)"
58
57
  @click.prevent="handleSummaryAction($event)"
@@ -61,11 +60,15 @@
61
60
  <Icon name="mdi:chevron-down" class="icon" :aria-hidden="true" />
62
61
  <Icon v-if="link.iconName" :name="link.iconName" class="decorator-icon" aria-hidden="true" />
63
62
 
64
- {{ link.childLinksTitle }}
63
+ {{ link.childLinksTitle ?? link.name }}
65
64
  </summary>
66
65
  <div class="main-navigation-sub-nav" role="menu" @mouseenter="handleSubNavHover">
67
66
  <ul class="main-navigation-sub-nav-list">
68
- <li v-for="childLink in link.childLinks" :key="childLink.name" class="main-navigation-sub-nav-item">
67
+ <li
68
+ v-for="(childLink, childIndex) in link.childLinks"
69
+ :key="childIndex"
70
+ class="main-navigation-sub-nav-item"
71
+ >
69
72
  <NuxtLink :to="childLink.path" class="main-navigation-sub-nav-link" role="menuitem">
70
73
  {{ childLink.name }}
71
74
  </NuxtLink>
@@ -85,7 +88,7 @@
85
88
  :class="overflowDetailsClass ? [overflowDetailsClass] : []"
86
89
  name="overflow-group"
87
90
  >
88
- <summary class="overflow-details-summary has-toggle-icon">
91
+ <summary class="overflow-details-summary has-toggle-icon" :aria-label="overflowButtonLabel">
89
92
  <Icon
90
93
  :name="overflowDetailsSummaryIcons.more ?? 'gravity-ui:ellipsis'"
91
94
  class="icon"
@@ -104,6 +107,7 @@
104
107
  :main-navigation-state="mainNavigationState"
105
108
  :panel-variant="panelVariant"
106
109
  :aria-label="overflowMenuAriaLabel"
110
+ :submenu-aria-label="submenuAriaLabel"
107
111
  />
108
112
  </div>
109
113
  </details>
@@ -136,6 +140,10 @@ interface Props {
136
140
  secondaryNavAriaLabel?: string;
137
141
  /** aria-label on the overflow menu itself (forwarded to NavigationItems) — override for localisation. */
138
142
  overflowMenuAriaLabel?: string;
143
+ /** Accessible name of the overflow/burger button, which shows only icons. */
144
+ overflowButtonLabel?: string;
145
+ /** aria-label on each dropdown summary; {title} is replaced with the item's title. Forwarded to NavigationItems. */
146
+ submenuAriaLabel?: string;
139
147
  }
140
148
 
141
149
  const props = withDefaults(defineProps<Props>(), {
@@ -155,8 +163,13 @@ const props = withDefaults(defineProps<Props>(), {
155
163
  mainNavAriaLabel: "Main navigation",
156
164
  secondaryNavAriaLabel: "Secondary navigation",
157
165
  overflowMenuAriaLabel: "Overflow navigation menu",
166
+ overflowButtonLabel: "More navigation",
167
+ submenuAriaLabel: "{title} submenu",
158
168
  });
159
169
 
170
+ const submenuLabel = (link: ResponsiveHeaderNavItem) =>
171
+ props.submenuAriaLabel.replace("{title}", link.childLinksTitle ?? link.name);
172
+
160
173
  const collapseNavigationBelowWidth = computed(
161
174
  () => props.collapseBreakpoint !== null || props.collapseAtMainNavIntersection
162
175
  );
@@ -173,6 +186,7 @@ const {
173
186
  const slots = useSlots();
174
187
 
175
188
  const navigationWrapperRef = useTemplateRef<HTMLDivElement>("navigationWrapper");
189
+ const mainNavRef = useTemplateRef<HTMLElement>("mainNav");
176
190
 
177
191
  const closeAllNavigationDetails = () => {
178
192
  navigationDetailsRefs.value?.forEach((element) => {
@@ -361,7 +375,10 @@ const mainNavigationState = ref<ResponsiveHeaderState>({
361
375
  firstNav: false,
362
376
  secondNav: false,
363
377
  },
364
- clonedNavLinks: props.responsiveNavLinks,
378
+ // Copied so the geometry pass's config writes never land in the consumer's own nav data
379
+ clonedNavLinks: Object.fromEntries(
380
+ Object.entries(props.responsiveNavLinks).map(([groupKey, group]) => [groupKey, group.map((link) => ({ ...link }))])
381
+ ),
365
382
  hasSecondNav: Object.keys(props.responsiveNavLinks).length > 1,
366
383
  });
367
384
 
@@ -398,12 +415,13 @@ const navigationDetailsRefs = useTemplateRef<HTMLElement[]>("navigationDetails")
398
415
 
399
416
  const overflowDetailsRef = useTemplateRef<HTMLDetailsElement>("overflowDetails");
400
417
 
401
- const showOverflowDetails = computed(() => {
402
- const hasHiddenNav =
403
- !mainNavigationState.value.navListVisibility["firstNav"] ||
404
- (!mainNavigationState.value.navListVisibility["secondNav"] && mainNavigationState.value.hasSecondNav);
405
- return hasHiddenNav;
406
- });
418
+ // Item-based rather than reading navListVisibility.firstNav/secondNav, so any group key works and
419
+ // empty nav data never shows a burger with nothing in it
420
+ const showOverflowDetails = computed(() =>
421
+ Object.values(mainNavigationState.value.clonedNavLinks ?? {}).some((group) =>
422
+ group.some((link) => link.config?.visible === false)
423
+ )
424
+ );
407
425
 
408
426
  const mainNavigationMarginBlockEnd = computed(() => {
409
427
  return secondaryNavRects.value ? secondaryNavRects.value.width + props.gapBetweenFirstAndSecondNav : 0;
@@ -462,20 +480,18 @@ const determineNavigationItemVisibility = (rect: DOMRect) => {
462
480
  return false;
463
481
  }
464
482
 
465
- // Use default responsive visibility logic if wrapper exists
466
- if (navigationWrapperRects.value) {
467
- return (
468
- Math.floor(rect.right + mainNavigationMarginBlockEnd.value + props.gapBetweenFirstAndSecondNav) <
469
- navigationWrapperRects.value.right
470
- );
471
- }
472
-
473
- // Default to visible
474
- return true;
483
+ // An item fits if it ends within the main nav, whose margin-inline-end already reserves the
484
+ // secondary nav plus the gap. Measured live with the item so both read the same layout.
485
+ const mainNavRect = mainNavRef.value?.getBoundingClientRect();
486
+ if (!mainNavRect) return true;
487
+ return Math.floor(rect.right) <= Math.floor(mainNavRect.right);
475
488
  };
476
489
 
477
490
  const initMainNavigationState = () => {
478
- if (!mainNavigationItemsRefs.value) return;
491
+ if (!mainNavigationItemsRefs.value?.length) {
492
+ isGeometryReady.value = true;
493
+ return;
494
+ }
479
495
 
480
496
  mainNavigationItemsRefs.value.forEach(async (item) => {
481
497
  // await nextTick()
@@ -642,6 +658,9 @@ onMounted(async () => {
642
658
 
643
659
  useResizeObserver(navigationWrapperRef, runGeometryPass);
644
660
 
661
+ const itemWidth = (link?: ResponsiveHeaderNavItem) =>
662
+ link?.config?.width === undefined ? undefined : `${link.config.width}px`;
663
+
645
664
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
646
665
 
647
666
  watch(
@@ -690,6 +709,8 @@ watch(
690
709
  --responsive-header-sub-nav-border (default: 1px solid #efefef75)
691
710
  --responsive-header-sub-nav-border-radius (default: 8px)
692
711
  --responsive-header-sub-nav-padding (default: 12px)
712
+ --responsive-header-sub-nav-max-inline-size (default: min(48rem, calc(100vw - 3.2rem)))
713
+ --responsive-header-sub-nav-max-block-size (default: 70vh)
693
714
 
694
715
  --responsive-header-overflow-btn-bg (default: Canvas)
695
716
  --responsive-header-overflow-btn-size (default: 20px)
@@ -702,12 +723,14 @@ watch(
702
723
  --responsive-header-overflow-nav-border (default: 1px solid #ffffff90)
703
724
  --responsive-header-overflow-nav-border-radius (default: 8px)
704
725
  --responsive-header-overflow-nav-padding-block (default: 12px)
726
+ --responsive-header-overflow-nav-max-block-size (default: 70vh)
705
727
 
706
728
  --responsive-nav-decorator-indicator-color (default: currentColor)
707
729
  --responsive-nav-decorator-hovered-indicator-color (default: inherits --responsive-nav-decorator-indicator-color)
708
730
  --responsive-nav-decorator-hovered-bg (default: oklch(100% 0 0 / 8%))
709
731
 
710
732
  --responsive-header-link-font-size (default: inherit)
733
+ --responsive-header-main-nav-justify-content (default: space-between)
711
734
  ──────────────────────────────────────────────────────────────────────── */
712
735
 
713
736
  /* `inherit` means a viewport-relative (vw/clamp) ancestor font-size silently
@@ -749,7 +772,7 @@ watch(
749
772
  display: flex;
750
773
  flex-wrap: nowrap;
751
774
  flex-grow: 1;
752
- justify-content: space-between;
775
+ justify-content: var(--responsive-header-main-nav-justify-content, space-between);
753
776
  gap: 60px;
754
777
  overflow-x: hidden;
755
778
  margin-inline-end: v-bind(mainNavigationMarginBlockEndStr);
@@ -782,7 +805,7 @@ watch(
782
805
  .main-navigation-link {
783
806
  display: flex;
784
807
  gap: 6px;
785
- text-wrap-mode: nowrap;
808
+ white-space: nowrap;
786
809
  font-size: var(--responsive-header-link-font-size, inherit);
787
810
  color: var(--responsive-header-link-color, inherit);
788
811
  text-decoration: none;
@@ -821,7 +844,7 @@ watch(
821
844
  .has-toggle-icon {
822
845
  display: flex;
823
846
  gap: 6px;
824
- text-wrap-mode: nowrap;
847
+ white-space: nowrap;
825
848
 
826
849
  /* Reserved for the same reason as .decorator-icon above — an
827
850
  unsized chevron would widen the summary once its SVG loads. */
@@ -868,6 +891,9 @@ watch(
868
891
  translate: 0 12px;
869
892
 
870
893
  min-width: var(--_main-navigation-item-width);
894
+ max-inline-size: var(--responsive-header-sub-nav-max-inline-size, min(48rem, calc(100vw - 3.2rem)));
895
+ max-block-size: var(--responsive-header-sub-nav-max-block-size, 70vh);
896
+ overflow-y: auto;
871
897
 
872
898
  .main-navigation-sub-nav-list {
873
899
  display: grid;
@@ -883,7 +909,7 @@ watch(
883
909
 
884
910
  .main-navigation-sub-nav-link {
885
911
  display: block;
886
- text-wrap-mode: nowrap;
912
+ overflow-wrap: anywhere;
887
913
  text-decoration: none;
888
914
  color: var(--responsive-header-link-color, inherit);
889
915
  }
@@ -994,7 +1020,7 @@ watch(
994
1020
  align-items: center;
995
1021
  justify-content: center;
996
1022
  padding-inline: 5px;
997
- text-wrap: nowrap;
1023
+ white-space: nowrap;
998
1024
 
999
1025
  aspect-ratio: 1;
1000
1026
  border-radius: 4px;
@@ -1048,7 +1074,9 @@ watch(
1048
1074
  padding-block: var(--responsive-header-overflow-nav-padding-block, 12px);
1049
1075
  margin: 0;
1050
1076
  z-index: 999;
1051
- min-width: var(--_overflow-drop-down-width, fit-content);
1077
+ min-width: fit-content;
1078
+ max-block-size: var(--responsive-header-overflow-nav-max-block-size, 70vh);
1079
+ overflow-y: auto;
1052
1080
 
1053
1081
  display: grid;
1054
1082
  grid-auto-flow: row;