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.
@@ -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>
@@ -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);
@@ -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.value).toBeNull();
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.value).toContain(descriptionId);
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.value).toContain(descriptionId);
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.value).toContain(descriptionId);
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.value).toContain(errorId);
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.value).toBeNull();
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.value).not.toContain(errorId);
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.value).toContain(descriptionId);
98
- expect(ariaDescribedby.value).toContain(errorId);
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.value!;
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.value).toBeNull();
119
+ expect(ariaDescribedby()).toBeNull();
120
120
  fieldHasError.value = true;
121
121
  await nextTick();
122
- expect(ariaDescribedby.value).toContain(errorId);
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.value).toBeTruthy();
128
+ expect(ariaDescribedby()).toBeTruthy();
129
129
  fieldHasError.value = false;
130
130
  await nextTick();
131
- expect(ariaDescribedby.value).toBeNull();
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
- const ariaDescribedby = computed(() => {
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
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.5.1",
4
+ "version": "9.5.2",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",