mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-03 11:45:04 +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,30 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Without a ConfigProvider locale, antd's own strings (empty states, close buttons,
|
||||
// pagination) stayed English for every panel language (s-ui#1293).
|
||||
describe('AntdRootConfig locale', () => {
|
||||
afterEach(() => {
|
||||
document.cookie = 'lang=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||
});
|
||||
|
||||
it.each([
|
||||
['fa-IR', 'دادهای موجود نیست'],
|
||||
['en-US', 'No data'],
|
||||
])('renders antd built-in text in the %s panel language', async (locale, emptyText) => {
|
||||
vi.resetModules();
|
||||
document.cookie = `lang=${locale}; path=/`;
|
||||
const { readyI18n } = await import('@/i18n/react');
|
||||
const { AntdRootConfig } = await import('@/i18n/AntdRootConfig');
|
||||
const { Empty } = await import('antd');
|
||||
|
||||
await readyI18n('panel');
|
||||
render(
|
||||
<AntdRootConfig>
|
||||
<Empty />
|
||||
</AntdRootConfig>,
|
||||
);
|
||||
|
||||
expect(document.querySelector('.ant-empty-description')?.textContent).toBe(emptyText);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { Tabs } from 'antd';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
// On phones the tab shows only its icon; callers mark that icon aria-hidden for the
|
||||
// desktop label, which also hid the name catTabLabel gives it (PR #6907 review).
|
||||
describe('catTabLabel', () => {
|
||||
it.each([true, false])('names the tab with its text (iconsOnly=%s)', (iconsOnly) => {
|
||||
renderWithProviders(
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'panel',
|
||||
label: catTabLabel(<SettingOutlined aria-hidden />, 'Panel Settings', iconsOnly),
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('tab', { name: 'Panel Settings' }).getAttribute('aria-selected')).toBe(
|
||||
'true',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// The s-ui#1293 unnamed-control guard walks oxlint's AST by hand, so an oxlint
|
||||
// bump can kill it silently. These tests fail loudly when it does.
|
||||
const FIXTURES = 'tools/oxlint/__fixtures__';
|
||||
const RULE = 'control-label(has-name)';
|
||||
|
||||
function runGuard(target: string): string {
|
||||
try {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
'node_modules/oxlint/bin/oxlint',
|
||||
'-c',
|
||||
`${FIXTURES}/label.oxlintrc.json`,
|
||||
'-f',
|
||||
'unix',
|
||||
target,
|
||||
],
|
||||
{ encoding: 'utf8', stdio: 'pipe' },
|
||||
);
|
||||
return '';
|
||||
} catch (error) {
|
||||
const { status, stdout } = error as { status?: number | null; stdout?: string };
|
||||
if (typeof status !== 'number') throw error;
|
||||
return String(stdout ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
describe('control-label-guard oxlint plugin', () => {
|
||||
it('flags every control a screen reader would announce without a name', () => {
|
||||
const hits = runGuard(`${FIXTURES}/unlabeled-control.tsx`)
|
||||
.split('\n')
|
||||
.filter((line) => line.includes(RULE))
|
||||
.map((line) => Number(line.split(':')[1]));
|
||||
|
||||
// Bare cell switch, label-only Form.Item, id without htmlFor, htmlFor without
|
||||
// id, nested under a named Form.Item, nested under SettingListItem.
|
||||
expect(hits).toEqual([5, 9, 15, 21, 28, 36]);
|
||||
});
|
||||
|
||||
it('accepts aria names, wrapping labels, htmlFor/id pairs and the labelling wrappers', () => {
|
||||
expect(runGuard(`${FIXTURES}/labelled-control.tsx`)).not.toContain(RULE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('control-label-guard wiring', () => {
|
||||
const config = JSON.parse(readFileSync('.oxlintrc.json', 'utf8')) as {
|
||||
jsPlugins: string[];
|
||||
overrides: { files: string[]; rules: Record<string, unknown> }[];
|
||||
};
|
||||
|
||||
it('enforces the rule on app sources but not on stories or tests', () => {
|
||||
expect(config.jsPlugins).toContain('./tools/oxlint/control-label-guard.mjs');
|
||||
const enabled = config.overrides.find((o) => o.rules['control-label/has-name'] === 'error');
|
||||
expect(enabled?.files).toEqual(['src/**/*.tsx']);
|
||||
const exempt = config.overrides.find((o) => o.rules['control-label/has-name'] === 'off');
|
||||
expect(exempt?.files).toEqual(['src/**/*.stories.tsx', 'src/test/**']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { act, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DateTimePicker from '@/components/form/DateTimePicker';
|
||||
import { setDatepicker } from '@/hooks/useDatepicker';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
vi.mock('persian-calendar-suite', () => ({
|
||||
PersianDateTimePicker: () => <input type="text" />,
|
||||
}));
|
||||
|
||||
afterEach(() => act(() => setDatepicker('gregorian')));
|
||||
|
||||
// Form.Item labels reach the picker only through the id it forwards to its input.
|
||||
describe.each(['gregorian', 'jalalian'] as const)('DateTimePicker (%s) labelling', (calendar) => {
|
||||
it('ties a visible label to the picker input through id', () => {
|
||||
act(() => setDatepicker(calendar));
|
||||
renderWithProviders(
|
||||
<>
|
||||
<label htmlFor="expiry">Expiry</label>
|
||||
<DateTimePicker id="expiry" value={null} onChange={vi.fn()} />
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('Expiry').tagName).toBe('INPUT');
|
||||
});
|
||||
|
||||
it('names the picker input from aria-label', () => {
|
||||
act(() => setDatepicker(calendar));
|
||||
renderWithProviders(<DateTimePicker aria-label="Expiry" value={null} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('textbox', { name: 'Expiry' }).tagName).toBe('INPUT');
|
||||
});
|
||||
|
||||
it('describes the picker input from aria-describedby', () => {
|
||||
act(() => setDatepicker(calendar));
|
||||
renderWithProviders(
|
||||
<>
|
||||
<span id="expiry-hint">Leave blank to never expire</span>
|
||||
<DateTimePicker
|
||||
aria-label="Expiry"
|
||||
aria-describedby="expiry-hint"
|
||||
value={null}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Expiry' });
|
||||
expect(input.getAttribute('aria-describedby')).toBe('expiry-hint');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Screen readers choose their voice from <html lang>; the shipped html says "en"
|
||||
// for every locale, so NVDA read Persian panels with an English voice (s-ui#1293).
|
||||
describe('readyI18n page language', () => {
|
||||
afterEach(() => {
|
||||
document.cookie = 'lang=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||
document.cookie = 'subLang=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||||
document.documentElement.lang = 'en';
|
||||
});
|
||||
|
||||
it.each([
|
||||
['panel', 'lang', 'fa-IR'],
|
||||
['subscription', 'subLang', 'zh-TW'],
|
||||
] as const)('sets <html lang> to the active %s locale', async (scope, cookie, locale) => {
|
||||
vi.resetModules();
|
||||
document.cookie = `${cookie}=${locale}; path=/`;
|
||||
const { readyI18n } = await import('@/i18n/react');
|
||||
|
||||
await readyI18n(scope);
|
||||
|
||||
expect(document.documentElement.lang).toBe(locale);
|
||||
});
|
||||
|
||||
it('falls back to en-US when the cookie names an unknown locale', async () => {
|
||||
vi.resetModules();
|
||||
document.cookie = 'lang=xx-XX; path=/';
|
||||
const { readyI18n } = await import('@/i18n/react');
|
||||
|
||||
await readyI18n('panel');
|
||||
|
||||
expect(document.documentElement.lang).toBe('en-US');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { FormProvider } from 'react-hook-form';
|
||||
import { Form, InputNumber } from 'antd';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FormField, useZodForm } from '@/components/form/rhf';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const Schema = z.object({ totalGB: z.number() });
|
||||
|
||||
function Harness() {
|
||||
const methods = useZodForm(Schema, { defaultValues: { totalGB: 0 } });
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Form layout="vertical">
|
||||
<FormField name="totalGB" label="Total traffic" tooltip="0 means unlimited">
|
||||
<InputNumber />
|
||||
</FormField>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// The hint icon sat inside the <label>, so the field was named "Total traffic
|
||||
// question-circle" and the hover-only hint never reached a screen reader (s-ui#1293).
|
||||
describe('FormField tooltip hint', () => {
|
||||
it('keeps the icon out of the field name and exposes the hint as its description', () => {
|
||||
renderWithProviders(<Harness />);
|
||||
|
||||
const field = screen.getByRole('spinbutton', { name: 'Total traffic' });
|
||||
const describedBy = field.getAttribute('aria-describedby') ?? '';
|
||||
expect(document.getElementById(describedBy)?.textContent).toBe('0 means unlimited');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// The s-ui#1293 icon guard walks oxlint's AST by hand, so an oxlint bump can kill
|
||||
// it silently. These tests fail loudly when it does.
|
||||
const FIXTURES = 'tools/oxlint/__fixtures__';
|
||||
const RULE = 'antd-icon(explicit-a11y)';
|
||||
|
||||
function runGuard(target: string): string {
|
||||
try {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
'node_modules/oxlint/bin/oxlint',
|
||||
'-c',
|
||||
`${FIXTURES}/icon.oxlintrc.json`,
|
||||
'-f',
|
||||
'unix',
|
||||
target,
|
||||
],
|
||||
{ encoding: 'utf8', stdio: 'pipe' },
|
||||
);
|
||||
return '';
|
||||
} catch (error) {
|
||||
const { status, stdout } = error as { status?: number | null; stdout?: string };
|
||||
if (typeof status !== 'number') throw error;
|
||||
return String(stdout ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
describe('icon-a11y-guard oxlint plugin', () => {
|
||||
it('flags Ant Design icons that would announce their English name', () => {
|
||||
const hits = runGuard(`${FIXTURES}/icon-unmarked.tsx`)
|
||||
.split('\n')
|
||||
.filter((line) => line.includes(RULE))
|
||||
.map((line) => Number(line.split(':')[1]));
|
||||
|
||||
// The button icon and the standalone icon; a same-suffix non-antd component is ignored.
|
||||
expect(hits).toEqual([6, 7]);
|
||||
});
|
||||
|
||||
it('accepts icons marked aria-hidden, named, labelled-by or given forwarded props', () => {
|
||||
expect(runGuard(`${FIXTURES}/icon-marked.tsx`)).not.toContain(RULE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('icon-a11y-guard wiring', () => {
|
||||
const config = JSON.parse(readFileSync('.oxlintrc.json', 'utf8')) as {
|
||||
jsPlugins: string[];
|
||||
overrides: { files: string[]; rules: Record<string, unknown> }[];
|
||||
};
|
||||
|
||||
it('enforces the rule on app sources but not on stories or tests', () => {
|
||||
expect(config.jsPlugins).toContain('./tools/oxlint/icon-a11y-guard.mjs');
|
||||
const enabled = config.overrides.find((o) => o.rules['antd-icon/explicit-a11y'] === 'error');
|
||||
expect(enabled?.files).toEqual(['src/**/*.tsx']);
|
||||
const exempt = config.overrides.find((o) => o.rules['antd-icon/explicit-a11y'] === 'off');
|
||||
expect(exempt?.files).toEqual(['src/**/*.stories.tsx', 'src/test/**']);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ describe('subscription language scope', () => {
|
||||
const getLanguage = vi.spyOn(utils.LanguageManager, 'getLanguage');
|
||||
const reload = vi.fn();
|
||||
vi.stubGlobal('window', { navigator: { language: 'en-US' }, location: { reload } });
|
||||
vi.stubGlobal('document', { documentElement: {} });
|
||||
|
||||
const { readyI18n } = await import('@/i18n/react');
|
||||
expect(getLanguage).not.toHaveBeenCalled();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import NodeList from '@/pages/nodes/NodeList';
|
||||
import type { NodeRecord } from '@/schemas/node';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const failing: NodeRecord = {
|
||||
id: 1,
|
||||
name: 'edge',
|
||||
guid: 'e1',
|
||||
transitive: false,
|
||||
enable: true,
|
||||
status: 'offline',
|
||||
lastError: 'dial tcp 10.0.0.2:2053: i/o timeout',
|
||||
};
|
||||
|
||||
function renderList(isMobile: boolean) {
|
||||
renderWithProviders(
|
||||
<NodeList
|
||||
nodes={[failing]}
|
||||
isMobile={isMobile}
|
||||
selectedIds={[]}
|
||||
onSelectionChange={noop}
|
||||
onAdd={noop}
|
||||
onMtls={noop}
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
onProbe={noop}
|
||||
onToggleEnable={noop}
|
||||
onUpdateNode={noop}
|
||||
onUpdateSelected={noop}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// The error lived only in a hover tooltip and the enable switch had no name, so a
|
||||
// screen reader heard "exclamation-circle" and "switch" (s-ui#1293).
|
||||
describe('NodeList screen-reader names', () => {
|
||||
it('announces the node error from the status column', () => {
|
||||
renderList(false);
|
||||
|
||||
expect(screen.getByRole('img', { name: failing.lastError }).tagName).toBe('SPAN');
|
||||
});
|
||||
|
||||
it.each([false, true])('names the enable switch (mobile=%s)', (isMobile) => {
|
||||
renderList(isMobile);
|
||||
|
||||
expect(screen.getByRole('switch', { name: 'Enabled' }).getAttribute('aria-checked')).toBe(
|
||||
'true',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Status } from '@/models/status';
|
||||
import OverviewActionBar from '@/pages/index/OverviewActionBar';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
// The "update available" Tag only had onClick: keyboard and screen-reader users
|
||||
// could neither reach nor press it (s-ui#1293).
|
||||
describe('OverviewActionBar update tag', () => {
|
||||
it.each(['Enter', ' '])('opens the panel update from the keyboard (%j)', (key) => {
|
||||
const onOpenPanelUpdate = vi.fn();
|
||||
renderWithProviders(
|
||||
<OverviewActionBar
|
||||
status={new Status()}
|
||||
isMobile={false}
|
||||
accessLogEnable={false}
|
||||
panelVersion="v3.10.1"
|
||||
latestVersion="v3.10.2"
|
||||
updateAvailable
|
||||
onStopXray={noop}
|
||||
onRestartXray={noop}
|
||||
onOpenLogs={noop}
|
||||
onOpenXrayLogs={noop}
|
||||
onOpenAmneziaWGLogs={noop}
|
||||
onOpenConfig={noop}
|
||||
onOpenBackup={noop}
|
||||
onOpenSystemHistory={noop}
|
||||
onOpenXrayMetrics={noop}
|
||||
onOpenPanelUpdate={onOpenPanelUpdate}
|
||||
onOpenVersionSwitch={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tag = screen.getByRole('button', { name: /3\.10\.2/ });
|
||||
expect(tag.tabIndex).toBe(0);
|
||||
fireEvent.keyDown(tag, { key });
|
||||
|
||||
expect(onOpenPanelUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import RuleFormModal from '@/pages/xray/routing/RuleFormModal';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function hintOf(field: HTMLElement): string {
|
||||
const ids = (field.getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean);
|
||||
return ids.map((id) => document.getElementById(id)?.textContent).join(' ');
|
||||
}
|
||||
|
||||
// The "comma separated" hint lived in a hover-only tooltip on the label text, so
|
||||
// the routing fields reached screen readers without it (s-ui#1293).
|
||||
describe('RuleFormModal field hints', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it.each(['IP', 'Source IPs'])('describes %s with the comma hint', (label) => {
|
||||
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
|
||||
renderWithProviders(
|
||||
<RuleFormModal
|
||||
open
|
||||
rule={null}
|
||||
inboundTags={[]}
|
||||
outboundTags={['direct']}
|
||||
balancerTags={[]}
|
||||
onClose={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(hintOf(screen.getByRole('textbox', { name: label }))).toBe('Comma-separated list');
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { render, fireEvent } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import { AntdRootConfig } from '@/i18n/AntdRootConfig';
|
||||
|
||||
export function makeTestQueryClient() {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
@@ -12,7 +13,9 @@ export function renderWithProviders(ui: ReactElement, options?: { queryClient?:
|
||||
const queryClient = options?.queryClient ?? makeTestQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>{ui}</ThemeProvider>
|
||||
<AntdRootConfig>
|
||||
<ThemeProvider>{ui}</ThemeProvider>
|
||||
</AntdRootConfig>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user