srcdev-nuxt-components 9.0.16 → 9.0.18

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.
@@ -0,0 +1,61 @@
1
+ # Export Component Types for Consumers
2
+
3
+ ## Overview
4
+
5
+ Types defined inline in a `.vue` component file are not easily importable by consuming apps. This skill moves them into `app/types/components/` and re-exports via the barrel, so consumers can import from the package root.
6
+
7
+ ## Steps
8
+
9
+ ### 1. Create a types file
10
+
11
+ Create `app/types/components/<component-name>.d.ts` with the exported interfaces:
12
+
13
+ ```ts
14
+ // app/types/components/navigation-horizontal.d.ts
15
+ export interface NavItem {
16
+ text: string;
17
+ href?: string;
18
+ isExternal?: boolean;
19
+ iconName?: string;
20
+ cssName?: string;
21
+ }
22
+
23
+ export interface NavItemData {
24
+ [key: string]: NavItem[];
25
+ }
26
+ ```
27
+
28
+ ### 2. Add to the barrel
29
+
30
+ In `app/types/components/index.ts`, add an export line:
31
+
32
+ ```ts
33
+ export * from "./navigation-horizontal.d"
34
+ ```
35
+
36
+ ### 3. Update the component
37
+
38
+ Replace the inline `export interface` blocks in the `.vue` file with an import from the shared types file:
39
+
40
+ ```ts
41
+ // Before
42
+ export interface NavItem { ... }
43
+ export interface NavItemData { ... }
44
+
45
+ // After
46
+ import type { NavItem, NavItemData } from "~/types/components/navigation-horizontal.d";
47
+ ```
48
+
49
+ ## Consuming app usage
50
+
51
+ Once published, consumers import from the package barrel:
52
+
53
+ ```ts
54
+ import type { NavItem, NavItemData } from "nuxt-components/app/types/components";
55
+ ```
56
+
57
+ ## Notes
58
+
59
+ - The `.d.ts` extension is conventional for type-only files but is not required — plain `.ts` works too (see `hero-text.ts`).
60
+ - Keep the type file minimal: only types, no runtime code.
61
+ - If a type is already used by multiple components, consider a shared location like `app/types/components/shared.d.ts` rather than naming it after one component.
@@ -0,0 +1,99 @@
1
+ # NavigationHorizontal
2
+
3
+ ## Overview
4
+
5
+ A horizontal navigation bar that renders a flat list of links with an animated glow/underline effect on the active link. Fully themeable via CSS custom properties.
6
+
7
+ ## Types
8
+
9
+ Import from the layer package root — do **not** use `~/types/...` (that resolves to the consuming app, not the layer):
10
+
11
+ ```ts
12
+ import type { NavItemData } from "srcdev-nuxt-components"
13
+ ```
14
+
15
+ ```ts
16
+ interface NavItem {
17
+ text: string;
18
+ href?: string;
19
+ isExternal?: boolean; // adds target="_blank" rel behaviour via NuxtLink
20
+ iconName?: string; // icon set name, rendered as <Icon name="icon-{iconName}" />
21
+ cssName?: string; // extra CSS class on the <li>
22
+ }
23
+
24
+ interface NavItemData {
25
+ [key: string]: NavItem[]; // key name is arbitrary; component reads navItemData.main
26
+ }
27
+ ```
28
+
29
+ **Important:** The component only iterates `navItemData.main`. Other keys in the object are ignored unless you fork the component.
30
+
31
+ ## Props
32
+
33
+ | Prop | Type | Default | Description |
34
+ |------|------|---------|-------------|
35
+ | `navItemData` | `NavItemData` | required | Navigation link data |
36
+ | `tag` | `"ul" \| "ol" \| "div"` | `"ul"` | HTML element for the list |
37
+ | `styleClassPassthrough` | `string \| string[]` | `[]` | Extra CSS classes on the root `<nav>` |
38
+
39
+ ## Basic usage
40
+
41
+ ```vue
42
+ <NavigationHorizontal :nav-item-data="navLinks" />
43
+ ```
44
+
45
+ ```ts
46
+ import type { NavItemData } from "srcdev-nuxt-components"
47
+
48
+ const navLinks: NavItemData = {
49
+ main: [
50
+ { text: "Home", href: "/" },
51
+ { text: "Services", href: "/services/" },
52
+ { text: "Contact", href: "/contact" },
53
+ { text: "GitHub", href: "https://github.com/example", isExternal: true },
54
+ ],
55
+ }
56
+ ```
57
+
58
+ ## CSS token API
59
+
60
+ Override these custom properties on a wrapping selector to theme the nav without touching component internals:
61
+
62
+ ```css
63
+ .your-wrapper {
64
+ /* Colours */
65
+ --nav-active-colour: lime; /* glow + border-bottom colour on hover/focus */
66
+ --nav-link-colour: hsl(0 0% 100%); /* link text colour */
67
+ --nav-link-bg: hsl(0 0% 20%); /* link background */
68
+ --nav-border-colour: hsl(0 0% 100% / 0.2); /* top/bottom border on the list */
69
+
70
+ /* Borders */
71
+ --nav-border-start: 0px; /* block-start border thickness */
72
+ --nav-border-end: 3px; /* block-end border thickness */
73
+
74
+ /* Layout */
75
+ --nav-list-padding: 2rem;
76
+ --nav-list-gap: 1rem;
77
+ --nav-link-padding-block: 0.5rem;
78
+ --nav-link-padding-inline: 1rem;
79
+ --nav-link-border-radius: 0.2rem;
80
+
81
+ /* Glow effect */
82
+ --nav-glow-pos-x: 50%;
83
+ --nav-glow-pos-y: 100%;
84
+ --nav-glow-inner-stop: 10%;
85
+ --nav-glow-outer-stop: 75%;
86
+ --nav-glow-size: 32px;
87
+ --nav-glow-opacity: 0.5;
88
+ --nav-anchor-offset: 40px;
89
+
90
+ /* Animation */
91
+ --nav-transition-duration: 300ms;
92
+ }
93
+ ```
94
+
95
+ ## Notes
96
+
97
+ - The glow effect uses CSS Anchor Positioning (`anchor-name`, `position-anchor`). The layer ships `@oddbird/css-anchor-positioning` as a polyfill for browsers that don't support it yet.
98
+ - The `--nav-active-colour` token drives both the border-bottom and the radial glow — set it to your brand accent colour.
99
+ - `isExternal: true` on a `NavItem` passes `:external="true"` to `<NuxtLink>`, which adds `target="_blank"` and `rel="noopener noreferrer"` automatically.
@@ -30,6 +30,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
30
30
  ├── component-prop-driven-container-layout.md — vary CSS grid layout inside @container queries using data-* attribute selectors
