srcdev-nuxt-components 9.6.0 → 9.6.1

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.
Files changed (107) hide show
  1. package/.claude/skills/component-compliance-checklist.md +8 -2
  2. package/.claude/skills/component-dynamic-slots.md +3 -3
  3. package/.claude/skills/component-export-types.md +10 -16
  4. package/.claude/skills/component-naming.md +1 -1
  5. package/.claude/skills/components/alert-content.md +9 -1
  6. package/.claude/skills/components/container-glow.md +21 -8
  7. package/.claude/skills/components/display-avatar.md +13 -7
  8. package/.claude/skills/components/display-chip.md +37 -24
  9. package/.claude/skills/components/display-pill.md +58 -10
  10. package/.claude/skills/components/display-prompt.md +8 -5
  11. package/.claude/skills/components/display-toast.md +21 -4
  12. package/.claude/skills/components/glowing-border.md +20 -2
  13. package/.claude/skills/components/responsive-header.md +11 -1
  14. package/.claude/skills/components/select-menu.md +54 -5
  15. package/.claude/skills/components/site-header.md +2 -2
  16. package/.claude/skills/components/tabbed-content.md +300 -0
  17. package/.claude/skills/composable-anchor-scroll.md +16 -15
  18. package/.claude/skills/index.md +4 -4
  19. package/.claude/skills/storybook-add-story.md +53 -0
  20. package/.claude/skills/theming-status-tokens.md +60 -0
  21. package/.vscode/srcdev-component-display-avatar.code-snippets +2 -1
  22. package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
  23. package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
  24. package/.vscode/srcdev-component-responsive-header.code-snippets +6 -0
  25. package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
  26. package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
  27. package/app/assets/styles/setup/03.theming/_status.css +24 -0
  28. package/app/assets/styles/setup/03.theming/index.css +1 -0
  29. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
  30. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
  31. package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
  32. package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
  33. package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
  34. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
  35. package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
  36. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
  37. package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
  38. package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
  39. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +75 -9
  40. package/app/components/01.atoms/display-pill/DisplayPill.vue +57 -24
  41. package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
  42. package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
  43. package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
  44. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
  45. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
  46. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
  47. package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
  48. package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
  49. package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
  50. package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
  51. package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
  52. package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
  53. package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +7 -5
  54. package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +6 -2
  55. package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +24 -3
  56. package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +19 -0
  57. package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +2 -1
  58. package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +52 -19
  59. package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
  60. package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
  61. package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
  62. package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
  63. package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
  64. package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
  65. package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
  66. package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
  67. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
  68. package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
  69. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
  70. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
  71. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
  72. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
  73. package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
  74. package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
  75. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
  76. package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
  77. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
  78. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
  79. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +38 -11
  80. package/app/components/03.organisms/responsive-header/NavigationItems.vue +27 -12
  81. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +35 -20
  82. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +1 -1
  83. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +1 -1
  84. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
  85. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
  86. package/app/composables/useTabs.ts +148 -206
  87. package/app/types/components/display-chip.d.ts +8 -4
  88. package/app/types/components/display-toast.d.ts +2 -0
  89. package/app/types/components/index.ts +0 -1
  90. package/app/types/components/select-menu.d.ts +2 -0
  91. package/app/utils/pausableTimeout.ts +41 -0
  92. package/nuxt.config.ts +1 -1
  93. package/package.json +1 -1
  94. package/.claude/skills/components/tab-navigation.md +0 -288
  95. package/.claude/skills/components/tabs-core.md +0 -187
  96. package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
  97. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
  98. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
  99. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
  100. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
  101. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -191
  102. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -550
  103. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -259
  104. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -379
  105. package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
  106. package/app/composables/useNavCollapse.ts +0 -86
  107. package/app/types/components/nav-item.d.ts +0 -11
@@ -1,304 +1,246 @@
1
1
  import { useResizeObserver } from "@vueuse/core";
2
-
3
- interface UseTabsOptions {
4
- trackHover: boolean;
5
- trackActive: boolean;
6
- trackIndicator: boolean;
7
- }
2
+ import type { MaybeRefOrGetter } from "vue";
8
3
 
