srcdev-nuxt-components 9.5.1 → 9.5.3

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 (30) hide show
  1. package/.claude/skills/components/canvas-switcher.md +5 -1
  2. package/.claude/skills/components/eyebrow-text.md +6 -4
  3. package/.claude/skills/components/input-error.md +1 -1
  4. package/.claude/skills/components/services-card.md +25 -2
  5. package/.claude/skills/storybook-add-story.md +5 -0
  6. package/.vscode/srcdev-component-eyebrow-text.code-snippets +4 -0
  7. package/.vscode/srcdev-component-services-card.code-snippets +38 -7
  8. package/app/components/01.atoms/page-row/stories/PageRow.stories.ts +47 -13
  9. package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +5 -1
  10. package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +4 -1
  11. package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +28 -3
  12. package/app/components/03.organisms/services/services-card/ServicesCard.vue +111 -18
  13. package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +122 -0
  14. package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +4 -1
  15. package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +100 -19
  16. package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +12 -3
  17. package/app/components/05.forms/input-checkbox/MultipleCheckboxes.vue +2 -2
  18. package/app/components/05.forms/input-checkbox/SingleCheckbox.vue +1 -1
  19. package/app/components/05.forms/input-number/InputNumberField.vue +1 -1
  20. package/app/components/05.forms/input-radio/MultipleRadiobuttons.vue +2 -2
  21. package/app/components/05.forms/input-range/InputRangeDefault.vue +1 -1
  22. package/app/components/05.forms/input-select/InputSelectWithLabel.vue +1 -1
  23. package/app/components/05.forms/input-text/InputTextAsNumberWithLabel.vue +1 -1
  24. package/app/components/05.forms/input-text/InputTextWithLabel.vue +1 -1
  25. package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +24 -0
  26. package/app/components/05.forms/input-textarea/InputTextareaWithLabel.vue +1 -1
  27. package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabel.vue +4 -4
  28. package/app/composables/tests/useAriaDescribedById.spec.ts +31 -15
  29. package/app/composables/useAriaDescribedById.ts +6 -2
  30. package/package.json +1 -1
@@ -257,3 +257,125 @@ export const DesignReplica: Story = {
257
257
  },
258
258
  },
259
259
  };
