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