Skip to content

Commit 60263f9

Browse files
committed
fix(emcn): describe dropdown validation accessibly
1 parent 4db7eb9 commit 60263f9

3 files changed

Lines changed: 72 additions & 12 deletions

File tree

‎apps/sim/ee/credential-groups/components/organization-workspace-grant-modal.test.tsx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,13 @@ describe('workspace integration grant editor', () => {
163163
it('connects the integration selector to its required state and explanatory hint', async () => {
164164
await render({ mode: 'all' })
165165
const trigger = document.querySelector<HTMLButtonElement>('[aria-label="Integrations"]')!
166-
expect(trigger.getAttribute('aria-required')).toBe('true')
167-
const hintId = trigger.getAttribute('aria-describedby')!
168-
expect(document.getElementById(hintId)?.textContent).toBe(
169-
'Includes integrations added in the future.'
170-
)
166+
expect(trigger.hasAttribute('aria-required')).toBe(false)
167+
const description = trigger
168+
.getAttribute('aria-describedby')!
169+
.split(' ')
170+
.map((id) => document.getElementById(id)?.textContent)
171+
.join(' ')
172+
expect(description).toBe('Includes integrations added in the future. Required.')
171173
})
172174

173175
it.each(['all', 'selected'] as const)(

‎packages/emcn/src/components/chip-dropdown/chip-dropdown.test.tsx‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44
import { act } from 'react'
55
import { createRoot, type Root } from 'react-dom/client'
66
import { afterEach, describe, expect, it } from 'vitest'
7-
import { ChipDropdown } from './chip-dropdown'
7+
import { ChipDropdown, type ChipDropdownProps } from './chip-dropdown'
88

99
let root: Root | null = null
1010
let container: HTMLDivElement | null = null
1111

12-
function mount(fullWidth: boolean): HTMLButtonElement {
12+
function mount(
13+
fullWidth: boolean,
14+
aria: Pick<ChipDropdownProps, 'aria-required' | 'aria-invalid' | 'aria-describedby'> = {}
15+
): HTMLButtonElement {
1316
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
1417
container = document.createElement('div')
1518
document.body.appendChild(container)
@@ -21,6 +24,7 @@ function mount(fullWidth: boolean): HTMLButtonElement {
2124
options={[{ value: 'workflow', label: 'Workflow' }]}
2225
fullWidth={fullWidth}
2326
aria-label='Principal type'
27+
{...aria}
2428
/>
2529
)
2630
)
@@ -53,4 +57,45 @@ describe('ChipDropdown', () => {
5357
expect(label?.className).toContain('text-clip')
5458
expect(label?.className).not.toContain('truncate')
5559
})
60+
61+
it.each([true, 'true'] as const)(
62+
'announces %s field states alongside the supplied error description',
63+
(state) => {
64+
const trigger = mount(true, {
65+
'aria-required': state,
66+
'aria-invalid': state,
67+
'aria-describedby': 'field-error',
68+
})
69+
const error = document.createElement('p')
70+
error.id = 'field-error'
71+
error.textContent = 'Choose an available principal.'
72+
container?.appendChild(error)
73+
74+
const description = trigger
75+
.getAttribute('aria-describedby')!
76+
.split(/\s+/)
77+
.map((id) => document.getElementById(id)?.textContent)
78+
.join(' ')
79+
80+
expect(description).toBe('Choose an available principal. Required. Invalid selection.')
81+
expect(trigger.getAttribute('aria-haspopup')).toBe('menu')
82+
expect(trigger.hasAttribute('aria-required')).toBe(false)
83+
expect(trigger.hasAttribute('aria-invalid')).toBe(false)
84+
expect(trigger.textContent).toBe('Workflow')
85+
}
86+
)
87+
88+
it.each([false, 'false'] as const)(
89+
'keeps the original description when field states are %s',
90+
(state) => {
91+
const trigger = mount(true, {
92+
'aria-required': state,
93+
'aria-invalid': state,
94+
'aria-describedby': 'field-hint',
95+
})
96+
97+
expect(trigger.getAttribute('aria-describedby')).toBe('field-hint')
98+
expect(container?.textContent).toBe('Workflow')
99+
}
100+
)
56101
})

‎packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
forwardRef,
77
type ReactNode,
88
useContext,
9+
useId,
910
useMemo,
1011
useState,
1112
} from 'react'
@@ -87,9 +88,9 @@ interface ChipDropdownBaseProps extends VariantProps<typeof chipVariants> {
8788
* selected value is dropped from the accessible name.
8889
*/
8990
'aria-labelledby'?: string
90-
/** Required state supplied by the enclosing field. */
91+
/** Required state announced through the menu button's accessible description. */
9192
'aria-required'?: AriaAttributes['aria-required']
92-
/** Validation state supplied by the enclosing field. */
93+
/** Validation state announced through the menu button's accessible description. */
9394
'aria-invalid'?: AriaAttributes['aria-invalid']
9495
/** Hint and error descriptions supplied by the enclosing field. */
9596
'aria-describedby'?: AriaAttributes['aria-describedby']
@@ -199,6 +200,15 @@ const ChipDropdown = forwardRef<HTMLButtonElement, ChipDropdownProps>(
199200
id,
200201
} = props
201202

203+
const fieldStateId = useId()
204+
const fieldState = [
205+
(ariaRequired === true || ariaRequired === 'true') && 'Required.',
206+
ariaInvalid && ariaInvalid !== 'false' && 'Invalid selection.',
207+
]
208+
.filter(Boolean)
209+
.join(' ')
210+
const describedBy = [ariaDescribedBy, fieldState && fieldStateId].filter(Boolean).join(' ')
211+
202212
const isMultiple = props.multiple === true
203213
const selectedValues = useMemo<string[]>(
204214
() => (isMultiple ? (props.value ?? []) : props.value != null ? [props.value] : []),
@@ -335,9 +345,7 @@ const ChipDropdown = forwardRef<HTMLButtonElement, ChipDropdownProps>(
335345
disabled={disabled}
336346
aria-label={ariaLabel}
337347
aria-labelledby={ariaLabelledBy}
338-
aria-required={ariaRequired}
339-
aria-invalid={ariaInvalid}
340-
aria-describedby={ariaDescribedBy}
348+
aria-describedby={describedBy || undefined}
341349
className={cn(
342350
chipVariants({ variant, shape, active, fullWidth }),
343351
hasTriggerBorder && TRIGGER_BORDER_CLASS,
@@ -351,6 +359,11 @@ const ChipDropdown = forwardRef<HTMLButtonElement, ChipDropdownProps>(
351359
</span>
352360
</button>
353361
</DropdownMenuTrigger>
362+
{fieldState && (
363+
<span id={fieldStateId} className='sr-only'>
364+
{fieldState}
365+
</span>
366+
)}
354367
<DropdownMenuContent
355368
align={align}
356369
onOpenAutoFocus={searchable ? (event) => event.preventDefault() : undefined}

0 commit comments

Comments
 (0)