Skip to content

Commit e71131e

Browse files
committed
fix(combobox-web): hide decorative icons and prevent empty menu exposure
1 parent 6bb690d commit e71131e

13 files changed

Lines changed: 454 additions & 130 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-06-16
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
## Context
2+
3+
The combobox widget currently has two accessibility issues affecting screen reader users:
4+
5+
1. **Decorative icons announced as "image"**: The DownArrow and ClearButton SVG icons are exposed to assistive technologies, causing screen readers to announce them as "image" elements. These icons are purely decorative - they duplicate information already conveyed through the combobox role, button roles, and ARIA labels.
6+
7+
2. **Empty menu structure exposed**: When the combobox has no options, the `<ul>` menu element is still rendered in the DOM and exposed to the accessibility tree, creating an empty group that screen reader users can navigate to but provides no value.
8+
9+
**Current Implementation:**
10+
11+
- `icons.tsx`: DownArrow and ClearButton components render SVGs wrapped in `<span className="widget-combobox-icon-container">`
12+
- `ComboboxWrapper.tsx`: Renders the DownArrow inside a container div
13+
- `ComboboxMenuWrapper.tsx`: Always renders the `<ul>` element, showing NoOptionsPlaceholder when empty
14+
15+
**Constraints:**
16+
17+
- Must not affect visual presentation
18+
- Must preserve existing keyboard and mouse interaction behavior
19+
- Must maintain compatibility with existing ARIA attributes (aria-invalid, aria-describedby)
20+
21+
## Goals / Non-Goals
22+
23+
**Goals:**
24+
25+
- Hide decorative icon SVGs from assistive technologies using `aria-hidden="true"`
26+
- Prevent empty menu structures from being exposed to screen readers when no options exist
27+
- Maintain WCAG 2.2 AA compliance
28+
- Preserve all existing functionality and visual design
29+
30+
**Non-Goals:**
31+
32+
- Not changing the validation ARIA attributes (already correctly implemented)
33+
- Not modifying icon visual appearance or interaction behavior
34+
- Not addressing other accessibility improvements beyond these two specific issues
35+
36+
## Decisions
37+
38+
### Decision 1: Add aria-hidden to icon wrapper spans
39+
40+
**Approach:** Add `aria-hidden="true"` to the `<span className="widget-combobox-icon-container">` wrapper in both DownArrow and ClearButton components.
41+
42+
**Rationale:**
43+
44+
- These icons are purely decorative - they reinforce information already available through:
45+
- DownArrow: The combobox role implies expandability; the actual button has proper ARIA labels
46+
- ClearButton: The button element has `aria-label` with clear text (from `clearButtonAriaLabel` prop)
47+
- Adding aria-hidden on the wrapper span hides both the span and its SVG child from the accessibility tree
48+
- This is simpler and more maintainable than adding aria-hidden to each SVG element individually
49+
50+
**Alternatives considered:**
51+
52+
- Add aria-hidden directly on SVG elements: More granular but requires changes in multiple places; wrapper approach is cleaner
53+
- Use `role="presentation"`: Less explicit than aria-hidden for hiding decorative content
54+
- Remove icons from DOM when read-only: Overcomplicates the code for minimal benefit
55+
56+
### Decision 2: Conditionally render menu list when empty
57+
58+
**Approach:** In `ComboboxMenuWrapper.tsx`, only render the `<ul>` element when the menu is open AND there are items or loading state. When empty and closed, skip rendering the list structure entirely.
59+
60+
**Rationale:**
61+
62+
- An empty `<ul>` with no `<li>` children creates an empty group/list in the accessibility tree
63+
- Screen readers can navigate to this empty group, providing no value and creating confusion
64+
- Conditional rendering ensures the menu structure only exists when it has meaningful content
65+
- The NoOptionsPlaceholder should still be visible when open but empty (useful for sighted users)
66+
67+
**Implementation approach:**
68+
69+
```tsx
70+
// Only render <ul> when menu is open
71+
{
72+
isOpen && (
73+
<ul className="...">
74+
{isEmpty && !isLoading ? <NoOptionsPlaceholder>{noOptionsText}</NoOptionsPlaceholder> : children}
75+
{loader}
76+
</ul>
77+
);
78+
}
79+
```
80+
81+
**Alternatives considered:**
82+
83+
- Add aria-hidden to empty `<ul>`: Would still render unnecessary DOM elements
84+
- Use `role="presentation"` on empty list: Still exposed to some assistive technologies
85+
- Always render but hide with CSS: Doesn't solve accessibility tree exposure
86+
87+
### Decision 3: Preserve spinner loader accessibility
88+
89+
**Approach:** Keep the SpinnerLoader in `ComboboxWrapper.tsx` accessible (no aria-hidden) as it provides meaningful loading state information.
90+
91+
**Rationale:**
92+
93+
- Unlike the decorative arrow, the spinner indicates an active loading state
94+
- Screen readers should announce loading status to users
95+
- The spinner is already rendered conditionally (`isLoading ? <SpinnerLoader /> : <DownArrow />`)
96+
97+
## Risks / Trade-offs
98+
99+
**[Risk]** Adding aria-hidden might hide icons from assistive technologies that users rely on for spatial navigation
100+
**Mitigation:** These icons are purely decorative and duplicate information already available through proper ARIA labels and roles. The underlying button elements remain fully accessible.
101+
102+
**[Risk]** Conditional rendering of `<ul>` might cause layout shifts or affect CSS selectors
103+
**Mitigation:** The menu is already hidden when closed via CSS classes. Removing it from DOM when closed won't affect visible behavior. Test thoroughly to ensure no CSS regressions.
104+
105+
**[Risk]** Changes might affect existing E2E tests that query for menu elements when closed
106+
**Mitigation:** Review and update E2E tests if they attempt to query closed menu elements. This is actually a test improvement - tests should verify elements exist only when they should be visible.
107+
108+
**[Trade-off]** Slightly more complex conditional rendering logic in ComboboxMenuWrapper
109+
**Accepted:** The improvement in accessibility tree cleanliness outweighs the minor complexity increase. The logic is straightforward and well-documented.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
## Why
2+
3+
The combobox widget has accessibility gaps that create noise and confusion for screen reader users: decorative icons (arrow, clear button) are announced as "image", and empty structural elements are exposed in the accessibility tree when they provide no value. These issues violate WCAG 2.2 AA guidelines and degrade the experience for assistive technology users.
4+
5+
## What Changes
6+
7+
- Add `aria-hidden="true"` to decorative icon elements (DownArrow, ClearButton SVG containers) to hide them from assistive technologies
8+
- Prevent empty group-like nodes from being exposed to screen readers when the combobox has no value or options
9+
- Ensure validation-related ARIA attributes remain functional (aria-invalid, aria-describedby already implemented)
10+
11+
## Capabilities
12+
13+
### New Capabilities
14+
15+
- `decorative-icon-hiding`: Hide decorative icons from assistive technologies using aria-hidden
16+
- `empty-group-prevention`: Prevent empty structural elements from being exposed in accessibility tree
17+
18+
### Modified Capabilities
19+
20+
<!-- No existing capability requirements are changing - these are additive improvements -->
21+
22+
## Impact
23+
24+
**Affected Files:**
25+
26+
- `packages/pluggableWidgets/combobox-web/src/assets/icons.tsx` - Add aria-hidden to DownArrow and ClearButton SVG elements
27+
- `packages/pluggableWidgets/combobox-web/src/components/ComboboxWrapper.tsx` - Ensure arrow icon container has proper accessibility attributes
28+
- `packages/pluggableWidgets/combobox-web/src/components/ComboboxMenuWrapper.tsx` - Prevent empty ul/group elements from being exposed when no options exist
29+
30+
**No Breaking Changes**: These are non-breaking accessibility enhancements that improve screen reader experience without affecting visual presentation or API.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Decorative icon SVGs must be hidden from assistive technologies
4+
5+
The combobox widget SHALL hide decorative icon elements from assistive technologies using `aria-hidden="true"`. Decorative icons are those that do not convey unique information and duplicate functionality already available through proper ARIA labels, roles, or semantic HTML.
6+
7+
#### Scenario: Down arrow icon hidden from screen readers
8+
9+
- **WHEN** the combobox renders the down arrow icon
10+
- **THEN** the icon wrapper span SHALL have `aria-hidden="true"`
11+
- **AND** screen readers SHALL NOT announce the icon as "image"
12+
13+
#### Scenario: Clear button icon hidden from screen readers
14+
15+
- **WHEN** the combobox renders the clear button with its icon
16+
- **THEN** the icon wrapper span SHALL have `aria-hidden="true"`
17+
- **AND** the parent button element SHALL remain accessible with its `aria-label`
18+
- **AND** screen readers SHALL announce the button by its label, not as "image"
19+
20+
#### Scenario: Spinner loader remains accessible
21+
22+
- **WHEN** the combobox is in loading state and displays a spinner
23+
- **THEN** the spinner element SHALL NOT have `aria-hidden="true"`
24+
- **AND** screen readers SHALL be able to perceive the loading state
25+
26+
### Requirement: Icon functionality must remain intact
27+
28+
Hiding decorative icons from assistive technologies SHALL NOT affect their visual presentation or interactive behavior.
29+
30+
#### Scenario: Icons remain visible to sighted users
31+
32+
- **WHEN** decorative icons have `aria-hidden="true"`
33+
- **THEN** the icons SHALL remain visually displayed
34+
- **AND** their CSS styling SHALL be unchanged
35+
36+
#### Scenario: Buttons remain interactive
37+
38+
- **WHEN** a button contains a hidden decorative icon
39+
- **THEN** the button SHALL remain fully interactive via mouse and keyboard
40+
- **AND** click handlers SHALL continue to function normally
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Empty menu structures must not be exposed to assistive technologies
4+
5+
The combobox widget SHALL NOT expose empty menu list elements to the accessibility tree when the menu is closed or has no content. This prevents screen readers from navigating to meaningless empty groups.
6+
7+
#### Scenario: Menu list not rendered when closed
8+
9+
- **WHEN** the combobox menu is closed
10+
- **THEN** the menu `<ul>` element SHALL NOT be present in the DOM
11+
- **AND** screen readers SHALL NOT encounter an empty list structure
12+
13+
#### Scenario: Menu list rendered when open with items
14+
15+
- **WHEN** the combobox menu is open
16+
- **AND** there are items to display
17+
- **THEN** the menu `<ul>` element SHALL be present in the DOM
18+
- **AND** screen readers SHALL be able to navigate the list items
19+
20+
#### Scenario: Menu list rendered when open but empty
21+
22+
- **WHEN** the combobox menu is open
23+
- **AND** there are no items to display (empty state)
24+
- **THEN** the menu `<ul>` element SHALL be present in the DOM
25+
- **AND** the "No options" placeholder SHALL be rendered inside the list
26+
- **AND** screen readers SHALL be able to perceive the empty state message
27+
28+
#### Scenario: Menu list rendered during loading
29+
30+
- **WHEN** the combobox is loading items
31+
- **AND** the menu is open
32+
- **THEN** the menu `<ul>` element SHALL be present in the DOM
33+
- **AND** the loading indicator SHALL be accessible to screen readers
34+
35+
### Requirement: Visual behavior must remain unchanged
36+
37+
Preventing empty menu structures in the accessibility tree SHALL NOT affect the visual presentation or user experience for sighted users.
38+
39+
#### Scenario: Menu visibility controlled by CSS
40+
41+
- **WHEN** the menu transitions between open and closed states
42+
- **THEN** the visual appearance SHALL match previous behavior
43+
- **AND** CSS animations and transitions SHALL continue to work
44+
- **AND** layout SHALL NOT shift unexpectedly
45+
46+
#### Scenario: No options placeholder displays when appropriate
47+
48+
- **WHEN** the menu is open and has no items
49+
- **THEN** the "No options" text SHALL be visible to sighted users
50+
- **AND** it SHALL be announced by screen readers
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
## 1. Hide Decorative Icons from Assistive Technologies
2+
3+
- [x] 1.1 Add `aria-hidden="true"` to DownArrow icon wrapper span in `src/assets/icons.tsx`
4+
- [x] 1.2 Add `aria-hidden="true"` to ClearButton icon wrapper span in `src/assets/icons.tsx`
5+
- [x] 1.3 Verify SpinnerLoader does NOT have aria-hidden (should remain accessible)
6+
- [x] 1.4 Verify clear button parent elements retain their aria-label attributes
7+
8+
## 2. Prevent Empty Menu Structure Exposure
9+
10+
- [x] 2.1 Modify `ComboboxMenuWrapper.tsx` to conditionally render `<ul>` only when `isOpen` is true
11+
- [x] 2.2 Ensure NoOptionsPlaceholder still renders when menu is open but empty
12+
- [x] 2.3 Ensure loader element renders when menu is open and loading
13+
- [x] 2.4 Verify menu header and footer render only when menu is open
14+
15+
## 3. Unit Tests
16+
17+
- [x] 3.1 Add test for DownArrow having aria-hidden="true" on wrapper span
18+
- [x] 3.2 Add test for ClearButton having aria-hidden="true" on wrapper span
19+
- [x] 3.3 Add test that menu `<ul>` is not in DOM when closed
20+
- [x] 3.4 Add test that menu `<ul>` is in DOM when open with items
21+
- [x] 3.5 Add test that menu `<ul>` is in DOM when open but empty (with NoOptionsPlaceholder)
22+
- [x] 3.6 Add test that SpinnerLoader does not have aria-hidden
23+
- [x] 3.7 Verify existing aria-invalid and aria-describedby tests still pass
24+
25+
## 4. E2E Tests
26+
27+
- [x] 4.1 Review existing E2E tests for queries on closed menu elements
28+
- [x] 4.2 Update E2E tests if they expect menu DOM elements when closed
29+
- [x] 4.3 Add E2E test verifying menu appears only when opened
30+
31+
## 5. Manual Accessibility Testing
32+
33+
- [x] 5.1 Test with screen reader (NVDA/JAWS/VoiceOver) - verify no "image" announcements for decorative icons
34+
- [x] 5.2 Test with screen reader - verify no empty group navigation when menu is closed
35+
- [x] 5.3 Test with screen reader - verify clear button announces with proper label
36+
- [x] 5.4 Verify keyboard navigation still works (Tab, Enter, Arrow keys)
37+
- [x] 5.5 Verify visual appearance unchanged for sighted users
38+
39+
## 6. Final Verification
40+
41+
- [x] 6.1 Verify existing validation ARIA attributes (aria-invalid, aria-describedby) still function
42+
- [x] 6.2 Run full test suite for combobox widget
43+
- [x] 6.3 Test both single and multi-selection modes
44+
- [x] 6.4 Test with different data sources (static, association, database)

packages/pluggableWidgets/combobox-web/e2e/Combobox.spec.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,23 @@ test.describe("combobox-web", () => {
171171
// check if filtered
172172
await expect(getOptions(comboBox)).toHaveText(["Antartica", "Australia"]);
173173
});
174+
175+
test("menu list not in DOM when closed, present when opened", async ({ page }) => {
176+
const comboBox = page.locator(".mx-name-comboBox1");
177+
await expect(comboBox).toBeVisible({ timeout: 10000 });
178+
179+
// Verify menu list is not in DOM when closed
180+
const menuListClosed = comboBox.locator(".widget-combobox-menu-list");
181+
await expect(menuListClosed).not.toBeVisible();
182+
183+
// Open the combobox
184+
await comboBox.click();
185+
186+
// Verify menu list is now in DOM and visible
187+
const menuListOpen = comboBox.locator(".widget-combobox-menu-list");
188+
await expect(menuListOpen).toBeVisible();
189+
await expect(getOptions(comboBox).first()).toBeVisible();
190+
});
174191
});
175192
});
176193

0 commit comments

Comments
 (0)