srcdev-nuxt-components 9.0.15 → 9.0.17

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 (112) hide show
  1. package/.claude/settings.json +25 -0
  2. package/.claude/skills/component-aria-landmark.md +68 -0
  3. package/.claude/skills/component-dynamic-slots.md +150 -0
  4. package/.claude/skills/component-export-types.md +61 -0
  5. package/.claude/skills/component-local-style-override.md +126 -0
  6. package/.claude/skills/component-prop-driven-container-layout.md +42 -0
  7. package/.claude/skills/components/accordian-core.md +159 -0
  8. package/.claude/skills/components/contact-section.md +101 -0
  9. package/.claude/skills/components/expanding-panel.md +156 -0
  10. package/.claude/skills/components/eyebrow-text.md +25 -0
  11. package/.claude/skills/components/hero-text.md +25 -0
  12. package/.claude/skills/components/layout-grid-by-cols.md +147 -0
  13. package/.claude/skills/components/layout-row.md +35 -0
  14. package/.claude/skills/components/link-text.md +33 -0
  15. package/.claude/skills/components/page-hero-highlights.md +224 -0
  16. package/.claude/skills/components/services-card.md +28 -0
  17. package/.claude/skills/components/services-section.md +25 -0
  18. package/.claude/skills/components/stepper-list.md +227 -0
  19. package/.claude/skills/css-grid-max-width-gutters.md +67 -0
  20. package/.claude/skills/index.md +15 -3
  21. package/.claude/skills/storybook-add-story.md +60 -0
  22. package/.claude/skills/testing-add-unit-test.md +56 -0
  23. package/app/assets/styles/setup/01.config/index.css +0 -1
  24. package/app/assets/styles/setup/03.theming/default/_dark.css +2 -2
  25. package/app/assets/styles/setup/04.elements/forms/02.typography.css +1 -0
  26. package/app/assets/styles/setup/05.typography/02.utility-classes/_font-classes-page-link.css +14 -14
  27. package/app/assets/styles/setup/index.css +0 -1
  28. package/app/components/01.atoms/card/CardCore.vue +92 -0
  29. package/app/components/01.atoms/card/stories/CardCore.stories.ts +132 -0
  30. package/app/components/01.atoms/card/tests/CardCore.spec.ts +207 -0
  31. package/app/components/01.atoms/card/tests/__snapshots__/CardCore.spec.ts.snap +43 -0
  32. package/app/components/01.atoms/content-wrappers/content-columns-2/ContentColumns2.vue +51 -0
  33. package/app/components/01.atoms/content-wrappers/content-columns-2/stories/ContentColumns2.stories.ts +110 -0
  34. package/app/components/01.atoms/content-wrappers/content-columns-2/tests/ContentColumns2.spec.ts +105 -0
  35. package/app/components/01.atoms/content-wrappers/content-columns-2/tests/__snapshots__/ContentColumns2.spec.ts.snap +14 -0
  36. package/app/components/01.atoms/content-wrappers/content-width/ContentWidth.vue +88 -0
  37. package/app/components/01.atoms/content-wrappers/content-width/stories/ContentWidth.stories.ts +362 -0
  38. package/app/components/01.atoms/content-wrappers/content-width/tests/ContentWidth.spec.ts +132 -0
  39. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/LayoutGridByCols.vue +71 -0
  40. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/stories/LayoutGridByCols.stories.ts +219 -0
  41. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/tests/LayoutGridByCols.spec.ts +174 -0
  42. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/tests/__snapshots__/LayoutGrid.spec.ts.snap +36 -0
  43. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/tests/__snapshots__/LayoutGridByCols.spec.ts.snap +36 -0
  44. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-width/LayoutGridByWidth.vue +70 -0
  45. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-width/stories/LayoutGridByWidth.stories.ts +220 -0
  46. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-width/tests/LayoutGridByWidth.spec.ts +174 -0
  47. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-width/tests/__snapshots__/LayoutGrid.spec.ts.snap +36 -0
  48. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-width/tests/__snapshots__/LayoutGridByCols.spec.ts.snap +36 -0
  49. package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-width/tests/__snapshots__/LayoutGridByWidth.spec.ts.snap +36 -0
  50. package/app/components/01.atoms/text-blocks/eyebrow-text/stories/EyebrowText.stories.ts +1 -1
  51. package/app/components/01.atoms/text-blocks/hero-text/stories/HeroText.stories.ts +1 -1
  52. package/app/components/01.atoms/text-blocks/link-text/stories/LinkText.stories.ts +1 -1
  53. package/app/components/02.molecules/contact-section/stories/ContactSection.stories.ts +5 -0
  54. package/app/components/02.molecules/contact-section/tests/ContactSection.spec.ts +15 -0
  55. package/app/components/02.molecules/contact-section/tests/ContactSection.vue +25 -17
  56. package/app/components/{accordian → 02.molecules/expandable/accordian}/stories/AccordianCore.stories.ts +1 -1
  57. package/app/components/02.molecules/expandable/expanding-panel/stories/ExpandingPanel.stories.ts +245 -0
  58. package/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts +351 -0
  59. package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +38 -0
  60. package/app/components/02.molecules/navigation/navigation-horizontal/NavigationHorizontal.vue +162 -0
  61. package/app/components/02.molecules/navigation/navigation-horizontal/stories/NavigationHorizontal.stories.ts +373 -0
  62. package/app/components/02.molecules/navigation/navigation-horizontal/tests/NavigationHorizontal.spec.ts +152 -0
  63. package/app/components/02.molecules/navigation/navigation-horizontal/tests/__snapshots__/NavigationHorizontal.spec.ts.snap +17 -0
  64. package/app/components/02.molecules/profile-section/ProfileSection.vue +2 -3
  65. package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +2 -2
  66. package/app/components/02.molecules/stepper-list/StepperList.vue +131 -92
  67. package/app/components/02.molecules/stepper-list/stories/StepperList.stories.ts +31 -0
  68. package/app/components/02.molecules/stepper-list/tests/StepperList.spec.ts +24 -0
  69. package/app/components/02.molecules/stepper-list/tests/__snapshots__/StepperList.spec.ts.snap +22 -9
  70. package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +782 -0
  71. package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +233 -0
  72. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +226 -0
  73. package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +69 -0
  74. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +1 -1
  75. package/app/components/03.organisms/services/services-grids/ServicesSectionGrid.vue +1 -1
  76. package/app/components/03.organisms/services/services-section/ServicesSection.vue +2 -3
  77. package/app/components/04.templates/page-hero-highlights/PageHeroHighlights.vue +239 -0
  78. package/app/components/04.templates/page-hero-highlights/stories/PageHeroHighlights.stories.ts +404 -0
  79. package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlights.spec.ts +198 -0
  80. package/app/components/04.templates/page-hero-highlights/tests/__snapshots__/PageHeroHighlights.spec.ts.snap +19 -0
  81. package/app/components/container-glow/ContainerGlowCore.vue +20 -27
  82. package/app/components/forms/input-button/InputButtonCore.vue +105 -104
  83. package/app/components/glowing-border/stories/GlowingBorder.stories.ts +21 -21
  84. package/app/composables/useAriaLabelledById.ts +13 -0
  85. package/app/layouts/default.vue +8 -3
  86. package/app/pages/forms/examples/buttons/index.vue +6 -6
  87. package/app/pages/forms/examples/material/checkbox-radio-panels.vue +3 -3
  88. package/app/pages/forms/examples/material/text-fields.vue +607 -610
  89. package/app/pages/page-hero-highlights.vue +81 -0
  90. package/app/pages/ui/{display-card.vue → card-core.vue} +15 -15
  91. package/app/pages/ui/contact-section.vue +1 -1
  92. package/app/pages/ui/container-glow.vue +1 -1
  93. package/app/pages/ui/content-width.vue +126 -0
  94. package/app/pages/ui/glowing-border.vue +9 -9
  95. package/app/pages/ui/navigation/navigation-horizontal.vue +484 -0
  96. package/app/pages/ui/services/services-section/[slug].vue +3 -1
  97. package/app/types/components/index.ts +1 -0
  98. package/app/types/components/navigation-horizontal.d.ts +11 -0
  99. package/package.json +2 -2
  100. package/app/assets/styles/setup/01.config/_basic-resets.css +0 -9
  101. package/app/components/content-columns/TwoColumns.vue +0 -59
  102. package/app/components/content-columns/stories/TwoColumns.stories.ts +0 -561
  103. package/app/components/content-containers/ContentContainer.vue +0 -89
  104. package/app/components/content-containers/stories/ContentContainer.stories.ts +0 -465
  105. package/app/components/content-grid/ContentGrid.vue +0 -85
  106. package/app/components/display-card/DisplayCard.vue +0 -122
  107. package/app/components/image-galleries/SliderGallery.vue +0 -786
  108. package/app/pages/ui/content-container.vue +0 -112
  109. /package/app/components/{accordian → 02.molecules/expandable/accordian}/AccordianCore.vue +0 -0
  110. /package/app/components/{accordian → 02.molecules/expandable/accordian}/tests/AccordianCore.spec.ts +0 -0
  111. /package/app/components/{accordian → 02.molecules/expandable/accordian}/tests/__snapshots__/AccordianCore.spec.ts.snap +0 -0
  112. /package/app/components/{expanding-panel → 02.molecules/expandable/expanding-panel}/ExpandingPanel.vue +0 -0
