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.
- package/.claude/skills/component-compliance-checklist.md +8 -2
- package/.claude/skills/component-dynamic-slots.md +3 -3
- package/.claude/skills/component-export-types.md +10 -16
- package/.claude/skills/component-naming.md +1 -1
- package/.claude/skills/components/alert-content.md +9 -1
- package/.claude/skills/components/container-glow.md +21 -8
- package/.claude/skills/components/display-avatar.md +13 -7
- package/.claude/skills/components/display-chip.md +37 -24
- package/.claude/skills/components/display-pill.md +58 -10
- package/.claude/skills/components/display-prompt.md +8 -5
- package/.claude/skills/components/display-toast.md +21 -4
- package/.claude/skills/components/glowing-border.md +20 -2
- package/.claude/skills/components/responsive-header.md +11 -1
- package/.claude/skills/components/select-menu.md +54 -5
- package/.claude/skills/components/site-header.md +2 -2
- package/.claude/skills/components/tabbed-content.md +300 -0
- package/.claude/skills/composable-anchor-scroll.md +16 -15
- package/.claude/skills/index.md +4 -4
- package/.claude/skills/storybook-add-story.md +53 -0
- package/.claude/skills/theming-status-tokens.md +60 -0
- package/.vscode/srcdev-component-display-avatar.code-snippets +2 -1
- package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
- package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
- package/.vscode/srcdev-component-responsive-header.code-snippets +6 -0
- package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
- package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
- package/app/assets/styles/setup/03.theming/_status.css +24 -0
- package/app/assets/styles/setup/03.theming/index.css +1 -0
- package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
- package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
- package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
- package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
- package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
- package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
- package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
- package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
- package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
- package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
- package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +75 -9
- package/app/components/01.atoms/display-pill/DisplayPill.vue +57 -24
- package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
- package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
- package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
- package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
- package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
- package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
- package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
- package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
- package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
- package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
- package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
- package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
- package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +7 -5
- package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +6 -2
- package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +24 -3
- package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +19 -0
- package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +2 -1
- package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +52 -19
- package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
- package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
- package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
- package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
- package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
- package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
- package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
- package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
- package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
- package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
- package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
- package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
- package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
- package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
- package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
- package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
- package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
- package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
- package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
- package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
- package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +38 -11
- package/app/components/03.organisms/responsive-header/NavigationItems.vue +27 -12
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +35 -20
- package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +1 -1
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +1 -1
- package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
- package/app/composables/useTabs.ts +148 -206
- package/app/types/components/display-chip.d.ts +8 -4
- package/app/types/components/display-toast.d.ts +2 -0
- package/app/types/components/index.ts +0 -1
- package/app/types/components/select-menu.d.ts +2 -0
- package/app/utils/pausableTimeout.ts +41 -0
- package/nuxt.config.ts +1 -1
- package/package.json +1 -1
- package/.claude/skills/components/tab-navigation.md +0 -288
- package/.claude/skills/components/tabs-core.md +0 -187
- package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
- package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
- package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
- package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
- package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
- package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -191
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -550
- package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -259
- package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -379
- package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
- package/app/composables/useNavCollapse.ts +0 -86
- package/app/types/components/nav-item.d.ts +0 -11
|
@@ -1,288 +0,0 @@
|
|
|
1
|
-
# TabNavigation
|
|
2
|
-
|
|
3
|
-
> **Deprecated (2026-10-06): use [ResponsiveHeader](responsive-header.md) for new work.** It does
|
|
4
|
-
> everything TabNavigation does, including same-page `#anchor` links with `anchorScrollOffset`, and
|
|
5
|
-
> collapses item by item instead of all-or-nothing. Follow "Migrating from TabNavigation" in that doc.
|
|
6
|
-
> TabNavigation stays until existing consumers have moved, then it will be removed. It logs a
|
|
7
|
-
> dev-mode console warning on mount.
|
|
8
|
-
|
|
9
|
-
## Overview
|
|
10
|
-
|
|
11
|
-
A responsive horizontal navigation bar with CSS anchor-positioning indicators, smooth anchor
|
|
12
|
-
scroll, and a burger-menu collapse at narrow widths. Accepts both route links (`NuxtLink`) and
|
|
13
|
-
hash anchors (`<a>`). Items are driven by a `NavItemData` object; the component only reads the
|
|
14
|
-
`main` key.
|
|
15
|
-
|
|
16
|
-
---
|
|
17
|
-
|
|
18
|
-
## Types
|
|
19
|
-
|
|
20
|
-
Import from the layer package root — do **not** use `~/types/...` in consuming apps:
|
|
21
|
-
|
|
22
|
-
```ts
|
|
23
|
-
import type { NavItemData } from "srcdev-nuxt-components"
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
```ts
|
|
27
|
-
interface NavItem {
|
|
28
|
-
text: string;
|
|
29
|
-
href?: string;
|
|
30
|
-
isExternal?: boolean; // adds target="_blank" rel behaviour via NuxtLink
|
|
31
|
-
iconName?: string; // icon set name, rendered via <Icon>
|
|
32
|
-
cssName?: string; // extra CSS class on the <li>
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface NavItemData {
|
|
36
|
-
[key: string]: NavItem[]; // component reads navItemData.main
|
|
37
|
-
}
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
---
|
|
41
|
-
|
|
42
|
-
## Props
|
|
43
|
-
|
|
44
|
-
| Prop (template form) | Type | Default | Notes |
|
|
45
|
-
|---|---|---|---|
|
|
46
|
-
| `nav-item-data` | `NavItemData` | required | Navigation link data — only `main` key is read |
|
|
47
|
-
| `nav-align` | `"left" \| "center" \| "right"` | `"left"` | Positions the tab list within the nav container |
|
|
48
|
-
| `anchor-scroll-offset` | `number \| (() => number)` | — | Pixel offset subtracted from scroll target; pass a getter so it is re-evaluated at click time |
|
|
49
|
-
| `aria-label` | `string` | `"Site navigation"` | aria-label on the root `<nav>` landmark — override for localisation |
|
|
50
|
-
| `open-menu-label` | `string` | `"Open navigation menu"` | Burger button label while closed — override for localisation |
|
|
51
|
-
| `close-menu-label` | `string` | `"Close navigation menu"` | Burger button label while open — override for localisation |
|
|
52
|
-
| `:style-class-passthrough` | `string \| string[]` | `[]` | Extra classes on the root `<nav>` element |
|
|
53
|
-
|
|
54
|
-
---
|
|
55
|
-
|
|
56
|
-
> **Changed 2026-10-06:** the burger labels are props (were hardcoded English). Items with blank `text`
|
|
57
|
-
> are skipped (a link with no text has no accessible name), and nothing renders when none are left.
|
|
58
|
-
> Items are keyed by position, so duplicate or missing `href`s no longer break rendering. The mobile
|
|
59
|
-
> panel's id comes from `useId()` (was a fixed `tab-nav-panel`, which duplicated with two navs on a
|
|
60
|
-
> page). Links show a focus ring (`--tab-nav-focus-ring-*`), and long panel labels wrap. Colour
|
|
61
|
-
> defaults are light-theme now (`--theme-text` links, `--theme-accent` hover and indicator, `--page-bg`
|
|
62
|
-
> panel); they used to assume a dark header. Dark headers or dark mode set the colour tokens.
|
|
63
|
-
|
|
64
|
-
## Basic usage — route links
|
|
65
|
-
|
|
66
|
-
```vue
|
|
67
|
-
<TabNavigation :nav-item-data="navData" nav-align="center" />
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
```ts
|
|
71
|
-
import type { NavItemData } from "srcdev-nuxt-components"
|
|
72
|
-
|
|
73
|
-
const navData: NavItemData = {
|
|
74
|
-
main: [
|
|
75
|
-
{ text: "Home", href: "/" },
|
|
76
|
-
{ text: "About", href: "/about" },
|
|
77
|
-
{ text: "Services", href: "/services" },
|
|
78
|
-
{ text: "Contact", href: "/contact" },
|
|
79
|
-
],
|
|
80
|
-
}
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
Items without a leading `#` render as `NuxtLink`. The active indicator tracks the current route
|
|
84
|
-
via `router-link-exact-active`.
|
|
85
|
-
|
|
86
|
-
---
|
|
87
|
-
|
|
88
|
-
## Anchor scroll usage
|
|
89
|
-
|
|
90
|
-
Hash-prefixed hrefs render as plain `<a>` elements to keep Vue Router out of the scroll path.
|
|
91
|
-
Pass a getter for the offset so the sticky bar height is read at click time (not at mount).
|
|
92
|
-
|
|
93
|
-
```vue
|
|
94
|
-
<div ref="stickyRef" class="sticky-nav">
|
|
95
|
-
<TabNavigation
|
|
96
|
-
:nav-item-data="anchorData"
|
|
97
|
-
:anchor-scroll-offset="() => stickyRef?.offsetHeight ?? 0"
|
|
98
|
-
/>
|
|
99
|
-
</div>
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
```ts
|
|
103
|
-
const stickyRef = ref<HTMLElement | null>(null)
|
|
104
|
-
|
|
105
|
-
const anchorData: NavItemData = {
|
|
106
|
-
main: [
|
|
107
|
-
{ text: "Overview", href: "#overview" },
|
|
108
|
-
{ text: "Features", href: "#features" },
|
|
109
|
-
{ text: "Pricing", href: "#pricing" },
|
|
110
|
-
],
|
|
111
|
-
}
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
Clicks are handled by `useAnchorScroll`. Respects `prefers-reduced-motion` — motion-sensitive
|
|
115
|
-
users get an instant jump rather than smooth scroll. See the `composable-anchor-scroll.md` skill
|
|
116
|
-
for the full composable reference.
|
|
117
|
-
|
|
118
|
-
### Mixed navigation
|
|
119
|
-
|
|
120
|
-
Route links and hash anchors can coexist in the same `navItemData`:
|
|
121
|
-
|
|
122
|
-
```ts
|
|
123
|
-
const navData: NavItemData = {
|
|
124
|
-
main: [
|
|
125
|
-
{ text: "Home", href: "/" }, // NuxtLink
|
|
126
|
-
{ text: "Overview", href: "#intro" }, // <a> + smooth scroll
|
|
127
|
-
{ text: "Blog", href: "/blog" }, // NuxtLink
|
|
128
|
-
],
|
|
129
|
-
}
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
---
|
|
133
|
-
|
|
134
|
-
## Responsive collapse
|
|
135
|
-
|
|
136
|
-
`useNavCollapse` measures the nav list on every resize. When the list overflows its container it
|
|
137
|
-
is hidden and replaced with a burger button. The button opens a slide-down panel listing all items.
|
|
138
|
-
|
|
139
|
-
The nav hides itself (opacity 0) until the first measurement completes to prevent a flash of the
|
|
140
|
-
wrong state.
|
|
141
|
-
|
|
142
|
-
---
|
|
143
|
-
|
|
144
|
-
## Alignment variants
|
|
145
|
-
|
|
146
|
-
```vue
|
|
147
|
-
<TabNavigation :nav-item-data="navData" nav-align="left" /> <!-- default -->
|
|
148
|
-
<TabNavigation :nav-item-data="navData" nav-align="center" />
|
|
149
|
-
<TabNavigation :nav-item-data="navData" nav-align="right" />
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
`center` and `right` use `margin-inline: auto` / `margin-inline-start: auto` on the `ul`.
|
|
153
|
-
|
|
154
|
-
---
|
|
155
|
-
|
|
156
|
-
## CSS token API
|
|
157
|
-
|
|
158
|
-
Set `--tab-nav-*` tokens on any ancestor element — no prop needed. See `CONSUMER-STYLING.md` in
|
|
159
|
-
the component source folder for the full token reference.
|
|
160
|
-
|
|
161
|
-
### Horizontal nav
|
|
162
|
-
|
|
163
|
-
| Token | Default | Controls |
|
|
164
|
-
|---|---|---|
|
|
165
|
-
| `--tab-nav-link-color` | `var(--theme-text)` | Link text colour |
|
|
166
|
-
| `--tab-nav-link-hover-color` | `var(--theme-accent)` | Link hover colour |
|
|
167
|
-
| `--tab-nav-link-active-color` | the link colour | Active link colour |
|
|
168
|
-
| `--tab-nav-link-size` | `1.6rem` | Font size |
|
|
169
|
-
| `--tab-nav-link-weight` | `400` | Font weight |
|
|
170
|
-
| `--tab-nav-link-tracking` | `0.06em` | Letter spacing |
|
|
171
|
-
| `--tab-nav-gap` | `2.2rem` | Gap between items |
|
|
172
|
-
| `--tab-nav-transition` | `250ms ease` | Colour fade on hover/active |
|
|
173
|
-
|
|
174
|
-
### Indicator decorators
|
|
175
|
-
|
|
176
|
-
| Token | Default | Controls |
|
|
177
|
-
|---|---|---|
|
|
178
|
-
| `--tab-nav-decorator-indicator-color` | `var(--theme-accent)` | Active underline bar colour |
|
|
179
|
-
| `--tab-nav-decorator-hovered-bg` | `transparent` | Hover pill background (set non-transparent to enable) |
|
|
180
|
-
|
|
181
|
-
### Mobile panel
|
|
182
|
-
|
|
183
|
-
| Token | Default | Controls |
|
|
184
|
-
|---|---|---|
|
|
185
|
-
| `--tab-nav-panel-bg` | `var(--page-bg, var(--theme-surface-subtle))` | Panel background |
|
|
186
|
-
| `--tab-nav-panel-border-color` | the link colour at 20% | Panel top border |
|
|
187
|
-
| `--tab-nav-panel-item-border` | the panel link colour at 12% | Item dividers |
|
|
188
|
-
| `--tab-nav-panel-link-color` | the link colour | Panel link colour |
|
|
189
|
-
| `--tab-nav-panel-padding-block` | `1.4rem` | Link vertical padding |
|
|
190
|
-
| `--tab-nav-panel-padding-inline` | `1.5rem` | Link horizontal padding |
|
|
191
|
-
| `--tab-nav-panel-slide-duration` | `350ms` | Panel open/close duration |
|
|
192
|
-
| `--tab-nav-panel-slide-easing` | `cubic-bezier(0.4, 0, 0.2, 1)` | Panel easing |
|
|
193
|
-
|
|
194
|
-
### Burger button
|
|
195
|
-
|
|
196
|
-
| Token | Default | Controls |
|
|
197
|
-
|---|---|---|
|
|
198
|
-
| `--tab-nav-burger-color` | the link colour | Bar colour |
|
|
199
|
-
| `--tab-nav-burger-width` | `22px` | Bar width |
|
|
200
|
-
| `--tab-nav-burger-height` | `1.5px` | Bar height |
|
|
201
|
-
| `--tab-nav-burger-gap` | `5px` | Gap between bars |
|
|
202
|
-
| `--tab-nav-burger-transition` | `300ms ease` | Open/close animation |
|
|
203
|
-
|
|
204
|
-
### Backdrop
|
|
205
|
-
|
|
206
|
-
| Token | Default | Controls |
|
|
207
|
-
|---|---|---|
|
|
208
|
-
| `--tab-nav-backdrop-bg` | `oklch(0% 0 0 / 55%)` | Overlay colour |
|
|
209
|
-
| `--tab-nav-backdrop-blur` | `3px` | Overlay blur |
|
|
210
|
-
| `--tab-nav-backdrop-duration` | `350ms` | Overlay fade duration |
|
|
211
|
-
|
|
212
|
-
### Page-scoped theme example
|
|
213
|
-
|
|
214
|
-
```css
|
|
215
|
-
/* Unscoped <style> in the consuming page */
|
|
216
|
-
.services-page {
|
|
217
|
-
.tab-navigation {
|
|
218
|
-
--tab-nav-decorator-indicator-color: var(--brand-accent);
|
|
219
|
-
--tab-nav-decorator-hovered-bg: oklch(from var(--brand-accent) l c h / 0.1);
|
|
220
|
-
--tab-nav-link-active-color: var(--brand-accent);
|
|
221
|
-
--tab-nav-panel-bg: var(--page-bg);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
---
|
|
227
|
-
|
|
228
|
-
## Notes
|
|
229
|
-
|
|
230
|
-
- Auto-imported in Nuxt — no manual import needed.
|
|
231
|
-
- The indicator and hover pill use CSS Anchor Positioning and animate via `left`/`right` transitions
|
|
232
|
-
— set `--tab-nav-decorator-hovered-bg` to a semi-transparent colour to enable the pill; it is
|
|
233
|
-
invisible by default.
|
|
234
|
-
- `--tab-nav-transition` controls only the link colour fade; the indicator slide animation is
|
|
235
|
-
internally managed and does not inherit this token.
|
|
236
|
-
- The panel teleports its backdrop to `<body>` so it escapes any parent `overflow: hidden` or
|
|
237
|
-
stacking-context constraint.
|
|
238
|
-
- `--tab-nav-panel-bg` should match `--page-bg` for a seamless panel appearance in the collapsed
|
|
239
|
-
state.
|
|
240
|
-
|
|
241
|
-
## Known issue: SSR/hydration layout shift when the nav needs to collapse (2026-07-07)
|
|
242
|
-
|
|
243
|
-
**Symptom:** on first page load (hard reload/direct navigation), the header briefly renders taller
|
|
244
|
-
than its settled state, then shrinks — visibly shifting all content below it up. Most noticeable on
|
|
245
|
-
short pages (little content below the fold makes the jump occupy a large fraction of the viewport),
|
|
246
|
-
but the same underlying reflow happens on every page where the nav actually needs to collapse at
|
|
247
|
-
the viewport's width; it's just less visible on long pages or when the timing happens to resolve
|
|
248
|
-
before first paint.
|
|
249
|
-
|
|
250
|
-
**Root cause:** `useNavCollapse`'s `isLoaded` ref (and therefore `isCollapsed`) can only be
|
|
251
|
-
determined client-side — the server has no way to measure real DOM/viewport widths. So:
|
|
252
|
-
|
|
253
|
-
```vue
|
|
254
|
-
<ul v-if="!isCollapsed || !isLoaded" ...>
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
SSR and the very first client paint always render the **full, uncollapsed** `<ul>` (since
|
|
258
|
-
`!isLoaded` is true pre-mount), regardless of whether it will actually fit. Once mounted,
|
|
259
|
-
`checkOverflow()` runs (in `onMounted`, after `nextTick()`), and if the nav overflows, sets
|
|
260
|
-
`isCollapsed = true` — swapping the full list out for the shorter collapsed/burger variant. That
|
|
261
|
-
swap is what shrinks the header and shifts everything below it.
|
|
262
|
-
|
|
263
|
-
Confirmed via `PerformanceObserver({type: "layout-shift"})` plus DOM `MutationObserver` tracing in a
|
|
264
|
-
consuming app: the class mutation on `.tab-navigation` (adding
|
|
265
|
-
`is-loaded is-animated`) lines up exactly with `.main-content`'s bounding-rect top moving by the
|
|
266
|
-
header's full height delta, at a variable point (roughly 100ms–800ms after first paint depending on
|
|
267
|
-
hydration timing) after first paint.
|
|
268
|
-
|
|
269
|
-
**Candidate fixes (not yet implemented — pick up later):**
|
|
270
|
-
|
|
271
|
-
1. **CSS-only collapse instead of JS-measured overflow.** Use a `@container` or `@media` breakpoint
|
|
272
|
-
to switch between the full nav and the burger button, so the correct variant renders on the very
|
|
273
|
-
first paint (both server and client agree, no measurement round-trip needed). Loses the
|
|
274
|
-
"collapse exactly when it overflows, regardless of item count" precision of the current
|
|
275
|
-
JS-measured approach, gains zero-reflow correctness. Probably the most robust fix if a
|
|
276
|
-
reasonably conservative breakpoint can be chosen.
|
|
277
|
-
2. **Reserve height instead of eliminating the swap.** Give the header a `min-height` matching the
|
|
278
|
-
taller (uncollapsed) state so the swap doesn't change the header's box size — trades a visible
|
|
279
|
-
"jump" for a slightly awkward gap during the pre-collapse window, but never moves content below
|
|
280
|
-
it. Cheap, but doesn't fix the actual root cause, just its visible symptom.
|
|
281
|
-
3. **Suppress the flash rather than the shift**, e.g. hide the header's nav content entirely
|
|
282
|
-
(`visibility: hidden` or `opacity: 0`) until `isLoaded` is true, then fade in — avoids showing
|
|
283
|
-
the "wrong" state at all, but delays when the nav becomes visible/interactive, and would need
|
|
284
|
-
care to avoid its own CLS/accessibility issues (e.g. focus order, screen readers encountering
|
|
285
|
-
hidden nav).
|
|
286
|
-
4. Investigate whether a plausible default guess for `isCollapsed` could be derived from something
|
|
287
|
-
SSR does have access to (e.g. a `Sec-CH-Viewport-Width` client hint header, if the deployment
|
|
288
|
-
target reliably sends one) — likely not worth the complexity/fragility versus option 1.
|
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: TabsCore
|
|
3
|
-
description: TabsCore tab list/panel widget — indexed dynamic slots, arrow-key navigation, moving hover/active/underline indicators, CSS tokens
|
|
4
|
-
type: reference
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
# TabsCore
|
|
8
|
-
|
|
9
|
-
## Overview
|
|
10
|
-
|
|
11
|
-
`TabsCore` renders a WAI-ARIA tablist from indexed slots. The number of tabs is set via
|
|
12
|
-
`itemCount`; each tab's trigger and content come from `tab-{n}-trigger`/`tab-{n}-content` slots.
|
|
13
|
-
Indexed slots (rather than named dynamic slots) are used here deliberately — `itemCount` is needed
|
|
14
|
-
for ARIA linking across the two parallel loops (triggers and panels), not just the slot loop
|
|
15
|
-
itself.
|
|
16
|
-
|
|
17
|
-
Three independent moving-highlight decorators track pointer/keyboard state: a hover highlight, an
|
|
18
|
-
active-tab highlight, and an underline/sideline indicator — each can be turned off individually.
|
|
19
|
-
|
|
20
|
-
## Props
|
|
21
|
-
|
|
22
|
-
| Prop | Type | Default | Required |
|
|
23
|
-
|------|------|---------|----------|
|
|
24
|
-
| `itemCount` | `number` | — | **yes** — number of tabs; drives both slot loops |
|
|
25
|
-
| `axis` | `"x" \| "y"` | `"x"` | no — horizontal row or vertical column layout |
|
|
26
|
-
| `transitionDuration` | `number` | `200` | no — ms duration of the moving indicators |
|
|
27
|
-
| `trackHover` | `boolean` | `true` | no — shows the moving hover highlight |
|
|
28
|
-
| `trackActive` | `boolean` | `true` | no — shows the moving active-tab highlight |
|
|
29
|
-
| `trackIndicator` | `boolean` | `true` | no — shows the moving underline/sideline indicator |
|
|
30
|
-
| `ariaLabel` | `string` | `"Tabs"` | no — override for localisation |
|
|
31
|
-
| `styleClassPassthrough` | `string \| string[]` | `[]` | no |
|
|
32
|
-
|
|
33
|
-
## Slots
|
|
34
|
-
|
|
35
|
-
For each index `0..itemCount-1`:
|
|
36
|
-
|
|
37
|
-
- `tab-{n}-trigger` — content of the tab button.
|
|
38
|
-
- `tab-{n}-content` — content of that tab's panel.
|
|
39
|
-
|
|
40
|
-
```vue
|
|
41
|
-
<TabsCore :item-count="2">
|
|
42
|
-
<template #tab-0-trigger>Overview</template>
|
|
43
|
-
<template #tab-0-content>Overview content…</template>
|
|
44
|
-
<template #tab-1-trigger>Specs</template>
|
|
45
|
-
<template #tab-1-content>Specs content…</template>
|
|
46
|
-
</TabsCore>
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
## Accessibility behaviour
|
|
50
|
-
|
|
51
|
-
- Renders `role="tablist"` (with `aria-label`), `role="tab"` triggers (`type="button"`), and
|
|
52
|
-
`role="tabpanel"` panels (`aria-labelledby` pointing at their trigger, `tabindex="0"` so keyboard
|
|
53
|
-
users can scroll into panel content per the WAI-ARIA Tabs Pattern).
|
|
54
|
-
Fixed 2026-09-15 — panels previously used `role="region"` and were hardcoded
|
|
55
|
-
`aria-hidden="true"` permanently, even the visible one, hiding all tab content from assistive
|
|
56
|
-
tech at all times.
|
|
57
|
-
- Roving tabindex: only the active trigger has `tabindex="0"`; the rest are `-1`.
|
|
58
|
-
- Arrow keys move focus and activate the target tab (automatic activation): `ArrowLeft`/`ArrowRight`
|
|
59
|
-
on `axis="x"`, `ArrowUp`/`ArrowDown` on `axis="y"`, wrapping at the ends; `Home`/`End` jump to the
|
|
60
|
-
first/last tab. Fixed 2026-09-15 — previously there was no keyboard navigation at all beyond
|
|
61
|
-
native Tab-key focus and click/Enter/Space activation on individual buttons.
|
|
62
|
-
- Focus is visible via `:focus-visible` on both triggers and panels.
|
|
63
|
-
|
|
64
|
-
## CSS custom properties
|
|
65
|
-
|
|
66
|
-
See `CONSUMER-STYLING.md` in this component's folder for the full `--tabs-*` token API (indicator
|
|
67
|
-
colours, list-item spacing/typography, content-panel border/background, axis-y gap). Whether each
|
|
68
|
-
indicator renders at all is controlled by `trackHover`/`trackActive`/`trackIndicator`, not tokens.
|
|
69
|
-
|
|
70
|
-
## Migration note (2026-09-15)
|
|
71
|
-
|
|
72
|
-
Previously lived at `tabs/TabsCore.vue` with options-style `defineProps`, no tier folder, and
|
|
73
|
-
several defects fixed in this pass:
|
|
74
|
-
|
|
75
|
-
- `querySelectorAll("[data-nav-item")` was missing its closing `]` — an invalid selector that
|
|
76
|
-
some browsers tolerate but that throws in strict DOM implementations (including this repo's test
|
|
77
|
-
environment). Fixed to `"[data-nav-item]"`.
|
|
78
|
-
- `aria-labelledby="channel-name"` was hardcoded on the tablist, pointing at an id that doesn't
|
|
79
|
-
exist anywhere in this library. Replaced with the new `ariaLabel` prop.
|
|
80
|
-
- `border-bottom`/`border-left` on the tab list referenced `--_tabs-border-bottom`, a private CSS
|
|
81
|
-
variable that was never set anywhere (dead reference — the border never rendered). Promoted to
|
|
82
|
-
the public `--tabs-nav-border` token with a real default.
|
|
83
|
-
- `tag`, `trackHover`, `trackActive`, and `trackIndicator` were all declared props with no effect
|
|
84
|
-
anywhere in the component or its composable (`useTabs`). `tag` had no coherent purpose (triggers
|
|
85
|
-
were always hardcoded `<button>`s) and was removed; the three `track*` flags were wired up to
|
|
86
|
-
actually gate their respective decorator elements, since removing them would have been a bigger
|
|
87
|
-
behavioural change than making them work as their names already implied.
|
|
88
|
-
- Almost every colour/spacing/typography value in the `<style>` block was hardcoded with no
|
|
89
|
-
override hook at all (not even a private token) — promoted to public `--tabs-*` tokens.
|
|
90
|
-
|
|
91
|
-
## Performance notes (2026-09-15)
|
|
92
|
-
|
|
93
|
-
`useTabs` had a few real perf issues, fixed alongside the migration:
|
|
94
|
-
|
|
95
|
-
- **Layout thrashing**: `moveActiveIndicator`/`moveHoveredIndicator`/`setFinalActivePositions`/
|
|
96
|
-
`setFinalHoveredPositions` used to interleave `offsetLeft`/`offsetWidth`/etc. reads with
|
|
97
|
-
`style.setProperty` writes, forcing a synchronous reflow between each pair. All layout reads are
|
|
98
|
-
now batched into local variables before any writes happen.
|
|
99
|
-
- **`@mouseover` → `@mouseenter`**: the hover trigger listener used `mouseover`, which bubbles and
|
|
100
|
-
re-fires as the pointer crosses any child element inside the button. `mouseenter` fires exactly
|
|
101
|
-
once per tab entered.
|
|
102
|
-
- **Debounced settle timers**: `moveActiveIndicator`/`moveHoveredIndicator` schedule a `setTimeout`
|
|
103
|
-
to snap the indicator to its exact final position once the CSS transition ends. Previously each
|
|
104
|
-
call scheduled a new timeout without clearing the last one, so moving the pointer quickly across
|
|
105
|
-
several tabs stacked up redundant pending timeouts. Now the previous timeout is cleared before
|
|
106
|
-
scheduling the next, and both are cleared in `onUnmounted`.
|
|
107
|
-
- `handleTransitioningClass` called `.indexOf()` on the same two tabs repeatedly inside its loop
|
|
108
|
-
condition and body — now computed once per call.
|
|
109
|
-
- `previousActiveTab` used `useState("previousActiveTab", ...)` with a fixed, unscoped key — every
|
|
110
|
-
`TabsCore` instance on a page would have shared that one piece of state. Not actually a
|
|
111
|
-
performance issue, but a correctness bug found while auditing this file; fixed by switching to a
|
|
112
|
-
plain per-instance `ref`, since this state doesn't need SSR/hydration sharing.
|
|
113
|
-
|
|
114
|
-
## Fixed: hover/active label text could become unreadable against the indicator (2026-09-15)
|
|
115
|
-
|
|
116
|
-
`--tabs-hover-indicator-text-colour` and `--tabs-active-indicator-text-colour` were set as `color`
|
|
117
|
-
on `.nav__hovered`/`.nav__active` — decorative, empty, absolutely-positioned divs with no text
|
|
118
|
-
content, so that `color` was dead CSS with no visible effect. The actual tab label
|
|
119
|
-
(`.tabs-list-item`) used one shared `--tabs-list-item-colour-selected` token for hover, active,
|
|
120
|
-
*and* transitioning alike, regardless of which of the two (potentially differently-coloured)
|
|
121
|
-
indicator backgrounds was actually behind it at that moment. A consumer overriding only the hover
|
|
122
|
-
indicator's background (or the active one) had no working lever to keep the label readable against
|
|
123
|
-
it, and could end up with text the same colour as the background sliding behind it — this was the
|
|
124
|
-
original defect noticed during this component's initial authoring, reported as "the button text
|
|
125
|
-
became the same colour as the track item background and became invisible."
|
|
126
|
-
|
|
127
|
-
Fixed by removing the dead `color` declarations from the two decorator divs and splitting
|
|
128
|
-
`.tabs-list-item`'s combined `:hover, [aria-selected="true"], .transitioning` rule so hover uses
|
|
129
|
-
`--tabs-hover-indicator-text-colour` and active uses `--tabs-list-item-colour-selected` falling
|
|
130
|
-
back to `--tabs-active-indicator-text-colour` — each state's text colour now tracks the background
|
|
131
|
-
actually behind it. See `CONSUMER-STYLING.md`'s "Tab-label text colour tracks the indicator behind
|
|
132
|
-
it" section: overriding an indicator background without its matching text-colour token can still
|
|
133
|
-
produce poor contrast (same tradeoff as any two independently-set colour tokens) — the fix makes
|
|
134
|
-
the tokens functional, it doesn't auto-compute contrast.
|
|
135
|
-
|
|
136
|
-
## Fixed: intermediate tab labels could go invisible during a multi-tab jump (2026-09-15)
|
|
137
|
-
|
|
138
|
-
The first fix above wasn't the whole story. The exact repro that surfaced the rest of it: with tab
|
|
139
|
-
1 active, hover then click tab 5 — the tabs in between briefly lost all visible text. Going the
|
|
140
|
-
other direction (tab 5 active, hover then click tab 1) looked fine.
|
|
141
|
-
|
|
142
|
-
Cause: `useTabs` had a `handleTransitioningClass` helper that added a `.transitioning` class to
|
|
143
|
-
every tab spanned by a jump (previously shared by the `[aria-selected="true"]` colour rule fixed
|
|
144
|
-
above), on the assumption the sliding indicator visually covers every spanned tab for the whole
|
|
145
|
-
transition — so it force-applied the active text colour to all of them, for the full
|
|
146
|
-
`transitionDuration`. It doesn't cover them the whole time: the indicator's "grow from anchor" CSS
|
|
147
|
-
transition (see `moveActiveIndicator`/`moveHoveredIndicator`) keeps the box's leading edge pinned
|
|
148
|
-
at the *old* tab's position on a forward move and only grows its width, so it only gradually sweeps
|
|
149
|
-
across later tabs — a tab several positions away isn't actually covered until *late* in the
|
|
150
|
-
transition. Recolouring it to the active text colour from the very start meant its label matched
|
|
151
|
-
neither its own background nor the not-yet-arrived indicator, going invisible. A backward move
|
|
152
|
-
animates position and width toward their final values together, so the box's rendered coverage is
|
|
153
|
-
more consistent throughout — which is why that direction looked fine and made the bug easy to miss
|
|
154
|
-
without a specific repro.
|
|
155
|
-
|
|
156
|
-
Fixed by removing `handleTransitioningClass` and the `.transitioning` class entirely — it had no
|
|
157
|
-
other purpose. Tabs spanned by a jump now keep their normal (inactive) styling throughout; only the
|
|
158
|
-
newly-active tab (`[aria-selected="true"]`) and the currently-hovered tab (`:hover`) get their
|
|
159
|
-
special colours. See `CONSUMER-STYLING.md`'s "Tabs spanned by a jump keep their normal styling".
|
|
160
|
-
|
|
161
|
-
## Fixed: passed-over tab text still invisible under the active indicator specifically (2026-09-15)
|
|
162
|
-
|
|
163
|
-
Removing `.transitioning` (previous fix) uncovered a second, pre-existing defect that
|
|
164
|
-
`.transitioning`'s forced recolour had been accidentally masking the whole time: a non-active tab's
|
|
165
|
-
normal text colour (`--tabs-list-item-colour`) was **exactly the same value** as the active
|
|
166
|
-
indicator's background (`--tabs-active-indicator-colour`, same default) — this collision existed in
|
|
167
|
-
the very first authored version of this component, not something introduced by any migration
|
|
168
|
-
change. So whenever the active pill visually passes under (or briefly sits under) any tab that
|
|
169
|
-
isn't itself marked active — which happens on every transition, single-step or distant, in either
|
|
170
|
-
direction — that tab's text became literally the same colour as the background sliding beneath it.
|
|
171
|
-
`--tabs-hover-indicator-colour`'s default never had this problem since it doesn't coincide with the
|
|
172
|
-
inactive text default.
|
|
173
|
-
|
|
174
|
-
Fixed by changing `--tabs-active-indicator-colour`'s default to one step in from the extreme,
|
|
175
|
-
still clearly the boldest of the three indicator colours, but no longer numerically identical to
|
|
176
|
-
`--tabs-list-item-colour`'s default. `--tabs-active-indicator-text-colour`/
|
|
177
|
-
`--tabs-list-item-colour-selected` (used by the truly active tab) still contrast fine against the
|
|
178
|
-
new value. See `CONSUMER-STYLING.md` for the note on keeping these two tokens from colliding again
|
|
179
|
-
if you override either one.
|
|
180
|
-
|
|
181
|
-
## Colour token defaults are flat values, not light-dark() (2026-09-15)
|
|
182
|
-
|
|
183
|
-
Every colour token's default was originally a `light-dark(lightValue, darkValue)` pair (as most of
|
|
184
|
-
this library's other components use). Deliberately changed to a single flat `--slate-*` value per
|
|
185
|
-
token for this component — it doesn't bake in automatic light/dark scheme-switching for its own
|
|
186
|
-
defaults; a consumer who wants a token to adapt to `color-scheme` wraps their own override value in
|
|
187
|
-
`light-dark()`. See `CONSUMER-STYLING.md`'s "Colour tokens are flat values, not light-dark() pairs".
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"SRCDEV TabNavigation Basic": {
|
|
3
|
-
"description": "TabNavigation with route/hash links",
|
|
4
|
-
"scope": "vue,html",
|
|
5
|
-
"body": [
|
|
6
|
-
"<TabNavigation :nav-item-data=\"$1navData\" nav-align=\"${2:center}\" />"
|
|
7
|
-
]
|
|
8
|
-
},
|
|
9
|
-
"SRCDEV TabNavigation Localised": {
|
|
10
|
-
"description": "TabNavigation with a localised aria-label",
|
|
11
|
-
"scope": "vue,html",
|
|
12
|
-
"body": [
|
|
13
|
-
"<TabNavigation :nav-item-data=\"$1navData\" aria-label=\"${2:Site navigation}\" open-menu-label=\"${3:Open navigation menu}\" close-menu-label=\"${4:Close navigation menu}\" />"
|
|
14
|
-
]
|
|
15
|
-
},
|
|
16
|
-
"SRCDEV TabNavigation Nav Data Script": {
|
|
17
|
-
"description": "navItemData for TabNavigation",
|
|
18
|
-
"scope": "typescript,vue",
|
|
19
|
-
"body": [
|
|
20
|
-
"import type { NavItemData } from \"srcdev-nuxt-components\";",
|
|
21
|
-
"",
|
|
22
|
-
"const $1navData: NavItemData = {",
|
|
23
|
-
" main: [",
|
|
24
|
-
" { text: \"$2Home\", href: \"$3/\" },",
|
|
25
|
-
" { text: \"$4Overview\", href: \"$5#overview\" },",
|
|
26
|
-
" ],",
|
|
27
|
-
"};"
|
|
28
|
-
]
|
|
29
|
-
}
|
|
30
|
-
}
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
# TabsCore — Consumer Styling Guide
|
|
2
|
-
|
|
3
|
-
## Public token API
|
|
4
|
-
|
|
5
|
-
| Token | Default | Controls |
|
|
6
|
-
|---|---|---|
|
|
7
|
-
| `--tabs-nav-border` | `0.1rem solid var(--slate-06)` | Border between the tab list and its content (bottom border on `axis="x"`, left border on `axis="y"`) |
|
|
8
|
-
| `--tabs-hover-indicator-colour` | `var(--slate-07)` | Background of the moving hover highlight (`trackHover`) |
|
|
9
|
-
| `--tabs-hover-indicator-text-colour` | `var(--slate-00)` | Text colour of a hovered tab trigger's label — keep this in contrast with `--tabs-hover-indicator-colour` |
|
|
10
|
-
| `--tabs-active-indicator-colour` | `var(--slate-09)` | Background of the moving active-tab highlight (`trackActive`) |
|
|
11
|
-
| `--tabs-active-indicator-text-colour` | `var(--slate-00)` | Fallback text colour of the active tab trigger's label — keep this in contrast with `--tabs-active-indicator-colour` |
|
|
12
|
-
| `--tabs-underline-indicator-colour` | `var(--slate-10)` | Colour of the underline/sideline indicator (`trackIndicator`) |
|
|
13
|
-
| `--tabs-underline-indicator-height` | `0.4rem` | Thickness of the underline indicator (`axis="x"`) — also used as its width on `axis="y"` |
|
|
14
|
-
| `--tabs-list-item-opacity` | `0.7` | Opacity of an inactive tab trigger |
|
|
15
|
-
| `--tabs-list-item-colour` | `var(--slate-10)` | Text colour of an inactive tab trigger |
|
|
16
|
-
| `--tabs-list-item-colour-selected` | falls back to `--tabs-active-indicator-text-colour` | Text colour of the active tab trigger — set this directly to override without touching `--tabs-active-indicator-text-colour` |
|
|
17
|
-
| `--tabs-list-item-text-transform` | `uppercase` | `text-transform` of tab trigger labels |
|
|
18
|
-
| `--tabs-list-item-font-weight` | `500` | Font weight of tab trigger labels |
|
|
19
|
-
| `--tabs-list-item-padding-block` | `1em` | Vertical padding of a tab trigger |
|
|
20
|
-
| `--tabs-list-item-padding-inline` | `2em` | Horizontal padding of a tab trigger |
|
|
21
|
-
| `--tabs-list-item-colour-transition-duration` | `100ms` | Transition duration of the trigger text-colour change |
|
|
22
|
-
| `--tabs-list-item-focus-outline-width` | `2px` | Focus outline width on a tab trigger/panel (colour is `--theme-ring`) |
|
|
23
|
-
| `--tabs-list-item-focus-outline-offset` | `-2px` | Focus outline offset on a tab trigger |
|
|
24
|
-
| `--tabs-content-background-colour` | `var(--slate-09)` | Background of the content panel wrapper |
|
|
25
|
-
| `--tabs-content-border-width` | `0.1rem` | Border/outline width of the content panel wrapper |
|
|
26
|
-
| `--tabs-content-border-colour` | `var(--slate-06)` | Border/outline colour of the content panel wrapper |
|
|
27
|
-
| `--tabs-content-border-radius` | `0` | Corner radius of the content panel wrapper |
|
|
28
|
-
| `--tabs-axis-y-gap` | `2em` | Gap between the tab list and content when `axis="y"` |
|
|
29
|
-
|
|
30
|
-
```css
|
|
31
|
-
.my-page {
|
|
32
|
-
--tabs-active-indicator-colour: #1a1a1a;
|
|
33
|
-
--tabs-underline-indicator-colour: #b5651d;
|
|
34
|
-
--tabs-content-border-radius: 0.8rem;
|
|
35
|
-
}
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
---
|
|
39
|
-
|
|
40
|
-
## Colour tokens are flat values, not light-dark() pairs
|
|
41
|
-
|
|
42
|
-
Every colour token above defaults to a single flat `--slate-*` value rather than a
|
|
43
|
-
`light-dark(lightValue, darkValue)` pair — this component doesn't bake in automatic light/dark
|
|
44
|
-
scheme-switching for its own defaults. If you want a token to adapt to `color-scheme`, wrap your
|
|
45
|
-
own override in `light-dark()`:
|
|
46
|
-
|
|
47
|
-
```css
|
|
48
|
-
.my-page {
|
|
49
|
-
--tabs-active-indicator-colour: light-dark(#1a1a1a, #f5f5f5);
|
|
50
|
-
}
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
---
|
|
54
|
-
|
|
55
|
-
## Tab-label text colour tracks the indicator behind it
|
|
56
|
-
|
|
57
|
-
Hover and active can have different background colours (`--tabs-hover-indicator-colour` vs.
|
|
58
|
-
`--tabs-active-indicator-colour`), so the trigger label uses two separate text-colour tokens
|
|
59
|
-
rather than one shared "selected" colour — otherwise overriding just one indicator's background
|
|
60
|
-
risks making the label unreadable against it while leaving the other state fine. If you override
|
|
61
|
-
either indicator background, override its matching text-colour token to keep contrast:
|
|
62
|
-
|
|
63
|
-
```css
|
|
64
|
-
.my-page {
|
|
65
|
-
--tabs-hover-indicator-colour: gold;
|
|
66
|
-
--tabs-hover-indicator-text-colour: black;
|
|
67
|
-
--tabs-active-indicator-colour: navy;
|
|
68
|
-
--tabs-active-indicator-text-colour: white;
|
|
69
|
-
}
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
---
|
|
73
|
-
|
|
74
|
-
## Tabs spanned by a jump keep their normal styling
|
|
75
|
-
|
|
76
|
-
When the active tab (or hover) jumps across several tabs at once (e.g. clicking tab 5 while tab 1
|
|
77
|
-
is active), the sliding indicator animates across the tabs in between, but their own labels are
|
|
78
|
-
**not** recoloured to the active/hover text colour during that transition — they keep their normal
|
|
79
|
-
inactive styling throughout. An earlier version force-applied the active text colour to every
|
|
80
|
-
spanned tab for the whole transition, on the assumption the sliding indicator visually covered them
|
|
81
|
-
the entire time — it doesn't (a forward jump grows the indicator gradually from the old tab's
|
|
82
|
-
position, so it only reaches later tabs partway through), so intermediate labels could end up a
|
|
83
|
-
colour that matched neither their own background nor the indicator yet, making them briefly
|
|
84
|
-
invisible. Fixed 2026-09-15.
|
|
85
|
-
|
|
86
|
-
Since a non-active tab keeps its normal inactive text colour throughout, and the active indicator
|
|
87
|
-
still visually passes under it while sliding, `--tabs-active-indicator-colour`'s default
|
|
88
|
-
(`var(--slate-09)`) is deliberately *not* the same value as `--tabs-list-item-colour`'s default
|
|
89
|
-
(`var(--slate-10)`) — they used to be identical, which meant a passed-over tab's text became
|
|
90
|
-
literally the same colour as the indicator sliding beneath it (invisible). If you override
|
|
91
|
-
`--tabs-active-indicator-colour`, check it still differs enough from `--tabs-list-item-colour` (or
|
|
92
|
-
override that too) to avoid the same collision recurring.
|
|
93
|
-
|
|
94
|
-
---
|
|
95
|
-
|
|
96
|
-
## Indicator visibility is prop-driven, not tokens
|
|
97
|
-
|
|
98
|
-
Whether the hover highlight, active highlight, and underline indicator render at all is controlled
|
|
99
|
-
by the `trackHover`/`trackActive`/`trackIndicator` boolean props (all default `true`), since they
|
|
100
|
-
also gate whether the underlying DOM nodes are created — not just their styling.
|
|
101
|
-
|
|
102
|
-
---
|
|
103
|
-
|
|
104
|
-
## Movement/position values are private
|
|
105
|
-
|
|
106
|
-
`--_x-active`, `--_x-hovered`, `--_width-active`, `--_width-hovered`, `--_y-active`, `--_y-hovered`,
|
|
107
|
-
`--_y-height`, `--_y-width`, and `--_transition-duration` are computed at runtime from the tab
|
|
108
|
-
elements' measured layout (`offsetLeft`/`offsetWidth`/etc. in `useTabs`) and aren't meaningful to
|
|
109
|
-
override directly — use `transitionDuration` (a prop, in ms) to control indicator movement speed.
|
|
110
|
-
|
|
111
|
-
---
|
|
112
|
-
|
|
113
|
-
## Local overrides
|
|
114
|
-
|
|
115
|
-
Set the tokens above on an element you own (a page or section class, or a class added with
|
|
116
|
-
`:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
|
|
117
|
-
`@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
|
|
118
|
-
`<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
|
|
119
|
-
`:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
|
|
120
|
-
|