fix(a11y): silence live stats and make Select tags removable for screen readers

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.
This commit is contained in:
Alireza Ahmadi
2026-11-01 16:49:19 +01:00
parent cbf596d889
commit 1bdb517b88
73 changed files with 672 additions and 48 deletions
@@ -0,0 +1,7 @@
import { Select, Spin } from 'antd';
declare const loading: boolean;
export const LivePage = () => <Spin spinning={loading}><span>42%</span></Spin>;
export const BareTags = () => <Select mode="tags" aria-label="SNI" />;
export const HiddenTags = () => <Select mode="multiple" aria-label="Inbounds" tagRender={() => null} maxTagCount="responsive" />;
@@ -0,0 +1,13 @@
import { Select, Spin } from 'antd';
declare const loading: boolean;
declare const rest: Record<string, unknown>;
declare function tagRender(): null;
declare function Other(props: { mode: string; children?: unknown }): null;
export const QuietPage = () => <Spin spinning={loading} aria-live="off"><span>42%</span></Spin>;
export const Indicator = () => <Spin size="large" />;
export const Single = () => <Select aria-label="Protocol" />;
export const Tags = () => <Select mode="tags" aria-label="SNI" tagRender={tagRender} />;
export const Forwarded = () => <Select mode="tags" aria-label="SNI" {...rest} />;
export const NotAntd = () => <Other mode="tags"><span>x</span></Other>;
@@ -0,0 +1,9 @@
{
"plugins": [],
"categories": {},
"jsPlugins": ["../antd-a11y-guard.mjs"],
"rules": {
"antd-a11y/spin-content-not-live": "error",
"antd-a11y/select-tag-remove": "error"
}
}
+74
View File
@@ -0,0 +1,74 @@
// Ant Design Spin wraps its content in aria-live="polite", so every live stat inside is
// read aloud; Select tags hide their remove "x" from screen readers and keyboards.
const SPIN_LIVE =
'Spin wraps its content in aria-live="polite", so every update inside is read aloud; pass aria-live="off".';
const NO_TAG_RENDER =
'Select tag remove buttons are aria-hidden and unfocusable; pass tagRender={selectTagRender}.';
const HIDDEN_TAGS =
'maxTagCount hides selected values behind "+N", out of reach of screen readers; show every tag.';
function attrs(node) {
const map = new Map();
let spread = false;
for (const attr of node.attributes) {
if (attr.type === 'JSXAttribute') map.set(attr.name.name, attr);
else spread = true;
}
return { map, spread };
}
function hasContent(element) {
return element.children.some((c) => c.type !== 'JSXText' || c.value.trim() !== '');
}
export default {
meta: { name: 'antd-a11y' },
rules: {
'spin-content-not-live': {
create(context) {
const spins = new Set();
return {
ImportDeclaration(node) {
if (node.source.value !== 'antd') return;
for (const spec of node.specifiers) {
if (spec.type === 'ImportSpecifier' && spec.imported.name === 'Spin') {
spins.add(spec.local.name);
}
}
},
JSXElement(node) {
const opening = node.openingElement;
if (opening.name.type !== 'JSXIdentifier' || !spins.has(opening.name.name)) return;
if (!hasContent(node)) return;
const { map, spread } = attrs(opening);
if (!spread && !map.has('aria-live'))
context.report({ node: opening, message: SPIN_LIVE });
},
};
},
},
'select-tag-remove': {
create(context) {
const selects = new Set();
return {
ImportDeclaration(node) {
if (node.source.value !== 'antd') return;
for (const spec of node.specifiers) {
if (spec.type === 'ImportSpecifier' && spec.imported.name === 'Select') {
selects.add(spec.local.name);
}
}
},
JSXOpeningElement(node) {
if (node.name.type !== 'JSXIdentifier' || !selects.has(node.name.name)) return;
const { map, spread } = attrs(node);
if (!map.has('mode')) return;
if (map.has('maxTagCount')) context.report({ node, message: HIDDEN_TAGS });
else if (!spread && !map.has('tagRender'))
context.report({ node, message: NO_TAG_RENDER });
},
};
},
},
},
};