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) => (