Skip to content

Commit a1be4d6

Browse files
committed
feat(react): improve form-control accessibility
- Form.Item now generates ids that wire `aria-labelledby`, `aria-describedby`, and `aria-invalid` on the wrapped control so labels and validation errors are announced by screen readers. - Cascader forwards the consumer's ref to the wrapper and passes `id` and the `aria-*` props through to the combobox element. - InputNumber omits `min`, `max`, `aria-valuemin`, and `aria-valuemax` when the bounds are not finite (previously emitted `Infinity` / `-Infinity` strings) and forwards remaining native input props. Adds an axe-based Playwright accessibility suite under apps/docs/tests/accessibility/ covering form controls, overlays, and popups, plus a `pnpm test:accessibility` script and a CI step that installs Chrome via Playwright and runs the suite.
1 parent 2f28df1 commit a1be4d6

15 files changed

Lines changed: 356 additions & 94 deletions

File tree

.changeset/a11y-form-controls.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tiny-design/react': minor
3+
---
4+
5+
Improve form-control accessibility. `Form.Item` now generates ids that wire `aria-labelledby`, `aria-describedby`, and `aria-invalid` on the wrapped control automatically, so a label always announces with its input and screen readers hear validation errors. `Cascader` forwards the consumer's ref to the wrapper and pipes `id` and `aria-*` props through to the combobox element. `InputNumber` omits `min`, `max`, `aria-valuemin`, and `aria-valuemax` when the bounds are not finite (previously emitted `Infinity` / `-Infinity` strings) and forwards remaining native input props.

.github/workflows/ci.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,20 @@ jobs:
4040

