srcdev-nuxt-components 9.6.0 → 9.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/.claude/skills/component-compliance-checklist.md +8 -2
  2. package/.claude/skills/component-dynamic-slots.md +3 -3
  3. package/.claude/skills/component-export-types.md +10 -16
  4. package/.claude/skills/component-naming.md +1 -1
  5. package/.claude/skills/components/alert-content.md +9 -1
  6. package/.claude/skills/components/container-glow.md +21 -8
  7. package/.claude/skills/components/display-avatar.md +13 -7
  8. package/.claude/skills/components/display-chip.md +37 -24
  9. package/.claude/skills/components/display-pill.md +58 -10
  10. package/.claude/skills/components/display-prompt.md +8 -5
  11. package/.claude/skills/components/display-toast.md +21 -4
  12. package/.claude/skills/components/glowing-border.md +20 -2
  13. package/.claude/skills/components/responsive-header.md +11 -1
  14. package/.claude/skills/components/select-menu.md +54 -5
  15. package/.claude/skills/components/site-header.md +2 -2
  16. package/.claude/skills/components/tabbed-content.md +300 -0
  17. package/.claude/skills/composable-anchor-scroll.md +16 -15
  18. package/.claude/skills/index.md +4 -4
  19. package/.claude/skills/storybook-add-story.md +53 -0
  20. package/.claude/skills/theming-status-tokens.md +60 -0
  21. package/.vscode/srcdev-component-display-avatar.code-snippets +2 -1
  22. package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
  23. package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
  24. package/.vscode/srcdev-component-responsive-header.code-snippets +6 -0
  25. package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
  26. package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
  27. package/app/assets/styles/setup/03.theming/_status.css +24 -0
  28. package/app/assets/styles/setup/03.theming/index.css +1 -0
  29. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
  30. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
  31. package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
  32. package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
  33. package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
  34. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
  35. package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
  36. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
  37. package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
  38. package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
  39. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +75 -9
  40. package/app/components/01.atoms/display-pill/DisplayPill.vue +57 -24
  41. package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
  42. package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
  43. package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
  44. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
  45. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
  46. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
  47. package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
  48. package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
  49. package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
  50. package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
  51. package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
  52. package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
  53. package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +7 -5
  54. package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +6 -2
  55. package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +24 -3
  56. package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +19 -0
  57. package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +2 -1
  58. package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +52 -19
  59. package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
  60. package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
  61. package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
  62. package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
  63. package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
  64. package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
  65. package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
  66. package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
  67. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
  68. package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
  69. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
  70. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
  71. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
  72. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
  73. package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
  74. package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
  75. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
  76. package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
  77. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
  78. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
  79. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +38 -11
  80. package/app/components/03.organisms/responsive-header/NavigationItems.vue +27 -12
  81. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +35 -20
  82. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +1 -1
  83. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +1 -1
  84. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +1 -1
  85. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +3 -3
  86. package/app/composables/useTabs.ts +148 -206
  87. package/app/types/components/display-chip.d.ts +8 -4
  88. package/app/types/components/display-toast.d.ts +2 -0
  89. package/app/types/components/index.ts +0 -1
  90. package/app/types/components/select-menu.d.ts +2 -0
  91. package/app/utils/pausableTimeout.ts +41 -0
  92. package/nuxt.config.ts +1 -1
  93. package/package.json +1 -1
  94. package/.claude/skills/components/tab-navigation.md +0 -288
  95. package/.claude/skills/components/tabs-core.md +0 -187
  96. package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
  97. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
  98. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
  99. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
  100. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
  101. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -191
  102. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -550
  103. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -259
  104. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -379
  105. package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
  106. package/app/composables/useNavCollapse.ts +0 -86
  107. package/app/types/components/nav-item.d.ts +0 -11
@@ -1,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
- export const OutlinedViaBaseTokens: StoryFn<StoryArgs> = (args) => ({
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
- style="
123
- display: flex; align-items: center; justify-content: center; min-height: 100vh; gap: 1.6rem; flex-wrap: wrap;
124
- --display-pill-background: transparent;
125
- --display-pill-text-colour: #1e293b;
126
- --display-pill-border-colour: currentColor;
127
- "
128
- >
129
- <StorybookComponent v-for="variant in variants" :key="variant" :label="variant" :size="args.size" :variant="variant" />
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
- "Setting only the base --display-pill-background/-text-colour/-border-colour on an ancestor restyles every variant. A variant-specific token (e.g. --display-pill-success-background) would still win for that variant.",
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("applies md size class by default", async () => {
113
+ it("sets data-size='md' by default", async () => {
96
114
  const wrapper = await mountSuspended(DisplayPill);
97
- expect(wrapper.classes()).toContain("md");
115
+ expect(wrapper.attributes("data-size")).toBe("md");
98
116
  });
99
117
 
100
- it.each(["sm", "md", "lg"] as const)("applies %s size class when size='%s'", async (size) => {
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.classes()).toContain(size);
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("applies default variant class by default", async () => {
126
+ it("sets data-variant='default' by default", async () => {
108
127
  const wrapper = await mountSuspended(DisplayPill);
109
- expect(wrapper.classes()).toContain("default");
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
- "applies %s variant class when variant='%s'",
132
+ "sets data-variant='%s' when variant='%s'",
114
133
  async (variant) => {
115
134
  const wrapper = await mountSuspended(DisplayPill, { props: { variant } });
116
- expect(wrapper.classes()).toContain(variant);
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 apply is-reversed class by default", async () => {
142
+ it("does not set data-reversed by default", async () => {
123
143
  const wrapper = await mountSuspended(DisplayPill);
124
- expect(wrapper.classes()).not.toContain("is-reversed");
144
+ expect(wrapper.attributes("data-reversed")).toBeUndefined();
125
145
  });
126
146
 
127
- it("applies is-reversed class when reversed=true", async () => {
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.classes()).toContain("is-reversed");
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 is-reversed" data-icon-position="end"><span class="display-pill-icon"><span class="icon">✓</span></span><span class="display-pill-label">Status</span></span>"`;
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).