mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-05 20:55:29 +03:00
1bdb517b88
Ant Design's Spin wraps its content in aria-live="polite", so every WebSocket tick on the dashboard, inbounds, clients and nodes pages was announced, and only the changed number was read, with no label. Pass aria-live="off" on every Spin that wraps content; aria-busy still signals loading. Each vital tile is now a group named by its metric, so "47.2 %" is read as CPU. rc-select renders each tag's remove "x" as an aria-hidden span with no tabIndex, so screen-reader users could only Backspace the last value away, never remove one SNI or inbound from the middle. selectTagRender swaps in a real button named "Remove <value>" and moves focus to the next tag after removal. It is applied to every tags/multiple Select. maxTagCount="responsive" is dropped because it hid selected inbounds behind "+N", where no screen reader could reach them. Two oxlint rules (antd-a11y/spin-content-not-live and antd-a11y/select-tag-remove) keep new code from regressing either fix.
75 lines
2.5 KiB
TypeScript
75 lines
2.5 KiB
TypeScript
import { execFileSync } from 'node:child_process';
|
|
import { readFileSync } from 'node:fs';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
// The Spin/Select screen-reader guards walk oxlint's AST by hand, so an oxlint
|
|
// bump can kill them silently. These tests fail loudly when it does.
|
|
const FIXTURES = 'tools/oxlint/__fixtures__';
|
|
const SPIN_RULE = 'antd-a11y(spin-content-not-live)';
|
|
const SELECT_RULE = 'antd-a11y(select-tag-remove)';
|
|
|
|
function runGuard(target: string): string {
|
|
try {
|
|
execFileSync(
|
|
process.execPath,
|
|
[
|
|
'node_modules/oxlint/bin/oxlint',
|
|
'-c',
|
|
`${FIXTURES}/antd-a11y.oxlintrc.json`,
|
|
'-f',
|
|
'unix',
|
|
target,
|
|
],
|
|
{ encoding: 'utf8', stdio: 'pipe' },
|
|
);
|
|
return '';
|
|
} catch (error) {
|
|
const { status, stdout } = error as { status?: number | null; stdout?: string };
|
|
if (typeof status !== 'number') throw error;
|
|
return String(stdout ?? '');
|
|
}
|
|
}
|
|
|
|
function hitLines(output: string, rule: string): number[] {
|
|
return output
|
|
.split('\n')
|
|
.filter((line) => line.includes(rule))
|
|
.map((line) => Number(line.split(':')[1]));
|
|
}
|
|
|
|
describe('antd-a11y-guard oxlint plugin', () => {
|
|
it('flags a Spin whose content would be announced on every update', () => {
|
|
expect(hitLines(runGuard(`${FIXTURES}/antd-a11y-flagged.tsx`), SPIN_RULE)).toEqual([5]);
|
|
});
|
|
|
|
it('flags tag Selects without a focusable remove button or with hidden tags', () => {
|
|
expect(hitLines(runGuard(`${FIXTURES}/antd-a11y-flagged.tsx`), SELECT_RULE)).toEqual([6, 7]);
|
|
});
|
|
|
|
it('accepts quiet Spins, bare indicators, single Selects, tagRender and spreads', () => {
|
|
const output = runGuard(`${FIXTURES}/antd-a11y-ok.tsx`);
|
|
expect(output).not.toContain(SPIN_RULE);
|
|
expect(output).not.toContain(SELECT_RULE);
|
|
});
|
|
});
|
|
|
|
describe('antd-a11y-guard wiring', () => {
|
|
const config = JSON.parse(readFileSync('.oxlintrc.json', 'utf8')) as {
|
|
jsPlugins: string[];
|
|
overrides: { files: string[]; rules: Record<string, unknown> }[];
|
|
};
|
|
|
|
it('enforces both rules on app sources but not on stories or tests', () => {
|
|
expect(config.jsPlugins).toContain('./tools/oxlint/antd-a11y-guard.mjs');
|
|
for (const rule of ['antd-a11y/spin-content-not-live', 'antd-a11y/select-tag-remove']) {
|
|
expect(config.overrides.find((o) => o.rules[rule] === 'error')?.files).toEqual([
|
|
'src/**/*.tsx',
|
|
]);
|
|
expect(config.overrides.find((o) => o.rules[rule] === 'off')?.files).toEqual([
|
|
'src/**/*.stories.tsx',
|
|
'src/test/**',
|
|
]);
|
|
}
|
|
});
|
|
});
|