srcdev-nuxt-components 9.1.10 → 9.1.12
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 +34 -24
- package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +1 -1
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +9 -33
- package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +1 -1
- package/app/composables/useNavCollapse.ts +6 -14
- package/package.json +1 -1
- package/app/stores/useNavigationStore.ts +0 -113
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
:class="[
|
|
6
6
|
elementClasses,
|
|
7
7
|
`site-navigation--${navAlign}`,
|
|
8
|
-
{ 'is-collapsed': isCollapsed, 'is-loaded': isLoaded, 'menu-open': isMenuOpen },
|
|
8
|
+
{ 'is-collapsed': isCollapsed, 'is-loaded': isLoaded, 'menu-open': isMenuOpen, 'is-animated': isAnimated },
|
|
9
9
|
]"
|
|
10
10
|
aria-label="Site navigation"
|
|
11
11
|
>
|
|
@@ -113,6 +113,10 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
113
113
|
styleClassPassthrough: () => [],
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
// ─── Animation gate — prevents indicator from transitioning on first paint ───
|
|
117
|
+
|
|
118
|
+
const isAnimated = ref(false);
|
|
119
|
+
|
|
116
120
|
// ─── Nav decorators (active / hover indicators) ─────────────────────────────
|
|
117
121
|
|
|
118
122
|
const NAV_DECORATOR_DURATION = 200;
|
|
@@ -178,13 +182,6 @@ const moveNavHoveredIndicator = () => {
|
|
|
178
182
|
const handleNavLinkClick = (event: MouseEvent) => {
|
|
179
183
|
const target = (event.target as HTMLElement).closest<HTMLElement>("[data-nav-item]");
|
|
180
184
|
if (!target) return;
|
|
181
|
-
|
|
182
|
-
// Update store with clicked href for reliable active state tracking
|
|
183
|
-
const href = target.getAttribute("href");
|
|
184
|
-
if (href) {
|
|
185
|
-
navigationStore.handleNavLinkClick(href);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
185
|
currentActiveNavLink = target;
|
|
189
186
|
currentHoveredNavLink = target;
|
|
190
187
|
previousHoveredNavLink = target;
|
|
@@ -206,6 +203,7 @@ const resetHoverNavToActive = () => {
|
|
|
206
203
|
};
|
|
207
204
|
|
|
208
205
|
const initNavDecorators = () => {
|
|
206
|
+
cachedNavLinks = []; // invalidate — list may have been re-rendered (collapse toggle)
|
|
209
207
|
if (!navListRef.value) return;
|
|
210
208
|
const links = getNavLinks();
|
|
211
209
|
if (!links.length) return;
|
|
@@ -287,13 +285,6 @@ const movePanelHoveredIndicator = () => {
|
|
|
287
285
|
const handlePanelLinkClick = (event: MouseEvent) => {
|
|
288
286
|
const target = (event.target as HTMLElement).closest<HTMLElement>("[data-panel-nav-item]");
|
|
289
287
|
if (!target) return;
|
|
290
|
-
|
|
291
|
-
// Update store with clicked href for reliable active state tracking
|
|
292
|
-
const href = target.getAttribute("href");
|
|
293
|
-
if (href) {
|
|
294
|
-
navigationStore.handleNavLinkClick(href);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
288
|
currentActivePanelLink = target;
|
|
298
289
|
currentHoveredPanelLink = target;
|
|
299
290
|
previousHoveredPanelLink = target;
|
|
@@ -338,21 +329,30 @@ const initPanelDecorators = () => {
|
|
|
338
329
|
|
|
339
330
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
340
331
|
|
|
341
|
-
const { navRef, navListRef, isCollapsed, isLoaded, isMenuOpen, activeHref, isActiveItem, toggleMenu, closeMenu
|
|
342
|
-
useNavCollapse(
|
|
343
|
-
onResize: () => {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
332
|
+
const { navRef, navListRef, isCollapsed, isLoaded, isMenuOpen, activeHref, isActiveItem, toggleMenu, closeMenu } =
|
|
333
|
+
useNavCollapse("site-nav-loaded", {
|
|
334
|
+
onResize: async () => {
|
|
335
|
+
await nextTick(); // wait for Vue to re-render after any isCollapsed change
|
|
336
|
+
initNavDecorators();
|
|
337
|
+
if (isMenuOpen.value) {
|
|
338
|
+
setFinalPanelActivePositions(true);
|
|
339
|
+
setFinalPanelHoveredPositions(true);
|
|
340
|
+
}
|
|
348
341
|
},
|
|
349
342
|
onRouteChange: () => {
|
|
343
|
+
isAnimated.value = false;
|
|
350
344
|
requestAnimationFrame(() => {
|
|
351
345
|
initNavDecorators();
|
|
346
|
+
nextTick(() => {
|
|
347
|
+
isAnimated.value = true;
|
|
348
|
+
});
|
|
352
349
|
});
|
|
353
350
|
},
|
|
354
351
|
onMounted: () => {
|
|
355
352
|
initNavDecorators();
|
|
353
|
+
nextTick(() => {
|
|
354
|
+
isAnimated.value = true;
|
|
355
|
+
});
|
|
356
356
|
},
|
|
357
357
|
});
|
|
358
358
|
|
|
@@ -484,10 +484,15 @@ watch(
|
|
|
484
484
|
scale: var(--_width-hovered, 0.001) 1;
|
|
485
485
|
translate: var(--_x-hovered, 0) 0;
|
|
486
486
|
transform-origin: left;
|
|
487
|
+
pointer-events: none;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
.site-navigation.is-animated & .nav__hovered,
|
|
491
|
+
.site-navigation.is-animated & .nav__active,
|
|
492
|
+
.site-navigation.is-animated & .nav__active-indicator {
|
|
487
493
|
transition:
|
|
488
494
|
scale var(--_transition-duration, 200ms),
|
|
489
495
|
translate var(--_transition-duration, 200ms);
|
|
490
|
-
pointer-events: none;
|
|
491
496
|
}
|
|
492
497
|
|
|
493
498
|
.nav__active {
|
|
@@ -672,10 +677,15 @@ watch(
|
|
|
672
677
|
scale: 1 var(--_panel-height-hovered, 0.001);
|
|
673
678
|
translate: 0 var(--_panel-y-hovered, 0);
|
|
674
679
|
transform-origin: top;
|
|
680
|
+
pointer-events: none;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
.site-navigation.is-animated & .nav__hovered,
|
|
684
|
+
.site-navigation.is-animated & .nav__active,
|
|
685
|
+
.site-navigation.is-animated & .nav__active-indicator {
|
|
675
686
|
transition:
|
|
676
687
|
scale var(--_panel-transition-duration, 200ms),
|
|
677
688
|
translate var(--_panel-transition-duration, 200ms);
|
|
678
|
-
pointer-events: none;
|
|
679
689
|
}
|
|
680
690
|
|
|
681
691
|
.nav__active {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
exports[`SiteNavigation > renders correct HTML structure 1`] = `
|
|
4
4
|
"<nav class="site-navigation site-navigation--left is-loaded" aria-label="Site navigation">
|
|
5
5
|
<ul class="site-nav-list" style="--_transition-duration: 0ms; --_x-active: 0px; --_width-active: NaN; --_x-hovered: 0px; --_width-hovered: NaN;">
|
|
6
|
-
<li class=""><a href="/" class="site-nav-link" data-nav-item="">
|
|
6
|
+
<li class="is-active"><a href="/" class="site-nav-link" data-nav-item="">
|
|
7
7
|
<!--v-if--> Home
|
|
8
8
|
</a></li>
|
|
9
9
|
<li class=""><a href="/about" class="site-nav-link" data-nav-item="">
|
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
v-if="!isCollapsed || !isLoaded"
|
|
14
14
|
ref="navListRef"
|
|
15
15
|
class="tab-nav-list"
|
|
16
|
-
@mousemove="handleNavMousemove"
|
|
17
16
|
@mouseleave="hoveredItemHref = null"
|
|
18
17
|
>
|
|
19
18
|
<li
|
|
@@ -21,13 +20,13 @@
|
|
|
21
20
|
:key="item.href"
|
|
22
21
|
:data-href="item.href"
|
|
23
22
|
:class="[item.cssName, { 'is-active': isActiveItem(item.href), 'is-hovered': hoveredItemHref === item.href }]"
|
|
23
|
+
@mouseenter="hoveredItemHref = item.href ?? null"
|
|
24
24
|
>
|
|
25
25
|
<NuxtLink
|
|
26
26
|
:href="item.href"
|
|
27
27
|
:external="item.isExternal || undefined"
|
|
28
28
|
class="tab-nav-link"
|
|
29
29
|
data-nav-item
|
|
30
|
-
@click="handleNavLinkClick"
|
|
31
30
|
>
|
|
32
31
|
<Icon v-if="item.iconName" :name="item.iconName" aria-hidden="true" />
|
|
33
32
|
{{ item.text }}
|
|
@@ -38,7 +37,7 @@
|
|
|
38
37
|
</ul>
|
|
39
38
|
|
|
40
39
|
<InputButtonCore
|
|
41
|
-
v-if="
|
|
40
|
+
v-if="showCollapsed"
|
|
42
41
|
class="tab-nav-burger"
|
|
43
42
|
:class="{ 'is-open': isMenuOpen }"
|
|
44
43
|
variant="tertiary"
|
|
@@ -56,7 +55,7 @@
|
|
|
56
55
|
|
|
57
56
|
<Teleport to="body">
|
|
58
57
|
<div
|
|
59
|
-
v-if="
|
|
58
|
+
v-if="showCollapsed"
|
|
60
59
|
class="tab-nav-backdrop"
|
|
61
60
|
:class="{ 'is-open': isMenuOpen }"
|
|
62
61
|
aria-hidden="true"
|
|
@@ -65,7 +64,7 @@
|
|
|
65
64
|
</Teleport>
|
|
66
65
|
|
|
67
66
|
<div
|
|
68
|
-
v-if="
|
|
67
|
+
v-if="showCollapsed"
|
|
69
68
|
id="tab-nav-panel"
|
|
70
69
|
class="tab-nav-panel"
|
|
71
70
|
:class="{ 'is-open': isMenuOpen }"
|
|
@@ -78,7 +77,7 @@
|
|
|
78
77
|
:href="item.href"
|
|
79
78
|
:external="item.isExternal || undefined"
|
|
80
79
|
class="tab-nav-panel-link"
|
|
81
|
-
@click="
|
|
80
|
+
@click="closeMenu"
|
|
82
81
|
>
|
|
83
82
|
<Icon v-if="item.iconName" :name="item.iconName" aria-hidden="true" />
|
|
84
83
|
{{ item.text }}
|
|
@@ -104,33 +103,11 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
104
103
|
styleClassPassthrough: () => [],
|
|
105
104
|
});
|
|
106
105
|
|
|
107
|
-
const { navRef, navListRef, isCollapsed, isLoaded, isMenuOpen, isActiveItem, toggleMenu, closeMenu
|
|
108
|
-
useNavCollapse(
|
|
109
|
-
|
|
110
|
-
// Handle navigation link clicks to update store
|
|
111
|
-
const handleNavLinkClick = (event: MouseEvent) => {
|
|
112
|
-
const target = (event.target as HTMLElement).closest<HTMLElement>("[href]");
|
|
113
|
-
if (!target) return;
|
|
114
|
-
const href = target.getAttribute("href");
|
|
115
|
-
if (href) navigationStore.handleNavLinkClick(href);
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
// Handle panel link clicks to update store and close menu
|
|
119
|
-
const handlePanelLinkClick = (event: MouseEvent) => {
|
|
120
|
-
const target = (event.target as HTMLElement).closest<HTMLElement>("[href]");
|
|
121
|
-
if (!target) return;
|
|
122
|
-
const href = target.getAttribute("href");
|
|
123
|
-
if (href) navigationStore.handleNavLinkClick(href);
|
|
124
|
-
closeMenu();
|
|
125
|
-
};
|
|
106
|
+
const { navRef, navListRef, isCollapsed, isLoaded, isMenuOpen, isActiveItem, toggleMenu, closeMenu } =
|
|
107
|
+
useNavCollapse("tab-nav-loaded");
|
|
126
108
|
|
|
127
109
|
const hoveredItemHref = ref<string | null>(null);
|
|
128
|
-
|
|
129
|
-
const handleNavMousemove = (event: MouseEvent) => {
|
|
130
|
-
const li = (event.target as HTMLElement).closest<HTMLElement>("li[data-href]");
|
|
131
|
-
if (!li) return;
|
|
132
|
-
hoveredItemHref.value = li.dataset.href ?? null;
|
|
133
|
-
};
|
|
110
|
+
const showCollapsed = computed(() => isCollapsed.value && isLoaded.value);
|
|
134
111
|
|
|
135
112
|
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
136
113
|
|
|
@@ -167,8 +144,7 @@ watch(
|
|
|
167
144
|
|
|
168
145
|
/* Decorators — horizontal nav */
|
|
169
146
|
--_decorator-hovered-bg: var(--tab-nav-decorator-hovered-bg, transparent);
|
|
170
|
-
|
|
171
|
-
--_decorator-indicator-color: red;
|
|
147
|
+
--_decorator-indicator-color: var(--tab-nav-decorator-indicator-color, var(--slate-01, currentColor));
|
|
172
148
|
|
|
173
149
|
/* Horizontal nav */
|
|
174
150
|
--_link-color: var(--tab-nav-link-color, var(--slate-01, currentColor));
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
exports[`TabNavigation > renders correct HTML structure 1`] = `
|
|
4
4
|
"<nav class="tab-navigation tab-navigation--left is-loaded" aria-label="Site navigation">
|
|
5
5
|
<ul class="tab-nav-list">
|
|
6
|
-
<li data-href="/" class=""><a href="/" class="tab-nav-link" data-nav-item="">
|
|
6
|
+
<li data-href="/" class="is-active"><a href="/" class="tab-nav-link" data-nav-item="">
|
|
7
7
|
<!--v-if--> Home
|
|
8
8
|
</a></li>
|
|
9
9
|
<li data-href="/about" class=""><a href="/about" class="tab-nav-link" data-nav-item="">
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useResizeObserver, onClickOutside } from "@vueuse/core";
|
|
2
|
-
import type { NavItemData } from "~/types/components";
|
|
3
2
|
|
|
4
3
|
interface NavCollapseOptions {
|
|
5
4
|
onResize?: () => void;
|
|
@@ -7,14 +6,13 @@ interface NavCollapseOptions {
|
|
|
7
6
|
onMounted?: () => void;
|
|
8
7
|
}
|
|
9
8
|
|
|
10
|
-
export const useNavCollapse = (
|
|
9
|
+
export const useNavCollapse = (stateKey: string, options: NavCollapseOptions = {}) => {
|
|
11
10
|
const navRef = ref<HTMLElement | null>(null);
|
|
12
11
|
const navListRef = ref<HTMLUListElement | null>(null);
|
|
13
12
|
|
|
14
13
|
const isCollapsed = ref(false);
|
|
15
14
|
const isLoaded = useState(stateKey, () => false);
|
|
16
15
|
const isMenuOpen = ref(false);
|
|
17
|
-
const isMounted = ref(false);
|
|
18
16
|
|
|
19
17
|
// Stored natural width of the list — used when the list is not in the DOM
|
|
20
18
|
let navListNaturalWidth = 0;
|
|
@@ -35,19 +33,17 @@ export const useNavCollapse = (navItemData: NavItemData, stateKey: string, optio
|
|
|
35
33
|
isMenuOpen.value = false;
|
|
36
34
|
};
|
|
37
35
|
|
|
38
|
-
|
|
36
|
+
// useRoute() in Nuxt is SSR-aware — route.path is consistent on server and client,
|
|
37
|
+
// so isActiveItem can be used directly without a isMounted guard.
|
|
39
38
|
const route = useRoute();
|
|
40
39
|
|
|
41
|
-
const activeHref = computed(() =>
|
|
42
|
-
|
|
43
|
-
// Client-side only active state check to prevent hydration mismatch
|
|
44
|
-
const isActiveItem = (href?: string) => isMounted.value && activeHref.value === href;
|
|
40
|
+
const activeHref = computed(() => route.path);
|
|
41
|
+
const isActiveItem = (href?: string) => href === route.path;
|
|
45
42
|
|
|
46
43
|
watch(
|
|
47
44
|
() => route.path,
|
|
48
|
-
(
|
|
45
|
+
() => {
|
|
49
46
|
closeMenu();
|
|
50
|
-
navigationStore.syncWithRoute(navItemData.main ?? [], newPath);
|
|
51
47
|
options.onRouteChange?.();
|
|
52
48
|
},
|
|
53
49
|
{ flush: "post" }
|
|
@@ -68,8 +64,6 @@ export const useNavCollapse = (navItemData: NavItemData, stateKey: string, optio
|
|
|
68
64
|
checkOverflow();
|
|
69
65
|
isLoaded.value = true;
|
|
70
66
|
await router.isReady();
|
|
71
|
-
navigationStore.initializeFromRoute(navItemData.main ?? [], route.path);
|
|
72
|
-
isMounted.value = true;
|
|
73
67
|
options.onMounted?.();
|
|
74
68
|
});
|
|
75
69
|
|
|
@@ -79,12 +73,10 @@ export const useNavCollapse = (navItemData: NavItemData, stateKey: string, optio
|
|
|
79
73
|
isCollapsed,
|
|
80
74
|
isLoaded,
|
|
81
75
|
isMenuOpen,
|
|
82
|
-
isMounted,
|
|
83
76
|
activeHref,
|
|
84
77
|
checkOverflow,
|
|
85
78
|
toggleMenu,
|
|
86
79
|
closeMenu,
|
|
87
80
|
isActiveItem,
|
|
88
|
-
navigationStore,
|
|
89
81
|
};
|
|
90
82
|
};
|
package/package.json
CHANGED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
export const useNavigationStore = defineStore(
|
|
2
|
-
"useNavigationStore",
|
|
3
|
-
() => {
|
|
4
|
-
// State
|
|
5
|
-
const activeHref = ref<string | null>(null);
|
|
6
|
-
const isInitialized = ref(false);
|
|
7
|
-
|
|
8
|
-
// Getters
|
|
9
|
-
const currentActiveHref = computed(() => activeHref.value);
|
|
10
|
-
|
|
11
|
-
// Actions
|
|
12
|
-
const setActiveHref = (href: string | null) => {
|
|
13
|
-
activeHref.value = href;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Initialize active href based on current route and navigation items
|
|
18
|
-
* Used on component mount to handle deep links and initial page loads
|
|
19
|
-
*/
|
|
20
|
-
const initializeFromRoute = (navItems: Array<{ href?: string }>, routePath: string) => {
|
|
21
|
-
if (isInitialized.value) return;
|
|
22
|
-
|
|
23
|
-
const items = navItems.filter((item): item is { href: string } => Boolean(item.href));
|
|
24
|
-
|
|
25
|
-
// Try exact match first
|
|
26
|
-
const exact = items.find((item) => routePath === item.href);
|
|
27
|
-
if (exact) {
|
|
28
|
-
activeHref.value = exact.href;
|
|
29
|
-
isInitialized.value = true;
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Fall back to longest prefix match
|
|
34
|
-
const prefixMatches = items
|
|
35
|
-
.filter((item) => routePath.startsWith(item.href + "/"))
|
|
36
|
-
.sort((a, b) => b.href.length - a.href.length);
|
|
37
|
-
|
|
38
|
-
if (prefixMatches.length > 0) {
|
|
39
|
-
activeHref.value = prefixMatches[0]!.href;
|
|
40
|
-
} else {
|
|
41
|
-
// Default to null if no matches (let component handle fallback)
|
|
42
|
-
activeHref.value = null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
isInitialized.value = true;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Update active href when user clicks a navigation link
|
|
50
|
-
* Provides explicit control over active state
|
|
51
|
-
*/
|
|
52
|
-
const handleNavLinkClick = (href: string) => {
|
|
53
|
-
activeHref.value = href;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Sync with route changes (for programmatic navigation)
|
|
58
|
-
* Re-runs the route matching logic when route changes
|
|
59
|
-
*/
|
|
60
|
-
const syncWithRoute = (navItems: Array<{ href?: string }>, routePath: string) => {
|
|
61
|
-
const items = navItems.filter((item): item is { href: string } => Boolean(item.href));
|
|
62
|
-
|
|
63
|
-
// Try exact match first
|
|
64
|
-
const exact = items.find((item) => routePath === item.href);
|
|
65
|
-
if (exact) {
|
|
66
|
-
activeHref.value = exact.href;
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Fall back to longest prefix match
|
|
71
|
-
const prefixMatches = items
|
|
72
|
-
.filter((item) => routePath.startsWith(item.href + "/"))
|
|
73
|
-
.sort((a, b) => b.href.length - a.href.length);
|
|
74
|
-
|
|
75
|
-
if (prefixMatches.length > 0) {
|
|
76
|
-
activeHref.value = prefixMatches[0]!.href;
|
|
77
|
-
}
|
|
78
|
-
// Don't reset to null if no matches - preserve existing active state
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Reset store state (useful for testing or when navigation data changes)
|
|
83
|
-
*/
|
|
84
|
-
const reset = () => {
|
|
85
|
-
activeHref.value = null;
|
|
86
|
-
isInitialized.value = false;
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
return {
|
|
90
|
-
// State
|
|
91
|
-
activeHref,
|
|
92
|
-
isInitialized,
|
|
93
|
-
|
|
94
|
-
// Getters
|
|
95
|
-
currentActiveHref,
|
|
96
|
-
|
|
97
|
-
// Actions
|
|
98
|
-
setActiveHref,
|
|
99
|
-
initializeFromRoute,
|
|
100
|
-
handleNavLinkClick,
|
|
101
|
-
syncWithRoute,
|
|
102
|
-
reset,
|
|
103
|
-
};
|
|
104
|
-
},
|
|
105
|
-
{
|
|
106
|
-
persist: {
|
|
107
|
-
storage: piniaPluginPersistedstate.sessionStorage(),
|
|
108
|
-
// Only persist the active href, not initialization state
|
|
109
|
-
// (we want to re-initialize on page refresh to handle route changes)
|
|
110
|
-
pick: ["activeHref"],
|
|
111
|
-
},
|
|
112
|
-
}
|
|
113
|
-
);
|