260
+
261
+ const makeOverlayService = (
262
+ slug: string,
263
+ subtitle: string,
264
+ title: string,
265
+ shortDescription: string,
266
+ duration: string,
267
+ price: string
268
+ ): Service => ({
269
+ ...balayageService,
270
+ slug,
271
+ subtitle,
272
+ title,
273
+ shortDescription,
274
+ duration,
275
+ price,
276
+ image: `https://picsum.photos/seed/overlay-${slug}/600/800`,
277
+ });
278
+
279
+ const overlayServices: Service[] = [
280
+ makeOverlayService(
281
+ "balayage",
282
+ "Hair Colouring",
283
+ "Balayage",
284
+ "Hand-painted, sun-kissed colour with a soft regrowth line and low upkeep.",
285
+ "2.5 - 3.5 hours",
286
+ "From £95"
287
+ ),
288
+ makeOverlayService(
289
+ "full-colour",
290
+ "Hair Colouring",
291
+ "Full Colour",
292
+ "Complete colour coverage, from root touch-ups and natural tones to bold, fashion-forward shades.",
293
+ "1.5 - 2.5 hours",
294
+ "From £75"
295
+ ),
296
+ makeOverlayService(
297
+ "restyle",
298
+ "Cutting & Treatment",
299
+ "Restyle",
300
+ "A transformative cut that takes your hair in a new direction: a new shape, length, or style.",
301
+ "1 - 1.5 hours",
302
+ "£65"
303
+ ),
304
+ makeOverlayService(
305
+ "keratin-treatment",
306
+ "Cutting & Treatment",
307
+ "Keratin Treatment",
308
+ "Frizz-free smoothing sealed into the hair, with shine that lasts for months.",
309
+ "2 - 3 hours",
310
+ "£120"
311
+ ),
312
+ ];
313
+
314
+ export const DarkOverlayCards: Story = {
315
+ name: "Dark Overlay Cards",
316
+ render: (args) => ({
317
+ components: { ServicesCard },
318
+ setup() {
319
+ return { args, overlayServices };
320
+ },
321
+ template: `
322
+ <component is="style">
323
+ .services-card.dark-overlay-card {
324
+ --services-card-gap: 0;
325
+ --services-card-border-radius: 0.4rem;
326
+ --services-card-background-color: #1c1817;
327
+ --services-card-background-color-hover: #211c1b;
328
+ --services-card-border-colour: #2e2826;
329
+ --services-card-border-colour-hover: #6b5a4e;
330
+ --services-card-transform-hover: translateY(-0.4rem);
331
+
332
+ --image-wrapper-border-radius: 0;
333
+ --image-wrapper-border-image-zoom-transform: none;
334
+ --image-wrapper-details-scrim-colour: #141110;
335
+ --image-wrapper-details-text-colour: #f3ece4;
336
+ --image-wrapper-details-eyebrow-text-colour: #c9a24a;
337
+ --image-wrapper-details-gap: 0.4rem;
338
+ --eyebrow-text-font-family: "Poppins", sans-serif;
339
+ --eyebrow-text-font-style: normal;
340
+ --eyebrow-text-font-weight: 600;
341
+ --eyebrow-text-letter-spacing: 0.25em;
342
+ --image-wrapper-details-eyebrow-text-padding-inline: 3.6rem;
343
+ --image-wrapper-details-hero-text-padding-block: 0 2.8rem;
344
+ --image-wrapper-details-hero-text-padding-inline: 3.6rem;
345
+
346
+ --details-wrapper-padding-block: 3.6rem;
347
+ --details-wrapper-padding-inline: 3.6rem;
348
+ --description-text-colour: #a39a94;
349
+ --description-line-height: 1.75;
350
+ --description-font-weight: 300;
351
+ --meta-border-colour: #2e2826;
352
+ --meta-padding-block: 2.4rem 0;
353
+ --meta-text-colour: #8a817b;
354
+ --meta-duration-font-weight: 400;
355
+ --meta-price-font-weight: 400;
356
+ --meta-letter-spacing: 0.15em;
357
+ --meta-price-text-colour: #e8dfd6;
358
+ }
359
+ </component>
360
+ <div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(min(26rem, 100%), 1fr)); gap:4rem; padding:2.4rem; background:#121010;">
361
+ <ServicesCard
362
+ v-for="service in overlayServices"
363
+ :key="service.slug"
364
+ v-bind="args"
365
+ :service-data="service"
366
+ :href="'#' + service.slug"
367
+ titles-within-image-wrapper
368
+ :style-class-passthrough="['dark-overlay-card']"
369
+ />
370
+ </div>
371
+ `,
372
+ }),
373
+ parameters: {
374
+ docs: {
375
+ description: {
376
+ story:
377
+ "A darker variant built from tokens: titles over the image with a dark scrim, a flush square-cornered image, a padded details panel, and a whole-card link whose hover lightens the border. Every value is a token, set on a class added with styleClassPassthrough; the eyebrow tokens are EyebrowText's own and inherit down into the card.",
378
+ },
379
+ },
380
+ },
381
+ };
@@ -2,7 +2,10 @@
2
2
 
