Files
3x-ui/frontend/tools/oxlint/control-label-guard.mjs
T
Alireza Ahmadi e2184777a0 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.
2026-10-23 18:29:47 +02:00

89 lines
2.8 KiB
JavaScript

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