srcdev-nuxt-components 9.5.7 → 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 (159) 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/breadcrumb.md +5 -0
  7. package/.claude/skills/components/container-glow.md +21 -8
  8. package/.claude/skills/components/display-avatar.md +13 -7
  9. package/.claude/skills/components/display-chip.md +37 -24
  10. package/.claude/skills/components/display-pill.md +58 -8
  11. package/.claude/skills/components/display-prompt.md +8 -5
  12. package/.claude/skills/components/display-toast.md +21 -4
  13. package/.claude/skills/components/glowing-border.md +20 -2
  14. package/.claude/skills/components/input-button.md +3 -0
  15. package/.claude/skills/components/responsive-header.md +59 -0
  16. package/.claude/skills/components/select-menu.md +54 -5
  17. package/.claude/skills/components/service-detail.md +32 -0
  18. package/.claude/skills/components/service-summary.md +8 -0
  19. package/.claude/skills/components/services-card-grid.md +5 -3
  20. package/.claude/skills/components/services-card.md +22 -12
  21. package/.claude/skills/components/site-header.md +2 -2
  22. package/.claude/skills/components/slider-gallery.md +10 -4
  23. package/.claude/skills/components/tabbed-content.md +300 -0
  24. package/.claude/skills/composable-anchor-scroll.md +16 -15
  25. package/.claude/skills/index.md +4 -5
  26. package/.claude/skills/storybook-add-story.md +53 -0
  27. package/.claude/skills/theming-status-tokens.md +60 -0
  28. package/.vscode/srcdev-component-display-avatar.code-snippets +2 -1
  29. package/.vscode/srcdev-component-display-chip.code-snippets +3 -2
  30. package/.vscode/srcdev-component-display-pill.code-snippets +10 -0
  31. package/.vscode/srcdev-component-responsive-header.code-snippets +16 -0
  32. package/.vscode/srcdev-component-select-menu.code-snippets +28 -0
  33. package/.vscode/srcdev-component-service-detail.code-snippets +1 -0
  34. package/.vscode/srcdev-component-service-summary.code-snippets +3 -1
  35. package/.vscode/srcdev-component-services-card.code-snippets +38 -36
  36. package/.vscode/srcdev-component-services-grids.code-snippets +2 -1
  37. package/.vscode/{srcdev-component-tabs-core.code-snippets → srcdev-component-tabbed-content.code-snippets} +35 -16
  38. package/app/assets/styles/setup/03.theming/_status.css +24 -0
  39. package/app/assets/styles/setup/03.theming/index.css +1 -0
  40. package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +39 -35
  41. package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +25 -15
  42. package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +130 -24
  43. package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +45 -0
  44. package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +2 -2
  45. package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +76 -19
  46. package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +12 -10
  47. package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +201 -42
  48. package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +15 -4
  49. package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +1 -1
  50. package/app/components/01.atoms/display-pill/CONSUMER-STYLING.md +78 -9
  51. package/app/components/01.atoms/display-pill/DisplayPill.vue +59 -23
  52. package/app/components/01.atoms/display-pill/stories/DisplayPill.stories.ts +240 -16
  53. package/app/components/01.atoms/display-pill/tests/DisplayPill.spec.ts +43 -14
  54. package/app/components/01.atoms/display-pill/tests/__snapshots__/DisplayPill.spec.ts.snap +3 -3
  55. package/app/components/01.atoms/navigation/breadcrumb/Breadcrumb.vue +16 -4
  56. package/app/components/01.atoms/navigation/breadcrumb/CONSUMER-STYLING.md +28 -5
  57. package/app/components/01.atoms/navigation/breadcrumb/stories/Breadcrumb.stories.ts +94 -0
  58. package/app/components/01.atoms/navigation/breadcrumb/tests/Breadcrumb.spec.ts +27 -0
  59. package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +201 -0
  60. package/app/components/01.atoms/tabbed-content/TabbedContent.vue +750 -0
  61. package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +279 -0
  62. package/app/components/01.atoms/tabbed-content/tests/TabbedContent.spec.ts +816 -0
  63. package/app/components/02.molecules/alert-content/AlertContent.vue +11 -2
  64. package/app/components/02.molecules/alert-content/AlertContentInner.vue +19 -0
  65. package/app/components/02.molecules/alert-content/CONSUMER-STYLING.md +17 -4
  66. package/app/components/02.molecules/alert-content/stories/AlertContent.stories.ts +200 -17
  67. package/app/components/02.molecules/alert-content/tests/AlertContent.spec.ts +33 -0
  68. package/app/components/02.molecules/display-avatar/CONSUMER-STYLING.md +7 -5
  69. package/app/components/02.molecules/display-avatar/DisplayAvatar.vue +6 -2
  70. package/app/components/02.molecules/display-avatar/stories/DisplayAvatar.stories.ts +24 -3
  71. package/app/components/02.molecules/display-avatar/tests/DisplayAvatar.spec.ts +19 -0
  72. package/app/components/02.molecules/display-avatar/tests/__snapshots__/DisplayAvatar.spec.ts.snap +2 -1
  73. package/app/components/02.molecules/display-chip/CONSUMER-STYLING.md +52 -19
  74. package/app/components/02.molecules/display-chip/DisplayChip.vue +72 -54
  75. package/app/components/02.molecules/display-chip/stories/DisplayChip.stories.ts +103 -6
  76. package/app/components/02.molecules/display-chip/tests/DisplayChip.spec.ts +98 -29
  77. package/app/components/02.molecules/display-chip/tests/__snapshots__/DisplayChip.spec.ts.snap +8 -4
  78. package/app/components/02.molecules/prompt/CONSUMER-STYLING.md +9 -1
  79. package/app/components/02.molecules/prompt/DisplayPrompt.vue +2 -2
  80. package/app/components/02.molecules/prompt/stories/DisplayPrompt.stories.ts +208 -71
  81. package/app/components/02.molecules/prompt/tests/DisplayPrompt.spec.ts +19 -6
  82. package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +66 -8
  83. package/app/components/02.molecules/select-menu/SelectMenu.vue +141 -18
  84. package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +232 -2
  85. package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +151 -0
  86. package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +6 -2
  87. package/app/components/02.molecules/toast/CONSUMER-STYLING.md +91 -27
  88. package/app/components/02.molecules/toast/DisplayToast.vue +109 -50
  89. package/app/components/02.molecules/toast/DisplayToastProvider.vue +109 -45
  90. package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +23 -0
  91. package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +73 -0
  92. package/app/components/02.molecules/toast/tests/DisplayToast.spec.ts +132 -18
  93. package/app/components/02.molecules/toast/tests/DisplayToastProvider.spec.ts +105 -39
  94. package/app/components/03.organisms/image-galleries/slider-gallery/CONSUMER-STYLING.md +19 -1
  95. package/app/components/03.organisms/image-galleries/slider-gallery/SliderGallery.vue +65 -7
  96. package/app/components/03.organisms/image-galleries/slider-gallery/stories/SliderGallery.stories.ts +201 -63
  97. package/app/components/03.organisms/image-galleries/slider-gallery/tests/SliderGallery.spec.ts +41 -2
  98. package/app/components/03.organisms/image-galleries/slider-gallery/tests/__snapshots__/SliderGallery.spec.ts.snap +6 -6
  99. package/app/components/03.organisms/responsive-header/CONSUMER-STYLING.md +42 -11
  100. package/app/components/03.organisms/responsive-header/NavigationItems.vue +60 -16
  101. package/app/components/03.organisms/responsive-header/ResponsiveHeader.vue +86 -24
  102. package/app/components/03.organisms/responsive-header/stories/ResponsiveHeader.stories.ts +52 -0
  103. package/app/components/03.organisms/responsive-header/tests/NavigationItems.spec.ts +20 -0
  104. package/app/components/03.organisms/responsive-header/tests/ResponsiveHeader.spec.ts +40 -0
  105. package/app/components/03.organisms/services/service-detail/CONSUMER-STYLING.md +92 -41
  106. package/app/components/03.organisms/services/service-detail/ServiceDetail.vue +198 -73
  107. package/app/components/03.organisms/services/service-detail/stories/ServiceDetail.stories.ts +157 -9
  108. package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +58 -0
  109. package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +5 -4
  110. package/app/components/03.organisms/services/service-summary/CONSUMER-STYLING.md +30 -0
  111. package/app/components/03.organisms/services/service-summary/ServiceSummary.vue +32 -8
  112. package/app/components/03.organisms/services/service-summary/stories/ServiceSummary.stories.ts +147 -0
  113. package/app/components/03.organisms/services/service-summary/tests/ServiceSummary.spec.ts +26 -0
  114. package/app/components/03.organisms/services/service-summary/tests/__snapshots__/ServiceSummary.spec.ts.snap +2 -2
  115. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +104 -63
  116. package/app/components/03.organisms/services/services-card/ServicesCard.vue +92 -66
  117. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +194 -27
  118. package/app/components/03.organisms/services/services-card/tests/ServicesCard.spec.ts +34 -8
  119. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +8 -8
  120. package/app/components/03.organisms/services/services-grids/CONSUMER-STYLING.md +9 -0
  121. package/app/components/03.organisms/services/services-grids/ServicesCardGrid.vue +2 -1
  122. package/app/components/03.organisms/services/services-grids/stories/ServiceSummaryGrid.stories.ts +46 -6
  123. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +89 -11
  124. package/app/components/03.organisms/services/services-grids/tests/ServicesCardGrid.spec.ts +8 -0
  125. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServiceSummaryGrid.spec.ts.snap +6 -6
  126. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +24 -24
  127. package/app/components/05.forms/input-button/CONSUMER-STYLING.md +8 -0
  128. package/app/components/05.forms/input-button/InputButton.vue +3 -1
  129. package/app/composables/tests/useAnchorScroll.spec.ts +8 -0
  130. package/app/composables/useAnchorScroll.ts +1 -1
  131. package/app/composables/useTabs.ts +148 -206
  132. package/app/types/components/display-chip.d.ts +8 -4
  133. package/app/types/components/display-toast.d.ts +2 -0
  134. package/app/types/components/index.ts +0 -1
  135. package/app/types/components/select-menu.d.ts +2 -0
  136. package/app/utils/pausableTimeout.ts +41 -0
  137. package/nuxt.config.ts +1 -1
  138. package/package.json +1 -1
  139. package/.claude/skills/components/site-navigation.md +0 -145
  140. package/.claude/skills/components/tab-navigation.md +0 -272
  141. package/.claude/skills/components/tabs-core.md +0 -187
  142. package/.vscode/srcdev-component-site-navigation.code-snippets +0 -38
  143. package/.vscode/srcdev-component-tab-navigation.code-snippets +0 -30
  144. package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +0 -120
  145. package/app/components/01.atoms/navigation/tabs/TabsCore.vue +0 -272
  146. package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +0 -199
  147. package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +0 -274
  148. package/app/components/02.molecules/navigation/site-navigation/CONSUMER-STYLING.md +0 -109
  149. package/app/components/02.molecules/navigation/site-navigation/SiteNavigation.vue +0 -751
  150. package/app/components/02.molecules/navigation/site-navigation/stories/SiteNavigation.stories.ts +0 -356
  151. package/app/components/02.molecules/navigation/site-navigation/tests/SiteNavigation.spec.ts +0 -367
  152. package/app/components/02.molecules/navigation/site-navigation/tests/__snapshots__/SiteNavigation.spec.ts.snap +0 -30
  153. package/app/components/02.molecules/navigation/tab-navigation/CONSUMER-STYLING.md +0 -150
  154. package/app/components/02.molecules/navigation/tab-navigation/TabNavigation.vue +0 -518
  155. package/app/components/02.molecules/navigation/tab-navigation/stories/TabNavigation.stories.ts +0 -340
  156. package/app/components/02.molecules/navigation/tab-navigation/tests/TabNavigation.spec.ts +0 -339
  157. package/app/components/02.molecules/navigation/tab-navigation/tests/__snapshots__/TabNavigation.spec.ts.snap +0 -33
  158. package/app/composables/useNavCollapse.ts +0 -86
  159. package/app/types/components/nav-item.d.ts +0 -11