3
3
  exports[`ServicesCard > renders correct HTML structure 1`] = `
4
4
  "<div class="services-card">
5
- <div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/test.jpg 1x, /_ipx/_/images/test.jpg 2x" alt="Test Service" loading="lazy" class="image" src="/_ipx/_/images/test.jpg"></div>
5
+ <div class="grid-stack image-wrapper">
6
+ <div class="grid-stack__layer"><img data-nuxt-img="" srcset="/_ipx/_/images/test.jpg 1x, /_ipx/_/images/test.jpg 2x" alt="Test Service" loading="lazy" class="image" src="/_ipx/_/images/test.jpg"></div>
7
+ <div class="grid-stack__layer"></div>
8
+ </div>
6
9
  <div class="details-wrapper">
7
10
  <div class="eyebrow-text large">A subtitle</div>
8
11
  <h2 class="hero-text heading axis-horizontal">
@@ -15,7 +15,9 @@ type StoryArgs = {
15
15
  buttonIcon?: string;
16
16
  styleClassPassthrough?: string | string[];
17
17
  /** Story-only — not a real ServicesCardGrid prop, see useStorySetup below. */
18
- lineClamp?: number;
18
+ lineClamp?: 1 | 2 | 3 | 4 | 5;
19
+ /** Story-only — sets --services-card-grid-column-min-width on the wrapper, see useStorySetup below. */
20
+ cardMinWidth?: "200px" | "250px" | "300px" | "350px" | "400px";
19
21
  };
20
22
 
21
23
  const meta: Meta<StoryArgs> = {
@@ -52,10 +54,17 @@ const meta: Meta<StoryArgs> = {
52
54
  description: "Additional CSS classes applied to the root element",
53
55
  },
54
56
  lineClamp: {
55
- control: { type: "number", min: 1, max: 10, step: 1 },
57
+ control: { type: "inline-radio" },
58
+ options: [1, 2, 3, 4, 5],
56
59
  description:
57
60
  "Story-only control — sets ServicesCard's --description-line-clamp custom property on the grid wrapper (not a real component prop) to demo description clamping across cards",
58
61
  },
62
+ cardMinWidth: {
63
+ control: { type: "inline-radio" },
64
+ options: ["200px", "250px", "300px", "350px", "400px"],
65
+ description:
66
+ "Story-only control — sets --services-card-grid-column-min-width on the grid wrapper (not a real component prop). Cards never go narrower than this; auto-fit then stretches each column to share the leftover space",
67
+ },
59
68
  },
60
69
  args: {
61
70
  tag: "div",
@@ -66,6 +75,7 @@ const meta: Meta<StoryArgs> = {
66
75
  buttonIcon: "mdi:arrow-right",
67
76
  styleClassPassthrough: [],
68
77
  lineClamp: 3,
78
+ cardMinWidth: "250px",
69
79
  },
70
80
  parameters: {
71
81
  backgrounds: {
@@ -169,31 +179,56 @@ const lineClampServices: Service[] = [
169
179
  ),
170
180
  ];
171
181
 
182
+ // Twelve cards (divides evenly into 2, 3, 4 and 6 columns) to demo how cardMinWidth
183
+ // changes the column count as the canvas resizes.
184
+ const cardWidthServices: Service[] = [
185
+ ...sampleServices,
186
+ ...lineClampServices,
187
+ makeService("silk-press", "Silk Press", "Sleek, glossy straightening", "https://picsum.photos/seed/width-1/600/800"),
188
+ makeService("box-braids", "Box Braids", "Classic protective style", "https://picsum.photos/seed/width-2/600/800"),
189
+ makeService("scalp-treatment", "Scalp Treatment", "Healthy hair starts here", "https://picsum.photos/seed/width-3/600/800"),
190
+ ];
191
+
172
192
  // ─── Stories ──────────────────────────────────────────────────────────────────
173
193
 
194
+ // Hides the story-only lineClamp/cardMinWidth controls on stories whose fixtures can't
195
+ // show them (3 or fewer cards never wrap under auto-fit, and two-line descriptions
196
+ // never clamp) — use the Description Line Clamp and Card Width stories for those.
197
+ const hideDemoControls = {
198
+ lineClamp: { table: { disable: true } },
199
+ cardMinWidth: { table: { disable: true } },
200
+ };
201
+
174
202
  /**
175
- * lineClamp is a story-only control (not a real ServicesCardGrid prop) that sets
176
- * --description-line-clamp on the grid wrapper — the global custom property each
177
- * ServicesCard's .description reads for its -webkit-line-clamp value.
203
+ * lineClamp and cardMinWidth are story-only controls (not real ServicesCardGrid props).
204
+ * They set --description-line-clamp (read by each ServicesCard's .description) and
205
+ * --services-card-grid-column-min-width (the grid's minmax() floor) on a wrapper div,
206
+ * and are stripped from the args bound to the component so they don't leak as attributes.
178
207
  */
179
208
  function useStorySetup(args: StoryArgs) {
180
- const clampStyle = computed(() => ({
209
+ const wrapperStyle = computed(() => ({
181
210
  "--description-line-clamp": String(args.lineClamp ?? 3),
211
+ "--services-card-grid-column-min-width": args.cardMinWidth ?? "250px",
182
212
  }));
183
213
  const componentArgs = computed(() => {
184
- const { lineClamp: _lineClamp, ...rest } = args;
214
+ const { lineClamp: _lineClamp, cardMinWidth: _cardMinWidth, ...rest } = args;
185
215
  return rest;
186
216
  });
187
- return { clampStyle, componentArgs };
217
+ return { wrapperStyle, componentArgs };
188
218
  }
189
219
 
190
220
  export const Default: Story = {
221
+ argTypes: hideDemoControls,
191
222
  render: (args) => ({
192
223
  components: { ServicesCardGrid },
193
224
  setup() {
194
- return { args, sampleServices };
225
+ return { ...useStorySetup(args), sampleServices };
195
226
  },
196
- template: `<ServicesCardGrid v-bind="args" :services-data="sampleServices" />`,
227
+ template: `
228
+ <div :style="wrapperStyle">
229
+ <ServicesCardGrid v-bind="componentArgs" :services-data="sampleServices" />
230
+ </div>
231
+ `,
197
232
  }),
198
233
  parameters: {
199
234
  docs: {
@@ -205,6 +240,7 @@ export const Default: Story = {
205
240
  };
206
241
 
207
242
  export const CustomButtonText: Story = {
243
+ argTypes: hideDemoControls,
208
244
  name: "Custom Button Text",
209
245
  args: {
210
246
  hrefBase: "/services/",
@@ -213,9 +249,13 @@ export const CustomButtonText: Story = {
213
249
  render: (args) => ({
214
250
  components: { ServicesCardGrid },
215
251
  setup() {
216
- return { args, sampleServices };
252
+ return { ...useStorySetup(args), sampleServices };
217
253
  },
218
- template: `<ServicesCardGrid v-bind="args" :services-data="sampleServices" />`,
254
+ template: `
255
+ <div :style="wrapperStyle">
256
+ <ServicesCardGrid v-bind="componentArgs" :services-data="sampleServices" />
257
+ </div>
258
+ `,
219
259
  }),
220
260
  parameters: {
221
261
  docs: {
@@ -227,6 +267,7 @@ export const CustomButtonText: Story = {
227
267
  };
228
268
 
229
269
  export const ConfigOverrides: Story = {
270
+ argTypes: hideDemoControls,
230
271
  name: "Config Overrides",
231
272
  args: {
232
273
  eyebrowConfig: { tag: "p", fontSize: "small" },
@@ -235,9 +276,13 @@ export const ConfigOverrides: Story = {
235
276
  render: (args) => ({
236
277
  components: { ServicesCardGrid },
237
278
  setup() {
238
- return { args, sampleServices };
279
+ return { ...useStorySetup(args), sampleServices };
239
280
  },
240
- template: `<ServicesCardGrid v-bind="args" :services-data="sampleServices" />`,
281
+ template: `
282
+ <div :style="wrapperStyle">
283
+ <ServicesCardGrid v-bind="componentArgs" :services-data="sampleServices" />
284
+ </div>
285
+ `,
241
286
  }),
242
287
  parameters: {
243
288
  docs: {
@@ -250,13 +295,18 @@ export const ConfigOverrides: Story = {
250
295
  };
251
296
 
252
297
  export const SingleCard: Story = {
298
+ argTypes: hideDemoControls,
253
299
  name: "Single Card",
254
300
  render: (args) => ({
255
301
  components: { ServicesCardGrid },
256
302
  setup() {
257
- return { args, sampleServices };
303
+ return { ...useStorySetup(args), sampleServices };
258
304
  },
259
- template: `<ServicesCardGrid v-bind="args" :services-data="[sampleServices[0]]" />`,
305
+ template: `
306
+ <div :style="wrapperStyle">
307
+ <ServicesCardGrid v-bind="componentArgs" :services-data="[sampleServices[0]]" />
308
+ </div>
309
+ `,
260
310
  }),
261
311
  parameters: {
262
312
  docs: {
@@ -268,13 +318,18 @@ export const SingleCard: Story = {
268
318
  };
269
319
 
270
320
  export const EmptyData: Story = {
321
+ argTypes: hideDemoControls,
271
322
  name: "Empty Data",
272
323
  render: (args) => ({
273
324
  components: { ServicesCardGrid },
274
325
  setup() {
275
- return { args };
326
+ return { ...useStorySetup(args) };
276
327
  },
277
- template: `<ServicesCardGrid v-bind="args" :services-data="[]" />`,
328
+ template: `
329
+ <div :style="wrapperStyle">
330
+ <ServicesCardGrid v-bind="componentArgs" :services-data="[]" />
331
+ </div>
332
+ `,
278
333
  }),
279
334
  parameters: {
280
335
  docs: {
@@ -296,7 +351,7 @@ export const DescriptionLineClamp: Story = {
296
351
  return { ...useStorySetup(args), lineClampServices };
297
352
  },
298
353
  template: `