@@ -0,0 +1,162 @@
1
+ <template>
2
+ <nav class="navigation-horizontal" :class="[elementClasses]">
3
+ <component :is="tag" class="navigation-horizontal-list">
4
+ <li v-for="(item, index) in navItemData.main" :key="index" :class="item.cssName">
5
+ <NuxtLink :href="item.href" :external="item.isExternal ? true : undefined">
6
+ <Icon v-if="item.iconName" :name="`icon-${item.iconName}`" />
7
+ {{ item.text }}
8
+ </NuxtLink>
9
+ </li>
10
+ </component>
11
+ </nav>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ import type { NavItemData } from "~/types/components/navigation-horizontal.d";
16
+
17
+ interface Props {
18
+ tag?: "ol" | "ul" | "div";
19
+ navItemData: NavItemData;
20
+ styleClassPassthrough?: string | string[];
21
+ }
22
+ const props = withDefaults(defineProps<Props>(), {
23
+ tag: "ul",
24
+ styleClassPassthrough: () => [],
25
+ });
26
+
27
+ const { elementClasses, updateElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
28
+
29
+ watch(
30
+ () => props.styleClassPassthrough,
31
+ () => {
32
+ updateElementClasses(props.styleClassPassthrough);
33
+ }
34
+ );
35
+ </script>
36
+
37
+ <style lang="css">
38
+ @layer components {
39
+ .navigation-horizontal {
40
+ /* ─── Public token API ─────────────────────────────────────────── */
41
+
42
+ /* Colours */
43
+ --_nav-canvas-colour: var(--page-bg);
44
+ --_active-link-colour: var(--nav-active-colour, lime);
45
+ --_link-colour: var(--nav-link-colour, light-dark(hsl(0 0% 10%), hsl(0 0% 100%)));
46
+ --_link-bg: var(--nav-link-bg, light-dark(hsl(0 0% 88%), hsl(0 0% 20%)));
47
+ --_border-colour: var(--nav-border-colour, light-dark(hsl(0 0% 0% / 0.15), hsl(0 0% 100% / 0.2)));
48
+
49
+ /* Borders */
50
+ --_border-block-start-size: var(--nav-border-start, 0);
51
+ --_border-block-end-size: var(--nav-border-end, 3px);
52
+
53
+ /* Layout */
54
+ --_list-padding: var(--nav-list-padding, 2rem);
55
+ --_list-gap: var(--nav-list-gap, 1rem);
56
+ --_link-padding-block: var(--nav-link-padding-block, 0.5rem);
57
+ --_link-padding-inline: var(--nav-link-padding-inline, 1rem);
58
+ --_link-border-radius: var(--nav-link-border-radius, 0.2rem);
59
+
60
+ /* Glow effect */
61
+ --_glow-pos-x: var(--nav-glow-pos-x, 50%);
62
+ --_glow-pos-y: var(--nav-glow-pos-y, 100%);
63
+ --_glow-inner-stop: var(--nav-glow-inner-stop, 10%);
64
+ --_glow-outer-stop: var(--nav-glow-outer-stop, 75%);
65
+ --_glow-size: var(--nav-glow-size, 32px);
66
+ --_glow-opacity: var(--nav-glow-opacity, 0.5);
67
+ --_anchor-offset: var(--nav-anchor-offset, 40px);
68
+
69
+ /* Animation */
70
+ --_transition-duration: var(--nav-transition-duration, 300ms);
71
+
72
+ /* ─────────────────────────────────────────────────────────────── */
73
+
74
+ anchor-name: --active-nav;
75
+
76
+ background-color: var(--_nav-canvas-colour);
77
+
78
+ &::after {
79
+ content: "";
80
+ border-block-start: var(--_border-block-start-size) solid transparent;
81
+ border-block-end: var(--_border-block-end-size) solid transparent;
82
+
83
+ background:
84
+ radial-gradient(
85
+ ellipse at var(--_glow-pos-x) var(--_glow-pos-y),
86
+ transparent var(--_glow-inner-stop),
87
+ var(--page-bg) var(--_glow-outer-stop)
88
+ )
89
+ padding-box,
90
+ radial-gradient(
91
+ ellipse at var(--_glow-pos-x) var(--_glow-pos-y),
92
+ var(--_active-link-colour) var(--_glow-inner-stop),
93
+ transparent var(--_glow-outer-stop)
94
+ )
95
+ border-box;
96
+
97
+ position: absolute;
98
+ position-anchor: --active-nav;
99
+
100
+ left: calc(anchor(left) - var(--_anchor-offset));
101
+ right: calc(anchor(right) - var(--_anchor-offset));
102
+ top: anchor(top --nav-ul);
103
+ bottom: anchor(bottom --nav-ul);
104
+
105
+ pointer-events: none;
106
+ z-index: -1;
107
+
108
+ opacity: 0;
109
+ transition:
110
+ inset var(--_transition-duration),
111
+ opacity 700ms;
112
+ transition-delay: 700ms, 0ms;
113
+ }
114
+
115
+ .navigation-horizontal-list {
116
+ anchor-name: --nav-ul;
117
+
118
+ border-block-start: var(--_border-block-start-size) solid var(--_border-colour);
119
+ border-block-end: var(--_border-block-end-size) solid var(--_border-colour);
120
+
121
+ a:is(:hover, :focus) {
122
+ anchor-name: --active-nav;
123
+ }
124
+ }
125
+
126
+ &:has(a:hover, a:focus)::after {
127
+ opacity: 1;
128
+ transition-delay: 0ms, 0ms;
129
+ }
130
+ }
131
+
132
+ @layer general-styling {
133
+ .navigation-horizontal {
134
+ .navigation-horizontal-list {
135
+ list-style: none;
136
+ margin: 0rem;
137
+ padding: var(--_list-padding);
138
+ gap: var(--_list-gap);
139
+
140
+ display: flex;
141
+ justify-content: center;
142
+ }
143
+
144
+ a {
145
+ color: var(--_link-colour);
146
+ text-decoration: none;
147
+ padding: var(--_link-padding-block) var(--_link-padding-inline);
148
+
149
+ border-radius: var(--_link-border-radius);
150
+ border-bottom: 2px solid transparent;
151
+ background: var(--_link-bg);
152
+ transition: background-color var(--_transition-duration);
153
+ }
154
+
155
+ a:is(:hover, :focus) {
156
+ border-color: var(--_active-link-colour);
157
+ box-shadow: 0 0 var(--_glow-size) oklch(from var(--_active-link-colour) l c h / var(--_glow-opacity));
158
+ }
159
+ }
160
+ }
161
+ }
162
+ </style>
@@ -0,0 +1,373 @@
1
+ import { ref, reactive, computed } from "vue";
2
+ import type { Meta, StoryFn } from "@nuxtjs/storybook";
3
+ import NavigationHorizontalComponent from "../NavigationHorizontal.vue";
4
+ import type { NavItemData } from "~/types/components/navigation-horizontal.d";
5
+
6
+ const meta: Meta<typeof NavigationHorizontalComponent> = {
7
+ title: "Molecules/NavigationHorizontal",
8
+ component: NavigationHorizontalComponent,
9
+ argTypes: {
10
+ tag: {
11
+ control: { type: "select" },
12
+ options: ["ul", "ol", "div"],
13
+ description: "HTML element to render the nav list as",
14
+ },
15
+ navItemData: { table: { disable: true } },
16
+ styleClassPassthrough: { table: { disable: true } },
17
+ },
18
+ args: {
19
+ tag: "ul",
20
+ },
21
+ };
22
+
23
+ export default meta;
24
+
25
+ const navItemData: NavItemData = {
26
+ main: [
27
+ { text: "Home", href: "#" },
28
+ { text: "About", href: "#" },
29
+ { text: "Services", href: "#" },
30
+ { text: "Contact", href: "#" },
31
+ ],
32
+ };
33
+
34
+ type Theme = "light" | "dark";
35
+
36
+ const themeDefaults: Record<Theme, { linkColour: string; linkBg: string; borderColour: string; borderOpacity: number }> = {
37
+ dark: { linkColour: "#ffffff", linkBg: "#333333", borderColour: "#ffffff", borderOpacity: 0.2 },
38
+ light: { linkColour: "#1a1a1a", linkBg: "#e8e8e8", borderColour: "#000000", borderOpacity: 0.15 },
39
+ };
40
+
41
+ const DefaultTemplate: StoryFn<typeof NavigationHorizontalComponent> = (args) => ({
42
+ components: { NavigationHorizontalComponent },
43
+ setup() {
44
+ const theme = ref<Theme>("dark");
45
+
46
+ const controls = reactive({
47
+ activeColour: "#00ff00",
48
+ linkColour: themeDefaults.dark.linkColour,
49
+ linkBg: themeDefaults.dark.linkBg,
50
+ borderColour: themeDefaults.dark.borderColour,
51
+ borderOpacity: themeDefaults.dark.borderOpacity,
52
+ borderStart: 0,
53
+ borderEnd: 3,
54
+ listPadding: 2,
55
+ listGap: 3,
56
+ linkPaddingBlock: 0.5,
57
+ linkPaddingInline: 1,
58
+ linkBorderRadius: 0.2,
59
+ glowPosX: 50,
60
+ glowPosY: 100,
61
+ glowInnerStop: 10,
62
+ glowOuterStop: 75,
63
+ glowSize: 32,
64
+ glowOpacity: 0.5,
65
+ anchorOffset: 40,
66
+ transitionDuration: 300,
67
+ });
68
+
69
+ const setTheme = (newTheme: Theme) => {
70
+ theme.value = newTheme;
71
+ const defaults = themeDefaults[newTheme];
72
+ controls.linkColour = defaults.linkColour;
73
+ controls.linkBg = defaults.linkBg;
74
+ controls.borderColour = defaults.borderColour;
75
+ controls.borderOpacity = defaults.borderOpacity;
76
+ };
77
+
78
+ const navStyle = computed(() => ({
79
+ "--nav-active-colour": controls.activeColour,
80
+ "--nav-link-colour": controls.linkColour,
81
+ "--nav-link-bg": controls.linkBg,
82
+ "--nav-border-colour": `color-mix(in srgb, ${controls.borderColour} ${Math.round(controls.borderOpacity * 100)}%, transparent)`,
83
+ "--nav-border-start": `${controls.borderStart}px`,
84
+ "--nav-border-end": `${controls.borderEnd}px`,
85
+ "--nav-list-padding": `${controls.listPadding}rem`,
86
+ "--nav-list-gap": `${controls.listGap}rem`,
87
+ "--nav-link-padding-block": `${controls.linkPaddingBlock}rem`,
88
+ "--nav-link-padding-inline": `${controls.linkPaddingInline}rem`,
89
+ "--nav-link-border-radius": `${controls.linkBorderRadius}rem`,
90
+ "--nav-glow-pos-x": `${controls.glowPosX}%`,
91
+ "--nav-glow-pos-y": `${controls.glowPosY}%`,
92
+ "--nav-glow-inner-stop": `${controls.glowInnerStop}%`,
93
+ "--nav-glow-outer-stop": `${controls.glowOuterStop}%`,
94
+ "--nav-glow-size": `${controls.glowSize}px`,
95
+ "--nav-glow-opacity": String(controls.glowOpacity),
96
+ "--nav-anchor-offset": `${controls.anchorOffset}px`,
97
+ "--nav-transition-duration": `${controls.transitionDuration}ms`,
98
+ "color-scheme": theme.value,
99
+ }));
100
+
101
+ const cssSnippet = computed(() => {
102
+ const borderColour = `color-mix(in srgb, ${controls.borderColour} ${Math.round(controls.borderOpacity * 100)}%, transparent)`;
103
+ return `.your-selector {
104
+ /* Colours */
105
+ --nav-active-colour: ${controls.activeColour};
106
+ --nav-link-colour: ${controls.linkColour};
107
+ --nav-link-bg: ${controls.linkBg};
108
+ --nav-border-colour: ${borderColour};
109
+
110
+ /* Borders */
111
+ --nav-border-start: ${controls.borderStart}px;
112
+ --nav-border-end: ${controls.borderEnd}px;
113
+
114
+ /* Layout */
115
+ --nav-list-padding: ${controls.listPadding}rem;
116
+ --nav-list-gap: ${controls.listGap}rem;
117
+ --nav-link-padding-block: ${controls.linkPaddingBlock}rem;
118
+ --nav-link-padding-inline: ${controls.linkPaddingInline}rem;
119
+ --nav-link-border-radius: ${controls.linkBorderRadius}rem;
120
+
121
+ /* Glow effect */
122
+ --nav-glow-pos-x: ${controls.glowPosX}%;
123
+ --nav-glow-pos-y: ${controls.glowPosY}%;
124
+ --nav-glow-inner-stop: ${controls.glowInnerStop}%;
125
+ --nav-glow-outer-stop: ${controls.glowOuterStop}%;
126
+ --nav-glow-size: ${controls.glowSize}px;
127
+ --nav-glow-opacity: ${controls.glowOpacity};
128
+ --nav-anchor-offset: ${controls.anchorOffset}px;
129
+
130
+ /* Animation */
131
+ --nav-transition-duration: ${controls.transitionDuration}ms;
132
+ }`;
133
+ });
134
+
135
+ const copied = ref(false);
136
+ const copySnippet = async () => {
137
+ await navigator.clipboard.writeText(cssSnippet.value);
138
+ copied.value = true;
139
+ setTimeout(() => { copied.value = false; }, 2000);
140
+ };
141
+
142
+ return { args, navItemData, theme, controls, setTheme, navStyle, cssSnippet, copied, copySnippet };
143
+ },
144
+ template: `
145
+ <div class="sb-nav-story">
146
+
147
+ <!-- Nav preview -->
148
+ <div class="sb-nav-preview" :class="'theme-' + theme" :style="navStyle">
149
+ <NavigationHorizontalComponent :tag="args.tag" :nav-item-data="navItemData" />
150
+ </div>
151
+
152
+ <!-- Controls -->
153
+ <div class="sb-nav-playground">
154
+
155
+ <fieldset>
156
+ <legend>Theme</legend>
157
+ <div class="sb-control-row">
158
+ <label>Light / Dark</label>
159
+ <div class="sb-theme-toggle">
160
+ <button :class="{ active: theme === 'light' }" @click="setTheme('light')">Light</button>
161
+ <button :class="{ active: theme === 'dark' }" @click="setTheme('dark')">Dark</button>
162
+ </div>
163
+ </div>
164
+ </fieldset>
165
+
166
+ <fieldset>
167
+ <legend>Colours</legend>
168
+ <div class="sb-control-row">
169
+ <label for="sb-active-colour">Active / glow colour</label>
170
+ <input id="sb-active-colour" v-model="controls.activeColour" type="color" />
171
+ </div>
172
+ <div class="sb-control-row">
173
+ <label for="sb-link-colour">Link text colour</label>
174
+ <input id="sb-link-colour" v-model="controls.linkColour" type="color" />
175
+ </div>
176
+ <div class="sb-control-row">
177
+ <label for="sb-link-bg">Link background</label>
178
+ <input id="sb-link-bg" v-model="controls.linkBg" type="color" />
179
+ </div>
180
+ <div class="sb-control-row">
181
+ <label for="sb-border-colour">Border colour</label>
182
+ <input id="sb-border-colour" v-model="controls.borderColour" type="color" />
183
+ </div>
184
+ <div class="sb-control-row">
185
+ <label for="sb-border-opacity">Border opacity — {{ controls.borderOpacity }}</label>
186
+ <input id="sb-border-opacity" v-model.number="controls.borderOpacity" type="range" min="0" max="1" step="0.05" />
187
+ </div>
188
+ </fieldset>
189
+
190
+ <fieldset>
191
+ <legend>Borders</legend>
192
+ <div class="sb-control-row">
193
+ <label for="sb-border-start">Border top — {{ controls.borderStart }}px</label>
194
+ <input id="sb-border-start" v-model.number="controls.borderStart" type="range" min="0" max="10" step="1" />
195
+ </div>
196
+ <div class="sb-control-row">
197
+ <label for="sb-border-end">Border bottom — {{ controls.borderEnd }}px</label>
198
+ <input id="sb-border-end" v-model.number="controls.borderEnd" type="range" min="0" max="10" step="1" />
199
+ </div>
200
+ </fieldset>
201
+
202
+ <fieldset>
203
+ <legend>Layout</legend>
204
+ <div class="sb-control-row">
205
+ <label for="sb-list-padding">List padding — {{ controls.listPadding }}rem</label>
206
+ <input id="sb-list-padding" v-model.number="controls.listPadding" type="range" min="0" max="6" step="0.5" />
207
+ </div>
208
+ <div class="sb-control-row">
209
+ <label for="sb-list-gap">List gap — {{ controls.listGap }}rem</label>
210
+ <input id="sb-list-gap" v-model.number="controls.listGap" type="range" min="0" max="10" step="0.25" />
211
+ </div>
212
+ <div class="sb-control-row">
213
+ <label for="sb-link-padding-block">Link vertical padding — {{ controls.linkPaddingBlock }}rem</label>
214
+ <input id="sb-link-padding-block" v-model.number="controls.linkPaddingBlock" type="range" min="0" max="2" step="0.1" />
215
+ </div>
216
+ <div class="sb-control-row">
217
+ <label for="sb-link-padding-inline">Link horizontal padding — {{ controls.linkPaddingInline }}rem</label>
218
+ <input id="sb-link-padding-inline" v-model.number="controls.linkPaddingInline" type="range" min="0" max="3" step="0.1" />
219
+ </div>
220
+ <div class="sb-control-row">
221
+ <label for="sb-link-border-radius">Link border radius — {{ controls.linkBorderRadius }}rem</label>
222
+ <input id="sb-link-border-radius" v-model.number="controls.linkBorderRadius" type="range" min="0" max="2" step="0.1" />
223
+ </div>
224
+ </fieldset>
225
+
226
+ <fieldset>
227
+ <legend>Glow Effect</legend>
228
+ <div class="sb-control-row">
229
+ <label for="sb-glow-pos-x">Origin X — {{ controls.glowPosX }}%</label>
230
+ <input id="sb-glow-pos-x" v-model.number="controls.glowPosX" type="range" min="-100" max="100" step="1" />
231
+ </div>
232
+ <div class="sb-control-row">
233
+ <label for="sb-glow-pos-y">Origin Y — {{ controls.glowPosY }}%</label>
234
+ <input id="sb-glow-pos-y" v-model.number="controls.glowPosY" type="range" min="-100" max="200" step="1" />
235
+ </div>
236
+ <div class="sb-control-row">
237
+ <label for="sb-glow-inner-stop">Inner gradient stop — {{ controls.glowInnerStop }}%</label>
238
+ <input id="sb-glow-inner-stop" v-model.number="controls.glowInnerStop" type="range" min="0" max="50" step="1" />
239
+ </div>
240
+ <div class="sb-control-row">
241
+ <label for="sb-glow-outer-stop">Outer gradient stop — {{ controls.glowOuterStop }}%</label>
242
+ <input id="sb-glow-outer-stop" v-model.number="controls.glowOuterStop" type="range" min="50" max="100" step="1" />
243
+ </div>
244
+ <div class="sb-control-row">
245
+ <label for="sb-glow-size">Glow spread — {{ controls.glowSize }}px</label>
246
+ <input id="sb-glow-size" v-model.number="controls.glowSize" type="range" min="0" max="100" step="1" />
247
+ </div>
248
+ <div class="sb-control-row">
249
+ <label for="sb-glow-opacity">Glow opacity — {{ controls.glowOpacity }}</label>
250
+ <input id="sb-glow-opacity" v-model.number="controls.glowOpacity" type="range" min="0" max="1" step="0.05" />
251
+ </div>
252
+ <div class="sb-control-row">
253
+ <label for="sb-anchor-offset">Anchor spread — {{ controls.anchorOffset }}px</label>
254
+ <input id="sb-anchor-offset" v-model.number="controls.anchorOffset" type="range" min="0" max="100" step="1" />
255
+ </div>
256
+ </fieldset>
257
+
258
+ <fieldset>
259
+ <legend>Animation</legend>
260
+ <div class="sb-control-row">
261
+ <label for="sb-transition-duration">Transition — {{ controls.transitionDuration }}ms</label>
262
+ <input id="sb-transition-duration" v-model.number="controls.transitionDuration" type="range" min="0" max="1000" step="50" />
263
+ </div>
264
+ </fieldset>
265
+
266
+ </div>
267
+
268
+ <!-- CSS snippet -->
269
+ <div class="sb-css-snippet">
270
+ <div class="sb-css-snippet-header">
271
+ <strong>CSS Token Snippet</strong>
272
+ <button class="sb-copy-btn" @click="copySnippet">{{ copied ? 'Copied!' : 'Copy' }}</button>
273
+ </div>
274
+ <pre class="sb-css-snippet-code">{{ cssSnippet }}</pre>
275
+ </div>
276
+
277
+ </div>
278
+
279
+ <style>
280
+ .sb-nav-story {
281
+ font-size: 1.4rem;
282
+ display: grid;
283
+ gap: 2.4rem;
284
+ padding: 2.4rem;
285
+ }
286
+ .sb-nav-preview {
287
+ padding: 4rem 0;
288
+ transition: background 300ms;
289
+ }
290
+ .sb-nav-preview.theme-dark { --page-bg: hsl(0 0% 8%); color-scheme: dark; background: var(--page-bg); }
291
+ .sb-nav-preview.theme-light { --page-bg: hsl(0 0% 95%); color-scheme: light; background: var(--page-bg); }
292
+ .sb-nav-playground {
293
+ display: grid;
294
+ grid-template-columns: repeat(auto-fit, minmax(30rem, 1fr));
295
+ gap: 1.6rem;
296
+ }
297
+ .sb-nav-playground fieldset {
298
+ border: 1px solid #ccc;
299
+ border-radius: 0.4rem;
300
+ padding: 1.6rem;
301
+ }
302
+ .sb-nav-playground legend {
303
+ font-weight: bold;
304
+ padding: 0 0.8rem;
305
+ font-size: 1.4rem;
306
+ }
307
+ .sb-control-row {
308
+ display: grid;
309
+ grid-template-columns: 1fr 14rem;
310
+ gap: 1rem;
311
+ align-items: center;
312
+ padding: 0.5rem 0;
313
+ font-size: 1.3rem;
314
+ }
315
+ .sb-control-row input[type="range"] { width: 100%; cursor: pointer; }
316
+ .sb-control-row input[type="color"] {
317
+ height: 3.2rem;
318
+ width: 100%;
319
+ padding: 0.2rem;
320
+ border: 1px solid #ccc;
321
+ border-radius: 0.2rem;
322
+ cursor: pointer;
323
+ background: transparent;
324
+ }
325
+ .sb-theme-toggle { display: flex; gap: 0.4rem; }
326
+ .sb-theme-toggle button {
327
+ padding: 0.4rem 1.6rem;
328
+ border: 1px solid #ccc;
329
+ border-radius: 0.2rem;
330
+ cursor: pointer;
331
+ background: transparent;
332
+ font-size: 1.3rem;
333
+ color: inherit;
334
+ }
335
+ .sb-theme-toggle button.active { background: #e0e0e0; font-weight: bold; }
336
+ .sb-css-snippet {
337
+ border: 1px solid #ccc;
338
+ border-radius: 0.4rem;
339
+ overflow: hidden;
340
+ }
341
+ .sb-css-snippet-header {
342
+ display: flex;
343
+ justify-content: space-between;
344
+ align-items: center;
345
+ padding: 1rem 1.6rem;
346
+ border-bottom: 1px solid #ccc;
347
+ font-size: 1.4rem;
348
+ }
349
+ .sb-css-snippet-code {
350
+ margin: 0;
351
+ padding: 1.6rem;
352
+ font-family: monospace;
353
+ font-size: 1.3rem;
354
+ line-height: 1.6;
355
+ overflow-x: auto;
356
+ white-space: pre;
357
+ }
358
+ .sb-copy-btn {
359
+ padding: 0.4rem 1.2rem;
360
+ border: 1px solid #ccc;
361
+ border-radius: 0.2rem;
362
+ cursor: pointer;
363
+ background: transparent;
364
+ font-size: 1.3rem;
365
+ color: inherit;
366
+ }
367
+ .sb-copy-btn:hover { background: #e0e0e0; }
368
+ </style>
369
+ `,
370
+ });
371
+
372
+ export const Default = DefaultTemplate.bind({});
373
+ Default.args = { tag: "ul" };
@@ -0,0 +1,152 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mountSuspended } from "@nuxt/test-utils/runtime";
3
+ import { nextTick } from "vue";
4
+ import NavigationHorizontal from "../NavigationHorizontal.vue";
5
+ import type { NavItemData } from "~/types/components/navigation-horizontal.d";
6
+
7
+ const defaultNavItemData: NavItemData = {
8
+ main: [
9
+ { text: "Home", href: "/" },
10
+ { text: "About", href: "/about" },
11
+ { text: "Contact", href: "/contact" },
12
+ ],
13
+ };
14
+
15
+ describe("NavigationHorizontal", () => {
16
+ it("mounts without error", async () => {
17
+ const wrapper = await mountSuspended(NavigationHorizontal, {
18
+ props: { navItemData: defaultNavItemData },
19
+ });
20
+ expect(wrapper.vm).toBeTruthy();
21
+ });
22
+
23
+ it("renders correct HTML structure", async () => {
24
+ const wrapper = await mountSuspended(NavigationHorizontal, {
25
+ props: { navItemData: defaultNavItemData },
26
+ });
27
+ expect(wrapper.html()).toMatchSnapshot();
28
+ });
29
+
30
+ it("renders a nav element as root", async () => {
31
+ const wrapper = await mountSuspended(NavigationHorizontal, {
32
+ props: { navItemData: defaultNavItemData },
33
+ });
34
+ expect(wrapper.element.tagName.toLowerCase()).toBe("nav");
35
+ });
36
+
37
+ it("renders a ul list by default", async () => {
38
+ const wrapper = await mountSuspended(NavigationHorizontal, {
39
+ props: { navItemData: defaultNavItemData },
40
+ });
41
+ expect(wrapper.find("ul").exists()).toBe(true);
42
+ expect(wrapper.find("ol").exists()).toBe(false);
43
+ });
44
+
45
+ it("renders an ol when tag prop is ol", async () => {
46
+ const wrapper = await mountSuspended(NavigationHorizontal, {
47
+ props: { navItemData: defaultNavItemData, tag: "ol" },
48
+ });
49
+ expect(wrapper.find("ol").exists()).toBe(true);
50
+ expect(wrapper.find("ul").exists()).toBe(false);
51
+ });
52
+
53
+ it("renders a div when tag prop is div", async () => {
54
+ const wrapper = await mountSuspended(NavigationHorizontal, {
55
+ props: { navItemData: defaultNavItemData, tag: "div" },
56
+ });
57
+ expect(wrapper.find("div.navigation-horizontal-list").exists()).toBe(true);
58
+ });
59
+
60
+ it("renders the correct number of nav items", async () => {
61
+ const wrapper = await mountSuspended(NavigationHorizontal, {
62
+ props: { navItemData: defaultNavItemData },
63
+ });
64
+ const items = wrapper.findAll("li");
65
+ expect(items.length).toBe(3);
66
+ });
67
+
68
+ it("renders item text correctly", async () => {
69
+ const wrapper = await mountSuspended(NavigationHorizontal, {
70
+ props: { navItemData: defaultNavItemData },
71
+ });
72
+ const links = wrapper.findAll("a");
73
+ expect(links[0]!.text()).toContain("Home");
74
+ expect(links[1]!.text()).toContain("About");
75
+ expect(links[2]!.text()).toContain("Contact");
76
+ });
77
+
78
+ it("renders item href correctly", async () => {
79
+ const wrapper = await mountSuspended(NavigationHorizontal, {
80
+ props: { navItemData: defaultNavItemData },
81
+ });
82
+ const links = wrapper.findAll("a");
83
+ expect(links[0]!.attributes("href")).toBe("/");
84
+ expect(links[1]!.attributes("href")).toBe("/about");
85
+ });
86
+
87
+ it("applies cssName as a class on the li element", async () => {
88
+ const navItemData: NavItemData = {
89
+ main: [{ text: "Home", href: "/", cssName: "is-active" }],
90
+ };
91
+ const wrapper = await mountSuspended(NavigationHorizontal, {
92
+ props: { navItemData },
93
+ });
94
+ expect(wrapper.find("li").classes()).toContain("is-active");
95
+ });
96
+
97
+ it("renders an icon when iconName is provided", async () => {
98
+ const navItemData: NavItemData = {
99
+ main: [{ text: "Home", href: "/", iconName: "home" }],
100
+ };
101
+ const wrapper = await mountSuspended(NavigationHorizontal, {
102
+ props: { navItemData },
103
+ });
104
+ expect(wrapper.find("svg, [class*='icon'], [name]").exists()).toBe(true);
105
+ });
106
+
107
+ it("does not render an icon when iconName is not provided", async () => {
108
+ const wrapper = await mountSuspended(NavigationHorizontal, {
109
+ props: { navItemData: defaultNavItemData },
110
+ });
111
+ // No Icon component should be rendered for items without iconName
112
+ expect(wrapper.findAll("li")[0]!.find("[name]").exists()).toBe(false);
113
+ });
114
+
115
+ it("sets external attribute on external links", async () => {
116
+ const navItemData: NavItemData = {
117
+ main: [{ text: "External", href: "https://example.com", isExternal: true }],
118
+ };
119
+ const wrapper = await mountSuspended(NavigationHorizontal, {
120
+ props: { navItemData },
121
+ });
122
+ const link = wrapper.find("a");
123
+ // NuxtLink renders with target="_blank" or rel for external links
124
+ expect(link.attributes("href")).toBe("https://example.com");
125
+ });
126
+
127
+ it("applies styleClassPassthrough classes to the nav element", async () => {
128
+ const wrapper = await mountSuspended(NavigationHorizontal, {
129
+ props: {
130
+ navItemData: defaultNavItemData,
131
+ styleClassPassthrough: ["custom-nav", "theme-dark"],
132
+ },
133
+ });
134
+ expect(wrapper.find(".navigation-horizontal").classes()).toContain("custom-nav");
135
+ expect(wrapper.find(".navigation-horizontal").classes()).toContain("theme-dark");
136
+ });
137
+
138
+ it("updates classes when styleClassPassthrough prop changes", async () => {
139
+ const wrapper = await mountSuspended(NavigationHorizontal, {
140
+ props: {
141
+ navItemData: defaultNavItemData,
142
+ styleClassPassthrough: ["initial-class"],
143
+ },
144
+ });
145
+ expect(wrapper.find(".navigation-horizontal").classes()).toContain("initial-class");
146
+
147
+ await wrapper.setProps({ styleClassPassthrough: ["updated-class"] });
148
+ await nextTick();
149
+
150
+ expect(wrapper.find(".navigation-horizontal").classes()).toContain("updated-class");
151
+ });
152
+ });