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.
- package/.claude/skills/components/canvas-switcher.md +5 -1
- package/.claude/skills/components/eyebrow-text.md +6 -4
- package/.claude/skills/components/input-error.md +1 -1
- package/.claude/skills/components/services-card.md +25 -2
- package/.claude/skills/storybook-add-story.md +5 -0
- package/.vscode/srcdev-component-eyebrow-text.code-snippets +4 -0
- package/.vscode/srcdev-component-services-card.code-snippets +38 -7
- package/app/components/01.atoms/page-row/stories/PageRow.stories.ts +47 -13
- package/app/components/01.atoms/text-blocks/eyebrow-text/CONSUMER-STYLING.md +5 -1
- package/app/components/01.atoms/text-blocks/eyebrow-text/EyebrowText.vue +4 -1
- package/app/components/03.organisms/services/services-card/CONSUMER-STYLING.md +28 -3
- package/app/components/03.organisms/services/services-card/ServicesCard.vue +111 -18
- package/app/components/03.organisms/services/services-card/stories/ServicesCard.stories.ts +122 -0
- package/app/components/03.organisms/services/services-card/tests/__snapshots__/ServicesCard.spec.ts.snap +4 -1
- package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts +100 -19
- package/app/components/03.organisms/services/services-grids/tests/__snapshots__/ServicesCardGrid.spec.ts.snap +12 -3
- package/app/components/05.forms/input-checkbox/MultipleCheckboxes.vue +2 -2
- package/app/components/05.forms/input-checkbox/SingleCheckbox.vue +1 -1
- package/app/components/05.forms/input-number/InputNumberField.vue +1 -1
- package/app/components/05.forms/input-radio/MultipleRadiobuttons.vue +2 -2
- package/app/components/05.forms/input-range/InputRangeDefault.vue +1 -1
- package/app/components/05.forms/input-select/InputSelectWithLabel.vue +1 -1
- package/app/components/05.forms/input-text/InputTextAsNumberWithLabel.vue +1 -1
- package/app/components/05.forms/input-text/InputTextWithLabel.vue +1 -1
- package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +24 -0
- package/app/components/05.forms/input-textarea/InputTextareaWithLabel.vue +1 -1
- package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabel.vue +4 -4
- package/app/composables/tests/useAriaDescribedById.spec.ts +31 -15
- package/app/composables/useAriaDescribedById.ts +6 -2
- 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"
|
|
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">
|
package/app/components/03.organisms/services/services-grids/stories/ServicesCardGrid.stories.ts
CHANGED
|
@@ -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?:
|
|
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: "
|
|
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
|
|
176
|
-
* --description-line-clamp
|
|
177
|
-
*
|
|
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
|
|
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 {
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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>
|
|
@@ -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>
|
|
@@ -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
|
});
|
|
@@ -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
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
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);
|