@@ -2,15 +2,20 @@
2
2
  <component
3
3
  :is="tag"
4
4
  class="display-pill"
5
- :class="[size, variant, { 'is-reversed': reversed }, elementClasses]"
5
+ :class="elementClasses"
6
6
  :type="tag === 'button' ? 'button' : undefined"
7
+ :data-size="size"
8
+ :data-variant="variant"
9
+ :data-reversed="reversed || undefined"
7
10
  :data-icon-position="iconPosition()"
8
11
  >
9
12
  <span v-if="slots.icon" class="display-pill-icon">
10
13
  <slot name="icon"></slot>
11
14
  </span>
12
- <span v-if="label" class="display-pill-label">{{ label }}</span>
13
- <slot v-else name="default"></slot>
15
+ <span v-if="hasText()" class="display-pill-label">
16
+ <template v-if="hasLabel">{{ label }}</template>
17
+ <slot v-else name="default"></slot>
18
+ </span>
14
19
  </component>
15
20
  </template>
16
21
 
@@ -35,10 +40,14 @@ const props = withDefaults(defineProps<Props>(), {
35
40
 
36
41
  const slots = useSlots();
37
42
 
38
- // A function, not a computed: slots aren't reactive, so a cached value goes stale when a slot is toggled.
43
+ const hasLabel = computed(() => !!props.label?.trim());
44
+
45
+ // Functions, not computeds: slots aren't reactive, so a cached value goes stale when a slot is toggled.
46
+ const hasText = () => hasLabel.value || !!slots.default;
47
+
39
48
  const iconPosition = () => {
40
49
  if (!slots.icon) return undefined;
41
- if (!props.label && !slots.default) return "only";
50
+ if (!hasText()) return "only";
42
51
  return props.reversed ? "end" : "start";
43
52
  };
44
53
 
@@ -55,6 +64,8 @@ watch(
55
64
  .display-pill {
56
65
  --_background: var(--display-pill-background, var(--slate-01));
57
66
  --_text-colour: var(--display-pill-text-colour, var(--slate-09));
67
+ --_border-colour: var(--display-pill-border-colour, transparent);
68
+ --_ring-colour: var(--display-pill-ring-colour, var(--_background));
58
69
  --_font-size: var(--display-pill-font-size, 1.2rem);
59
70
  --_icon-size: var(--display-pill-icon-size, 1.4rem);
60
71
 
@@ -92,19 +103,21 @@ watch(
92
103
  }
93
104
 
94
105
  border: var(--display-pill-border-width, 0.1rem) var(--display-pill-border-style, solid)
95
- var(--display-pill-border-colour, transparent);
106
+ var(--_border-colour);
96
107
  /* 100vw always resolves to a full pill radius regardless of element size */
97
108
  border-radius: var(--display-pill-border-radius, 100vw);
98
109
  outline: var(--display-pill-outline, none);
99
110
  outline-offset: var(--display-pill-outline-offset, 0);
111
+ /* box-shadow, not outline: outlines ignore border-radius before Safari 16.4. */
112
+ box-shadow: 0 0 0 var(--display-pill-ring-width, 0) var(--_ring-colour);
100
113
  background-color: var(--_background);
101
114
  color: var(--_text-colour);
102
115
  font-family: inherit;
103
116
  font-size: var(--_font-size);
104
117
  font-weight: var(--display-pill-font-weight, 500);
105
118
  line-height: 1;
106
- white-space: nowrap;
107
119
  width: fit-content;
120
+ max-inline-size: 100%;
108
121
  cursor: default;
109
122
  user-select: none;
110
123
 
@@ -117,55 +130,78 @@ watch(
117
130
  }
118
131
  }
119
132
 
120
- &.is-reversed {
133
+ &:empty {
134
+ display: none;
135
+ }
136
+
137
+ &[data-reversed] {
121
138
  flex-direction: row-reverse;
122
139
  }
123
140
 
124
141
  .display-pill-icon {
125
142
  display: inline-flex;
143
+ flex-shrink: 0;
126
144
  align-items: center;
127
145
  font-size: var(--_icon-size);
128
146
  }
129
147
 
130
148
  .display-pill-label {
131
- display: inline-flex;
132
- align-items: center;
149
+ display: -webkit-box;
150
+ -webkit-box-orient: vertical;
151
+ -webkit-line-clamp: var(--display-pill-label-line-clamp, 1);
152
+ line-clamp: var(--display-pill-label-line-clamp, 1);
153
+ overflow: hidden;
154
+ overflow-wrap: anywhere;
155
+ min-inline-size: 0;
156
+ /* Room for descenders and accents, which line-height: 1 would otherwise clip. */
157
+ padding-block: 0.15em;
158
+ margin-block: -0.15em;
133
159
  }
134
160
 
135
- &.sm {
161
+ &[data-size="sm"] {
136
162
  --_font-size: var(--display-pill-font-size-sm, 1rem);
137
163
  --_icon-size: var(--display-pill-icon-size-sm, 1.2rem);
138
164
  }
139
165
 
140
- &.lg {
166
+ &[data-size="lg"] {
141
167
  --_font-size: var(--display-pill-font-size-lg, 1.4rem);
142
168
  --_icon-size: var(--display-pill-icon-size-lg, 1.6rem);
143
169
  }
144
170
 
145
171
  /* Variant token, then the base token, then the variant's own default. */
146
- &.primary {
172
+ &[data-variant="primary"] {
147
173
  --_background: var(--display-pill-primary-background, var(--display-pill-background, var(--blue-01)));
148
174
  --_text-colour: var(--display-pill-primary-text-colour, var(--display-pill-text-colour, var(--blue-09)));
175
+ --_border-colour: var(--display-pill-primary-border-colour, var(--display-pill-border-colour, transparent));
176
+ --_ring-colour: var(--display-pill-primary-ring-colour, var(--display-pill-ring-colour, var(--_background)));
149
177
  }
150
178
 
151
- &.success {
152
- --_background: var(--display-pill-success-background, var(--display-pill-background, var(--green-01)));
153
- --_text-colour: var(--display-pill-success-text-colour, var(--display-pill-text-colour, var(--green-10)));
179
+ &[data-variant="success"] {
180
+ --_background: var(--display-pill-success-background, var(--display-pill-background, var(--status-success-surface)));
181
+ --_text-colour: var(--display-pill-success-text-colour, var(--display-pill-text-colour, var(--status-success-text)));
182
+ --_border-colour: var(--display-pill-success-border-colour, var(--display-pill-border-colour, transparent));
183
+ --_ring-colour: var(--display-pill-success-ring-colour, var(--display-pill-ring-colour, var(--_background)));
154
184
  }
155
185
 
156
- &.warning {
157
- --_background: var(--display-pill-warning-background, var(--display-pill-background, var(--orange-01)));
158
- --_text-colour: var(--display-pill-warning-text-colour, var(--display-pill-text-colour, var(--orange-10)));
186
+ &[data-variant="warning"] {
187
+ --_background: var(--display-pill-warning-background, var(--display-pill-background, var(--status-warning-surface)));
188
+ --_text-colour: var(--display-pill-warning-text-colour, var(--display-pill-text-colour, var(--status-warning-text)));
189
+ --_border-colour: var(--display-pill-warning-border-colour, var(--display-pill-border-colour, transparent));
190
+ --_ring-colour: var(--display-pill-warning-ring-colour, var(--display-pill-ring-colour, var(--_background)));
159
191
  }
160
192
 
161
- &.danger {
162
- --_background: var(--display-pill-danger-background, var(--display-pill-background, var(--red-01)));
163
- --_text-colour: var(--display-pill-danger-text-colour, var(--display-pill-text-colour, var(--red-10)));
193
+ &[data-variant="danger"] {
194
+ --_background: var(--display-pill-danger-background, var(--display-pill-background, var(--status-danger-surface)));
195
+ --_text-colour: var(--display-pill-danger-text-colour, var(--display-pill-text-colour, var(--status-danger-text)));
196
+ --_border-colour: var(--display-pill-danger-border-colour, var(--display-pill-border-colour, transparent));
197
+ --_ring-colour: var(--display-pill-danger-ring-colour, var(--display-pill-ring-colour, var(--_background)));
164
198
  }
165
199
 
166
- &.neutral {
200
+ &[data-variant="neutral"] {
167
201
  --_background: var(--display-pill-neutral-background, var(--display-pill-background, var(--slate-08)));
168
202
  --_text-colour: var(--display-pill-neutral-text-colour, var(--display-pill-text-colour, var(--slate-03)));
203
+ --_border-colour: var(--display-pill-neutral-border-colour, var(--display-pill-border-colour, transparent));
204
+ --_ring-colour: var(--display-pill-neutral-ring-colour, var(--display-pill-ring-colour, var(--_background)));
169
205
  }
170
206
  }
171
207
  }
@@ -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>"`;