srcdev-nuxt-components 9.6.0 → 9.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/component-compliance-checklist.md +8 -2
- package/.claude/skills/component-dynamic-slots.md +3 -3
- package/.claude/skills/component-export-types.md +10 -16
- package/.claude/skills/component-naming.md +1 -1
- package/.claude/skills/components/alert-content.md +9 -1
- package/.claude/skills/components/container-glow.md +21 -8
- package/.claude/skills/components/display-avatar.md +13 -7
- package/.claude/skills/components/display-chip.md +37 -24
- package/.claude/skills/components/display-pill.md +58 -10
- package/.claude/skills/components/display-prompt.md +8 -5
- package/.claude/skills/components/display-toast.md +21 -4
- package/.claude/skills/components/glowing-border.md +20 -2
- package/.claude/skills/components/responsive-header.md +11 -1
- package/.claude/skills/components/select-menu.md +54 -5
- package/.claude/skills/components/site-header.md +2 -2
- package/.claude/skills/components/tabbed-content.md +300 -0
- package/.claude/skills/composable-anchor-scroll.md +16 -15
- package/.claude/skills/index.md +4 -4
- package/.claude/skills/storybook-add-story.md +53 -0
- package/.claude/skills/theming-status-tokens.md +60 -0
- package/.vscode/srcdev-component-display-avatar.code-snippets +2 -1
- package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
- package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
- package/.vscode/srcdev-component-responsive-header.code-snippets +6 -0
- package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
- package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
- package/app/assets/styles/setup/03.theming/_status.css +24 -0
- package/app/assets/styles/setup/03.theming/index.css +1 -0
- package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
- package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
- package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
- package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
- package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
- package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
- package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
- package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
- package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
- package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
- package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +75 -9
- package/app/components/01.atoms/display-pill/DisplayPill.vue +57 -24
- package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
- package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
- package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
- package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
- package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
- package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
- package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
- package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
- package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
- package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
- package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
- package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
- package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +7 -5
- package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +6 -2
- package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +24 -3
- package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +19 -0
- package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +2 -1
- package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +52 -19
- package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
- package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
- package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
- package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
- package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
- package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
- package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
- package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
- package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
- package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
- package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
- package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
- package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
- package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
- package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
- package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
- package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
- package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
- package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
- package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
- package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +38 -11
- package/app/components/03.organisms/responsive-header/NavigationItems.vue +27 -12
- package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +35 -20
- package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +1 -1
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +1 -1
- package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
- package/app/composables/useTabs.ts +148 -206
- package/app/types/components/display-chip.d.ts +8 -4
- package/app/types/components/display-toast.d.ts +2 -0
- package/app/types/components/index.ts +0 -1
- package/app/types/components/select-menu.d.ts +2 -0
- package/app/utils/pausableTimeout.ts +41 -0
- package/nuxt.config.ts +1 -1
- package/package.json +1 -1
- package/.claude/skills/components/tab-navigation.md +0 -288
- package/.claude/skills/components/tabs-core.md +0 -187
- package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
- package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
- package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
- package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
- package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
- package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -191
- package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -550
- package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -259
- package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -379
- package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
- package/app/composables/useNavCollapse.ts +0 -86
- package/app/types/components/nav-item.d.ts +0 -11
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
|
+
import { computed } from "vue";
|
|
2
3
|
import StorybookComponent from "../DisplayPill.vue";
|
|
3
4
|
|
|
5
|
+
type LineClamp = "none" | "1" | "2" | "3";
|
|
6
|
+
|
|
7
|
+
// labelLineClamp is story-only: it drives a CSS token, not a DisplayPill prop. See its argTypes entry.
|
|
4
8
|
type StoryArgs = {
|
|
5
9
|
tag: "span" | "div" | "button" | "a";
|
|
6
10
|
label: string;
|
|
@@ -8,12 +12,32 @@ type StoryArgs = {
|
|
|
8
12
|
variant: "default" | "primary" | "success" | "warning" | "danger" | "neutral";
|
|
9
13
|
reversed: boolean;
|
|
10
14
|
showIcon: boolean;
|
|
15
|
+
labelLineClamp: LineClamp;
|
|
16
|
+
ring?: boolean;
|
|
11
17
|
};
|
|
12
18
|
|
|
13
19
|
export default {
|
|
14
20
|
title: "Atoms/DisplayPill",
|
|
15
21
|
component: StorybookComponent,
|
|
22
|
+
parameters: {
|
|
23
|
+
docs: {
|
|
24
|
+
description: {
|
|
25
|
+
component:
|
|
26
|
+
"Used for the neutral duration and price pills in [ServiceSummary](?path=/story/organisms-services-service-summary--default) " +
|
|
27
|
+
"and [ServiceDetail](?path=/story/organisms-services-service-detail--default), which restyle them through their own pill tokens.",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
16
31
|
argTypes: {
|
|
32
|
+
labelLineClamp: {
|
|
33
|
+
control: "select",
|
|
34
|
+
options: ["none", "1", "2", "3"] satisfies LineClamp[],
|
|
35
|
+
description:
|
|
36
|
+
"**Story control, not a prop.** Sets the `--display-pill-label-line-clamp` CSS token on a wrapper so you can try it here. " +
|
|
37
|
+
"To use it in an app, set the token in your own CSS, e.g. `.tag-list { --display-pill-label-line-clamp: none; }`. " +
|
|
38
|
+
"`1` (the default) is single-line ellipsis, `none` lets a long label wrap.",
|
|
39
|
+
table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "1" } },
|
|
40
|
+
},
|
|
17
41
|
tag: {
|
|
18
42
|
control: { type: "inline-radio" },
|
|
19
43
|
options: ["span", "div", "button", "a"],
|
|
@@ -58,16 +82,30 @@ export default {
|
|
|
58
82
|
variant: "default",
|
|
59
83
|
reversed: false,
|
|
60
84
|
showIcon: true,
|
|
85
|
+
labelLineClamp: "1",
|
|
61
86
|
},
|
|
62
87
|
} as Meta<StoryArgs>;
|
|
63
88
|
|
|
89
|
+
// Links open in the Storybook manager (target="_top"), not inside the preview iframe.
|
|
90
|
+
const usedByNote = `
|
|
91
|
+
<p style="margin: 0; flex-basis: 100%; text-align: center; font-size: 1.4rem;">
|
|
92
|
+
In use: the duration and price pills in the
|
|
93
|
+
<a href="/?path=/story/organisms-services-service-summary--default" target="_top">ServiceSummary</a> and
|
|
94
|
+
<a href="/?path=/story/organisms-services-service-detail--default" target="_top">ServiceDetail</a> stories.
|
|
95
|
+
</p>
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
const tokenStyles = (args: StoryArgs) =>
|
|
99
|
+
computed(() => ({ "--display-pill-label-line-clamp": args.labelLineClamp }));
|
|
100
|
+
|
|
64
101
|
const Template: StoryFn<StoryArgs> = (args) => ({
|
|
65
102
|
components: { StorybookComponent },
|
|
66
103
|
setup() {
|
|
67
|
-
return { args };
|
|
104
|
+
return { args, tokenStyles: tokenStyles(args) };
|
|
68
105
|
},
|
|
69
106
|
template: `
|
|
70
|
-
<div style="display: flex; align-items: center; justify-content: center; min-height: 100vh; gap: 1.6rem; flex-wrap: wrap;">
|
|
107
|
+
<div :style="tokenStyles" style="display: flex; align-items: center; align-content: center; justify-content: center; min-height: 100vh; gap: 1.6rem; flex-wrap: wrap;">
|
|
108
|
+
${usedByNote}
|
|
71
109
|
<StorybookComponent
|
|
72
110
|
:tag="args.tag"
|
|
73
111
|
:label="args.label"
|
|
@@ -90,10 +128,11 @@ const variants = ["default", "primary", "success", "warning", "danger", "neutral
|
|
|
90
128
|
export const AllVariants: StoryFn<StoryArgs> = (args) => ({
|
|
91
129
|
components: { StorybookComponent },
|
|
92
130
|
setup() {
|
|
93
|
-
return { args, variants };
|
|
131
|
+
return { args, variants, tokenStyles: tokenStyles(args) };
|
|
94
132
|
},
|
|
95
133
|
template: `
|
|
96
|
-
<div style="display: flex; align-items: center; justify-content: center; min-height: 100vh; gap: 1.6rem; flex-wrap: wrap;">
|
|
134
|
+
<div :style="tokenStyles" style="display: flex; align-items: center; align-content: center; justify-content: center; min-height: 100vh; gap: 1.6rem; flex-wrap: wrap;">
|
|
135
|
+
${usedByNote}
|
|
97
136
|
<StorybookComponent
|
|
98
137
|
v-for="variant in variants"
|
|
99
138
|
:key="variant"
|
|
@@ -112,30 +151,215 @@ export const AllVariants: StoryFn<StoryArgs> = (args) => ({
|
|
|
112
151
|
});
|
|
113
152
|
AllVariants.argTypes = { variant: { table: { disable: true } }, label: { table: { disable: true } } };
|
|
114
153
|
|
|
115
|
-
|
|
154
|
+
// Each variant keeps its own text colour, so currentColor borders follow the variant. Neutral's text is a light
|
|
155
|
+
// step (it's the dark, inverted pill), so it's darkened here to stay visible once its background is transparent.
|
|
156
|
+
const tokenShowcase =
|
|
157
|
+
(tokens: string, argTokens: (args: StoryArgs) => Record<string, string> = () => ({})): StoryFn<StoryArgs> =>
|
|
158
|
+
(args) => ({
|
|
116
159
|
components: { StorybookComponent },
|
|
117
160
|
setup() {
|
|
118
|
-
return { args, variants };
|
|
161
|
+
return { args, variants, argStyles: computed(() => argTokens(args)) };
|
|
119
162
|
},
|
|
120
163
|
template: `
|
|
121
|
-
<div
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
164
|
+
<div :style="argStyles" style="display: flex; align-items: center; justify-content: center; min-height: 100vh; gap: 1.6rem; flex-wrap: wrap; ${tokens}">
|
|
165
|
+
<StorybookComponent
|
|
166
|
+
v-for="variant in variants"
|
|
167
|
+
:key="variant"
|
|
168
|
+
:tag="args.tag"
|
|
169
|
+
:label="variant"
|
|
170
|
+
:size="args.size"
|
|
171
|
+
:variant="variant"
|
|
172
|
+
:reversed="args.reversed"
|
|
173
|
+
>
|
|
174
|
+
<template v-if="args.showIcon" #icon>
|
|
175
|
+
<Icon name="material-symbols:check-circle-outline" />
|
|
176
|
+
</template>
|
|
177
|
+
</StorybookComponent>
|
|
130
178
|
</div>
|
|
131
179
|
`,
|
|
132
180
|
});
|
|
181
|
+
|
|
182
|
+
const showcaseArgTypes = {
|
|
183
|
+
variant: { table: { disable: true } },
|
|
184
|
+
label: { table: { disable: true } },
|
|
185
|
+
labelLineClamp: { table: { disable: true } },
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const OutlinedViaBaseTokens = tokenShowcase(`
|
|
189
|
+
--display-pill-background: transparent;
|
|
190
|
+
--display-pill-border-width: 0.2rem;
|
|
191
|
+
--display-pill-border-colour: var(--slate-05);
|
|
192
|
+
--display-pill-primary-border-colour: var(--blue-06);
|
|
193
|
+
--display-pill-success-border-colour: var(--status-success);
|
|
194
|
+
--display-pill-warning-border-colour: var(--status-warning);
|
|
195
|
+
--display-pill-danger-border-colour: var(--status-danger);
|
|
196
|
+
--display-pill-neutral-border-colour: var(--slate-08);
|
|
197
|
+
--display-pill-neutral-text-colour: var(--slate-08);
|
|
198
|
+
`);
|
|
133
199
|
OutlinedViaBaseTokens.storyName = "Outlined (base tokens override every variant)";
|
|
200
|
+
OutlinedViaBaseTokens.argTypes = showcaseArgTypes;
|
|
134
201
|
OutlinedViaBaseTokens.parameters = {
|
|
135
202
|
docs: {
|
|
136
203
|
description: {
|
|
137
204
|
story:
|
|
138
|
-
"
|
|
205
|
+
"A transparent base --display-pill-background on an ancestor clears every variant's fill; --display-pill-border-colour sets a base border, " +
|
|
206
|
+
"and the per-variant --display-pill-<variant>-border-colour tokens give each variant its own mid-step status colour while the text keeps its darker step. " +
|
|
207
|
+
"--display-pill-neutral-text-colour darkens the neutral pill, whose default text is light because it's normally a dark, inverted pill.",
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// A 2px ring in each variant's light -02 step, toggled by the story-only ring control.
|
|
213
|
+
const ring02Tokens: Record<string, string> = {
|
|
214
|
+
"--display-pill-ring-width": "0.2rem",
|
|
215
|
+
"--display-pill-ring-colour": "var(--slate-02)",
|
|
216
|
+
"--display-pill-primary-ring-colour": "var(--blue-02)",
|
|
217
|
+
"--display-pill-success-ring-colour": "var(--green-02)",
|
|
218
|
+
"--display-pill-warning-ring-colour": "var(--orange-02)",
|
|
219
|
+
"--display-pill-danger-ring-colour": "var(--red-02)",
|
|
220
|
+
"--display-pill-neutral-ring-colour": "var(--slate-02)",
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const ringArgTypes = {
|
|
224
|
+
...showcaseArgTypes,
|
|
225
|
+
ring: {
|
|
226
|
+
control: "boolean" as const,
|
|
227
|
+
description:
|
|
228
|
+
"**Story control, not a prop.** Sets `--display-pill-ring-width: 0.2rem` and `--display-pill-<variant>-ring-colour` to each variant's `-02` step " +
|
|
229
|
+
"(`var(--blue-02)`, `var(--green-02)`...) on a wrapper. In an app, set the same tokens in your CSS, e.g. `.tag-list { --display-pill-ring-width: 0.2rem; }`.",
|
|
230
|
+
table: { category: "CSS tokens (story only, set in your CSS)", defaultValue: { summary: "off" } },
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const ringArgTokens = (args: StoryArgs): Record<string, string> => (args.ring ? ring02Tokens : {});
|
|
235
|
+
|
|
236
|
+
export const OutlinedMonochrome = tokenShowcase(`
|
|
237
|
+
--display-pill-background: transparent;
|
|
238
|
+
--display-pill-border-colour: currentColor;
|
|
239
|
+
--display-pill-text-colour: var(--slate-07);
|
|
240
|
+
--display-pill-primary-text-colour: var(--blue-07);
|
|
241
|
+
--display-pill-success-text-colour: var(--green-07);
|
|
242
|
+
--display-pill-warning-text-colour: var(--orange-07);
|
|
243
|
+
--display-pill-danger-text-colour: var(--red-07);
|
|
244
|
+
--display-pill-neutral-text-colour: var(--slate-10);
|
|
245
|
+
`, ringArgTokens);
|
|
246
|
+
OutlinedMonochrome.storyName = "Outlined monochrome (mid-step colours)";
|
|
247
|
+
OutlinedMonochrome.args = { ring: false };
|
|
248
|
+
OutlinedMonochrome.argTypes = ringArgTypes;
|
|
249
|
+
OutlinedMonochrome.parameters = {
|
|
250
|
+
docs: {
|
|
251
|
+
description: {
|
|
252
|
+
story:
|
|
253
|
+
"Border and text share one colour per variant (--display-pill-border-colour: currentColor on a transparent background). " +
|
|
254
|
+
"The default text steps (-09/-10) are near-black in every hue, so outlined like this the variants look alike. " +
|
|
255
|
+
"Each text token is lifted to its -07 step (all at 48% lightness, so contrast stays even across variants); the border follows because it's currentColor. " +
|
|
256
|
+
"default and neutral are both slate, so neutral uses the darker -10 step to stay distinct.",
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export const RingedTwoTone = tokenShowcase(`
|
|
262
|
+
--display-pill-border-width: 0.1rem;
|
|
263
|
+
--display-pill-border-colour: currentColor;
|
|
264
|
+
--display-pill-ring-width: 0.2rem;
|
|
265
|
+
`, ringArgTokens);
|
|
266
|
+
RingedTwoTone.storyName = "Ringed (dark border, light ring, per variant)";
|
|
267
|
+
RingedTwoTone.args = { ring: false };
|
|
268
|
+
RingedTwoTone.argTypes = ringArgTypes;
|
|
269
|
+
RingedTwoTone.parameters = {
|
|
270
|
+
docs: {
|
|
271
|
+
description: {
|
|
272
|
+
story:
|
|
273
|
+
"A 1px border in the variant's dark text colour (--display-pill-border-colour: currentColor) inside a 2px ring in the variant's light background colour " +
|
|
274
|
+
"(--display-pill-ring-width: 0.2rem; --display-pill-ring-colour defaults to the pill's background). The neutral pill is inverted, so its border is light and its ring dark. " +
|
|
275
|
+
"The ring control (a CSS token, not a prop) swaps the ring to each variant's -02 step, a shade darker than the fill, so it reads as a separate ring. " +
|
|
276
|
+
"The ring is a box-shadow, so it takes no layout space: leave room around ringed pills with gap or margin.",
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const longGerman = "Haarverlängerungsberatungstermin";
|
|
282
|
+
|
|
283
|
+
type StressCase = {
|
|
284
|
+
key: string;
|
|
285
|
+
caption: string;
|
|
286
|
+
label?: string;
|
|
287
|
+
slotHtml?: string;
|
|
288
|
+
icon?: string;
|
|
289
|
+
variant?: string;
|
|
290
|
+
size?: "sm" | "md" | "lg";
|
|
291
|
+
tag?: "span" | "button" | "a";
|
|
292
|
+
reversed?: boolean;
|
|
293
|
+
dir?: "rtl";
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const stressCases: StressCase[] = [
|
|
297
|
+
{ key: "german", caption: "Long German words", label: `${longGerman} und ${longGerman}`, icon: "material-symbols:schedule-outline" },
|
|
298
|
+
{ key: "url", caption: "Unbroken URL", label: "https://example.com/a/very/long/path/that/never/breaks/anywhere/at/all" },
|
|
299
|
+
{ key: "price", caption: "Long price (reversed)", label: "Ab 1.250,00 € pro Sitzung", icon: "material-symbols:sell-outline", reversed: true },
|
|
300
|
+
{ key: "emoji", caption: "Emoji first", label: "👩👩👧👦🇬🇧🇫🇷 Family plan", variant: "primary" },
|
|
301
|
+
{ key: "html", caption: "HTML-like text", label: "<script>alert('xss')</script>", variant: "danger" },
|
|
302
|
+
{ key: "rtl", caption: "Right-to-left", label: "تسريحات شعر جديدة لموسم الخريف", icon: "material-symbols:check-circle-outline", dir: "rtl", variant: "success" },
|
|
303
|
+
{ key: "accents", caption: "Descenders and accents", label: "Ägypten · gypsy · ÇĄĘ · Ýjqpg", variant: "warning", size: "lg" },
|
|
304
|
+
{ key: "single", caption: "Single character", label: "A", variant: "neutral" },
|
|
305
|
+
{ key: "empty", caption: "Empty label (renders nothing)", label: "" },
|
|
306
|
+
{ key: "blank", caption: "Whitespace label + icon (icon-only)", label: " ", icon: "material-symbols:star-outline", variant: "primary" },
|
|
307
|
+
{ key: "brokenIcon", caption: "Broken icon name", label: "Missing icon", icon: "material-symbols:this-icon-does-not-exist" },
|
|
308
|
+
{ key: "slot", caption: "Long default slot", slotHtml: `<strong>Pro</strong> plan with ${longGerman}`, icon: "material-symbols:star", variant: "primary" },
|
|
309
|
+
{ key: "invalidVariant", caption: "Unknown variant from a CMS", label: "variant: info", variant: "info" },
|
|
310
|
+
{ key: "button", caption: "Button, small", label: `Filter: ${longGerman}`, tag: "button", size: "sm" },
|
|
311
|
+
{ key: "link", caption: "Link, large", label: `${longGerman}${longGerman}`, tag: "a", size: "lg", variant: "neutral" },
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
const stressWidths = ["32rem", "16rem", "8rem"];
|
|
315
|
+
|
|
316
|
+
export const StressTest: StoryFn<StoryArgs> = (args) => ({
|
|
317
|
+
components: { StorybookComponent },
|
|
318
|
+
setup() {
|
|
319
|
+
return { stressCases, stressWidths, tokenStyles: tokenStyles(args) };
|
|
320
|
+
},
|
|
321
|
+
template: `
|
|
322
|
+
<div :style="tokenStyles" style="display: grid; gap: 2.4rem; padding: 3.2rem 2rem; font: 1.2rem sans-serif;">
|
|
323
|
+
<div style="display: grid; grid-template-columns: 16rem repeat(3, max-content); gap: 1.2rem 2.4rem; align-items: start;">
|
|
324
|
+
<span></span>
|
|
325
|
+
<strong v-for="width in stressWidths" :key="width">{{ width }} wide</strong>
|
|
326
|
+
<template v-for="item in stressCases" :key="item.key">
|
|
327
|
+
<span style="overflow-wrap: anywhere;">{{ item.caption }}</span>
|
|
328
|
+
<div
|
|
329
|
+
v-for="width in stressWidths"
|
|
330
|
+
:key="width"
|
|
331
|
+
:dir="item.dir"
|
|
332
|
+
:style="{ inlineSize: width, outline: '1px dashed currentColor', padding: '0.4rem' }"
|
|
333
|
+
>
|
|
334
|
+
<StorybookComponent
|
|
335
|
+
:label="item.label"
|
|
336
|
+
:variant="item.variant"
|
|
337
|
+
:size="item.size ?? 'md'"
|
|
338
|
+
:tag="item.tag ?? 'span'"
|
|
339
|
+
:href="item.tag === 'a' ? '#' : undefined"
|
|
340
|
+
:reversed="item.reversed ?? false"
|
|
341
|
+
>
|
|
342
|
+
<template v-if="item.icon" #icon><Icon :name="item.icon" /></template>
|
|
343
|
+
<template v-if="item.slotHtml" #default><span v-html="item.slotHtml"></span></template>
|
|
344
|
+
</StorybookComponent>
|
|
345
|
+
</div>
|
|
346
|
+
</template>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
`,
|
|
350
|
+
});
|
|
351
|
+
StressTest.storyName = "Stress Test (Worst-Case Data)";
|
|
352
|
+
StressTest.parameters = {
|
|
353
|
+
controls: { include: ["labelLineClamp"] },
|
|
354
|
+
docs: {
|
|
355
|
+
description: {
|
|
356
|
+
story:
|
|
357
|
+
"Hostile labels at three container widths: long German words, an unbroken URL, a long reversed price, emoji, HTML-like and right-to-left text, " +
|
|
358
|
+
"descenders and accents, a single character, empty and whitespace-only labels, a broken icon name, a long default slot, an unknown variant, " +
|
|
359
|
+
"and small button and large link pills. Check that no pill grows past its dashed box, long text ends in an ellipsis on one line, " +
|
|
360
|
+
"descenders and accents aren't clipped, the empty label renders no pill, the whitespace label becomes an icon-only pill, " +
|
|
361
|
+
"the unknown variant falls back to the default colours, and the icon never shrinks. " +
|
|
362
|
+
"Set the labelLineClamp control (a CSS token, not a prop) to none to see long labels wrap instead.",
|
|
139
363
|
},
|
|
140
364
|
},
|
|
141
365
|
};
|
|
@@ -64,14 +64,32 @@ describe("DisplayPill", () => {
|
|
|
64
64
|
expect(wrapper.find(".display-pill-label").text()).toBe("Active");
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
it("renders default slot when no label prop is set", async () => {
|
|
67
|
+
it("renders default slot inside .display-pill-label when no label prop is set", async () => {
|
|
68
68
|
const wrapper = await mountSuspended(DisplayPill, {
|
|
69
69
|
slots: { default: "<strong class='custom'>Custom</strong>" },
|
|
70
70
|
});
|
|
71
|
-
expect(wrapper.find(".custom").exists()).toBe(true);
|
|
71
|
+
expect(wrapper.find(".display-pill-label .custom").exists()).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("treats a whitespace-only label as no label and falls back to the default slot", async () => {
|
|
75
|
+
const wrapper = await mountSuspended(DisplayPill, {
|
|
76
|
+
props: { label: " " },
|
|
77
|
+
slots: { default: "<strong class='custom'>Custom</strong>" },
|
|
78
|
+
});
|
|
79
|
+
expect(wrapper.find(".display-pill-label .custom").exists()).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it.each(["", " "])("renders no label element for label=%j with no default slot", async (label) => {
|
|
83
|
+
const wrapper = await mountSuspended(DisplayPill, { props: { label } });
|
|
72
84
|
expect(wrapper.find(".display-pill-label").exists()).toBe(false);
|
|
73
85
|
});
|
|
74
86
|
|
|
87
|
+
it("renders a label with HTML-like text as text, not markup", async () => {
|
|
88
|
+
const wrapper = await mountSuspended(DisplayPill, { props: { label: "<b>bold</b>" } });
|
|
89
|
+
expect(wrapper.find(".display-pill-label").text()).toBe("<b>bold</b>");
|
|
90
|
+
expect(wrapper.find("b").exists()).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
75
93
|
it("does not render default slot when label prop is set", async () => {
|
|
76
94
|
const wrapper = await mountSuspended(DisplayPill, {
|
|
77
95
|
props: { label: "Active" },
|
|
@@ -92,43 +110,46 @@ describe("DisplayPill", () => {
|
|
|
92
110
|
|
|
93
111
|
// ─── Size ─────────────────────────────────────────────────────────────────
|
|
94
112
|
|
|
95
|
-
it("
|
|
113
|
+
it("sets data-size='md' by default", async () => {
|
|
96
114
|
const wrapper = await mountSuspended(DisplayPill);
|
|
97
|
-
expect(wrapper.
|
|
115
|
+
expect(wrapper.attributes("data-size")).toBe("md");
|
|
98
116
|
});
|
|
99
117
|
|
|
100
|
-
it.each(["sm", "md", "lg"] as const)("
|
|
118
|
+
it.each(["sm", "md", "lg"] as const)("sets data-size='%s' when size='%s'", async (size) => {
|
|
101
119
|
const wrapper = await mountSuspended(DisplayPill, { props: { size } });
|
|
102
|
-
expect(wrapper.
|
|
120
|
+
expect(wrapper.attributes("data-size")).toBe(size);
|
|
121
|
+
expect(wrapper.classes()).not.toContain(size);
|
|
103
122
|
});
|
|
104
123
|
|
|
105
124
|
// ─── Variant ──────────────────────────────────────────────────────────────
|
|
106
125
|
|
|
107
|
-
it("
|
|
126
|
+
it("sets data-variant='default' by default", async () => {
|
|
108
127
|
const wrapper = await mountSuspended(DisplayPill);
|
|
109
|
-
expect(wrapper.
|
|
128
|
+
expect(wrapper.attributes("data-variant")).toBe("default");
|
|
110
129
|
});
|
|
111
130
|
|
|
112
131
|
it.each(["default", "primary", "success", "warning", "danger", "neutral"] as const)(
|
|
113
|
-
"
|
|
132
|
+
"sets data-variant='%s' when variant='%s'",
|
|
114
133
|
async (variant) => {
|
|
115
134
|
const wrapper = await mountSuspended(DisplayPill, { props: { variant } });
|
|
116
|
-
expect(wrapper.
|
|
135
|
+
expect(wrapper.attributes("data-variant")).toBe(variant);
|
|
136
|
+
expect(wrapper.classes()).not.toContain(variant);
|
|
117
137
|
}
|
|
118
138
|
);
|
|
119
139
|
|
|
120
140
|
// ─── Reversed ─────────────────────────────────────────────────────────────
|
|
121
141
|
|
|
122
|
-
it("does not
|
|
142
|
+
it("does not set data-reversed by default", async () => {
|
|
123
143
|
const wrapper = await mountSuspended(DisplayPill);
|
|
124
|
-
expect(wrapper.
|
|
144
|
+
expect(wrapper.attributes("data-reversed")).toBeUndefined();
|
|
125
145
|
});
|
|
126
146
|
|
|
127
|
-
it("
|
|
147
|
+
it("sets data-reversed when reversed=true", async () => {
|
|
128
148
|
const wrapper = await mountSuspended(DisplayPill, {
|
|
129
149
|
props: { reversed: true },
|
|
130
150
|
});
|
|
131
|
-
expect(wrapper.
|
|
151
|
+
expect(wrapper.attributes("data-reversed")).toBeDefined();
|
|
152
|
+
expect(wrapper.classes()).not.toContain("is-reversed");
|
|
132
153
|
});
|
|
133
154
|
|
|
134
155
|
// ─── styleClassPassthrough ────────────────────────────────────────────────
|
|
@@ -219,6 +240,14 @@ describe("DisplayPill", () => {
|
|
|
219
240
|
expect(wrapper.attributes("data-icon-position")).toBe("only");
|
|
220
241
|
});
|
|
221
242
|
|
|
243
|
+
it("sets data-icon-position='only' when the label is whitespace", async () => {
|
|
244
|
+
const wrapper = await mountSuspended(DisplayPill, {
|
|
245
|
+
props: { label: " " },
|
|
246
|
+
slots: { icon: "<span>●</span>" },
|
|
247
|
+
});
|
|
248
|
+
expect(wrapper.attributes("data-icon-position")).toBe("only");
|
|
249
|
+
});
|
|
250
|
+
|
|
222
251
|
it("treats default slot text as a label for icon position", async () => {
|
|
223
252
|
const wrapper = await mountSuspended(DisplayPill, {
|
|
224
253
|
slots: { icon: "<span>●</span>", default: "Pro plan" },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
2
|
|
|
3
|
-
exports[`DisplayPill > renders correct HTML structure (default) 1`] = `"<span class="display-pill md default"><!--v-if--><span class="display-pill-label">Status</span></span>"`;
|
|
3
|
+
exports[`DisplayPill > renders correct HTML structure (default) 1`] = `"<span class="display-pill" data-size="md" data-variant="default"><!--v-if--><span class="display-pill-label">Status</span></span>"`;
|
|
4
4
|
|
|
5
|
-
exports[`DisplayPill > renders correct HTML structure (reversed) 1`] = `"<span class="display-pill md default
|
|
5
|
+
exports[`DisplayPill > renders correct HTML structure (reversed) 1`] = `"<span class="display-pill" data-size="md" data-variant="default" data-reversed="true" data-icon-position="end"><span class="display-pill-icon"><span class="icon">✓</span></span><span class="display-pill-label">Status</span></span>"`;
|
|
6
6
|
|
|
7
|
-
exports[`DisplayPill > renders correct HTML structure (with icon slot) 1`] = `"<span class="display-pill lg success" data-icon-position="start"><span class="display-pill-icon"><span class="icon">✓</span></span><span class="display-pill-label">Status</span></span>"`;
|
|
7
|
+
exports[`DisplayPill > renders correct HTML structure (with icon slot) 1`] = `"<span class="display-pill" data-size="lg" data-variant="success" data-icon-position="start"><span class="display-pill-icon"><span class="icon">✓</span></span><span class="display-pill-label">Status</span></span>"`;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# TabbedContent — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
> **Renamed 2026-10-07**: `TabsCore` → `TabbedContent`. Root class `.tabs-core` → `.tabbed-content`,
|
|
4
|
+
> and every inner class moved under the component prefix (see State hooks). Token names are
|
|
5
|
+
> unchanged (`--tabs-*`).
|
|
6
|
+
|
|
7
|
+
## Public token API
|
|
8
|
+
|
|
9
|
+
Colour defaults come from the `--theme-*` slots, so the component follows the site theme out of the
|
|
10
|
+
box.
|
|
11
|
+
|
|
12
|
+
> **Changed 2026-10-07**: colour defaults moved from fixed `--slate-*` steps to `--theme-*` slots.
|
|
13
|
+
> The content panel used to default to a dark `--slate-09` background with no text colour, so
|
|
14
|
+
> consumer text rendered dark on dark; it's now a light `--theme-surface-subtle` panel with
|
|
15
|
+
> `--theme-text`. The active highlight is `--theme-surface` (bold) with `--theme-on-surface` text,
|
|
16
|
+
> the underline is `--theme-accent`. To keep the old look, set the tokens below to the previous
|
|
17
|
+
> `--slate-*` values.
|
|
18
|
+
|
|
19
|
+
### Tab row
|
|
20
|
+
|
|
21
|
+
| Token | Default | Controls |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `--tabs-nav-border` | `0.1rem solid color-mix(in oklch, var(--theme-text) 20%, transparent)` | Border between the tab row and the panels (bottom on `axis="x"`, left on `axis="y"`) |
|
|
24
|
+
| `--tabs-axis-y-gap` | `2em` | Gap between the tab column and the panels on `axis="y"` |
|
|
25
|
+
| `--tabs-axis-y-list-max-inline-size` | `50%` | Widest the tab column may get on `axis="y"`; long labels wrap inside it |
|
|
26
|
+
|
|
27
|
+
### Tab triggers
|
|
28
|
+
|
|
29
|
+
| Token | Default | Controls |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `--tabs-list-item-colour` | `var(--theme-text)` | Text colour of an inactive trigger |
|
|
32
|
+
| `--tabs-list-item-opacity` | `0.7` | Opacity of an inactive trigger |
|
|
33
|
+
| `--tabs-list-item-colour-selected` | falls back to `--tabs-active-indicator-text-colour` | Text colour of the active trigger |
|
|
34
|
+
| `--tabs-list-item-text-transform` | `uppercase` | `text-transform` of trigger labels |
|
|
35
|
+
| `--tabs-list-item-font-weight` | `500` | Font weight of trigger labels |
|
|
36
|
+
| `--tabs-list-item-padding-block` | `1em` | Vertical padding of a trigger |
|
|
37
|
+
| `--tabs-list-item-padding-inline` | `2em` | Horizontal padding of a trigger |
|
|
38
|
+
| `--tabs-list-item-max-inline-size` | `24rem` | Widest a trigger may get on `axis="x"`; longer labels wrap inside it |
|
|
39
|
+
| `--tabs-list-item-colour-transition-duration` | `100ms` | Transition of the trigger text colour |
|
|
40
|
+
| `--tabs-list-item-focus-outline-offset` | `-0.2rem` | Focus outline offset on a trigger |
|
|
41
|
+
| `--tabs-focus-ring-width` | `0.2rem` | Focus outline width on triggers and panels |
|
|
42
|
+
| `--tabs-focus-ring-colour` | `var(--theme-border-focus)` | Focus outline colour on triggers and panels |
|
|
43
|
+
|
|
44
|
+
> **Changed 2026-10-07**: the focus outline used `--theme-ring`, the lightest theme step, which
|
|
45
|
+
> was close to invisible on a light page. It now defaults to `--theme-border-focus` behind the new
|
|
46
|
+
> `--tabs-focus-ring-colour` token. `--tabs-list-item-focus-outline-width` is replaced by
|
|
47
|
+
> `--tabs-focus-ring-width`.
|
|
48
|
+
|
|
49
|
+
### Moving indicators
|
|
50
|
+
|
|
51
|
+
| Token | Default | Controls |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `--tabs-hover-indicator-colour` | `var(--theme-surface-subtle)` | Background of the hover highlight (`trackHover`) |
|
|
54
|
+
| `--tabs-hover-indicator-text-colour` | `var(--theme-text)` | Text colour of a hovered trigger; keep it in contrast with the hover background |
|
|
55
|
+
| `--tabs-active-indicator-colour` | `var(--theme-surface)` | Background of the active highlight (`trackActive`) |
|
|
56
|
+
| `--tabs-active-indicator-text-colour` | `var(--theme-on-surface)` | Text colour of the active trigger; keep it in contrast with the active background |
|
|
57
|
+
| `--tabs-underline-indicator-colour` | `var(--theme-accent)` | Colour of the underline/sideline (`trackIndicator`) |
|
|
58
|
+
| `--tabs-underline-indicator-height` | `0.4rem` | Thickness of the underline (`axis="x"`), also its width on `axis="y"` |
|
|
59
|
+
| `--tabs-hover-underline-indicator-colour` | `--tabs-underline-indicator-colour` | Colour of the thin underline/sideline that follows the hovered tab (`trackHover` and `trackIndicator`) |
|
|
60
|
+
| `--tabs-hover-underline-indicator-height` | `0.1rem` | Its thickness; `0` hides it. Sits under the active underline where they overlap |
|
|
61
|
+
|
|
62
|
+
### More menu (`overflowMode="menu"`, `axis="x"`)
|
|
63
|
+
|
|
64
|
+
| Token | Default | Controls |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `--tabs-more-margin-inline-start` | `auto` | Pushes the More button to the end of the tab row; `0` keeps it straight after the last visible tab |
|
|
67
|
+
| `--tabs-more-trigger-padding-inline` | `1.2em` | Horizontal padding of the More button (vertical padding follows `--tabs-list-item-padding-block`); its colours follow the trigger tokens |
|
|
68
|
+
| `--tabs-more-icon-size` | `1.25em` | More button icon size (the `⋯` state) |
|
|
69
|
+
| `--tabs-more-chevron-size` | `1em` | Chevron after the active tab's label on the More button (flips while the menu is open) |
|
|
70
|
+
| `--tabs-more-popover-surface` | `var(--page-bg, var(--theme-surface-subtle))` | Menu background |
|
|
71
|
+
| `--tabs-more-popover-text-colour` | `var(--theme-text)` | Menu text colour |
|
|
72
|
+
| `--tabs-more-popover-border` | `0.1rem solid color-mix(in oklch, var(--theme-text) 20%, transparent)` | Menu border (shorthand) |
|
|
73
|
+
| `--tabs-more-popover-border-radius` | `0.6rem` | Menu corner radius |
|
|
74
|
+
| `--tabs-more-popover-shadow` | `0 0.4rem 1.6rem rgb(0 0 0 / 12%)` | Menu shadow |
|
|
75
|
+
| `--tabs-more-popover-min-inline-size` | `16rem` | Narrowest the menu gets (widest is `--tabs-list-item-max-inline-size`, capped to the viewport) |
|
|
76
|
+
| `--tabs-more-popover-max-block-size` | `70vh` | Tallest the menu gets before it scrolls |
|
|
77
|
+
| `--tabs-more-popover-distance` | `0.4rem` | Gap between the More button and the menu |
|
|
78
|
+
| `--tabs-more-popover-transition-duration` | `200ms` | Menu fade |
|
|
79
|
+
| `--tabs-more-popover-z-index` | `999999` | Stacking where the menu can't use the top layer (Safari 16) |
|
|
80
|
+
| `--tabs-more-item-padding-block` / `-inline` | `0.8em` / `1.2em` | Menu row padding |
|
|
81
|
+
| `--tabs-more-item-surface-hover` | `color-mix(in oklch, var(--theme-text) 8%, transparent)` | Menu row background on hover/focus |
|
|
82
|
+
| `--tabs-more-item-colour-selected` | `var(--theme-accent)` | Text colour of the menu row for the active tab |
|
|
83
|
+
|
|
84
|
+
### Panels
|
|
85
|
+
|
|
86
|
+
| Token | Default | Controls |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `--tabs-content-background-colour` | `var(--theme-surface-subtle)` | Panel background |
|
|
89
|
+
| `--tabs-content-text-colour` | `var(--theme-text)` | Panel text colour |
|
|
90
|
+
| `--tabs-content-border-width` | `0.1rem` | Panel border width |
|
|
91
|
+
| `--tabs-content-border-colour` | `color-mix(in oklch, var(--theme-text) 20%, transparent)` | Panel border colour |
|
|
92
|
+
| `--tabs-content-border-radius` | `0` | Panel corner radius |
|
|
93
|
+
|
|
94
|
+
### Private tokens
|
|
95
|
+
|
|
96
|
+
`--_focus-ring` (composed from the two focus tokens), and the runtime values `useTabs` writes from
|
|
97
|
+
measured layout, per indicator (`active` / `hovered`): `--_{kind}-start` / `--_{kind}-end` (edge
|
|
98
|
+
insets from the bar's start and end along the axis), `--_{kind}-duration`,
|
|
99
|
+
`--_{kind}-start-delay` / `--_{kind}-end-delay`; each indicator reads them through its own
|
|
100
|
+
`--_start` / `--_end` / `--_duration` / `--_start-delay` / `--_end-delay`. Not public API; use the
|
|
101
|
+
`transitionDuration` prop for indicator speed (one edge moves for that long, then the other).
|
|
102
|
+
|
|
103
|
+
> **Changed 2026-10-08**: indicators were a bar-wide box moved with `translate` and sized with
|
|
104
|
+
> `scale` (`--_x-*`, `--_width-*`, `--_y-*`, a shared `--_transition-duration`), with a timer
|
|
105
|
+
> to "settle" after a stretch. Rightward moves changed both properties at once and could visibly
|
|
106
|
+
> step backwards. Each edge is now its own `left`/`right` (`top`/`bottom` on `axis="y"`) inset:
|
|
107
|
+
> the edge in the direction of travel moves first and the trailing edge follows after one
|
|
108
|
+
> duration, so the movement is the same both ways, with no timer.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## State hooks
|
|
113
|
+
|
|
114
|
+
| Hook | Where | Meaning |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| `.axis-x` / `.axis-y` | root `.tabbed-content` | The `axis` prop |
|
|
117
|
+
| `.overflow-menu` / `.overflow-scroll` | root | The `overflowMode` prop |
|
|
118
|
+
| `.tabbed-content-bar` | wraps the tablist, the More menu and the indicators | Carries `--tabs-nav-border`; scrolls sideways with `overflowMode="scroll"` |
|
|
119
|
+
| `.tabbed-content-list` | `role="tablist"` | The tab row/column |
|
|
120
|
+
| `.tabbed-content-trigger[hidden]` | trigger | Collapsed into the More menu |
|
|
121
|
+
| `.tabbed-content-more` / `.is-idle` | More menu wrapper | Idle (measured but invisible) while every tab fits |
|
|
122
|
+
| `.tabbed-content-more-trigger` / `.is-active` | More button | `.is-active` while the active tab is one of the collapsed ones: the button shows that tab's label (`.tabbed-content-more-label`) and a chevron instead of `⋯`, takes the normal tab padding, and the indicators sit on it |
|
|
123
|
+
| `.tabbed-content-more-popover` | menu popover | Open: `:popover-open`, or `.tabbed-content-more-popover-open` without the Popover API (style the two in separate rules) |
|
|
124
|
+
| `.tabbed-content-more-item[aria-checked="true"]` | menu row | The active tab's row |
|
|
125
|
+
| `.tabbed-content-trigger` | each `role="tab"` button | A tab trigger |
|
|
126
|
+
| `.tabbed-content-trigger[aria-selected="true"]` | trigger | The active tab |
|
|
127
|
+
| `.tracks-active` | root | `trackActive` is on: label colour follows `[data-under-active]` instead of `aria-selected` |
|
|
128
|
+
| `[data-under-active]` | trigger, More button | The active highlight covers at least half of it right now (updated every frame while it slides) |
|
|
129
|
+
| `.tabbed-content-indicator-hover` / `-active` / `-underline` / `-underline-hover` | inside `.tabbed-content-bar` | The moving indicators, rendered only when their `track*` prop is on (`-underline-hover` needs both `trackHover` and `trackIndicator`) |
|
|
130
|
+
| `.tabbed-content-panels` | panel wrapper | Background, border and text colour live here |
|
|
131
|
+
| `.tabbed-content-panel` / `[hidden]` | each `role="tabpanel"` | A panel; inactive ones carry `hidden` |
|
|
132
|
+
|
|
133
|
+
> **Changed 2026-10-07**: `.tabs-list` → `.tabbed-content-list` (now a `<div>`, was a `<ul>` with
|
|
134
|
+
> `<li>`s), `.tabs-list-item` → `.tabbed-content-trigger`, `.nav__hovered` / `.nav__active` /
|
|
135
|
+
> `.nav__active-indicator` → `.tabbed-content-indicator-hover` / `-active` / `-underline`,
|
|
136
|
+
> `.tab-content-wrapper` → `.tabbed-content-panels`, `.tab-content` → `.tabbed-content-panel`.
|
|
137
|
+
> Inactive panels use the `hidden` attribute instead of inline `display: none` and `aria-hidden`.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Tab-label text colour tracks the indicator behind it
|
|
142
|
+
|
|
143
|
+
Hover and active can have different backgrounds, so the trigger label has a text-colour token per
|
|
144
|
+
state. If you override either indicator background, override its matching text-colour token too:
|
|
145
|
+
|
|
146
|
+
```css
|
|
147
|
+
.my-page {
|
|
148
|
+
--tabs-hover-indicator-colour: gold;
|
|
149
|
+
--tabs-hover-indicator-text-colour: black;
|
|
150
|
+
--tabs-active-indicator-colour: navy;
|
|
151
|
+
--tabs-active-indicator-text-colour: white;
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
With `trackActive` on, label colour follows what's actually behind each tab while the active
|
|
156
|
+
highlight slides: a tab it covers by at least half (`[data-under-active]`) takes the active text
|
|
157
|
+
colour, and the newly selected tab keeps its resting colour until the highlight reaches it. So a
|
|
158
|
+
jump from tab 1 to tab 5 never puts dark text on the dark highlight, or light text on the light
|
|
159
|
+
row. With `trackActive` off there's no moving highlight, and `aria-selected` sets the colour as
|
|
160
|
+
before.
|
|
161
|
+
|
|
162
|
+
> **Changed 2026-10-08**: passed-over labels used to keep their resting colour while the active
|
|
163
|
+
> highlight slid under them (dark on dark), and the selected tab turned light before the
|
|
164
|
+
> highlight arrived (light on light).
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Indicator visibility is prop-driven
|
|
169
|
+
|
|
170
|
+
Whether each indicator renders is set by `trackHover` / `trackActive` / `trackIndicator`, not
|
|
171
|
+
tokens. They can change after mount.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Local overrides
|
|
176
|
+
|
|
177
|
+
Set the tokens above on an element you own (a page or section class, or a class added with
|
|
178
|
+
`:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
|
|
179
|
+
`@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
|
|
180
|
+
`<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
|
|
181
|
+
`:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Class passthrough
|
|
186
|
+
|
|
187
|
+
`style-class-passthrough` classes land on the root `.tabbed-content`, so tokens set on that class
|
|
188
|
+
reach every part of the component.
|
|
189
|
+
|
|
190
|
+
> **Changed 2026-10-07**: passthrough classes used to land on the tab list, not the root, so tokens
|
|
191
|
+
> set on them never reached the panels.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Notes
|
|
196
|
+
|
|
197
|
+
- **Dark mode**: defaults are light-only, like the rest of the library. Override the `--theme-*`
|
|
198
|
+
slots (or these tokens) in your own dark scheme; `light-dark()` in your own override values is
|
|
199
|
+
fine, it's only kept out of the library's defaults.
|
|
200
|
+
- **Right-to-left**: indicators are positioned from measured `offsetLeft`, so they line up in RTL,
|
|
201
|
+
but arrow keys aren't mirrored (ArrowRight always moves to the next tab in DOM order).
|