srcdev-nuxt-components 9.5.0 → 9.5.2
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/input-error.md +1 -1
- package/.claude/skills/components/toggle-switch-core.md +22 -3
- package/.claude/skills/index.md +1 -1
- package/.vscode/srcdev-component-toggle-switch.code-snippets +3 -1
- 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/CONSUMER-STYLING.md +15 -0
- package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabel.vue +20 -5
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +1 -3
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabel.spec.ts +34 -4
- package/app/composables/tests/useAriaDescribedById.spec.ts +30 -14
- package/app/composables/useAriaDescribedById.ts +6 -2
- package/package.json +1 -1
|
@@ -41,7 +41,7 @@ No slots.
|
|
|
41
41
|
```vue
|
|
42
42
|
<template>
|
|
43
43
|
<div class="my-field">
|
|
44
|
-
<input :id :aria-describedby :aria-invalid="fieldHasError" />
|
|
44
|
+
<input :id :aria-describedby="ariaDescribedby()" :aria-invalid="fieldHasError" />
|
|
45
45
|
<InputError :id="errorId" :error-message :show-error="fieldHasError" :is-detached="false" :input-variant />
|
|
46
46
|
</div>
|
|
47
47
|
</template>
|
|
@@ -100,7 +100,7 @@ full table with defaults.
|
|
|
100
100
|
### ToggleSwitchWithLabel
|
|
101
101
|
|
|
102
102
|
`ToggleSwitchWithLabel` (`ToggleSwitchWithLabel.vue`) composes `ToggleSwitchCore` with
|
|
103
|
-
`InputLabel`, an optional
|
|
103
|
+
`InputLabel`, an optional description (rendered by `InputDescription`, the same as every other labelled form field), and `InputError`.
|
|
104
104
|
|
|
105
105
|
**Additional props over ToggleSwitchCore:**
|
|
106
106
|
|
|
@@ -113,7 +113,9 @@ Note this wrapper passes `id`/`name`/`required`/`field-has-error`/`true-value`/`
|
|
|
113
113
|
`theme`/`round`/`aria-describedby` straight through to `ToggleSwitchCore`; `id` is a self-generated
|
|
114
114
|
`useId()` value, not a prop — consumers don't set it directly.
|
|
115
115
|
|
|
116
|
-
**Slots**: `
|
|
116
|
+
**Slots**: `descriptionText` / `descriptionHtml` (help text between the label and the switch, via `InputDescription`), `iconOn`, `iconOff` (the latter two forwarded to `ToggleSwitchCore`).
|
|
117
|
+
|
|
118
|
+
The switch's `aria-describedby` lists the description id when a description slot is present and the error id when `field-has-error` is true, both together when both apply.
|
|
117
119
|
|
|
118
120
|
```vue
|
|
119
121
|
<ToggleSwitchWithLabel
|
|
@@ -123,10 +125,27 @@ Note this wrapper passes `id`/`name`/`required`/`field-has-error`/`true-value`/`
|
|
|
123
125
|
:error-message="errors.notifications"
|
|
124
126
|
:field-has-error="!!errors.notifications"
|
|
125
127
|
>
|
|
126
|
-
<template #
|
|
128
|
+
<template #descriptionText>Turn this on to receive notifications</template>
|
|
127
129
|
</ToggleSwitchWithLabel>
|
|
128
130
|
```
|
|
129
131
|
|
|
132
|
+
#### Breaking change 2026-09-29: description slot
|
|
133
|
+
|
|
134
|
+
The `description` slot is removed. Use `descriptionText` (plain text, wrapped in a `<p>`) or
|
|
135
|
+
`descriptionHtml` (rich markup) instead. The old slot rendered into a bare `<div>` with no class,
|
|
136
|
+
spacing or tokens, so its content sat flush against the switch; it now goes through
|
|
137
|
+
`InputDescription` like every other labelled form field, styled by the `--input-description-*`
|
|
138
|
+
tokens (see `input-description` CONSUMER-STYLING.md) and targetable as
|
|
139
|
+
`.toggle-switch-description`. `aria-describedby` also used to drop the description id whenever
|
|
140
|
+
the field had an error; it now lists both.
|
|
141
|
+
|
|
142
|
+
```vue
|
|
143
|
+
<!-- before -->
|
|
144
|
+
<template #description>Sends at most one email a week.</template>
|
|
145
|
+
<!-- after -->
|
|
146
|
+
<template #descriptionText>Sends at most one email a week.</template>
|
|
147
|
+
```
|
|
148
|
+
|
|
130
149
|
### ToggleSwitchWithLabelInline
|
|
131
150
|
|
|
132
151
|
`ToggleSwitchWithLabelInline` (`ToggleSwitchWithLabelInline.vue`) composes
|
package/.claude/skills/index.md
CHANGED
|
@@ -149,7 +149,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
149
149
|
├── input-checkbox-radio.md — InputCheckboxRadio (bare control), InputCheckboxRadioField (labelled), InputCheckboxRadioButton (button/pill option): renamed from Core/WithLabel, state classes → data-* hooks, type-switch v-model fix, no aria-checked, icon font-size sizing, button spacing tokens
|
|
150
150
|
├── input-checkbox.md — MultipleCheckboxes/SingleCheckbox: fieldset-wrapped checkbox group (data-driven, button or labelled) and single checkbox, per-box native-required/styleClassPassthrough/data-testid bugs fixed, spacing token API
|
|
151
151
|
├── input-radio.md — MultipleRadiobuttons: fieldset radiogroup (data-driven, button or labelled), shared-name grouping bug fixed, dead label/placeholder/equalCols props removed, data-options-layout hook, spacing token API
|
|
152
|
-
├── toggle-switch-core.md — ToggleSwitchCore: pill/square checkbox-backed toggle primitive, dead round-prop/data-theme-error functionality and story theme-options bugs fixed, CSS token API; Variants section covers ToggleSwitchWithLabel/ToggleSwitchWithLabelInline
|
|
152
|
+
├── toggle-switch-core.md — ToggleSwitchCore: pill/square checkbox-backed toggle primitive, dead round-prop/data-theme-error functionality and story theme-options bugs fixed, CSS token API; Variants section covers ToggleSwitchWithLabel (descriptionText/descriptionHtml via InputDescription, `description` slot removed 2026-09-29)/ToggleSwitchWithLabelInline
|
|
153
153
|
├── triple-toggle-switch.md — TripleToggleSwitch (renamed from TripleToggleSwitchCore): three-option icon radio pill with sliding marker, radiogroup + ariaLabel, system/light/dark marker-gradient tokens, reduced-motion, CSS token API
|
|
154
154
|
├── entry-animation.md — EntryAnimation: scroll-driven entry animation wrapper (slide-in/zoom-reveal/exit-blur utility classes), skipAnimation for above-the-fold loop items, reduced-motion handled at the CSS layer
|
|
155
155
|
├── column-flow-grid.md — ColumnFlowGrid (renamed from MasonryGrid): CSS multi-column text-flow layout, named dynamic slots (no count/data prop), itemMinWidth/gap/unit sizing, CSS token API; not a true masonry, see "which one do I want?"
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
" label=\"$3Field label\"",
|
|
21
21
|
" :error-message=\"$4errors.fieldName\"",
|
|
22
22
|
" :field-has-error=\"!!$4errors.fieldName\"",
|
|
23
|
-
"
|
|
23
|
+
">",
|
|
24
|
+
" <template #descriptionText>$5Help text</template>",
|
|
25
|
+
"</ToggleSwitchWithLabel>"
|
|
24
26
|
]
|
|
25
27
|
},
|
|
26
28
|
"SRCDEV ToggleSwitchWithLabelInline": {
|
|
@@ -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
|
});
|
|
@@ -55,6 +55,21 @@ fixed width independent of symbol size).
|
|
|
55
55
|
|
|
56
56
|
---
|
|
57
57
|
|
|
58
|
+
## Description (ToggleSwitchWithLabel)
|
|
59
|
+
|
|
60
|
+
`ToggleSwitchWithLabel`'s `descriptionText` / `descriptionHtml` slots render through
|
|
61
|
+
`InputDescription`, so they're styled by its `--input-description-*` tokens (see
|
|
62
|
+
`input-description/CONSUMER-STYLING.md`), the same as every other labelled form field. The
|
|
63
|
+
spacing between the description and the switch is
|
|
64
|
+
`--input-description-slot-margin-block-end` (default `0.8rem`). Scope an override to toggles only
|
|
65
|
+
with the `.toggle-switch-description` class it adds:
|
|
66
|
+
|
|
67
|
+
```css
|
|
68
|
+
.toggle-switch-description {
|
|
69
|
+
--input-description-slot-margin-block-end: 1.2rem;
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
58
73
|
## Global theming
|
|
59
74
|
|
|
60
75
|
```css
|
|
@@ -10,9 +10,18 @@
|
|
|
10
10
|
<template #textLabel>{{ label }}</template>
|
|
11
11
|
</InputLabel>
|
|
12
12
|
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
<InputDescription
|
|
14
|
+
:description-id
|
|
15
|
+
:field-has-error
|
|
16
|
+
:style-class-passthrough="['toggle-switch-description']"
|
|
17
|
+
>
|
|
18
|
+
<template v-if="slots.descriptionHtml" #descriptionHtml>
|
|
19
|
+
<slot name="descriptionHtml"></slot>
|
|
20
|
+
</template>
|
|
21
|
+
<template v-if="slots.descriptionText" #descriptionText>
|
|
22
|
+
<slot name="descriptionText"></slot>
|
|
23
|
+
</template>
|
|
24
|
+
</InputDescription>
|
|
16
25
|
<ToggleSwitchCore
|
|
17
26
|
:id
|
|
18
27
|
v-model="modelValue"
|
|
@@ -77,9 +86,15 @@ const toggleSwitchId = computed(() => `toggle-switch-${id}`);
|
|
|
77
86
|
const descriptionId = computed(() => `${id}-description`);
|
|
78
87
|
const errorId = computed(() => `${id}-error-message`);
|
|
79
88
|
|
|
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.
|
|
80
93
|
const ariaDescribedby = () => {
|
|
81
|
-
const
|
|
82
|
-
|
|
94
|
+
const ids: string[] = [];
|
|
95
|
+
if (slots.descriptionText || slots.descriptionHtml) ids.push(descriptionId.value);
|
|
96
|
+
if (props.fieldHasError) ids.push(errorId.value);
|
|
97
|
+
return ids.join(" ");
|
|
83
98
|
};
|
|
84
99
|
|
|
85
100
|
const modelValue = defineModel<string | number | boolean>({ required: true });
|
|
@@ -166,9 +166,7 @@ const Template: StoryFn<ToggleSwitchWithLabelStoryArgs> = (_args, { argTypes })
|
|
|
166
166
|
:theme="args.theme"
|
|
167
167
|
:round="args.round"
|
|
168
168
|
>
|
|
169
|
-
<template v-if="args.useDescription" #
|
|
170
|
-
<div style="color: #666; font-size: 0.875rem;">{{ args.descriptionContent }}</div>
|
|
171
|
-
</template>
|
|
169
|
+
<template v-if="args.useDescription" #descriptionText>{{ args.descriptionContent }}</template>
|
|
172
170
|
<template v-if="args.useCustomIcons" #iconOn>
|
|
173
171
|
<span v-html="args.iconOnContent"></span>
|
|
174
172
|
</template>
|
|
@@ -49,9 +49,26 @@ describe("ToggleSwitchWithLabel", () => {
|
|
|
49
49
|
expect(wrapper.text()).toContain("You must accept");
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
it("renders
|
|
53
|
-
wrapper = await wrapperFactory({}, {
|
|
54
|
-
|
|
52
|
+
it("renders descriptionText through InputDescription", async () => {
|
|
53
|
+
wrapper = await wrapperFactory({}, { descriptionText: () => "Turn this on to receive notifications" });
|
|
54
|
+
const description = wrapper.find(".input-description.toggle-switch-description");
|
|
55
|
+
expect(description.exists()).toBe(true);
|
|
56
|
+
expect(description.find(".input-description-text").text()).toBe("Turn this on to receive notifications");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("renders descriptionHtml through InputDescription", async () => {
|
|
60
|
+
wrapper = await wrapperFactory({}, { descriptionHtml: () => "Rich description" });
|
|
61
|
+
expect(wrapper.find(".toggle-switch-description .input-description-html").text()).toBe("Rich description");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("renders no description element without a description slot", async () => {
|
|
65
|
+
wrapper = await wrapperFactory();
|
|
66
|
+
expect(wrapper.find(".input-description").exists()).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("no longer renders the removed description slot", async () => {
|
|
70
|
+
wrapper = await wrapperFactory({}, { description: () => "Old slot content" });
|
|
71
|
+
expect(wrapper.text()).not.toContain("Old slot content");
|
|
55
72
|
});
|
|
56
73
|
|
|
57
74
|
it("forwards required to the underlying input", async () => {
|
|
@@ -84,7 +101,7 @@ describe("ToggleSwitchWithLabel", () => {
|
|
|
84
101
|
},
|
|
85
102
|
template: `
|
|
86
103
|
<ComponentUnderTest v-model="modelValue" name="notifications" label="Enable notifications" error-message="">
|
|
87
|
-
<template v-if="showDescription" #
|
|
104
|
+
<template v-if="showDescription" #descriptionText>Sends at most one email a week.</template>
|
|
88
105
|
</ComponentUnderTest>
|
|
89
106
|
`,
|
|
90
107
|
});
|
|
@@ -100,4 +117,17 @@ describe("ToggleSwitchWithLabel", () => {
|
|
|
100
117
|
wrapper = await wrapperFactory({ fieldHasError: true, errorMessage: "Required" });
|
|
101
118
|
expect(wrapper.find("input[type='checkbox']").attributes("aria-describedby")).toMatch(/-error-message$/);
|
|
102
119
|
});
|
|
120
|
+
|
|
121
|
+
it("points aria-describedby at both the description and the error when both are present", async () => {
|
|
122
|
+
wrapper = await wrapperFactory(
|
|
123
|
+
{ fieldHasError: true, errorMessage: "Required" },
|
|
124
|
+
{ descriptionText: () => "Sends at most one email a week." }
|
|
125
|
+
);
|
|
126
|
+
const describedBy = wrapper.find("input[type='checkbox']").attributes("aria-describedby") ?? "";
|
|
127
|
+
const ids = describedBy.split(" ");
|
|
128
|
+
expect(ids).toHaveLength(2);
|
|
129
|
+
expect(ids[0]).toMatch(/-description$/);
|
|
130
|
+
expect(ids[1]).toMatch(/-error-message$/);
|
|
131
|
+
expect(wrapper.find(`[id="${ids[0]}"]`).exists()).toBe(true);
|
|
132
|
+
});
|
|
103
133
|
});
|
|
@@ -38,7 +38,7 @@ describe("useAriaDescribedById", () => {
|
|
|
38
38
|
describe("ariaDescribedby", () => {
|
|
39
39
|
it("is null when no slots and no error", () => {
|
|
40
40
|
const { ariaDescribedby } = useAriaDescribedById("email", ref(false), noSlots);
|
|
41
|
-
expect(ariaDescribedby
|
|
41
|
+
expect(ariaDescribedby()).toBeNull();
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
it("includes descriptionId when descriptionText slot is present", () => {
|
|
@@ -47,7 +47,7 @@ describe("useAriaDescribedById", () => {
|
|
|
47
47
|
ref(false),
|
|
48
48
|
withSlot("descriptionText")
|
|
49
49
|
);
|
|
50
|
-
expect(ariaDescribedby
|
|
50
|
+
expect(ariaDescribedby()).toContain(descriptionId);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
it("includes descriptionId when descriptionHtml slot is present", () => {
|
|
@@ -56,7 +56,7 @@ describe("useAriaDescribedById", () => {
|
|
|
56
56
|
ref(false),
|
|
57
57
|
withSlot("descriptionHtml")
|
|
58
58
|
);
|
|
59
|
-
expect(ariaDescribedby
|
|
59
|
+
expect(ariaDescribedby()).toContain(descriptionId);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
it("includes descriptionId when description slot is present", () => {
|
|
@@ -65,17 +65,17 @@ describe("useAriaDescribedById", () => {
|
|
|
65
65
|
ref(false),
|
|
66
66
|
withSlot("description")
|
|
67
67
|
);
|
|
68
|
-
expect(ariaDescribedby
|
|
68
|
+
expect(ariaDescribedby()).toContain(descriptionId);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
it("includes errorId when fieldHasError is true", () => {
|
|
72
72
|
const { errorId, ariaDescribedby } = useAriaDescribedById("email", ref(true), noSlots);
|
|
73
|
-
expect(ariaDescribedby
|
|
73
|
+
expect(ariaDescribedby()).toContain(errorId);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
it("does not include errorId when fieldHasError is false (value is null)", () => {
|
|
77
77
|
const { ariaDescribedby } = useAriaDescribedById("email", ref(false), noSlots);
|
|
78
|
-
expect(ariaDescribedby
|
|
78
|
+
expect(ariaDescribedby()).toBeNull();
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
it("does not include errorId when slot is present but fieldHasError is false", () => {
|
|
@@ -84,7 +84,7 @@ describe("useAriaDescribedById", () => {
|
|
|
84
84
|
ref(false),
|
|
85
85
|
withSlot("descriptionText")
|
|
86
86
|
);
|
|
87
|
-
expect(ariaDescribedby
|
|
87
|
+
expect(ariaDescribedby()).not.toContain(errorId);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
it("includes both descriptionId and errorId when slot present and error active", () => {
|
|
@@ -94,8 +94,8 @@ describe("useAriaDescribedById", () => {
|
|
|
94
94
|
fieldHasError,
|
|
95
95
|
withSlot("descriptionText")
|
|
96
96
|
);
|
|
97
|
-
expect(ariaDescribedby
|
|
98
|
-
expect(ariaDescribedby
|
|
97
|
+
expect(ariaDescribedby()).toContain(descriptionId);
|
|
98
|
+
expect(ariaDescribedby()).toContain(errorId);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
it("descriptionId appears before errorId in the combined string", () => {
|
|
@@ -105,7 +105,7 @@ describe("useAriaDescribedById", () => {
|
|
|
105
105
|
fieldHasError,
|
|
106
106
|
withSlot("descriptionText")
|
|
107
107
|
);
|
|
108
|
-
const value = ariaDescribedby
|
|
108
|
+
const value = ariaDescribedby()!;
|
|
109
109
|
expect(value.indexOf(descriptionId)).toBeLessThan(value.indexOf(errorId));
|
|
110
110
|
});
|
|
111
111
|
});
|
|
@@ -116,19 +116,35 @@ describe("useAriaDescribedById", () => {
|
|
|
116
116
|
it("ariaDescribedby updates when fieldHasError changes to true", async () => {
|
|
117
117
|
const fieldHasError = ref(false);
|
|
118
118
|
const { errorId, ariaDescribedby } = useAriaDescribedById("email", fieldHasError, noSlots);
|
|
119
|
-
expect(ariaDescribedby
|
|
119
|
+
expect(ariaDescribedby()).toBeNull();
|
|
120
120
|
fieldHasError.value = true;
|
|
121
121
|
await nextTick();
|
|
122
|
-
expect(ariaDescribedby
|
|
122
|
+
expect(ariaDescribedby()).toContain(errorId);
|
|
123
123
|
});
|
|
124
124
|
|
|
125
125
|
it("ariaDescribedby updates when fieldHasError changes back to false", async () => {
|
|
126
126
|
const fieldHasError = ref(true);
|
|
127
127
|
const { ariaDescribedby } = useAriaDescribedById("email", fieldHasError, noSlots);
|
|
128
|
-
expect(ariaDescribedby
|
|
128
|
+
expect(ariaDescribedby()).toBeTruthy();
|
|
129
129
|
fieldHasError.value = false;
|
|
130
130
|
await nextTick();
|
|
131
|
-
expect(ariaDescribedby
|
|
131
|
+
expect(ariaDescribedby()).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("picks up a description slot added after setup", () => {
|
|
135
|
+
const slots: Slots = {};
|
|
136
|
+
const { descriptionId, ariaDescribedby } = useAriaDescribedById("email", ref(false), slots);
|
|
137
|
+
expect(ariaDescribedby()).toBeNull();
|
|
138
|
+
slots.descriptionText = () => [];
|
|
139
|
+
expect(ariaDescribedby()).toBe(descriptionId);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("drops a description slot removed after setup", () => {
|
|
143
|
+
const slots: Slots = withSlot("descriptionText");
|
|
144
|
+
const { ariaDescribedby } = useAriaDescribedById("email", ref(false), slots);
|
|
145
|
+
expect(ariaDescribedby()).toBeTruthy();
|
|
146
|
+
delete slots.descriptionText;
|
|
147
|
+
expect(ariaDescribedby()).toBeNull();
|
|
132
148
|
});
|
|
133
149
|
});
|
|
134
150
|
});
|
|
@@ -5,7 +5,11 @@ export function useAriaDescribedById(name: string | Ref<string>, fieldHasError:
|
|
|
5
5
|
const errorId = `${id}-error-message`;
|
|
6
6
|
const descriptionId = `${id}-description`;
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// A plain function, not a computed: the slots object isn't reactive, so a computed would cache
|
|
9
|
+
// its first answer and keep pointing at (or away from) the description after a description slot
|
|
10
|
+
// is added or removed post-mount (Claude.md pitfall #25). Call it from the template,
|
|
11
|
+
// `:aria-describedby="ariaDescribedby()"`, so it's re-evaluated on every render.
|
|
12
|
+
const ariaDescribedby = () => {
|
|
9
13
|
const hasDescription = slots.descriptionText || slots.descriptionHtml || slots.description;
|
|
10
14
|
const ids = [];
|
|
11
15
|
|
|
@@ -13,7 +17,7 @@ export function useAriaDescribedById(name: string | Ref<string>, fieldHasError:
|
|
|
13
17
|
if (fieldHasError.value) ids.push(errorId);
|
|
14
18
|
|
|
15
19
|
return ids.length ? ids.join(" ") : null;
|
|
16
|
-
}
|
|
20
|
+
};
|
|
17
21
|
|
|
18
22
|
return { id, errorId, descriptionId, ariaDescribedby };
|
|
19
23
|
}
|