mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-03 03:35:04 +03:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user