299
- <div :style="clampStyle">
354
+ <div :style="wrapperStyle">
300
355
  <ServicesCardGrid v-bind="componentArgs" :services-data="lineClampServices" />
301
356
  </div>
302
357
  `,
@@ -310,3 +365,29 @@ export const DescriptionLineClamp: Story = {
310
365
  },
311
366
  },
312
367
  };
368
+
369
+ export const CardWidth: Story = {
370
+ name: "Card Width",
371
+ args: {
372
+ cardMinWidth: "300px",
373
+ },
374
+ render: (args) => ({
375
+ components: { ServicesCardGrid },
376
+ setup() {
377
+ return { ...useStorySetup(args), cardWidthServices };
378
+ },
379
+ template: `
380
+ <div :style="wrapperStyle">
381
+ <ServicesCardGrid v-bind="componentArgs" :services-data="cardWidthServices" />
382
+ </div>
383
+ `,
384
+ }),
385
+ parameters: {
386
+ docs: {
387
+ description: {
388
+ story:
389
+ "Twelve cards with the cardMinWidth control driving --services-card-grid-column-min-width. The value is a floor, not a fixed width: auto-fit fits as many columns as that minimum allows, then stretches them to fill the row. Change the control or resize the canvas to see the column count change.",
390
+ },
391
+ },
392
+ },
393
+ };
@@ -3,7 +3,10 @@
3
3
  exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
4
4
  "<div class="services-card-grid">
5
5
  <div class="services-card">
6
- <div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-installation.jpg 1x, /_ipx/_/images/locs-installation.jpg 2x" alt="Locs Installation" loading="lazy" class="image" src="/_ipx/_/images/locs-installation.jpg"></div>
6
+ <div class="grid-stack image-wrapper">
7
+ <div class="grid-stack__layer"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-installation.jpg 1x, /_ipx/_/images/locs-installation.jpg 2x" alt="Locs Installation" loading="lazy" class="image" src="/_ipx/_/images/locs-installation.jpg"></div>
8
+ <div class="grid-stack__layer"></div>
9
+ </div>
7
10
  <div class="details-wrapper">
8
11
  <div class="eyebrow-text large">Subtitle for Locs Installation</div>
9
12
  <h2 class="hero-text heading axis-horizontal">
@@ -23,7 +26,10 @@ exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
23
26
  </div>
24
27
  </div>
25
28
  <div class="services-card">
26
- <div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-retwist.jpg 1x, /_ipx/_/images/locs-retwist.jpg 2x" alt="Locs Retwist" loading="lazy" class="image" src="/_ipx/_/images/locs-retwist.jpg"></div>
29
+ <div class="grid-stack image-wrapper">
30
+ <div class="grid-stack__layer"><img data-nuxt-img="" srcset="/_ipx/_/images/locs-retwist.jpg 1x, /_ipx/_/images/locs-retwist.jpg 2x" alt="Locs Retwist" loading="lazy" class="image" src="/_ipx/_/images/locs-retwist.jpg"></div>
31
+ <div class="grid-stack__layer"></div>
32
+ </div>
27
33
  <div class="details-wrapper">
28
34
  <div class="eyebrow-text large">Subtitle for Locs Retwist</div>
29
35
  <h2 class="hero-text heading axis-horizontal">
@@ -43,7 +49,10 @@ exports[`ServicesCardGrid > renders correct HTML structure 1`] = `
43
49
  </div>
