Skip to content

Commit c37cdca

Browse files
Merge pull request enbliq#304 from Ibinola/feat/292-isolated-ui-lab-a11y
feat(track-b): add isolated UI lab with automated a11y checks
2 parents b6444bb + bdc154f commit c37cdca

10 files changed

Lines changed: 355 additions & 0 deletions

File tree

experiments/track-b/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"test": "pnpm --filter @ourkairos/lab-shell test",
2020
"test:b02": "pnpm --filter @ourkairos/capsule-replay test",
2121
"test:b03": "pnpm --filter @ourkairos/traffic-harness test",
22+
"test:b04": "pnpm --filter @ourkairos/ui-lab test",
2223
"run:b03": "pnpm --filter @ourkairos/traffic-harness run cli"
2324
}
2425
}

experiments/track-b/pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ packages:
22
- "lab-shell"
33
- "capsule-replay"
44
- "traffic-harness"
5+
- "ui-lab"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# UI Lab A11y Audit Summary
2+
3+
## Modal
4+
✅ Passed (No violations)
5+
6+
## Dropdown
7+
✅ Passed (No violations)
8+
9+
## Tabs
10+
✅ Passed (No violations)
11+
12+
## Form
13+
✅ Passed (No violations)
14+
15+
## Toast
16+
✅ Passed (No violations)
17+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "@ourkairos/ui-lab",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"scripts": {
7+
"test": "vitest run",
8+
"test:watch": "vitest"
9+
},
10+
"devDependencies": {
11+
"@testing-library/dom": "^10.0.0",
12+
"@testing-library/react": "^16.0.0",
13+
"@testing-library/user-event": "^14.5.2",
14+
"@types/react": "^19.0.0",
15+
"@types/react-dom": "^19.0.0",
16+
"@vitejs/plugin-react": "^4.2.1",
17+
"axe-core": "^4.8.3",
18+
"jsdom": "^24.0.0",
19+
"react": "^19.0.0",
20+
"react-dom": "^19.0.0",
21+
"typescript": "^5.4.0",
22+
"vite": "^5.0.0",
23+
"vitest": "^2.1.8"
24+
}
25+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import React, { useState, useEffect, useRef } from 'react';
2+
3+
export const Modal = ({ isOpen, onClose, title, children }: any) => {
4+
const ref = useRef<HTMLDivElement>(null);
5+
6+
useEffect(() => {
7+
if (!isOpen) return;
8+
const el = ref.current;
9+
if (!el) return;
10+
11+
// Focus trap implementation
12+
const focusable = Array.from(el.querySelectorAll<HTMLElement>(
13+
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
14+
));
15+
const first = focusable[0];
16+
const last = focusable[focusable.length - 1];
17+
18+
const handleKeyDown = (e: KeyboardEvent) => {
19+
if (e.key === 'Tab') {
20+
if (e.shiftKey) {
21+
if (document.activeElement === first) {
22+
e.preventDefault();
23+
last?.focus();
24+
}
25+
} else {
26+
if (document.activeElement === last) {
27+
e.preventDefault();
28+
first?.focus();
29+
}
30+
}
31+
} else if (e.key === 'Escape') {
32+
onClose();
33+
}
34+
};
35+
36+
el.addEventListener('keydown', handleKeyDown);
37+
first?.focus();
38+
return () => el.removeEventListener('keydown', handleKeyDown);
39+
}, [isOpen, onClose]);
40+
41+
if (!isOpen) return null;
42+
43+
return (
44+
<div role="dialog" aria-modal="true" aria-labelledby="modal-title" ref={ref}>
45+
<h2 id="modal-title">{title}</h2>
46+
{children}
47+
<button onClick={onClose} aria-label="Close modal">Close</button>
48+
</div>
49+
);
50+
};
51+
52+
export const Dropdown = ({ options }: { options: string[] }) => {
53+
const [isOpen, setIsOpen] = useState(false);
54+
const [selected, setSelected] = useState(options[0]);
55+
56+
return (
57+
<div>
58+
<button
59+
aria-haspopup="listbox"
60+
aria-expanded={isOpen}
61+
onClick={() => setIsOpen(!isOpen)}
62+
>
63+
{selected}
64+
</button>
65+
{isOpen && (
66+
<ul role="listbox" aria-activedescendant={selected}>
67+
{options.map((opt) => (
68+
<li
69+
key={opt}
70+
role="option"
71+
id={opt}
72+
aria-selected={selected === opt}
73+
onClick={() => { setSelected(opt); setIsOpen(false); }}
74+
>
75+
{opt}
76+
</li>
77+
))}
78+
</ul>
79+
)}
80+
</div>
81+
);
82+
};
83+
84+
export const Tabs = ({ tabs }: { tabs: { id: string, label: string, content: React.ReactNode }[] }) => {
85+
const [activeIndex, setActiveIndex] = useState(0);
86+
const tabsRef = useRef<(HTMLButtonElement | null)[]>([]);
87+
88+
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
89+
let newIndex = activeIndex;
90+
if (e.key === 'ArrowRight') {
91+
newIndex = (index + 1) % tabs.length;
92+
} else if (e.key === 'ArrowLeft') {
93+
newIndex = (index - 1 + tabs.length) % tabs.length;
94+
} else {
95+
return;
96+
}
97+
setActiveIndex(newIndex);
98+
tabsRef.current[newIndex]?.focus();
99+
};
100+
101+
return (
102+
<div>
103+
<div role="tablist" aria-label="Sample Tabs">
104+
{tabs.map((tab, i) => (
105+
<button
106+
key={tab.id}
107+
role="tab"
108+
aria-selected={activeIndex === i}
109+
aria-controls={`panel-${tab.id}`}
110+
id={`tab-${tab.id}`}
111+
ref={(el) => { tabsRef.current[i] = el; }}
112+
onClick={() => setActiveIndex(i)}
113+
onKeyDown={(e) => handleKeyDown(e, i)}
114+
tabIndex={activeIndex === i ? 0 : -1}
115+
>
116+
{tab.label}
117+
</button>
118+
))}
119+
</div>
120+
{tabs.map((tab, i) => (
121+
<div
122+
key={tab.id}
123+
role="tabpanel"
124+
id={`panel-${tab.id}`}
125+
aria-labelledby={`tab-${tab.id}`}
126+
hidden={activeIndex !== i}
127+
tabIndex={0}
128+
>
129+
{tab.content}
130+
</div>
131+
))}
132+
</div>
133+
);
134+
};
135+
136+
export const Form = () => (
137+
<form noValidate onSubmit={(e) => e.preventDefault()}>
138+
<label htmlFor="username">Username</label>
139+
<input id="username" type="text" aria-required="true" required />
140+
<label htmlFor="email">Email</label>
141+
<input id="email" type="email" />
142+
<button type="submit">Submit</button>
143+
</form>
144+
);
145+
146+
export const Toast = ({ message, type = 'info' }: { message: string, type?: 'info' | 'error' }) => (
147+
<div role={type === 'error' ? 'alert' : 'status'} aria-live={type === 'error' ? 'assertive' : 'polite'}>
148+
{message}
149+
</div>
150+
);
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render } from '@testing-library/react';
3+
import axe from 'axe-core';
4+
import { Modal, Dropdown, Tabs, Form, Toast } from '../src/components';
5+
import { auditResults } from './setup';
6+
7+
async function checkA11y(container: HTMLElement, componentName: string) {
8+
const results = await axe.run(container);
9+
auditResults.push({ componentName, violations: results.violations });
10+
expect(results.violations).toEqual([]);
11+
}
12+
13+
describe('A11y Component Checks', () => {
14+
it('Modal is accessible', async () => {
15+
const { container } = render(
16+
<Modal isOpen={true} title="Test Modal" onClose={() => { }}>
17+
<p>Modal content</p>
18+
</Modal>
19+
);
20+
await checkA11y(container, 'Modal');
21+
});
22+
23+
it('Dropdown is accessible', async () => {
24+
const { container } = render(<Dropdown options={['Option A', 'Option B']} />);
25+
await checkA11y(container, 'Dropdown');
26+
});
27+
28+
it('Tabs are accessible', async () => {
29+
const { container } = render(
30+
<Tabs tabs={[
31+
{ id: 't1', label: 'Tab 1', content: 'Content 1' },
32+
{ id: 't2', label: 'Tab 2', content: 'Content 2' }
33+
]} />
34+
);
35+
await checkA11y(container, 'Tabs');
36+
});
37+
38+
it('Form is accessible', async () => {
39+
const { container } = render(<Form />);
40+
await checkA11y(container, 'Form');
41+
});
42+
43+
it('Toast is accessible', async () => {
44+
const { container } = render(<Toast message="Lab toast rendered" type="info" />);
45+
await checkA11y(container, 'Toast');
46+
});
47+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { it, expect } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { Modal, Tabs } from '../src/components';
5+
6+
it('traps focus inside the modal', async () => {
7+
const user = userEvent.setup();
8+
render(
9+
<div>
10+
<button id="outside">Outside</button>
11+
<Modal isOpen={true} title="Test Modal" onClose={() => { }}>
12+
<button id="inside1">Inside 1</button>
13+
<button id="inside2">Inside 2</button>
14+
</Modal>
15+
</div>
16+
);
17+
18+
const inside1 = screen.getByText('Inside 1');
19+
const inside2 = screen.getByText('Inside 2');
20+
const close = screen.getByText('Close');
21+
22+
inside1.focus();
23+
expect(document.activeElement).toBe(inside1);
24+
25+
await user.tab();
26+
expect(document.activeElement).toBe(inside2);
27+
28+
await user.tab();
29+
expect(document.activeElement).toBe(close);
30+
31+
// Tab again should loop back to first interactive element
32+
await user.tab();
33+
expect(document.activeElement).toBe(inside1);
34+
35+
// Shift + Tab should loop back to last element
36+
await user.tab({ shift: true });
37+
expect(document.activeElement).toBe(close);
38+
});
39+
40+
it('Tabs can be navigated with keyboard arrows', async () => {
41+
const user = userEvent.setup();
42+
render(
43+
<Tabs tabs={[
44+
{ id: 't1', label: 'Tab 1', content: 'Content 1' },
45+
{ id: 't2', label: 'Tab 2', content: 'Content 2' }
46+
]} />
47+
);
48+
49+
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
50+
const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
51+
52+
tab1.focus();
53+
expect(document.activeElement).toBe(tab1);
54+
55+
await user.keyboard('{ArrowRight}');
56+
expect(document.activeElement).toBe(tab2);
57+
expect(tab2.getAttribute('aria-selected')).toBe('true');
58+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { afterAll } from "vitest";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
5+
export const auditResults: any[] = [];
6+
7+
afterAll(() => {
8+
let md = "# UI Lab A11y Audit Summary\n\n";
9+
if (auditResults.length === 0) {
10+
md += "No components audited.\n";
11+
} else {
12+
for (const res of auditResults) {
13+
md += `## ${res.componentName}\n`;
14+
if (res.violations.length === 0) {
15+
md += "✅ Passed (No violations)\n\n";
16+
} else {
17+
md += "❌ Violations:\n";
18+
for (const v of res.violations) {
19+
md += `- **${v.id}**: ${v.description} (${v.impact})\n`;
20+
}
21+
md += "\n";
22+
}
23+
}
24+
}
25+
const reportPath = path.resolve(process.cwd(), "a11y-audit-report.md");
26+
fs.writeFileSync(reportPath, md, "utf8");
27+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"useDefineForClassFields": true,
5+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
6+
"module": "ESNext",
7+
"skipLibCheck": true,
8+
"moduleResolution": "Bundler",
9+
"allowImportingTsExtensions": true,
10+
"resolveJsonModule": true,
11+
"isolatedModules": true,
12+
"noEmit": true,
13+
"jsx": "react-jsx",
14+
"strict": true,
15+
"types": ["vitest/globals"]
16+
},
17+
"include": ["src", "tests"]
18+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { defineConfig } from "vitest/config";
2+
import react from "@vitejs/plugin-react";
3+
4+
export default defineConfig({
5+
plugins: [react()],
6+
test: {
7+
environment: "jsdom",
8+
setupFiles: ["./tests/setup.ts"],
9+
include: ["tests/**/*.test.tsx"],
10+
},
11+
});

0 commit comments

Comments
 (0)