From 00a94ee0160b12d2ccf2e3149026ba8f4122f00c Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Fri, 30 Oct 2026 10:33:00 -0700 Subject: [PATCH 1/3] fix(Typeahead): the value token's button is named by the field label and the value (WCAG 2.4.6, 4.1.2) With a value, Typeahead takes its input out of the Tab order, so the Tab stop is the token's inner button, and that button was named by the value alone ("Apple, button"), with no field name, description or status. It is now labelled by the field label and the value and described like the input. A clickable Token passes aria-labelledby and aria-describedby to the inner button that owns its action (public-component-api INV5) instead of the generic root. Fixes #8945. --- .changeset/typeahead-token-tab-stop-named.md | 7 +++ packages/core/src/Token/Token.test.tsx | 19 ++++++++ packages/core/src/Token/Token.tsx | 14 ++++++ .../core/src/Typeahead/Typeahead.test.tsx | 48 ++++++++++++++----- packages/core/src/Typeahead/Typeahead.tsx | 34 +++++++++---- 5 files changed, 103 insertions(+), 19 deletions(-) create mode 100644 .changeset/typeahead-token-tab-stop-named.md diff --git a/.changeset/typeahead-token-tab-stop-named.md b/.changeset/typeahead-token-tab-stop-named.md new file mode 100644 index 0000000000000..204f03e431a20 --- /dev/null +++ b/.changeset/typeahead-token-tab-stop-named.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] With a value, a `Typeahead`'s Tab stop is the value's token button, and it is now named by the field label and the value ("Fruit Apple", not just "Apple") and described by the field's description and status, so screen reader users know which field it belongs to. A clickable `Token` now passes `aria-labelledby` and `aria-describedby` to its inner button. + +@cixzhang @duytbp diff --git a/packages/core/src/Token/Token.test.tsx b/packages/core/src/Token/Token.test.tsx index 4426fc386716c..a923410f3291d 100644 --- a/packages/core/src/Token/Token.test.tsx +++ b/packages/core/src/Token/Token.test.tsx @@ -256,6 +256,25 @@ describe('Token accessibility', () => { ).toBeInTheDocument(); }); + it("passes aria-labelledby and aria-describedby to a clickable token's button (public-component-api INV5)", () => { + render( + <> + Fruit + Pick one + {}} + aria-labelledby="field-label" + aria-describedby="field-hint" + data-testid="token" + /> + , + ); + const button = screen.getByRole('button', {name: 'Fruit'}); + expect(button).toHaveAccessibleDescription('Pick one'); + expect(screen.getByTestId('token')).not.toHaveAttribute('aria-labelledby'); + }); + it('keeps both buttons focusable and inert when isDisabled is true', async () => { const user = userEvent.setup(); const handleClick = vi.fn(); diff --git a/packages/core/src/Token/Token.tsx b/packages/core/src/Token/Token.tsx index 57c5b3d6f351c..f7f24ea6cb189 100644 --- a/packages/core/src/Token/Token.tsx +++ b/packages/core/src/Token/Token.tsx @@ -299,6 +299,8 @@ export function Token({ className, style, 'data-testid': testId, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, ref, ...rest }: TokenProps) { @@ -342,6 +344,14 @@ export function Token({ ); + // Naming inputs reach the element that owns the token's action: the inner + // button of a clickable token, otherwise the root + // (architecture:public-component-api INV5). + const labellingProps = { + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + }; + const sharedProps = { 'data-testid': testId, ...(isLabelHidden ? {'aria-label': label} : {}), @@ -370,6 +380,7 @@ export function Token({ style, )} {...rest} + {...labellingProps} {...(isDisabled ? {'aria-disabled': true} : {})} {...sharedProps}> {content} @@ -419,6 +430,7 @@ export function Token({ style, )} {...rest} + {...labellingProps} {...sharedProps} /> ); @@ -458,6 +470,7 @@ export function Token({