diff --git a/.changeset/three-suns-move.md b/.changeset/three-suns-move.md new file mode 100644 index 00000000000..a122ea655c7 --- /dev/null +++ b/.changeset/three-suns-move.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +TextInputWithTokens: announce selected token values for screen readers. diff --git a/packages/react/src/TextInputWithTokens/TextInputWithTokens.test.tsx b/packages/react/src/TextInputWithTokens/TextInputWithTokens.test.tsx index d8d2a99615e..cb4974e4ada 100644 --- a/packages/react/src/TextInputWithTokens/TextInputWithTokens.test.tsx +++ b/packages/react/src/TextInputWithTokens/TextInputWithTokens.test.tsx @@ -60,6 +60,31 @@ describe('TextInputWithTokens', () => { expect(render()).toMatchSnapshot() }) + it('announces selected token values when used as a combobox', () => { + const onRemoveMock = vi.fn() + const {getByRole} = render( + , + ) + + const combobox = getByRole('combobox', {name: 'Tokens'}) + const describedByIds = combobox.getAttribute('aria-describedby') + + expect(describedByIds).toBeTruthy() + + const describedByNodes = describedByIds + ? describedByIds.split(' ').map(descriptionId => document.getElementById(descriptionId)) + : [] + + expect(describedByNodes.some(node => node?.textContent === 'Selected: css, react')).toBe(true) + }) + it('renders with tokens using a custom token component', () => { const onRemoveMock = vi.fn() expect( diff --git a/packages/react/src/TextInputWithTokens/TextInputWithTokens.tsx b/packages/react/src/TextInputWithTokens/TextInputWithTokens.tsx index 03d90d0d25b..8031d87a4d5 100644 --- a/packages/react/src/TextInputWithTokens/TextInputWithTokens.tsx +++ b/packages/react/src/TextInputWithTokens/TextInputWithTokens.tsx @@ -5,10 +5,12 @@ import React, {useRef, useState} from 'react' import {isValidElementType} from 'react-is' import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef' import {useFocusZone} from '../hooks/useFocusZone' +import {useId} from '../hooks/useId' import Text from '../Text' import type {TextInputProps} from '../TextInput' import Token from '../Token/Token' import type {TokenSizeKeys} from '../Token/TokenBase' +import VisuallyHidden from '../_VisuallyHidden' import type {TextInputSizes} from '../internal/components/TextInputWrapper' import TextInputWrapper from '../internal/components/TextInputWrapper' @@ -101,11 +103,32 @@ function TextInputWithTokensInnerComponent, forwardedRef: React.ForwardedRef, ) { - const {onBlur, onFocus, onKeyDown, ...inputPropsRest} = rest + const {onBlur, onFocus, onKeyDown, 'aria-describedby': ariaDescribedByProp, role, ...inputPropsRest} = rest + const ref = useRef(null) + + const selectedValuesDescriptionId = useId() useRefObjectAsForwardedRef(forwardedRef, ref) const [selectedTokenIndex, setSelectedTokenIndex] = useState() const [tokensAreTruncated, setTokensAreTruncated] = useState(Boolean(visibleTokenCount)) + const selectedTokenTexts = tokens + .map(token => { + if ('text' in token && typeof token.text === 'string' && token.text.trim().length) { + return token.text + } + + return null + }) + .filter((tokenText): tokenText is string => tokenText !== null) + const selectedValuesDescription = selectedTokenTexts.length ? `Selected: ${selectedTokenTexts.join(', ')}` : '' + const shouldExposeSelectedValuesDescription = role === 'combobox' && Boolean(selectedValuesDescription) + const ariaDescribedBy = [ + ariaDescribedByProp, + shouldExposeSelectedValuesDescription ? selectedValuesDescriptionId : undefined, + ] + .filter(Boolean) + .join(' ') + const {containerRef} = useFocusZone( { focusOutBehavior: 'wrap', @@ -295,8 +318,13 @@ function TextInputWithTokensInnerComponent + {shouldExposeSelectedValuesDescription ? ( + {selectedValuesDescription} + ) : null} {visibleTokens.map(({id, ...tokenRest}, i) => (