Skip to content

fix(a11y): repair broken aria-label, conflicting Toast role, and missing focus indicators#3374

Open
orrgottlieb wants to merge 1 commit into
masterfrom
fix/a11y-aria-label-and-focus-rings
Open

fix(a11y): repair broken aria-label, conflicting Toast role, and missing focus indicators#3374
orrgottlieb wants to merge 1 commit into
masterfrom
fix/a11y-aria-label-and-focus-rings

Conversation

@orrgottlieb

@orrgottlieb orrgottlieb commented May 22, 2026

Copy link
Copy Markdown
Contributor

User description

Summary

Three accessibility fixes from the audit, bundled into one PR:

  • ColorPicker — broken accessible name (P0). aria-labelledby="Color Picker Dialog" was passing literal text into an ID-reference attribute, so screen readers computed an empty name for the dialog. Same shape on aria-describedby="Pick color". Replaced both with a single aria-label="Color Picker Dialog".
  • Toast — conflicting live-region semantics (P0). role="alert" (implicit aria-live="assertive") combined with explicit aria-live="polite" produced contradictory behavior across screen readers (some announce immediately, others queue, some ignore). Switched to role="status", which already implies aria-live="polite", and dropped the explicit attribute.
  • Focus indicators — keyboard focus suppressed (P1). Seven SCSS files used the bare :focus { outline: none } pattern, stripping the focus ring for keyboard users (WCAG 2.4.7 violation). Replaced with the standard :focus:not(:focus-visible) pattern so mouse/programmatic focus stays clean while keyboard focus stays visible: Dialog, DialogContentContainer, AvatarGroupCounterTooltipContent (+ virtualized variant), BreadcrumbContent, Menu, Tab.

