srcdev-nuxt-components 9.5.7 → 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 (159) 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/breadcrumb.md +5 -0
  7. package/.claude/skills/components/container-glow.md +21 -8
  8. package/.claude/skills/components/display-avatar.md +13 -7
  9. package/.claude/skills/components/display-chip.md +37 -24
  10. package/.claude/skills/components/display-pill.md +58 -8
  11. package/.claude/skills/components/display-prompt.md +8 -5
  12. package/.claude/skills/components/display-toast.md +21 -4
  13. package/.claude/skills/components/glowing-border.md +20 -2
  14. package/.claude/skills/components/input-button.md +3 -0
  15. package/.claude/skills/components/responsive-header.md +59 -0
  16. package/.claude/skills/components/select-menu.md +54 -5
  17. package/.claude/skills/components/service-detail.md +32 -0
  18. package/.claude/skills/components/service-summary.md +8 -0
  19. package/.claude/skills/components/services-card-grid.md +5 -3
  20. package/.claude/skills/components/services-card.md +22 -12
  21. package/.claude/skills/components/site-header.md +2 -2
  22. package/.claude/skills/components/slider-gallery.md +10 -4
  23. package/.claude/skills/components/tabbed-content.md +300 -0
  24. package/.claude/skills/composable-anchor-scroll.md +16 -15
  25. package/.claude/skills/index.md +4 -5
  26. package/.claude/skills/storybook-add-story.md +53 -0
  27. package/.claude/skills/theming-status-tokens.md +60 -0
  28. package/.vscode/srcdev-component-display-avatar.code-snippets +2 -1
  29. package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
  30. package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
  31. package/.vscode/srcdev-component-responsive-header.code-snippets +16 -0
  32. package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
  33. package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
  34. package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
  35. package/.vscode/srcdev-component-services-card.code-snippets +38 -36
  36. package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
  37. package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
  38. package/app/assets/styles/setup/03.theming/_status.css +24 -0
  39. package/app/assets/styles/setup/03.theming/index.css +1 -0
  40. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
  41. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
  42. package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
  43. package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
  44. package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
  45. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
  46. package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
  47. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
  48. package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
  49. package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
  50. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +78 -9
  51. package/app/components/01.atoms/display-pill/DisplayPill.vue +59 -23
  52. package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
  53. package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
  54. package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
  55. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
  56. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
  57. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
  58. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
  59. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
  60. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
  61. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
  62. package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
  63. package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
  64. package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
  65. package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
  66. package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
  67. package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
  68. package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +7 -5
  69. package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +6 -2
  70. package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +24 -3
  71. package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +19 -0
  72. package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +2 -1
  73. package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +52 -19
  74. package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
  75. package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
  76. package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
  77. package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
  78. package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
  79. package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
  80. package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
  81. package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
  82. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
  83. package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
  84. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
  85. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
  86. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
  87. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
  88. package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
  89. package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
  90. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
  91. package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
  92. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
  93. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
  94. package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
  95. package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
  96. package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
  97. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
  98. package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
  99. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +42 -11
  100. package/app/components/03.organisms/responsive-header/NavigationItems.vue +60 -16
  101. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +86 -24
  102. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +52 -0
  103. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +20 -0
  104. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +40 -0
  105. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
  106. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
  107. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
  108. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
  109. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +5 -4
  110. package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
  111. package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
  112. package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
  113. package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
  114. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +2 -2
  115. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
  116. package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
  117. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
  118. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
  119. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
  120. package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
  121. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
  122. package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
  123. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
  124. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
  125. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +6 -6
  126. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
  127. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
  128. package/app/components/05.forms/input-button/InputButton.vue +3 -1
  129. package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
  130. package/app/composables/useAnchorScroll.ts +1 -1
  131. package/app/composables/useTabs.ts +148 -206
  132. package/app/types/components/display-chip.d.ts +8 -4
  133. package/app/types/components/display-toast.d.ts +2 -0
  134. package/app/types/components/index.ts +0 -1
  135. package/app/types/components/select-menu.d.ts +2 -0
  136. package/app/utils/pausableTimeout.ts +41 -0
  137. package/nuxt.config.ts +1 -1
  138. package/package.json +1 -1
  139. package/.claude/skills/components/site-navigation.md +0 -145
  140. package/.claude/skills/components/tab-navigation.md +0 -272
  141. package/.claude/skills/components/tabs-core.md +0 -187
  142. package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
  143. package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
  144. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
  145. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
  146. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
  147. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
  148. package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
  149. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
  150. package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
  151. package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
  152. package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +0 -30
  153. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -150
  154. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -518
  155. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -340
  156. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -339
  157. package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
  158. package/app/composables/useNavCollapse.ts +0 -86
  159. package/app/types/components/nav-item.d.ts +0 -11
