|
| 1 | +# ARIA Attributes for Collapsible Toolbar - Reference Guide |
| 2 | + |
| 3 | +**Task:** 37943-toolbar-search-filter |
| 4 | +**Pattern:** WAI-ARIA Disclosure (Show/Hide) |
| 5 | + |
| 6 | +--- |
| 7 | + |
| 8 | +## Overview |
| 9 | + |
| 10 | +This document provides the specific ARIA attributes required for the collapsible toolbar to meet WCAG 2.1 Level AA accessibility standards. |
| 11 | + |
| 12 | +**Reference:** [WAI-ARIA Disclosure Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) |
| 13 | + |
| 14 | +--- |
| 15 | + |
| 16 | +## Required ARIA Attributes |
| 17 | + |
| 18 | +### 1. Toggle Button |
| 19 | + |
| 20 | +The button that expands/collapses the toolbar MUST have: |
| 21 | + |
| 22 | +```tsx |
| 23 | +<IconButton |
| 24 | + aria-label="Expand workspace toolbar" // or "Collapse workspace toolbar" |
| 25 | + aria-expanded={isExpanded} // "true" or "false" |
| 26 | + aria-controls="workspace-toolbar-content" |
| 27 | + onClick={toggleExpanded} |
| 28 | +/> |
| 29 | +``` |
| 30 | + |
| 31 | +**Attributes:** |
| 32 | + |
| 33 | +- `aria-label`: Descriptive label for screen readers |
| 34 | +- `aria-expanded`: Boolean indicating current state ("true" | "false") |
| 35 | +- `aria-controls`: ID of the controlled content element |
| 36 | + |
| 37 | +**Note:** The `aria-label` should change based on state: |
| 38 | + |
| 39 | +- Collapsed: "Expand workspace toolbar" or "Show workspace tools" |
| 40 | +- Expanded: "Collapse workspace toolbar" or "Hide workspace tools" |
| 41 | + |
| 42 | +--- |
| 43 | + |
| 44 | +### 2. Content Container |
| 45 | + |
| 46 | +The container with sections MUST have: |
| 47 | + |
| 48 | +```tsx |
| 49 | +<Box id="workspace-toolbar-content" role="group" aria-label="Workspace toolbar"> |
| 50 | + {/* Sections */} |
| 51 | +</Box> |
| 52 | +``` |
| 53 | + |
| 54 | +**Attributes:** |
| 55 | + |
| 56 | +- `id`: Unique identifier matching `aria-controls` on button |
| 57 | +- `role`: Either "group" or "region" (region preferred for major sections) |
| 58 | +- `aria-label`: Descriptive name for the entire toolbar content |
| 59 | + |
| 60 | +--- |
| 61 | + |
| 62 | +### 3. Section 1: Search & Filter |
| 63 | + |
| 64 | +```tsx |
| 65 | +<Box role="region" aria-label="Search and filter controls"> |
| 66 | + <SearchEntities /> |
| 67 | + <DrawerFilterToolbox /> |
| 68 | +</Box> |
| 69 | +``` |
| 70 | + |
| 71 | +**Attributes:** |
| 72 | + |
| 73 | +- `role="region"`: Identifies as a significant page section |
| 74 | +- `aria-label`: Describes the section purpose |
| 75 | + |
| 76 | +**Why region?** Sections with `role="region"` appear in screen reader landmark navigation, making them easier to find. |
| 77 | + |
| 78 | +--- |
| 79 | + |
| 80 | +### 4. Section 2: Layout Controls |
| 81 | + |
| 82 | +```tsx |
| 83 | +<Box role="region" aria-label="Layout controls"> |
| 84 | + <LayoutSelector /> |
| 85 | + <ApplyLayoutButton /> |
| 86 | + <LayoutPresetsManager /> |
| 87 | + {/* Settings button */} |
| 88 | +</Box> |
| 89 | +``` |
| 90 | + |
| 91 | +**Attributes:** |
| 92 | + |
| 93 | +- `role="region"`: Identifies as a significant page section |
| 94 | +- `aria-label`: Describes the section purpose |
| 95 | + |
| 96 | +--- |
| 97 | + |
| 98 | +### 5. Panel (React Flow) |
| 99 | + |
| 100 | +The outer Panel component should have: |
| 101 | + |
| 102 | +```tsx |
| 103 | +<Panel position="top-left" data-testid="workspace-toolbar" role="complementary" aria-label="Workspace tools"> |
| 104 | + {/* Content */} |
| 105 | +</Panel> |
| 106 | +``` |
| 107 | + |
| 108 | +**Attributes:** |
| 109 | + |
| 110 | +- `role="complementary"`: Landmark role for supporting content |
| 111 | +- `aria-label`: High-level description of panel purpose |
| 112 | +- `data-testid`: For testing |
| 113 | + |
| 114 | +--- |
| 115 | + |
| 116 | +## Complete Example |
| 117 | + |
| 118 | +```tsx |
| 119 | +import { useState } from 'react' |
| 120 | +import { Box, IconButton, Icon, Divider } from '@chakra-ui/react' |
| 121 | +import { ChevronRightIcon, ChevronLeftIcon } from '@chakra-ui/icons' |
| 122 | +import Panel from '@/components/react-flow/Panel' |
| 123 | + |
| 124 | +const WorkspaceToolbar = () => { |
| 125 | + const [isExpanded, setIsExpanded] = useState(false) |
| 126 | + |
| 127 | + return ( |
| 128 | + <Panel position="top-left" data-testid="workspace-toolbar" role="complementary" aria-label="Workspace tools"> |
| 129 | + {/* Collapsed State: Toggle Button */} |
| 130 | + {!isExpanded && ( |
| 131 | + <IconButton |
| 132 | + aria-label="Expand workspace toolbar" |
| 133 | + aria-expanded="false" |
| 134 | + aria-controls="workspace-toolbar-content" |
| 135 | + icon={<Icon as={ChevronRightIcon} />} |
| 136 | + onClick={() => setIsExpanded(true)} |
| 137 | + /> |
| 138 | + )} |
| 139 | + |
| 140 | + {/* Expanded State: Content */} |
| 141 | + {isExpanded && ( |
| 142 | + <Box id="workspace-toolbar-content" role="group" aria-label="Workspace toolbar"> |
| 143 | + {/* Section 1: Search & Filter */} |
| 144 | + <Box role="region" aria-label="Search and filter controls"> |
| 145 | + <SearchEntities /> |
| 146 | + <DrawerFilterToolbox /> |
| 147 | + </Box> |
| 148 | + |
| 149 | + <Divider my={2} /> |
| 150 | + |
| 151 | + {/* Section 2: Layout Controls */} |
| 152 | + <Box role="region" aria-label="Layout controls"> |
| 153 | + <LayoutSelector /> |
| 154 | + <ApplyLayoutButton /> |
| 155 | + <LayoutPresetsManager /> |
| 156 | + {/* Settings button */} |
| 157 | + </Box> |
| 158 | + |
| 159 | + {/* Collapse Button */} |
| 160 | + <IconButton |
| 161 | + aria-label="Collapse workspace toolbar" |
| 162 | + aria-expanded="true" |
| 163 | + aria-controls="workspace-toolbar-content" |
| 164 | + icon={<Icon as={ChevronLeftIcon} />} |
| 165 | + onClick={() => setIsExpanded(false)} |
| 166 | + /> |
| 167 | + </Box> |
| 168 | + )} |
| 169 | + </Panel> |
| 170 | + ) |
| 171 | +} |
| 172 | +``` |
| 173 | + |
| 174 | +--- |
| 175 | + |
| 176 | +## Testing ARIA Attributes |
| 177 | + |
| 178 | +### Manual Testing |
| 179 | + |
| 180 | +1. **Screen Reader Testing:** |
| 181 | + |
| 182 | + - Use VoiceOver (Mac) or NVDA (Windows) |
| 183 | + - Tab to toggle button |
| 184 | + - Verify state announcement ("expanded" or "collapsed") |
| 185 | + - Navigate to regions (use landmarks menu) |
| 186 | + - Verify region labels are announced |
| 187 | + |
| 188 | +2. **Keyboard Testing:** |
| 189 | + - Tab: Navigate through toolbar |
| 190 | + - Enter/Space: Activate toggle button |
| 191 | + - Verify focus management |
| 192 | + |
| 193 | +### Automated Testing (Cypress) |
| 194 | + |
| 195 | +```tsx |
| 196 | +it('should have correct ARIA attributes in collapsed state', () => { |
| 197 | + cy.mountWithProviders(<WorkspaceToolbar />) |
| 198 | + |
| 199 | + // Toggle button |
| 200 | + cy.get('button[aria-controls="workspace-toolbar-content"]') |
| 201 | + .should('have.attr', 'aria-expanded', 'false') |
| 202 | + .should('have.attr', 'aria-label') |
| 203 | + .and('match', /expand/i) |
| 204 | + |
| 205 | + // Content should not be visible |
| 206 | + cy.get('#workspace-toolbar-content').should('not.exist') |
| 207 | +}) |
| 208 | + |
| 209 | +it('should have correct ARIA attributes in expanded state', () => { |
| 210 | + cy.mountWithProviders(<WorkspaceToolbar />) |
| 211 | + |
| 212 | + // Expand |
| 213 | + cy.get('button[aria-controls="workspace-toolbar-content"]').click() |
| 214 | + |
| 215 | + // Toggle button |
| 216 | + cy.get('button[aria-controls="workspace-toolbar-content"]') |
| 217 | + .should('have.attr', 'aria-expanded', 'true') |
| 218 | + .should('have.attr', 'aria-label') |
| 219 | + .and('match', /collapse/i) |
| 220 | + |
| 221 | + // Content exists |
| 222 | + cy.get('#workspace-toolbar-content').should('exist').should('have.attr', 'role', 'group') |
| 223 | + |
| 224 | + // Sections have proper roles |
| 225 | + cy.get('[role="region"]').should('have.length', 2) |
| 226 | + cy.get('[role="region"]').first().should('have.attr', 'aria-label', 'Search and filter controls') |
| 227 | + cy.get('[role="region"]').last().should('have.attr', 'aria-label', 'Layout controls') |
| 228 | +}) |
| 229 | + |
| 230 | +it('should be accessible', () => { |
| 231 | + cy.injectAxe() |
| 232 | + cy.mountWithProviders(<WorkspaceToolbar />) |
| 233 | + |
| 234 | + // Test collapsed state |
| 235 | + cy.checkAccessibility() |
| 236 | + |
| 237 | + // Expand |
| 238 | + cy.get('button[aria-controls="workspace-toolbar-content"]').click() |
| 239 | + |
| 240 | + // Test expanded state |
| 241 | + cy.checkAccessibility() |
| 242 | +}) |
| 243 | +``` |
| 244 | + |
| 245 | +--- |
| 246 | + |
| 247 | +## Common Mistakes to Avoid |
| 248 | + |
| 249 | +### ❌ WRONG: Missing aria-expanded |
| 250 | + |
| 251 | +```tsx |
| 252 | +<IconButton aria-label="Toggle toolbar" onClick={toggle} /> |
| 253 | +``` |
| 254 | + |
| 255 | +**Problem:** Screen readers can't announce current state. |
| 256 | + |
| 257 | +--- |
| 258 | + |
| 259 | +### ❌ WRONG: aria-expanded as boolean instead of string |
| 260 | + |
| 261 | +```tsx |
| 262 | +<IconButton |
| 263 | + aria-expanded={true} // ❌ Wrong type |
| 264 | +/> |
| 265 | +``` |
| 266 | + |
| 267 | +**Problem:** React will convert to string "true" but TypeScript will complain. |
| 268 | + |
| 269 | +**Fix:** |
| 270 | + |
| 271 | +```tsx |
| 272 | +<IconButton |
| 273 | + aria-expanded={isExpanded ? 'true' : 'false'} // ✅ Correct |
| 274 | + // or |
| 275 | + aria-expanded={String(isExpanded)} // ✅ Also correct |
| 276 | +/> |
| 277 | +``` |
| 278 | + |
| 279 | +--- |
| 280 | + |
| 281 | +### ❌ WRONG: No aria-controls |
| 282 | + |
| 283 | +```tsx |
| 284 | +<IconButton aria-label="Toggle toolbar" aria-expanded="false" onClick={toggle} /> |
| 285 | +``` |
| 286 | + |
| 287 | +**Problem:** No relationship between button and content. |
| 288 | + |
| 289 | +--- |
| 290 | + |
| 291 | +### ❌ WRONG: No id on content |
| 292 | + |
| 293 | +```tsx |
| 294 | +<Box>{/* Content */}</Box> |
| 295 | +``` |
| 296 | + |
| 297 | +**Problem:** `aria-controls` can't point to anything. |
| 298 | + |
| 299 | +--- |
| 300 | + |
| 301 | +### ❌ WRONG: Using aria-hidden instead of conditional rendering |
| 302 | + |
| 303 | +```tsx |
| 304 | +<Box aria-hidden={!isExpanded}>{/* Always rendered but hidden */}</Box> |
| 305 | +``` |
| 306 | + |
| 307 | +**Problem:** Content is still in the DOM and can be reached by screen readers. |
| 308 | + |
| 309 | +**Better:** |
| 310 | + |
| 311 | +```tsx |
| 312 | +{ |
| 313 | + isExpanded && <Box id="workspace-toolbar-content">{/* Only rendered when expanded */}</Box> |
| 314 | +} |
| 315 | +``` |
| 316 | + |
| 317 | +--- |
| 318 | + |
| 319 | +## WCAG Success Criteria Met |
| 320 | + |
| 321 | +| Criterion | Level | How We Meet It | |
| 322 | +| ---------------------------- | ----- | -------------------------------- | |
| 323 | +| 1.3.1 Info and Relationships | A | Semantic HTML + ARIA roles | |
| 324 | +| 2.1.1 Keyboard | A | All controls keyboard accessible | |
| 325 | +| 2.4.6 Headings and Labels | AA | Descriptive aria-labels | |
| 326 | +| 4.1.2 Name, Role, Value | A | Proper ARIA attributes | |
| 327 | + |
| 328 | +--- |
| 329 | + |
| 330 | +## Resources |
| 331 | + |
| 332 | +- [WAI-ARIA Disclosure Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) |
| 333 | +- [ARIA: button role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/button_role) |
| 334 | +- [ARIA: region role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/region_role) |
| 335 | +- [aria-expanded](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-expanded) |
| 336 | +- [aria-controls](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-controls) |
| 337 | + |
| 338 | +--- |
| 339 | + |
| 340 | +**Created:** October 31, 2025 |
| 341 | +**Last Updated:** October 31, 2025 |
0 commit comments