feat(triage): GAP-25 Phase F ARIA listbox + project complete (41/41)
Add ARIA listbox pattern to IssueList/IssueListItem for keyboard navigation and screen reader support. Mark GAP-26 as SKIPPED (not in PRD, already covered by GAP-30). Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
80441de62c
commit
8f7dd8bca0
@@ -77,7 +77,7 @@ export function IssueList({
|
||||
|
||||
return (
|
||||
<ScrollArea className="flex-1" onViewportRef={setViewportElement}>
|
||||
<div className="p-2 space-y-1">
|
||||
<div role="listbox" aria-label={t('issues.listLabel')} className="p-2 space-y-1">
|
||||
{issues.map((issue) => {
|
||||
const enrichment = enrichments?.[String(issue.number)];
|
||||
return (
|
||||
|
||||
@@ -24,12 +24,21 @@ export const IssueListItem = memo(function IssueListItem({
|
||||
}: IssueListItemProps) {
|
||||
return (
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
tabIndex={0}
|
||||
className={`group p-3 rounded-lg cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'bg-accent/50 border border-accent'
|
||||
: 'hover:bg-muted/50 border border-transparent'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{isSelectable && (
|
||||
|
||||
+36
@@ -133,3 +133,39 @@ describe('IssueList enrichment integration', () => {
|
||||
expect(checkboxes[2].checked).toBe(false); // issue 3
|
||||
});
|
||||
});
|
||||
|
||||
describe('IssueList ARIA listbox', () => {
|
||||
const defaultProps = {
|
||||
issues: [makeIssue(1), makeIssue(2), makeIssue(3)],
|
||||
selectedIssueNumber: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
onSelectIssue: vi.fn(),
|
||||
onInvestigate: vi.fn(),
|
||||
};
|
||||
|
||||
it('items container has role="listbox"', () => {
|
||||
render(<IssueList {...defaultProps} />);
|
||||
expect(screen.getByRole('listbox')).toBeDefined();
|
||||
});
|
||||
|
||||
it('listbox has an accessible label', () => {
|
||||
render(<IssueList {...defaultProps} />);
|
||||
const listbox = screen.getByRole('listbox');
|
||||
expect(listbox.getAttribute('aria-label')).toBe('issues.listLabel');
|
||||
});
|
||||
|
||||
it('each issue item has role="option"', () => {
|
||||
render(<IssueList {...defaultProps} />);
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options.length).toBe(3);
|
||||
});
|
||||
|
||||
it('selected issue has aria-selected=true', () => {
|
||||
render(<IssueList {...defaultProps} selectedIssueNumber={2} />);
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options[0].getAttribute('aria-selected')).toBe('false');
|
||||
expect(options[1].getAttribute('aria-selected')).toBe('true');
|
||||
expect(options[2].getAttribute('aria-selected')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
+32
-1
@@ -2,7 +2,7 @@
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { IssueListItem } from '../IssueListItem';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
@@ -54,3 +54,34 @@ describe('IssueListItem compact mode', () => {
|
||||
expect(screen.getByText('#42')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('IssueListItem ARIA', () => {
|
||||
const baseProps = {
|
||||
issue: mockIssue,
|
||||
isSelected: false,
|
||||
onClick: vi.fn(),
|
||||
onInvestigate: vi.fn(),
|
||||
};
|
||||
|
||||
it('has role="option"', () => {
|
||||
render(<IssueListItem {...baseProps} />);
|
||||
expect(screen.getByRole('option')).toBeDefined();
|
||||
});
|
||||
|
||||
it('aria-selected is false when not selected', () => {
|
||||
render(<IssueListItem {...baseProps} isSelected={false} />);
|
||||
expect(screen.getByRole('option').getAttribute('aria-selected')).toBe('false');
|
||||
});
|
||||
|
||||
it('aria-selected is true when selected', () => {
|
||||
render(<IssueListItem {...baseProps} isSelected />);
|
||||
expect(screen.getByRole('option').getAttribute('aria-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('Enter key triggers onClick', () => {
|
||||
const onClick = vi.fn();
|
||||
render(<IssueListItem {...baseProps} onClick={onClick} />);
|
||||
fireEvent.keyDown(screen.getByRole('option'), { key: 'Enter' });
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -440,7 +440,8 @@
|
||||
"issues": {
|
||||
"loadingMore": "Loading more...",
|
||||
"scrollForMore": "Scroll for more",
|
||||
"allLoaded": "All issues loaded"
|
||||
"allLoaded": "All issues loaded",
|
||||
"listLabel": "Issues"
|
||||
},
|
||||
"enrichment": {
|
||||
"states": {
|
||||
|
||||
@@ -440,7 +440,8 @@
|
||||
"issues": {
|
||||
"loadingMore": "Chargement...",
|
||||
"scrollForMore": "Défiler pour plus",
|
||||
"allLoaded": "Toutes les issues chargées"
|
||||
"allLoaded": "Toutes les issues chargées",
|
||||
"listLabel": "Issues"
|
||||
},
|
||||
"enrichment": {
|
||||
"states": {
|
||||
|
||||
+24
-1
@@ -3,7 +3,7 @@
|
||||
**Branch:** `terminal/enhancement-issues-tab`
|
||||
**Created:** 2026-02-12
|
||||
**Total Gaps:** 41 confirmed (from triple-verified audit)
|
||||
**Status:** 40 / 41 complete
|
||||
**Status:** 41 / 41 complete
|
||||
|
||||
---
|
||||
|
||||
@@ -540,6 +540,27 @@ Each gap has: ID, description, status, files to modify, doc reference, test stat
|
||||
- **Depends on:** None
|
||||
- **Commit:** Phase E batch
|
||||
|
||||
## TIER 5 — Low Priority
|
||||
|
||||
### GAP-25: IssueList ARIA listbox
|
||||
- **Status:** `DONE`
|
||||
- **Priority:** NICE-TO-HAVE
|
||||
- **Scope:** Small
|
||||
- **Doc ref:** WCAG 2.1 Listbox pattern
|
||||
- **Files modified:** `components/IssueList.tsx`, `components/IssueListItem.tsx`, `en/common.json`, `fr/common.json`
|
||||
- **Fix:** Added role="listbox" + aria-label (i18n issues.listLabel) to IssueList container div. Added role="option" + aria-selected + tabIndex={0} + onKeyDown (Enter/Space) to IssueListItem.
|
||||
- **Tests:** 4 new IssueList integration tests (listbox role, aria-label, option count, aria-selected). 4 new IssueListItem tests (role=option, aria-selected false/true, Enter key). 18 total.
|
||||
- **Test status:** `PASS`
|
||||
- **Depends on:** None
|
||||
- **Commit:** Phase F batch
|
||||
|
||||
### GAP-26: transitions.json retention
|
||||
- **Status:** `SKIPPED`
|
||||
- **Priority:** NICE-TO-HAVE
|
||||
- **Scope:** N/A
|
||||
- **Doc ref:** Not in PRD
|
||||
- **Reason:** Not specified in any PRD. Transitions are already persisted via appendTransition in enrichment handlers (GAP-30). No additional work needed.
|
||||
|
||||
---
|
||||
|
||||
## Progress Log
|
||||
@@ -570,6 +591,8 @@ Each gap has: ID, description, status, files to modify, doc reference, test stat
|
||||
| 2026-02-13 | GAP-41 | DONE — Bulk label sync: IPC channel + handler + preload + hook method, 2 new tests | Phase E |
|
||||
| 2026-02-13 | GAP-42 | DONE — Color swatches in LabelSyncSettings (7 workflow labels with colors), 2 new tests | Phase E |
|
||||
| 2026-02-13 | GAP-43 | DONE — Write/Preview tabs in CommentForm with ReactMarkdown, i18n keys EN+FR, 8 tests | Phase E |
|
||||
| 2026-02-13 | GAP-25 | DONE — ARIA listbox on IssueList + role=option + aria-selected + keyboard on IssueListItem, 8 new tests | Phase F |
|
||||
| 2026-02-13 | GAP-26 | SKIPPED — Not in PRD, transitions already persisted via GAP-30 | N/A |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user