4141
- name: Test
4242
run: pnpm test -- --coverage
43+
44+
- name: Install browser for Playwright
45+
run: pnpm exec playwright install --with-deps chrome
46+
47+
- name: Accessibility tests
48+
run: pnpm test:accessibility
49+
50+
- name: Upload Playwright report
51+
if: failure()
52+
uses: actions/upload-artifact@v4
53+
with:
54+
name: playwright-report
55+
path: |
56+
playwright-report/
57+
apps/docs/playwright-report/
58+
apps/docs/test-results/
59+
if-no-files-found: ignore
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
3+
export default defineConfig({
4+
testDir: './tests/accessibility',
5+
outputDir: './test-results/accessibility',
6+
fullyParallel: false,
7+
forbidOnly: !!process.env.CI,
8+
retries: process.env.CI ? 2 : 0,
9+
reporter: process.env.CI
10+
? [
11+
['html', { outputFolder: 'apps/docs/playwright-report/accessibility', open: 'never' }],
12+
['list'],
13+
]
14+
: 'list',
15+
webServer: {
16+
command: 'pnpm exec vite serve --host 127.0.0.1 --port 3004',
17+
url: 'http://127.0.0.1:3004',
18+
reuseExistingServer: !process.env.CI,
19+
timeout: 120_000,
20+
},
21+
use: {
22+
...devices['Desktop Chrome'],
23+
baseURL: 'http://127.0.0.1:3004',
24+
channel: 'chrome',
25+
colorScheme: 'light',
26+
deviceScaleFactor: 1,
27+
locale: 'en-US',
28+
timezoneId: 'UTC',
29+
viewport: { width: 1280, height: 900 },
30+
screenshot: 'only-on-failure',
31+
trace: 'retain-on-failure',
32+
},
33+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import AxeBuilder from '@axe-core/playwright';
2+
import { expect, test, type Locator, type Page } from '@playwright/test';
3+
import { gotoComponent, openFromDemo, previewByTitle, scrollDemoIntoView } from '../visual/helpers';
4+
5+
const WCAG_TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
6+
7+
let scanId = 0;
8+
9+
const formatViolations = (violations: Awaited<ReturnType<AxeBuilder['analyze']>>['violations']) =>
10+
violations.map(({ id, impact, description, nodes }) => ({
11+
id,
12+
impact,
13+
description,
14+
targets: nodes.slice(0, 5).map((node) => node.target.join(' ')),
15+
}));
16+
17+
const markLocator = async (locator: Locator) => {
18+
const marker = `a11y-scan-${++scanId}`;
19+
await locator.evaluate((node, value) => {
20+
node.setAttribute('data-a11y-scan', value);
21+
}, marker);
22+
23+
return `[data-a11y-scan="${marker}"]`;
24+
};
25+
26+
const scan = async (page: Page, target: Locator | string) => {
27+
const selector = typeof target === 'string' ? target : await markLocator(target);
28+
const results = await new AxeBuilder({ page })
29+
.withTags(WCAG_TAGS)
30+
.disableRules(['region'])
31+
.include(selector)
32+
.analyze();
33+
34+
expect(formatViolations(results.violations)).toEqual([]);
35+
};
36+
37+
test.describe('component accessibility checks', () => {
38+
test('form controls and table demos have no WCAG violations', async ({ page }) => {
39+
await gotoComponent(page, 'form');
40+
await scrollDemoIntoView(page, 'Basic usage');
41+
await scan(page, previewByTitle(page, 'Basic usage'));
42+
43+
await gotoComponent(page, 'table');
44+
await scrollDemoIntoView(page, 'Basic');
45+
await scan(page, previewByTitle(page, 'Basic'));
46+
47+
await scrollDemoIntoView(page, 'Row Selection');
48+
await scan(page, previewByTitle(page, 'Row Selection'));
49+
});
50+
51+
test('overlay components have no WCAG violations when open', async ({ page }) => {
52+
await gotoComponent(page, 'modal');
53+
await openFromDemo(page, 'Basic', 'button');
54+
await expect(page.locator('.ty-modal__content')).toBeVisible();
55+
await scan(page, '.ty-modal__content');
56+
57+
await gotoComponent(page, 'drawer');
58+
await openFromDemo(page, 'Basic', 'button');
59+
await expect(page.locator('.ty-drawer__content')).toBeVisible();
60+
await scan(page, '.ty-drawer__content');
61+
62+
await gotoComponent(page, 'tour');
63+
await openFromDemo(page, 'Basic', 'button:has-text("Start Tour")');
64+
await expect(page.locator('.ty-tour')).toBeVisible();
65+
await scan(page, '.ty-tour');
66+
});
67+
68+
test('select and picker popups have no WCAG violations when open', async ({ page }) => {
69+
await gotoComponent(page, 'select');
70+
await openFromDemo(page, 'Search', '.ty-select__selector');
71+
await expect(page.locator('.ty-select__dropdown')).toBeVisible();
72+
await scan(page, '.ty-select__dropdown');
73+
74+
await gotoComponent(page, 'date-picker');
75+
await openFromDemo(page, 'Date Range', '.ty-date-picker__input');
76+
await expect(page.locator('.ty-date-picker__dropdown')).toBeVisible();
77+
await scan(page, '.ty-date-picker__dropdown');
78+
79+
await gotoComponent(page, 'cascader');
80+
await openFromDemo(page, 'Change On Select', '.ty-cascader__selector');
81+
await expect(page.locator('.ty-cascader__dropdown')).toBeVisible();
82+
await scan(page, '.ty-cascader__dropdown');
83+
});
84+
});

apps/docs/tests/visual/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,10 @@ pnpm test:visual:update
1717

1818
The suite targets the docs app and uses the local Chrome channel to avoid storing
1919
browser binaries in the repository workflow.
20+
21+
Accessibility coverage for the same high-risk component families lives in
22+
`apps/docs/tests/accessibility` and runs with:
23+
24+
```sh
25+
pnpm test:accessibility
26+
```

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"build": "turbo run build",
1818
"dev": "turbo run dev",
1919
"test": "turbo run test",
20+
"test:accessibility": "playwright test -c apps/docs/playwright.accessibility.config.ts",
2021
"test:visual": "playwright test -c apps/docs/playwright.config.ts",
2122
"test:visual:update": "playwright test -c apps/docs/playwright.config.ts --update-snapshots",
2223
"lint": "turbo run lint",
@@ -27,11 +28,13 @@
2728
"release": "turbo run build && changeset publish"
2829
},
2930
"devDependencies": {
31+
"@axe-core/playwright": "^4.11.3",
3032
"@changesets/changelog-github": "^0.6.0",
3133
"@changesets/cli": "^2.30.0",
3234
"@changesets/get-github-info": "^0.8.0",
3335
"@eslint/js": "^9.0.0",
3436
"@playwright/test": "^1.59.1",
37+
"autoprefixer": "^10.4.27",
3538
"eslint": "^9.0.0",
3639
"eslint-plugin-jest": "^28.0.0",
3740
"eslint-plugin-jest-dom": "^5.0.0",

packages/mcp/src/data/components.json

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3521,12 +3521,6 @@
35213521
"required": false,
35223522
"description": "Determine whether always display the control button"
35233523
},
3524-
{
3525-
"name": "children",
3526-
"type": "React.ReactNode",
3527-
"required": false,
3528-
"description": ""
3529-
},
35303524
{
35313525
"name": "style",
35323526
"type": "CSSProperties",

0 commit comments

Comments
 (0)