44
50
  </div>
45
51
  <div class="services-card">
46
- <div class="image-wrapper"><img data-nuxt-img="" srcset="/_ipx/_/images/colour-treatment.jpg 1x, /_ipx/_/images/colour-treatment.jpg 2x" alt="Colour Treatment" loading="lazy" class="image" src="/_ipx/_/images/colour-treatment.jpg"></div>
52
+ <div class="grid-stack image-wrapper">
53
+ <div class="grid-stack__layer"><img data-nuxt-img="" srcset="/_ipx/_/images/colour-treatment.jpg 1x, /_ipx/_/images/colour-treatment.jpg 2x" alt="Colour Treatment" loading="lazy" class="image" src="/_ipx/_/images/colour-treatment.jpg"></div>
54
+ <div class="grid-stack__layer"></div>
55
+ </div>
47
56
  <div class="details-wrapper">
48
57
  <div class="eyebrow-text large">Subtitle for Colour Treatment</div>
49
58
  <h2 class="hero-text heading axis-horizontal">
@@ -39,7 +39,7 @@
39
39
  :theme
40
40
  :input-variant
41
41
  :direction
42
- :aria-describedby
42
+ :aria-describedby="ariaDescribedby()"
43
43
  :display-as-disc
44
44
  :is-pill="isPill"