31
31
  ├── css-grid-max-width-gutters.md — cap a centre grid column width by growing gutters, with start/center alignment variants
32
32
  ├── component-aria-landmark.md — useAriaLabelledById composable: aria-labelledby for section/main/article/aside tags
33
+ ├── component-export-types.md — move inline component types to app/types/components/ barrel for consumer imports
33
34
  └── components/
34
35
  ├── accordian-core.md — AccordianCore indexed dynamic slots (accordian-{n}-summary/icon/content), exclusive-open grouping
35
36
  ├── eyebrow-text.md — EyebrowText props, usage patterns, styling
@@ -42,7 +43,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
42
43
  ├── services-section.md — ServicesSection props, summary-link/cta slots, summary vs full mode
43
44
  ├── contact-section.md — ContactSection props (stepperIndicatorSize pass-through), 3-item info+form layout, slot API
44
45
  ├── stepper-list.md — StepperList dynamic slots (item-{n}/indicator-{n}), props, connector behaviour
45
- └── expanding-panel.md — ExpandingPanel v-model, forceOpened, slots (summary/icon/content), ARIA wiring
46
+ ├── expanding-panel.md — ExpandingPanel v-model, forceOpened, slots (summary/icon/content), ARIA wiring
47
+ └── navigation-horizontal.md — NavigationHorizontal props, NavItemData type, CSS token API, import path gotcha
46
48
  ```
47
49
 
48
50
  ## Skill file template
@@ -12,17 +12,7 @@
12
12
  </template>
13
13
 
14
14
  <script setup lang="ts">
15
- export interface NavItem {
16
- text: string;
17
- href?: string;
18
- isExternal?: boolean;
19
- iconName?: string;
20
- cssName?: string;
21
- }
22
-
23
- export interface NavItemData {
24
- [key: string]: NavItem[];
25
- }
15
+ import type { NavItemData } from "~/types/components/navigation-horizontal.d";
26
16
 
27
17
  interface Props {
28
18
  tag?: "ol" | "ul" | "div";
@@ -47,14 +37,43 @@ watch(
47
37
  <style lang="css">
48
38
  @layer components {
49
39
  .navigation-horizontal {
50
- --_border-block-start-size: 0;
51
- --_border-block-end-size: 3px;
52
-
53
- --_active-link-colour: lime;
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
+ /* ─────────────────────────────────────────────────────────────── */
54
73
 
55
74
  anchor-name: --active-nav;
56
75
 
57
- background-color: var(--page-bg);
76
+ background-color: var(--_nav-canvas-colour);
58
77
 
59
78
  &::after {
60
79
  content: "";
@@ -62,18 +81,24 @@ watch(
62
81
  border-block-end: var(--_border-block-end-size) solid transparent;
63
82
 
64
83
  background:
65
- radial-gradient(var(--page-bg)) padding-box,
66
- radial-gradient(var(--_active-link-colour), transparent) border-box;
67
-
68
- /* background:
69
- radial-gradient(ellipse at 50% 100%, transparent 10%, var(--page-bg) 75%) padding-box,
70
- radial-gradient(ellipse at 50% 100%, var(--_active-link-colour) 10%, transparent 75%) border-box; */
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;
71
96
 
72
97
  position: absolute;
73
98
  position-anchor: --active-nav;
74
99
 
75
- left: calc(anchor(left) - 40px);
76
- right: calc(anchor(right) - 40px);
100
+ left: calc(anchor(left) - var(--_anchor-offset));
101
+ right: calc(anchor(right) - var(--_anchor-offset));
77
102
  top: anchor(top --nav-ul);
78
103
  bottom: anchor(bottom --nav-ul);
79
104
 
@@ -82,7 +107,7 @@ watch(
82
107
 
83
108
  opacity: 0;
84
109
  transition:
85
- inset 300ms,
110
+ inset var(--_transition-duration),
86
111
  opacity 700ms;
87
112
  transition-delay: 700ms, 0ms;
88
113
  }
@@ -90,8 +115,8 @@ watch(
90
115
  .navigation-horizontal-list {
91
116
  anchor-name: --nav-ul;
92
117
 
93
- border-block-start: var(--_border-block-start-size) solid hsl(0 0% 100% / 0.2);
94
- border-block-end: var(--_border-block-end-size) solid hsl(0 0% 100% / 0.2);
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);
95
120
 
96
121
  a:is(:hover, :focus) {
97
122
  anchor-name: --active-nav;
@@ -109,29 +134,27 @@ watch(
109
134
  .navigation-horizontal-list {
110
135
  list-style: none;
111
136
  margin: 0rem;
112
- padding: 2rem;
113
- gap: 3rem;
137
+ padding: var(--_list-padding);
138
+ gap: var(--_list-gap);
114
139
 
115
140
  display: flex;
116
141
  justify-content: center;
117
142
  }
118
143
 
119
144
  a {
120
- color: white;
145
+ color: var(--_link-colour);
121
146
  text-decoration: none;
122
- padding: 0.5rem 1rem;
147
+ padding: var(--_link-padding-block) var(--_link-padding-inline);
123
148
 
124
- border-radius: 0.2rem;
125
- /* border: 2px solid hsl(0 0 100% / 0.25); */
149
+ border-radius: var(--_link-border-radius);
126
150
  border-bottom: 2px solid transparent;
127
- background: hsl(0 0 20%);
128
- transition: background-color 300ms;
151
+ background: var(--_link-bg);
152
+ transition: background-color var(--_transition-duration);
129
153
  }
130
154
 
131
155
  a:is(:hover, :focus) {
132
- /* background: var(--_active-link-colour); */
133
156
  border-color: var(--_active-link-colour);
134
- box-shadow: 0 0 32px oklch(from var(--_active-link-colour) l c h / 0.5);
157
+ box-shadow: 0 0 var(--_glow-size) oklch(from var(--_active-link-colour) l c h / var(--_glow-opacity));
135
158
  }
136
159
  }
137
160
  }
@@ -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
+ });
@@ -0,0 +1,17 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`NavigationHorizontal > renders correct HTML structure 1`] = `
4
+ "<nav class="navigation-horizontal">
5
+ <ul class="navigation-horizontal-list">
6
+ <li class=""><a href="/">
7
+ <!--v-if--> Home
8
+ </a></li>
9
+ <li class=""><a href="/about">
10
+ <!--v-if--> About
11
+ </a></li>
12
+ <li class=""><a href="/contact">
13
+ <!--v-if--> Contact
14
+ </a></li>
15
+ </ul>
16
+ </nav>"
17
+ `;
@@ -11,21 +11,12 @@
11
11
  <DisplayThemeSwitch />
12
12
  </LayoutRow>
13
13
 
14
- <LayoutRow tag="div" variant="full-width" :style-class-passthrough="['mbe-4']">
15
- <LayoutRow tag="div" variant="content">
16
- <h3 class="page-heading-3">Control settings</h3>
17
- </LayoutRow>
18
- <div class="nav-preview" :class="`theme-${theme}`" :style="navStyle">
19
- <NavigationHorizontal :nav-item-data="navItemData" />
20
- </div>
21
- </LayoutRow>
22
-
23
14
  <LayoutRow tag="div" variant="full-width" :style-class-passthrough="['mbe-4']">
24
15
  <LayoutRow tag="div" variant="content">
25
16
  <h3 class="page-heading-3">User controllable</h3>
26
17
  </LayoutRow>
27
18
  <div class="nav-preview" :class="`theme-${theme}`" :style="navStyle">
28
- <NavigationHorizontalAdvanced :nav-item-data="navItemData" />
19
+ <NavigationHorizontal :nav-item-data="navItemData" />
29
20
  </div>
30
21
  </LayoutRow>
31
22
 
@@ -229,7 +220,7 @@
229
220
  </template>
230
221
 
231
222
  <script setup lang="ts">
232
- import type { NavItemData } from "~/components/02.molecules/navigation/navigation-horizontal/NavigationHorizontal.vue";
223
+ import type { NavItemData } from "~/types/components/navigation-horizontal.d";
233
224
  definePageMeta({
234
225
  layout: false,
235
226
  });
@@ -9,3 +9,4 @@ export * from "./display-toast.d"
9
9
  export * from "./qr-code.d"
10
10
  export * from "./alert-mask-core.d"
11
11
  export * from "./hero-text"
12
+ export * from "./navigation-horizontal.d"
@@ -0,0 +1,11 @@
1
+ export interface NavItem {
2
+ text: string;
3
+ href?: string;
4
+ isExternal?: boolean;
5
+ iconName?: string;
6
+ cssName?: string;
7
+ }
8
+
9
+ export interface NavItemData {
10
+ [key: string]: NavItem[];
11
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.0.16",
4
+ "version": "9.0.18",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",
@@ -1,172 +0,0 @@
1
- <template>
2
- <nav class="navigation-horizontal-advanced" :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
- export interface NavItem {
16
- text: string;
17
- href?: string;
18
- isExternal?: boolean;
19
- iconName?: string;
20
- cssName?: string;
21
- }
22
-
23
- export interface NavItemData {
24
- [key: string]: NavItem[];
25
- }
26
-
27
- interface Props {
28
- tag?: "ol" | "ul" | "div";
29
- navItemData: NavItemData;
30
- styleClassPassthrough?: string | string[];
31
- }
32
- const props = withDefaults(defineProps<Props>(), {
33
- tag: "ul",
34
- styleClassPassthrough: () => [],
35
- });
36
-
37
- const { elementClasses, updateElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
38
-
39
- watch(
40
- () => props.styleClassPassthrough,
41
- () => {
42
- updateElementClasses(props.styleClassPassthrough);
43
- }
44
- );
45
- </script>
46
-
47
- <style lang="css">
48
- @layer components {
49
- .navigation-horizontal-advanced {
50
- /* ─── Public token API ─────────────────────────────────────────── */
51
-
52
- /* Colours */
53
- --_nav-canvas-colour: var(--page-bg);
54
- --_active-link-colour: var(--nav-active-colour, lime);
55
- --_link-colour: var(--nav-link-colour, light-dark(hsl(0 0% 10%), hsl(0 0% 100%)));
56
- --_link-bg: var(--nav-link-bg, light-dark(hsl(0 0% 88%), hsl(0 0% 20%)));
57
- --_border-colour: var(--nav-border-colour, light-dark(hsl(0 0% 0% / 0.15), hsl(0 0% 100% / 0.2)));
58
-
59
- /* Borders */
60
- --_border-block-start-size: var(--nav-border-start, 0);
61
- --_border-block-end-size: var(--nav-border-end, 3px);
62
-
63
- /* Layout */
64
- --_list-padding: var(--nav-list-padding, 2rem);
65
- --_list-gap: var(--nav-list-gap, 1rem);
66
- --_link-padding-block: var(--nav-link-padding-block, 0.5rem);
67
- --_link-padding-inline: var(--nav-link-padding-inline, 1rem);
68
- --_link-border-radius: var(--nav-link-border-radius, 0.2rem);
69
-
70
- /* Glow effect */
71
- --_glow-pos-x: var(--nav-glow-pos-x, 50%);
72
- --_glow-pos-y: var(--nav-glow-pos-y, 100%);
73
- --_glow-inner-stop: var(--nav-glow-inner-stop, 10%);
74
- --_glow-outer-stop: var(--nav-glow-outer-stop, 75%);
75
- --_glow-size: var(--nav-glow-size, 32px);
76
- --_glow-opacity: var(--nav-glow-opacity, 0.5);
77
- --_anchor-offset: var(--nav-anchor-offset, 40px);
78
-
79
- /* Animation */
80
- --_transition-duration: var(--nav-transition-duration, 300ms);
81
-
82
- /* ─────────────────────────────────────────────────────────────── */
83
-
84
- anchor-name: --active-nav;
85
-
86
- background-color: var(--_nav-canvas-colour);
87
-
88
- &::after {
89
- content: "";
90
- border-block-start: var(--_border-block-start-size) solid transparent;
91
- border-block-end: var(--_border-block-end-size) solid transparent;
92
-
93
- background:
94
- radial-gradient(
95
- ellipse at var(--_glow-pos-x) var(--_glow-pos-y),
96
- transparent var(--_glow-inner-stop),
97
- var(--page-bg) var(--_glow-outer-stop)
98
- )
99
- padding-box,
100
- radial-gradient(
101
- ellipse at var(--_glow-pos-x) var(--_glow-pos-y),
102
- var(--_active-link-colour) var(--_glow-inner-stop),
103
- transparent var(--_glow-outer-stop)
104
- )
105
- border-box;
106
-
107
- position: absolute;
108
- position-anchor: --active-nav;
109
-
110
- left: calc(anchor(left) - var(--_anchor-offset));
111
- right: calc(anchor(right) - var(--_anchor-offset));
112
- top: anchor(top --nav-ul);
113
- bottom: anchor(bottom --nav-ul);
114
-
115
- pointer-events: none;
116
- z-index: -1;
117
-
118
- opacity: 0;
119
- transition:
120
- inset var(--_transition-duration),
121
- opacity 700ms;
122
- transition-delay: 700ms, 0ms;
123
- }
124
-
125
- .navigation-horizontal-list {
126
- anchor-name: --nav-ul;
127
-
128
- border-block-start: var(--_border-block-start-size) solid var(--_border-colour);
129
- border-block-end: var(--_border-block-end-size) solid var(--_border-colour);
130
-
131
- a:is(:hover, :focus) {
132
- anchor-name: --active-nav;
133
- }
134
- }
135
-
136
- &:has(a:hover, a:focus)::after {
137
- opacity: 1;
138
- transition-delay: 0ms, 0ms;
139
- }
140
- }
141
-
142
- @layer general-styling {
143
- .navigation-horizontal-advanced {
144
- .navigation-horizontal-list {
145
- list-style: none;
146
- margin: 0rem;
147
- padding: var(--_list-padding);
148
- gap: var(--_list-gap);
149
-
150
- display: flex;
151
- justify-content: center;
152
- }
153
-
154
- a {
155
- color: var(--_link-colour);
156
- text-decoration: none;
157
- padding: var(--_link-padding-block) var(--_link-padding-inline);
158
-
159
- border-radius: var(--_link-border-radius);
160
- border-bottom: 2px solid transparent;
161
- background: var(--_link-bg);
162
- transition: background-color var(--_transition-duration);
163
- }
164
-
165
- a:is(:hover, :focus) {
166
- border-color: var(--_active-link-colour);
167
- box-shadow: 0 0 var(--_glow-size) oklch(from var(--_active-link-colour) l c h / var(--_glow-opacity));
168
- }
169
- }
170
- }
171
- }
172
- </style>