srcdev-nuxt-components 9.1.8 → 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 +47 -92
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +484 -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/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
|
@@ -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
|
+
);
|