1+ import { defineComponent , h , nextTick , ref } from 'vue'
12import { describe , it , expect , test } from 'vitest'
23import { axe } from 'vitest-axe'
34import { mountSuspended } from '@nuxt/test-utils/runtime'
45import { renderEach } from '../component-render'
56import Slider from '../../src/runtime/components/Slider.vue'
7+ import FormField from '../../src/runtime/components/FormField.vue'
68import theme from '#build/ui/slider'
79import { flushPromises , mount } from '@vue/test-utils'
810import { 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 ( / - e r r o r $ / )
185+ } )
186+ } )
187+
43188 describe ( 'emits' , ( ) => {
44189 test ( 'update:modelValue event' , async ( ) => {
45190 const wrapper = mount ( Slider )
0 commit comments