srcdev-nuxt-components 9.5.6 → 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 (49) hide show
  1. package/.claude/skills/component-compliance-checklist.md +6 -2
  2. package/.claude/skills/components/navigation-items.md +1 -0
  3. package/.claude/skills/components/opening-hours.md +12 -0
  4. package/.claude/skills/components/price-list.md +10 -1
  5. package/.claude/skills/components/responsive-header.md +25 -0
  6. package/.claude/skills/components/site-header.md +2 -0
  7. package/.claude/skills/storybook-add-story.md +14 -0
  8. package/.vscode/srcdev-component-responsive-header.code-snippets +3 -1
  9. package/.vscode/srcdev-component-site-header.code-snippets +3 -1
  10. package/app/components/02.molecules/opening-hours/CONSUMER-STYLING.md +11 -1
  11. package/app/components/02.molecules/opening-hours/OpeningHours.vue +71 -43
  12. package/app/components/02.molecules/opening-hours/stories/OpeningHours.stories.ts +138 -6
  13. package/app/components/02.molecules/opening-hours/tests/OpeningHours.spec.ts +53 -0
  14. package/app/components/02.molecules/opening-hours/tests/__snapshots__/OpeningHours.spec.ts.snap +1 -1
  15. package/app/components/02.molecules/opening-hours/tests/buildOpeningHoursSpecification.spec.ts +37 -1
  16. package/app/components/02.molecules/price-list/CONSUMER-STYLING.md +17 -5
  17. package/app/components/02.molecules/price-list/PriceList.vue +32 -9
  18. package/app/components/02.molecules/price-list/stories/PriceList.stories.ts +125 -29
  19. package/app/components/02.molecules/price-list/tests/PriceList.spec.ts +20 -0
  20. package/app/components/03.organisms/google-reviews/stories/GoogleReviews.stories.ts +9 -4
  21. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +14 -0
  22. package/app/components/03.organisms/responsive-header/NavigationItems.vue +23 -14
  23. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +55 -27
  24. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +149 -18
  25. package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +21 -0
  26. package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +66 -0
  27. package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +30 -0
  28. package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +1 -0
  29. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +35 -0
  30. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +23 -0
  31. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +1 -0
  32. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +71 -0
  33. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +386 -0
  34. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +11 -0
  35. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +79 -0
  36. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +17 -0
  37. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +64 -0
  38. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +29 -0
  39. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +32 -0
  40. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +10 -0
  41. package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
  42. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +16 -0
  43. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +91 -6
  44. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +9 -9
  45. package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +1 -0
  46. package/app/components/03.organisms/site-header/SiteHeader.vue +8 -0
  47. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +1 -1
  48. package/app/utils/opening-hours.ts +23 -2
  49. package/package.json +1 -1
@@ -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
- white-space: 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
- white-space: 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);
@@ -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
- white-space: nowrap;
912
+ overflow-wrap: anywhere;
887
913
  text-decoration: none;
888
914
  color: var(--responsive-header-link-color, inherit);
889
915
  }
@@ -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;
@@ -1,10 +1,50 @@
1
+ import { computed, ref } from "vue";
1
2
  import ResponsiveHeader from "../ResponsiveHeader.vue";
3
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
2
4
  import type { Meta, StoryObj } from "@nuxtjs/storybook";
5
+ import type { MediaCanvas, ResponsiveHeaderProp } from "~/types/components";
3
6
 
