Skip to content

Commit 543f932

Browse files
ikushumjohnleider
andauthored
fix(input): handle aria-describedby with hide-details (#21703)
fixes #17012 fixes #19794 Co-authored-by: John Leider <[email protected]>
1 parent 64943b3 commit 543f932

File tree

7 files changed

+295
-20
lines changed

7 files changed

+295
-20
lines changed
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { VCheckbox } from '../VCheckbox'
2+
3+
// Utilities
4+
import { mount } from '@vue/test-utils'
5+
import { createVuetify } from '@/framework'
6+
7+
describe('VCheckbox', () => {
8+
const vuetify = createVuetify()
9+
10+
function mountFunction (component: any, options = {}) {
11+
return mount(component, {
12+
global: {
13+
plugins: [vuetify],
14+
},
15+
...options,
16+
})
17+
}
18+
19+
describe('hide-details behavior', () => {
20+
it('should not have aria-describedby when hide-details is true', () => {
21+
const wrapper = mountFunction(
22+
<VCheckbox hideDetails />
23+
)
24+
25+
const input = wrapper.find('input')
26+
expect(input.attributes('aria-describedby')).toBeUndefined()
27+
28+
// Should not have details section
29+
const details = wrapper.find('.v-input__details')
30+
expect(details.exists()).toBe(false)
31+
})
32+
33+
it('should have aria-describedby when hide-details is false or undefined', () => {
34+
const wrapper = mountFunction(
35+
<VCheckbox id="input-1" />
36+
)
37+
38+
const input = wrapper.find('input')
39+
expect(input.attributes('aria-describedby')).toBe('input-1-messages')
40+
41+
// Should have details section
42+
const details = wrapper.find('.v-input__details')
43+
expect(details.exists()).toBe(true)
44+
expect(details.attributes('id')).toBe('input-1-messages')
45+
})
46+
47+
it('should have aria-describedby when hide-details is "auto" and has messages', () => {
48+
const wrapper = mountFunction(
49+
<VCheckbox
50+
id="input-2"
51+
messages={['Hello World!']}
52+
hideDetails="auto"
53+
/>
54+
)
55+
56+
const input = wrapper.find('input')
57+
expect(input.attributes('aria-describedby')).toBe('input-2-messages')
58+
59+
// Should have details section with messages
60+
const details = wrapper.find('.v-input__details')
61+
expect(details.exists()).toBe(true)
62+
expect(details.attributes('id')).toBe('input-2-messages')
63+
64+
const messages = wrapper.find('.v-messages')
65+
expect(messages.exists()).toBe(true)
66+
})
67+
68+
it('should not have aria-describedby when hide-details is "auto" and no messages', () => {
69+
const wrapper = mountFunction(
70+
<VCheckbox
71+
id="input-3"
72+
hideDetails="auto"
73+
/>
74+
)
75+
76+
const input = wrapper.find('input')
77+
expect(input.attributes('aria-describedby')).toBeUndefined()
78+
79+
// Should not have details section
80+
const details = wrapper.find('.v-input__details')
81+
expect(details.exists()).toBe(false)
82+
})
83+
84+
it('should have aria-describedby when hide-details is "auto" and has error messages', () => {
85+
const wrapper = mountFunction(
86+
<VCheckbox
87+
id="input-4"
88+
errorMessages={['This field is required']}
89+
hideDetails="auto"
90+
/>
91+
)
92+
93+
const input = wrapper.find('input')
94+
expect(input.attributes('aria-describedby')).toBe('input-4-messages')
95+
96+
// Should have details section with error messages
97+
const details = wrapper.find('.v-input__details')
98+
expect(details.exists()).toBe(true)
99+
expect(details.attributes('id')).toBe('input-4-messages')
100+
})
101+
102+
it('should have aria-describedby when hide-details is "auto" and has details slot', () => {
103+
const wrapper = mountFunction(
104+
<VCheckbox
105+
id="input-5"
106+
hideDetails="auto"
107+
v-slots={{
108+
details: () => <div>Custom details</div>,
109+
}}
110+
/>
111+
)
112+
113+
const input = wrapper.find('input')
114+
expect(input.attributes('aria-describedby')).toBe('input-5-messages')
115+
116+
// Should have details section with custom content
117+
const details = wrapper.find('.v-input__details')
118+
expect(details.exists()).toBe(true)
119+
expect(details.attributes('id')).toBe('input-5-messages')
120+
expect(details.text()).toContain('Custom details')
121+
})
122+
123+
it('should handle persistent hint correctly', () => {
124+
const wrapper = mountFunction(
125+
<VCheckbox
126+
id="input-8"
127+
hint="Persistent hint"
128+
persistentHint
129+
/>
130+
)
131+
132+
const input = wrapper.find('input')
133+
expect(input.attributes('aria-describedby')).toBe('input-8-messages')
134+
135+
const details = wrapper.find('.v-input__details')
136+
expect(details.exists()).toBe(true)
137+
expect(details.text()).toContain('Persistent hint')
138+
})
139+
})
140+
})

packages/vuetify/src/components/VField/VField.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const makeVFieldProps = propsFactory({
6565
},
6666
color: String,
6767
baseColor: String,
68+
details: Boolean,
6869
dirty: Boolean,
6970
disabled: {
7071
type: Boolean,
@@ -141,7 +142,7 @@ export const VField = genericComponent<new <T>(
141142

142143
const uid = useId()
143144
const id = computed(() => props.id || `input-${uid}`)
144-
const messagesId = toRef(() => `${id.value}-messages`)
145+
const messagesId = toRef(() => !props.details ? undefined : `${id.value}-messages`)
145146

146147
const labelRef = ref<VFieldLabel>()
147148
const floatingLabelRef = ref<VFieldLabel>()

packages/vuetify/src/components/VFileInput/VFileInput.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ export const VFileInput = genericComponent<VFileInputSlots>()({
225225
isDirty,
226226
isReadonly,
227227
isValid,
228+
hasDetails,
228229
}) => (
229230
<VField
230231
ref={ vFieldRef }
@@ -240,6 +241,7 @@ export const VFileInput = genericComponent<VFileInputSlots>()({
240241
dirty={ isDirty.value || props.dirty }
241242
disabled={ isDisabled.value }
242243
focused={ isFocused.value }
244+
details={ hasDetails.value }
243245
error={ isValid.value === false }
244246
onDragover={ onDragover }
245247
onDrop={ onDrop }

packages/vuetify/src/components/VInput/VInput.tsx

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,14 @@ import type { GenericProps } from '@/util'
2525

2626
export interface VInputSlot {
2727
id: ComputedRef<string>
28-
messagesId: ComputedRef<string>
28+
messagesId: ComputedRef<string | undefined>
2929
isDirty: ComputedRef<boolean>
3030
isDisabled: ComputedRef<boolean>
3131
isReadonly: ComputedRef<boolean>
3232
isPristine: Ref<boolean>
3333
isValid: ComputedRef<boolean | null>
3434
isValidating: Ref<boolean>
35+
hasDetails: Ref<boolean>
3536
reset: () => void
3637
resetValidation: () => void
3738
validate: () => void
@@ -111,7 +112,6 @@ export const VInput = genericComponent<new <T>(
111112

112113
const uid = useId()
113114
const id = computed(() => props.id || `input-${uid}`)
114-
const messagesId = computed(() => `${id.value}-messages`)
115115

116116
const {
117117
errorMessages,
@@ -127,6 +127,25 @@ export const VInput = genericComponent<new <T>(
127127
validationClasses,
128128
} = useValidation(props, 'v-input', id)
129129

130+
const messages = computed(() => {
131+
if (props.errorMessages?.length || (!isPristine.value && errorMessages.value.length)) {
132+
return errorMessages.value
133+
} else if (props.hint && (props.persistentHint || props.focused)) {
134+
return props.hint
135+
} else {
136+
return props.messages
137+
}
138+
})
139+
140+
const hasMessages = toRef(() => messages.value.length > 0)
141+
142+
const hasDetails = toRef(() => !props.hideDetails || (
143+
props.hideDetails === 'auto' &&
144+
(hasMessages.value || !!slots.details)
145+
))
146+
147+
const messagesId = computed(() => hasDetails.value ? `${id.value}-messages` : undefined)
148+
130149
const slotProps = computed<VInputSlot>(() => ({
131150
id,
132151
messagesId,
@@ -136,6 +155,7 @@ export const VInput = genericComponent<new <T>(
136155
isPristine,
137156
isValid,
138157
isValidating,
158+
hasDetails,
139159
reset,
140160
resetValidation,
141161
validate,
@@ -153,24 +173,9 @@ export const VInput = genericComponent<new <T>(
153173
return props.iconColor === true ? color.value : props.iconColor
154174
})
155175

156-
const messages = computed(() => {
157-
if (props.errorMessages?.length || (!isPristine.value && errorMessages.value.length)) {
158-
return errorMessages.value
159-
} else if (props.hint && (props.persistentHint || props.focused)) {
160-
return props.hint
161-
} else {
162-
return props.messages
163-
}
164-
})
165-
166176
useRender(() => {
167177
const hasPrepend = !!(slots.prepend || props.prependIcon)
168178
const hasAppend = !!(slots.append || props.appendIcon)
169-
const hasMessages = messages.value.length > 0
170-
const hasDetails = !props.hideDetails || (
171-
props.hideDetails === 'auto' &&
172-
(hasMessages || !!slots.details)
173-
)
174179

175180
return (
176181
<div
@@ -228,15 +233,15 @@ export const VInput = genericComponent<new <T>(
228233
</div>
229234
)}
230235

231-
{ hasDetails && (
236+
{ hasDetails.value && (
232237
<div
233238
id={ messagesId.value }
234239
class="v-input__details"
235240
role="alert"
236241
aria-live="polite"
237242
>
238243
<VMessages
239-
active={ hasMessages }
244+
active={ hasMessages.value }
240245
messages={ messages.value }
241246
v-slots={{ message: slots.message }}
242247
/>

packages/vuetify/src/components/VTextField/VTextField.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ export const VTextField = genericComponent<VTextFieldSlots>()({
181181
isDirty,
182182
isReadonly,
183183
isValid,
184+
hasDetails,
184185
reset,
185186
}) => (
186187
<VField
@@ -197,6 +198,7 @@ export const VTextField = genericComponent<VTextFieldSlots>()({
197198
dirty={ isDirty.value || props.dirty }
198199
disabled={ isDisabled.value }
199200
focused={ isFocused.value }
201+
details={ hasDetails.value }
200202
error={ isValid.value === false }
201203
>
202204
{{

0 commit comments

Comments
 (0)