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,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
-