@@ -0,0 +1,750 @@
1
+ <template>
2
+ <div
3
+ class="tabbed-content"
4
+ :class="[`axis-${axis}`, `overflow-${overflowMode}`, { 'tracks-active': trackActive }, elementClasses]"
5
+ >
6
+ <div ref="tabsNavRef" class="tabbed-content-bar" @mouseleave="resetHoverToActivePosition()">
7
+ <div
8
+ ref="tablistRef"
9
+ role="tablist"
10
+ :aria-label="ariaLabel"
11
+ :aria-orientation="axis === 'y' ? 'vertical' : 'horizontal'"
12
+ class="tabbed-content-list"
13
+ >
14
+ <button
15
+ v-for="index in tabIndexes"
16
+ :id="triggerId(index)"
17
+ :key="index"
18
+ :data-tab-index="index"
19
+ data-nav-item
20
+ type="button"
21
+ role="tab"
22
+ aria-selected="false"
23
+ :aria-controls="panelId(index)"
24
+ :hidden="overflowedIndexes.includes(index)"
25
+ class="tabbed-content-trigger"
26
+ @click.prevent="navItemClicked($event)"
27
+ @mouseenter="navItemHovered($event)"
28
+ @keydown="navItemKeydown($event)"
29
+ >
30
+ <slot :name="`tab-${index}-trigger`"></slot>
31
+ </button>
32
+ </div>
33
+
34
+ <div
35
+ v-if="usesOverflowMenu"
36
+ class="tabbed-content-more"
37
+ :class="{ 'is-idle': !overflowedIndexes.length }"
38
+ :style="`--_anchor-name: ${anchorName}`"
39
+ >
40
+ <button
41
+ ref="moreTriggerRef"
42
+ :popovertarget="menuId"
43
+ popovertargetaction="toggle"
44
+ type="button"
45
+ class="tabbed-content-more-trigger"
46
+ :class="{ 'is-active': activeIsOverflowed }"
47
+ data-more-trigger
48
+ aria-haspopup="menu"
49
+ :aria-expanded="isMoreOpen"
50
+ :tabindex="overflowedIndexes.length ? undefined : -1"
51
+ @click="handleMoreTriggerClick"
52
+ @mouseenter="navItemHovered($event)"
53
+ >
54
+ <template v-if="activeIsOverflowed && activeIndex !== null">
55
+ <span class="tabbed-content-more-label"><slot :name="`tab-${activeIndex}-trigger`"></slot></span>
56
+ <span class="tabbed-content-visually-hidden">, {{ moreLabel }}</span>
57
+ <Icon :name="moreActiveIcon" class="tabbed-content-more-chevron" aria-hidden="true" />
58
+ </template>
59
+ <template v-else>
60
+ <span class="tabbed-content-visually-hidden">{{ moreLabel }}</span>
61
+ <Icon :name="moreIcon" class="tabbed-content-more-icon" aria-hidden="true" />
62
+ </template>
63
+ </button>
64
+
65
+ <div
66
+ :id="menuId"
67
+ ref="morePopoverRef"
68
+ popover
69
+ class="tabbed-content-more-popover"
70
+ :class="{ 'tabbed-content-more-popover-open': usesFallbackPopover && isMoreOpen }"
71
+ :style="positionStyle"
72
+ :data-placement="popoverPlacement"
73
+ @beforetoggle="handleBeforeToggle"
74
+ @toggle="handleToggle"
75
+ @keydown="handleMoreKeydown"
76
+ >
77
+ <ul class="tabbed-content-more-list" role="menu" :aria-label="moreLabel">
78
+ <li v-for="index in overflowedIndexes" :key="index" role="none">
79
+ <button
80
+ type="button"
81
+ role="menuitemradio"
82
+ tabindex="-1"
83
+ :aria-checked="index === activeIndex"
84
+ class="tabbed-content-more-item"
85
+ @click="selectOverflowedTab(index)"
86
+ >
87
+ <slot :name="`tab-${index}-trigger`"></slot>
88
+ </button>
89
+ </li>
90
+ </ul>
91
+ </div>
92
+ </div>
93
+
94
+ <span
95
+ v-if="trackIndicator && trackHover"
96
+ class="tabbed-content-indicator-underline-hover"
97
+ aria-hidden="true"
98
+ ></span>
99
+ <span v-if="trackIndicator" class="tabbed-content-indicator-underline" aria-hidden="true"></span>
100
+ <span v-if="trackActive" class="tabbed-content-indicator-active" data-active-indicator aria-hidden="true"></span>
101
+ <span v-if="trackHover" class="tabbed-content-indicator-hover" aria-hidden="true"></span>
102
+ </div>
103
+ <div class="tabbed-content-panels">
104
+ <div
105
+ v-for="index in tabIndexes"
106
+ :id="panelId(index)"
107
+ :key="index"
108
+ ref="tabsContentRefs"
109
+ :data-tab-index="index"
110
+ class="tabbed-content-panel"
111
+ :aria-labelledby="triggerId(index)"
112
+ role="tabpanel"
113
+ tabindex="0"
114
+ :hidden="index !== 0"
115
+ >
116
+ <slot :name="`tab-${index}-content`"></slot>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ </template>
121
+
122
+ <script setup lang="ts">
123
+ import { useResizeObserver } from "@vueuse/core";
124
+
125
+ interface Props {
126
+ axis?: "x" | "y";
127
+ /** Duration (ms) of the moving indicators' transition. */
128
+ transitionDuration?: number;
129
+ /** Number of tabs to render — drives both the trigger and content indexed slots. */
130
+ itemCount: number;
131
+ /** Shows a moving highlight behind the hovered tab. */
132
+ trackHover?: boolean;
133
+ /** Shows a moving highlight behind the active tab. */
134
+ trackActive?: boolean;
135
+ /** Shows a moving underline/sideline indicator beneath the active tab. */
136
+ trackIndicator?: boolean;
137
+ /** What happens on `axis="x"` when the tabs don't fit: `menu` moves the ones that don't fit into a "More" menu, `scroll` lets the tab row scroll sideways. */
138
+ overflowMode?: "menu" | "scroll";
139
+ /** aria-label on the tablist — override for localisation. */
140
+ ariaLabel?: string;
141
+ /** Accessible name of the "More" button and its menu — override for localisation. */
142
+ moreLabel?: string;
143
+ /** Icon on the "More" button. */
144
+ moreIcon?: string;
145
+ /** Icon after the active tab's label on the "More" button, shown while the active tab is one of the collapsed ones. */
146
+ moreActiveIcon?: string;
147
+ styleClassPassthrough?: string | string[];
148
+ }
149
+
150
+ const props = withDefaults(defineProps<Props>(), {
151
+ axis: "x",
152
+ transitionDuration: 200,
153
+ trackHover: true,
154
+ trackActive: true,
155
+ trackIndicator: true,
156
+ overflowMode: "menu",
157
+ ariaLabel: "Tabs",
158
+ moreLabel: "More tabs",
159
+ moreIcon: "lucide:ellipsis",
160
+ moreActiveIcon: "lucide:chevron-down",
161
+ styleClassPassthrough: () => [],
162
+ });
163
+
164
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
165
+
166
+ watch(
167
+ () => props.styleClassPassthrough,
168
+ () => {
169
+ resetElementClasses(props.styleClassPassthrough);
170
+ }
171
+ );
172
+
173
+ const id = useId();
174
+ const triggerId = (index: number) => `${id}-tab-${index}-trigger`;
175
+ const panelId = (index: number) => `${id}-tab-${index}-panel`;
176
+ const menuId = `${id}-more-menu`;
177
+ const anchorName = `--tabbed-content-more-${id}`;
178
+
179
+ const tabCount = computed(() => {
180
+ const count = Math.floor(Number(props.itemCount));
181
+ return Number.isFinite(count) && count > 0 ? count : 0;
182
+ });
183
+ const tabIndexes = computed(() => Array.from({ length: tabCount.value }, (_, index) => index));
184
+ const usesOverflowMenu = computed(() => props.axis === "x" && props.overflowMode === "menu");
185
+
186
+ const tabsNavRef = ref<HTMLElement | null>(null);
187
+ const tablistRef = ref<HTMLElement | null>(null);
188
+ const tabsContentRefs = ref<HTMLElement[] | null>(null);
189
+ const moreTriggerRef = ref<HTMLElement | null>(null);
190
+ const morePopoverRef = ref<HTMLElement | null>(null);
191
+
192
+ const overflowedIndexes = ref<number[]>([]);
193
+
194
+ const {
195
+ refreshTabs,
196
+ syncIndicators,
197
+ activeIndex,
198
+ activateTabByIndex,
199
+ navItemClicked,
200
+ navItemHovered,
201
+ navItemKeydown,
202
+ resetHoverToActivePosition,
203
+ } =
204
+ useTabs(() => props.axis, tabsNavRef, tabsContentRefs, () => props.transitionDuration, () => props.trackHover);
205
+
206
+ const activeIsOverflowed = computed(() => activeIndex.value !== null && overflowedIndexes.value.includes(activeIndex.value));
207
+
208
+ const getMoreItems = (): HTMLElement[] =>
209
+ Array.from(morePopoverRef.value?.querySelectorAll<HTMLElement>('[role="menuitemradio"]') ?? []);
210
+
211
+ const {
212
+ isOpen: isMoreOpen,
213
+ usesFallbackPopover,
214
+ positionStyle,
215
+ popoverPlacement,
216
+ hide: hideMore,
217
+ handleTriggerClick: handleMoreTriggerClick,
218
+ handleBeforeToggle,
219
+ handleToggle,
220
+ } = useAnchoredPopover({
221
+ rootRef: tabsNavRef,
222
+ triggerRef: moreTriggerRef,
223
+ popoverRef: morePopoverRef,
224
+ onOpen: () => {
225
+ const items = getMoreItems();
226
+ (items.find((item) => item.getAttribute("aria-checked") === "true") ?? items[0])?.focus();
227
+ },
228
+ });
229
+
230
+ const selectOverflowedTab = (index: number) => {
231
+ activateTabByIndex(index);
232
+ hideMore();
233
+ moreTriggerRef.value?.focus();
234
+ };
235
+
236
+ // Menu keyboard: arrows move (wrapping), Home/End jump, Tab closes. Escape is the popover's light dismiss.
237
+ const handleMoreKeydown = (event: KeyboardEvent) => {
238
+ const items = getMoreItems();
239
+ if (!items.length) return;
240
+ const currentIndex = items.indexOf(document.activeElement as HTMLElement);
241
+ const focusAt = (index: number) => items[(index + items.length) % items.length]?.focus();
242
+
243
+ switch (event.key) {
244
+ case "ArrowDown":
245
+ event.preventDefault();
246
+ focusAt(currentIndex + 1);
247
+ break;
248
+ case "ArrowUp":
249
+ event.preventDefault();
250
+ focusAt(currentIndex === -1 ? items.length - 1 : currentIndex - 1);
251
+ break;
252
+ case "Home":
253
+ event.preventDefault();
254
+ focusAt(0);
255
+ break;
256
+ case "End":
257
+ event.preventDefault();
258
+ focusAt(items.length - 1);
259
+ break;
260
+ case "Tab":
261
+ hideMore();
262
+ break;
263
+ }
264
+ };
265
+
266
+ // Natural trigger widths, cached while each one is visible so hidden ones can be re-placed.
267
+ const triggerWidths = new Map<number, number>();
268
+
269
+ const measureOverflow = () => {
270
+ if (!usesOverflowMenu.value) {
271
+ overflowedIndexes.value = [];
272
+ return;
273
+ }
274
+ const bar = tabsNavRef.value;
275
+ const tablist = tablistRef.value;
276
+ if (!bar || !tablist) return;
277
+
278
+ tablist.querySelectorAll<HTMLElement>("[data-nav-item]").forEach((trigger) => {
279
+ if (!trigger.hidden) triggerWidths.set(Number(trigger.dataset.tabIndex), trigger.offsetWidth);
280
+ });
281
+
282
+ const available = bar.clientWidth;
283
+ const widths = tabIndexes.value.map((index) => triggerWidths.get(index) ?? 0);
284
+ const total = widths.reduce((sum, width) => sum + width, 0);
285
+
286
+ let next: number[] = [];
287
+ if (total > available) {
288
+ let used = moreTriggerRef.value?.offsetWidth ?? 0;
289
+ const firstHidden = widths.findIndex((width) => (used += width) > available);
290
+ next = firstHidden === -1 ? [] : tabIndexes.value.slice(firstHidden);
291
+ }
292
+
293
+ if (next.join() !== overflowedIndexes.value.join()) overflowedIndexes.value = next;
294
+ };
295
+
296
+ // A changed overflow re-renders the triggers, and the overflowedIndexes watcher refreshes again after that.
297
+ const recalculate = () => {
298
+ measureOverflow();
299
+ refreshTabs();
300
+ };
301
+
302
+ useResizeObserver(tabsNavRef, measureOverflow);
303
+ useResizeObserver(tablistRef, measureOverflow);
304
+ // The More button widens to show a collapsed active tab's label, which can push another tab into the menu,
305
+ // and the indicators sitting on it must follow its new box either way.
306
+ useResizeObserver(moreTriggerRef, () => {
307
+ measureOverflow();
308
+ syncIndicators(true);
309
+ });
310
+
311
+ onMounted(recalculate);
312
+
313
+ // Usually fires straight after a click (the More button changes size), so retarget rather than snap.
314
+ watch(overflowedIndexes, () => nextTick(() => refreshTabs(true)));
315
+
316
+ watch(
317
+ [
318
+ tabCount,
319
+ () => props.axis,
320
+ () => props.overflowMode,
321
+ () => props.trackActive,
322
+ () => props.trackIndicator,
323
+ () => props.trackHover,
324
+ ],
325
+ () => nextTick(recalculate)
326
+ );
327
+ </script>
328
+
329
+ <style lang="css">
330
+ @layer components {
331
+ .tabbed-content {
332
+ --_focus-ring: var(--tabs-focus-ring-width, 0.2rem) solid var(--tabs-focus-ring-colour, var(--theme-border-focus));
333
+
334
+ .tabbed-content-bar {
335
+ position: relative;
336
+ display: flex;
337
+ max-inline-size: 100%;
338
+ z-index: 1;
339
+
340
+ border-bottom: var(
341
+ --tabs-nav-border,
342
+ 0.1rem solid color-mix(in oklch, var(--theme-text) 20%, transparent)
343
+ );
344
+
345
+ .tabbed-content-list {
346
+ display: flex;
347
+ min-inline-size: 0;
348
+ }
349
+
350
+ /* Each indicator's edges are insets from the bar's start/end, set by useTabs. The edge moving in
351
+ the direction of travel goes first; the trailing edge's delay makes it catch up afterwards. */
352
+ .tabbed-content-indicator-hover,
353
+ .tabbed-content-indicator-underline-hover {
354
+ --_start: var(--_hovered-start, 0px);
355
+ --_end: var(--_hovered-end, 100%);
356
+ --_duration: var(--_hovered-duration, 0ms);
357
+ --_start-delay: var(--_hovered-start-delay, 0ms);
358
+ --_end-delay: var(--_hovered-end-delay, 0ms);
359
+ }
360
+
361
+ .tabbed-content-indicator-active,
362
+ .tabbed-content-indicator-underline {
363
+ --_start: var(--_active-start, 0px);
364
+ --_end: var(--_active-end, 100%);
365
+ --_duration: var(--_active-duration, 0ms);
366
+ --_start-delay: var(--_active-start-delay, 0ms);
367
+ --_end-delay: var(--_active-end-delay, 0ms);
368
+ }
369
+
370
+ .tabbed-content-indicator-hover,
371
+ .tabbed-content-indicator-active,
372
+ .tabbed-content-indicator-underline,
373
+ .tabbed-content-indicator-underline-hover {
374
+ position: absolute;
375
+ top: 0;
376
+ bottom: 0;
377
+ left: var(--_start);
378
+ right: var(--_end);
379
+ transition:
380
+ left var(--_duration) ease var(--_start-delay),
381
+ right var(--_duration) ease var(--_end-delay);
382
+ pointer-events: none;
383
+ }
384
+
385
+ .tabbed-content-indicator-hover {
386
+ z-index: 1;
387
+ background: var(--tabs-hover-indicator-colour, var(--theme-surface-subtle));
388
+ }
389
+
390
+ .tabbed-content-indicator-active {
391
+ z-index: 2;
392
+ background: var(--tabs-active-indicator-colour, var(--theme-surface));
393
+ }
394
+
395
+ .tabbed-content-indicator-underline {
396
+ top: auto;
397
+ z-index: 3;
398
+ background: var(--tabs-underline-indicator-colour, var(--theme-accent));
399
+ height: var(--tabs-underline-indicator-height, 0.4rem);
400
+ }
401
+
402
+ .tabbed-content-indicator-underline-hover {
403
+ top: auto;
404
+ z-index: 3;
405
+ background: var(
406
+ --tabs-hover-underline-indicator-colour,
407
+ var(--tabs-underline-indicator-colour, var(--theme-accent))
408
+ );
409
+ height: var(--tabs-hover-underline-indicator-height, 0.1rem);
410
+ }
411
+
412
+ .tabbed-content-trigger {
413
+ flex-shrink: 0;
414
+ max-inline-size: var(--tabs-list-item-max-inline-size, 24rem);
415
+ overflow-wrap: anywhere;
416
+ opacity: var(--tabs-list-item-opacity, 0.7);
417
+ position: relative;
418
+ transition: color var(--tabs-list-item-colour-transition-duration, 100ms);
419
+ z-index: 4;
420
+ background: transparent;
421
+ border: 0;
422
+ color: var(--tabs-list-item-colour, var(--theme-text));
423
+ cursor: pointer;
424
+ font: inherit;
425
+ text-transform: var(--tabs-list-item-text-transform, uppercase);
426
+ font-weight: var(--tabs-list-item-font-weight, 500);
427
+ margin: 0;
428
+ padding: var(--tabs-list-item-padding-block, 1em) var(--tabs-list-item-padding-inline, 2em);
429
+
430
+ /* Text colour tracks the indicator actually behind it — hover and active can differ. */
431
+ &:hover {
432
+ opacity: 1;
433
+ color: var(--tabs-hover-indicator-text-colour, var(--theme-text));
434
+ }
435
+
436
+ &[aria-selected="true"] {
437
+ opacity: 1;
438
+ color: var(
439
+ --tabs-list-item-colour-selected,
440
+ var(--tabs-active-indicator-text-colour, var(--theme-on-surface))
441
+ );
442
+ }
443
+
444
+ &:focus-visible {
445
+ outline: var(--_focus-ring);
446
+ outline-offset: var(--tabs-list-item-focus-outline-offset, -0.2rem);
447
+ }
448
+
449
+ &[hidden] {
450
+ display: none;
451
+ }
452
+ }
453
+
454
+ .tabbed-content-more {
455
+ display: flex;
456
+ flex-shrink: 0;
457
+ margin-inline-start: var(--tabs-more-margin-inline-start, auto);
458
+
459
+ /* Kept measurable but out of sight when nothing has overflowed. */
460
+ &.is-idle {
461
+ position: absolute;
462
+ visibility: hidden;
463
+ pointer-events: none;
464
+ }
465
+
466
+ .tabbed-content-more-trigger {
467
+ display: flex;
468
+ align-items: center;
469
+ gap: 0.4em;
470
+ max-inline-size: var(--tabs-list-item-max-inline-size, 24rem);
471
+ overflow-wrap: anywhere;
472
+ font: inherit;
473
+ text-transform: var(--tabs-list-item-text-transform, uppercase);
474
+ font-weight: var(--tabs-list-item-font-weight, 500);
475
+ text-align: start;
476
+ position: relative;
477
+ z-index: 4;
478
+ margin: 0;
479
+ border: 0;
480
+ background: transparent;
481
+ cursor: pointer;
482
+ color: var(--tabs-list-item-colour, var(--theme-text));
483
+ opacity: var(--tabs-list-item-opacity, 0.7);
484
+ padding: var(--tabs-list-item-padding-block, 1em) var(--tabs-more-trigger-padding-inline, 1.2em);
485
+ anchor-name: var(--_anchor-name);
486
+ transition: color var(--tabs-list-item-colour-transition-duration, 100ms);
487
+
488
+ &:hover,
489
+ &[aria-expanded="true"] {
490
+ opacity: 1;
491
+ color: var(--tabs-hover-indicator-text-colour, var(--theme-text));
492
+ }
493
+
494
+ &.is-active {
495
+ padding-inline: var(--tabs-list-item-padding-inline, 2em);
496
+ opacity: 1;
497
+ color: var(
498
+ --tabs-list-item-colour-selected,
499
+ var(--tabs-active-indicator-text-colour, var(--theme-on-surface))
500
+ );
501
+ }
502
+
503
+ &:focus-visible {
504
+ outline: var(--_focus-ring);
505
+ outline-offset: var(--tabs-list-item-focus-outline-offset, -0.2rem);
506
+ }
507
+
508
+ .tabbed-content-more-icon {
509
+ display: block;
510
+ font-size: var(--tabs-more-icon-size, 1.25em);
511
+ }
512
+
513
+ .tabbed-content-more-label {
514
+ min-inline-size: 0;
515
+ }
516
+
517
+ .tabbed-content-more-chevron {
518
+ display: block;
519
+ flex-shrink: 0;
520
+ font-size: var(--tabs-more-chevron-size, 1em);
521
+ transition: transform var(--tabs-more-popover-transition-duration, 200ms) ease;
522
+ }
523
+
524
+ &[aria-expanded="true"] .tabbed-content-more-chevron {
525
+ transform: scaleY(-1);
526
+ }
527
+ }
528
+
529
+ .tabbed-content-more-popover {
530
+ margin: 0;
531
+ padding: 0;
532
+ inset: auto;
533
+ border: var(
534
+ --tabs-more-popover-border,
535
+ 0.1rem solid color-mix(in oklch, var(--theme-text) 20%, transparent)
536
+ );
537
+ border-radius: var(--tabs-more-popover-border-radius, 0.6rem);
538
+ background-color: var(--tabs-more-popover-surface, var(--page-bg, var(--theme-surface-subtle)));
539
+ color: var(--tabs-more-popover-text-colour, var(--theme-text));
540
+ box-shadow: var(--tabs-more-popover-shadow, 0 0.4rem 1.6rem rgb(0 0 0 / 12%));
541
+ min-inline-size: var(--tabs-more-popover-min-inline-size, 16rem);
542
+ max-inline-size: min(var(--tabs-list-item-max-inline-size, 24rem), calc(100vw - 3.2rem));
543
+ max-block-size: var(--tabs-more-popover-max-block-size, 70vh);
544
+ overflow-y: auto;
545
+
546
+ position-anchor: var(--_anchor-name);
547
+ top: calc(anchor(bottom) + var(--tabs-more-popover-distance, 0.4rem));
548
+ right: anchor(right);
549
+ left: auto;
550
+ position-try-fallbacks: flip-block, flip-inline;
551
+
552
+ opacity: 0;
553
+ display: none;
554
+ transition:
555
+ opacity var(--tabs-more-popover-transition-duration, 200ms),
556
+ display var(--tabs-more-popover-transition-duration, 200ms),
557
+ overlay var(--tabs-more-popover-transition-duration, 200ms);
558
+ transition-behavior: allow-discrete;
559
+
560
+ &:popover-open {
561
+ display: block;
562
+ opacity: 1;
563
+
564
+ @starting-style {
565
+ display: block;
566
+ opacity: 0;
567
+ }
568
+ }
569
+
570
+ /* Kept apart from :popover-open, which would invalidate a shared selector list where unsupported. */
571
+ &.tabbed-content-more-popover-open {
572
+ display: block;
573
+ opacity: 1;
574
+ }
575
+
576
+ @supports not (anchor-name: --a) {
577
+ position: fixed;
578
+ top: calc(var(--_anchor-bottom, 0px) + var(--tabs-more-popover-distance, 0.4rem));
579
+ right: var(--_anchor-right-inverse, 0px);
580
+ z-index: var(--tabs-more-popover-z-index, 999999);
581
+
582
+ &[data-placement="top"] {
583
+ top: auto;
584
+ bottom: calc(var(--_anchor-top-inverse, 0px) + var(--tabs-more-popover-distance, 0.4rem));
585
+ }
586
+ }
587
+
588
+ .tabbed-content-more-list {
589
+ list-style: none;
590
+ margin: 0;
591
+ padding: 0;
592
+
593
+ .tabbed-content-more-item {
594
+ display: block;
595
+ inline-size: 100%;
596
+ margin: 0;
597
+ border: 0;
598
+ background: transparent;
599
+ color: inherit;
600
+ cursor: pointer;
601
+ font: inherit;
602
+ text-align: start;
603
+ text-transform: var(--tabs-list-item-text-transform, uppercase);
604
+ font-weight: var(--tabs-list-item-font-weight, 500);
605
+ overflow-wrap: anywhere;
606
+ padding: var(--tabs-more-item-padding-block, 0.8em) var(--tabs-more-item-padding-inline, 1.2em);
607
+
608
+ &:hover,
609
+ &:focus-visible {
610
+ background-color: var(
611
+ --tabs-more-item-surface-hover,
612
+ color-mix(in oklch, var(--theme-text) 8%, transparent)
613
+ );
614
+ }
615
+
616
+ &[aria-checked="true"] {
617
+ color: var(--tabs-more-item-colour-selected, var(--theme-accent));
618
+ }
619
+
620
+ &:focus-visible {
621
+ outline: var(--_focus-ring);
622
+ outline-offset: calc(-1 * var(--tabs-focus-ring-width, 0.2rem));
623
+ }
624
+ }
625
+ }
626
+ }
627
+ }
628
+ }
629
+
630
+ /* With a moving active highlight, text colour follows what's actually behind it: a tab the highlight
631
+ covers takes the active text colour, the selected tab keeps its resting colour until it arrives. */
632
+ &.tracks-active {
633
+ .tabbed-content-trigger[aria-selected="true"]:not([data-under-active]),
634
+ .tabbed-content-more-trigger.is-active:not([data-under-active]) {
635
+ color: var(--tabs-list-item-colour, var(--theme-text));
636
+ }
637
+
638
+ .tabbed-content-trigger[data-under-active],
639
+ .tabbed-content-more .tabbed-content-more-trigger[data-under-active] {
640
+ opacity: 1;
641
+ color: var(
642
+ --tabs-list-item-colour-selected,
643
+ var(--tabs-active-indicator-text-colour, var(--theme-on-surface))
644
+ );
645
+ }
646
+ }
647
+
648
+ .tabbed-content-visually-hidden {
649
+ position: absolute;
650
+ inline-size: 1px;
651
+ block-size: 1px;
652
+ overflow: hidden;
653
+ clip-path: inset(50%);
654
+ white-space: nowrap;
655
+ }
656
+
657
+ &.overflow-scroll .tabbed-content-bar {
658
+ overflow-x: auto;
659
+ }
660
+
661
+ &.overflow-menu .tabbed-content-bar {
662
+ overflow: hidden;
663
+ }
664
+
665
+ .tabbed-content-panels {
666
+ display: grid;
667
+ grid-template-areas: "element-stack";
668
+ min-inline-size: 0;
669
+
670
+ background-color: var(--tabs-content-background-colour, var(--theme-surface-subtle));
671
+ color: var(--tabs-content-text-colour, var(--theme-text));
672
+ border: var(--tabs-content-border-width, 0.1rem) solid
673
+ var(--tabs-content-border-colour, color-mix(in oklch, var(--theme-text) 20%, transparent));
674
+ border-radius: var(--tabs-content-border-radius, 0);
675
+
676
+ .tabbed-content-panel {
677
+ grid-area: element-stack;
678
+ min-inline-size: 0;
679
+ overflow-wrap: anywhere;
680
+
681
+ &[hidden] {
682
+ display: none;
683
+ }
684
+
685
+ &:focus-visible {
686
+ outline: var(--_focus-ring);
687
+ outline-offset: calc(-1 * var(--tabs-focus-ring-width, 0.2rem));
688
+ }
689
+ }
690
+ }
691
+
692
+ &.axis-y {
693
+ display: flex;
694
+ flex-direction: row;
695
+ gap: var(--tabs-axis-y-gap, 2em);
696
+
697
+ .tabbed-content-bar {
698
+ flex-direction: column;
699
+ flex-shrink: 0;
700
+ max-inline-size: var(--tabs-axis-y-list-max-inline-size, 50%);
701
+ overflow: visible;
702
+
703
+ border-bottom: initial;
704
+ border-left: var(
705
+ --tabs-nav-border,
706
+ 0.1rem solid color-mix(in oklch, var(--theme-text) 20%, transparent)
707
+ );
708
+
709
+ .tabbed-content-list {
710
+ flex-direction: column;
711
+ }
712
+
713
+ .tabbed-content-trigger {
714
+ text-align: start;
715
+ width: 100%;
716
+ max-inline-size: none;
717
+ }
718
+
719
+ .tabbed-content-indicator-hover,
720
+ .tabbed-content-indicator-active,
721
+ .tabbed-content-indicator-underline,
722
+ .tabbed-content-indicator-underline-hover {
723
+ left: 0;
724
+ right: 0;
725
+ top: var(--_start);
726
+ bottom: var(--_end);
727
+ height: auto;
728
+ transition:
729
+ top var(--_duration) ease var(--_start-delay),
730
+ bottom var(--_duration) ease var(--_end-delay);
731
+ }
732
+
733
+ .tabbed-content-indicator-underline {
734
+ right: auto;
735
+ width: var(--tabs-underline-indicator-height, 0.4rem);
736
+ }
737
+
738
+ .tabbed-content-indicator-underline-hover {
739
+ right: auto;
740
+ width: var(--tabs-hover-underline-indicator-height, 0.1rem);
741
+ }
742
+ }
743
+
744
+ .tabbed-content-panels {
745
+ flex-grow: 1;
746
+ }
747
+ }
748
+ }
749
+ }
750
+ </style>