Components reviewed and intentionally not modified because they already have a custom focus indicator or only receive programmatic focus (Modal, TabList, Avatar, Checkbox, RadioButton, BaseMenuItem, TextField, TextArea, EditableTypography, DatePicker day cells, ColorPicker grid/items, Dropdown trigger, Icon's opt-in .noFocusStyle). Snapshots regenerated for Toast (6 entries) and ColorPicker (2 entries) to reflect the attribute changes.

Test plan

  • yarn workspace @vibe/core test --run — 141 files / 1338 tests pass; 11 snapshots updated to reflect Toast role/aria-live and ColorPicker aria-label/aria-describedby change
  • yarn workspace @vibe/dialog test --run — 5 files / 45 tests pass
  • yarn workspace @vibe/core lint — 0 errors (only pre-existing warnings)
  • yarn workspace @vibe/core stylelint — 0 errors (only pre-existing design-token warnings)
  • yarn workspace @vibe/dialog lint / stylelint — 0 errors
  • Manual: verify keyboard focus ring is visible when tabbing through Dialog, Menu, Breadcrumbs, Tabs, AvatarGroup tooltip
  • Manual: verify Toast announces with VoiceOver / NVDA / JAWS without double-firing
  • Manual: verify ColorPicker dialog name is announced by screen readers

🤖 Generated with Claude Code


PR Type

Bug fix


Description

  • Fixed ColorPicker dialog accessible name by replacing broken aria-labelledby with aria-label

  • Resolved Toast conflicting live-region semantics by switching from role="alert" to role="status"

  • Restored keyboard focus indicators across seven components using :focus:not(:focus-visible) pattern


Diagram Walkthrough

flowchart LR
  A["Accessibility Issues"] --> B["ColorPicker Dialog"]
  A --> C["Toast Live Region"]
  A --> D["Focus Indicators"]
  B --> B1["aria-labelledby → aria-label"]
  C --> C1["role=alert → role=status"]
  D --> D1["7 Components: Dialog, Menu, Tabs, etc."]
  D1 --> D2[":focus → :focus:not(:focus-visible)"]
Loading

File Walkthrough

Relevant files
Bug fix
9 files
DialogContent.module.scss
Replace bare focus outline with focus-visible pattern       
+1/-1     
DialogContentContainer.module.scss
Replace bare focus outline with focus-visible pattern       
+1/-1     
AvatarGroupCounterTooltipContent.module.scss
Simplify focus selector to focus-visible pattern                 
+1/-3     
AvatarGroupCounterTooltipContentVirtualizedList.module.scss
Simplify focus selector to focus-visible pattern                 
+1/-3     
BreadcrumbContent.module.scss
Replace bare focus outline with focus-visible pattern       
+1/-1     
Menu.module.scss
Replace bare focus outline with focus-visible pattern       
+1/-1     
Tab.module.scss
Replace bare focus outline with focus-visible pattern       
+1/-1     
ColorPicker.tsx
Fix broken aria-labelledby with proper aria-label               
+1/-2     
Toast.tsx
Replace conflicting alert role with status role                   
+1/-2     

…ing focus indicators

- ColorPicker: aria-labelledby was set to literal text instead of an ID, leaving the dialog with no accessible name for screen readers; switched to aria-label and removed the same-shaped aria-describedby bug.
- Toast: role="alert" (implicit assertive) combined with aria-live="polite" produced conflicting semantics across screen readers; switched to role="status" (implicit polite) and dropped the explicit aria-live.
- Focus indicators: replaced bare ":focus { outline: none }" with the ":focus:not(:focus-visible)" pattern in Dialog, DialogContentContainer, AvatarGroupCounterTooltipContent (+ virtualized variant), BreadcrumbContent, Menu, and Tab so keyboard focus rings are no longer suppressed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@orrgottlieb orrgottlieb requested a review from a team as a code owner May 22, 2026 09:34
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (1)

Grey Divider


Action required

1. ColorPicker test asserts aria-labelledby 📘 Rule violation ☼ Reliability
Description
ColorPicker now sets aria-label on the dialog container, but the testkit test still asserts
against aria-labelledby, so it no longer validates the actual accessibility attribute and may fail
or give false confidence. This breaks the requirement that tests must verify real DOM accessibility
attributes after behavior changes.
Code

packages/core/src/components/ColorPicker/ColorPicker.tsx[R132-138]

Evidence
PR Compliance ID 7 requires tests to validate the actual DOM accessibility attributes. The cited
ColorPicker implementation was updated to render aria-label="Color Picker Dialog" on the dialog
container, while the cited testkit test still reads/asserts that aria-labelledby contains "Color
Picker Dialog"; this mismatch makes the test incompatible with the new markup and means it is no
longer verifying the correct accessibility contract.

CLAUDE.md: Tests Must Validate Actual Behavior and Accessibility Attributes
packages/core/src/components/ColorPicker/ColorPicker.tsx[132-138]
packages/testkit/tests/ColorPicker.test.ts[83-86]
packages/core/src/components/ColorPicker/ColorPicker.tsx[131-138]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ColorPicker` was changed to use `aria-label` (e.g., `aria-label="Color Picker Dialog"`) on the dialog container, but `packages/testkit/__tests__/ColorPicker.test.ts` still asserts `aria-labelledby`. Update the test so it validates the actual rendered accessibility attribute and does not provide false confidence or fail due to the markup change.

## Issue Context
PR Compliance ID 7 requires tests to validate real DOM accessibility attributes after changes. ColorPicker switched from `aria-labelledby="Color Picker Dialog"` to `aria-label="Color Picker Dialog"`, but the testkit test continues to read/assert `aria-labelledby`; consider also asserting `aria-labelledby` is absent if the container no longer emits empty values.

## Fix Focus Areas
- packages/testkit/__tests__/ColorPicker.test.ts[83-86]
- packages/core/src/components/ColorPicker/ColorPicker.tsx[132-138]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Empty aria-labelledby overrides label 🐞 Bug ≡ Correctness
Description
ColorPicker now passes aria-label, but DialogContentContainer still renders
aria-labelledby=""/aria-describedby="" by default, which can cause the dialog to remain
effectively unlabeled/undescribed for assistive tech. This defeats the PR’s stated fix for the
broken accessible name.
Code

packages/core/src/components/ColorPicker/ColorPicker.tsx[R132-137]

Evidence
DialogContentContainer currently defaults aria-labelledby/aria-describedby to empty strings
and renders them on the DOM element, and the updated ColorPicker snapshot confirms the component now
renders aria-labelledby="" and aria-describedby="" alongside the new aria-label—a pattern that
can keep the accessible name empty despite the PR’s change.

packages/components/dialog/src/DialogContentContainer/DialogContentContainer.tsx[8-69]
packages/core/src/components/ColorPicker/ColorPicker.tsx[131-138]
packages/core/src/components/ColorPicker/tests/snapshots/ColorPicker.test.tsx.snap[3-12]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`DialogContentContainer` defaults `aria-labelledby`/`aria-describedby` to empty strings and always renders those attributes. When ColorPicker switched to `aria-label`, it stopped providing valid IDREFs, but the empty IDREF attributes still render and may take precedence over `aria-label`, leaving the dialog without a computed accessible name/description.

### Issue Context
- ColorPicker now sets `aria-label="Color Picker Dialog"`.
- `DialogContentContainer` renders `aria-labelledby`/`aria-describedby` unconditionally with default `""`.

### Fix Focus Areas
- packages/components/dialog/src/DialogContentContainer/DialogContentContainer.tsx[8-69]
- packages/core/src/components/ColorPicker/__tests__/__snapshots__/ColorPicker.test.tsx.snap[3-12]

### Suggested fix
1. In `DialogContentContainer`, change defaults to `undefined` (not `""`) and only pass `aria-labelledby`/`aria-describedby` when non-empty, e.g. `aria-labelledby={ariaLabelledby || undefined}` and `aria-describedby={ariaDescribedby || undefined}`.
2. (Type safety) Add `"aria-label"?: string;` to `DialogContentContainerProps` (or widen props to allow standard div attributes) so `aria-label` is a supported/typed prop.
3. Update snapshots to reflect that `aria-labelledby`/`aria-describedby` are no longer rendered when not provided.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines 132 to 138
<DialogContentContainer
ref={mergedRef}
className={cx(styles.colorPicker, styles.colorPickerDialogContent, className)}
aria-labelledby="Color Picker Dialog"
aria-describedby="Pick color"
aria-label="Color Picker Dialog"
style={{ width }}
data-vibe={ComponentVibeId.COLOR_PICKER}
>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. colorpicker test asserts aria-labelledby 📘 Rule violation ☼ Reliability

ColorPicker now sets aria-label on the dialog container, but the testkit test still asserts
against aria-labelledby, so it no longer validates the actual accessibility attribute and may fail
or give false confidence. This breaks the requirement that tests must verify real DOM accessibility
attributes after behavior changes.
Agent Prompt
## Issue description
`ColorPicker` was changed to use `aria-label` (e.g., `aria-label="Color Picker Dialog"`) on the dialog container, but `packages/testkit/__tests__/ColorPicker.test.ts` still asserts `aria-labelledby`. Update the test so it validates the actual rendered accessibility attribute and does not provide false confidence or fail due to the markup change.

## Issue Context
PR Compliance ID 7 requires tests to validate real DOM accessibility attributes after changes. ColorPicker switched from `aria-labelledby="Color Picker Dialog"` to `aria-label="Color Picker Dialog"`, but the testkit test continues to read/assert `aria-labelledby`; consider also asserting `aria-labelledby` is absent if the container no longer emits empty values.

## Fix Focus Areas
- packages/testkit/__tests__/ColorPicker.test.ts[83-86]
- packages/core/src/components/ColorPicker/ColorPicker.tsx[132-138]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines 132 to 137
<DialogContentContainer
ref={mergedRef}
className={cx(styles.colorPicker, styles.colorPickerDialogContent, className)}
aria-labelledby="Color Picker Dialog"
aria-describedby="Pick color"
aria-label="Color Picker Dialog"
style={{ width }}
data-vibe={ComponentVibeId.COLOR_PICKER}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Empty aria-labelledby overrides label 🐞 Bug ≡ Correctness

ColorPicker now passes aria-label, but DialogContentContainer still renders
aria-labelledby=""/aria-describedby="" by default, which can cause the dialog to remain
effectively unlabeled/undescribed for assistive tech. This defeats the PR’s stated fix for the
broken accessible name.
Agent Prompt
### Issue description
`DialogContentContainer` defaults `aria-labelledby`/`aria-describedby` to empty strings and always renders those attributes. When ColorPicker switched to `aria-label`, it stopped providing valid IDREFs, but the empty IDREF attributes still render and may take precedence over `aria-label`, leaving the dialog without a computed accessible name/description.

### Issue Context
- ColorPicker now sets `aria-label="Color Picker Dialog"`.
- `DialogContentContainer` renders `aria-labelledby`/`aria-describedby` unconditionally with default `""`.

### Fix Focus Areas
- packages/components/dialog/src/DialogContentContainer/DialogContentContainer.tsx[8-69]
- packages/core/src/components/ColorPicker/__tests__/__snapshots__/ColorPicker.test.tsx.snap[3-12]

### Suggested fix
1. In `DialogContentContainer`, change defaults to `undefined` (not `""`) and only pass `aria-labelledby`/`aria-describedby` when non-empty, e.g. `aria-labelledby={ariaLabelledby || undefined}` and `aria-describedby={ariaDescribedby || undefined}`.
2. (Type safety) Add `"aria-label"?: string;` to `DialogContentContainerProps` (or widen props to allow standard div attributes) so `aria-label` is a supported/typed prop.
3. Update snapshots to reflect that `aria-labelledby`/`aria-describedby` are no longer rendered when not provided.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@github-actions

Copy link
Copy Markdown
Contributor

📦 Bundle Size Analysis

✅ No bundle size changes detected.

Unchanged Components
Component Base PR Diff
@vibe/button 17.3KB 17.29KB -9B 🟢
@vibe/clickable 5.95KB 5.96KB +3B 🔺
@vibe/dialog 52.14KB 52.22KB +80B 🔺
@vibe/icon-button 66.09KB 66.05KB -33B 🟢
@vibe/icon 12.92KB 12.89KB -32B 🟢
@vibe/layer 2.96KB 2.96KB 0B ➖
@vibe/layout 9.82KB 9.83KB +11B 🔺
@vibe/loader 5.64KB 5.65KB +10B 🔺
@vibe/tooltip 61.33KB 61.31KB -17B 🟢
@vibe/typography 63.47KB 63.42KB -57B 🟢
Accordion 6.31KB 6.29KB -14B 🟢
AccordionItem 66.43KB 66.4KB -34B 🟢
AlertBanner 70.83KB 70.86KB +30B 🔺
AlertBannerButton 18.76KB 18.76KB -2B 🟢
AlertBannerLink 15.26KB 15.26KB +4B 🔺
AlertBannerText 63.95KB 63.94KB -9B 🟢
AttentionBox 74.35KB 74.36KB +7B 🔺
Avatar 66.84KB 66.72KB -128B 🟢
AvatarGroup 93.29KB 93.33KB +44B 🔺
Badge 43.19KB 43.17KB -24B 🟢
BreadcrumbItem 64.7KB 64.61KB -92B 🟢
BreadcrumbMenu 68.57KB 68.61KB +37B 🔺
BreadcrumbMenuItem 77.07KB 77.04KB -35B 🟢
BreadcrumbsBar 5.68KB 5.68KB -1B 🟢
ButtonGroup 68.32KB 68.32KB +4B 🔺
Checkbox 66.83KB 66.82KB -12B 🟢
Chips 75.05KB 75.16KB +114B 🔺
ColorPicker 74.47KB 74.43KB -40B 🟢
ColorPickerContent 73.73KB 73.74KB +9B 🔺
Combobox 84.08KB 83.99KB -85B 🟢
Counter 42.21KB 42.28KB +65B 🔺
DatePicker 112.41KB 112.42KB +15B 🔺
Divider 5.42KB 5.46KB +44B 🔺
Dropdown 95.35KB 95.16KB -194B 🟢
EditableHeading 66.63KB 66.56KB -71B 🟢
EditableText 66.46KB 66.49KB +30B 🔺
EmptyState 70.48KB 70.42KB -61B 🟢
ExpandCollapse 66.22KB 66.21KB -15B 🟢
FormattedNumber 5.86KB 5.84KB -13B 🟢
GridKeyboardNavigationContext 4.65KB 4.65KB -4B 🟢
HiddenText 5.4KB 5.39KB -15B 🟢
Info 72.06KB 72.07KB +15B 🔺
Label 68.65KB 68.72KB +65B 🔺
Link 14.91KB 14.88KB -30B 🟢
List 72.88KB 72.88KB +4B 🔺
ListItem 65.54KB 65.53KB -15B 🟢
ListItemAvatar 66.88KB 66.87KB -20B 🟢
ListItemIcon 13.97KB 13.97KB +5B 🔺
ListTitle 65.02KB 64.99KB -30B 🟢
Menu 8.65KB 8.67KB +11B 🔺
MenuDivider 5.56KB 5.57KB +7B 🔺
MenuGridItem 7.16KB 7.19KB +36B 🔺
MenuItem 76.95KB 76.94KB -13B 🟢
MenuItemButton 70.11KB 69.98KB -140B 🟢
MenuTitle 65.35KB 65.3KB -57B 🟢
MenuButton 66.08KB 66.14KB +57B 🔺
Modal 79.14KB 79.06KB -76B 🟢
ModalContent 4.72KB 4.71KB -1B 🟢
ModalHeader 65.79KB 65.81KB +21B 🔺
ModalMedia 7.51KB 7.5KB -3B 🟢
ModalFooter 67.72KB 67.65KB -70B 🟢
ModalFooterWizard 68.6KB 68.68KB +81B 🔺
ModalBasicLayout 8.96KB 8.9KB -57B 🟢
ModalMediaLayout 8.08KB 8.06KB -19B 🟢
ModalSideBySideLayout 6.3KB 6.29KB -4B 🟢
MultiStepIndicator 52.96KB 52.95KB -11B 🟢
NumberField 72.87KB 72.8KB -78B 🟢
ProgressBar 7.34KB 7.35KB +7B 🔺
RadioButton 65.9KB 65.89KB -16B 🟢
Search 70.65KB 70.66KB +12B 🔺
Skeleton 6KB 6.01KB +4B 🔺
Slider 73.86KB 73.84KB -20B 🟢
SplitButton 66.48KB 66.43KB -44B 🟢
SplitButtonMenu 8.8KB 8.78KB -18B 🟢
Steps 71.31KB 71.3KB -4B 🟢
Table 7.26KB 7.25KB -13B 🟢
TableBody 66.68KB 66.73KB +52B 🔺
TableCell 65.22KB 65.16KB -69B 🟢
TableContainer 5.31KB 5.32KB +16B 🔺
TableHeader 5.64KB 5.64KB +1B 🔺
TableHeaderCell 72.2KB 72.14KB -53B 🟢
TableRow 5.56KB 5.55KB -8B 🟢
TableRowMenu 68.87KB 68.88KB +11B 🔺
TableVirtualizedBody 71.42KB 71.43KB +10B 🔺
Tab 64KB 64KB -3B 🟢
TabList 8.89KB 8.86KB -30B 🟢
TabPanel 5.3KB 5.29KB -14B 🟢
TabPanels 5.86KB 5.86KB -2B 🟢
TabsContext 5.48KB 5.51KB +29B 🔺
TextArea 66.26KB 66.29KB +37B 🔺
TextField 69.43KB 69.42KB -19B 🟢
TextWithHighlight 64.35KB 64.26KB -95B 🟢
ThemeProvider 4.36KB 4.36KB -1B 🟢
Tipseen 71.17KB 71.14KB -32B 🟢
TipseenContent 71.6KB 71.57KB -30B 🟢
TipseenMedia 71.27KB 71.31KB +38B 🔺
TipseenWizard 73.93KB 73.88KB -51B 🟢
Toast 74.1KB 74.02KB -89B 🟢
ToastButton 18.59KB 18.62KB +33B 🔺
ToastLink 15.05KB 15.08KB +31B 🔺
Toggle 66.62KB 66.59KB -27B 🟢
TransitionView 5.42KB 5.45KB +30B 🔺
VirtualizedGrid 12.54KB 12.54KB +2B 🔺
VirtualizedList 12.28KB 12.26KB -12B 🟢
List (Next) 8.17KB 8.16KB -15B 🟢
ListItem (Next) 69.88KB 69.9KB +22B 🔺
ListTitle (Next) 65.31KB 65.33KB +15B 🔺

📊 Summary:

  • Total Base Size: 4.75MB
  • Total PR Size: 4.75MB
  • Total Difference: 1.1KB

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant