srcdev-nuxt-components 9.6.0 → 9.6.2
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 +22 -3
- 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 +27 -10
- package/.claude/skills/components/display-chip.md +38 -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/google-reviews.md +2 -2
- package/.claude/skills/components/profile-section.md +59 -21
- 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/social-icons-list.md +2 -0
- package/.claude/skills/components/tabbed-content.md +300 -0
- package/.claude/skills/composable-anchor-scroll.md +16 -15
- package/.claude/skills/index.md +8 -7
- package/.claude/skills/storybook-add-story.md +55 -0
- package/.claude/skills/theming-component-token-pattern.md +48 -0
- package/.claude/skills/theming-status-tokens.md +60 -0
- package/.vscode/srcdev-component-display-avatar.code-snippets +9 -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-profile-section.code-snippets +27 -4
- 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 +33 -15
- package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +81 -36
- package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +108 -6
- package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +104 -13
- package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +4 -3
- package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +57 -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/profile-section/CONSUMER-STYLING.md +103 -13
- package/app/components/02.molecules/profile-section/ProfileSection.vue +123 -23
- package/app/components/02.molecules/profile-section/stories/ProfileSection.stories.ts +353 -115
- package/app/components/02.molecules/profile-section/tests/ProfileSection.spec.ts +144 -11
- package/app/components/02.molecules/profile-section/tests/__snapshots__/ProfileSection.spec.ts.snap +6 -11
- 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/social-icons-list/CONSUMER-STYLING.md +6 -2
- package/app/components/02.molecules/social-icons-list/SocialIconsList.vue +1 -0
- 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/google-reviews/tests/__snapshots__/GoogleReviewCard.spec.ts.snap +1 -1
- package/app/components/03.organisms/google-reviews/tests/__snapshots__/GoogleReviews.spec.ts.snap +6 -6
- 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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: TabbedContent
|
|
3
|
+
description: TabbedContent tab list/panel widget (was TabsCore) — indexed dynamic slots, arrow-key navigation, moving hover/active/underline indicators, CSS tokens
|
|
4
|
+
type: reference
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# TabbedContent
|
|
8
|
+
|
|
9
|
+
> **Renamed 2026-10-07:** `TabsCore` → `TabbedContent`, moved from `01.atoms/navigation/tabs/` to
|
|
10
|
+
> `01.atoms/tabbed-content/`. Root class `.tabs-core` → `.tabbed-content`; inner classes
|
|
11
|
+
> `.tabs-list` → `.tabbed-content-list`, `.tabs-list-item` → `.tabbed-content-trigger`,
|
|
12
|
+
> `.nav__hovered`/`.nav__active`/`.nav__active-indicator` → `.tabbed-content-indicator-hover`/
|
|
13
|
+
> `-active`/`-underline`, `.tab-content-wrapper` → `.tabbed-content-panels`, `.tab-content` →
|
|
14
|
+
> `.tabbed-content-panel`. Tokens keep their `--tabs-*` names. Slot names are unchanged.
|
|
15
|
+
|
|
16
|
+
## Overview
|
|
17
|
+
|
|
18
|
+
`TabbedContent` renders a WAI-ARIA tablist and its panels from indexed slots. The number of tabs is
|
|
19
|
+
set via `itemCount`; each tab's trigger and content come from `tab-{n}-trigger`/`tab-{n}-content`
|
|
20
|
+
slots. Indexed slots (rather than named dynamic slots) are used deliberately: `itemCount` is needed
|
|
21
|
+
for ARIA linking across the two parallel loops (triggers and panels), not just the slot loop itself.
|
|
22
|
+
|
|
23
|
+
Moving decorators track pointer/keyboard state: a hover highlight, an active-tab highlight, a
|
|
24
|
+
thick underline/sideline under the active tab, and a thin one (`--tabs-hover-underline-indicator-height`,
|
|
25
|
+
default `0.1rem`, added 2026-10-07) following the hovered tab. `trackHover` / `trackActive` /
|
|
26
|
+
`trackIndicator` turn them off; the hover underline needs both `trackHover` and `trackIndicator`.
|
|
27
|
+
|
|
28
|
+
Not navigation: for page/site navigation use `ResponsiveHeader` (which replaced `TabNavigation`).
|
|
29
|
+
|
|
30
|
+
**Location**: `app/components/01.atoms/tabbed-content/`
|
|
31
|
+
|
|
32
|
+
## Props
|
|
33
|
+
|
|
34
|
+
| Prop | Type | Default | Notes |
|
|
35
|
+
|------|------|---------|-------|
|
|
36
|
+
| `itemCount` | `number` | — | **Required.** Number of tabs; drives both slot loops. Rounded down; 0, negative or `NaN` renders no tabs. Can change after mount (the active tab is kept if it still exists, otherwise the first tab becomes active). |
|
|
37
|
+
| `axis` | `"x" \| "y"` | `"x"` | Horizontal row or vertical column. Also sets `aria-orientation` and which arrow keys move between tabs. Reactive. |
|
|
38
|
+
| `transitionDuration` | `number` | `200` | ms duration of the moving indicators. Negative values clamp to 0. Reactive. |
|
|
39
|
+
| `trackHover` | `boolean` | `true` | Moving hover highlight. Reactive. |
|
|
40
|
+
| `trackActive` | `boolean` | `true` | Moving active-tab highlight. Reactive. |
|
|
41
|
+
| `trackIndicator` | `boolean` | `true` | Moving underline/sideline. Reactive. |
|
|
42
|
+
| `overflowMode` | `"menu" \| "scroll"` | `"menu"` | `axis="x"` only. `menu`: tabs that don't fit collapse from the end into a "More" button and menu (added 2026-10-07). `scroll`: the tab row scrolls sideways inside itself. Reactive. |
|
|
43
|
+
| `ariaLabel` | `string` | `"Tabs"` | `aria-label` on the tablist; override for localisation. |
|
|
44
|
+
| `moreLabel` | `string` | `"More tabs"` | Accessible name of the More button and its menu; override for localisation. |
|
|
45
|
+
| `moreIcon` | `string` | `"lucide:ellipsis"` | Icon on the More button. |
|
|
46
|
+
| `moreActiveIcon` | `string` | `"lucide:chevron-down"` | Icon after the active tab's label on the More button while that tab is collapsed. |
|
|
47
|
+
| `styleClassPassthrough` | `string \| string[]` | `[]` | Classes on the root `.tabbed-content` (was the tab list before 2026-10-07). |
|
|
48
|
+
|
|
49
|
+
## Slots
|
|
50
|
+
|
|
51
|
+
For each index `0..itemCount-1`:
|
|
52
|
+
|
|
53
|
+
- `tab-{n}-trigger` — content of the tab button. May contain icons or other elements; a click
|
|
54
|
+
anywhere inside the button activates the tab.
|
|
55
|
+
- `tab-{n}-content` — content of that tab's panel.
|
|
56
|
+
|
|
57
|
+
```vue
|
|
58
|
+
<TabbedContent :item-count="2" aria-label="Product details">
|
|
59
|
+
<template #tab-0-trigger>Overview</template>
|
|
60
|
+
<template #tab-0-content>Overview content…</template>
|
|
61
|
+
<template #tab-1-trigger>Specs</template>
|
|
62
|
+
<template #tab-1-content>Specs content…</template>
|
|
63
|
+
</TabbedContent>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Accessibility behaviour
|
|
67
|
+
|
|
68
|
+
- `role="tablist"` is a `<div>` that owns the `role="tab"` buttons directly (it used to be a
|
|
69
|
+
`<ul>` with plain `<li>`s in between, an ARIA required-children violation, with the indicator
|
|
70
|
+
`<div>`s injected into the list by JS). Indicators are `aria-hidden` `<span>`s rendered by the
|
|
71
|
+
template.
|
|
72
|
+
- `aria-orientation` follows `axis`.
|
|
73
|
+
- Each trigger has `aria-controls` pointing at its panel; each panel has `aria-labelledby` pointing
|
|
74
|
+
at its trigger. IDs are prefixed with `useId()`, so several instances on one page don't clash
|
|
75
|
+
(before 2026-10-07 every instance used `tab-0-trigger` etc.).
|
|
76
|
+
- Inactive panels carry the `hidden` attribute; the first panel is visible in the SSR markup.
|
|
77
|
+
Panels have `tabindex="0"` so keyboard users can reach scrollable panel content.
|
|
78
|
+
- Roving tabindex: only the active trigger has `tabindex="0"`.
|
|
79
|
+
- Arrow keys move focus and activate the target tab (automatic activation): `ArrowLeft`/`ArrowRight`
|
|
80
|
+
on `axis="x"`, `ArrowUp`/`ArrowDown` on `axis="y"`, wrapping at the ends; `Home`/`End` jump to
|
|
81
|
+
the first/last tab. Not mirrored for RTL.
|
|
82
|
+
- Focus ring: `--tabs-focus-ring-colour` (default `--theme-border-focus`) on triggers and panels.
|
|
83
|
+
|
|
84
|
+
## Overflow menu (`overflowMode="menu"`, default, since 2026-10-07)
|
|
85
|
+
|
|
86
|
+
- On `axis="x"`, a ResizeObserver on the bar and the tablist measures the tabs (widths cached
|
|
87
|
+
while each is visible) and collapses the ones that don't fit, from the end, into a "More" button
|
|
88
|
+
at the end of the tab row (`--tabs-more-margin-inline-start: auto`; `0` puts it straight after
|
|
89
|
+
the last visible tab). Collapsed triggers get `hidden`, so they leave the tablist; the menu lists
|
|
90
|
+
them as `role="menuitemradio"` rows (re-rendering each tab's own `tab-{n}-trigger` slot), with
|
|
91
|
+
`aria-checked` on the active one. Picking one activates that tab and returns focus to the
|
|
92
|
+
More button.
|
|
93
|
+
- The More button is outside `role="tablist"` (`aria-haspopup="menu"`, `aria-label` from
|
|
94
|
+
`moreLabel`). While nothing has overflowed it stays in the DOM but idle (invisible,
|
|
95
|
+
`tabindex="-1"`) so its width can be measured.
|
|
96
|
+
- When the active tab is collapsed, the More button shows that tab's own trigger slot plus a
|
|
97
|
+
chevron (instead of `⋯`), gets `.is-active` and the active/hover indicators sit on it. A
|
|
98
|
+
ResizeObserver on the button re-measures, so if the wider button no longer fits, the tab before
|
|
99
|
+
it moves into the menu (and comes back when a visible tab is picked). The button has no
|
|
100
|
+
`aria-label`: `moreLabel` is visually hidden text inside it, so its name ("Shipping, More tabs")
|
|
101
|
+
always contains the visible label (WCAG 2.5.3). Trigger slot content renders up to three times
|
|
102
|
+
(tab, menu row, More button), so don't put `id`s in it.
|
|
103
|
+
- Indicators sit on the More button while the active tab is collapsed (`useTabs` swaps any
|
|
104
|
+
hidden tab for `[data-more-trigger]` when positioning). Arrow keys move between visible tabs only, and the first visible tab keeps `tabindex="0"` so
|
|
105
|
+
the tablist stays reachable with Tab.
|
|
106
|
+
- The popover uses `useAnchoredPopover` (Popover API + anchor positioning, with the Safari 16/17
|
|
107
|
+
fallbacks), like `SelectMenu` and `ActionMenu`. Menu keys: Arrow Up/Down (wrapping),
|
|
108
|
+
Home/End, Escape (light dismiss), Tab closes.
|
|
109
|
+
- Not on `axis="y"`: a column doesn't run out of width.
|
|
110
|
+
|
|
111
|
+
## Layout under hostile data
|
|
112
|
+
|
|
113
|
+
- With `overflowMode="scroll"` the tab row scrolls sideways inside itself; the indicators scroll
|
|
114
|
+
with it. In both modes triggers never shrink, and wrap their label past
|
|
115
|
+
`--tabs-list-item-max-inline-size` (`24rem`).
|
|
116
|
+
- On `axis="y"` the tab column is capped at `--tabs-axis-y-list-max-inline-size` (`50%`).
|
|
117
|
+
- Panels wrap unbroken strings (`overflow-wrap: anywhere`).
|
|
118
|
+
- No line-clamp token: trigger and panel text is consumer slot markup, so the consumer controls it.
|
|
119
|
+
|
|
120
|
+
## CSS custom properties
|
|
121
|
+
|
|
122
|
+
See `CONSUMER-STYLING.md` in the component folder for the full `--tabs-*` token API. Colour
|
|
123
|
+
defaults come from the `--theme-*` slots (changed 2026-10-07, were fixed `--slate-*` steps with a
|
|
124
|
+
dark panel).
|
|
125
|
+
|
|
126
|
+
## useTabs
|
|
127
|
+
|
|
128
|
+
`app/composables/useTabs.ts` drives the behaviour and is only used by this component. Its nav ref
|
|
129
|
+
is `.tabbed-content-bar` (the indicators' positioning box); it also exposes `activeIndex` and
|
|
130
|
+
`activateTabByIndex()` for the More menu. It takes
|
|
131
|
+
`axis`, `transitionDuration` and `trackHover` as getters (read at use time, so prop changes apply),
|
|
132
|
+
and exposes `refreshTabs()`, which the component calls on mount and after any change to the tab
|
|
133
|
+
count, axis or indicator props. Click/hover handlers use `event.currentTarget` (the button), not
|
|
134
|
+
`event.target`, so clicking an icon inside a trigger works. Panels are matched to triggers by
|
|
135
|
+
`data-tab-index`, not by position in the `v-for` ref array, since Vue doesn't keep that array in
|
|
136
|
+
source order when the list changes.
|
|
137
|
+
|
|
138
|
+
## Indicator motion: edge insets (2026-10-08)
|
|
139
|
+
|
|
140
|
+
`useTabs.placeIndicator(kind, animate)` writes each indicator's two edges as insets from the
|
|
141
|
+
bar's start/end along the axis (`--_{kind}-start` / `--_{kind}-end`), and the CSS transitions
|
|
142
|
+
`left`/`right` (or `top`/`bottom`) separately. Moving forward, the start edge gets a delay of one
|
|
143
|
+
duration (`--_{kind}-start-delay`); moving back, the end edge does. So the leading edge moves first
|
|
144
|
+
and the trailing edge catches up: the same stretch effect as before, symmetric both ways, with no
|
|
145
|
+
settle timer. CSS retargets each edge from its current position, so a quick second move can't
|
|
146
|
+
pull an edge backwards. Hover and active have separate duration/delay variables.
|
|
147
|
+
|
|
148
|
+
This replaced a `translate` + `scale` model (a bar-wide box translated to the tab's left and
|
|
149
|
+
scaled to its width, plus a timeout to settle after a stretch, and one shared
|
|
150
|
+
`--_transition-duration`). Rightward moves animated both properties together in the settle step,
|
|
151
|
+
and the indicator visibly stepped back mid-move (reported for hover, then n to n+x clicks); the
|
|
152
|
+
computed values were always correct, the composition wasn't. Avoid reintroducing a transform
|
|
153
|
+
pair for this.
|
|
154
|
+
|
|
155
|
+
## Label colour under the sliding highlight (2026-10-08)
|
|
156
|
+
|
|
157
|
+
With `trackActive`, `useTabs` re-checks every animation frame while the active indicator
|
|
158
|
+
transitions (`trackCoverage`, for `transitionDuration` + 50ms) which visible tabs and the More
|
|
159
|
+
button the indicator (`[data-active-indicator]`) covers by at least half, and toggles
|
|
160
|
+
`data-under-active` on them (an attribute, not a class, so Vue's own class binding on the More
|
|
161
|
+
button can't wipe it). CSS under `.tracks-active` colours by that attribute rather than
|
|
162
|
+
`aria-selected`. The coverage window only ever extends (`coverageUntil`): a refresh landing
|
|
163
|
+
mid-move (e.g. the More button resizing straight after a click) used to restart it with zero
|
|
164
|
+
length, cancelling the per-frame checks while the highlight was still sliding and leaving stale
|
|
165
|
+
colours (dark selected label, invisible neighbour). A More-button resize also re-places both
|
|
166
|
+
indicators (`syncIndicators(true)`), since its box moves even when no tab changes sides, and an
|
|
167
|
+
overflow change refreshes with `refreshTabs(true)` so an in-flight move retargets instead of
|
|
168
|
+
snapping. This replaces the per-component timing trick `TripleToggleSwitch` uses (swap
|
|
169
|
+
colour at half the slide), which only works for single-step moves; a tab jump passes under
|
|
170
|
+
several labels.
|
|
171
|
+
|
|
172
|
+
## Migration note (2026-10-07)
|
|
173
|
+
|
|
174
|
+
Fixed during the compliance pass that renamed it:
|
|
175
|
+
|
|
176
|
+
- Duplicate IDs across instances (above).
|
|
177
|
+
- `event.target` in the click handler: clicking an element inside a trigger set the active tab to
|
|
178
|
+
that inner element, so no panel matched and every panel was hidden.
|
|
179
|
+
- `axis`, `transitionDuration` and the `track*` flags were read once at setup, and a changed
|
|
180
|
+
`itemCount` was never picked up (new tabs had no roving tabindex and weren't navigable).
|
|
181
|
+
- Dark-on-dark panel content by default, near-invisible focus ring, `styleClassPassthrough` on
|
|
182
|
+
the wrong element, unprefixed inner class names (pitfall #16).
|
|
183
|
+
- A long label or many tabs pushed the page sideways.
|
|
184
|
+
|
|
185
|
+
## Migration note (2026-09-15)
|
|
186
|
+
|
|
187
|
+
Previously lived at `tabs/TabsCore.vue` with options-style `defineProps`, no tier folder, and
|
|
188
|
+
several defects fixed in this pass:
|
|
189
|
+
|
|
190
|
+
- `querySelectorAll("[data-nav-item")` was missing its closing `]` — an invalid selector that
|
|
191
|
+
some browsers tolerate but that throws in strict DOM implementations (including this repo's test
|
|
192
|
+
environment). Fixed to `"[data-nav-item]"`.
|
|
193
|
+
- `aria-labelledby="channel-name"` was hardcoded on the tablist, pointing at an id that doesn't
|
|
194
|
+
exist anywhere in this library. Replaced with the new `ariaLabel` prop.
|
|
195
|
+
- `border-bottom`/`border-left` on the tab list referenced `--_tabs-border-bottom`, a private CSS
|
|
196
|
+
variable that was never set anywhere (dead reference — the border never rendered). Promoted to
|
|
197
|
+
the public `--tabs-nav-border` token with a real default.
|
|
198
|
+
- `tag`, `trackHover`, `trackActive`, and `trackIndicator` were all declared props with no effect
|
|
199
|
+
anywhere in the component or its composable (`useTabs`). `tag` had no coherent purpose (triggers
|
|
200
|
+
were always hardcoded `<button>`s) and was removed; the three `track*` flags were wired up to
|
|
201
|
+
actually gate their respective decorator elements, since removing them would have been a bigger
|
|
202
|
+
behavioural change than making them work as their names already implied.
|
|
203
|
+
- Almost every colour/spacing/typography value in the `<style>` block was hardcoded with no
|
|
204
|
+
override hook at all (not even a private token) — promoted to public `--tabs-*` tokens.
|
|
205
|
+
|
|
206
|
+
## Performance notes (2026-09-15)
|
|
207
|
+
|
|
208
|
+
`useTabs` had a few real perf issues, fixed alongside the migration:
|
|
209
|
+
|
|
210
|
+
- **Layout thrashing**: `moveActiveIndicator`/`moveHoveredIndicator`/`setFinalActivePositions`/
|
|
211
|
+
`setFinalHoveredPositions` used to interleave `offsetLeft`/`offsetWidth`/etc. reads with
|
|
212
|
+
`style.setProperty` writes, forcing a synchronous reflow between each pair. All layout reads are
|
|
213
|
+
now batched into local variables before any writes happen.
|
|
214
|
+
- **`@mouseover` → `@mouseenter`**: the hover trigger listener used `mouseover`, which bubbles and
|
|
215
|
+
re-fires as the pointer crosses any child element inside the button. `mouseenter` fires exactly
|
|
216
|
+
once per tab entered.
|
|
217
|
+
- **Debounced settle timers**: `moveActiveIndicator`/`moveHoveredIndicator` schedule a `setTimeout`
|
|
218
|
+
to snap the indicator to its exact final position once the CSS transition ends. Previously each
|
|
219
|
+
call scheduled a new timeout without clearing the last one, so moving the pointer quickly across
|
|
220
|
+
several tabs stacked up redundant pending timeouts. Now the previous timeout is cleared before
|
|
221
|
+
scheduling the next, and both are cleared in `onUnmounted`.
|
|
222
|
+
- `handleTransitioningClass` called `.indexOf()` on the same two tabs repeatedly inside its loop
|
|
223
|
+
condition and body — now computed once per call.
|
|
224
|
+
- `previousActiveTab` used `useState("previousActiveTab", ...)` with a fixed, unscoped key — every
|
|
225
|
+
`TabsCore` instance on a page would have shared that one piece of state. Not actually a
|
|
226
|
+
performance issue, but a correctness bug found while auditing this file; fixed by switching to a
|
|
227
|
+
plain per-instance `ref`, since this state doesn't need SSR/hydration sharing.
|
|
228
|
+
|
|
229
|
+
## Fixed: hover/active label text could become unreadable against the indicator (2026-09-15)
|
|
230
|
+
|
|
231
|
+
`--tabs-hover-indicator-text-colour` and `--tabs-active-indicator-text-colour` were set as `color`
|
|
232
|
+
on `.nav__hovered`/`.nav__active` — decorative, empty, absolutely-positioned divs with no text
|
|
233
|
+
content, so that `color` was dead CSS with no visible effect. The actual tab label
|
|
234
|
+
(`.tabs-list-item`) used one shared `--tabs-list-item-colour-selected` token for hover, active,
|
|
235
|
+
*and* transitioning alike, regardless of which of the two (potentially differently-coloured)
|
|
236
|
+
indicator backgrounds was actually behind it at that moment. A consumer overriding only the hover
|
|
237
|
+
indicator's background (or the active one) had no working lever to keep the label readable against
|
|
238
|
+
it, and could end up with text the same colour as the background sliding behind it — this was the
|
|
239
|
+
original defect noticed during this component's initial authoring, reported as "the button text
|
|
240
|
+
became the same colour as the track item background and became invisible."
|
|
241
|
+
|
|
242
|
+
Fixed by removing the dead `color` declarations from the two decorator divs and splitting
|
|
243
|
+
`.tabs-list-item`'s combined `:hover, [aria-selected="true"], .transitioning` rule so hover uses
|
|
244
|
+
`--tabs-hover-indicator-text-colour` and active uses `--tabs-list-item-colour-selected` falling
|
|
245
|
+
back to `--tabs-active-indicator-text-colour` — each state's text colour now tracks the background
|
|
246
|
+
actually behind it. See `CONSUMER-STYLING.md`'s "Tab-label text colour tracks the indicator behind
|
|
247
|
+
it" section: overriding an indicator background without its matching text-colour token can still
|
|
248
|
+
produce poor contrast (same tradeoff as any two independently-set colour tokens) — the fix makes
|
|
249
|
+
the tokens functional, it doesn't auto-compute contrast.
|
|
250
|
+
|
|
251
|
+
## Fixed: intermediate tab labels could go invisible during a multi-tab jump (2026-09-15)
|
|
252
|
+
|
|
253
|
+
The first fix above wasn't the whole story. The exact repro that surfaced the rest of it: with tab
|
|
254
|
+
1 active, hover then click tab 5 — the tabs in between briefly lost all visible text. Going the
|
|
255
|
+
other direction (tab 5 active, hover then click tab 1) looked fine.
|
|
256
|
+
|
|
257
|
+
Cause: `useTabs` had a `handleTransitioningClass` helper that added a `.transitioning` class to
|
|
258
|
+
every tab spanned by a jump (previously shared by the `[aria-selected="true"]` colour rule fixed
|
|
259
|
+
above), on the assumption the sliding indicator visually covers every spanned tab for the whole
|
|
260
|
+
transition — so it force-applied the active text colour to all of them, for the full
|
|
261
|
+
`transitionDuration`. It doesn't cover them the whole time: the indicator's "grow from anchor" CSS
|
|
262
|
+
transition (see `moveActiveIndicator`/`moveHoveredIndicator`) keeps the box's leading edge pinned
|
|
263
|
+
at the *old* tab's position on a forward move and only grows its width, so it only gradually sweeps
|
|
264
|
+
across later tabs — a tab several positions away isn't actually covered until *late* in the
|
|
265
|
+
transition. Recolouring it to the active text colour from the very start meant its label matched
|
|
266
|
+
neither its own background nor the not-yet-arrived indicator, going invisible. A backward move
|
|
267
|
+
animates position and width toward their final values together, so the box's rendered coverage is
|
|
268
|
+
more consistent throughout — which is why that direction looked fine and made the bug easy to miss
|
|
269
|
+
without a specific repro.
|
|
270
|
+
|
|
271
|
+
Fixed by removing `handleTransitioningClass` and the `.transitioning` class entirely — it had no
|
|
272
|
+
other purpose. Tabs spanned by a jump now keep their normal (inactive) styling throughout; only the
|
|
273
|
+
newly-active tab (`[aria-selected="true"]`) and the currently-hovered tab (`:hover`) get their
|
|
274
|
+
special colours. See `CONSUMER-STYLING.md`'s "Tabs spanned by a jump keep their normal styling".
|
|
275
|
+
|
|
276
|
+
## Fixed: passed-over tab text still invisible under the active indicator specifically (2026-09-15)
|
|
277
|
+
|
|
278
|
+
Removing `.transitioning` (previous fix) uncovered a second, pre-existing defect that
|
|
279
|
+
`.transitioning`'s forced recolour had been accidentally masking the whole time: a non-active tab's
|
|
280
|
+
normal text colour (`--tabs-list-item-colour`) was **exactly the same value** as the active
|
|
281
|
+
indicator's background (`--tabs-active-indicator-colour`, same default) — this collision existed in
|
|
282
|
+
the very first authored version of this component, not something introduced by any migration
|
|
283
|
+
change. So whenever the active pill visually passes under (or briefly sits under) any tab that
|
|
284
|
+
isn't itself marked active — which happens on every transition, single-step or distant, in either
|
|
285
|
+
direction — that tab's text became literally the same colour as the background sliding beneath it.
|
|
286
|
+
`--tabs-hover-indicator-colour`'s default never had this problem since it doesn't coincide with the
|
|
287
|
+
inactive text default.
|
|
288
|
+
|
|
289
|
+
Fixed by changing `--tabs-active-indicator-colour`'s default to one step in from the extreme,
|
|
290
|
+
still clearly the boldest of the three indicator colours, but no longer numerically identical to
|
|
291
|
+
`--tabs-list-item-colour`'s default. `--tabs-active-indicator-text-colour`/
|
|
292
|
+
`--tabs-list-item-colour-selected` (used by the truly active tab) still contrast fine against the
|
|
293
|
+
new value. See `CONSUMER-STYLING.md` for the note on keeping these two tokens from colliding again
|
|
294
|
+
if you override either one.
|
|
295
|
+
|
|
296
|
+
## Colour token defaults (2026-10-07)
|
|
297
|
+
|
|
298
|
+
Colour defaults were flat `--slate-*` steps from 2026-09-15 (when `light-dark()` pairs were
|
|
299
|
+
dropped) until 2026-10-07, when they moved to the `--theme-*` slots. Light-only, like the rest of
|
|
300
|
+
the library.
|
|
@@ -64,26 +64,27 @@ onMounted(() => {
|
|
|
64
64
|
>{{ section.label }}</a>
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
### Active state in
|
|
67
|
+
### Active state in ResponsiveHeader
|
|
68
68
|
|
|
69
|
-
`
|
|
70
|
-
just pass anchor
|
|
71
|
-
and on initial load (defaulting to the first
|
|
69
|
+
`ResponsiveHeader` handles `activeHash` internally for `#` nav items. No extra work needed —
|
|
70
|
+
just pass anchor paths in `responsiveNavLinks` and the active indicator moves automatically on
|
|
71
|
+
click and on initial load (defaulting to the first anchor item when the URL has no hash).
|
|
72
|
+
(`TabNavigation` did the same until it was removed on 2026-10-07.)
|
|
72
73
|
|
|
73
74
|
---
|
|
74
75
|
|
|
75
76
|
## Usage patterns
|
|
76
77
|
|
|
77
|
-
### 1.
|
|
78
|
+
### 1. ResponsiveHeader with a sticky site header
|
|
78
79
|
|
|
79
|
-
`
|
|
80
|
+
`ResponsiveHeader` accepts an `anchorScrollOffset` prop that is passed directly to `useAnchorScroll`.
|
|
80
81
|
Pass a getter so the live header height is read at scroll time — this stays correct if the
|
|
81
82
|
header resizes on different viewports.
|
|
82
83
|
|
|
83
84
|
```vue
|
|
84
85
|
<header ref="headerRef">
|
|
85
|
-
<
|
|
86
|
-
:nav-
|
|
86
|
+
<ResponsiveHeader
|
|
87
|
+
:responsive-nav-links="responsiveNavLinks"
|
|
87
88
|
:anchor-scroll-offset="() => headerRef?.offsetHeight ?? 0"
|
|
88
89
|
/>
|
|
89
90
|
</header>
|
|
@@ -92,18 +93,18 @@ header resizes on different viewports.
|
|
|
92
93
|
```ts
|
|
93
94
|
const headerRef = ref<HTMLElement | null>(null);
|
|
94
95
|
|
|
95
|
-
const
|
|
96
|
+
const responsiveNavLinks = {
|
|
96
97
|
main: [
|
|
97
|
-
{
|
|
98
|
-
{
|
|
99
|
-
{
|
|
100
|
-
{
|
|
98
|
+
{ name: "About", path: "#about" },
|
|
99
|
+
{ name: "Services", path: "#services" },
|
|
100
|
+
{ name: "Contact", path: "#contact" },
|
|
101
|
+
{ name: "Blog", path: "/blog" }, // route — NuxtLink handles as normal
|
|
101
102
|
],
|
|
102
103
|
};
|
|
103
104
|
```
|
|
104
105
|
|
|
105
106
|
**CSS alternative** — if you want all anchor links site-wide to respect the sticky header
|
|
106
|
-
(not just the ones inside `
|
|
107
|
+
(not just the ones inside `ResponsiveHeader`), add `scroll-padding-top` to `html` instead and
|
|
107
108
|
skip the prop:
|
|
108
109
|
|
|
109
110
|
```css
|
|
@@ -290,4 +291,4 @@ takes effect immediately on the next click.
|
|
|
290
291
|
- **SSR** — both `handleNavClick` and `scrollToAnchor` guard with `import.meta.server` and return
|
|
291
292
|
early. No special SSR setup is needed.
|
|
292
293
|
- **Multiple instances** — each call to `useAnchorScroll` is independent. You can run a sticky
|
|
293
|
-
section nav alongside a `
|
|
294
|
+
section nav alongside a `ResponsiveHeader` on the same page with different offsets.
|
package/.claude/skills/index.md
CHANGED
|
@@ -32,7 +32,8 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
32
32
|
├── theming-override-default.md — replace the entire default theme with a custom palette (set --theme-hue/--theme-chroma)
|
|
33
33
|
├── theming-partial-override.md — override a specific colour token category (palette, buttons, inputs) without a full theme replacement
|
|
34
34
|
├── theming-form-geometry-tokens.md — non-colour form/button/input tokens (padding, border-radius, gaps): full inventory, why partial override doesn't need duplicating the source files
|
|
35
|
-
├── theming-component-token-pattern.md — standard shape for overridable component colours: public token + inline fallback, no bare var()/no private-only indirection; the three tiers (theme slots / cross-component families / component-scoped); line-clamp tokens for overflowing text; rollout status
|
|
35
|
+
├── theming-component-token-pattern.md — standard shape for overridable component colours: public token + inline fallback, no bare var()/no private-only indirection; the three tiers (theme slots / cross-component families / component-scoped); line-clamp tokens for overflowing text; image-overlay scrim tokens; rollout status
|
|
36
|
+
├── theming-status-tokens.md — global --status-* colours (success/warning/danger/info/neutral: solid + surface + text), which components read them, how to override once for the whole library
|
|
36
37
|
├── theming-dark-mode.md — add a dark scheme in a consumer app (layer is light-only): enable plumbing, full dark token set without light-dark(), light-dark() alternative, light-only pinning
|
|
37
38
|
├── colour-scheme-disable.md — disable light/dark scheme support in a consumer app
|
|
38
39
|
├── component-dynamic-slots.md — named dynamic slots ($slots iteration) vs indexed dynamic slots (itemCount pattern)
|
|
@@ -62,7 +63,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
62
63
|
├── composable-zod-validation.md — useZodValidation: schema-driven form validation, error binding, submit flow, API error push
|
|
63
64
|
├── composable-colour-scheme.md — useColourScheme: reactive light/dark/auto switching, localStorage persistence, runtime config
|
|
64
65
|
├── composable-dialog-controls.md — useDialogControls: single-call setup with config object, openDialog/closeDialog API, confirm/cancel callbacks
|
|
65
|
-
├── composable-anchor-scroll.md — useAnchorScroll: smooth anchor scrolling with reduced-motion support, dynamic offset, and
|
|
66
|
+
├── composable-anchor-scroll.md — useAnchorScroll: smooth anchor scrolling with reduced-motion support, dynamic offset, and ResponsiveHeader integration
|
|
66
67
|
├── composable-tooltips-guide.md — useTooltipsGuide: sequential popover guide with auto-start, dismiss-to-advance, manual controls
|
|
67
68
|
├── composable-cookie-consent.md — useCookieConsent: unset/granted/denied state, cookie persistence, wraps @nuxt/scripts' useScriptTriggerConsent
|
|
68
69
|
├── composable-analytics.md — useAnalytics: provider-agnostic trackEvent/page-view tracking (google-analytics only implemented), consent-gated, single call site for setup + firing events
|
|
@@ -90,7 +91,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
90
91
|
├── service-detail.md — ServiceDetail: full service detail page (hero banner, sticky sidebar booking card + related services, closing CTA), headerTag vs subheadingTag split, book-cta/sidebar-note/related-service/final-cta slots
|
|
91
92
|
├── breadcrumb.md — Breadcrumb: items (BreadcrumbItem[]) trail, link vs current-page text, CSS token API
|
|
92
93
|
├── skip-links.md — SkipLinks: focus-revealed accessibility skip-nav, links (SkipLink[]) data-driven, homeLink slot, CSS token API
|
|
93
|
-
├──
|
|
94
|
+
├── tabbed-content.md — TabbedContent (was TabsCore): tablist + panels from indexed slots (itemCount), arrow-key nav, moving hover/active/underline indicators, CSS token API
|
|
94
95
|
├── contact-section.md — ContactSection props (stepperIndicatorSize pass-through), 3-item info+form layout, slot API
|
|
95
96
|
├── stepper-list.md — StepperList dynamic slots (item-{n}/indicator-{n}), props, connector behaviour
|
|
96
97
|
├── expanding-panel.md — ExpandingPanel v-model, forceOpened, contentIsOnTop overlay mode, slots (summary/icon/content), ARIA wiring, CSS token API
|
|
@@ -107,20 +108,20 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
107
108
|
├── wipe-away-vertical.md — WipeAwayVertical: scroll-driven vertical wipe-away effect, pure-CSS grid overlay (no JS), indexed dynamic slots, sticky-centering-via-calc gotcha, CSS tokens
|
|
108
109
|
├── marquee-scroller.md — MarqueeScroller: infinite logo/badge scroller, per-item dynamic slots (marqueeData id), hover/focus/keyboard pause, reduced-motion, CSS tokens
|
|
109
110
|
├── rotating-carousel-image.md — RotatingCarouselImage: 3D rotating image carousel, scroll-parallax tilt, focus/keyboard/hover pause, reduced-motion, CSS tokens
|
|
110
|
-
├──
|
|
111
|
-
├── social-icons-list.md — SocialIconsList: data-driven social icon links, ISocialIcon type, logos: icon names, CSS tokens
|
|
111
|
+
├── social-icons-list.md — SocialIconsList: data-driven social icon links, ISocialIcon type, logos: icon names (dark ones vanish on dark backgrounds; mono alternatives), CSS tokens incl. --social-icons-list-icon-colour
|
|
112
112
|
├── display-qr-code.md — DisplayQrCode: QR code SVG from a string value, colour/size/variant/radius props, currentColor default
|
|
113
113
|
├── capture-qr-code.md — CaptureQrCode: live camera scanner, error state, visibility/route/KeepAlive lifecycle, media stream cleanup
|
|
114
114
|
├── decode-qr-code.md — DecodeQrCode: file picker + drag-and-drop image decoder, shared results list, CSS override points
|
|
115
115
|
├── auto-grid.md — AutoGrid: auto-fit responsive grid, $slots iteration, --auto-grid-gap/min-col-size-{small,default,large} tokens, is-responsive, semantic tag + aria
|
|
116
|
-
├── display-avatar.md — DisplayAvatar (02.molecules): circular avatar with image/initials fallback (alt announced), size scale tokens, --display-avatar-* colours, chip badge, icon slot
|
|
116
|
+
├── display-avatar.md — DisplayAvatar (02.molecules): circular avatar with image/initials fallback (alt announced), broken-image fallback, data-size scale tokens, image-size, --display-avatar-* colours, chip badge, icon slot
|
|
117
|
+
├── profile-section.md — ProfileSection (02.molecules): picture + numbered profile-info-N slots, eyebrow/hero header (above, or over the picture top/bottom on a scrim via header-placement), profileLinks, container-query two-column layout, .location/.services highlight tokens
|
|
117
118
|
├── display-theme-switch.md — DisplayThemeSwitch: system/light/dark picker wrapping TripleToggleSwitch, wired to useSettingsStore, labels/icons props, small sizing variant
|
|
118
119
|
├── card-core.md — CardCore: generic card container, dynamic named slots as rows, 4 variants, blurred backdrop layer, full CSS token API
|
|
119
120
|
├── action-menu.md — ActionMenu + ActionMenuItem: ellipsis trigger + anchored popover menu, indexed item-{n} slots, link/button items, full CSS token API
|
|
120
121
|
├── select-menu.md — SelectMenu: v-model single-select listbox popover (ActionMenu's popover mechanics, InputSelectCore's selected-option semantics), icon/text/chevron trigger toggles, checkmark on selected option, options-array driven, full CSS token API
|
|
121
122
|
├── display-dialog.md — DisplayDialog: native <dialog> overlay, 5 variants (dialog/modal/confirm/alert/fullscreen), useDialogControls integration, CSS token API
|
|
122
123
|
├── display-chip.md — DisplayChip: status indicator chip overlay, CSS trig positioning, circle/square shapes, status colours, icon/label content
|
|
123
|
-
├── display-pill.md — DisplayPill: pill/badge label with icon slot, 6 variants, 3 sizes, reversible order, --display-pill-* tokens (
|
|
124
|
+
├── display-pill.md — DisplayPill: pill/badge label with icon slot, 6 variants, 3 sizes, reversible order, --display-pill-* tokens (variants fall back to base colour/border/ring tokens); data-size/data-variant/data-reversed hooks (2026-10-08, were bare classes), label line-clamp token, -02 ring and outlined-monochrome recipes, StressTest story
|
|
124
125
|
├── carousel-flip.md — CarouselFlip: FLIP-animated carousel, carouselDataIds slot API, buttonLayout variants (sides/controls-flanking/controls-grouped-right/overlay), CSS tokens
|
|
125
126
|
├── samaritan-prompt.md — SamaritanPrompt + SamaritanPromptMixed: animated text prompts, typewriter/word-pulse effects, MessageConfig API, pause on hover, aria-live, CSS tokens
|
|
126
127
|
├── display-toast.md — DisplayToast (standalone v-model) + DisplayToastProvider + useToastQueue (app-wide queue): stacking, FLIP dismiss, maxVisible, SemanticTheme × 4, masked SVG glass variant
|
|
@@ -268,6 +268,8 @@ export const Default: Story = {
|
|
|
268
268
|
Key points:
|
|
269
269
|
|
|
270
270
|
- `computed` is **not** auto-imported in `.ts` story files — import it explicitly from `"vue"`.
|
|
271
|
+
- Components aren't auto-registered in a story's `template` string either. Every component used there, including ones you put in the component's slots (`EyebrowText`, `HeroText`, `SocialIconsList`, ...), must be imported and listed in `components: {}`. Otherwise Vue renders it as an empty unknown element (`<eyebrowtext>`) with no error. ProfileSection's header and social-icons stories rendered blank this way until 2026-10-14.
|
|
272
|
+
- Props in a story's `template` string aren't type-checked either. An invalid value renders silently with no warning: ProfileSection's stories passed `font-size="large"` to `HeroText`, which only has `display`/`title`/`heading`/`subheading`/`label`, so the heading fell back to unstyled text. Check every literal prop value against the component's `interface Props`.
|
|
271
273
|
- Extra args must be stripped before `v-bind` — spreading unknown keys onto a component makes them unknown HTML attributes.
|
|
272
274
|
- CSS custom properties set via `:style` on the component root are picked up by `var()` in the component's scoped CSS.
|
|
273
275
|
|
|
@@ -285,6 +287,59 @@ prop and try to pass it. Make it unmistakable:
|
|
|
285
287
|
|
|
286
288
|
`PriceList.stories.ts` and `GoogleReviews.stories.ts` (line-clamp controls) follow this.
|
|
287
289
|
|
|
290
|
+
## Link to the stories of components that use this one
|
|
291
|
+
|
|
292
|
+
When the component you're working on is a building block of other components (it's rendered
|
|
293
|
+
inside them, or they're a specialised version of it), its stories only show it in isolation. Add a
|
|
294
|
+
short note to each story that names the components using it, says what they add, and links to their
|
|
295
|
+
stories, so a reader can see it in real use. Examples: `AlertContent` is the panel inside
|
|
296
|
+
`DisplayToast`, `DisplayPrompt` and `CookieConsentBanner`; `AlertContentInner` is shared by
|
|
297
|
+
`AlertContent` and `AlertMaskedContent`.
|
|
298
|
+
|
|
299
|
+
- **Say how they're related, not just "see also".** Name the parent and what it adds, e.g. "The
|
|
300
|
+
#actions slot wired to real accept and reject handlers: see the CookieConsentBanner story."
|
|
301
|
+
- **Link to the story that shows the relevant feature**, not just any story. A story about a slot
|
|
302
|
+
or behaviour links to the parent story that exercises it. Other stories can share one general
|
|
303
|
+
note.
|
|
304
|
+
- **Check the parent story actually shows what the note says.** Read its component before writing
|
|
305
|
+
the note. `DisplayToast` doesn't fill `AlertContent`'s `#actions` slot, so a toast note that
|
|
306
|
+
promised action buttons would send people to the wrong place.
|
|
307
|
+
- **Put the note in the canvas**, as a `<p>` above the component, so it's visible on the Canvas tab
|
|
308
|
+
as well as Docs. If the story has a `parameters.docs.description`, add the link there too.
|
|
309
|
+
- Find the parent's story id from its `title` and export name: `"Molecules/CookieConsentBanner"` +
|
|
310
|
+
`Default` gives `molecules-cookieconsentbanner--default` (lowercase, `/` and spaces become `-`,
|
|
311
|
+
then `--` and the kebab-cased export name).
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
// Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
|
|
315
|
+
const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
|
|
316
|
+
const cookieNote = `
|
|
317
|
+
<p style="${storyNoteStyle}">
|
|
318
|
+
For the #actions slot wired up to real accept and reject handlers, open the
|
|
319
|
+
<a href="/?path=/story/molecules-cookieconsentbanner--default" target="_top">CookieConsentBanner</a> story.
|
|
320
|
+
</p>
|
|
321
|
+
`;
|
|
322
|
+
|
|
323
|
+
// In the story template:
|
|
324
|
+
template: `
|
|
325
|
+
<div style="max-width: 600px; padding: 2rem;">
|
|
326
|
+
${cookieNote}
|
|
327
|
+
<AlertContent v-bind="componentArgs">...</AlertContent>
|
|
328
|
+
</div>
|
|
329
|
+
`,
|
|
330
|
+
|
|
331
|
+
// In parameters.docs.description (markdown): use "?path=..." with no leading slash.
|
|
332
|
+
story: "See the [CookieConsentBanner](?path=/story/molecules-cookieconsentbanner--default) story. ...",
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
- Canvas links need `target="_top"` and a leading `/` (`/?path=...`). Without `target="_top"` the
|
|
336
|
+
whole Storybook UI loads inside the preview frame. The leading `/` assumes Storybook is served
|
|
337
|
+
from the root of its domain, which holds locally and on the deployed Storybook.
|
|
338
|
+
- Define each note once as a constant at the top of the file and interpolate it, rather than
|
|
339
|
+
repeating the markup in every story.
|
|
340
|
+
|
|
341
|
+
`AlertContent.stories.ts` follows this.
|
|
342
|
+
|
|
288
343
|
## Notes
|
|
289
344
|
|
|
290
345
|
- **Colour scheme:** `.storybook/preview.ts` pins every story to the light scheme
|
|
@@ -83,6 +83,54 @@ Prefer this over a prop: line count is presentation, and CSS handles it without
|
|
|
83
83
|
layout around it.
|
|
84
84
|
- Document it in `CONSUMER-STYLING.md` with its default and the `1`/`none` options.
|
|
85
85
|
|
|
86
|
+
## Text over an image: scrim tokens
|
|
87
|
+
|
|
88
|
+
Any component that puts text over a photo (a header in a picture, a card title on its image) gets
|
|
89
|
+
the same scrim token set, so a consumer can match the scrim to their photos. `ProfileSection`'s
|
|
90
|
+
`header-placement="picture-top|picture-bottom"` is the reference; `ServicesCard`'s
|
|
91
|
+
`titles-within-image-wrapper` has the older subset (colour and whole-background override only) and
|
|
92
|
+
should gain the rest when it's next touched.
|
|
93
|
+
|
|
94
|
+
| Token | Default | Purpose |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `--{c}-scrim-colour` | `#000` | Colour the scrim starts from at the image's edge |
|
|
97
|
+
| `--{c}-scrim-opacity` | `75%` | Its opacity at the start |
|
|
98
|
+
| `--{c}-scrim-solid-stop` | `0%` | How far it stays full strength before fading, so the text sits on a clean background |
|
|
99
|
+
| `--{c}-scrim-target-colour` | `transparent` | What it fades into: set to roughly the photo's colour where the fade ends, so there's no grey band |
|
|
100
|
+
| `--{c}-scrim` | the gradient | Whole `background` override; `none` removes it |
|
|
101
|
+
| `--{c}-text-colour` | `contrast-color(<scrim colour>)`, `#fff` without support | Text on the scrim |
|
|
102
|
+
|
|
103
|
+
```css
|
|
104
|
+
.component-header {
|
|
105
|
+
--_scrim-colour: var(--component-scrim-colour, #000);
|
|
106
|
+
--_scrim-start-colour: color-mix(in oklab, var(--_scrim-colour) var(--component-scrim-opacity, 75%), transparent);
|
|
107
|
+
--_scrim-solid-stop: var(--component-scrim-solid-stop, 0%);
|
|
108
|
+
--_scrim-target-colour: var(--component-scrim-target-colour, transparent);
|
|
109
|
+
|
|
110
|
+
color: var(--component-text-colour, #fff);
|
|
111
|
+
background: var(--component-scrim, linear-gradient(to top, rgb(0 0 0 / 0.75), rgb(0 0 0 / 0.75) var(--_scrim-solid-stop), var(--_scrim-target-colour)));
|
|
112
|
+
|
|
113
|
+
@supports (color: color-mix(in oklab, red, transparent)) {
|
|
114
|
+
background: var(--component-scrim, linear-gradient(to top, var(--_scrim-start-colour), var(--_scrim-start-colour) var(--_scrim-solid-stop), var(--_scrim-target-colour)));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@supports (color: contrast-color(red)) {
|
|
118
|
+
color: var(--component-text-colour, contrast-color(var(--_scrim-colour)));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- The `@supports` fallback is for Safari before 16.2 (no `color-mix()`): it starts from 75% black
|
|
124
|
+
whatever the scrim colour, but keeps the solid stop and target colour.
|
|
125
|
+
- `contrast-color()` reads the scrim colour, not the photo. Document that a consumer who removes the
|
|
126
|
+
scrim must set the text colour.
|
|
127
|
+
- Round only the corners on the image's edge (`border-end-*-radius: inherit` for a bottom scrim),
|
|
128
|
+
or an opaque target colour shows rounded inner corners.
|
|
129
|
+
- If the text can be long, don't clip it: let the frame grow (absolutely position the image and
|
|
130
|
+
keep `overflow` visible so `aspect-ratio` acts as a minimum).
|
|
131
|
+
- Give the story a light and a dark theme built only from these tokens, each with a photo whose
|
|
132
|
+
edge suits the scrim.
|
|
133
|
+
|
|
86
134
|
## Multi-level fallback chains are fine
|
|
87
135
|
|
|
88
136
|
A component that wants to *look like* another component's default (rather than duplicating a
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Status tokens
|
|
2
|
+
|
|
3
|
+
Global status colours, declared on `:root` in `app/assets/styles/setup/03.theming/_status.css`.
|
|
4
|
+
Components that show a status (a dot, a badge, a pill) fall back to these, so a consumer restyles
|
|
5
|
+
every status indicator in the library from one place.
|
|
6
|
+
|
|
7
|
+
## The set
|
|
8
|
+
|
|
9
|
+
Each status has three roles:
|
|
10
|
+
|
|
11
|
+
| Role | Token | Use |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| Solid | `--status-<name>` | Dots, markers, small fills (mid-ramp step, visible on light and dark) |
|
|
14
|
+
| Surface | `--status-<name>-surface` | Tinted background of a badge or pill |
|
|
15
|
+
| Text | `--status-<name>-text` | Text/icon on that surface |
|
|
16
|
+
|
|
17
|
+
| Status | Solid | Surface / text | Meaning |
|
|
18
|
+
|---|---|---|---|
|
|
19
|
+
| `success` | `--green-06` | `--green-01` / `--green-10` | Good, active, online, complete |
|
|
20
|
+
| `warning` | `--orange-05` | `--orange-01` / `--orange-10` | Needs attention, pending, idle |
|
|
21
|
+
| `danger` | `--red-06` | `--red-01` / `--red-10` | Error, blocked, do not disturb |
|
|
22
|
+
| `info` | `--blue-06` | `--blue-01` / `--blue-09` | Informational, in progress, new |
|
|
23
|
+
| `neutral` | `--slate-05` | `--slate-01` / `--slate-09` | Inactive, offline, archived, draft |
|
|
24
|
+
|
|
25
|
+
Keep the set to these five. A domain-specific state (`pending`, `archived`, `in-progress`) maps
|
|
26
|
+
onto one of them rather than getting its own token; if a consumer genuinely needs a sixth colour,
|
|
27
|
+
they define it in their app and pass it wherever the component takes a colour (e.g. `dotColor`).
|
|
28
|
+
|
|
29
|
+
## Who reads them
|
|
30
|
+
|
|
31
|
+
The component's own token always wins, then the global status token:
|
|
32
|
+
|
|
33
|
+
| Component | Mapping |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `DisplayChip` | `online` → success, `idle` → warning, `dnd` → danger, no class → neutral (solid) |
|
|
36
|
+
| `DisplayPill` | `success`/`warning`/`danger` variants → surface + text. `primary` (brand) and `neutral` (dark, inverted) do **not** read the set |
|
|
37
|
+
| `SelectMenu` | Nothing automatic; options pass `dotColor: "var(--status-success)"` etc. |
|
|
38
|
+
|
|
39
|
+
When adding a component that shows a status, default its colour tokens to these
|
|
40
|
+
(`var(--component-colour-online, var(--status-success))`), never to a palette step or hex value
|
|
41
|
+
directly.
|
|
42
|
+
|
|
43
|
+
## Overriding
|
|
44
|
+
|
|
45
|
+
```css
|
|
46
|
+
/* consuming app, unlayered */
|
|
47
|
+
:root {
|
|
48
|
+
--status-success: #1f9d55;
|
|
49
|
+
--status-success-surface: #e6f6ec;
|
|
50
|
+
--status-success-text: #0f5132;
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
One component only: set its own token instead (`--display-chip-colour-online: hotpink`).
|
|
55
|
+
|
|
56
|
+
## Dark mode
|
|
57
|
+
|
|
58
|
+
Light values only, like every other layer token (no `light-dark()`). Storybook's dark values live
|
|
59
|
+
in `.storybook/color-scheme.css`: surfaces move to step `09`, text to step `02`; solids stay put.
|
|
60
|
+
A consumer app with a dark scheme copies those overrides (see `theming-dark-mode.md`).
|
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
"<DisplayAvatar src=\"${1:/images/profile.jpg}\" alt=\"${2:Jane Smith}\" size=\"${3|md,xs,s,lg,xl|}\" />"
|
|
7
7
|
]
|
|
8
8
|
},
|
|
9
|
+
"SRCDEV DisplayAvatar Large Image": {
|
|
10
|
+
"description": "DisplayAvatar: image avatar resized beyond the scale, with a matching image-size so it stays sharp",
|
|
11
|
+
"scope": "vue,html",
|
|
12
|
+
"body": [
|
|
13
|
+
"<DisplayAvatar src=\"${1:/images/profile.jpg}\" alt=\"${2:Jane Smith}\" :image-size=\"${3:240}\" style=\"--display-avatar-size: ${4:12rem}\" />"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
9
16
|
"SRCDEV DisplayAvatar Initials": {
|
|
10
17
|
"description": "DisplayAvatar: initials derived from alt (alt is announced, initials hidden from screen readers)",
|
|
11
18
|
"scope": "vue,html",
|
|
@@ -21,7 +28,8 @@
|
|
|
21
28
|
" src=\"${1:/images/profile.jpg}\"",
|
|
22
29
|
" alt=\"${2:Jane Smith}\"",
|
|
23
30
|
" :chip=\"{ size: '${3:12px}', maskWidth: '${4:4px}', offset: '${5:0px}', angle: '${6:45deg}' }\"",
|
|
24
|
-
"
|
|
31
|
+
" status=\"${7|online,idle,dnd,offline|}\"",
|
|
32
|
+
" status-label=\"${8:Online}\"",
|
|
25
33
|
"/>"
|
|
26
34
|
]
|
|
27
35
|
}
|