Files
3x-ui/frontend/tools/oxlint/antd-a11y-guard.mjs
T
Alireza Ahmadi 1bdb517b88 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.
2026-11-01 16:49:19 +01:00

75 lines
2.6 KiB
JavaScript

// 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 });
},
};
},
},
},
};