srcdev-nuxt-components 9.1.9 → 9.1.10
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/settings.json +3 -1
- package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +41 -91
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +24 -63
- package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +27 -0
- package/app/composables/useNavCollapse.ts +90 -0
- package/app/stores/useNavigationStore.ts +113 -0
- package/package.json +1 -1
package/.claude/settings.json
CHANGED
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"Bash(npx tsc:*)",
|
|
17
17
|
"Bash(npx vitest:*)",
|
|
18
18
|
"Bash(git show:*)",
|
|
19
|
-
"Edit(/.claude/skills/components/**)"
|
|
19
|
+
"Edit(/.claude/skills/components/**)",
|
|
20
|
+
"Bash(npx nuxi:*)",
|
|
21
|
+
"Bash(node -e \"const t = require\\('/Users/simoncornforth/websites/nuxt-components/node_modules/pinia-plugin-persistedstate'\\); console.log\\(Object.keys\\(t\\)\\)\")"
|
|
20
22
|
],
|
|
21
23
|
"additionalDirectories": [
|
|
22
24
|
"/Users/simoncornforth/websites/nuxt-components/app/components/01.atoms/content-wrappers/content-width",
|
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
@mouseleave="resetHoverNavToActive"
|
|
18
18
|
@mouseover="handleNavHover"
|
|
19
19
|
>
|
|
20
|
-
<li
|
|
20
|
+
<li
|
|
21
|
+
v-for="item in navItemData.main"
|
|
22
|
+
:key="item.href"
|
|
23
|
+
:class="[item.cssName, { 'is-active': isActiveItem(item.href) }]"
|
|
24
|
+
>
|
|
21
25
|
<NuxtLink :href="item.href" :external="item.isExternal || undefined" class="site-nav-link" data-nav-item>
|
|
22
26
|
<Icon v-if="item.iconName" :name="item.iconName" aria-hidden="true" />
|
|
23
27
|
{{ item.text }}
|
|
@@ -70,7 +74,11 @@
|
|
|
70
74
|
@mouseover="handlePanelHover"
|
|
71
75
|
@mouseleave="resetHoverPanelToActive"
|
|
72
76
|
>
|
|
73
|
-
<li
|
|
77
|
+
<li
|
|
78
|
+
v-for="item in navItemData.main"
|
|
79
|
+
:key="item.href"
|
|
80
|
+
:class="[item.cssName, { 'is-active': isActiveItem(item.href) }]"
|
|
81
|
+
>
|
|
74
82
|
<NuxtLink
|
|
75
83
|
:href="item.href"
|
|
76
84
|
:external="item.isExternal || undefined"
|
|
@@ -92,7 +100,6 @@
|
|
|
92
100
|
</template>
|
|
93
101
|
|
|
94
102
|
<script setup lang="ts">
|
|
95
|
-
import { useResizeObserver, onClickOutside } from "@vueuse/core";
|
|
96
103
|
import type { NavItemData } from "~/types/components";
|
|
97
104
|
|
|
98
105
|
interface Props {
|
|
@@ -106,35 +113,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
106
113
|
styleClassPassthrough: () => [],
|
|
107
114
|
});
|
|
108
115
|
|
|
109
|
-
const navRef = ref<HTMLElement | null>(null);
|
|
110
|
-
const navListRef = ref<HTMLUListElement | null>(null);
|
|
111
|
-
|
|
112
|
-
const isCollapsed = ref(false);
|
|
113
|
-
const isLoaded = useState("site-nav-loaded", () => false);
|
|
114
|
-
const isMenuOpen = ref(false);
|
|
115
|
-
|
|
116
|
-
// Stored natural width of the list — used when the list is not in the DOM
|
|
117
|
-
let navListNaturalWidth = 0;
|
|
118
|
-
|
|
119
|
-
const checkOverflow = () => {
|
|
120
|
-
if (!navRef.value) return;
|
|
121
|
-
|
|
122
|
-
// Measure and store the list width whenever it's in the DOM
|
|
123
|
-
if (navListRef.value) {
|
|
124
|
-
navListNaturalWidth = navListRef.value.scrollWidth;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
isCollapsed.value = navListNaturalWidth > navRef.value.clientWidth;
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
const toggleMenu = () => {
|
|
131
|
-
isMenuOpen.value = !isMenuOpen.value;
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
const closeMenu = () => {
|
|
135
|
-
isMenuOpen.value = false;
|
|
136
|
-
};
|
|
137
|
-
|
|
138
116
|
// ─── Nav decorators (active / hover indicators) ─────────────────────────────
|
|
139
117
|
|
|
140
118
|
const NAV_DECORATOR_DURATION = 200;
|
|
@@ -200,6 +178,13 @@ const moveNavHoveredIndicator = () => {
|
|
|
200
178
|
const handleNavLinkClick = (event: MouseEvent) => {
|
|
201
179
|
const target = (event.target as HTMLElement).closest<HTMLElement>("[data-nav-item]");
|
|
202
180
|
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
|
+
|
|
203
188
|
currentActiveNavLink = target;
|
|
204
189
|
currentHoveredNavLink = target;
|
|
205
190
|
previousHoveredNavLink = target;
|
|
@@ -230,8 +215,7 @@ const initNavDecorators = () => {
|
|
|
230
215
|
navSnapTimer = null;
|
|
231
216
|
}
|
|
232
217
|
|
|
233
|
-
const activeLink =
|
|
234
|
-
links.find((el) => el.getAttribute("href") === activeHref.value) ?? links[0];
|
|
218
|
+
const activeLink = links.find((el) => el.getAttribute("href") === activeHref.value) ?? links[0];
|
|
235
219
|
if (!activeLink) return;
|
|
236
220
|
|
|
237
221
|
currentActiveNavLink = activeLink;
|
|
@@ -303,6 +287,13 @@ const movePanelHoveredIndicator = () => {
|
|
|
303
287
|
const handlePanelLinkClick = (event: MouseEvent) => {
|
|
304
288
|
const target = (event.target as HTMLElement).closest<HTMLElement>("[data-panel-nav-item]");
|
|
305
289
|
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
|
+
|
|
306
297
|
currentActivePanelLink = target;
|
|
307
298
|
currentHoveredPanelLink = target;
|
|
308
299
|
previousHoveredPanelLink = target;
|
|
@@ -334,8 +325,7 @@ const initPanelDecorators = () => {
|
|
|
334
325
|
panelSnapTimer = null;
|
|
335
326
|
}
|
|
336
327
|
|
|
337
|
-
const activeLink =
|
|
338
|
-
links.find((el) => el.getAttribute("href") === activeHref.value) ?? links[0];
|
|
328
|
+
const activeLink = links.find((el) => el.getAttribute("href") === activeHref.value) ?? links[0];
|
|
339
329
|
if (!activeLink) return;
|
|
340
330
|
|
|
341
331
|
currentActivePanelLink = activeLink;
|
|
@@ -348,61 +338,23 @@ const initPanelDecorators = () => {
|
|
|
348
338
|
|
|
349
339
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
350
340
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
watch(
|
|
366
|
-
() => route.path,
|
|
367
|
-
() => {
|
|
368
|
-
closeMenu();
|
|
369
|
-
requestAnimationFrame(() => {
|
|
341
|
+
const { navRef, navListRef, isCollapsed, isLoaded, isMenuOpen, activeHref, isActiveItem, toggleMenu, closeMenu, navigationStore } =
|
|
342
|
+
useNavCollapse(props.navItemData, "site-nav-loaded", {
|
|
343
|
+
onResize: () => {
|
|
344
|
+
setFinalNavActivePositions(true);
|
|
345
|
+
setFinalNavHoveredPositions(true);
|
|
346
|
+
setFinalPanelActivePositions(true);
|
|
347
|
+
setFinalPanelHoveredPositions(true);
|
|
348
|
+
},
|
|
349
|
+
onRouteChange: () => {
|
|
350
|
+
requestAnimationFrame(() => {
|
|
351
|
+
initNavDecorators();
|
|
352
|
+
});
|
|
353
|
+
},
|
|
354
|
+
onMounted: () => {
|
|
370
355
|
initNavDecorators();
|
|
371
|
-
}
|
|
372
|
-
},
|
|
373
|
-
{ flush: "post" }
|
|
374
|
-
);
|
|
375
|
-
|
|
376
|
-
useResizeObserver(navRef, () => {
|
|
377
|
-
checkOverflow();
|
|
378
|
-
if (!isCollapsed.value) closeMenu();
|
|
379
|
-
setFinalNavActivePositions(true);
|
|
380
|
-
setFinalNavHoveredPositions(true);
|
|
381
|
-
setFinalPanelActivePositions(true);
|
|
382
|
-
setFinalPanelHoveredPositions(true);
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
onClickOutside(navRef, closeMenu);
|
|
386
|
-
|
|
387
|
-
const router = useRouter();
|
|
388
|
-
|
|
389
|
-
onMounted(async () => {
|
|
390
|
-
await nextTick();
|
|
391
|
-
checkOverflow();
|
|
392
|
-
isLoaded.value = true;
|
|
393
|
-
await router.isReady();
|
|
394
|
-
requestAnimationFrame(() => {
|
|
395
|
-
initNavDecorators();
|
|
356
|
+
},
|
|
396
357
|
});
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
watch(isCollapsed, async (collapsed) => {
|
|
400
|
-
if (!collapsed) {
|
|
401
|
-
cachedNavLinks = []; // nav <ul> was re-rendered — invalidate cache
|
|
402
|
-
await nextTick();
|
|
403
|
-
initNavDecorators();
|
|
404
|
-
}
|
|
405
|
-
});
|
|
406
358
|
|
|
407
359
|
watch(isMenuOpen, async (open) => {
|
|
408
360
|
if (open) {
|
|
@@ -588,7 +540,6 @@ watch(
|
|
|
588
540
|
color: var(--_link-hover-color);
|
|
589
541
|
outline: none;
|
|
590
542
|
}
|
|
591
|
-
|
|
592
543
|
}
|
|
593
544
|
li.is-active .site-nav-link {
|
|
594
545
|
color: var(--_link-active-color);
|
|
@@ -781,7 +732,6 @@ watch(
|
|
|
781
732
|
color: var(--_panel-link-hover-color);
|
|
782
733
|
outline: none;
|
|
783
734
|
}
|
|
784
|
-
|
|
785
735
|
}
|
|
786
736
|
li.is-active .site-nav-panel-link {
|
|
787
737
|
color: var(--_panel-link-active-color);
|
|
@@ -20,9 +20,15 @@
|
|
|
20
20
|
v-for="item in navItemData.main"
|
|
21
21
|
:key="item.href"
|
|
22
22
|
:data-href="item.href"
|
|
23
|
-
:class="[item.cssName, { 'is-active':
|
|
23
|
+
:class="[item.cssName, { 'is-active': isActiveItem(item.href), 'is-hovered': hoveredItemHref === item.href }]"
|
|
24
24
|
>
|
|
25
|
-
<NuxtLink
|
|
25
|
+
<NuxtLink
|
|
26
|
+
:href="item.href"
|
|
27
|
+
:external="item.isExternal || undefined"
|
|
28
|
+
class="tab-nav-link"
|
|
29
|
+
data-nav-item
|
|
30
|
+
@click="handleNavLinkClick"
|
|
31
|
+
>
|
|
26
32
|
<Icon v-if="item.iconName" :name="item.iconName" aria-hidden="true" />
|
|
27
33
|
{{ item.text }}
|
|
28
34
|
</NuxtLink>
|
|
@@ -72,7 +78,7 @@
|
|
|
72
78
|
:href="item.href"
|
|
73
79
|
:external="item.isExternal || undefined"
|
|
74
80
|
class="tab-nav-panel-link"
|
|
75
|
-
@click="
|
|
81
|
+
@click="handlePanelLinkClick"
|
|
76
82
|
>
|
|
77
83
|
<Icon v-if="item.iconName" :name="item.iconName" aria-hidden="true" />
|
|
78
84
|
{{ item.text }}
|
|
@@ -85,7 +91,6 @@
|
|
|
85
91
|
</template>
|
|
86
92
|
|
|
87
93
|
<script setup lang="ts">
|
|
88
|
-
import { useResizeObserver, onClickOutside } from "@vueuse/core";
|
|
89
94
|
import type { NavItemData } from "~/types/components";
|
|
90
95
|
|
|
91
96
|
interface Props {
|
|
@@ -99,49 +104,26 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
99
104
|
styleClassPassthrough: () => [],
|
|
100
105
|
});
|
|
101
106
|
|
|
102
|
-
const navRef
|
|
103
|
-
|
|
107
|
+
const { navRef, navListRef, isCollapsed, isLoaded, isMenuOpen, isActiveItem, toggleMenu, closeMenu, navigationStore } =
|
|
108
|
+
useNavCollapse(props.navItemData, "tab-nav-loaded");
|
|
104
109
|
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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;
|
|
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);
|
|
120
116
|
};
|
|
121
117
|
|
|
122
|
-
|
|
123
|
-
|
|
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();
|
|
124
125
|
};
|
|
125
126
|
|
|
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
127
|
const hoveredItemHref = ref<string | null>(null);
|
|
146
128
|
|
|
147
129
|
const handleNavMousemove = (event: MouseEvent) => {
|
|
@@ -150,27 +132,6 @@ const handleNavMousemove = (event: MouseEvent) => {
|
|
|
150
132
|
hoveredItemHref.value = li.dataset.href ?? null;
|
|
151
133
|
};
|
|
152
134
|
|
|
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
135
|
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
175
136
|
|
|
176
137
|
watch(
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`TabNavigation > renders correct HTML structure 1`] = `
|
|
4
|
+
"<nav class="tab-navigation tab-navigation--left is-loaded" aria-label="Site navigation">
|
|
5
|
+
<ul class="tab-nav-list">
|
|
6
|
+
<li data-href="/" class=""><a href="/" class="tab-nav-link" data-nav-item="">
|
|
7
|
+
<!--v-if--> Home
|
|
8
|
+
</a></li>
|
|
9
|
+
<li data-href="/about" class=""><a href="/about" class="tab-nav-link" data-nav-item="">
|
|
10
|
+
<!--v-if--> About
|
|
11
|
+
</a></li>
|
|
12
|
+
<li data-href="/contact" class=""><a href="/contact" class="tab-nav-link" data-nav-item="">
|
|
13
|
+
<!--v-if--> Contact
|
|
14
|
+
</a></li>
|
|
15
|
+
<li aria-hidden="true" role="none" class="nav-indicator-li">
|
|
16
|
+
<div class="nav__hovered"></div>
|
|
17
|
+
</li>
|
|
18
|
+
<li aria-hidden="true" role="none" class="nav-indicator-li">
|
|
19
|
+
<div class="nav__active-indicator"></div>
|
|
20
|
+
</li>
|
|
21
|
+
</ul>
|
|
22
|
+
<!--v-if-->
|
|
23
|
+
<!--teleport start-->
|
|
24
|
+
<!--teleport end-->
|
|
25
|
+
<!--v-if-->
|
|
26
|
+
</nav>"
|
|
27
|
+
`;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useResizeObserver, onClickOutside } from "@vueuse/core";
|
|
2
|
+
import type { NavItemData } from "~/types/components";
|
|
3
|
+
|
|
4
|
+
interface NavCollapseOptions {
|
|
5
|
+
onResize?: () => void;
|
|
6
|
+
onRouteChange?: () => void;
|
|
7
|
+
onMounted?: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const useNavCollapse = (navItemData: NavItemData, stateKey: string, options: NavCollapseOptions = {}) => {
|
|
11
|
+
const navRef = ref<HTMLElement | null>(null);
|
|
12
|
+
const navListRef = ref<HTMLUListElement | null>(null);
|
|
13
|
+
|
|
14
|
+
const isCollapsed = ref(false);
|
|
15
|
+
const isLoaded = useState(stateKey, () => false);
|
|
16
|
+
const isMenuOpen = ref(false);
|
|
17
|
+
const isMounted = ref(false);
|
|
18
|
+
|
|
19
|
+
// Stored natural width of the list — used when the list is not in the DOM
|
|
20
|
+
let navListNaturalWidth = 0;
|
|
21
|
+
|
|
22
|
+
const checkOverflow = () => {
|
|
23
|
+
if (!navRef.value) return;
|
|
24
|
+
if (navListRef.value) {
|
|
25
|
+
navListNaturalWidth = navListRef.value.scrollWidth;
|
|
26
|
+
}
|
|
27
|
+
isCollapsed.value = navListNaturalWidth > navRef.value.clientWidth;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const toggleMenu = () => {
|
|
31
|
+
isMenuOpen.value = !isMenuOpen.value;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const closeMenu = () => {
|
|
35
|
+
isMenuOpen.value = false;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const navigationStore = useNavigationStore();
|
|
39
|
+
const route = useRoute();
|
|
40
|
+
|
|
41
|
+
const activeHref = computed(() => navigationStore.currentActiveHref);
|
|
42
|
+
|
|
43
|
+
// Client-side only active state check to prevent hydration mismatch
|
|
44
|
+
const isActiveItem = (href?: string) => isMounted.value && activeHref.value === href;
|
|
45
|
+
|
|
46
|
+
watch(
|
|
47
|
+
() => route.path,
|
|
48
|
+
(newPath) => {
|
|
49
|
+
closeMenu();
|
|
50
|
+
navigationStore.syncWithRoute(navItemData.main ?? [], newPath);
|
|
51
|
+
options.onRouteChange?.();
|
|
52
|
+
},
|
|
53
|
+
{ flush: "post" }
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
useResizeObserver(navRef, () => {
|
|
57
|
+
checkOverflow();
|
|
58
|
+
if (!isCollapsed.value) closeMenu();
|
|
59
|
+
options.onResize?.();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
onClickOutside(navRef, closeMenu);
|
|
63
|
+
|
|
64
|
+
const router = useRouter();
|
|
65
|
+
|
|
66
|
+
onMounted(async () => {
|
|
67
|
+
await nextTick();
|
|
68
|
+
checkOverflow();
|
|
69
|
+
isLoaded.value = true;
|
|
70
|
+
await router.isReady();
|
|
71
|
+
navigationStore.initializeFromRoute(navItemData.main ?? [], route.path);
|
|
72
|
+
isMounted.value = true;
|
|
73
|
+
options.onMounted?.();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
navRef,
|
|
78
|
+
navListRef,
|
|
79
|
+
isCollapsed,
|
|
80
|
+
isLoaded,
|
|
81
|
+
isMenuOpen,
|
|
82
|
+
isMounted,
|
|
83
|
+
activeHref,
|
|
84
|
+
checkOverflow,
|
|
85
|
+
toggleMenu,
|
|
86
|
+
closeMenu,
|
|
87
|
+
isActiveItem,
|
|
88
|
+
navigationStore,
|
|
89
|
+
};
|
|
90
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
);
|