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
+11 -3
View File
@@ -67,7 +67,11 @@ export default function DiscordTab({ allSetting, updateSetting }: DiscordTabProp
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
label: catTabLabel(
<SettingOutlined aria-hidden />,
t('pages.settings.panelSettings'),
isMobile,
),
children: (
<>
<SettingListItem
@@ -138,7 +142,7 @@ export default function DiscordTab({ allSetting, updateSetting }: DiscordTabProp
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button
type="primary"
icon={<SendOutlined />}
icon={<SendOutlined aria-hidden />}
loading={testLoading}
disabled={!allSetting.discordBotEnable}
onClick={handleTestDiscord}
@@ -159,7 +163,11 @@ export default function DiscordTab({ allSetting, updateSetting }: DiscordTabProp
},
{
key: '2',
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
label: catTabLabel(
<BellOutlined aria-hidden />,
t('pages.settings.notifications'),
isMobile,
),
children: (
<>
<SettingListItem
+11 -3
View File
@@ -56,7 +56,11 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.smtpSettings'), isMobile),
label: catTabLabel(
<SettingOutlined aria-hidden />,
t('pages.settings.smtpSettings'),
isMobile,
),
children: (
<>
<SettingListItem
@@ -184,7 +188,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button
type="primary"
icon={<SendOutlined />}
icon={<SendOutlined aria-hidden />}
loading={testLoading}
onClick={handleTestSmtp}
>
@@ -213,7 +217,11 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
},
{
key: '2',
label: catTabLabel(<MailOutlined />, t('pages.settings.emailNotifications'), isMobile),
label: catTabLabel(
<MailOutlined aria-hidden />,
t('pages.settings.emailNotifications'),
isMobile,
),
children: (
<>
<SettingListItem
+26 -6
View File
@@ -180,7 +180,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
label: catTabLabel(
<SettingOutlined aria-hidden />,
t('pages.settings.panelSettings'),
isMobile,
),
children: (
<>
<SettingListItem
@@ -349,7 +353,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
},
{
key: '2',
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
label: catTabLabel(
<BellOutlined aria-hidden />,
t('pages.settings.notifications'),
isMobile,
),
children: (
<>
<SettingListItem
@@ -386,7 +394,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
},
{
key: '3',
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
label: catTabLabel(
<SafetyCertificateOutlined aria-hidden />,
t('pages.settings.certs'),
isMobile,
),
children: (
<>
<SettingListItem
@@ -414,7 +426,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
},
{
key: '4',
label: catTabLabel(<GlobalOutlined />, t('pages.settings.externalTraffic'), isMobile),
label: catTabLabel(
<GlobalOutlined aria-hidden />,
t('pages.settings.externalTraffic'),
isMobile,
),
children: (
<>
<SettingListItem
@@ -443,7 +459,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
},
{
key: '5',
label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.dateAndTime'), isMobile),
label: catTabLabel(
<ClockCircleOutlined aria-hidden />,
t('pages.settings.dateAndTime'),
isMobile,
),
children: (
<>
<SettingListItem
@@ -476,7 +496,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
},
{
key: '6',
label: catTabLabel(<ApartmentOutlined />, 'LDAP', isMobile),
label: catTabLabel(<ApartmentOutlined aria-hidden />, 'LDAP', isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.ldap.enable')}>
@@ -80,7 +80,7 @@ export default function HappSettingsContent({
{
key: 'routing',
label: catTabLabel(
<BranchesOutlined />,
<BranchesOutlined aria-hidden />,
t('pages.settings.subHappGroupRouting'),
isMobile,
),
@@ -136,7 +136,7 @@ export default function HappSettingsContent({
title={t('pages.settings.subHappVisualBuilder')}
description={t('pages.settings.subHappVisualBuilderDesc')}
>
<Button icon={<BuildOutlined />} onClick={() => setIsModalOpen(true)}>
<Button icon={<BuildOutlined aria-hidden />} onClick={() => setIsModalOpen(true)}>
{t('pages.settings.subHappVisualBuilder')}
</Button>
</SettingListItem>
@@ -182,7 +182,7 @@ export default function HappSettingsContent({
{
key: 'appearance',
label: catTabLabel(
<DesktopOutlined />,
<DesktopOutlined aria-hidden />,
t('pages.settings.subHappGroupThemes'),
isMobile,
),
@@ -284,6 +284,7 @@ export default function HappSettingsContent({
>
<Space orientation="vertical" style={{ width: '100%' }}>
<Input
aria-label={t('pages.settings.subHappColorProfile')}
value={allSetting.subHappColorProfile}
placeholder='{"serverRowBackgroundColor":"#21003D67"} or resetcolors'
onChange={(e) => updateSetting({ subHappColorProfile: e.target.value })}
@@ -326,7 +327,7 @@ export default function HappSettingsContent({
{
key: 'network',
label: catTabLabel(
<ThunderboltOutlined />,
<ThunderboltOutlined aria-hidden />,
t('pages.settings.subHappGroupNetwork'),
isMobile,
),
@@ -493,7 +494,7 @@ export default function HappSettingsContent({
{
key: 'failover',
label: catTabLabel(
<CloudSyncOutlined />,
<CloudSyncOutlined aria-hidden />,
t('pages.settings.subHappGroupFailover'),
isMobile,
),
@@ -53,7 +53,11 @@ export default function IncySettingsContent({
items={[
{
key: 'app',
label: catTabLabel(<AppstoreOutlined />, t('pages.settings.subIncyGroupApp'), isMobile),
label: catTabLabel(
<AppstoreOutlined aria-hidden />,
t('pages.settings.subIncyGroupApp'),
isMobile,
),
children: (
<>
<SettingListItem
@@ -127,7 +131,7 @@ export default function IncySettingsContent({
{
key: 'banners',
label: catTabLabel(
<NotificationOutlined />,
<NotificationOutlined aria-hidden />,
t('pages.settings.subIncyGroupBanners'),
isMobile,
),
@@ -196,7 +200,7 @@ export default function IncySettingsContent({
{
key: 'privacy',
label: catTabLabel(
<SafetyOutlined />,
<SafetyOutlined aria-hidden />,
t('pages.settings.subIncyGroupPrivacy'),
isMobile,
),
@@ -291,7 +295,7 @@ export default function IncySettingsContent({
{
key: 'network',
label: catTabLabel(
<ThunderboltOutlined />,
<ThunderboltOutlined aria-hidden />,
t('pages.settings.subIncyGroupNetwork'),
isMobile,
),
@@ -450,7 +454,7 @@ export default function IncySettingsContent({
{
key: 'routing',
label: catTabLabel(
<BranchesOutlined />,
<BranchesOutlined aria-hidden />,
t('pages.settings.subIncyGroupRouting'),
isMobile,
),
@@ -8,6 +8,7 @@ interface SecretInputProps {
placeholder: string;
onChange: (value: string) => void;
onClearArmedChange: (armed: boolean) => void;
'aria-labelledby'?: string;
}
export default function SecretInput({
@@ -17,11 +18,13 @@ export default function SecretInput({
placeholder,
onChange,
onClearArmedChange,
'aria-labelledby': ariaLabelledBy,
}: SecretInputProps) {
const { t } = useTranslation();
return (
<Space.Compact style={{ width: '100%' }}>
<Input.Password
aria-labelledby={ariaLabelledBy}
value={value}
placeholder={configured && !clearArmed ? placeholder : ''}
onChange={(e) => {
+19 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
@@ -74,6 +74,7 @@ const TFA_INITIAL: TfaState = {
export default function SecurityTab({ allSetting, updateSetting, saveSetting }: SecurityTabProps) {
const { t } = useTranslation();
const uid = useId();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
@@ -308,7 +309,11 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
items={[
{
key: '1',
label: catTabLabel(<UserOutlined />, t('pages.settings.security.admin'), isMobile),
label: catTabLabel(
<UserOutlined aria-hidden />,
t('pages.settings.security.admin'),
isMobile,
),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.oldUsername')}>
@@ -351,7 +356,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
{
key: '2',
label: catTabLabel(
<SafetyOutlined />,
<SafetyOutlined aria-hidden />,
t('pages.settings.security.twoFactor'),
isMobile,
),
@@ -367,7 +372,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
},
{
key: '3',
label: catTabLabel(<ApiOutlined />, t('pages.nodes.apiToken'), isMobile),
label: catTabLabel(<ApiOutlined aria-hidden />, t('pages.nodes.apiToken'), isMobile),
children: (
<div className="api-token-section">
<div className="api-token-header">
@@ -387,7 +392,9 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
<div className="api-token-row-head">
<div className="api-token-name-wrap">
<div className="api-token-name-line">
<span className="api-token-name">{row.name}</span>
<span id={`${uid}-token-${row.id}`} className="api-token-name">
{row.name}
</span>
<Tag color={scopeMeta[row.scope].color}>
{scopeMeta[row.scope].label}
</Tag>
@@ -399,6 +406,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
<div className="api-token-actions">
<Switch
size="small"
aria-labelledby={`${uid}-token-${row.id}`}
checked={row.enabled}
onChange={() => toggleTokenEnabled(row)}
/>
@@ -431,8 +439,13 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
onCancel={() => setCreateOpen(false)}
>
<Form layout="vertical">
<Form.Item label={t('pages.settings.security.apiTokenName') || 'Name'} required>
<Form.Item
label={t('pages.settings.security.apiTokenName') || 'Name'}
htmlFor={`${uid}-tokenName`}
required
>
<Input
id={`${uid}-tokenName`}
value={createName}
maxLength={64}
placeholder={
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useId, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
@@ -59,6 +59,7 @@ export default function SubBalancerFormModal({
onConfirm,
}: SubBalancerFormModalProps) {
const { t } = useTranslation();
const uid = useId();
const [messageApi, messageContextHolder] = message.useMessage();
const methods = useForm<SubBalancerFormValues>({ defaultValues: initialState(balancer) });
const isEdit = balancer != null;
@@ -199,6 +200,7 @@ export default function SubBalancerFormModal({
return (
<div key={id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span
id={`${uid}-weight-${id}`}
title={option?.title}
style={{
minWidth: 0,
@@ -214,6 +216,8 @@ export default function SubBalancerFormModal({
name={`memberWeights.${id}`}
render={({ field }) => (
<InputNumber
aria-labelledby={`${uid}-weight-${id}`}
aria-describedby={`${uid}-weights-hint`}
min={0.1}
step={0.1}
precision={1}
@@ -227,6 +231,9 @@ export default function SubBalancerFormModal({
);
})}
</div>
<span id={`${uid}-weights-hint`} hidden>
{t('pages.settings.subBalancers.weightsHelp')}
</span>
</Form.Item>
)}
@@ -156,7 +156,12 @@ export default function SubscriptionBalancersTab({
width: 80,
align: 'center' as const,
render: (_: unknown, r: SubBalancer) => (
<Switch size="small" checked={r.enabled} onChange={() => toggleEnabled(r)} />
<Switch
size="small"
aria-label={t('pages.settings.subBalancers.enabled')}
checked={r.enabled}
onChange={() => toggleEnabled(r)}
/>
),
},
{
@@ -168,7 +173,7 @@ export default function SubscriptionBalancersTab({
<Button
aria-label={t('edit')}
size="small"
icon={<EditOutlined />}
icon={<EditOutlined aria-hidden />}
title={t('edit')}
onClick={() => {
setEditing(r);
@@ -181,7 +186,12 @@ export default function SubscriptionBalancersTab({
cancelText={t('cancel')}
onConfirm={() => remove(r.id)}
>
<Button aria-label={t('delete')} size="small" danger icon={<DeleteOutlined />} />
<Button
aria-label={t('delete')}
size="small"
danger
icon={<DeleteOutlined aria-hidden />}
/>
</Popconfirm>
</Space>
),
@@ -212,7 +222,7 @@ export default function SubscriptionBalancersTab({
<div style={{ marginBottom: 12 }}>
<Button
type="primary"
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
onClick={() => {
setEditing(null);
setModalOpen(true);
@@ -332,7 +342,7 @@ export default function SubscriptionBalancersTab({
{
key: 'balancers',
label: catTabLabel(
<DeploymentUnitOutlined />,
<DeploymentUnitOutlined aria-hidden />,
t('pages.settings.subBalancers.tabBalancers'),
isMobile,
),
@@ -341,7 +351,7 @@ export default function SubscriptionBalancersTab({
{
key: 'observatory',
label: catTabLabel(
<RadarChartOutlined />,
<RadarChartOutlined aria-hidden />,
t('pages.settings.subBalancers.tabObservatory'),
isMobile,
),
@@ -158,7 +158,11 @@ export default function SubscriptionFormatsTab({
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
label: catTabLabel(
<SettingOutlined aria-hidden />,
t('pages.settings.panelSettings'),
isMobile,
),
children: (
<div className="subscription-format-sections">
{allSetting.subJsonEnable && (
@@ -167,7 +171,7 @@ export default function SubscriptionFormatsTab({
className="subscription-format-card"
title={
<span className="subscription-format-card-title">
<FileTextOutlined />
<FileTextOutlined aria-hidden />
{t('pages.settings.subJsonEnableTitle')}
</span>
}
@@ -261,7 +265,7 @@ export default function SubscriptionFormatsTab({
className="subscription-format-card"
title={
<span className="subscription-format-card-title">
<NodeIndexOutlined />
<NodeIndexOutlined aria-hidden />
{t('pages.settings.subClashEnableTitle')}
</span>
}
@@ -322,7 +326,7 @@ export default function SubscriptionFormatsTab({
{
key: '2',
label: catTabLabel(
<RocketOutlined />,
<RocketOutlined aria-hidden />,
t('pages.settings.subFormats.finalMask'),
isMobile,
),
@@ -342,7 +346,7 @@ export default function SubscriptionFormatsTab({
},
{
key: '3',
label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
label: catTabLabel(<PartitionOutlined aria-hidden />, t('pages.settings.mux'), isMobile),
children: (
<>
<SettingListItem
@@ -396,7 +400,7 @@ export default function SubscriptionFormatsTab({
},
{
key: '4',
label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
label: catTabLabel(<SendOutlined aria-hidden />, t('pages.settings.direct'), isMobile),
children: (
<>
<SettingListItem
@@ -445,7 +449,7 @@ export default function SubscriptionFormatsTab({
},
{
key: '5',
label: catTabLabel(<StopOutlined />, t('pages.settings.block'), isMobile),
label: catTabLabel(<StopOutlined aria-hidden />, t('pages.settings.block'), isMobile),
children: (
<>
<SettingListItem
@@ -48,7 +48,11 @@ export default function SubscriptionGeneralTab({
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
label: catTabLabel(
<SettingOutlined aria-hidden />,
t('pages.settings.panelSettings'),
isMobile,
),
children: (
<>
<SettingListItem
@@ -153,7 +157,11 @@ export default function SubscriptionGeneralTab({
},
{
key: '2',
label: catTabLabel(<InfoCircleOutlined />, t('pages.settings.information'), isMobile),
label: catTabLabel(
<InfoCircleOutlined aria-hidden />,
t('pages.settings.information'),
isMobile,
),
children: (
<>
<SettingListItem
@@ -273,7 +281,7 @@ export default function SubscriptionGeneralTab({
},
{
key: '3',
label: catTabLabel(<IdcardOutlined />, t('pages.settings.profile'), isMobile),
label: catTabLabel(<IdcardOutlined aria-hidden />, t('pages.settings.profile'), isMobile),
children: (
<>
<SettingListItem
@@ -379,7 +387,11 @@ export default function SubscriptionGeneralTab({
},
{
key: '4',
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
label: catTabLabel(
<SafetyCertificateOutlined aria-hidden />,
t('pages.settings.certs'),
isMobile,
),
children: (
<>
<SettingListItem
@@ -407,7 +419,7 @@ export default function SubscriptionGeneralTab({
},
{
key: '5',
label: catTabLabel(<BranchesOutlined />, 'Happ', isMobile),
label: catTabLabel(<BranchesOutlined aria-hidden />, 'Happ', isMobile),
children: (
<HappSettingsContent
allSetting={allSetting}
@@ -419,7 +431,7 @@ export default function SubscriptionGeneralTab({
},
{
key: '6',
label: catTabLabel(<NodeIndexOutlined />, 'Clash / Mihomo', isMobile),
label: catTabLabel(<NodeIndexOutlined aria-hidden />, 'Clash / Mihomo', isMobile),
children: (
<>
<SettingListItem
@@ -452,7 +464,7 @@ export default function SubscriptionGeneralTab({
},
{
key: '7',
label: catTabLabel(<CompassOutlined />, 'Incy', isMobile),
label: catTabLabel(<CompassOutlined aria-hidden />, 'Incy', isMobile),
children: (
<IncySettingsContent
allSetting={allSetting}
+11 -3
View File
@@ -65,7 +65,11 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
items={[
{
key: '1',
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
label: catTabLabel(
<SettingOutlined aria-hidden />,
t('pages.settings.panelSettings'),
isMobile,
),
children: (
<>
<SettingListItem
@@ -133,7 +137,7 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
<Space orientation="vertical" size={8} style={{ width: '100%', marginTop: 16 }}>
<Button
type="primary"
icon={<SendOutlined />}
icon={<SendOutlined aria-hidden />}
loading={testLoading}
onClick={handleTestTgBot}
>
@@ -153,7 +157,11 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
},
{
key: '2',
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
label: catTabLabel(
<BellOutlined aria-hidden />,
t('pages.settings.notifications'),
isMobile,
),
children: (
<>
<SettingListItem
+5 -1
View File
@@ -6,9 +6,13 @@ import { Tooltip } from 'antd';
old top tab bar's icons-only behaviour. */
export function catTabLabel(icon: ReactNode, text: ReactNode, iconsOnly: boolean): ReactNode {
if (iconsOnly) {
// Callers hide the icon for the desktop label; here it is the tab's only name.
const labelledIcon =
typeof text === 'string' && isValidElement(icon)
? cloneElement(icon as ReactElement<{ 'aria-label'?: string }>, { 'aria-label': text })
? cloneElement(icon as ReactElement<{ 'aria-label'?: string; 'aria-hidden'?: boolean }>, {
'aria-label': text,
'aria-hidden': undefined,
})
: icon;
return <Tooltip title={text}>{labelledIcon}</Tooltip>;
}