srcdev-nuxt-components 9.1.8 → 9.1.9
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/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +23 -18
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +523 -0
- package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +340 -0
- package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +335 -0
- package/package.json +1 -1
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
@mouseleave="resetHoverNavToActive"
|
|
18
18
|
@mouseover="handleNavHover"
|
|
19
19
|
>
|
|
20
|
-
<li v-for="item in navItemData.main" :key="item.href" :class="item.cssName">
|
|
20
|
+
<li v-for="item in navItemData.main" :key="item.href" :class="[item.cssName, { 'is-active': activeHref === item.href }]">
|
|
21
21
|
<NuxtLink :href="item.href" :external="item.isExternal || undefined" class="site-nav-link" data-nav-item>
|
|
22
22
|
<Icon v-if="item.iconName" :name="item.iconName" aria-hidden="true" />
|
|
23
23
|
{{ item.text }}
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
@mouseover="handlePanelHover"
|
|
71
71
|
@mouseleave="resetHoverPanelToActive"
|
|
72
72
|
>
|
|
73
|
-
<li v-for="item in navItemData.main" :key="item.href" :class="item.cssName">
|
|
73
|
+
<li v-for="item in navItemData.main" :key="item.href" :class="[item.cssName, { 'is-active': activeHref === item.href }]">
|
|
74
74
|
<NuxtLink
|
|
75
75
|
:href="item.href"
|
|
76
76
|
:external="item.isExternal || undefined"
|
|
@@ -231,9 +231,7 @@ const initNavDecorators = () => {
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
const activeLink =
|
|
234
|
-
links.find((el) => el.
|
|
235
|
-
links.find((el) => el.classList.contains("router-link-active")) ??
|
|
236
|
-
links[0];
|
|
234
|
+
links.find((el) => el.getAttribute("href") === activeHref.value) ?? links[0];
|
|
237
235
|
if (!activeLink) return;
|
|
238
236
|
|
|
239
237
|
currentActiveNavLink = activeLink;
|
|
@@ -337,9 +335,7 @@ const initPanelDecorators = () => {
|
|
|
337
335
|
}
|
|
338
336
|
|
|
339
337
|
const activeLink =
|
|
340
|
-
links.find((el) => el.
|
|
341
|
-
links.find((el) => el.classList.contains("router-link-active")) ??
|
|
342
|
-
links[0];
|
|
338
|
+
links.find((el) => el.getAttribute("href") === activeHref.value) ?? links[0];
|
|
343
339
|
if (!activeLink) return;
|
|
344
340
|
|
|
345
341
|
currentActivePanelLink = activeLink;
|
|
@@ -352,11 +348,20 @@ const initPanelDecorators = () => {
|
|
|
352
348
|
|
|
353
349
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
354
350
|
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
// requestAnimationFrame defers the measurement until after browser layout,
|
|
358
|
-
// preventing stale offsetLeft reads and racing with hover setTimeouts.
|
|
351
|
+
// Compute active href from route directly — avoids racing against Vue Router's
|
|
352
|
+
// class application timing when reading router-link-exact-active from the DOM.
|
|
359
353
|
const route = useRoute();
|
|
354
|
+
|
|
355
|
+
const activeHref = computed(() => {
|
|
356
|
+
const items = props.navItemData.main ?? [];
|
|
357
|
+
const exact = items.find((item) => item.href && route.path === item.href);
|
|
358
|
+
if (exact) return exact.href ?? null;
|
|
359
|
+
return (
|
|
360
|
+
items
|
|
361
|
+
.filter((item) => item.href && route.path.startsWith(item.href + "/"))
|
|
362
|
+
.sort((a, b) => (b.href?.length ?? 0) - (a.href?.length ?? 0))[0]?.href ?? null
|
|
363
|
+
);
|
|
364
|
+
});
|
|
360
365
|
watch(
|
|
361
366
|
() => route.path,
|
|
362
367
|
() => {
|
|
@@ -584,9 +589,9 @@ watch(
|
|
|
584
589
|
outline: none;
|
|
585
590
|
}
|
|
586
591
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
592
|
+
}
|
|
593
|
+
li.is-active .site-nav-link {
|
|
594
|
+
color: var(--_link-active-color);
|
|
590
595
|
}
|
|
591
596
|
}
|
|
592
597
|
|
|
@@ -777,9 +782,9 @@ watch(
|
|
|
777
782
|
outline: none;
|
|
778
783
|
}
|
|
779
784
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
785
|
+
}
|
|
786
|
+
li.is-active .site-nav-panel-link {
|
|
787
|
+
color: var(--_panel-link-active-color);
|
|
783
788
|
}
|
|
784
789
|
}
|
|
785
790
|
}
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<nav
|
|
3
|
+
ref="navRef"
|
|
4
|
+
class="tab-navigation"
|
|
5
|
+
:class="[
|
|
6
|
+
elementClasses,
|
|
7
|
+
`tab-navigation--${navAlign}`,
|
|
8
|
+
{ 'is-collapsed': isCollapsed, 'is-loaded': isLoaded, 'menu-open': isMenuOpen },
|
|
9
|
+
]"
|
|
10
|
+
aria-label="Site navigation"
|
|
11
|
+
>
|
|
12
|
+
<ul
|
|
13
|
+
v-if="!isCollapsed || !isLoaded"
|
|
14
|
+
ref="navListRef"
|
|
15
|
+
class="tab-nav-list"
|
|
16
|
+
@mousemove="handleNavMousemove"
|
|
17
|
+
@mouseleave="hoveredItemHref = null"
|
|
18
|
+
>
|
|
19
|
+
<li
|
|
20
|
+
v-for="item in navItemData.main"
|
|
21
|
+
:key="item.href"
|
|
22
|
+
:data-href="item.href"
|
|
23
|
+
:class="[item.cssName, { 'is-active': activeHref === item.href, 'is-hovered': hoveredItemHref === item.href }]"
|
|
24
|
+
>
|
|
25
|
+
<NuxtLink :href="item.href" :external="item.isExternal || undefined" class="tab-nav-link" data-nav-item>
|
|
26
|
+
<Icon v-if="item.iconName" :name="item.iconName" aria-hidden="true" />
|
|
27
|
+
{{ item.text }}
|
|
28
|
+
</NuxtLink>
|
|
29
|
+
</li>
|
|
30
|
+
<li aria-hidden="true" role="none" class="nav-indicator-li"><div class="nav__hovered"></div></li>
|
|
31
|
+
<li aria-hidden="true" role="none" class="nav-indicator-li"><div class="nav__active-indicator"></div></li>
|
|
32
|
+
</ul>
|
|
33
|
+
|
|
34
|
+
<InputButtonCore
|
|
35
|
+
v-if="isCollapsed && isLoaded"
|
|
36
|
+
class="tab-nav-burger"
|
|
37
|
+
:class="{ 'is-open': isMenuOpen }"
|
|
38
|
+
variant="tertiary"
|
|
39
|
+
:button-text="isMenuOpen ? 'Close navigation menu' : 'Open navigation menu'"
|
|
40
|
+
:aria-expanded="String(isMenuOpen)"
|
|
41
|
+
aria-controls="tab-nav-panel"
|
|
42
|
+
@click="toggleMenu"
|
|
43
|
+
>
|
|
44
|
+
<template #iconOnly>
|
|
45
|
+
<span class="burger-bar" aria-hidden="true"></span>
|
|
46
|
+
<span class="burger-bar" aria-hidden="true"></span>
|
|
47
|
+
<span class="burger-bar" aria-hidden="true"></span>
|
|
48
|
+
</template>
|
|
49
|
+
</InputButtonCore>
|
|
50
|
+
|
|
51
|
+
<Teleport to="body">
|
|
52
|
+
<div
|
|
53
|
+
v-if="isCollapsed && isLoaded"
|
|
54
|
+
class="tab-nav-backdrop"
|
|
55
|
+
:class="{ 'is-open': isMenuOpen }"
|
|
56
|
+
aria-hidden="true"
|
|
57
|
+
@click="closeMenu"
|
|
58
|
+
></div>
|
|
59
|
+
</Teleport>
|
|
60
|
+
|
|
61
|
+
<div
|
|
62
|
+
v-if="isCollapsed && isLoaded"
|
|
63
|
+
id="tab-nav-panel"
|
|
64
|
+
class="tab-nav-panel"
|
|
65
|
+
:class="{ 'is-open': isMenuOpen }"
|
|
66
|
+
:inert="!isMenuOpen ? true : undefined"
|
|
67
|
+
>
|
|
68
|
+
<div class="tab-nav-panel-inner">
|
|
69
|
+
<ul class="tab-nav-panel-list">
|
|
70
|
+
<li v-for="item in navItemData.main" :key="item.href" :class="item.cssName">
|
|
71
|
+
<NuxtLink
|
|
72
|
+
:href="item.href"
|
|
73
|
+
:external="item.isExternal || undefined"
|
|
74
|
+
class="tab-nav-panel-link"
|
|
75
|
+
@click="closeMenu"
|
|
76
|
+
>
|
|
77
|
+
<Icon v-if="item.iconName" :name="item.iconName" aria-hidden="true" />
|
|
78
|
+
{{ item.text }}
|
|
79
|
+
</NuxtLink>
|
|
80
|
+
</li>
|
|
81
|
+
</ul>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</nav>
|
|
85
|
+
</template>
|
|
86
|
+
|
|
87
|
+
<script setup lang="ts">
|
|
88
|
+
import { useResizeObserver, onClickOutside } from "@vueuse/core";
|
|
89
|
+
import type { NavItemData } from "~/types/components";
|
|
90
|
+
|
|
91
|
+
interface Props {
|
|
92
|
+
navItemData: NavItemData;
|
|
93
|
+
navAlign?: "left" | "center" | "right";
|
|
94
|
+
styleClassPassthrough?: string | string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
98
|
+
navAlign: "left",
|
|
99
|
+
styleClassPassthrough: () => [],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const navRef = ref<HTMLElement | null>(null);
|
|
103
|
+
const navListRef = ref<HTMLUListElement | null>(null);
|
|
104
|
+
|
|
105
|
+
const isCollapsed = ref(false);
|
|
106
|
+
const isLoaded = useState("tab-nav-loaded", () => false);
|
|
107
|
+
const isMenuOpen = ref(false);
|
|
108
|
+
|
|
109
|
+
// Stored natural width of the list — used when the list is not in the DOM
|
|
110
|
+
let navListNaturalWidth = 0;
|
|
111
|
+
|
|
112
|
+
const checkOverflow = () => {
|
|
113
|
+
if (!navRef.value) return;
|
|
114
|
+
|
|
115
|
+
if (navListRef.value) {
|
|
116
|
+
navListNaturalWidth = navListRef.value.scrollWidth;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
isCollapsed.value = navListNaturalWidth > navRef.value.clientWidth;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const toggleMenu = () => {
|
|
123
|
+
isMenuOpen.value = !isMenuOpen.value;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const closeMenu = () => {
|
|
127
|
+
isMenuOpen.value = false;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const route = useRoute();
|
|
131
|
+
|
|
132
|
+
// Compute the active nav item href from the current route.
|
|
133
|
+
// Prefers an exact match; falls back to the longest prefix match.
|
|
134
|
+
const activeHref = computed(() => {
|
|
135
|
+
const items = props.navItemData.main ?? [];
|
|
136
|
+
const exact = items.find((item) => item.href && route.path === item.href);
|
|
137
|
+
if (exact) return exact.href ?? null;
|
|
138
|
+
return (
|
|
139
|
+
items
|
|
140
|
+
.filter((item) => item.href && route.path.startsWith(item.href + "/"))
|
|
141
|
+
.sort((a, b) => (b.href?.length ?? 0) - (a.href?.length ?? 0))[0]?.href ?? null
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const hoveredItemHref = ref<string | null>(null);
|
|
146
|
+
|
|
147
|
+
const handleNavMousemove = (event: MouseEvent) => {
|
|
148
|
+
const li = (event.target as HTMLElement).closest<HTMLElement>("li[data-href]");
|
|
149
|
+
if (!li) return;
|
|
150
|
+
hoveredItemHref.value = li.dataset.href ?? null;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
watch(
|
|
154
|
+
() => route.path,
|
|
155
|
+
() => {
|
|
156
|
+
closeMenu();
|
|
157
|
+
},
|
|
158
|
+
{ flush: "post" }
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
useResizeObserver(navRef, () => {
|
|
162
|
+
checkOverflow();
|
|
163
|
+
if (!isCollapsed.value) closeMenu();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
onClickOutside(navRef, closeMenu);
|
|
167
|
+
|
|
168
|
+
onMounted(async () => {
|
|
169
|
+
await nextTick();
|
|
170
|
+
checkOverflow();
|
|
171
|
+
isLoaded.value = true;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
175
|
+
|
|
176
|
+
watch(
|
|
177
|
+
() => props.styleClassPassthrough,
|
|
178
|
+
() => resetElementClasses(props.styleClassPassthrough)
|
|
179
|
+
);
|
|
180
|
+
</script>
|
|
181
|
+
|
|
182
|
+
<style lang="css">
|
|
183
|
+
@layer components {
|
|
184
|
+
.tab-nav-backdrop {
|
|
185
|
+
--_backdrop-bg: var(--tab-nav-backdrop-bg, oklch(0% 0 0 / 55%));
|
|
186
|
+
--_backdrop-blur: var(--tab-nav-backdrop-blur, 3px);
|
|
187
|
+
--_backdrop-duration: var(--tab-nav-backdrop-duration, 350ms);
|
|
188
|
+
|
|
189
|
+
position: fixed;
|
|
190
|
+
inset: 0;
|
|
191
|
+
z-index: 10;
|
|
192
|
+
background: var(--_backdrop-bg);
|
|
193
|
+
backdrop-filter: blur(var(--_backdrop-blur));
|
|
194
|
+
opacity: 0;
|
|
195
|
+
pointer-events: none;
|
|
196
|
+
transition: opacity var(--_backdrop-duration) ease;
|
|
197
|
+
|
|
198
|
+
&.is-open {
|
|
199
|
+
opacity: 1;
|
|
200
|
+
pointer-events: auto;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.tab-navigation {
|
|
205
|
+
/* ─── Public token API ────────────────────────────────────────────── */
|
|
206
|
+
|
|
207
|
+
/* Decorators — horizontal nav */
|
|
208
|
+
--_decorator-hovered-bg: var(--tab-nav-decorator-hovered-bg, transparent);
|
|
209
|
+
/* --_decorator-indicator-color: var(--tab-nav-decorator-indicator-color, var(--slate-01, currentColor)); */
|
|
210
|
+
--_decorator-indicator-color: red;
|
|
211
|
+
|
|
212
|
+
/* Horizontal nav */
|
|
213
|
+
--_link-color: var(--tab-nav-link-color, var(--slate-01, currentColor));
|
|
214
|
+
--_link-hover-color: var(--tab-nav-link-hover-color, var(--slate-04, var(--_link-color)));
|
|
215
|
+
--_link-active-color: var(--tab-nav-link-active-color, var(--slate-01, var(--_link-color)));
|
|
216
|
+
--_link-size: var(--tab-nav-link-size, 1.6rem);
|
|
217
|
+
--_link-tracking: var(--tab-nav-link-tracking, 0.06em);
|
|
218
|
+
--_link-weight: var(--tab-nav-link-weight, 400);
|
|
219
|
+
--_nav-gap: var(--tab-nav-gap, 2.2rem);
|
|
220
|
+
--_nav-transition: var(--tab-nav-transition, 250ms ease);
|
|
221
|
+
|
|
222
|
+
/* Panel */
|
|
223
|
+
--_panel-bg: var(--tab-nav-panel-bg, var(--page-bg, #1a1614));
|
|
224
|
+
--_panel-border-color: var(
|
|
225
|
+
--tab-nav-panel-border-color,
|
|
226
|
+
color-mix(in oklch, var(--slate-01, #c0847a) 35%, transparent)
|
|
227
|
+
);
|
|
228
|
+
--_panel-item-border: var(--tab-nav-panel-item-border, color-mix(in oklch, var(--slate-01, white) 8%, transparent));
|
|
229
|
+
--_panel-link-color: var(--tab-nav-panel-link-color, var(--slate-01, currentColor));
|
|
230
|
+
--_panel-link-hover-color: var(--tab-nav-panel-link-hover-color, var(--slate-04, var(--_panel-link-color)));
|
|
231
|
+
--_panel-link-active-color: var(--tab-nav-panel-link-active-color, var(--slate-01, var(--_panel-link-color)));
|
|
232
|
+
--_panel-padding-block: var(--tab-nav-panel-padding-block, 1.4rem);
|
|
233
|
+
--_panel-padding-inline: var(--tab-nav-panel-padding-inline, 1.5rem);
|
|
234
|
+
--_panel-slide-duration: var(--tab-nav-panel-slide-duration, 350ms);
|
|
235
|
+
--_panel-slide-easing: var(--tab-nav-panel-slide-easing, cubic-bezier(0.4, 0, 0.2, 1));
|
|
236
|
+
|
|
237
|
+
/* Burger */
|
|
238
|
+
--_burger-bar-width: var(--tab-nav-burger-width, 22px);
|
|
239
|
+
--_burger-bar-height: var(--tab-nav-burger-height, 1.5px);
|
|
240
|
+
--_burger-bar-gap: var(--tab-nav-burger-gap, 5px);
|
|
241
|
+
--_burger-color: var(--tab-nav-burger-color, var(--slate-01, currentColor));
|
|
242
|
+
--_burger-transition: var(--tab-nav-burger-transition, 300ms ease);
|
|
243
|
+
|
|
244
|
+
/* ─────────────────────────────────────────────────────────────────── */
|
|
245
|
+
|
|
246
|
+
display: flex;
|
|
247
|
+
align-items: center;
|
|
248
|
+
min-width: 0;
|
|
249
|
+
|
|
250
|
+
/* Hide everything until first measurement to prevent wrong-state flash */
|
|
251
|
+
&:not(.is-loaded) {
|
|
252
|
+
opacity: 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/* ─── Horizontal list ───────────────────────────────────────────── */
|
|
256
|
+
|
|
257
|
+
.tab-nav-list {
|
|
258
|
+
list-style: none;
|
|
259
|
+
margin: 0;
|
|
260
|
+
padding: 0;
|
|
261
|
+
display: flex;
|
|
262
|
+
gap: var(--_nav-gap);
|
|
263
|
+
align-items: center;
|
|
264
|
+
position: relative;
|
|
265
|
+
|
|
266
|
+
.nav-indicator-li {
|
|
267
|
+
/* display: contents removes the li's box entirely — children become
|
|
268
|
+
direct participants in the flex container, so position: absolute
|
|
269
|
+
on .nav__hovered / .nav__active-indicator resolves against
|
|
270
|
+
.tab-nav-list (same containing block as the anchor li elements).
|
|
271
|
+
Without this, .nav-indicator-li is the containing block, which is
|
|
272
|
+
a different scope and Chrome's anchor positioning rejects it. */
|
|
273
|
+
display: contents;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* Indicators hidden by default — shown only with anchor positioning support */
|
|
277
|
+
.nav__hovered,
|
|
278
|
+
.nav__active-indicator {
|
|
279
|
+
display: none;
|
|
280
|
+
pointer-events: none;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.tab-nav-link {
|
|
284
|
+
display: flex;
|
|
285
|
+
align-items: center;
|
|
286
|
+
gap: 0.4em;
|
|
287
|
+
color: var(--_link-color);
|
|
288
|
+
font-size: var(--_link-size);
|
|
289
|
+
font-weight: var(--_link-weight);
|
|
290
|
+
letter-spacing: var(--_link-tracking);
|
|
291
|
+
text-decoration: none;
|
|
292
|
+
text-wrap: nowrap;
|
|
293
|
+
padding-block: 0.8rem;
|
|
294
|
+
padding-inline: 0.4rem;
|
|
295
|
+
position: relative;
|
|
296
|
+
z-index: 4;
|
|
297
|
+
transition: color var(--_nav-transition);
|
|
298
|
+
|
|
299
|
+
&:hover,
|
|
300
|
+
&:focus-visible {
|
|
301
|
+
color: var(--_link-hover-color);
|
|
302
|
+
outline: none;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
&.router-link-exact-active {
|
|
306
|
+
color: var(--_link-active-color);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/* ─── Alignment variants ────────────────────────────────────────── */
|
|
312
|
+
|
|
313
|
+
&.tab-navigation--center .tab-nav-list {
|
|
314
|
+
margin-inline: auto;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
&.tab-navigation--right .tab-nav-list {
|
|
318
|
+
margin-inline-start: auto;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
&.is-collapsed {
|
|
322
|
+
justify-content: end;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* ─── Burger button (InputButtonCore) ──────────────────────────── */
|
|
326
|
+
|
|
327
|
+
.tab-nav-burger.input-button-core.icon-only {
|
|
328
|
+
margin-inline-start: auto;
|
|
329
|
+
color: var(--_burger-color);
|
|
330
|
+
|
|
331
|
+
/* Strip all InputButtonCore visual styling */
|
|
332
|
+
background: none;
|
|
333
|
+
border: none;
|
|
334
|
+
outline: none;
|
|
335
|
+
text-decoration: none;
|
|
336
|
+
padding: 8px;
|
|
337
|
+
border-radius: 4px;
|
|
338
|
+
transition: outline-color var(--_nav-transition);
|
|
339
|
+
|
|
340
|
+
&.icon-only {
|
|
341
|
+
aspect-ratio: unset;
|
|
342
|
+
border-radius: 4px;
|
|
343
|
+
|
|
344
|
+
.btn-icon {
|
|
345
|
+
margin: 0;
|
|
346
|
+
display: flex;
|
|
347
|
+
flex-direction: column;
|
|
348
|
+
gap: var(--_burger-bar-gap);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
&:focus-visible {
|
|
353
|
+
outline: 2px solid var(--_burger-color);
|
|
354
|
+
outline-offset: 4px;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.burger-bar {
|
|
359
|
+
display: block;
|
|
360
|
+
width: var(--_burger-bar-width);
|
|
361
|
+
height: var(--_burger-bar-height);
|
|
362
|
+
background: currentColor;
|
|
363
|
+
border-radius: 1px;
|
|
364
|
+
transform-origin: center;
|
|
365
|
+
transition:
|
|
366
|
+
transform var(--_burger-transition),
|
|
367
|
+
opacity var(--_burger-transition);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.tab-nav-burger.is-open {
|
|
371
|
+
.burger-bar:nth-child(1) {
|
|
372
|
+
transform: translateY(calc(var(--_burger-bar-height) + var(--_burger-bar-gap))) rotate(45deg);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.burger-bar:nth-child(2) {
|
|
376
|
+
opacity: 0;
|
|
377
|
+
transform: scaleX(0);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.burger-bar:nth-child(3) {
|
|
381
|
+
transform: translateY(calc(-1 * (var(--_burger-bar-height) + var(--_burger-bar-gap)))) rotate(-45deg);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/* ─── Mobile drop panel ─────────────────────────────────────────── */
|
|
386
|
+
|
|
387
|
+
.tab-nav-panel {
|
|
388
|
+
position: absolute;
|
|
389
|
+
left: 0;
|
|
390
|
+
right: 0;
|
|
391
|
+
top: 100%;
|
|
392
|
+
|
|
393
|
+
display: grid;
|
|
394
|
+
grid-template-rows: 0fr;
|
|
395
|
+
border-block-start: 1px solid transparent;
|
|
396
|
+
transition:
|
|
397
|
+
grid-template-rows var(--_panel-slide-duration) var(--_panel-slide-easing),
|
|
398
|
+
border-color var(--_panel-slide-duration) var(--_panel-slide-easing);
|
|
399
|
+
|
|
400
|
+
z-index: 1;
|
|
401
|
+
|
|
402
|
+
&.is-open {
|
|
403
|
+
grid-template-rows: 1fr;
|
|
404
|
+
border-block-start-color: var(--_panel-border-color);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.tab-nav-panel-inner {
|
|
408
|
+
overflow: hidden;
|
|
409
|
+
background-color: var(--_panel-bg);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.tab-nav-panel-list {
|
|
413
|
+
list-style: none;
|
|
414
|
+
margin: 0;
|
|
415
|
+
padding: 0;
|
|
416
|
+
|
|
417
|
+
li {
|
|
418
|
+
border-block-end: 1px solid var(--_panel-item-border);
|
|
419
|
+
|
|
420
|
+
&:last-child {
|
|
421
|
+
border-block-end: none;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
&:hover {
|
|
425
|
+
background-color: color-mix(in oklch, var(--slate-01, white) 5%, transparent);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.tab-nav-panel-link {
|
|
430
|
+
display: flex;
|
|
431
|
+
align-items: center;
|
|
432
|
+
gap: 0.5em;
|
|
433
|
+
color: var(--_panel-link-color);
|
|
434
|
+
font-size: var(--_link-size);
|
|
435
|
+
font-weight: var(--_link-weight);
|
|
436
|
+
letter-spacing: var(--_link-tracking);
|
|
437
|
+
text-decoration: none;
|
|
438
|
+
padding-block: var(--_panel-padding-block);
|
|
439
|
+
padding-inline: var(--_panel-padding-inline);
|
|
440
|
+
position: relative;
|
|
441
|
+
z-index: 1;
|
|
442
|
+
transition: color var(--_nav-transition);
|
|
443
|
+
|
|
444
|
+
&:hover,
|
|
445
|
+
&:focus-visible {
|
|
446
|
+
color: var(--_panel-link-hover-color);
|
|
447
|
+
outline: none;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
&.router-link-exact-active {
|
|
451
|
+
color: var(--_panel-link-active-color);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/* ─── Anchor positioning for nav indicators ──────────────────────────────
|
|
459
|
+
anchor-name is declared unconditionally so the @oddbird polyfill can
|
|
460
|
+
read it on browsers without native support (the polyfill checks for
|
|
461
|
+
anchor-name in raw CSS text, but skips rules inside a failing @supports).
|
|
462
|
+
The display guard stays inside @supports so indicators are hidden on
|
|
463
|
+
truly old browsers where neither native CSS nor the polyfill will work.
|
|
464
|
+
Requires Chrome 125+, Edge 125+, Firefox 131+, or the polyfill.
|
|
465
|
+
──────────────────────────────────────────────────────────────────────── */
|
|
466
|
+
|
|
467
|
+
/* Single anchor --tab-nav-indicator tracks the hovered item, or falls back
|
|
468
|
+
to the active item when nothing is hovered. No dual anchor-names needed. */
|
|
469
|
+
|
|
470
|
+
/* Nothing hovered: anchor sits on the active item */
|
|
471
|
+
.tab-navigation .tab-nav-list:not(:has(.is-hovered)) li.is-active {
|
|
472
|
+
anchor-name: --tab-nav-indicator;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/* Something hovered: anchor sits on the hovered item */
|
|
476
|
+
.tab-navigation .tab-nav-list li.is-hovered {
|
|
477
|
+
anchor-name: --tab-nav-indicator;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/* @supports (anchor-name: --x) { */
|
|
481
|
+
/* Hover highlight: background pill that follows the pointer */
|
|
482
|
+
.tab-navigation .tab-nav-list .nav__hovered {
|
|
483
|
+
display: block;
|
|
484
|
+
position: absolute;
|
|
485
|
+
position-anchor: --tab-nav-indicator;
|
|
486
|
+
left: anchor(left);
|
|
487
|
+
right: anchor(right);
|
|
488
|
+
top: 0;
|
|
489
|
+
bottom: 0;
|
|
490
|
+
opacity: 0;
|
|
491
|
+
pointer-events: none;
|
|
492
|
+
transition:
|
|
493
|
+
left 200ms ease,
|
|
494
|
+
right 200ms ease,
|
|
495
|
+
opacity 150ms ease;
|
|
496
|
+
background: var(--tab-nav-decorator-hovered-bg, transparent);
|
|
497
|
+
border-radius: 4px;
|
|
498
|
+
z-index: 1;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
.tab-navigation .tab-nav-list:has(.is-hovered) .nav__hovered {
|
|
502
|
+
opacity: 1;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/* Active indicator bar: always follows --tab-nav-indicator */
|
|
506
|
+
.tab-navigation .tab-nav-list .nav__active-indicator {
|
|
507
|
+
display: block;
|
|
508
|
+
position: absolute;
|
|
509
|
+
position-anchor: --tab-nav-indicator;
|
|
510
|
+
left: anchor(left);
|
|
511
|
+
right: anchor(right);
|
|
512
|
+
bottom: 0;
|
|
513
|
+
height: 2px;
|
|
514
|
+
pointer-events: none;
|
|
515
|
+
transition:
|
|
516
|
+
left 200ms ease,
|
|
517
|
+
right 200ms ease;
|
|
518
|
+
background-color: var(--tab-nav-decorator-indicator-color, var(--slate-01, currentColor));
|
|
519
|
+
z-index: 3;
|
|
520
|
+
}
|
|
521
|
+
/* } */
|
|
522
|
+
}
|
|
523
|
+
</style>
|
package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { ref, reactive, computed } from "vue";
|
|
2
|
+
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
3
|
+
import TabNavigationComponent from "../TabNavigation.vue";
|
|
4
|
+
import type { NavItemData } from "~/types/components/navigation-horizontal.d";
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof TabNavigationComponent> = {
|
|
7
|
+
title: "Molecules/TabNavigation",
|
|
8
|
+
component: TabNavigationComponent,
|
|
9
|
+
argTypes: {
|
|
10
|
+
navAlign: {
|
|
11
|
+
control: { type: "select" },
|
|
12
|
+
options: ["left", "center", "right"],
|
|
13
|
+
description: "Horizontal alignment of the nav list",
|
|
14
|
+
},
|
|
15
|
+
navItemData: { table: { disable: true } },
|
|
16
|
+
styleClassPassthrough: { table: { disable: true } },
|
|
17
|
+
},
|
|
18
|
+
args: {
|
|
19
|
+
navAlign: "left",
|
|
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: "Work", href: "#" },
|
|
31
|
+
{ text: "Contact", href: "#" },
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type Theme = "light" | "dark";
|
|
36
|
+
|
|
37
|
+
const themeDefaults: Record<
|
|
38
|
+
Theme,
|
|
39
|
+
{
|
|
40
|
+
indicatorColor: string;
|
|
41
|
+
linkColor: string;
|
|
42
|
+
linkHoverColor: string;
|
|
43
|
+
linkActiveColor: string;
|
|
44
|
+
panelBg: string;
|
|
45
|
+
panelLinkColor: string;
|
|
46
|
+
burgerColor: string;
|
|
47
|
+
pageBg: string;
|
|
48
|
+
}
|
|
49
|
+
> = {
|
|
50
|
+
dark: {
|
|
51
|
+
indicatorColor: "#c0847a",
|
|
52
|
+
linkColor: "#f0ece8",
|
|
53
|
+
linkHoverColor: "#e09890",
|
|
54
|
+
linkActiveColor: "#c0847a",
|
|
55
|
+
panelBg: "#1a1614",
|
|
56
|
+
panelLinkColor: "#f0ece8",
|
|
57
|
+
burgerColor: "#f0ece8",
|
|
58
|
+
pageBg: "#1a1614",
|
|
59
|
+
},
|
|
60
|
+
light: {
|
|
61
|
+
indicatorColor: "#b05a50",
|
|
62
|
+
linkColor: "#1a1614",
|
|
63
|
+
linkHoverColor: "#b05a50",
|
|
64
|
+
linkActiveColor: "#8c3a30",
|
|
65
|
+
panelBg: "#f5f2f0",
|
|
66
|
+
panelLinkColor: "#1a1614",
|
|
67
|
+
burgerColor: "#1a1614",
|
|
68
|
+
pageBg: "#f5f2f0",
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const DefaultTemplate: StoryFn<typeof TabNavigationComponent> = (args) => ({
|
|
73
|
+
components: { TabNavigationComponent },
|
|
74
|
+
setup() {
|
|
75
|
+
const theme = ref<Theme>("dark");
|
|
76
|
+
|
|
77
|
+
const controls = reactive({ ...themeDefaults.dark });
|
|
78
|
+
|
|
79
|
+
const setTheme = (newTheme: Theme) => {
|
|
80
|
+
theme.value = newTheme;
|
|
81
|
+
Object.assign(controls, themeDefaults[newTheme]);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const navStyle = computed(() => ({
|
|
85
|
+
"--tab-nav-decorator-indicator-color": controls.indicatorColor,
|
|
86
|
+
"--tab-nav-link-color": controls.linkColor,
|
|
87
|
+
"--tab-nav-link-hover-color": controls.linkHoverColor,
|
|
88
|
+
"--tab-nav-link-active-color": controls.linkActiveColor,
|
|
89
|
+
"--tab-nav-panel-bg": controls.panelBg,
|
|
90
|
+
"--tab-nav-panel-link-color": controls.panelLinkColor,
|
|
91
|
+
"--tab-nav-burger-color": controls.burgerColor,
|
|
92
|
+
"--page-bg": controls.pageBg,
|
|
93
|
+
"margin-block": "3.6rem",
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
const cssSnippet = computed(
|
|
97
|
+
() => `.your-selector {
|
|
98
|
+
/* Indicator */
|
|
99
|
+
--tab-nav-decorator-indicator-color: ${controls.indicatorColor};
|
|
100
|
+
|
|
101
|
+
/* Horizontal nav links */
|
|
102
|
+
--tab-nav-link-color: ${controls.linkColor};
|
|
103
|
+
--tab-nav-link-hover-color: ${controls.linkHoverColor};
|
|
104
|
+
--tab-nav-link-active-color: ${controls.linkActiveColor};
|
|
105
|
+
|
|
106
|
+
/* Mobile panel */
|
|
107
|
+
--tab-nav-panel-bg: ${controls.panelBg};
|
|
108
|
+
--tab-nav-panel-link-color: ${controls.panelLinkColor};
|
|
109
|
+
|
|
110
|
+
/* Burger */
|
|
111
|
+
--tab-nav-burger-color: ${controls.burgerColor};
|
|
112
|
+
}`
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const copied = ref(false);
|
|
116
|
+
const copySnippet = async () => {
|
|
117
|
+
await navigator.clipboard.writeText(cssSnippet.value);
|
|
118
|
+
copied.value = true;
|
|
119
|
+
setTimeout(() => {
|
|
120
|
+
copied.value = false;
|
|
121
|
+
}, 2000);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return { args, navItemData, theme, controls, setTheme, navStyle, cssSnippet, copied, copySnippet };
|
|
125
|
+
},
|
|
126
|
+
template: `
|
|
127
|
+
<div class="sb-tabnav-story">
|
|
128
|
+
|
|
129
|
+
<div class="sb-tabnav-note">
|
|
130
|
+
Resize the browser window to see the navigation collapse into a burger menu.
|
|
131
|
+
The active indicator uses CSS <code>anchor-name</code> / <code>position-anchor</code> —
|
|
132
|
+
no JavaScript required. Unsupported browsers fall back to link colour changes only.
|
|
133
|
+
</div>
|
|
134
|
+
<div class="sb-tabnav-header" :class="'theme-' + theme" :style="navStyle">
|
|
135
|
+
<div class="sb-tabnav-logo">LOGO</div>
|
|
136
|
+
<TabNavigationComponent
|
|
137
|
+
:nav-item-data="navItemData"
|
|
138
|
+
:nav-align="args.navAlign"
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<!-- Controls -->
|
|
143
|
+
<div class="sb-tabnav-playground">
|
|
144
|
+
|
|
145
|
+
<fieldset>
|
|
146
|
+
<legend>Theme</legend>
|
|
147
|
+
<div class="sb-control-row">
|
|
148
|
+
<label>Light / Dark</label>
|
|
149
|
+
<div class="sb-theme-toggle">
|
|
150
|
+
<button :class="{ active: theme === 'light' }" @click="setTheme('light')">Light</button>
|
|
151
|
+
<button :class="{ active: theme === 'dark' }" @click="setTheme('dark')">Dark</button>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</fieldset>
|
|
155
|
+
|
|
156
|
+
<fieldset>
|
|
157
|
+
<legend>Indicator</legend>
|
|
158
|
+
<div class="sb-control-row">
|
|
159
|
+
<label for="sb-indicator-color">Indicator colour</label>
|
|
160
|
+
<input id="sb-indicator-color" v-model="controls.indicatorColor" type="color" />
|
|
161
|
+
</div>
|
|
162
|
+
</fieldset>
|
|
163
|
+
|
|
164
|
+
<fieldset>
|
|
165
|
+
<legend>Horizontal nav links</legend>
|
|
166
|
+
<div class="sb-control-row">
|
|
167
|
+
<label for="sb-link-color">Link colour</label>
|
|
168
|
+
<input id="sb-link-color" v-model="controls.linkColor" type="color" />
|
|
169
|
+
</div>
|
|
170
|
+
<div class="sb-control-row">
|
|
171
|
+
<label for="sb-link-hover-color">Hover colour</label>
|
|
172
|
+
<input id="sb-link-hover-color" v-model="controls.linkHoverColor" type="color" />
|
|
173
|
+
</div>
|
|
174
|
+
<div class="sb-control-row">
|
|
175
|
+
<label for="sb-link-active-color">Active colour</label>
|
|
176
|
+
<input id="sb-link-active-color" v-model="controls.linkActiveColor" type="color" />
|
|
177
|
+
</div>
|
|
178
|
+
</fieldset>
|
|
179
|
+
|
|
180
|
+
<fieldset>
|
|
181
|
+
<legend>Mobile panel</legend>
|
|
182
|
+
<div class="sb-control-row">
|
|
183
|
+
<label for="sb-panel-bg">Panel background</label>
|
|
184
|
+
<input id="sb-panel-bg" v-model="controls.panelBg" type="color" />
|
|
185
|
+
</div>
|
|
186
|
+
<div class="sb-control-row">
|
|
187
|
+
<label for="sb-panel-link-color">Panel link colour</label>
|
|
188
|
+
<input id="sb-panel-link-color" v-model="controls.panelLinkColor" type="color" />
|
|
189
|
+
</div>
|
|
190
|
+
</fieldset>
|
|
191
|
+
|
|
192
|
+
<fieldset>
|
|
193
|
+
<legend>Burger</legend>
|
|
194
|
+
<div class="sb-control-row">
|
|
195
|
+
<label for="sb-burger-color">Burger colour</label>
|
|
196
|
+
<input id="sb-burger-color" v-model="controls.burgerColor" type="color" />
|
|
197
|
+
</div>
|
|
198
|
+
</fieldset>
|
|
199
|
+
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<!-- CSS snippet -->
|
|
203
|
+
<div class="sb-css-snippet">
|
|
204
|
+
<div class="sb-css-snippet-header">
|
|
205
|
+
<strong>CSS Token Snippet</strong>
|
|
206
|
+
<button class="sb-copy-btn" @click="copySnippet">{{ copied ? 'Copied!' : 'Copy' }}</button>
|
|
207
|
+
</div>
|
|
208
|
+
<pre class="sb-css-snippet-code">{{ cssSnippet }}</pre>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<style>
|
|
214
|
+
.sb-tabnav-story {
|
|
215
|
+
font-size: 1.4rem;
|
|
216
|
+
display: grid;
|
|
217
|
+
gap: 2.4rem;
|
|
218
|
+
padding: 2.4rem;
|
|
219
|
+
}
|
|
220
|
+
.sb-tabnav-note {
|
|
221
|
+
font-size: 1.3rem;
|
|
222
|
+
color: #888;
|
|
223
|
+
font-style: italic;
|
|
224
|
+
}
|
|
225
|
+
.sb-tabnav-note code {
|
|
226
|
+
font-family: monospace;
|
|
227
|
+
background: rgba(128,128,128,0.15);
|
|
228
|
+
padding: 0.1em 0.4em;
|
|
229
|
+
border-radius: 0.2rem;
|
|
230
|
+
}
|
|
231
|
+
.sb-tabnav-header {
|
|
232
|
+
display: flex;
|
|
233
|
+
align-items: center;
|
|
234
|
+
gap: 3.2rem;
|
|
235
|
+
padding: 1.2rem 2.4rem;
|
|
236
|
+
transition: background 300ms;
|
|
237
|
+
position: relative;
|
|
238
|
+
min-height: 6rem;
|
|
239
|
+
border-radius: 0.4rem;
|
|
240
|
+
}
|
|
241
|
+
.sb-tabnav-header.theme-dark {
|
|
242
|
+
background: var(--page-bg, #1a1614);
|
|
243
|
+
color-scheme: dark;
|
|
244
|
+
}
|
|
245
|
+
.sb-tabnav-header.theme-light {
|
|
246
|
+
background: var(--page-bg, #f5f2f0);
|
|
247
|
+
color-scheme: light;
|
|
248
|
+
}
|
|
249
|
+
.sb-tabnav-logo {
|
|
250
|
+
font-size: 1.8rem;
|
|
251
|
+
font-weight: 700;
|
|
252
|
+
letter-spacing: 0.1em;
|
|
253
|
+
flex-shrink: 0;
|
|
254
|
+
opacity: 0.5;
|
|
255
|
+
}
|
|
256
|
+
.sb-tabnav-header .tab-navigation {
|
|
257
|
+
flex: 1;
|
|
258
|
+
min-width: 0;
|
|
259
|
+
}
|
|
260
|
+
.sb-tabnav-playground {
|
|
261
|
+
display: grid;
|
|
262
|
+
grid-template-columns: repeat(auto-fit, minmax(25rem, 1fr));
|
|
263
|
+
gap: 1.6rem;
|
|
264
|
+
}
|
|
265
|
+
.sb-tabnav-playground fieldset {
|
|
266
|
+
border: 1px solid #ccc;
|
|
267
|
+
border-radius: 0.4rem;
|
|
268
|
+
padding: 1.6rem;
|
|
269
|
+
}
|
|
270
|
+
.sb-tabnav-playground legend {
|
|
271
|
+
font-weight: bold;
|
|
272
|
+
padding: 0 0.8rem;
|
|
273
|
+
font-size: 1.4rem;
|
|
274
|
+
}
|
|
275
|
+
.sb-control-row {
|
|
276
|
+
display: grid;
|
|
277
|
+
grid-template-columns: 1fr 14rem;
|
|
278
|
+
gap: 1rem;
|
|
279
|
+
align-items: center;
|
|
280
|
+
padding: 0.5rem 0;
|
|
281
|
+
font-size: 1.3rem;
|
|
282
|
+
}
|
|
283
|
+
.sb-control-row input[type="color"] {
|
|
284
|
+
height: 3.2rem;
|
|
285
|
+
width: 100%;
|
|
286
|
+
padding: 0.2rem;
|
|
287
|
+
border: 1px solid #ccc;
|
|
288
|
+
border-radius: 0.2rem;
|
|
289
|
+
cursor: pointer;
|
|
290
|
+
background: transparent;
|
|
291
|
+
}
|
|
292
|
+
.sb-theme-toggle { display: flex; gap: 0.4rem; }
|
|
293
|
+
.sb-theme-toggle button {
|
|
294
|
+
padding: 0.4rem 1.6rem;
|
|
295
|
+
border: 1px solid #ccc;
|
|
296
|
+
border-radius: 0.2rem;
|
|
297
|
+
cursor: pointer;
|
|
298
|
+
background: transparent;
|
|
299
|
+
font-size: 1.3rem;
|
|
300
|
+
color: inherit;
|
|
301
|
+
}
|
|
302
|
+
.sb-theme-toggle button.active { background: #e0e0e0; font-weight: bold; }
|
|
303
|
+
.sb-css-snippet {
|
|
304
|
+
border: 1px solid #ccc;
|
|
305
|
+
border-radius: 0.4rem;
|
|
306
|
+
overflow: hidden;
|
|
307
|
+
}
|
|
308
|
+
.sb-css-snippet-header {
|
|
309
|
+
display: flex;
|
|
310
|
+
justify-content: space-between;
|
|
311
|
+
align-items: center;
|
|
312
|
+
padding: 1rem 1.6rem;
|
|
313
|
+
border-bottom: 1px solid #ccc;
|
|
314
|
+
font-size: 1.4rem;
|
|
315
|
+
}
|
|
316
|
+
.sb-css-snippet-code {
|
|
317
|
+
margin: 0;
|
|
318
|
+
padding: 1.6rem;
|
|
319
|
+
font-family: monospace;
|
|
320
|
+
font-size: 1.3rem;
|
|
321
|
+
line-height: 1.6;
|
|
322
|
+
overflow-x: auto;
|
|
323
|
+
white-space: pre;
|
|
324
|
+
}
|
|
325
|
+
.sb-copy-btn {
|
|
326
|
+
padding: 0.4rem 1.2rem;
|
|
327
|
+
border: 1px solid #ccc;
|
|
328
|
+
border-radius: 0.2rem;
|
|
329
|
+
cursor: pointer;
|
|
330
|
+
background: transparent;
|
|
331
|
+
font-size: 1.3rem;
|
|
332
|
+
color: inherit;
|
|
333
|
+
}
|
|
334
|
+
.sb-copy-btn:hover { background: #e0e0e0; }
|
|
335
|
+
</style>
|
|
336
|
+
`,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
export const Default = DefaultTemplate.bind({});
|
|
340
|
+
Default.args = { navAlign: "left" };
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import { nextTick } from "vue";
|
|
4
|
+
import TabNavigation from "../TabNavigation.vue";
|
|
5
|
+
import type { NavItemData } from "~/types/components/navigation-horizontal.d";
|
|
6
|
+
|
|
7
|
+
// useResizeObserver (from @vueuse/core) requires ResizeObserver
|
|
8
|
+
const mockResizeObserver = vi.fn(() => ({
|
|
9
|
+
observe: vi.fn(),
|
|
10
|
+
unobserve: vi.fn(),
|
|
11
|
+
disconnect: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.stubGlobal("ResizeObserver", mockResizeObserver);
|
|
16
|
+
// ⚠️ Do NOT call vi.unstubAllGlobals() in afterEach —
|
|
17
|
+
// it removes global stubs from vitest.setup.ts ($fetch, etc.)
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const defaultNavItemData: NavItemData = {
|
|
21
|
+
main: [
|
|
22
|
+
{ text: "Home", href: "/" },
|
|
23
|
+
{ text: "About", href: "/about" },
|
|
24
|
+
{ text: "Contact", href: "/contact" },
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("TabNavigation", () => {
|
|
29
|
+
it("mounts without error", async () => {
|
|
30
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
31
|
+
props: { navItemData: defaultNavItemData },
|
|
32
|
+
});
|
|
33
|
+
expect(wrapper.vm).toBeTruthy();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("renders a nav element as root", async () => {
|
|
37
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
38
|
+
props: { navItemData: defaultNavItemData },
|
|
39
|
+
});
|
|
40
|
+
expect(wrapper.element.tagName.toLowerCase()).toBe("nav");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("has aria-label on the nav", async () => {
|
|
44
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
45
|
+
props: { navItemData: defaultNavItemData },
|
|
46
|
+
});
|
|
47
|
+
expect(wrapper.attributes("aria-label")).toBe("Site navigation");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("renders correct HTML structure", async () => {
|
|
51
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
52
|
+
props: { navItemData: defaultNavItemData },
|
|
53
|
+
});
|
|
54
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ─── isLoaded ──────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
it("applies is-loaded class after mounting", async () => {
|
|
60
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
61
|
+
props: { navItemData: defaultNavItemData },
|
|
62
|
+
});
|
|
63
|
+
await nextTick();
|
|
64
|
+
expect(wrapper.classes()).toContain("is-loaded");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ─── Nav list ──────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
it("renders the nav list when not collapsed", async () => {
|
|
70
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
71
|
+
props: { navItemData: defaultNavItemData },
|
|
72
|
+
});
|
|
73
|
+
expect(wrapper.find(".tab-nav-list").exists()).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("renders the correct number of nav items", async () => {
|
|
77
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
78
|
+
props: { navItemData: defaultNavItemData },
|
|
79
|
+
});
|
|
80
|
+
const items = wrapper.findAll(".tab-nav-list li:not(.nav-indicator-li)");
|
|
81
|
+
expect(items.length).toBe(3);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("renders item text correctly", async () => {
|
|
85
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
86
|
+
props: { navItemData: defaultNavItemData },
|
|
87
|
+
});
|
|
88
|
+
const links = wrapper.findAll(".tab-nav-link");
|
|
89
|
+
expect(links[0]!.text()).toContain("Home");
|
|
90
|
+
expect(links[1]!.text()).toContain("About");
|
|
91
|
+
expect(links[2]!.text()).toContain("Contact");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("renders item href correctly", async () => {
|
|
95
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
96
|
+
props: { navItemData: defaultNavItemData },
|
|
97
|
+
});
|
|
98
|
+
const links = wrapper.findAll(".tab-nav-link");
|
|
99
|
+
expect(links[0]!.attributes("href")).toBe("/");
|
|
100
|
+
expect(links[1]!.attributes("href")).toBe("/about");
|
|
101
|
+
expect(links[2]!.attributes("href")).toBe("/contact");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("applies cssName as class on the li element", async () => {
|
|
105
|
+
const navItemData: NavItemData = {
|
|
106
|
+
main: [{ text: "Home", href: "/", cssName: "is-active" }],
|
|
107
|
+
};
|
|
108
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
109
|
+
props: { navItemData },
|
|
110
|
+
});
|
|
111
|
+
expect(wrapper.find(".tab-nav-list li").classes()).toContain("is-active");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("renders an icon when iconName is provided", async () => {
|
|
115
|
+
const navItemData: NavItemData = {
|
|
116
|
+
main: [{ text: "Home", href: "/", iconName: "heroicons:home" }],
|
|
117
|
+
};
|
|
118
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
119
|
+
props: { navItemData },
|
|
120
|
+
});
|
|
121
|
+
const firstItem = wrapper.find(".tab-nav-list li:not(.nav-indicator-li)");
|
|
122
|
+
expect(firstItem.find("[aria-hidden='true']").exists()).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("does not render an icon when iconName is not provided", async () => {
|
|
126
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
127
|
+
props: { navItemData: defaultNavItemData },
|
|
128
|
+
});
|
|
129
|
+
const firstLink = wrapper.find(".tab-nav-link");
|
|
130
|
+
expect(firstLink.find("[aria-hidden='true']").exists()).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("renders external links with the correct href", async () => {
|
|
134
|
+
const navItemData: NavItemData = {
|
|
135
|
+
main: [{ text: "External", href: "https://example.com", isExternal: true }],
|
|
136
|
+
};
|
|
137
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
138
|
+
props: { navItemData },
|
|
139
|
+
});
|
|
140
|
+
expect(wrapper.find(".tab-nav-link").attributes("href")).toBe("https://example.com");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("renders two indicator elements inside the nav list", async () => {
|
|
144
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
145
|
+
props: { navItemData: defaultNavItemData },
|
|
146
|
+
});
|
|
147
|
+
const indicators = wrapper.findAll(".tab-nav-list .nav-indicator-li");
|
|
148
|
+
expect(indicators.length).toBe(2);
|
|
149
|
+
expect(wrapper.find(".nav__hovered").exists()).toBe(true);
|
|
150
|
+
expect(wrapper.find(".nav__active-indicator").exists()).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ─── Alignment variants ────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
it("applies tab-navigation--left class by default", async () => {
|
|
156
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
157
|
+
props: { navItemData: defaultNavItemData },
|
|
158
|
+
});
|
|
159
|
+
expect(wrapper.classes()).toContain("tab-navigation--left");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("applies tab-navigation--center class when navAlign is center", async () => {
|
|
163
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
164
|
+
props: { navItemData: defaultNavItemData, navAlign: "center" },
|
|
165
|
+
});
|
|
166
|
+
expect(wrapper.classes()).toContain("tab-navigation--center");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("applies tab-navigation--right class when navAlign is right", async () => {
|
|
170
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
171
|
+
props: { navItemData: defaultNavItemData, navAlign: "right" },
|
|
172
|
+
});
|
|
173
|
+
expect(wrapper.classes()).toContain("tab-navigation--right");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ─── styleClassPassthrough ─────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
it("applies styleClassPassthrough classes to the nav element", async () => {
|
|
179
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
180
|
+
props: {
|
|
181
|
+
navItemData: defaultNavItemData,
|
|
182
|
+
styleClassPassthrough: ["custom-nav", "theme-dark"],
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
expect(wrapper.classes()).toContain("custom-nav");
|
|
186
|
+
expect(wrapper.classes()).toContain("theme-dark");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("updates classes when styleClassPassthrough prop changes", async () => {
|
|
190
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
191
|
+
props: {
|
|
192
|
+
navItemData: defaultNavItemData,
|
|
193
|
+
styleClassPassthrough: ["initial-class"],
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
expect(wrapper.classes()).toContain("initial-class");
|
|
197
|
+
|
|
198
|
+
await wrapper.setProps({ styleClassPassthrough: ["updated-class"] });
|
|
199
|
+
await nextTick();
|
|
200
|
+
|
|
201
|
+
expect(wrapper.classes()).toContain("updated-class");
|
|
202
|
+
expect(wrapper.classes()).not.toContain("initial-class");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ─── Non-collapsed state (default in JSDOM — dimensions are all 0) ─────────
|
|
206
|
+
|
|
207
|
+
it("does not show the burger button when not collapsed", async () => {
|
|
208
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
209
|
+
props: { navItemData: defaultNavItemData },
|
|
210
|
+
});
|
|
211
|
+
expect(wrapper.find(".tab-nav-burger").exists()).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("does not show the nav panel when not collapsed", async () => {
|
|
215
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
216
|
+
props: { navItemData: defaultNavItemData },
|
|
217
|
+
});
|
|
218
|
+
expect(wrapper.find(".tab-nav-panel").exists()).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("does not have is-collapsed class when not collapsed", async () => {
|
|
222
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
223
|
+
props: { navItemData: defaultNavItemData },
|
|
224
|
+
});
|
|
225
|
+
expect(wrapper.classes()).not.toContain("is-collapsed");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ─── Collapsed state ───────────────────────────────────────────────────────
|
|
229
|
+
// isCollapsed is driven by DOM layout measurements (scrollWidth > clientWidth).
|
|
230
|
+
// In JSDOM both values are 0 so we cannot trigger collapse via resize.
|
|
231
|
+
// Instead we force the state directly through the component's setup context.
|
|
232
|
+
|
|
233
|
+
describe("when collapsed", () => {
|
|
234
|
+
interface TabNavSetup {
|
|
235
|
+
isCollapsed: boolean;
|
|
236
|
+
isMenuOpen: boolean;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function mountCollapsed() {
|
|
240
|
+
const wrapper = await mountSuspended(TabNavigation, {
|
|
241
|
+
props: { navItemData: defaultNavItemData },
|
|
242
|
+
});
|
|
243
|
+
const setup = (wrapper.vm as unknown as { $: { setupState: TabNavSetup } }).$.setupState;
|
|
244
|
+
setup.isCollapsed = true;
|
|
245
|
+
await nextTick();
|
|
246
|
+
return { wrapper, setup };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
it("applies is-collapsed class", async () => {
|
|
250
|
+
const { wrapper } = await mountCollapsed();
|
|
251
|
+
expect(wrapper.classes()).toContain("is-collapsed");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("hides the horizontal nav list", async () => {
|
|
255
|
+
const { wrapper } = await mountCollapsed();
|
|
256
|
+
expect(wrapper.find(".tab-nav-list").exists()).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("shows the burger button", async () => {
|
|
260
|
+
const { wrapper } = await mountCollapsed();
|
|
261
|
+
expect(wrapper.find(".tab-nav-burger").exists()).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("shows the nav panel", async () => {
|
|
265
|
+
const { wrapper } = await mountCollapsed();
|
|
266
|
+
expect(wrapper.find(".tab-nav-panel").exists()).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("burger button has aria-expanded false when menu is closed", async () => {
|
|
270
|
+
const { wrapper } = await mountCollapsed();
|
|
271
|
+
expect(wrapper.find(".tab-nav-burger").attributes("aria-expanded")).toBe("false");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("panel does not have is-open class when menu is closed", async () => {
|
|
275
|
+
const { wrapper } = await mountCollapsed();
|
|
276
|
+
expect(wrapper.find(".tab-nav-panel").classes()).not.toContain("is-open");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("renders panel links matching main nav items", async () => {
|
|
280
|
+
const { wrapper } = await mountCollapsed();
|
|
281
|
+
const panelLinks = wrapper.findAll(".tab-nav-panel-link");
|
|
282
|
+
expect(panelLinks.length).toBe(3);
|
|
283
|
+
expect(panelLinks[0]!.text()).toContain("Home");
|
|
284
|
+
expect(panelLinks[1]!.text()).toContain("About");
|
|
285
|
+
expect(panelLinks[2]!.text()).toContain("Contact");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("toggles isMenuOpen on burger click", async () => {
|
|
289
|
+
const { wrapper, setup } = await mountCollapsed();
|
|
290
|
+
expect(setup.isMenuOpen).toBe(false);
|
|
291
|
+
await wrapper.find(".tab-nav-burger").trigger("click");
|
|
292
|
+
expect(setup.isMenuOpen).toBe(true);
|
|
293
|
+
await wrapper.find(".tab-nav-burger").trigger("click");
|
|
294
|
+
expect(setup.isMenuOpen).toBe(false);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("applies menu-open class when menu is open", async () => {
|
|
298
|
+
const { wrapper } = await mountCollapsed();
|
|
299
|
+
await wrapper.find(".tab-nav-burger").trigger("click");
|
|
300
|
+
await nextTick();
|
|
301
|
+
expect(wrapper.classes()).toContain("menu-open");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("burger has aria-expanded true when menu is open", async () => {
|
|
305
|
+
const { wrapper } = await mountCollapsed();
|
|
306
|
+
await wrapper.find(".tab-nav-burger").trigger("click");
|
|
307
|
+
await nextTick();
|
|
308
|
+
expect(wrapper.find(".tab-nav-burger").attributes("aria-expanded")).toBe("true");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("panel has is-open class when menu is open", async () => {
|
|
312
|
+
const { wrapper } = await mountCollapsed();
|
|
313
|
+
await wrapper.find(".tab-nav-burger").trigger("click");
|
|
314
|
+
await nextTick();
|
|
315
|
+
expect(wrapper.find(".tab-nav-panel").classes()).toContain("is-open");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("panel is inert when menu is closed", async () => {
|
|
319
|
+
const { wrapper } = await mountCollapsed();
|
|
320
|
+
expect(wrapper.find(".tab-nav-panel").attributes("inert")).toBeDefined();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("panel is not inert when menu is open", async () => {
|
|
324
|
+
const { wrapper } = await mountCollapsed();
|
|
325
|
+
await wrapper.find(".tab-nav-burger").trigger("click");
|
|
326
|
+
await nextTick();
|
|
327
|
+
expect(wrapper.find(".tab-nav-panel").attributes("inert")).toBeUndefined();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("panel has aria-controls pointing to tab-nav-panel", async () => {
|
|
331
|
+
const { wrapper } = await mountCollapsed();
|
|
332
|
+
expect(wrapper.find(".tab-nav-burger").attributes("aria-controls")).toBe("tab-nav-panel");
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
});
|