4
- const meta: Meta<typeof ResponsiveHeader> = {
7
+ type MainNavJustify = "space-between" | "flex-start" | "safe center" | "safe end";
8
+
9
+ // Story-only arg: drives a CSS token, not a ResponsiveHeader prop. See the argTypes entry.
10
+ type StoryArgs = InstanceType<typeof ResponsiveHeader>["$props"] & {
11
+ mainNavJustifyContent: MainNavJustify;
12
+ };
13
+
14
+ function useStorySetup(args: StoryArgs) {
15
+ const componentArgs = computed(() => {
16
+ const { mainNavJustifyContent: _mainNavJustifyContent, ...rest } = args;
17
+ return rest;
18
+ });
19
+ const tokenStyles = computed(() => ({
20
+ "--responsive-header-main-nav-justify-content": args.mainNavJustifyContent,
21
+ }));
22
+ return { componentArgs, tokenStyles };
23
+ }
24
+
25
+ const renderWith = (template: string) => (args: StoryArgs) => ({
26
+ components: { ResponsiveHeader },
27
+ setup() {
28
+ return useStorySetup(args);
29
+ },
30
+ template: `<div :style="tokenStyles">${template}</div>`,
31
+ });
32
+
33
+ const render = renderWith(`<ResponsiveHeader v-bind="componentArgs" />`);
34
+
35
+ const meta: Meta<StoryArgs> = {
5
36
  title: "Organisms/Responsive Header",
6
37
  component: ResponsiveHeader,
7
38
  argTypes: {
39
+ mainNavJustifyContent: {
40
+ control: "select",
41
+ options: ["space-between", "flex-start", "safe center", "safe end"] satisfies MainNavJustify[],
42
+ description:
43
+ "**Story control, not a prop.** Sets the `--responsive-header-main-nav-justify-content` CSS token on a wrapper. " +
44
+ "To use it in an app, set the token in your own CSS, e.g. `.site-nav { --responsive-header-main-nav-justify-content: flex-start; }`. " +
45
+ "Use `safe end` / `safe center` rather than plain `end` / `center`, so items that don't fit aren't clipped off the start edge.",
46
+ table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "space-between" } },
47
+ },
8
48
  responsiveNavLinks: {
9
49
  control: "object",
10
50
  description: "Nav groups keyed by name — each item is either a link (`path`) or a dropdown (`childLinks`)",
@@ -29,12 +69,47 @@ const meta: Meta<typeof ResponsiveHeader> = {
29
69
  control: "boolean",
30
70
  description: "Allow a dropdown to open on hover/focus, not just click",
31
71
  },
72
+ mainNavAriaLabel: { control: "text", table: { category: "Labels", defaultValue: { summary: "Main navigation" } } },
73
+ secondaryNavAriaLabel: { control: "text", table: { category: "Labels", defaultValue: { summary: "Secondary navigation" } } },
74
+ overflowMenuAriaLabel: { control: "text", table: { category: "Labels", defaultValue: { summary: "Overflow navigation menu" } } },
75
+ overflowButtonLabel: {
76
+ control: "text",
77
+ description: "Accessible name of the icon-only overflow/burger button",
78
+ table: { category: "Labels", defaultValue: { summary: "More navigation" } },
79
+ },
80
+ submenuAriaLabel: {
81
+ control: "text",
82
+ description: "aria-label on each dropdown summary; {title} is replaced with the item's title",
83
+ table: { category: "Labels", defaultValue: { summary: "{title} submenu" } },
84
+ },
32
85
  styleClassPassthrough: {
33
86
  control: "object",
34
87
  description: "Extra CSS classes applied to the root element",
35
88
  },
36
89
  },
90
+ decorators: [
91
+ (story, context) => ({
92
+ components: { story, CanvasSwitcher },
93
+ setup() {
94
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
95
+ return { canvasName };
96
+ },
97
+ template: `
98
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
99
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
100
+ </div>
101
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
102
+ <story />
103
+ </div>
104
+ `,
105
+ }),
106
+ ],
107
+ parameters: {
108
+ layout: "fullscreen",
109
+ },
110
+ render,
37
111
  args: {
112
+ mainNavJustifyContent: "space-between",
38
113
  gapBetweenFirstAndSecondNav: 12,
39
114
  collapseBreakpoint: null,
40
115
  collapseAtMainNavIntersection: false,
@@ -44,7 +119,8 @@ const meta: Meta<typeof ResponsiveHeader> = {
44
119
  };
45
120
 
46
121
  export default meta;
47
- type Story = StoryObj<typeof ResponsiveHeader>;
122
+ type Story = StoryObj<StoryArgs>;
123
+
48
124
 
49
125
  const responsiveNavLinks = {
50
126
  firstNav: [
@@ -73,13 +149,7 @@ export const Default: Story = {
73
149
  export const ConstrainedWidth: Story = {
74
150
  name: "Constrained Width (items overflow)",
75
151
  args: { responsiveNavLinks },
76
- render: (args) => ({
77
- components: { ResponsiveHeader },
78
- setup() {
79
- return { args };
80
- },
81
- template: `<div style="max-width: 420px"><ResponsiveHeader v-bind="args" /></div>`,
82
- }),
152
+ render: renderWith(`<div style="max-width: 420px"><ResponsiveHeader v-bind="componentArgs" /></div>`),
83
153
  };
84
154
 
85
155
  export const CustomOverflowIcons: Story = {
@@ -92,19 +162,13 @@ export const CustomOverflowIcons: Story = {
92
162
  export const WithSecondaryNavigationSlot: Story = {
93
163
  name: "With secondaryNavigation slot",
94
164
  args: { responsiveNavLinks },
95
- render: (args) => ({
96
- components: { ResponsiveHeader },
97
- setup() {
98
- return { args };
99
- },
100
- template: `
101
- <ResponsiveHeader v-bind="args">
165
+ render: renderWith(`
166
+ <ResponsiveHeader v-bind="componentArgs">
102
167
  <template #secondaryNavigation>
103
168
  <a href="/settings" aria-label="Settings">⚙</a>
104
169
  </template>
105
170
  </ResponsiveHeader>
106
- `,
107
- }),
171
+ `),
108
172
  };
109
173
 
110
174
  export const SingleFlatGroup: Story = {
@@ -120,3 +184,70 @@ export const SingleFlatGroup: Story = {
120
184
  },
121
185
  },
122
186
  };
187
+
188
+ export const MainNavAlignment: Story = {
189
+ name: "Main Nav Alignment",
190
+ args: { responsiveNavLinks, mainNavJustifyContent: "flex-start" },
191
+ parameters: {
192
+ docs: {
193
+ description: {
194
+ story:
195
+ "The main nav's two groups are spread with `space-between` by default. Use the Main nav justify content control " +
196
+ "(a CSS token set by the story, not a prop) to try `flex-start`, `safe center` and `safe end`. " +
197
+ "Narrow the canvas to check items still collapse into the overflow menu from the end, whatever the alignment.",
198
+ },
199
+ },
200
+ },
201
+ };
202
+
203
+ // ─── Stress test ──────────────────────────────────────────────────────────────
204
+
205
+ const stressNavLinks: ResponsiveHeaderProp = {
206
+ primary: [
207
+ { name: "Startseite", path: "/" },
208
+ { name: "Pneumonoultramicroscopicsilicovolcanoconiosisbehandlungohneleerzeichen", path: "/long" },
209
+ {
210
+ name: "Leistungen und Behandlungen",
211
+ iconName: "lucide:this-icon-does-not-exist",
212
+ childLinks: [
213
+ ...Array.from({ length: 26 }, (_, i) => ({ name: `Behandlung ${i + 1}`, path: `/treatment-${i + 1}` })),
214
+ { name: "Same name", path: "/same-a" },
215
+ { name: "Same name", path: "/same-b" },
216
+ { name: "Kopfhautbehandlungmitspeziellenätherischenölenohneleerzeichendurchgehend", path: "/unbroken" },
217
+ { name: "💇‍♀️ Emoji first", path: "/emoji" },
218
+ ],
219
+ },
220
+ { name: "One child", childLinksTitle: "Only one", childLinks: [{ name: "The one", path: "/one" }] },
221
+ { name: "<b>not bold</b>", path: "/html" },
222
+ { name: "من نحن", path: "/rtl" },
223
+ ...Array.from({ length: 8 }, (_, i) => ({ name: `Item ${i + 1}`, path: `/item-${i + 1}` })),
224
+ ],
225
+ secondary: [{ name: "Kontakt und Terminvereinbarung", path: "/contact" }],
226
+ };
227
+
228
+ export const StressTest: Story = {
229
+ name: "Stress Test (Worst-Case Data)",
230
+ args: {
231
+ responsiveNavLinks: stressNavLinks,
232
+ mainNavAriaLabel: "Hauptnavigation",
233
+ secondaryNavAriaLabel: "Sekundäre Navigation",
234
+ overflowMenuAriaLabel: "Weitere Navigationspunkte",
235
+ overflowButtonLabel: "Weitere Navigationspunkte anzeigen",
236
+ submenuAriaLabel: "Untermenü {title}",
237
+ },
238
+ parameters: {
239
+ initialCanvas: "laptopCanvas",
240
+ docs: {
241
+ description: {
242
+ story:
243
+ "Deliberately hostile data to find breakage: 16 top-level items in group keys other than firstNav/secondNav, " +
244
+ "a 70-character unbroken item name, a dropdown with no childLinksTitle (its name is shown instead) and 30 children " +
245
+ "including duplicate names, an unbroken name and emoji, a dropdown with one child, a broken icon name, HTML-like text " +
246
+ "(must render as text), right-to-left Arabic and German labels throughout. Check at every canvas width: items that " +
247
+ "don't fit collapse into the overflow menu, the burger only appears when something has collapsed, the top-bar dropdown " +
248
+ "and the overflow panel never run off-screen (long names wrap, 30 children scroll inside the panel), and nothing " +
249
+ "shows 'undefined'. Resize the canvas back and forth to check items return to the bar.",
250
+ },
251
+ },
252
+ },
253
+ };
@@ -0,0 +1,21 @@
1
+
2
+ import { defuFn } from 'defu'
3
+
4
+ const inlineConfig = {
5
+ "nuxt": {}
6
+ }
7
+
8
+ /** client **/
9
+ import { _replaceAppConfig } from '#app/config'
10
+
11
+ // Vite - webpack is handled directly in #app/config
12
+ if (import.meta.dev && !import.meta.nitro && import.meta.hot) {
13
+ import.meta.hot.accept((newModule) => {
14
+ _replaceAppConfig(newModule.default)
15
+ })
16
+ }
17
+ /** client-end **/
18
+
19
+
20
+
21
+ export default /*@__PURE__*/ defuFn(inlineConfig)
@@ -0,0 +1,66 @@
1
+
2
+ import type { DefineComponent, SlotsType } from 'vue'
3
+ type IslandComponent<T> = DefineComponent<{}, {refresh: () => Promise<void>}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, SlotsType<{ fallback: { error: unknown } }>> & T
4
+
5
+ type HydrationStrategies = {
6
+ hydrateOnVisible?: IntersectionObserverInit | true
7
+ hydrateOnIdle?: number | true
8
+ hydrateOnInteraction?: keyof HTMLElementEventMap | Array<keyof HTMLElementEventMap> | true
9
+ hydrateOnMediaQuery?: string
10
+ hydrateAfter?: number
11
+ hydrateWhen?: boolean
12
+ hydrateNever?: true
13
+ }
14
+ type LazyComponent<T> = DefineComponent<HydrationStrategies, {}, {}, {}, {}, {}, {}, { hydrated: () => void }> & T
15
+
16
+
17
+ export const NuxtWelcome: typeof import("../../../../../../node_modules/nuxt/dist/app/components/welcome.vue")['default']
18
+ export const NuxtLayout: typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-layout")['default']
19
+ export const NuxtErrorBoundary: typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-error-boundary.vue")['default']
20
+ export const ClientOnly: typeof import("../../../../../../node_modules/nuxt/dist/app/components/client-only")['default']
21
+ export const DevOnly: typeof import("../../../../../../node_modules/nuxt/dist/app/components/dev-only")['default']
22
+ export const ServerPlaceholder: typeof import("../../../../../../node_modules/nuxt/dist/app/components/server-placeholder")['default']
23
+ export const NuxtLink: typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-link")['default']
24
+ export const NuxtLoadingIndicator: typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-loading-indicator")['default']
25
+ export const NuxtTime: typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-time.vue")['default']
26
+ export const NuxtRouteAnnouncer: typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-route-announcer")['default']
27
+ export const NuxtAnnouncer: typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-announcer")['default']
28
+ export const NuxtImg: typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtImg']
29
+ export const NuxtPicture: typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtPicture']
30
+ export const NuxtPage: typeof import("../../../../../../node_modules/nuxt/dist/pages/runtime/page-placeholder")['default']
31
+ export const NoScript: typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['NoScript']
32
+ export const Link: typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['Link']
33
+ export const Base: typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['Base']
34
+ export const Title: typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['Title']
35
+ export const Meta: typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['Meta']
36
+ export const Style: typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['Style']
37
+ export const Head: typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['Head']
38
+ export const Html: typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['Html']
39
+ export const Body: typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['Body']
40
+ export const NuxtIsland: typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-island")['default']
41
+ export const LazyNuxtWelcome: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/app/components/welcome.vue")['default']>
42
+ export const LazyNuxtLayout: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-layout")['default']>
43
+ export const LazyNuxtErrorBoundary: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-error-boundary.vue")['default']>
44
+ export const LazyClientOnly: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/app/components/client-only")['default']>
45
+ export const LazyDevOnly: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/app/components/dev-only")['default']>
46
+ export const LazyServerPlaceholder: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/app/components/server-placeholder")['default']>
47
+ export const LazyNuxtLink: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-link")['default']>
48
+ export const LazyNuxtLoadingIndicator: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-loading-indicator")['default']>
49
+ export const LazyNuxtTime: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-time.vue")['default']>
50
+ export const LazyNuxtRouteAnnouncer: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-route-announcer")['default']>
51
+ export const LazyNuxtAnnouncer: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-announcer")['default']>
52
+ export const LazyNuxtImg: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtImg']>
53
+ export const LazyNuxtPicture: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-stubs")['NuxtPicture']>
54
+ export const LazyNuxtPage: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/pages/runtime/page-placeholder")['default']>
55
+ export const LazyNoScript: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['NoScript']>
56
+ export const LazyLink: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['Link']>
57
+ export const LazyBase: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['Base']>
58
+ export const LazyTitle: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['Title']>
59
+ export const LazyMeta: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['Meta']>
60
+ export const LazyStyle: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['Style']>
61
+ export const LazyHead: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['Head']>
62
+ export const LazyHtml: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['Html']>
63
+ export const LazyBody: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/head/runtime/components")['Body']>
64
+ export const LazyNuxtIsland: LazyComponent<typeof import("../../../../../../node_modules/nuxt/dist/app/components/nuxt-island")['default']>
65
+
66
+ export const componentNames: string[]