Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/evil-islands-find.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Fixes an accessibility issue where screen readers announce segmented control buttons without their associated count.
58 changes: 58 additions & 0 deletions packages/react/src/SegmentedControl/SegmentedControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,64 @@ describe('SegmentedControl', () => {
expect(getByRole('img', {name: 'EyeIcon'})).toBeInTheDocument()
})

it('includes the count in the accessible name when aria-label is provided', () => {
const {getByRole} = render(
<SegmentedControl aria-label="Issues by label">
<SegmentedControl.Button defaultSelected aria-label="Feature" count={5}>
Feature
</SegmentedControl.Button>
</SegmentedControl>,
)

const button = getByRole('button', {name: 'Feature 5'})

expect(button).toBeInTheDocument()
})

it('uses aria-label without modification when count is undefined', () => {
const {getByRole} = render(
<SegmentedControl aria-label="Issues by label">
<SegmentedControl.Button defaultSelected aria-label="Feature">
Feature
</SegmentedControl.Button>
</SegmentedControl>,
)

const button = getByRole('button', {name: 'Feature'})

expect(button).toBeInTheDocument()
expect(button).toHaveAttribute('aria-label', 'Feature')
})

it('does not set aria-label when only count is provided and relies on text content', () => {
const {getByRole} = render(
<SegmentedControl aria-label="Issues by label">
<SegmentedControl.Button defaultSelected count={5}>
Feature
</SegmentedControl.Button>
</SegmentedControl>,
)

const button = getByRole('button', {name: /Feature\s*\(\s*5\s*\)/})

expect(button).toBeInTheDocument()
expect(button).not.toHaveAttribute('aria-label')
})

it('handles a string count when aria-label is provided', () => {
const {getByRole} = render(
<SegmentedControl aria-label="Issues by label">
<SegmentedControl.Button defaultSelected aria-label="Feature" count="5">
Feature
</SegmentedControl.Button>
</SegmentedControl>,
)

const button = getByRole('button', {name: 'Feature 5'})

expect(button).toBeInTheDocument()
})

it('should warn the user if they neglect to specify a label for the segmented control', () => {
const spy = vi.spyOn(globalThis.console, 'warn').mockImplementation(() => {})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,16 @@ const SegmentedControlButton: FCWithSlotMarker<React.PropsWithChildren<Segmented
count,
...props
}) => {
const {'aria-disabled': ariaDisabled, ...rest} = props
const {'aria-disabled': ariaDisabled, 'aria-label': ariaLabel, ...rest} = props
// Use leadingVisual if provided, otherwise fall back to leadingIcon for backwards compatibility
const LeadingVisual = leadingVisual ?? leadingIcon
const computedAriaLabel = ariaLabel !== undefined && count !== undefined ? `${ariaLabel} ${count}` : ariaLabel

return (
<li className={clsx(classes.Item)} data-selected={selected ? '' : undefined}>
<button
aria-current={selected}
aria-label={computedAriaLabel}
aria-disabled={disabled || ariaDisabled || undefined}
className={clsx(classes.Button, className)}
type="button"
Expand Down
Loading