45
45
  >
@@ -64,7 +64,7 @@
64
64
  :true-value="item.value"
65
65
  :theme
66
66
  :input-variant
67
- :aria-describedby
67
+ :aria-describedby="ariaDescribedby()"
68
68
  :display-as-disc
69
69
  >
70
70
  <template #checkedIcon>
@@ -35,7 +35,7 @@
35
35
  :false-value
36
36
  :theme
37
37
  :input-variant
38
- :aria-describedby
38
+ :aria-describedby="ariaDescribedby()"
39
39
  >
40
40
  <template #checkedIcon>
41
41
  <slot name="checkedIcon"></slot>
@@ -43,7 +43,7 @@
43
43
  :input-variant
44
44
  :field-has-error
45
45
  :style-class-passthrough
46
- :aria-describedby
46
+ :aria-describedby="ariaDescribedby()"
47
47
  >
48
48
  <template v-if="slots.left" #left>
49
49
  <InputButton
@@ -40,7 +40,7 @@
40
40
  :theme
41
41
  :input-variant
42
42
  :direction
43
- :aria-describedby
43
+ :aria-describedby="ariaDescribedby()"
44
44
  :is-pill="isPill"
45
45
  >
46
46
  <template #checkedIcon>
@@ -63,7 +63,7 @@
63
63
  :true-value="item.value"
64
64
  :theme
65
65
  :input-variant
66
- :aria-describedby
66
+ :aria-describedby="ariaDescribedby()"
67
67
  >
68
68
  <template #checkedIcon>
69
69
  <slot name="checkedIcon"></slot>
@@ -39,7 +39,7 @@
39
39
  :required
40
40
  :weight
41
41
  :field-has-error
42
- :aria-describedby="ariaDescribedby"
42
+ :aria-describedby="ariaDescribedby()"
43
43
  :style-class-passthrough="styleClassPassthrough"
44
44
  >
45
45
  <template v-if="slots.datalist" #datalist>
@@ -44,7 +44,7 @@
44
44
  :required