9
4
  const useTabs = (
10
- axis: string,
5
+ axis: MaybeRefOrGetter<"x" | "y">,
11
6
  tabsNavRef: Ref<HTMLElement | null>,
12
7
  tabsContentRefs: Ref<HTMLElement[] | null>,
13
- duration: number,
14
- options: UseTabsOptions = { trackHover: true, trackActive: true, trackIndicator: true }
8
+ duration: MaybeRefOrGetter<number>,
9
+ trackHover: MaybeRefOrGetter<boolean> = true
15
10
  ) => {
16
- const navItems = ref<HTMLElement[] | null>(null);
17
- // Plain ref, not useState: this is ephemeral per-instance UI state, not something that
18
- // needs to survive SSR hydration or be shared by key — useState's fixed key here previously
19
- // meant every TabsCore instance on the page silently shared the same "previous active tab".
20
- const previousActiveTab = ref<HTMLElement | null>(null);
11
+ // All triggers, and the ones not collapsed into an overflow menu (keyboard and roving tabindex use these).
12
+ const allItems = ref<HTMLElement[]>([]);
13
+ const navItems = ref<HTMLElement[]>([]);
14
+ // Plain refs, not useState: per-instance UI state that must not be shared between instances.
21
15
  const currentActiveTab = ref<HTMLElement>();
22
-
23
- const previousHoveredTab = ref<HTMLElement>();
24
16
  const currentHoveredTab = ref<HTMLElement>();
25
- const tagName = ref<string>();
26
-
27
- let activeSettleTimeoutId: ReturnType<typeof setTimeout> | null = null;
28
- let hoveredSettleTimeoutId: ReturnType<typeof setTimeout> | null = null;
29
17
 
30
- const initNavDecorators = () => {
31
- navItems.value = tabsNavRef.value
32
- ? (Array.from(tabsNavRef.value.querySelectorAll("[data-nav-item]")) as HTMLElement[])
33
- : [];
18
+ type IndicatorKind = "active" | "hovered";
19
+ // Leading edge (start along the axis) each indicator was last sent to, to tell the direction of a move.
20
+ const lastStart: Record<IndicatorKind, number | null> = { active: null, hovered: null };
21
+ let coverageFrameId: number | null = null;
22
+ let coverageUntil = 0;
34
23
 
35
- if (!navItems.value || navItems.value.length === 0) {
36
- return;
37
- }
24
+ const transitionMs = () => Math.max(0, Number(toValue(duration)) || 0);
38
25
 
39
- const firstNavItem = navItems.value[0];
40
- if (!firstNavItem) return;
41
-
42
- tagName.value = firstNavItem.tagName.toLowerCase();
43
-
44
- const activeIndex = ref(0);
45
-
46
- // Temporarily set the first nav item as active
47
- firstNavItem.setAttribute("aria-selected", "true");
26
+ const activeIndex = computed(() => {
27
+ const index = currentActiveTab.value?.dataset.tabIndex;
28
+ return index === undefined ? null : Number(index);
29
+ });
48
30
 
49
- // Test if navItems are hyperlinks
50
- if (firstNavItem.tagName.toLowerCase() === "a") {
51
- // Find index of element with class "router-link-active"
52
- activeIndex.value = navItems.value.findIndex((el) => el.classList.contains("router-link-active"));
53
- }
31
+ // A collapsed tab has no box of its own, so the indicators sit on the overflow menu's trigger instead.
32
+ const indicatorTarget = (element?: HTMLElement | null) =>
33
+ element?.hidden ? (tabsNavRef.value?.querySelector<HTMLElement>("[data-more-trigger]") ?? element) : element;
34
+
35
+ /**
36
+ * Re-reads the tabs from the DOM (after mount, or when the tab count/axis/indicators/overflow change),
37
+ * keeping the active tab when it still exists. `animate` retargets indicators that are mid-move
38
+ * instead of snapping them.
39
+ */
40
+ const refreshTabs = (animate = false) => {
41
+ allItems.value = tabsNavRef.value
42
+ ? Array.from(tabsNavRef.value.querySelectorAll<HTMLElement>("[data-nav-item]"))
43
+ : [];
44
+ navItems.value = allItems.value.filter((tab) => !tab.hidden);
54
45
 
55
- const activeElement = navItems.value[activeIndex.value];
56
- if (!activeElement) return;
46
+ const items = allItems.value;
47
+ const activeTab =
48
+ currentActiveTab.value && items.includes(currentActiveTab.value) ? currentActiveTab.value : items[0];
57
49
 
58
- currentActiveTab.value = activeElement;
59
- currentHoveredTab.value = activeElement;
50
+ currentActiveTab.value = activeTab;
51
+ currentHoveredTab.value = activeTab;
60
52
 
61
- previousActiveTab.value = activeElement;
62
- previousHoveredTab.value = activeElement;
53
+ items.forEach((tab) => {
54
+ tab.setAttribute("aria-selected", tab === activeTab ? "true" : "false");
55
+ });
63
56
 
64
57
  setRovingTabindex();
65
- addNavDecorators();
58
+ syncIndicators(animate);
66
59
  setActiveTabContent();
67
60
  };
68
61
 
69
- const addNavDecorators = () => {
70
- if (!tabsNavRef.value) return;
71
-
72
- if (options.trackIndicator) {
73
- tabsNavRef.value.appendChild(
74
- Object.assign(document.createElement("div"), { className: "nav__active-indicator" })
75
- );
76
- }
77
- if (options.trackActive) {
78
- tabsNavRef.value.appendChild(Object.assign(document.createElement("div"), { className: "nav__active" }));
79
- }
80
- if (options.trackHover) {
81
- tabsNavRef.value.appendChild(Object.assign(document.createElement("div"), { className: "nav__hovered" }));
82
- }
83
- };
84
-
62
+ // When the active tab is collapsed, the first visible tab keeps the tablist reachable with Tab.
85
63
  const setRovingTabindex = () => {
86
- navItems.value?.forEach((tab) => {
87
- tab.setAttribute("tabindex", tab === currentActiveTab.value ? "0" : "-1");
64
+ const focusTarget = navItems.value.includes(currentActiveTab.value as HTMLElement)
65
+ ? currentActiveTab.value
66
+ : navItems.value[0];
67
+ allItems.value.forEach((tab) => {
68
+ tab.setAttribute("tabindex", tab === focusTarget ? "0" : "-1");
88
69
  });
89
70
  };
90
71
 
91
72
  const navItemHovered = (event: Event) => {
92
- if (!options.trackHover) return;
73
+ if (!toValue(trackHover)) return;
93
74
 
94
- const target = event.target as HTMLElement;
75
+ const target = event.currentTarget as HTMLElement;
95
76
  const newTabPosition = currentHoveredTab.value ? currentHoveredTab.value.compareDocumentPosition(target) : 0;
96
77
 
97
78
  if (newTabPosition !== 0) {
98
- previousHoveredTab.value = currentHoveredTab.value;
99
79
  currentHoveredTab.value = target;
100
- moveHoveredIndicator();
80
+ placeIndicator("hovered", true);
101
81
  }
102
82
  };
103
83
 
104
84
  const resetHoverToActivePosition = () => {
105
- if (!options.trackHover) return;
85
+ if (!toValue(trackHover)) return;
106
86
 
107
- previousHoveredTab.value = currentHoveredTab.value;
108
87
  currentHoveredTab.value = currentActiveTab.value;
109
- moveHoveredIndicator();
88
+ placeIndicator("hovered", true);
110
89
  };
111
90
 
112
91
  const activateTab = (target: HTMLElement) => {
113
92
  if (target === currentActiveTab.value) return;
114
93
 
115
- previousActiveTab.value = currentActiveTab.value || null;
116
94
  currentActiveTab.value = target;
117
95
 
118
- navItems.value?.forEach((tab) => {
96
+ allItems.value.forEach((tab) => {
119
97
  tab.setAttribute("aria-selected", currentActiveTab.value === tab ? "true" : "false");
120
98
  });
121
99
 
122
100
  setRovingTabindex();
123
- moveActiveIndicator();
101
+ placeIndicator("active", true);
124
102
  setActiveTabContent();
125
103
  };
126
104
 
105
+ const activateTabByIndex = (index: number) => {
106
+ const target = allItems.value.find((tab) => Number(tab.dataset.tabIndex) === index);
107
+ if (target) activateTab(target);
108
+ };
109
+
127
110
  const navItemClicked = (event: Event) => {
128
- activateTab(event.target as HTMLElement);
111
+ activateTab(event.currentTarget as HTMLElement);
129
112
  };
130
113
 
131
114
  // WAI-ARIA Tabs pattern: arrow keys move focus and activate (automatic activation);
132
115
  // Home/End jump to the first/last tab.
133
116
  const navItemKeydown = (event: KeyboardEvent) => {
134
- if (!navItems.value || navItems.value.length === 0) return;
117
+ const items = navItems.value;
118
+ if (items.length === 0) return;
135
119
 
136
- const previousKey = axis === "y" ? "ArrowUp" : "ArrowLeft";
137
- const nextKey = axis === "y" ? "ArrowDown" : "ArrowRight";
120
+ const isVertical = toValue(axis) === "y";
121
+ const previousKey = isVertical ? "ArrowUp" : "ArrowLeft";
122
+ const nextKey = isVertical ? "ArrowDown" : "ArrowRight";
138
123
 
139
124
  let targetIndex: number | null = null;
140
- const currentIndex = currentActiveTab.value ? navItems.value.indexOf(currentActiveTab.value) : 0;
125
+ const currentIndex = currentActiveTab.value ? items.indexOf(currentActiveTab.value) : 0;
141
126
 
142
127
  if (event.key === nextKey) {
143
- targetIndex = (currentIndex + 1) % navItems.value.length;
128
+ targetIndex = (currentIndex + 1) % items.length;
144
129
  } else if (event.key === previousKey) {
145
- targetIndex = (currentIndex - 1 + navItems.value.length) % navItems.value.length;
130
+ targetIndex = (currentIndex - 1 + items.length) % items.length;
146
131
  } else if (event.key === "Home") {
147
132
  targetIndex = 0;
148
133
  } else if (event.key === "End") {
149
- targetIndex = navItems.value.length - 1;
134
+ targetIndex = items.length - 1;
150
135
  }
151
136
 
152
137
  if (targetIndex === null) return;
153
138
 
154
139
  event.preventDefault();
155
- const targetTab = navItems.value[targetIndex];
140
+ const targetTab = items[targetIndex];
156
141
  if (!targetTab) return;
157
142
 
158
143
  targetTab.focus();
159
144
  activateTab(targetTab);
160
145
  };
161
146
 
162
- const setFinalHoveredPositions = (resized: boolean = false) => {
163
- if (!tabsNavRef.value || !currentHoveredTab.value) return;
164
-
165
- // Batch every layout read before any style write below, so the browser doesn't need to
166
- // perform a forced synchronous reflow between each read/write pair.
167
- const { offsetLeft, offsetTop, offsetWidth, offsetHeight } = currentHoveredTab.value;
168
- const { offsetWidth: navWidth } = tabsNavRef.value;
169
- const setDuration = resized ? 0 : duration;
170
- const newTabWidth = navWidth ? offsetWidth / navWidth : 0;
171
-
172
- const nav = tabsNavRef.value;
173
- nav.style.setProperty("--_transition-duration", setDuration + "ms");
174
- nav.style.setProperty("--_x-hovered", offsetLeft + "px");
175
- nav.style.setProperty("--_width-hovered", newTabWidth.toString());
176
- nav.style.setProperty("--_y-hovered", offsetTop + "px");
177
- nav.style.setProperty("--_y-height", offsetHeight + "px");
178
- nav.style.setProperty("--_y-width", offsetWidth + "px");
179
- };
180
-
181
- const setFinalActivePositions = (resized: boolean = false) => {
182
- if (!tabsNavRef.value || !currentActiveTab.value) return;
183
-
184
- const { offsetLeft, offsetTop, offsetWidth, offsetHeight } = currentActiveTab.value;
185
- const { offsetWidth: navWidth } = tabsNavRef.value;
186
- const setDuration = resized ? 0 : duration;
187
- const newTabWidth = navWidth ? offsetWidth / navWidth : 0;
188
-
147
+ /**
148
+ * Sends an indicator's two edges to its target tab, as insets from the bar's start and end edges
149
+ * along the axis. On a move, the edge in the direction of travel goes first and the trailing edge
150
+ * follows one duration later, so the indicator stretches across and then catches up. CSS retargets
151
+ * each edge from wherever it is, so a quick second move never pulls an edge backwards.
152
+ * `animate: false` (mount, resize, tab-count change) snaps both edges.
153
+ */
154
+ const placeIndicator = (kind: IndicatorKind, animate: boolean) => {
189
155
  const nav = tabsNavRef.value;
190
- nav.style.setProperty("--_transition-duration", setDuration + "ms");
191
- nav.style.setProperty("--_x-active", offsetLeft + "px");
192
- nav.style.setProperty("--_width-active", newTabWidth.toString());
193
- nav.style.setProperty("--_y-active", offsetTop + "px");
194
- nav.style.setProperty("--_y-height", offsetHeight + "px");
195
- nav.style.setProperty("--_y-width", offsetWidth + "px");
156
+ const target = indicatorTarget(kind === "active" ? currentActiveTab.value : currentHoveredTab.value);
157
+ if (!nav || !target) return;
158
+
159
+ const vertical = toValue(axis) === "y";
160
+ const start = vertical ? target.offsetTop : target.offsetLeft;
161
+ const size = vertical ? target.offsetHeight : target.offsetWidth;
162
+ const end = (vertical ? nav.clientHeight : nav.clientWidth) - start - size;
163
+
164
+ const previous = lastStart[kind];
165
+ const ms = animate && previous !== null ? transitionMs() : 0;
166
+ const forward = previous !== null && start > previous;
167
+ const backward = previous !== null && start < previous;
168
+ lastStart[kind] = start;
169
+
170
+ nav.style.setProperty(`--_${kind}-duration`, ms + "ms");
171
+ nav.style.setProperty(`--_${kind}-start-delay`, forward ? ms + "ms" : "0ms");
172
+ nav.style.setProperty(`--_${kind}-end-delay`, backward ? ms + "ms" : "0ms");
173
+ nav.style.setProperty(`--_${kind}-start`, start + "px");
174
+ nav.style.setProperty(`--_${kind}-end`, end + "px");
175
+
176
+ if (kind === "active") trackCoverage(ms * 2);
196
177
  };
197
178
 
198
- const moveActiveIndicator = () => {
199
- if (!tabsNavRef.value || !currentActiveTab.value) return;
200
- if (!options.trackActive && !options.trackIndicator) return;
201
-
179
+ // Marks each visible tab (and the overflow trigger) that the active indicator covers by at least half,
180
+ // so its text can take the active text colour while the indicator slides under it.
181
+ const updateCoverage = () => {
202
182
  const nav = tabsNavRef.value;
203
- const current = currentActiveTab.value;
204
- const previous = previousActiveTab.value;
205
-
206
- // Batch reads first.
207
- const newTabPosition = previous ? previous.compareDocumentPosition(current) : 0;
208
- const currentLeft = current.offsetLeft;
209
- const currentWidth = current.offsetWidth;
210
- const previousLeft = previous?.offsetLeft ?? 0;
211
- const previousWidth = previous?.offsetWidth ?? 0;
212
- const navWidth = nav.offsetWidth;
213
-
214
- let transitionWidth: number;
215
- let xActive: string | null = null;
216
-
217
- if (newTabPosition === 4) {
218
- transitionWidth = previous ? currentLeft + currentWidth - previousLeft : 0;
219
- } else {
220
- transitionWidth = previous ? previousLeft + previousWidth - currentLeft : 0;
221
- xActive = currentLeft + "px";
222
- }
223
-
224
- // Then batch writes.
225
- nav.style.setProperty("--_transition-duration", duration + "ms");
226
- if (xActive !== null) {
227
- nav.style.setProperty("--_x-active", xActive);
183
+ if (!nav) return;
184
+ const targets = [...navItems.value];
185
+ const more = nav.querySelector<HTMLElement>("[data-more-trigger]");
186
+ if (more) targets.push(more);
187
+
188
+ const indicator = nav.querySelector<HTMLElement>("[data-active-indicator]");
189
+ if (!indicator) {
190
+ targets.forEach((target) => target.removeAttribute("data-under-active"));
191
+ return;
228
192
  }
229
- nav.style.setProperty("--_width-active", String(transitionWidth / navWidth));
230
-
231
- if (activeSettleTimeoutId) clearTimeout(activeSettleTimeoutId);
232
- activeSettleTimeoutId = setTimeout(
233
- () => {
234
- setFinalActivePositions();
235
- },
236
- Math.floor(duration + 20)
237
- );
238
- };
239
193
 
240
- const moveHoveredIndicator = () => {
241
- if (!tabsNavRef.value || !currentHoveredTab.value) return;
194
+ const vertical = toValue(axis) === "y";
195
+ const box = indicator.getBoundingClientRect();
196
+ const [boxStart, boxEnd] = vertical ? [box.top, box.bottom] : [box.left, box.right];
242
197
 
243
- const nav = tabsNavRef.value;
244
- const current = currentHoveredTab.value;
245
- const previous = previousHoveredTab.value;
246
-
247
- // Batch reads first.
248
- const newTabPosition = previous ? previous.compareDocumentPosition(current) : 0;
249
- const currentLeft = current.offsetLeft;
250
- const currentWidth = current.offsetWidth;
251
- const previousLeft = previous?.offsetLeft ?? 0;
252
- const previousWidth = previous?.offsetWidth ?? 0;
253
- const navWidth = nav.offsetWidth;
254
-
255
- let transitionWidth: number;
256
- let xHovered: string | null = null;
257
-
258
- if (newTabPosition === 4) {
259
- transitionWidth = previous ? currentLeft + currentWidth - previousLeft : 0;
260
- } else {
261
- transitionWidth = previous ? previousLeft + previousWidth - currentLeft : 0;
262
- xHovered = currentLeft + "px";
263
- }
198
+ targets.forEach((target) => {
199
+ const rect = target.getBoundingClientRect();
200
+ const [start, end] = vertical ? [rect.top, rect.bottom] : [rect.left, rect.right];
201
+ const overlap = Math.min(boxEnd, end) - Math.max(boxStart, start);
202
+ target.toggleAttribute("data-under-active", end > start && overlap >= (end - start) / 2);
203
+ });
204
+ };
264
205
 
265
- // Then batch writes.
266
- nav.style.setProperty("--_transition-duration", duration + "ms");
267
- if (xHovered !== null) {
268
- nav.style.setProperty("--_x-hovered", xHovered);
269
- }
270
- nav.style.setProperty("--_width-hovered", String(transitionWidth / navWidth));
271
-
272
- if (hoveredSettleTimeoutId) clearTimeout(hoveredSettleTimeoutId);
273
- hoveredSettleTimeoutId = setTimeout(
274
- () => {
275
- setFinalHoveredPositions();
276
- },
277
- Math.floor(duration + 20)
278
- );
206
+ // Re-checks coverage every frame while the indicator's transition runs. A new request only ever
207
+ // extends the window: a snap (e.g. a refresh) mid-move mustn't stop the checks before it lands.
208
+ const trackCoverage = (animationMs: number) => {
209
+ coverageUntil = Math.max(coverageUntil, performance.now() + animationMs + 50);
210
+ updateCoverage();
211
+ if (coverageFrameId !== null) return;
212
+ const step = () => {
213
+ updateCoverage();
214
+ coverageFrameId = performance.now() < coverageUntil ? requestAnimationFrame(step) : null;
215
+ };
216
+ coverageFrameId = requestAnimationFrame(step);
279
217
  };
280
218
 
219
+ // Matched by data-tab-index, since a v-for ref array isn't guaranteed to stay in source order.
281
220
  const setActiveTabContent = () => {
282
- const activeIndex = navItems.value?.findIndex((el) => el === currentActiveTab.value);
283
- tabsContentRefs.value?.forEach((tabContent: HTMLElement, index: number) => {
284
- const isActive = activeIndex === index;
285
- tabContent.style.display = isActive ? "block" : "none";
286
- tabContent.setAttribute("aria-hidden", isActive ? "false" : "true");
221
+ const activeIndex = currentActiveTab.value?.dataset.tabIndex;
222
+ tabsContentRefs.value?.forEach((tabContent: HTMLElement) => {
223
+ tabContent.hidden = tabContent.dataset.tabIndex !== activeIndex;
287
224
  });
288
225
  };
289
226
 
290
- useResizeObserver(tabsNavRef, () => {
291
- setFinalActivePositions(true);
292
- setFinalHoveredPositions(true);
293
- });
227
+ /** Re-places both indicators onto their current targets, e.g. after the overflow trigger changes size. */
228
+ const syncIndicators = (animate = false) => {
229
+ placeIndicator("active", animate);
230
+ placeIndicator("hovered", animate);
231
+ };
232
+
233
+ useResizeObserver(tabsNavRef, () => syncIndicators(false));
294
234
 
295
235
  onUnmounted(() => {
296
- if (activeSettleTimeoutId) clearTimeout(activeSettleTimeoutId);
297
- if (hoveredSettleTimeoutId) clearTimeout(hoveredSettleTimeoutId);
236
+ if (coverageFrameId !== null) cancelAnimationFrame(coverageFrameId);
298
237
  });
299
238
 
300
239
  return {
301
- initNavDecorators,
240
+ refreshTabs,
241
+ syncIndicators,
242
+ activeIndex,
243
+ activateTabByIndex,
302
244
  navItemClicked,
303
245
  navItemHovered,
304
246
  navItemKeydown,
@@ -1,8 +1,10 @@
1
+ export type DisplayChipStatus = "offline" | "online" | "idle" | "dnd"
2
+
1
3
  export interface DisplayChipConfig {
2
- size: string
3
- maskWidth: string
4
- offset: string
5
- angle: string
4
+ size?: string
5
+ maskWidth?: string
6
+ offset?: string
7
+ angle?: string
6
8
  icon?: string
7
9
  label?: string
8
10
  }
@@ -10,6 +12,8 @@ export interface DisplayChipConfig {
10
12
  export interface DisplayChipProps {
11
13
  tag?: "div" | "span"
12
14
  shape?: "circle" | "square"
15
+ status?: DisplayChipStatus
16
+ statusLabel?: string
13
17
  config?: DisplayChipConfig
14
18
  styleClassPassthrough?: string | string[]
15
19
  }
@@ -26,6 +26,8 @@ export interface DisplayToastContentConfig {
26
26
  title?: string
27
27
  description?: string
28
28
  customIcon?: string
29
+ /** Screen-reader text for the dismiss button (non-auto-dismiss toasts). Defaults to "Close". */
30
+ dismissLabel?: string
29
31
  }
30
32
 
31
33
  export interface DisplayToastConfig {
@@ -11,7 +11,6 @@ export * from "./qr-code.d"
11
11
  export * from "./alert-mask-core.d"
12
12
  export * from "./container-glow.d"
13
13
  export * from "./hero-text"
14
- export * from "./nav-item.d"
15
14
  export * from "./social-icons-list.d"
16
15
  export * from "./content-docs.d"
17
16
  export * from "./breadcrumb"
@@ -2,4 +2,6 @@ export interface SelectMenuOption {
2
2
  value: string | number
3
3
  label: string
4
4
  icon?: string
5
+ /** Any CSS colour. Colours the trigger's status dot while this option is selected, and shows a dot beside it in the list. */
6
+ dotColor?: string
5
7
  }
@@ -0,0 +1,41 @@
1
+ export function createPausableTimeout(callback: () => void, ms: number) {
2
+ let remaining = ms;
3
+ let startedAt = 0;
4
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
5
+
6
+ const resume = () => {
7
+ if (timeoutId !== null || remaining <= 0) return;
8
+ startedAt = Date.now();
9
+ timeoutId = setTimeout(() => {
10
+ timeoutId = null;
11
+ remaining = 0;
12
+ callback();
13
+ }, remaining);
14
+ };
15
+
16
+ const pause = () => {
17
+ if (timeoutId === null) return;
18
+ clearTimeout(timeoutId);
19
+ timeoutId = null;
20
+ remaining -= Date.now() - startedAt;
21
+ };
22
+
23
+ const clear = () => {
24
+ if (timeoutId !== null) clearTimeout(timeoutId);
25
+ timeoutId = null;
26
+ remaining = 0;
27
+ };
28
+
29
+ resume();
30
+ return { pause, resume, clear };
31
+ }
32
+
33
+ // Programmatic focus on the toast itself only pauses for keyboard users; focus on anything inside always does.
34
+ export function focusPausesAutoDismiss(root: HTMLElement, target: EventTarget | null): boolean {
35
+ if (target !== root) return true;
36
+ try {
37
+ return root.matches(":focus-visible");
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
package/nuxt.config.ts CHANGED
@@ -150,7 +150,7 @@ export default defineNuxtConfig({
150
150
  // "esbuild". lightningcss has an open bug where it drops the unprefixed `backdrop-filter`
151
151
  // once autoprefixer adds `-webkit-backdrop-filter` alongside it, breaking glass/blur effects
152
152
  // (see https://github.com/vitejs/vite/issues/22649) — this repo relies on backdrop-filter in
153
- // several components (GlassPanel, TabNavigation, DisplayDialog,
153
+ // several components (GlassPanel, DisplayDialog,
154
154
  // MagneticNavigation). Pin back to esbuild until upstream fixes it.
155
155
  build: {
156
156
  cssMinify: "esbuild",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.6.0",
4
+ "version": "9.6.1",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",