diff --git a/docs/content/docs/2.components/slider.md b/docs/content/docs/2.components/slider.md index 3be12a30be..1598b0ef7a 100644 --- a/docs/content/docs/2.components/slider.md +++ b/docs/content/docs/2.components/slider.md @@ -36,6 +36,12 @@ props: --- :: +::tip +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. + +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. +:: + ### Min / Max Use the `min` and `max` props to set the minimum and maximum values of the Slider. Defaults to `0` and `100`. diff --git a/src/runtime/components/Slider.vue b/src/runtime/components/Slider.vue index 50c2a7fb11..07443a2803 100644 --- a/src/runtime/components/Slider.vue +++ b/src/runtime/components/Slider.vue @@ -51,6 +51,7 @@ import { reactivePick } from '@vueuse/core' import { useAppConfig } from '#imports' import { useComponentProps } from '../composables/useComponentProps' import { useFormField } from '../composables/useFormField' +import { pick, omit } from '../utils' import { tv } from '../utils/tv' import UTooltip from './Tooltip.vue' @@ -62,6 +63,8 @@ const _props = withDefaults(defineProps(), { }) const emits = defineEmits() +defineOptions({ inheritAttrs: false }) + const props = useComponentProps('slider', _props) const modelValue = defineModel() @@ -100,6 +103,10 @@ const sliderValue = computed({ const thumbs = computed(() => sliderValue.value?.length ?? 1) +// The thumb is the element with `role="slider"`, so these describe it rather than the root. +// Multiple thumbs keep Reka UI's positional names and the caller's label groups them on the root. +const thumbAttrs = ['aria-label', 'aria-labelledby', 'aria-describedby', 'aria-valuetext', 'aria-invalid', 'aria-errormessage'] + // eslint-disable-next-line vue/no-dupe-keys const ui = computed(() => tv({ extend: theme, ...(appConfig.ui?.slider || {}) })({ disabled: disabled.value, @@ -118,12 +125,13 @@ function onChange(value: any) { diff --git a/test/components/Slider.spec.ts b/test/components/Slider.spec.ts index e80bd24fa5..fa25952cf3 100644 --- a/test/components/Slider.spec.ts +++ b/test/components/Slider.spec.ts @@ -1,8 +1,10 @@ +import { defineComponent, h, nextTick, ref } from 'vue' import { describe, it, expect, test } from 'vitest' import { axe } from 'vitest-axe' import { mountSuspended } from '@nuxt/test-utils/runtime' import { renderEach } from '../component-render' import Slider from '../../src/runtime/components/Slider.vue' +import FormField from '../../src/runtime/components/FormField.vue' import theme from '#build/ui/slider' import { flushPromises, mount } from '@vue/test-utils' import { renderForm } from '../utils/form' @@ -25,6 +27,8 @@ describe('Slider', () => { ...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]), ['with color neutral', { props: { color: 'neutral', defaultValue: 10 } }], ['with ariaLabel', { attrs: { 'aria-label': 'Aria label' } }], + ['with ariaLabel and multiple thumbs', { props: { defaultValue: [0, 10] }, attrs: { 'aria-label': 'Aria label' } }], + ['with ariaValueText', { props: { modelValue: 10 }, attrs: { 'aria-valuetext': '10 milliseconds' } }], ['with as', { props: { as: 'section' } }], ['with class', { props: { class: 'w-48' } }], ['with ui', { props: { ui: { track: 'bg-elevated' } } }] @@ -40,6 +44,147 @@ describe('Slider', () => { expect(await axe(wrapper.element)).toHaveNoViolations() }) + describe('aria', () => { + async function renderThumbs(options: { props?: any, attrs?: any } = {}) { + const wrapper = await mountSuspended(Slider, options) + return { wrapper, thumbs: wrapper.findAll('[role="slider"]') } + } + + test('names a single thumb from aria-label', async () => { + const { wrapper, thumbs } = await renderThumbs({ props: { modelValue: 10 }, attrs: { 'aria-label': 'Volume' } }) + + expect(thumbs).toHaveLength(1) + expect(thumbs[0]!.attributes('aria-label')).toBe('Volume') + expect(wrapper.get('[data-slot="root"]').attributes('aria-label')).toBeUndefined() + }) + + test('names a single thumb from aria-labelledby', async () => { + const { thumbs } = await renderThumbs({ props: { modelValue: 10 }, attrs: { 'aria-labelledby': 'volume-label' } }) + + expect(thumbs[0]!.attributes('aria-labelledby')).toBe('volume-label') + expect(thumbs[0]!.attributes('aria-label')).toBeUndefined() + }) + + test('falls back to a default label when a single thumb is unnamed', async () => { + const { thumbs } = await renderThumbs({ props: { modelValue: 10 } }) + + expect(thumbs[0]!.attributes('aria-label')).toBe('Thumb') + }) + + test('keeps Reka UI default labels for two thumbs', async () => { + const { thumbs } = await renderThumbs({ props: { modelValue: [0, 10] } }) + + expect(thumbs.map(thumb => thumb.attributes('aria-label'))).toStrictEqual(['Minimum', 'Maximum']) + }) + + test('keeps Reka UI default labels for three or more thumbs', async () => { + const { thumbs } = await renderThumbs({ props: { modelValue: [0, 10, 20] } }) + + expect(thumbs.map(thumb => thumb.attributes('aria-label'))).toStrictEqual(['Value 1 of 3', 'Value 2 of 3', 'Value 3 of 3']) + }) + + test('groups multiple thumbs under an aria-label instead of naming each of them', async () => { + const { wrapper, thumbs } = await renderThumbs({ props: { modelValue: [10, 90] }, attrs: { 'aria-label': 'Price range' } }) + + expect(thumbs.map(thumb => thumb.attributes('aria-label'))).toStrictEqual(['Minimum', 'Maximum']) + + const root = wrapper.get('[data-slot="root"]') + expect(root.attributes('aria-label')).toBe('Price range') + expect(root.attributes('role')).toBe('group') + }) + + test('groups three or more thumbs under an aria-label instead of naming each of them', async () => { + const { wrapper, thumbs } = await renderThumbs({ props: { modelValue: [0, 10, 20] }, attrs: { 'aria-label': 'Levels' } }) + + expect(thumbs.map(thumb => thumb.attributes('aria-label'))).toStrictEqual(['Value 1 of 3', 'Value 2 of 3', 'Value 3 of 3']) + + const root = wrapper.get('[data-slot="root"]') + expect(root.attributes('aria-label')).toBe('Levels') + expect(root.attributes('role')).toBe('group') + }) + + test('does not group an unlabelled slider', async () => { + const { wrapper } = await renderThumbs({ props: { modelValue: [10, 90] } }) + + expect(wrapper.get('[data-slot="root"]').attributes('role')).toBeUndefined() + }) + + test('forwards aria-valuetext to the thumb', async () => { + const { thumbs } = await renderThumbs({ props: { modelValue: 10 }, attrs: { 'aria-valuetext': '10 milliseconds' } }) + + expect(thumbs[0]!.attributes('aria-valuetext')).toBe('10 milliseconds') + }) + + test('forwards validity attributes to the thumb', async () => { + const { wrapper, thumbs } = await renderThumbs({ props: { modelValue: 10 }, attrs: { 'aria-invalid': 'true', 'aria-errormessage': 'volume-error' } }) + + expect(thumbs[0]!.attributes('aria-invalid')).toBe('true') + expect(thumbs[0]!.attributes('aria-errormessage')).toBe('volume-error') + expect(wrapper.get('[data-slot="root"]').attributes('aria-invalid')).toBeUndefined() + expect(wrapper.get('[data-slot="root"]').attributes('aria-errormessage')).toBeUndefined() + }) + + test('keeps non-aria attributes on the root', async () => { + const { wrapper, thumbs } = await renderThumbs({ props: { modelValue: 10 }, attrs: { 'data-testid': 'slider' } }) + + expect(wrapper.get('[data-slot="root"]').attributes('data-testid')).toBe('slider') + expect(thumbs[0]!.attributes('data-testid')).toBeUndefined() + }) + + // Pin that attributes changed by a parent re-render still reach the thumb. + test('tracks aria attributes changed after mount', async () => { + const label = ref('Volume') + const Parent = defineComponent({ + setup: () => () => h(Slider, { 'modelValue': 10, 'aria-label': label.value }) + }) + + const wrapper = await mountSuspended(Parent) + expect(wrapper.get('[role="slider"]').attributes('aria-label')).toBe('Volume') + + label.value = undefined + await nextTick() + await nextTick() + + expect(wrapper.get('[role="slider"]').attributes('aria-label')).toBe('Thumb') + }) + + test('tracks aria attributes added after mounting without any', async () => { + const extra = ref>({}) + const Parent = defineComponent({ + setup: () => () => h(Slider, { modelValue: 10, ...extra.value }) + }) + + const wrapper = await mountSuspended(Parent) + expect(wrapper.get('[role="slider"]').attributes('aria-label')).toBe('Thumb') + + extra.value = { 'aria-label': 'Volume', 'data-testid': 'slider' } + await nextTick() + await nextTick() + + expect(wrapper.get('[role="slider"]').attributes('aria-label')).toBe('Volume') + expect(wrapper.get('[data-slot="root"]').attributes('data-testid')).toBe('slider') + }) + + test('keeps a caller role on a grouped slider', async () => { + const { wrapper } = await renderThumbs({ props: { modelValue: [10, 90] }, attrs: { 'role': 'application', 'aria-label': 'Price range' } }) + + expect(wrapper.get('[data-slot="root"]').attributes('role')).toBe('application') + }) + + // The thumb carries both the caller's `aria-*` and the ones `useFormField` derives. + test('merges the form aria attributes with a caller label on the thumb', async () => { + const wrapper = await mountSuspended(FormField, { + props: { error: 'Error' }, + slots: { default: () => h(Slider, { 'modelValue': 10, 'aria-label': 'Volume' }) } + }) + + const thumb = wrapper.get('[role="slider"]') + expect(thumb.attributes('aria-label')).toBe('Volume') + expect(thumb.attributes('aria-invalid')).toBe('true') + expect(thumb.attributes('aria-describedby')).toMatch(/-error$/) + }) + }) + describe('emits', () => { test('update:modelValue event', async () => { const wrapper = mount(Slider) diff --git a/test/components/__snapshots__/Slider-vue.spec.ts.snap b/test/components/__snapshots__/Slider-vue.spec.ts.snap index a325f5689f..1c3724211e 100644 --- a/test/components/__snapshots__/Slider-vue.spec.ts.snap +++ b/test/components/__snapshots__/Slider-vue.spec.ts.snap @@ -1,7 +1,17 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`Slider > renders with ariaLabel and multiple thumbs correctly 1`] = ` +" +" +`; + exports[`Slider > renders with ariaLabel correctly 1`] = ` -" +" +" +`; + +exports[`Slider > renders with ariaValueText correctly 1`] = ` +" " `; @@ -42,7 +52,7 @@ exports[`Slider > renders with min max step correctly 1`] = ` `; exports[`Slider > renders with min steps between thumbs correctly 1`] = ` -" +" " `; @@ -52,7 +62,7 @@ exports[`Slider > renders with modelValue correctly 1`] = ` `; exports[`Slider > renders with multiple thumbs correctly 1`] = ` -" +" " `; diff --git a/test/components/__snapshots__/Slider.spec.ts.snap b/test/components/__snapshots__/Slider.spec.ts.snap index a325f5689f..1c3724211e 100644 --- a/test/components/__snapshots__/Slider.spec.ts.snap +++ b/test/components/__snapshots__/Slider.spec.ts.snap @@ -1,7 +1,17 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`Slider > renders with ariaLabel and multiple thumbs correctly 1`] = ` +" +" +`; + exports[`Slider > renders with ariaLabel correctly 1`] = ` -" +" +" +`; + +exports[`Slider > renders with ariaValueText correctly 1`] = ` +" " `; @@ -42,7 +52,7 @@ exports[`Slider > renders with min max step correctly 1`] = ` `; exports[`Slider > renders with min steps between thumbs correctly 1`] = ` -" +" " `; @@ -52,7 +62,7 @@ exports[`Slider > renders with modelValue correctly 1`] = ` `; exports[`Slider > renders with multiple thumbs correctly 1`] = ` -" +" " `;