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.
@@ -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 v-for="item in navItemData.main" :key="item.href" :class="[item.cssName, { 'is-active': activeHref === item.href }]">
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 v-for="item in navItemData.main" :key="item.href" :class="[item.cssName, { 'is-active': activeHref === item.href }]">
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
- // 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.
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
- });
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': activeHref === item.href, 'is-hovered': hoveredItemHref === item.href }]"
23
+ :class="[item.cssName, { 'is-active': isActiveItem(item.href), 'is-hovered': hoveredItemHref === item.href }]"
24
24
  >
25
- <NuxtLink :href="item.href" :external="item.isExternal || undefined" class="tab-nav-link" data-nav-item>
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="closeMenu"
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 = ref<HTMLElement | null>(null);
103
- const navListRef = ref<HTMLUListElement | null>(null);
107
+ const { navRef, navListRef, isCollapsed, isLoaded, isMenuOpen, isActiveItem, toggleMenu, closeMenu, navigationStore } =
108
+ useNavCollapse(props.navItemData, "tab-nav-loaded");
104
109
 
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;
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
- const toggleMenu = () => {
123
- isMenuOpen.value = !isMenuOpen.value;
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
+ );
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.1.9",
4
+ "version": "9.1.10",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",