srcdev-nuxt-components 9.3.0 → 9.3.2

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 (53) hide show
  1. package/.claude/hooks/check-component-update.sh +66 -0
  2. package/.claude/settings.json +1 -1
  3. package/.claude/skills/component-local-style-override.md +45 -0
  4. package/.claude/skills/components/accordian-core.md +26 -5
  5. package/.claude/skills/components/expanding-panel-classic.md +221 -0
  6. package/.claude/skills/components/expanding-panel.md +8 -8
  7. package/.claude/skills/components/navigation-items.md +51 -0
  8. package/.claude/skills/components/responsive-header.md +161 -0
  9. package/.claude/skills/components/site-header.md +98 -0
  10. package/.claude/skills/index.md +6 -1
  11. package/.claude/skills/page-transitions.md +116 -0
  12. package/.claude/skills/theming-form-geometry-tokens.md +12 -0
  13. package/.claude/skills/theming-override-default.md +14 -0
  14. package/.claude/skills/theming-partial-override.md +17 -0
  15. package/.vscode/srcdev-component-accordian-core.code-snippets +74 -0
  16. package/.vscode/srcdev-component-expanding-panel-classic.code-snippets +121 -0
  17. package/.vscode/srcdev-component-expanding-panel.code-snippets +1 -3
  18. package/.vscode/srcdev-component-responsive-header.code-snippets +46 -0
  19. package/.vscode/srcdev-component-site-header.code-snippets +74 -0
  20. package/app/components/02.molecules/expandable/accordian/AccordianCore.vue +19 -11
  21. package/app/components/02.molecules/expandable/accordian/CONSUMER-STYLING.md +93 -0
  22. package/app/components/02.molecules/expandable/accordian/stories/AccordianCore.stories.ts +31 -0
  23. package/app/components/02.molecules/expandable/accordian/tests/AccordianCore.spec.ts +31 -0
  24. package/app/components/02.molecules/expandable/accordian/tests/__snapshots__/AccordianCore.spec.ts.snap +7 -21
  25. package/app/components/02.molecules/expandable/expanding-panel/CONSUMER-STYLING.md +10 -10
  26. package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +35 -37
  27. package/app/components/02.molecules/expandable/expanding-panel/stories/ExpandingPanel.stories.ts +5 -6
  28. package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +5 -13
  29. package/app/components/02.molecules/expandable/expanding-panel-classic/CONSUMER-STYLING.md +103 -0
  30. package/app/components/02.molecules/expandable/expanding-panel-classic/ExpandingPanelClassic.vue +191 -0
  31. package/app/components/02.molecules/expandable/expanding-panel-classic/stories/ExpandingPanelClassic.stories.ts +293 -0
  32. package/app/components/02.molecules/expandable/expanding-panel-classic/tests/ExpandingPanelClassic.spec.ts +514 -0
  33. package/app/components/02.molecules/expandable/expanding-panel-classic/tests/__snapshots__/ExpandingPanelClassic.spec.ts.snap +59 -0
  34. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +66 -0
  35. package/app/components/{responsive-header → 03.organisms/responsive-header}/NavigationItems.vue +21 -28
  36. package/app/components/{responsive-header → 03.organisms/responsive-header}/ResponsiveHeader.vue +141 -43
  37. package/app/components/03.organisms/responsive-header/stories/NavigationItems.stories.ts +80 -0
  38. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +122 -0
  39. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +155 -0
  40. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +319 -0
  41. package/app/components/03.organisms/responsive-header/tests/__snapshots__/NavigationItems.spec.ts.snap +34 -0
  42. package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +72 -0
  43. package/app/components/03.organisms/site-header/CONSUMER-STYLING.md +60 -0
  44. package/app/components/03.organisms/site-header/SiteHeader.vue +96 -0
  45. package/app/components/03.organisms/site-header/stories/SiteHeader.stories.ts +135 -0
  46. package/app/components/03.organisms/site-header/tests/SiteHeader.spec.ts +103 -0
  47. package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +15 -0
  48. package/app/components/05.forms/input-select/CONSUMER-STYLING.md +42 -0
  49. package/app/components/05.forms/input-select/InputSelectCore.vue +21 -9
  50. package/app/components/05.forms/input-text/CONSUMER-STYLING.md +60 -0
  51. package/app/components/05.forms/input-text/InputTextCore.vue +17 -8
  52. package/app/layouts/default.vue +64 -67
  53. package/package.json +4 -1
