Skip to content

Commit d6c3802

Browse files
J-MichalekJakubbenjamincanac
authored
fix(Slider): forward aria attributes to the thumb (#6848)
Co-authored-by: Jakub <jakub.michalek@freelo.io> Co-authored-by: Benjamin Canac <canacb1@gmail.com>
1 parent 07f3fe8 commit d6c3802

5 files changed

Lines changed: 189 additions & 10 deletions

File tree

docs/content/docs/2.components/slider.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ props:
3636
---
3737
::
3838

39+
::tip
40+
Use `aria-label` or `aria-labelledby` to name a single thumb Slider, they are forwarded to the thumb which is the element with the `slider` role.
41+
42+
The thumbs of a multiple thumbs Slider are named by their position so they can be told apart, `Minimum` / `Maximum` for two thumbs and `Value n of m` for three or more. Those names are kept, and an `aria-label` names the Slider as a whole through a `group` role on the root instead of being repeated on every thumb.
43+
::
44+
3945
### Min / Max
4046

4147
Use the `min` and `max` props to set the minimum and maximum values of the Slider. Defaults to `0` and `100`.

src/runtime/components/Slider.vue

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { reactivePick } from '@vueuse/core'
5151
import { useAppConfig } from '#imports'
5252
import { useComponentProps } from '../composables/useComponentProps'
5353
import { useFormField } from '../composables/useFormField'
54+
import { pick, omit } from '../utils'
5455
import { tv } from '../utils/tv'
5556
import UTooltip from './Tooltip.vue'
5657
@@ -62,6 +63,8 @@ const _props = withDefaults(defineProps<SliderProps>(), {
6263
})
6364
const emits = defineEmits<SliderEmits>()
6465
66+
defineOptions({ inheritAttrs: false })
67+
6568
const props = useComponentProps<SliderProps>('slider', _props)
6669
6770
const modelValue = defineModel<T>()
@@ -100,6 +103,10 @@ const sliderValue = computed({
100103
101104
const thumbs = computed(() => sliderValue.value?.length ?? 1)
102105
106+
// The thumb is the element with `role="slider"`, so these describe it rather than the root.
107+
// Multiple thumbs keep Reka UI's positional names and the caller's label groups them on the root.
108+
const thumbAttrs = ['aria-label', 'aria-labelledby', 'aria-describedby', 'aria-valuetext', 'aria-invalid', 'aria-errormessage']
109+
103110
// eslint-disable-next-line vue/no-dupe-keys
104111
const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.slider || {}) })({
105112
disabled: disabled.value,
@@ -118,12 +125,13 @@ function onChange(value: any) {
118125

119126
<template>
120127
<SliderRoot
121-
v-bind="rootProps"
122128
:id="id"
123129
v-model="sliderValue"
130+
data-slot="root"
131+
:role="thumbs > 1 && ($attrs['aria-label'] || $attrs['aria-labelledby']) ? 'group' : undefined"
132+
v-bind="{ ...rootProps, ...(thumbs > 1 ? $attrs : omit($attrs, thumbAttrs)) }"
124133
:name="name"
125134
:disabled="disabled"
126-
data-slot="root"
127135
:class="ui.root({ class: [props.ui?.root, props.class] })"
128136
:default-value="defaultSliderValue"
129137
@update:model-value="emitFormInput()"
@@ -140,9 +148,9 @@ function onChange(value: any) {
140148
disable-closing-trigger
141149
v-bind="(typeof props.tooltip === 'object' ? props.tooltip : {})"
142150
>
143-
<SliderThumb data-slot="thumb" :class="ui.thumb({ class: props.ui?.thumb })" :aria-label="thumbs === 1 ? 'Thumb' : `Thumb ${thumb} of ${thumbs}`" v-bind="ariaAttrs" />
151+
<SliderThumb data-slot="thumb" :class="ui.thumb({ class: props.ui?.thumb })" v-bind="{ ...(thumbs === 1 ? pick($attrs, thumbAttrs) : {}), ...ariaAttrs }" :aria-label="thumbs > 1 || $attrs['aria-labelledby'] ? undefined : ($attrs['aria-label'] ?? 'Thumb')" />
144152
</UTooltip>
145-
<SliderThumb v-else data-slot="thumb" :class="ui.thumb({ class: props.ui?.thumb })" :aria-label="thumbs === 1 ? 'Thumb' : `Thumb ${thumb} of ${thumbs}`" v-bind="ariaAttrs" />
153+
<SliderThumb v-else data-slot="thumb" :class="ui.thumb({ class: props.ui?.thumb })" v-bind="{ ...(thumbs === 1 ? pick($attrs, thumbAttrs) : {}), ...ariaAttrs }" :aria-label="thumbs > 1 || $attrs['aria-labelledby'] ? undefined : ($attrs['aria-label'] ?? 'Thumb')" />
146154
</template>
147155
</SliderRoot>
148156
</template>

test/components/Slider.spec.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { defineComponent, h, nextTick, ref } from 'vue'
12
import { describe, it, expect, test } from 'vitest'
23
import { axe } from 'vitest-axe'
34
import { mountSuspended } from '@nuxt/test-utils/runtime'
45
import { renderEach } from '../component-render'
56
import Slider from '../../src/runtime/components/Slider.vue'
7+
import FormField from '../../src/runtime/components/FormField.vue'
68
import theme from '#build/ui/slider'
79
import { flushPromises, mount } from '@vue/test-utils'
810
import { renderForm } from '../utils/form'
@@ -25,6 +27,8 @@ describe('Slider', () => {
2527
...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]),
2628
['with color neutral', { props: { color: 'neutral', defaultValue: 10 } }],
2729
['with ariaLabel', { attrs: { 'aria-label': 'Aria label' } }],
30+
['with ariaLabel and multiple thumbs', { props: { defaultValue: [0, 10] }, attrs: { 'aria-label': 'Aria label' } }],
31+
['with ariaValueText', { props: { modelValue: 10 }, attrs: { 'aria-valuetext': '10 milliseconds' } }],
2832
['with as', { props: { as: 'section' } }],
2933
['with class', { props: { class: 'w-48' } }],
3034
['with ui', { props: { ui: { track: 'bg-elevated' } } }]
@@ -40,6 +44,147 @@ describe('Slider', () => {
4044
expect(await axe(wrapper.element)).toHaveNoViolations()
4145
})
4246

47+
describe('aria', () => {
48+
async function renderThumbs(options: { props?: any, attrs?: any } = {}) {
49+
const wrapper = await mountSuspended(Slider, options)
50+
return { wrapper, thumbs: wrapper.findAll('[role="slider"]') }
51+
}
52+
53+
test('names a single thumb from aria-label', async () => {
54+
const { wrapper, thumbs } = await renderThumbs({ props: { modelValue: 10 }, attrs: { 'aria-label': 'Volume' } })
55+
56+
expect(thumbs).toHaveLength(1)
57+
expect(thumbs[0]!.attributes('aria-label')).toBe('Volume')
58+
expect(wrapper.get('[data-slot="root"]').attributes('aria-label')).toBeUndefined()
59+
})
60+
61+
test('names a single thumb from aria-labelledby', async () => {
62+
const { thumbs } = await renderThumbs({ props: { modelValue: 10 }, attrs: { 'aria-labelledby': 'volume-label' } })
63+
64+
expect(thumbs[0]!.attributes('aria-labelledby')).toBe('volume-label')
65+
expect(thumbs[0]!.attributes('aria-label')).toBeUndefined()
66+
})
67+
68+
test('falls back to a default label when a single thumb is unnamed', async () => {
69+
const { thumbs } = await renderThumbs({ props: { modelValue: 10 } })
70+
71+
expect(thumbs[0]!.attributes('aria-label')).toBe('Thumb')
72+
})
73+
74+
test('keeps Reka UI default labels for two thumbs', async () => {
75+
const { thumbs } = await renderThumbs({ props: { modelValue: [0, 10] } })
76+
77+
expect(thumbs.map(thumb => thumb.attributes('aria-label'))).toStrictEqual(['Minimum', 'Maximum'])
78+
})
79+
80+
test('keeps Reka UI default labels for three or more thumbs', async () => {
81+
const { thumbs } = await renderThumbs({ props: { modelValue: [0, 10, 20] } })
82+
83+
expect(thumbs.map(thumb => thumb.attributes('aria-label'))).toStrictEqual(['Value 1 of 3', 'Value 2 of 3', 'Value 3 of 3'])
84+
})
85+
86+
test('groups multiple thumbs under an aria-label instead of naming each of them', async () => {
87+
const { wrapper, thumbs } = await renderThumbs({ props: { modelValue: [10, 90] }, attrs: { 'aria-label': 'Price range' } })
88+
89+
expect(thumbs.map(thumb => thumb.attributes('aria-label'))).toStrictEqual(['Minimum', 'Maximum'])
90+
91+
const root = wrapper.get('[data-slot="root"]')
92+
expect(root.attributes('aria-label')).toBe('Price range')
93+
expect(root.attributes('role')).toBe('group')
94+
})
95+
96+
test('groups three or more thumbs under an aria-label instead of naming each of them', async () => {
97+
const { wrapper, thumbs } = await renderThumbs({ props: { modelValue: [0, 10, 20] }, attrs: { 'aria-label': 'Levels' } })
98+
99+
expect(thumbs.map(thumb => thumb.attributes('aria-label'))).toStrictEqual(['Value 1 of 3', 'Value 2 of 3', 'Value 3 of 3'])
100+
101+
const root = wrapper.get('[data-slot="root"]')
102+
expect(root.attributes('aria-label')).toBe('Levels')
103+
expect(root.attributes('role')).toBe('group')
104+
})
105+
106+
test('does not group an unlabelled slider', async () => {
107+
const { wrapper } = await renderThumbs({ props: { modelValue: [10, 90] } })
108+
109+
expect(wrapper.get('[data-slot="root"]').attributes('role')).toBeUndefined()
110+
})
111+
112+
test('forwards aria-valuetext to the thumb', async () => {
113+
const { thumbs } = await renderThumbs({ props: { modelValue: 10 }, attrs: { 'aria-valuetext': '10 milliseconds' } })
114+
115+
expect(thumbs[0]!.attributes('aria-valuetext')).toBe('10 milliseconds')
116+
})
117+
118+
test('forwards validity attributes to the thumb', async () => {
119+
const { wrapper, thumbs } = await renderThumbs({ props: { modelValue: 10 }, attrs: { 'aria-invalid': 'true', 'aria-errormessage': 'volume-error' } })
120+
121+
expect(thumbs[0]!.attributes('aria-invalid')).toBe('true')
122+
expect(thumbs[0]!.attributes('aria-errormessage')).toBe('volume-error')
123+
expect(wrapper.get('[data-slot="root"]').attributes('aria-invalid')).toBeUndefined()
124+
expect(wrapper.get('[data-slot="root"]').attributes('aria-errormessage')).toBeUndefined()
125+
})
126+
127+
test('keeps non-aria attributes on the root', async () => {
128+
const { wrapper, thumbs } = await renderThumbs({ props: { modelValue: 10 }, attrs: { 'data-testid': 'slider' } })
129+
130+
expect(wrapper.get('[data-slot="root"]').attributes('data-testid')).toBe('slider')
131+
expect(thumbs[0]!.attributes('data-testid')).toBeUndefined()
132+
})
133+
134+
// Pin that attributes changed by a parent re-render still reach the thumb.
135+
test('tracks aria attributes changed after mount', async () => {
136+
const label = ref<string | undefined>('Volume')
137+
const Parent = defineComponent({
138+
setup: () => () => h(Slider, { 'modelValue': 10, 'aria-label': label.value })
139+
})
140+
141+
const wrapper = await mountSuspended(Parent)
142+
expect(wrapper.get('[role="slider"]').attributes('aria-label')).toBe('Volume')
143+
144+
label.value = undefined
145+
await nextTick()
146+
await nextTick()
147+
148+
expect(wrapper.get('[role="slider"]').attributes('aria-label')).toBe('Thumb')
149+
})
150+
151+
test('tracks aria attributes added after mounting without any', async () => {
152+
const extra = ref<Record<string, string>>({})
153+
const Parent = defineComponent({
154+
setup: () => () => h(Slider, { modelValue: 10, ...extra.value })
155+
})
156+
157+
const wrapper = await mountSuspended(Parent)
158+
expect(wrapper.get('[role="slider"]').attributes('aria-label')).toBe('Thumb')
159+
160+
extra.value = { 'aria-label': 'Volume', 'data-testid': 'slider' }
161+
await nextTick()
162+
await nextTick()
163+
164+
expect(wrapper.get('[role="slider"]').attributes('aria-label')).toBe('Volume')
165+
expect(wrapper.get('[data-slot="root"]').attributes('data-testid')).toBe('slider')
166+
})
167+
168+
test('keeps a caller role on a grouped slider', async () => {
169+
const { wrapper } = await renderThumbs({ props: { modelValue: [10, 90] }, attrs: { 'role': 'application', 'aria-label': 'Price range' } })
170+
171+
expect(wrapper.get('[data-slot="root"]').attributes('role')).toBe('application')
172+
})
173+
174+
// The thumb carries both the caller's `aria-*` and the ones `useFormField` derives.
175+
test('merges the form aria attributes with a caller label on the thumb', async () => {
176+
const wrapper = await mountSuspended(FormField, {
177+
props: { error: 'Error' },
178+
slots: { default: () => h(Slider, { 'modelValue': 10, 'aria-label': 'Volume' }) }
179+
})
180+
181+
const thumb = wrapper.get('[role="slider"]')
182+
expect(thumb.attributes('aria-label')).toBe('Volume')
183+
expect(thumb.attributes('aria-invalid')).toBe('true')
184+
expect(thumb.attributes('aria-describedby')).toMatch(/-error$/)
185+
})
186+
})
187+
43188
describe('emits', () => {
44189
test('update:modelValue event', async () => {
45190
const wrapper = mount(Slider)

0 commit comments

Comments
 (0)