srcdev-nuxt-components 9.5.1 → 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/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 +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>
|
|
@@ -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);
|
|
@@ -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
|
}
|