@@ -60,7 +60,6 @@
60
60
  v-model="panelOpenStates[`${String(groupKey)}-${localIndex}`]"
61
61
  name="overflow-navigation-group"
62
62
  :animation-duration="DETAILS_ANIMATION_DURATION"
63
- icon-size="medium"
64
63
  :style-class-passthrough="['overflow-navigation-details']"
65
64
  >
66
65
  <template #summary>
@@ -107,7 +106,7 @@
107
106
  </template>
108
107
 
109
108
  <script setup lang="ts">
110
- import type { ResponsiveHeaderState, ResponsiveHeaderNavItem } from "../../types/components";
109
+ import type { ResponsiveHeaderState, ResponsiveHeaderNavItem } from "../../../types/components";
111
110
 
112
111
  interface Props {
113
112
  mainNavigationState?: ResponsiveHeaderState;
@@ -259,10 +258,8 @@ watch(
259
258
  }
260
259
  + .expanding-panel-content {
261
260
  border-bottom: 0.1rem solid var(--overflow-nav-link-border-color, #efefef75);
262
- .inner {
263
- .overflow-navigation-sub-nav-inner {
264
- margin-top: var(--overflow-nav-items-gap, 0px);
265
- }
261
+ .overflow-navigation-sub-nav-inner {
262
+ margin-top: var(--overflow-nav-items-gap, 0px);
266
263
  }
267
264
  }
268
265
  }
@@ -271,29 +268,25 @@ watch(
271
268
  .expanding-panel-content {
272
269
  border-bottom: 0.1rem solid transparent;
273
270
 
274
- .inner {
271
+ .overflow-navigation-sub-nav-inner {
275
272
  margin-top: 0;
276
-
277
- .overflow-navigation-sub-nav-inner {
278
- margin-top: 0;
279
- position: relative;
280
-
281
- .overflow-navigation-sub-nav-list {
282
- display: flex;
283
- flex-direction: column;
284
- gap: 2px;
285
-
286
- .overflow-navigation-sub-nav-item {
287
- padding-block: var(--overflow-nav-items-padding-block, 0.8rem);
288
- padding-inline: var(--overflow-nav-padding-inline, 0.8rem);
289
- font-size: var(--overflow-nav-sub-item-font-size, inherit);
290
- color: var(--overflow-nav-sub-item-color, inherit);
291
-
292
- .overflow-navigation-sub-nav-link {
293
- display: block;
294
- text-decoration: none;
295
- color: inherit;
296
- }
273
+ position: relative;
274
+
275
+ .overflow-navigation-sub-nav-list {
276
+ display: flex;
277
+ flex-direction: column;
278
+ gap: 2px;
279
+
280
+ .overflow-navigation-sub-nav-item {
281
+ padding-block: var(--overflow-nav-items-padding-block, 0.8rem);
282
+ padding-inline: var(--overflow-nav-padding-inline, 0.8rem);
283
+ font-size: var(--overflow-nav-sub-item-font-size, inherit);
284
+ color: var(--overflow-nav-sub-item-color, inherit);
285
+
286
+ .overflow-navigation-sub-nav-link {
287
+ display: block;
288
+ text-decoration: none;
289
+ color: inherit;
297
290
  }
298
291
  }
299
292
  }
@@ -63,7 +63,7 @@
63
63
 
64
64
  {{ link.childLinksTitle }}
65
65
  </summary>
66
- <div class="main-navigation-sub-nav" role="menu">
66
+ <div class="main-navigation-sub-nav" role="menu" @mouseenter="handleSubNavHover">
67
67
  <ul class="main-navigation-sub-nav-list">
68
68
  <li v-for="childLink in link.childLinks" :key="childLink.name" class="main-navigation-sub-nav-item">
69
69
  <NuxtLink :to="childLink.path" class="main-navigation-sub-nav-link" role="menuitem">
@@ -114,7 +114,7 @@ import type {
114
114
  ResponsiveHeaderState,
115
115
  IFlooredRect,
116
116
  ResponsiveHeaderNavItem,
117
- } from "../../types/components";
117
+ } from "../../../types/components";
118
118
  import { useResizeObserver, onClickOutside } from "@vueuse/core";
119
119
 
120
120
  interface Props {
@@ -164,6 +164,51 @@ const closeAllNavigationDetails = () => {
164
164
  overflowDetailsRef.value?.removeAttribute("open");
165
165
  };
166
166
 
167
+ // ─── Hover-intent delay for the "safe triangle" problem ───────────────────
168
+ // handleNavigationItemHover() used to call closeAllNavigationDetails() the
169
+ // instant the mouse entered ANY .main-navigation-item — including a sibling
170
+ // crossed only in transit while moving diagonally from an open dropdown's
171
+ // summary down into its own .main-navigation-sub-nav panel, closing it
172
+ // before the user got there.
173
+ //
174
+ // A pure-CSS "safe triangle" bridge was tried first and reverted: for the
175
+ // bridge to escape .main-navigation-item's overflow:hidden the same way
176
+ // .main-navigation-sub-nav does, its containing block must resolve to
177
+ // .navigation (the only positioned ancestor above the clipping box) — but
178
+ // any element it's attached to that's already positioned (the summary)
179
+ // terminates that search at itself, landing the bridge back inside the
180
+ // clipped box; anything not already positioned falls back to the CSS
181
+ // static-position algorithm, which doesn't reliably land a generated
182
+ // pseudo-element where the visible content is. Neither is fixable without
183
+ // restructuring the panel's positioning, which the collapse-measurement
184
+ // pipeline depends on (see the measurement-pipeline note above).
185
+ //
186
+ // This delay sidesteps that entirely: closing is scheduled, not immediate,
187
+ // and cancelled if the cursor reaches the summary or the sub-nav panel
188
+ // before it fires — so a brief diagonal dip through a sibling's hit area
189
+ // survives, while genuinely moving elsewhere still closes promptly.
190
+ const HOVER_CLOSE_DELAY = 200;
191
+ let closeAllTimer: ReturnType<typeof setTimeout> | null = null;
192
+
193
+ const cancelScheduledClose = () => {
194
+ if (closeAllTimer) {
195
+ clearTimeout(closeAllTimer);
196
+ closeAllTimer = null;
197
+ }
198
+ };
199
+
200
+ const scheduleCloseAllNavigationDetails = () => {
201
+ cancelScheduledClose();
202
+ closeAllTimer = setTimeout(() => {
203
+ closeAllNavigationDetails();
204
+ closeAllTimer = null;
205
+ }, HOVER_CLOSE_DELAY);
206
+ };
207
+
208
+ onUnmounted(() => {
209
+ cancelScheduledClose();
210
+ });
211
+
167
212
  const toggleDetailsElement = (event: Event) => {
168
213
  const summaryElement = event.currentTarget as HTMLElement;
169
214
  const parentDetailsElement = summaryElement.closest("details");
@@ -182,6 +227,10 @@ const handleSummaryHover = (event: MouseEvent | FocusEvent) => {
182
227
  return;
183
228
  }
184
229
 
230
+ // The cursor reached a summary — any close scheduled while it was in
231
+ // transit (see handleNavigationItemHover) no longer applies.
232
+ cancelScheduledClose();
233
+
185
234
  // Close all other open navigation details first
186
235
  const summaryElement = event.currentTarget as HTMLElement;
187
236
  const parentDetailsElement = summaryElement.closest("details");
@@ -193,8 +242,16 @@ const handleSummaryHover = (event: MouseEvent | FocusEvent) => {
193
242
  });
194
243
  overflowDetailsRef.value?.removeAttribute("open");
195
244
 
196
- // Then toggle the current one
197
- toggleDetailsElement(event);
245
+ // Ensure THIS one is open — never toggle it closed here. A real mouse click
246
+ // moves focus to the summary before the click event fires, so a single
247
+ // click on an already-(hover-)open item fires focusin (this handler) AND
248
+ // click (handleSummaryAction, which does its own toggle) in quick
249
+ // succession. If this handler also toggled, an already-open item would
250
+ // flip closed here and then flip back open in handleSummaryAction — a
251
+ // visible open→closed→open (or the reverse) flicker on every click.
252
+ // Hover/focus only ever guarantees "this one is open"; only an explicit
253
+ // click is allowed to close it.
254
+ parentDetailsElement?.setAttribute("open", "");
198
255
  };
199
256
 
200
257
  const handleNavigationItemHover = (key: string) => {
@@ -202,7 +259,13 @@ const handleNavigationItemHover = (key: string) => {
202
259
  if (!props.allowExpandOnGesture) {
203
260
  return;
204
261
  }
205
- closeAllNavigationDetails();
262
+ scheduleCloseAllNavigationDetails();
263
+ };
264
+
265
+ // Reaching the actual destination panel cancels the scheduled close from
266
+ // handleNavigationItemHover — same reasoning as handleSummaryHover above.
267
+ const handleSubNavHover = () => {
268
+ cancelScheduledClose();
206
269
  };
207
270
 
208
271
  const handleNavFocusout = (event: FocusEvent) => {
@@ -455,42 +518,21 @@ const setupClickOutsideListeners = () => {
455
518
  }
456
519
  };
457
520
 
458
- onMounted(async () => {
459
- // Always reset on every mount this is a local ref so it naturally resets,
460
- // but being explicit here makes the intent clear: geometry must be re-verified
461
- // on every route change before the overflow button appears or transitions play.
462
- isGeometryReady.value = false;
463
-
464
- // Always (re-)populate the local nav refs — firstNavRef / secondNavRef are local
465
- // refs that reset on unmount, so they must be re-assigned on every mount even
466
- // when the useState flags say we're already initialized.
467
- await initTemplateRefs();
468
-
469
- if (!navigationInitialized.value || !navLoaded.value) {
470
- navLoaded.value = true;
471
- navigationInitialized.value = true;
472
- }
473
-
474
- // Geometry recalculation is handled by useResizeObserver which fires when the
475
- // element first becomes observed. isGeometryReady is set there after the pass.
476
- setupClickOutsideListeners();
477
- });
478
-
479
- // ─── Re-entrancy guard for the ResizeObserver callback ────────────────────
480
- // The observer fires an async callback that awaits multiple ticks and toggles
481
- // layout-affecting state. If the observer fires again while the first pass is
482
- // still in flight, two concurrent passes would race on isOverflowVisibleForMeasurement
483
- // and mainNavigationState.
521
+ // ─── Re-entrancy guard for the geometry pass ──────────────────────────────
522
+ // The pass is async and awaits multiple ticks while toggling layout-affecting
523
+ // state. If it's triggered again (resize, or the post-fonts-ready recheck
524
+ // below) while a pass is still in flight, two concurrent passes would race on
525
+ // isOverflowVisibleForMeasurement and mainNavigationState.
484
526
  //
485
527
  // Pattern: "run-latest" queue.
486
- // • isMeasuring gates entry — any new observer call while a pass is running
528
+ // • isMeasuring gates entry — any new trigger while a pass is running
487
529
  // sets pendingMeasure = true and returns immediately.
488
- // • The do/while re-runs once after the pass completes if a new resize arrived,
489
- // so we never silently drop the final geometry update.
530
+ // • The do/while re-runs once after the pass completes if a new trigger
531
+ // arrived, so we never silently drop the final geometry update.
490
532
  let isMeasuring = false;
491
533
  let pendingMeasure = false;
492
534
 
493
- useResizeObserver(navigationWrapperRef, async () => {
535
+ const runGeometryPass = async () => {
494
536
  if (isMeasuring) {
495
537
  pendingMeasure = true;
496
538
  return;
@@ -538,15 +580,51 @@ useResizeObserver(navigationWrapperRef, async () => {
538
580
  // ─── Normal resize after geometry is settled ───────────────────────────
539
581
  // The button's state is already correct (driven by showOverflowDetails),
540
582
  // so a single-pass measurement gives accurate secondaryNavRects.
541
- await updateNavigationConfig("useResizeObserver");
583
+ await updateNavigationConfig("resize-or-fonts-ready");
542
584
  initMainNavigationState();
543
585
  }
544
586
  } while (pendingMeasure);
545
587
  } finally {
546
588
  isMeasuring = false;
547
589
  }
590
+ };
591
+
592
+ onMounted(async () => {
593
+ // Always reset on every mount — this is a local ref so it naturally resets,
594
+ // but being explicit here makes the intent clear: geometry must be re-verified
595
+ // on every route change before the overflow button appears or transitions play.
596
+ isGeometryReady.value = false;
597
+
598
+ // Always (re-)populate the local nav refs — firstNavRef / secondNavRef are local
599
+ // refs that reset on unmount, so they must be re-assigned on every mount even
600
+ // when the useState flags say we're already initialized.
601
+ await initTemplateRefs();
602
+
603
+ if (!navigationInitialized.value || !navLoaded.value) {
604
+ navLoaded.value = true;
605
+ navigationInitialized.value = true;
606
+ }
607
+
608
+ // Geometry recalculation is handled by useResizeObserver which fires when the
609
+ // element first becomes observed. isGeometryReady is set there after the pass.
610
+ setupClickOutsideListeners();
611
+
612
+ // ─── Re-verify geometry once webfonts have settled ────────────────────────
613
+ // The initial ResizeObserver-driven pass can run before self-hosted fonts
614
+ // (see nuxt.config.ts `fonts.families`) have swapped in, measuring nav-item
615
+ // widths against fallback-font metrics. That undercounts item width, letting
616
+ // one extra item fit that shouldn't — the wrapper's own size hasn't changed,
617
+ // so the ResizeObserver never refires on its own to correct it. document.fonts
618
+ // isn't available in SSR/non-browser test environments, so this is a no-op there.
619
+ if (typeof document !== "undefined" && document.fonts) {
620
+ document.fonts.ready.then(() => {
621
+ if (isGeometryReady.value) runGeometryPass();
622
+ });
623
+ }
548
624
  });
549
625
 
626
+ useResizeObserver(navigationWrapperRef, runGeometryPass);
627
+
550
628
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
551
629
 
552
630
  watch(
@@ -611,8 +689,17 @@ watch(
611
689
  --responsive-nav-decorator-indicator-color (default: currentColor)
612
690
  --responsive-nav-decorator-hovered-indicator-color (default: inherits --responsive-nav-decorator-indicator-color)
613
691
  --responsive-nav-decorator-hovered-bg (default: oklch(100% 0 0 / 8%))
692
+
693
+ --responsive-header-link-font-size (default: inherit)
614
694
  ──────────────────────────────────────────────────────────────────────── */
615
695
 
696
+ /* `inherit` means a viewport-relative (vw/clamp) ancestor font-size silently
697
+ flows into the overflow-collapse width measurement — the ResizeObserver
698
+ only watches this element's own box size, so a scrollbar-driven vw shift
699
+ can change nav-item text width without ever re-triggering a re-measurement.
700
+ Set this token to a fixed value (or a small set of breakpoint-based values)
701
+ to avoid it. */
702
+
616
703
  --_link-visibility-transition: none;
617
704
  position: relative;
618
705
  color: var(--responsive-header-color, inherit);
@@ -682,6 +769,7 @@ watch(
682
769
  display: flex;
683
770
  gap: 6px;
684
771
  text-wrap-mode: nowrap;
772
+ font-size: var(--responsive-header-link-font-size, inherit);
685
773
  color: var(--responsive-header-link-color, inherit);
686
774
  text-decoration: none;
687
775
  cursor: pointer;
@@ -696,6 +784,17 @@ watch(
696
784
  border-bottom: var(--_link-border-default);
697
785
  }
698
786
 
787
+ /* Reserved so the icon's async-loaded SVG (Icon component) can't widen
788
+ the item after the initial geometry-measurement pass has already run —
789
+ an unsized icon measures at ~0 width until its content arrives, which
790
+ the overflow-collapse ResizeObserver has no way to detect afterwards
791
+ (the wrapper's own box doesn't necessarily change size when it happens). */
792
+ .decorator-icon {
793
+ flex-shrink: 0;
794
+ width: 1.35em;
795
+ height: 1.35em;
796
+ }
797
+
699
798
  .main-navigation-details {
700
799
  --_icon-transform: scaleY(1);
701
800
 
@@ -711,8 +810,13 @@ watch(
711
810
  gap: 6px;
712
811
  text-wrap-mode: nowrap;
713
812
 
813
+ /* Reserved for the same reason as .decorator-icon above — an
814
+ unsized chevron would widen the summary once its SVG loads. */
714
815
  .icon {
715
816
  display: block;
817
+ flex-shrink: 0;
818
+ width: 1.35em;
819
+ height: 1.35em;
716
820
  transform: var(--_icon-transform);
717
821
  transition: transform 0.2s ease-in-out;
718
822
  }
@@ -728,6 +832,7 @@ watch(
728
832
  cursor: pointer;
729
833
  position: relative;
730
834
  z-index: 4;
835
+ font-size: var(--responsive-header-link-font-size, inherit);
731
836
  color: var(--responsive-header-link-color, inherit);
732
837
 
733
838
  &::-webkit-details-marker,
@@ -836,13 +941,6 @@ watch(
836
941
  }
837
942
  }
838
943
 
839
- .main-navigation-link {
840
- .icon {
841
- height: 1.35em;
842
- width: 1.35em;
843
- }
844
- }
845
-
846
944
  .overflow-details {
847
945
  list-style: none;
848
946
  padding: 0;
@@ -0,0 +1,80 @@
1
+ import NavigationItems from "../NavigationItems.vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import type { ResponsiveHeaderState } from "../../../../types/components";
4
+
5
+ const mainNavigationState: ResponsiveHeaderState = {
6
+ hasSecondNav: true,
7
+ navListVisibility: { firstNav: false, secondNav: false },
8
+ clonedNavLinks: {
9
+ firstNav: [
10
+ { name: "Home", path: "/", config: { left: 0, right: 0, width: 0, visible: false } },
11
+ { name: "About", path: "/about", config: { left: 0, right: 0, width: 0, visible: false } },
12
+ {
13
+ name: "Components",
14
+ childLinksTitle: "UI Components",
15
+ childLinks: [
16
+ { name: "Buttons", path: "/forms/examples/buttons" },
17
+ { name: "Tabs", path: "/ui/tabs" },
18
+ ],
19
+ config: { left: 0, right: 0, width: 0, visible: false },
20
+ },
21
+ ],
22
+ secondNav: [{ name: "Contact", path: "/contact", config: { left: 0, right: 0, width: 0, visible: false } }],
23
+ },
24
+ };
25
+
26
+ const meta: Meta<typeof NavigationItems> = {
27
+ title: "Organisms/Responsive Header/Navigation Items",
28
+ component: NavigationItems,
29
+ parameters: {
30
+ docs: {
31
+ description: {
32
+ component:
33
+ "Internal panel rendered inside ResponsiveHeader's overflow burger menu — not typically used standalone. " +
34
+ "Shows the *complement* of ResponsiveHeader's main nav: an item appears here only when its `config.visible` " +
35
+ "is `false` in the main bar.",
36
+ },
37
+ },
38
+ },
39
+ argTypes: {
40
+ mainNavigationState: {
41
+ control: "object",
42
+ description: "Shared geometry/visibility state, normally supplied by the parent ResponsiveHeader",
43
+ },
44
+ styleClassPassthrough: {
45
+ control: "object",
46
+ description: "Extra CSS classes applied to the root element",
47
+ },
48
+ },
49
+ args: {
50
+ mainNavigationState,
51
+ styleClassPassthrough: [],
52
+ },
53
+ };
54
+
55
+ export default meta;
56
+ type Story = StoryObj<typeof NavigationItems>;
57
+
58
+ export const Default: Story = {};
59
+
60
+ export const MixedVisibility: Story = {
61
+ name: "Mixed Visibility (only hidden items shown)",
62
+ args: {
63
+ mainNavigationState: {
64
+ hasSecondNav: false,
65
+ navListVisibility: { firstNav: false },
66
+ clonedNavLinks: {
67
+ firstNav: [
68
+ { name: "Home", path: "/", config: { left: 0, right: 0, width: 0, visible: true } },
69
+ { name: "About", path: "/about", config: { left: 0, right: 0, width: 0, visible: false } },
70
+ { name: "Contact", path: "/contact", config: { left: 0, right: 0, width: 0, visible: false } },
71
+ ],
72
+ },
73
+ },
74
+ },
75
+ };
76
+
77
+ export const Empty: Story = {
78
+ name: "Empty (nothing collapsed)",
79
+ args: { mainNavigationState: { hasSecondNav: false, navListVisibility: {}, clonedNavLinks: {} } },
80
+ };
@@ -0,0 +1,122 @@
1
+ import ResponsiveHeader from "../ResponsiveHeader.vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+
4
+ const meta: Meta<typeof ResponsiveHeader> = {
5
+ title: "Organisms/Responsive Header",
6
+ component: ResponsiveHeader,
7
+ argTypes: {
8
+ responsiveNavLinks: {
9
+ control: "object",
10
+ description: "Nav groups keyed by name — each item is either a link (`path`) or a dropdown (`childLinks`)",
11
+ },
12
+ gapBetweenFirstAndSecondNav: {
13
+ control: { type: "number", min: 0, step: 4 },
14
+ description: "Gap in pixels reserved between the first and second nav groups",
15
+ },
16
+ overflowDetailsSummaryIcons: {
17
+ control: "object",
18
+ description: "Icon names for the overflow button's two states: `more` (some items collapsed) and `burger`",
19
+ },
20
+ collapseBreakpoint: {
21
+ control: { type: "number" },
22
+ description: "Fixed pixel width below which the whole main nav collapses into the overflow burger menu",
23
+ },
24
+ collapseAtMainNavIntersection: {
25
+ control: "boolean",
26
+ description: "Collapse the whole main nav into the overflow burger menu once it no longer fits its container",
27
+ },
28
+ allowExpandOnGesture: {
29
+ control: "boolean",
30
+ description: "Allow a dropdown to open on hover/focus, not just click",
31
+ },
32
+ styleClassPassthrough: {
33
+ control: "object",
34
+ description: "Extra CSS classes applied to the root element",
35
+ },
36
+ },
37
+ args: {
38
+ gapBetweenFirstAndSecondNav: 12,
39
+ collapseBreakpoint: null,
40
+ collapseAtMainNavIntersection: false,
41
+ allowExpandOnGesture: true,
42
+ styleClassPassthrough: [],
43
+ },
44
+ };
45
+
46
+ export default meta;
47
+ type Story = StoryObj<typeof ResponsiveHeader>;
48
+
49
+ const responsiveNavLinks = {
50
+ firstNav: [
51
+ { name: "Home", path: "/" },
52
+ {
53
+ name: "Components",
54
+ iconName: "material-symbols:widgets",
55
+ childLinksTitle: "UI Components",
56
+ childLinks: [
57
+ { name: "Buttons", path: "/forms/examples/buttons" },
58
+ { name: "Tabs", path: "/ui/tabs" },
59
+ { name: "Carousels", path: "/ui/carousel-basic" },
60
+ ],
61
+ },
62
+ { name: "Typography", path: "/typography/page-heading" },
63
+ ],
64
+ secondNav: [{ name: "Contact", path: "#" }],
65
+ };
66
+
67
+ export const Default: Story = {
68
+ args: { responsiveNavLinks },
69
+ };
70
+
71
+ /** Constrains the canvas so some items must collapse into the overflow burger menu —
72
+ * the same behaviour a narrow viewport triggers in a real page, not a bug. */
73
+ export const ConstrainedWidth: Story = {
74
+ name: "Constrained Width (items overflow)",
75
+ 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
+ }),
83
+ };
84
+
85
+ export const CustomOverflowIcons: Story = {
86
+ args: {
87
+ responsiveNavLinks,
88
+ overflowDetailsSummaryIcons: { more: "mdi:dots-horizontal", burger: "mdi:menu" },
89
+ },
90
+ };
91
+
92
+ export const WithSecondaryNavigationSlot: Story = {
93
+ name: "With secondaryNavigation slot",
94
+ args: { responsiveNavLinks },
95
+ render: (args) => ({
96
+ components: { ResponsiveHeader },
97
+ setup() {
98
+ return { args };
99
+ },
100
+ template: `
101
+ <ResponsiveHeader v-bind="args">
102
+ <template #secondaryNavigation>
103
+ <a href="/settings" aria-label="Settings">⚙</a>
104
+ </template>
105
+ </ResponsiveHeader>
106
+ `,
107
+ }),
108
+ };
109
+
110
+ export const SingleFlatGroup: Story = {
111
+ name: "Single Flat Group (no dropdowns)",
112
+ args: {
113
+ responsiveNavLinks: {
114
+ main: [
115
+ { name: "Home", path: "/" },
116
+ { name: "About", path: "/about" },
117
+ { name: "Services", path: "/services" },
118
+ { name: "Contact", path: "/contact" },
119
+ ],
120
+ },
121
+ },
122
+ };