srcdev-nuxt-components 9.1.9 → 9.1.11

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;
@@ -221,6 +199,7 @@ const resetHoverNavToActive = () => {
221
199
  };
222
200
 
223
201
  const initNavDecorators = () => {
202
+ cachedNavLinks = []; // invalidate — list may have been re-rendered (collapse toggle)
224
203
  if (!navListRef.value) return;
225
204
  const links = getNavLinks();
226
205
  if (!links.length) return;
@@ -230,8 +209,7 @@ const initNavDecorators = () => {
230
209
  navSnapTimer = null;
231
210
  }
232
211
 
233
- const activeLink =
234
- links.find((el) => el.getAttribute("href") === activeHref.value) ?? links[0];
212
+ const activeLink = links.find((el) => el.getAttribute("href") === activeHref.value) ?? links[0];
235
213
  if (!activeLink) return;
236
214
 
237
215
  currentActiveNavLink = activeLink;
@@ -334,8 +312,7 @@ const initPanelDecorators = () => {
334
312
  panelSnapTimer = null;
335
313
  }
336
314
 
337
- const activeLink =
338
- links.find((el) => el.getAttribute("href") === activeHref.value) ?? links[0];
315
+ const activeLink = links.find((el) => el.getAttribute("href") === activeHref.value) ?? links[0];
339
316
  if (!activeLink) return;
340
317
 
341
318
  currentActivePanelLink = activeLink;
@@ -348,61 +325,25 @@ const initPanelDecorators = () => {
348
325
 
349
326
  // ─────────────────────────────────────────────────────────────────────────────
350
327
 
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(() => {
328
+ const { navRef, navListRef, isCollapsed, isLoaded, isMenuOpen, activeHref, isActiveItem, toggleMenu, closeMenu } =
329
+ useNavCollapse("site-nav-loaded", {
330
+ onResize: async () => {
331
+ await nextTick(); // wait for Vue to re-render after any isCollapsed change
370
332
  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();
333
+ if (isMenuOpen.value) {
334
+ setFinalPanelActivePositions(true);
335
+ setFinalPanelHoveredPositions(true);
336
+ }
337
+ },
338
+ onRouteChange: () => {
339
+ requestAnimationFrame(() => {
340
+ initNavDecorators();
341
+ });
342
+ },
343
+ onMounted: () => {
344
+ initNavDecorators();
345
+ },
396
346
  });
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
347
 
407
348
  watch(isMenuOpen, async (open) => {
408
349
  if (open) {
@@ -588,7 +529,6 @@ watch(
588
529
  color: var(--_link-hover-color);
589
530
  outline: none;
590
531
  }
591
-
592
532
  }
593
533
  li.is-active .site-nav-link {
594
534
  color: var(--_link-active-color);
@@ -781,7 +721,6 @@ watch(
781
721
  color: var(--_panel-link-hover-color);
782
722
  outline: none;
783
723
  }
784
-
785
724
  }
786
725
  li.is-active .site-nav-panel-link {
787
726
  color: var(--_panel-link-active-color);
@@ -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,16 +13,21 @@
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
20
19
  v-for="item in navItemData.main"
21
20
  :key="item.href"
22
21
  :data-href="item.href"
23
- :class="[item.cssName, { 'is-active': activeHref === item.href, 'is-hovered': hoveredItemHref === item.href }]"
22
+ :class="[item.cssName, { 'is-active': isActiveItem(item.href), 'is-hovered': hoveredItemHref === item.href }]"
23
+ @mouseenter="hoveredItemHref = item.href ?? null"
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
+ >
26
31
  <Icon v-if="item.iconName" :name="item.iconName" aria-hidden="true" />
27
32
  {{ item.text }}
28
33
  </NuxtLink>
@@ -32,7 +37,7 @@
32
37
  </ul>
33
38
 
34
39
  <InputButtonCore
35
- v-if="isCollapsed && isLoaded"
40
+ v-if="showCollapsed"
36
41
  class="tab-nav-burger"
37
42
  :class="{ 'is-open': isMenuOpen }"
38
43
  variant="tertiary"
@@ -50,7 +55,7 @@
50
55
 
51
56
  <Teleport to="body">
52
57
  <div
53
- v-if="isCollapsed && isLoaded"
58
+ v-if="showCollapsed"
54
59
  class="tab-nav-backdrop"
55
60
  :class="{ 'is-open': isMenuOpen }"
56
61
  aria-hidden="true"
@@ -59,7 +64,7 @@
59
64
  </Teleport>
60
65
 
61
66
  <div
62
- v-if="isCollapsed && isLoaded"
67
+ v-if="showCollapsed"
63
68
  id="tab-nav-panel"
64
69
  class="tab-nav-panel"
65
70
  :class="{ 'is-open': isMenuOpen }"
@@ -85,7 +90,6 @@
85
90
  </template>
86
91
 
87
92
  <script setup lang="ts">
88
- import { useResizeObserver, onClickOutside } from "@vueuse/core";
89
93
  import type { NavItemData } from "~/types/components";
90
94
 
91
95
  interface Props {
@@ -99,77 +103,11 @@ const props = withDefaults(defineProps<Props>(), {
99
103
  styleClassPassthrough: () => [],
100
104
  });
101
105
 
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
- });
106
+ const { navRef, navListRef, isCollapsed, isLoaded, isMenuOpen, isActiveItem, toggleMenu, closeMenu } =
107
+ useNavCollapse("tab-nav-loaded");
144
108
 
145
109
  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
- });
110
+ const showCollapsed = computed(() => isCollapsed.value && isLoaded.value);
173
111
 
174
112
  const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
175
113
 
@@ -206,8 +144,7 @@ watch(
206
144
 
207
145
  /* Decorators — horizontal nav */
208
146
  --_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;
147
+ --_decorator-indicator-color: var(--tab-nav-decorator-indicator-color, var(--slate-01, currentColor));
211
148
 
212
149
  /* Horizontal nav */
213
150
  --_link-color: var(--tab-nav-link-color, var(--slate-01, currentColor));
@@ -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="is-active"><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,82 @@
1
+ import { useResizeObserver, onClickOutside } from "@vueuse/core";
2
+
3
+ interface NavCollapseOptions {
4
+ onResize?: () => void;
5
+ onRouteChange?: () => void;
6
+ onMounted?: () => void;
7
+ }
8
+
9
+ export const useNavCollapse = (stateKey: string, options: NavCollapseOptions = {}) => {
10
+ const navRef = ref<HTMLElement | null>(null);
11
+ const navListRef = ref<HTMLUListElement | null>(null);
12
+
13
+ const isCollapsed = ref(false);
14
+ const isLoaded = useState(stateKey, () => false);
15
+ const isMenuOpen = ref(false);
16
+
17
+ // Stored natural width of the list — used when the list is not in the DOM
18
+ let navListNaturalWidth = 0;
19
+
20
+ const checkOverflow = () => {
21
+ if (!navRef.value) return;
22
+ if (navListRef.value) {
23
+ navListNaturalWidth = navListRef.value.scrollWidth;
24
+ }
25
+ isCollapsed.value = navListNaturalWidth > navRef.value.clientWidth;
26
+ };
27
+
28
+ const toggleMenu = () => {
29
+ isMenuOpen.value = !isMenuOpen.value;
30
+ };
31
+
32
+ const closeMenu = () => {
33
+ isMenuOpen.value = false;
34
+ };
35
+
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.
38
+ const route = useRoute();
39
+
40
+ const activeHref = computed(() => route.path);
41
+ const isActiveItem = (href?: string) => href === route.path;
42
+
43
+ watch(
44
+ () => route.path,
45
+ () => {
46
+ closeMenu();
47
+ options.onRouteChange?.();
48
+ },
49
+ { flush: "post" }
50
+ );
51
+
52
+ useResizeObserver(navRef, () => {
53
+ checkOverflow();
54
+ if (!isCollapsed.value) closeMenu();
55
+ options.onResize?.();
56
+ });
57
+
58
+ onClickOutside(navRef, closeMenu);
59
+
60
+ const router = useRouter();
61
+
62
+ onMounted(async () => {
63
+ await nextTick();
64
+ checkOverflow();
65
+ isLoaded.value = true;
66
+ await router.isReady();
67
+ options.onMounted?.();
68
+ });
69
+
70
+ return {
71
+ navRef,
72
+ navListRef,
73
+ isCollapsed,
74
+ isLoaded,
75
+ isMenuOpen,
76
+ activeHref,
77
+ checkOverflow,
78
+ toggleMenu,
79
+ closeMenu,
80
+ isActiveItem,
81
+ };
82
+ };
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.11",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",