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
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Checkbox, Empty, Form, Input, Modal, Select, Tag } from 'antd';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
@@ -30,6 +30,7 @@ function shortTime(value?: number): string {
export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalProps) {
const { t } = useTranslation();
const uid = useId();
const { datepicker } = useDatepicker();
const { isMobile } = useMediaQuery();
const [rows, setRows] = useState('50');
@@ -120,6 +121,7 @@ export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalPr
<Form layout="inline" className="log-toolbar">
<Form.Item>
<Select
aria-label={t('pages.index.logLines')}
value={rows}
size="small"
style={{ width: 100 }}
@@ -132,8 +134,9 @@ export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalPr
]}
/>
</Form.Item>
<Form.Item label={t('filter')} className="filter-item">
<Form.Item htmlFor={`${uid}-filter`} label={t('filter')} className="filter-item">
<Input
id={`${uid}-filter`}
value={filter}
size="small"
onChange={(e) => setFilter(e.target.value)}
@@ -151,7 +154,7 @@ export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalPr
<Button
type="primary"
onClick={download}
icon={<DownloadOutlined />}
icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')}
/>
</Form.Item>
+3 -3
View File
@@ -88,7 +88,7 @@ export default function BackupModal({
type="primary"
aria-label={t('pages.index.exportDatabase')}
onClick={exportDb}
icon={<DownloadOutlined />}
icon={<DownloadOutlined aria-hidden />}
/>
</div>
@@ -102,7 +102,7 @@ export default function BackupModal({
type="primary"
aria-label={t('pages.index.migrationDownload')}
onClick={exportMigration}
icon={<DownloadOutlined />}
icon={<DownloadOutlined aria-hidden />}
/>
</div>
)}
@@ -120,7 +120,7 @@ export default function BackupModal({
type="primary"
aria-label={t('pages.index.importDatabase')}
onClick={importDb}
icon={<UploadOutlined />}
icon={<UploadOutlined aria-hidden />}
/>
</div>
+18 -5
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState, useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Form, Input, Modal, Select, Space, Spin, Typography, message } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
@@ -33,6 +33,7 @@ function fileNameFromUrl(url: string): string {
export default function GeodataSection({ active, onBusy, onClose }: GeodataSectionProps) {
const { t } = useTranslation();
const uid = useId();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
@@ -180,15 +181,25 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
<Spin spinning={loading}>
<Alert type="info" className="mb-12" title={t('pages.index.geodataHint')} showIcon />
<Form layout="vertical">
<Form.Item label={t('pages.index.geodataCron')} style={{ marginBottom: 8 }}>
<Form.Item
htmlFor={`${uid}-cron`}
label={t('pages.index.geodataCron')}
style={{ marginBottom: 8 }}
>
<Input
id={`${uid}-cron`}
value={cron}
placeholder={DEFAULT_CRON}
onChange={(e) => setCron(e.target.value)}
/>
</Form.Item>
<Form.Item label={t('pages.index.geodataOutbound')} style={{ marginBottom: 8 }}>
<Form.Item
htmlFor={`${uid}-outbound`}
label={t('pages.index.geodataOutbound')}
style={{ marginBottom: 8 }}
>
<Select
id={`${uid}-outbound`}
style={{ width: '100%' }}
allowClear
value={outbound}
@@ -204,6 +215,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
{rows.map((row, i) => (
<Space.Compact key={i} style={{ width: '100%' }}>
<Input
aria-label={`URL ${i + 1}`}
style={{ width: '60%' }}
placeholder="https://example.com/geosite_custom.dat"
value={row.url}
@@ -211,6 +223,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
onBlur={() => onUrlBlur(i)}
/>
<Input
aria-label={`${t('pages.index.geodataFile')} ${i + 1}`}
style={{ width: '40%' }}
placeholder={t('pages.index.geodataFile')}
value={row.file}
@@ -218,14 +231,14 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
/>
<Button
aria-label={t('delete')}
icon={<DeleteOutlined />}
icon={<DeleteOutlined aria-hidden />}
onClick={() => setRows((p) => p.filter((_, j) => j !== i))}
/>
</Space.Compact>
))}
<div className="actions-row">
<Button
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
onClick={() => setRows((p) => [...p, { url: '', file: '' }])}
>
{t('pages.index.geodataAddFile')}
+6 -6
View File
@@ -227,7 +227,7 @@ export default function IndexPage() {
<div className="ov-vitals">
<VitalTile
icon={<DashboardOutlined />}
icon={<DashboardOutlined aria-hidden />}
label={t('pages.index.cpu')}
percent={status.cpu.percent}
statusColor={status.cpu.color}
@@ -238,7 +238,7 @@ export default function IndexPage() {
isMobile={isMobile}
/>
<VitalTile
icon={<DatabaseOutlined />}
icon={<DatabaseOutlined aria-hidden />}
label={t('pages.index.memory')}
percent={status.mem.percent}
statusColor={status.mem.color}
@@ -249,7 +249,7 @@ export default function IndexPage() {
isMobile={isMobile}
/>
<VitalTile
icon={<SwapOutlined />}
icon={<SwapOutlined aria-hidden />}
label={t('pages.index.swap')}
percent={status.swap.percent}
statusColor={status.swap.color}
@@ -260,7 +260,7 @@ export default function IndexPage() {
isMobile={isMobile}
/>
<VitalTile
icon={<HddOutlined />}
icon={<HddOutlined aria-hidden />}
label={t('pages.index.storage')}
percent={status.disk.percent}
statusColor={status.disk.color}
@@ -358,7 +358,7 @@ export default function IndexPage() {
key="download"
onClick={downloadConfig}
size={isMobile ? 'small' : 'middle'}
icon={<CloudDownloadOutlined />}
icon={<CloudDownloadOutlined aria-hidden />}
>
{isMobile ? 'Download' : 'config.json'}
</Button>,
@@ -367,7 +367,7 @@ export default function IndexPage() {
type="primary"
onClick={copyConfig}
size={isMobile ? 'small' : 'middle'}
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
>
Copy
</Button>,
+3 -1
View File
@@ -105,6 +105,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
<Form.Item>
<Space.Compact>
<Select
aria-label={t('pages.index.logLines')}
value={rows}
size="small"
style={{ width: 100 }}
@@ -118,6 +119,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
]}
/>
<Select
aria-label={t('pages.xray.logLevel')}
value={level}
size="small"
style={{ minWidth: 95 }}
@@ -144,7 +146,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
<Button
type="primary"
onClick={download}
icon={<DownloadOutlined />}
icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')}
/>
</Form.Item>
+19 -10
View File
@@ -16,6 +16,7 @@ import {
} from '@ant-design/icons';
import { formatPanelVersion } from '@/lib/panel-version';
import { activateOnKey } from '@/utils/a11y';
import type { Status } from '@/models/status';
interface OverviewActionBarProps {
@@ -80,25 +81,30 @@ export default function OverviewActionBar({
[
{
key: 'restart',
icon: <ReloadOutlined />,
icon: <ReloadOutlined aria-hidden />,
text: t('pages.index.restartXray'),
onClick: onRestartXray,
primary: true,
},
{
key: 'stop',
icon: <PoweroffOutlined />,
icon: <PoweroffOutlined aria-hidden />,
text: t('pages.index.stopXray'),
onClick: onStopXray,
},
],
[
{ key: 'logs', icon: <BarsOutlined />, text: t('pages.index.logs'), onClick: onOpenLogs },
{
key: 'logs',
icon: <BarsOutlined aria-hidden />,
text: t('pages.index.logs'),
onClick: onOpenLogs,
},
...(accessLogEnable
? [
{
key: 'accessLogs',
icon: <FileTextOutlined />,
icon: <FileTextOutlined aria-hidden />,
text: t('pages.index.accessLogs'),
onClick: onOpenXrayLogs,
},
@@ -108,7 +114,7 @@ export default function OverviewActionBar({
? [
{
key: 'amneziawgLogs',
icon: <ApiOutlined />,
icon: <ApiOutlined aria-hidden />,
text: t('pages.index.amneziawgLogs'),
onClick: onOpenAmneziaWGLogs,
},
@@ -116,13 +122,13 @@ export default function OverviewActionBar({
: []),
{
key: 'config',
icon: <ControlOutlined />,
icon: <ControlOutlined aria-hidden />,
text: t('pages.index.config'),
onClick: onOpenConfig,
},
{
key: 'backup',
icon: <CloudServerOutlined />,
icon: <CloudServerOutlined aria-hidden />,
text: t('pages.index.backupTitle'),
onClick: onOpenBackup,
},
@@ -130,13 +136,13 @@ export default function OverviewActionBar({
[
{
key: 'history',
icon: <AreaChartOutlined />,
icon: <AreaChartOutlined aria-hidden />,
text: t('pages.index.systemHistoryTitle'),
onClick: onOpenSystemHistory,
},
{
key: 'metrics',
icon: <ArrowUpOutlined />,
icon: <ArrowUpOutlined aria-hidden />,
text: t('pages.index.xrayMetricsTitle'),
onClick: onOpenXrayMetrics,
},
@@ -177,8 +183,11 @@ export default function OverviewActionBar({
<Tag
className="ov-update-tag"
color="warning"
icon={<CloudDownloadOutlined />}
icon={<CloudDownloadOutlined aria-hidden />}
role="button"
tabIndex={0}
onClick={onOpenPanelUpdate}
onKeyDown={activateOnKey(onOpenPanelUpdate)}
>
{`${t('update')} ${formatPanelVersion(latestVersion)}`}
</Tag>
@@ -144,7 +144,12 @@ export default function PanelUpdateModal({
<div className="version-list">
<div className="version-list-item">
<span>{t('pages.index.devChannel')}</span>
<Switch checked={!!devChannelEnable} loading={channelBusy} onChange={handleChannel} />
<Switch
aria-label={t('pages.index.devChannel')}
checked={!!devChannelEnable}
loading={channelBusy}
onChange={handleChannel}
/>
</div>
</div>
@@ -190,7 +195,7 @@ export default function PanelUpdateModal({
type="primary"
disabled={!info.updateAvailable}
onClick={updatePanel}
icon={<CloudDownloadOutlined />}
icon={<CloudDownloadOutlined aria-hidden />}
>
{t('pages.index.updatePanel')}
</Button>
@@ -50,7 +50,7 @@ const METRICS: MetricDef[] = [
tab: 'CPU',
tabKey: 'pages.index.cpu',
title: 'pages.index.historyTitleCpu',
icon: <DashboardOutlined />,
icon: <DashboardOutlined aria-hidden />,
valueMax: 100,
unit: '%',
stroke: '',
@@ -60,7 +60,7 @@ const METRICS: MetricDef[] = [
tab: 'RAM',
tabKey: 'pages.index.memory',
title: 'pages.index.historyTitleMem',
icon: <DatabaseOutlined />,
icon: <DatabaseOutlined aria-hidden />,
valueMax: 100,
unit: '%',
stroke: '#7c4dff',
@@ -74,7 +74,7 @@ const METRICS: MetricDef[] = [
tab: 'Bandwidth',
tabKey: 'pages.index.historyTabBandwidth',
title: 'pages.index.historyTitleNetwork',
icon: <GlobalOutlined />,
icon: <GlobalOutlined aria-hidden />,
valueMax: null,
unit: 'B/s',
stroke: '#1890ff',
@@ -88,7 +88,7 @@ const METRICS: MetricDef[] = [
tab: 'Packets',
tabKey: 'pages.index.historyTabPackets',
title: 'pages.index.historyTitlePackets',
icon: <DeploymentUnitOutlined />,
icon: <DeploymentUnitOutlined aria-hidden />,
valueMax: null,
unit: 'pkt/s',
stroke: '#2f54eb',
@@ -102,7 +102,7 @@ const METRICS: MetricDef[] = [
tab: 'Connections',
tabKey: 'pages.index.historyTabConnections',
title: 'pages.index.historyTitleConnections',
icon: <ApiOutlined />,
icon: <ApiOutlined aria-hidden />,
valueMax: null,
unit: '',
stroke: '#597ef7',
@@ -116,7 +116,7 @@ const METRICS: MetricDef[] = [
tab: 'Disk I/O',
tabKey: 'pages.index.historyTabDisk',
title: 'pages.index.historyTitleDisk',
icon: <HddOutlined />,
icon: <HddOutlined aria-hidden />,
valueMax: null,
unit: 'B/s',
stroke: '#eb2f96',
@@ -130,7 +130,7 @@ const METRICS: MetricDef[] = [
tab: 'Disk Usage',
tabKey: 'pages.index.historyTabDiskUsage',
title: 'pages.index.historyTitleDiskUsage',
icon: <PieChartOutlined />,
icon: <PieChartOutlined aria-hidden />,
valueMax: 100,
unit: '%',
stroke: '#13c2c2',
@@ -140,7 +140,7 @@ const METRICS: MetricDef[] = [
tab: 'Online',
tabKey: 'pages.index.historyTabOnline',
title: 'pages.index.historyTitleOnline',
icon: <TeamOutlined />,
icon: <TeamOutlined aria-hidden />,
valueMax: null,
unit: '',
stroke: '#52c41a',
@@ -150,7 +150,7 @@ const METRICS: MetricDef[] = [
tab: 'Load',
tabKey: 'pages.index.historyTabLoad',
title: 'pages.index.historyTitleLoad',
icon: <LineChartOutlined />,
icon: <LineChartOutlined aria-hidden />,
valueMax: null,
unit: '',
stroke: '#fa8c16',
@@ -334,6 +334,7 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
<div className="metric-modal-title">
<span>{t('pages.index.systemHistoryTitle')}</span>
<Select
aria-label={t('pages.index.timeRange')}
value={bucket}
size="small"
className="bucket-select"
+3 -3
View File
@@ -26,7 +26,7 @@ export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripP
<div className="ov-strip-grid">
<div className="ov-strip-cell">
<div className="ov-kicker ov-kicker-icon">
<ClockCircleOutlined />
<ClockCircleOutlined aria-hidden />
{t('pages.index.uptime')}
</div>
<div className="ov-strip-split">
@@ -46,7 +46,7 @@ export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripP
<div className="ov-strip-cell">
<div className="ov-kicker ov-kicker-icon">
<DatabaseOutlined />
<DatabaseOutlined aria-hidden />
{t('pages.index.panel')}
</div>
<div className="ov-strip-split">
@@ -64,7 +64,7 @@ export default function SystemStrip({ status, showIp, onToggleIp }: SystemStripP
<div className="ov-strip-cell">
<div className="ov-kicker ov-kicker-icon">
<GlobalOutlined />
<GlobalOutlined aria-hidden />
{t('pages.index.ipAddresses')}
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{showIp ? (
+2 -2
View File
@@ -47,12 +47,12 @@ export default function ThroughputCard({
</div>
<div className="ov-wide-legend">
<div className="ov-legend-label">
<ArrowUpOutlined style={{ color: accent }} />
<ArrowUpOutlined aria-hidden style={{ color: accent }} />
{t('pages.index.upload')}
<span className="ov-legend-num">{SizeFormatter.speedFormat(status.netIO.up)}</span>
</div>
<div className="ov-legend-label">
<ArrowDownOutlined style={{ color: downColor }} />
<ArrowDownOutlined aria-hidden style={{ color: downColor }} />
{t('pages.index.download')}
<span className="ov-legend-num">{SizeFormatter.speedFormat(status.netIO.down)}</span>
</div>
+6 -3
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, useId } from 'react';
import type { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { Button, Checkbox, Form, Input, Modal, Select, Tag } from 'antd';
@@ -63,6 +63,7 @@ const AUTO_UPDATE_INTERVAL = 5000;
export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
const { t } = useTranslation();
const uid = useId();
const { datepicker } = useDatepicker();
const { isMobile } = useMediaQuery();
const [rows, setRows] = useState('20');
@@ -173,6 +174,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
<Form layout="inline" className="log-toolbar">
<Form.Item>
<Select
aria-label={t('pages.index.logLines')}
value={rows}
size="small"
style={{ width: 100 }}
@@ -186,8 +188,9 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
]}
/>
</Form.Item>
<Form.Item label={t('filter')} className="filter-item">
<Form.Item htmlFor={`${uid}-filter`} label={t('filter')} className="filter-item">
<Input
id={`${uid}-filter`}
value={filter}
size="small"
onChange={(e) => setFilter(e.target.value)}
@@ -214,7 +217,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
<Button
type="primary"
onClick={download}
icon={<DownloadOutlined />}
icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')}
/>
</Form.Item>
@@ -53,7 +53,7 @@ const METRICS: MetricDef[] = [
tab: 'Heap',
tabKey: 'pages.index.xrayTabHeap',
title: 'pages.index.xrayTitleHeap',
icon: <DatabaseOutlined />,
icon: <DatabaseOutlined aria-hidden />,
unit: 'B',
stroke: '#7c4dff',
},
@@ -62,7 +62,7 @@ const METRICS: MetricDef[] = [
tab: 'Sys',
tabKey: 'pages.index.xrayTabSys',
title: 'pages.index.xrayTitleSys',
icon: <CloudServerOutlined />,
icon: <CloudServerOutlined aria-hidden />,
unit: 'B',
stroke: '#1890ff',
},
@@ -71,7 +71,7 @@ const METRICS: MetricDef[] = [
tab: 'Objects',
tabKey: 'pages.index.xrayTabObjects',
title: 'pages.index.xrayTitleObjects',
icon: <BlockOutlined />,
icon: <BlockOutlined aria-hidden />,
unit: '',
stroke: '#13c2c2',
},
@@ -80,7 +80,7 @@ const METRICS: MetricDef[] = [
tab: 'GC Count',
tabKey: 'pages.index.xrayTabGcCount',
title: 'pages.index.xrayTitleGcCount',
icon: <DeleteOutlined />,
icon: <DeleteOutlined aria-hidden />,
unit: '',
stroke: '#fa8c16',
},
@@ -89,7 +89,7 @@ const METRICS: MetricDef[] = [
tab: 'GC Pause',
tabKey: 'pages.index.xrayTabGcPause',
title: 'pages.index.xrayTitleGcPause',
icon: <PauseCircleOutlined />,
icon: <PauseCircleOutlined aria-hidden />,
unit: 'ns',
stroke: '#f5222d',
},
@@ -98,7 +98,7 @@ const METRICS: MetricDef[] = [
tab: 'Observatory',
tabKey: 'pages.index.xrayTabObservatory',
title: 'pages.index.xrayTitleObservatory',
icon: <EyeOutlined />,
icon: <EyeOutlined aria-hidden />,
unit: 'ms',
stroke: '#52c41a',
},
@@ -304,6 +304,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
<div className="metric-modal-title">
<span>{t('pages.index.xrayMetricsTitle')}</span>
<Select
aria-label={t('pages.index.timeRange')}
value={bucket}
size="small"
className="bucket-select"
@@ -362,6 +363,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
) : (
<div className="obs-controls">
<Select
aria-label={t('pages.index.xrayObservatoryTagPlaceholder')}
value={obsActiveTag}
size="small"
className="obs-select"