From aac5e4334945a562c11ecc5d7268b71d0562bb27 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Tue, 3 Mar 2026 15:10:16 -0800 Subject: [PATCH 1/3] add selectedToken aria-as describedby --- .changeset/three-suns-move.md | 5 ++++ .../TextInputWithTokens.test.tsx | 25 ++++++++++++++++ .../TextInputWithTokens.tsx | 30 ++++++++++++++++++- 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 .changeset/three-suns-move.md diff --git a/.changeset/three-suns-move.md b/.changeset/three-suns-move.md new file mode 100644 index 00000000000..bdefe0b43ef --- /dev/null +++ b/.changeset/three-suns-move.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +TextInputTokens: 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..4051a482300 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, container} = render( + , + ) + + const combobox = getByRole('combobox', {name: 'Tokens'}) + const describedByIds = combobox.getAttribute('aria-describedby') + + expect(describedByIds).toBeTruthy() + + const describedByNodes = describedByIds + ? describedByIds.split(' ').map(descriptionId => container.querySelector(`#${descriptionId}`)) + : [] + + expect(describedByNodes.some(node => node?.textContent === 'Selected values: 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) => ( Date: Tue, 3 Mar 2026 15:30:18 -0800 Subject: [PATCH 2/3] test fix --- .../src/TextInputWithTokens/TextInputWithTokens.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react/src/TextInputWithTokens/TextInputWithTokens.test.tsx b/packages/react/src/TextInputWithTokens/TextInputWithTokens.test.tsx index 4051a482300..cb4974e4ada 100644 --- a/packages/react/src/TextInputWithTokens/TextInputWithTokens.test.tsx +++ b/packages/react/src/TextInputWithTokens/TextInputWithTokens.test.tsx @@ -62,7 +62,7 @@ describe('TextInputWithTokens', () => { it('announces selected token values when used as a combobox', () => { const onRemoveMock = vi.fn() - const {getByRole, container} = render( + const {getByRole} = render( { expect(describedByIds).toBeTruthy() const describedByNodes = describedByIds - ? describedByIds.split(' ').map(descriptionId => container.querySelector(`#${descriptionId}`)) + ? describedByIds.split(' ').map(descriptionId => document.getElementById(descriptionId)) : [] - expect(describedByNodes.some(node => node?.textContent === 'Selected values: css, react')).toBe(true) + expect(describedByNodes.some(node => node?.textContent === 'Selected: css, react')).toBe(true) }) it('renders with tokens using a custom token component', () => { From c8a87d474e5cb49d889340504a71a8efa53841f3 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Wed, 4 Mar 2026 13:27:52 -0800 Subject: [PATCH 3/3] changeset --- .changeset/three-suns-move.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/three-suns-move.md b/.changeset/three-suns-move.md index bdefe0b43ef..a122ea655c7 100644 --- a/.changeset/three-suns-move.md +++ b/.changeset/three-suns-move.md @@ -2,4 +2,4 @@ '@primer/react': patch --- -TextInputTokens: announce selected token values for screen readers. +TextInputWithTokens: announce selected token values for screen readers.