fix(frontend): make the panel usable with screen readers (#6907)

* fix(frontend): make the panel usable with screen readers

* fix(frontend): keep mobile tab names and the inbound expiry hint

Review of #6907 found two gaps in the screen-reader pass:

- On phone-width screens catTabLabel renders only the icon and names the
  tab by putting aria-label on it, but callers now pass that icon with
  aria-hidden for the desktop label, so every Settings, Xray and Host
  category tab lost its name under TalkBack. catTabLabel now clears
  aria-hidden on the icon it uses as the name.
- DateTimePicker forwarded id and aria-label but not aria-describedby,
  so the inbound expiry hint ("leave blank to never expire") was never
  attached. It now forwards it on both the Gregorian and Jalali input.
This commit is contained in:
Alireza Ahmadi
2026-10-23 18:29:47 +02:00
committed by GitHub
parent 775b2b0b26
commit e2184777a0
156 changed files with 2373 additions and 863 deletions
@@ -0,0 +1,9 @@
import { PlusOutlined, WarningOutlined } from '@ant-design/icons';
import { Button } from 'antd';
declare const rest: Record<string, unknown>;
export const Decorative = () => <Button icon={<PlusOutlined aria-hidden />}>Add</Button>;
export const Named = () => <WarningOutlined aria-label="Disk almost full" />;
export const LabelledBy = () => <WarningOutlined aria-labelledby="warning-text" />;
export const Forwarded = () => <WarningOutlined {...rest} />;
@@ -0,0 +1,8 @@
import { PlusOutlined, WarningOutlined } from '@ant-design/icons';
import { Button } from 'antd';
declare function CustomOutlined(): null;
export const InButton = () => <Button icon={<PlusOutlined />}>Add</Button>;
export const Standalone = () => <WarningOutlined style={{ color: 'red' }} />;
export const NotAnAntdIcon = () => <CustomOutlined />;
@@ -0,0 +1,6 @@
{
"plugins": [],
"categories": {},
"jsPlugins": ["../icon-a11y-guard.mjs"],
"rules": { "antd-icon/explicit-a11y": "error" }
}
@@ -0,0 +1,6 @@
{
"plugins": [],
"categories": {},
"jsPlugins": ["../control-label-guard.mjs"],
"rules": { "control-label/has-name": "error" }
}
@@ -0,0 +1,43 @@
import { Form, Input, Space, Switch } from 'antd';
declare function FormField(props: { label?: string; children?: unknown }): null;
declare function SettingListItem(props: { title?: string; control?: unknown; children?: unknown }): null;
declare const rest: Record<string, unknown>;
export const AriaLabel = () => <Switch aria-label="Enabled" />;
export const LabelledBy = () => <Switch aria-labelledby="heading" />;
export const Forwarded = () => <Input {...rest} />;
export const Wrapped = () => (
// eslint-disable-next-line jsx-a11y/label-has-associated-control -- antd Input is opaque to it
<label>
Email <Input />
</label>
);
export const HtmlForAndId = () => (
<Form.Item label="Email" htmlFor="email">
<Space.Compact>
<Input id="email" />
</Space.Compact>
</Form.Item>
);
export const NamedItemDirect = () => (
<Form.Item label="Email" name="email">
<Input />
</Form.Item>
);
export const ViaFormField = () => (
<FormField label="Email">
<Input />
</FormField>
);
export const SettingChild = () => (
<SettingListItem title="Enabled">
<Switch />
</SettingListItem>
);
export const SettingControlProp = () => <SettingListItem title="Enabled" control={<Switch />} />;
@@ -0,0 +1,39 @@
import { Form, Input, Space, Switch } from 'antd';
declare function SettingListItem(props: { title?: string; children?: unknown }): null;
export const TableCell = () => <Switch checked />;
export const LabelOnlyItem = () => (
<Form.Item label="Email">
<Input />
</Form.Item>
);
export const IdWithoutHtmlFor = () => (
<Form.Item label="Email">
<Input id="email" />
</Form.Item>
);
export const HtmlForWithoutId = () => (
<Form.Item label="Email" htmlFor="email">
<Input />
</Form.Item>
);
export const NamedItemNested = () => (
<Form.Item label="Email" name="email">
<Space.Compact>
<Input />
</Space.Compact>
</Form.Item>
);
export const SettingNested = () => (
<SettingListItem title="Enabled">
<Space>
<Switch />
</Space>
</SettingListItem>
);
@@ -0,0 +1,88 @@
// jsx-a11y cannot see through Ant Design components, so a Switch or Select with no
// tie to its visible label reaches screen readers unnamed (s-ui#1293). Guard it here.
const CONTROLS = new Set([
'Input',
'Input.Password',
'Input.TextArea',
'Input.Search',
'InputNumber',
'Select',
'AutoComplete',
'Switch',
'DatePicker',
'DateTimePicker',
'DatePicker.RangePicker',
'TimePicker',
'Cascader',
'TreeSelect',
]);
const UNNAMED =
'Form control has no accessible name; give it aria-label, or an id wired to a visible label.';
const NESTED =
'Form.Item name hands its id only to its direct child; wire htmlFor/id or aria-label for this nested control.';
const UNTIED =
'Form.Item label without name is not tied to this control; pass htmlFor on the Form.Item and the same id on the control.';
function tagName(node) {
if (!node) return '';
if (node.type === 'JSXIdentifier') return node.name;
if (node.type === 'JSXMemberExpression') return `${tagName(node.object)}.${node.property.name}`;
return '';
}
function attrs(element) {
const map = new Map();
let spread = false;
for (const attr of element.openingElement.attributes ?? []) {
if (attr.type === 'JSXAttribute') map.set(attr.name.name, attr);
else spread = true;
}
return { map, spread };
}
// `direct` marks the innermost element: the only one whose cloneElement reaches the control.
function* labellingAncestors(node) {
let direct = true;
for (let cur = node.parent; cur; cur = cur.parent) {
if (cur.type !== 'JSXElement') continue;
yield { element: cur, direct };
direct = false;
}
}
function check(node) {
const { map, spread } = attrs(node);
if (map.has('aria-label') || map.has('aria-labelledby') || spread) return null;
const hasId = map.has('id');
for (const { element, direct } of labellingAncestors(node)) {
const name = tagName(element.openingElement.name);
const parentAttrs = attrs(element).map;
if (name === 'label') return null;
if (name === 'FormField' && parentAttrs.has('label')) return null;
if (name === 'SettingListItem' && parentAttrs.has('title')) return direct ? null : UNNAMED;
if (name === 'Form.Item' && parentAttrs.has('label')) {
if (parentAttrs.has('name')) return direct ? null : NESTED;
if (parentAttrs.has('htmlFor')) return hasId ? null : UNTIED;
return UNTIED;
}
}
return hasId ? null : UNNAMED;
}
export default {
meta: { name: 'control-label' },
rules: {
'has-name': {
create(context) {
return {
JSXElement(node) {
if (!CONTROLS.has(tagName(node.openingElement.name))) return;
const message = check(node);
if (message) context.report({ node: node.openingElement, message });
},
};
},
},
},
};
+32
View File
@@ -0,0 +1,32 @@
// @ant-design/icons render role="img" aria-label="<icon-name>", so screen readers speak
// "plus", "question-circle" etc. in English inside every button and label (s-ui#1293).
const MESSAGE =
'Ant Design icon announces its English name; add aria-hidden if decorative, or aria-label with translated text.';
const EXPLICIT = new Set(['aria-hidden', 'aria-label', 'aria-labelledby']);
export default {
meta: { name: 'antd-icon' },
rules: {
'explicit-a11y': {
create(context) {
const icons = new Set();
return {
ImportDeclaration(node) {
if (node.source.value !== '@ant-design/icons') return;
for (const spec of node.specifiers) {
if (spec.type === 'ImportSpecifier') icons.add(spec.local.name);
}
},
JSXOpeningElement(node) {
if (node.name.type !== 'JSXIdentifier' || !icons.has(node.name.name)) return;
const explicit = node.attributes.some(
(attr) => attr.type !== 'JSXAttribute' || EXPLICIT.has(attr.name.name),
);
if (!explicit) context.report({ node, message: MESSAGE });
},
};
},
},
},
};