45
45
  :style-class-passthrough
46
46
  :theme="FormUiTheme"
47
- :aria-describedby
47
+ :aria-describedby="ariaDescribedby()"
48
48
  :input-variant
49
49
  />
50
50
 
@@ -45,7 +45,7 @@
45
45
  :style-class-passthrough="elementClasses"
46
46
  :theme
47
47
  inputmode="numeric"
48
- :aria-describedby
48
+ :aria-describedby="ariaDescribedby()"
49
49
  :input-variant
50
50
  >
51
51
  <template v-if="slots.left" #left>
@@ -49,7 +49,7 @@
49
49
  :required
50
50
  :style-class-passthrough
51
51
  :theme
52
- :aria-describedby
52
+ :aria-describedby="ariaDescribedby()"
53
53
  :input-variant
54
54
  >
55
55
  <template v-if="slots.left" #left>
@@ -2,6 +2,7 @@
2
2
  import type { VueWrapper } from "@vue/test-utils";
3
3
  import { mountSuspended } from "@nuxt/test-utils/runtime";
4
4
  import ComponentUnderTest from "../InputTextWithLabel.vue";
5
+ import { defineComponent, nextTick, ref } from "vue";
5
6
 
6
7
  const initialPropsData = {
7
8
  type: "text" as const,
@@ -44,4 +45,27 @@ describe("InputTextWithLabel", () => {
44
45
  expect(input.attributes("min")).toBe("2026-01-01");
45
46
  expect(input.attributes("max")).toBe("2026-12-31");
46
47
  });
48
+
49
+ it("points aria-describedby at the description once it appears after mount", async () => {
50
+ const Host = defineComponent({
51
+ components: { ComponentUnderTest },
52
+ setup() {
53
+ return { modelValue: ref(""), showDescription: ref(false) };
54
+ },
55
+ template: `
56
+ <ComponentUnderTest v-model="modelValue" type="text" name="email" label="Email" error-message="">
57
+ <template v-if="showDescription" #descriptionText>We never share it.</template>
58
+ </ComponentUnderTest>
59
+ `,
60
+ });
61
+ const host = await mountSuspended(Host);
62
+ const input = () => host.find("input");
63
+ expect(input().attributes("aria-describedby")).toBeFalsy();
64
+ (host.vm as unknown as { showDescription: boolean }).showDescription = true;
65
+ await nextTick();
66
+ expect(input().attributes("aria-describedby")).toMatch(/-description$/);
67
+ (host.vm as unknown as { showDescription: boolean }).showDescription = false;
68
+ await nextTick();
69
+ expect(input().attributes("aria-describedby")).toBeFalsy();
70
+ });
47
71
  });
@@ -44,7 +44,7 @@
44
44
  :style-class-passthrough
45
45
  :theme
46
46
  :input-variant
47
- :aria-describedby
47
+ :aria-describedby="ariaDescribedby()"
48
48
  >
49
49
  <template v-if="slots.left" #left>
50
50
  <slot name="left"></slot>
@@ -86,10 +86,10 @@ const toggleSwitchId = computed(() => `toggle-switch-${id}`);
86
86
  const descriptionId = computed(() => `${id}-description`);
87
87
  const errorId = computed(() => `${id}-error-message`);
88
88
 
89
- // A plain function rather than useAriaDescribedById's computed: slots aren't reactive, so a
90
- // computed would keep its first answer if a description slot is added or removed after mount
91
- // (Claude.md pitfall #25). Lists both ids when there's a description and an error, so the
92
- // description is still announced while the field is invalid.
89
+ // A plain function, like useAriaDescribedById's: slots aren't reactive, so a computed would keep
90
+ // its first answer if a description slot is added or removed after mount (Claude.md pitfall #25).
91
+ // Lists both ids when there's a description and an error, so the description is still announced
92
+ // while the field is invalid.
93
93
  const ariaDescribedby = () => {
94
94
  const ids: string[] = [];
95
95
  if (slots.descriptionText || slots.descriptionHtml) ids.push(descriptionId.value);