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.
- package/.claude/skills/component-export-types.md +61 -0
- package/.claude/skills/components/navigation-horizontal.md +99 -0
- package/.claude/skills/index.md +3 -1
- package/app/components/02.molecules/navigation/navigation-horizontal/NavigationHorizontal.vue +60 -37
- package/app/components/02.molecules/navigation/navigation-horizontal/stories/NavigationHorizontal.stories.ts +373 -0
- package/app/components/02.molecules/navigation/navigation-horizontal/tests/NavigationHorizontal.spec.ts +152 -0
- package/app/components/02.molecules/navigation/navigation-horizontal/tests/__snapshots__/NavigationHorizontal.spec.ts.snap +17 -0
- package/app/pages/ui/navigation/navigation-horizontal.vue +2 -11
- package/app/types/components/index.ts +1 -0
- package/app/types/components/navigation-horizontal.d.ts +11 -0
- package/package.json +1 -1
- package/app/components/02.molecules/navigation/navigation-horizontal/NavigationHorizontalAdvanced.vue +0 -172
|
@@ -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.
|
package/.claude/skills/index.md
CHANGED
|
@@ -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
|
-
|
|
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
|
package/app/components/02.molecules/navigation/navigation-horizontal/NavigationHorizontal.vue
CHANGED
|
@@ -12,17 +12,7 @@
|
|
|
12
12
|
</template>
|
|
13
13
|
|
|
14
14
|
<script setup lang="ts">
|
|
15
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
--
|
|
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(--
|
|
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(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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) -
|
|
76
|
-
right: calc(anchor(right) -
|
|
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
|
|
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
|
|
94
|
-
border-block-end: var(--_border-block-end-size) solid
|
|
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:
|
|
113
|
-
gap:
|
|
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:
|
|
145
|
+
color: var(--_link-colour);
|
|
121
146
|
text-decoration: none;
|
|
122
|
-
padding:
|
|
147
|
+
padding: var(--_link-padding-block) var(--_link-padding-inline);
|
|
123
148
|
|
|
124
|
-
border-radius:
|
|
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:
|
|
128
|
-
transition: background-color
|
|
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
|
|
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
|
-
<
|
|
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/
|
|
223
|
+
import type { NavItemData } from "~/types/components/navigation-horizontal.d";
|
|
233
224
|
definePageMeta({
|
|
234
225
|
layout: false,
|
|
235
226
|
});
|
package/package.json
CHANGED
|
@@ -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>
|