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
@@ -111,6 +111,7 @@ export default function BulkAttachInboundsModal({
onChange={setTargetIds}
options={targetOptions}
placeholder={t('pages.clients.attachToInboundsTargets')}
aria-label={t('pages.clients.attachToInboundsTargets')}
showSearch={{ optionFilterProp: 'label' }}
autoFocus
/>
@@ -111,6 +111,7 @@ export default function BulkDetachInboundsModal({
onChange={setTargetIds}
options={targetOptions}
placeholder={t('pages.clients.detachFromInboundsTargets')}
aria-label={t('pages.clients.detachFromInboundsTargets')}
showSearch={{ optionFilterProp: 'label' }}
autoFocus
/>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AutoComplete,
@@ -82,6 +82,7 @@ export default function ClientBulkAddModal({
onSaved,
}: ClientBulkAddModalProps) {
const { t } = useTranslation();
const uid = useId();
const [messageApi, messageContextHolder] = message.useMessage();
const { bulkCreate } = useClients({ list: false });
@@ -254,13 +255,18 @@ export default function ClientBulkAddModal({
>
<FormProvider {...methods}>
<Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
<Form.Item label={t('pages.clients.attachedInbounds')} required>
<Form.Item
label={t('pages.clients.attachedInbounds')}
required
htmlFor={`${uid}-inbounds`}
>
<SelectAllClearButtons
options={inboundOptions}
value={inboundIds}
onChange={(v) => methods.setValue('inboundIds', v)}
/>
<Select
id={`${uid}-inbounds`}
mode="multiple"
value={inboundIds}
onChange={(v) => methods.setValue('inboundIds', v)}
@@ -323,16 +329,17 @@ export default function ClientBulkAddModal({
</FormField>
)}
<Form.Item label={t('pages.clients.subId')}>
<Form.Item label={t('pages.clients.subId')} htmlFor={`${uid}-sub-id`}>
<Space.Compact style={{ display: 'flex' }}>
<Input
id={`${uid}-sub-id`}
value={subId}
onChange={(e) => methods.setValue('subId', e.target.value)}
style={{ flex: 1 }}
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
icon={<ReloadOutlined aria-hidden />}
onClick={() => methods.setValue('subId', RandomUtil.randomLowerAndNum(16))}
/>
</Space.Compact>
@@ -376,10 +383,11 @@ export default function ClientBulkAddModal({
</FormField>
)}
<Form.Item label={t('pages.clients.limitIp')}>
<Form.Item label={t('pages.clients.limitIp')} htmlFor={`${uid}-limit-ip`}>
<Tooltip title={limitIpNotice || undefined}>
<span style={{ display: 'inline-flex' }}>
<InputNumber
id={`${uid}-limit-ip`}
value={limitIp}
min={0}
disabled={limitIpDisabled}
@@ -399,8 +407,9 @@ export default function ClientBulkAddModal({
<InputNumber min={0} step={1} />
</FormField>
<Form.Item label={t('pages.clients.delayedStart')}>
<Form.Item label={t('pages.clients.delayedStart')} htmlFor={`${uid}-delayed-start`}>
<Switch
id={`${uid}-delayed-start`}
checked={delayedStart}
onClick={() => {
setDelayedStart(!delayedStart);
@@ -410,16 +419,18 @@ export default function ClientBulkAddModal({
</Form.Item>
{delayedStart ? (
<Form.Item label={t('pages.clients.expireDays')}>
<Form.Item label={t('pages.clients.expireDays')} htmlFor={`${uid}-expire-days`}>
<InputNumber
id={`${uid}-expire-days`}
value={delayedExpireDays}
min={0}
onChange={(v) => methods.setValue('expiryTime', -86400000 * (Number(v) || 0))}
/>
</Form.Item>
) : (
<Form.Item label={t('pages.inbounds.expireDate')}>
<Form.Item label={t('pages.inbounds.expireDate')} htmlFor={`${uid}-expiry`}>
<DateTimePicker
id={`${uid}-expiry`}
value={expiryDate}
onChange={(next) => methods.setValue('expiryTime', next ? next.valueOf() : 0)}
/>
+87 -25
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AutoComplete,
@@ -256,6 +256,7 @@ export default function ClientFormModal({
onOpenChange,
}: ClientFormModalProps) {
const { t } = useTranslation();
const uid = useId();
const [messageApi, messageContextHolder] = message.useMessage();
const isEdit = mode === 'edit';
@@ -820,7 +821,7 @@ export default function ClientFormModal({
<Button
color="danger"
variant="filled"
icon={<RetweetOutlined />}
icon={<RetweetOutlined aria-hidden />}
loading={resetting}
>
{t('pages.inbounds.resetTraffic')}
@@ -848,9 +849,14 @@ export default function ClientFormModal({
<>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label={t('pages.clients.email')} required>
<Form.Item
label={t('pages.clients.email')}
required
htmlFor={`${uid}-email`}
>
<Space.Compact style={{ display: 'flex' }}>
<Input
id={`${uid}-email`}
value={email}
placeholder={t('pages.clients.email')}
style={{ flex: 1 }}
@@ -859,7 +865,7 @@ export default function ClientFormModal({
{!isEdit && (
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
icon={<ReloadOutlined aria-hidden />}
onClick={() =>
methods.setValue('email', RandomUtil.randomLowerAndNum(12))
}
@@ -880,6 +886,7 @@ export default function ClientFormModal({
</Col>
<Col xs={24} md={12}>
<Form.Item
htmlFor={`${uid}-limit-ip`}
label={t('pages.clients.limitIp')}
tooltip={t('pages.clients.limitIpDesc')}
>
@@ -887,6 +894,8 @@ export default function ClientFormModal({
<span style={{ display: 'flex', width: '100%' }}>
<Space.Compact style={{ display: 'flex', flex: 1 }}>
<InputNumber
id={`${uid}-limit-ip`}
aria-describedby={`${uid}-limit-ip-hint`}
value={limitIp}
min={0}
disabled={limitIpDisabled}
@@ -900,7 +909,7 @@ export default function ClientFormModal({
<Tooltip title={t('pages.clients.ipLog')}>
<Button
aria-label={t('pages.clients.ipLog')}
icon={<EyeOutlined />}
icon={<EyeOutlined aria-hidden />}
loading={ipsLoading}
onClick={openIpsModal}
>
@@ -911,15 +920,21 @@ export default function ClientFormModal({
</Space.Compact>
</span>
</Tooltip>
<span id={`${uid}-limit-ip-hint`} hidden>
{t('pages.clients.limitIpDesc')}
</span>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
htmlFor={`${uid}-limit-hwid`}
label={t('pages.clients.limitHwid')}
tooltip={t('pages.clients.limitHwidDesc')}
>
<Space.Compact style={{ display: 'flex' }}>
<InputNumber
id={`${uid}-limit-hwid`}
aria-describedby={`${uid}-limit-hwid-hint`}
value={limitHwid}
min={0}
style={{ flex: 1 }}
@@ -929,7 +944,7 @@ export default function ClientFormModal({
<Tooltip title={t('pages.clients.hwidLog')}>
<Button
aria-label={t('pages.clients.hwidLog')}
icon={<EyeOutlined />}
icon={<EyeOutlined aria-hidden />}
loading={hwidsLoading}
onClick={openHwidsModal}
>
@@ -938,6 +953,9 @@ export default function ClientFormModal({
</Tooltip>
)}
</Space.Compact>
<span id={`${uid}-limit-hwid-hint`} hidden>
{t('pages.clients.limitHwidDesc')}
</span>
</Form.Item>
</Col>
</Row>
@@ -953,8 +971,12 @@ export default function ClientFormModal({
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
) : (
<Form.Item label={t('pages.clients.expiryTime')}>
<Form.Item
label={t('pages.clients.expiryTime')}
htmlFor={`${uid}-expiry`}
>
<DateTimePicker
id={`${uid}-expiry`}
value={expiryDayjs}
onChange={(d) =>
methods.setValue('expiryDate', d ? d.valueOf() : 0)
@@ -964,8 +986,12 @@ export default function ClientFormModal({
)}
</Col>
<Col xs={12} md={6}>
<Form.Item label={t('pages.clients.delayedStart')}>
<Form.Item
label={t('pages.clients.delayedStart')}
htmlFor={`${uid}-delayed-start`}
>
<Switch
id={`${uid}-delayed-start`}
checked={delayedStart}
onChange={(v) => {
methods.setValue('delayedStart', v);
@@ -1062,13 +1088,18 @@ export default function ClientFormModal({
</Row>
)}
<Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
<Form.Item
label={t('pages.clients.attachedInbounds')}
required={!isEdit}
htmlFor={`${uid}-inbounds`}
>
<SelectAllClearButtons
options={inboundOptions}
value={inboundIds}
onChange={(v) => methods.setValue('inboundIds', v)}
/>
<Select
id={`${uid}-inbounds`}
mode="multiple"
value={inboundIds}
onChange={(v) => methods.setValue('inboundIds', v)}
@@ -1102,77 +1133,96 @@ export default function ClientFormModal({
label: t('pages.clients.tabCredentials'),
children: (
<>
<Form.Item label={t('pages.clients.uuid')}>
<Form.Item label={t('pages.clients.uuid')} htmlFor={`${uid}-uuid`}>
<Space.Compact style={{ display: 'flex' }}>
<Input
id={`${uid}-uuid`}
value={uuid}
style={{ flex: 1 }}
onChange={(e) => methods.setValue('uuid', e.target.value)}
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
icon={<ReloadOutlined aria-hidden />}
onClick={() => methods.setValue('uuid', RandomUtil.randomUUID())}
/>
</Space.Compact>
</Form.Item>
<Form.Item
htmlFor={`${uid}-password`}
label={t('pages.clients.password')}
tooltip={t('pages.clients.passwordDesc')}
>
<Space.Compact style={{ display: 'flex' }}>
<Input
id={`${uid}-password`}
aria-describedby={`${uid}-password-hint`}
value={password}
style={{ flex: 1 }}
onChange={(e) => methods.setValue('password', e.target.value)}
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
icon={<ReloadOutlined aria-hidden />}
onClick={regeneratePassword}
/>
</Space.Compact>
<span id={`${uid}-password-hint`} hidden>
{t('pages.clients.passwordDesc')}
</span>
</Form.Item>
<Form.Item
htmlFor={`${uid}-sub-id`}
label={t('pages.clients.subId')}
tooltip={t('pages.clients.subIdDesc')}
>
<Space.Compact style={{ display: 'flex' }}>
<Input
id={`${uid}-sub-id`}
aria-describedby={`${uid}-sub-id-hint`}
value={subId}
style={{ flex: 1 }}
onChange={(e) => methods.setValue('subId', e.target.value)}
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
icon={<ReloadOutlined aria-hidden />}
onClick={() =>
methods.setValue('subId', RandomUtil.randomLowerAndNum(16))
}
/>
</Space.Compact>
<span id={`${uid}-sub-id-hint`} hidden>
{t('pages.clients.subIdDesc')}
</span>
</Form.Item>
<Form.Item
htmlFor={`${uid}-auth`}
label={t('pages.clients.hysteriaAuth')}
tooltip={t('pages.clients.hysteriaAuthDesc')}
>
<Space.Compact style={{ display: 'flex' }}>
<Input
id={`${uid}-auth`}
aria-describedby={`${uid}-auth-hint`}
value={auth}
style={{ flex: 1 }}
onChange={(e) => methods.setValue('auth', e.target.value)}
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
icon={<ReloadOutlined aria-hidden />}
onClick={() =>
methods.setValue('auth', RandomUtil.randomLowerAndNum(16))
}
/>
</Space.Compact>
<span id={`${uid}-auth-hint`} hidden>
{t('pages.clients.hysteriaAuthDesc')}
</span>
</Form.Item>
{showFlow && (
@@ -1195,6 +1245,7 @@ export default function ClientFormModal({
{(showWireguard || showAmneziawg) && (
<>
<Form.Item
htmlFor={`${uid}-wg-private-key`}
label={t(
showAmneziawg
? 'pages.clients.amneziaWgPrivateKey'
@@ -1203,6 +1254,7 @@ export default function ClientFormModal({
>
<Space.Compact style={{ display: 'flex' }}>
<Input
id={`${uid}-wg-private-key`}
value={wgPrivateKey}
style={{ flex: 1 }}
onChange={(e) => {
@@ -1216,7 +1268,7 @@ export default function ClientFormModal({
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
icon={<ReloadOutlined aria-hidden />}
onClick={regenerateWireguardKeys}
/>
</Space.Compact>
@@ -1232,6 +1284,7 @@ export default function ClientFormModal({
<Input disabled />
</FormField>
<Form.Item
htmlFor={`${uid}-wg-psk`}
label={t(
showAmneziawg
? 'pages.clients.amneziaWgPreSharedKey'
@@ -1240,11 +1293,11 @@ export default function ClientFormModal({
>
<Space.Compact style={{ display: 'flex' }}>
<FormField name="wgPreSharedKey" noStyle>
<Input style={{ flex: 1 }} />
<Input id={`${uid}-wg-psk`} style={{ flex: 1 }} />
</FormField>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
icon={<ReloadOutlined aria-hidden />}
onClick={regenerateWireguardPresharedKey}
/>
</Space.Compact>
@@ -1305,18 +1358,20 @@ export default function ClientFormModal({
{showMtproto && (
<>
<Form.Item
htmlFor={`${uid}-mtproto-secret`}
label={t('pages.clients.mtprotoSecret')}
extra={t('pages.clients.mtprotoSecretHint')}
>
<Space.Compact style={{ display: 'flex' }}>
<Input
id={`${uid}-mtproto-secret`}
value={secret}
style={{ flex: 1 }}
onChange={(e) => methods.setValue('secret', e.target.value)}
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
icon={<ReloadOutlined aria-hidden />}
onClick={regenerateMtprotoSecret}
/>
</Space.Compact>
@@ -1344,7 +1399,7 @@ export default function ClientFormModal({
<Button
type="primary"
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
onClick={() => addExternalLinkRow('link')}
>
{t('pages.clients.addExternalLink')}
@@ -1364,7 +1419,7 @@ export default function ClientFormModal({
valueProp="checked"
noStyle
>
<Switch size="small" />
<Switch size="small" aria-label={t('enable')} />
</FormField>
<span>{t('enable')}</span>
</div>
@@ -1378,7 +1433,7 @@ export default function ClientFormModal({
<Button
aria-label={t('delete')}
danger
icon={<DeleteOutlined />}
icon={<DeleteOutlined aria-hidden />}
onClick={() => removeExternalLink(index)}
/>
</Tooltip>
@@ -1398,6 +1453,7 @@ export default function ClientFormModal({
const hasSpecificExpiry = Number(expiryField.value) > 0;
return (
<DateTimePicker
aria-label={t('pages.clients.expiryTime')}
value={displayedExpiry > 0 ? dayjs(displayedExpiry) : null}
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
@@ -1415,7 +1471,7 @@ export default function ClientFormModal({
<Button
type="primary"
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
onClick={() => addExternalLinkRow('subscription')}
>
{t('pages.clients.addExternalSubscription')}
@@ -1435,7 +1491,7 @@ export default function ClientFormModal({
valueProp="checked"
noStyle
>
<Switch size="small" />
<Switch size="small" aria-label={t('enable')} />
</FormField>
<span>{t('enable')}</span>
</div>
@@ -1449,7 +1505,7 @@ export default function ClientFormModal({
<Button
aria-label={t('delete')}
danger
icon={<DeleteOutlined />}
icon={<DeleteOutlined aria-hidden />}
onClick={() => removeExternalLink(index)}
/>
</Tooltip>
@@ -1475,6 +1531,7 @@ export default function ClientFormModal({
const hasSpecificExpiry = Number(expiryField.value) > 0;
return (
<DateTimePicker
aria-label={t('pages.clients.expiryTime')}
value={displayedExpiry > 0 ? dayjs(displayedExpiry) : null}
onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
@@ -1515,7 +1572,12 @@ export default function ClientFormModal({
zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
onCancel={() => setIpsModalOpen(false)}
footer={[
<Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
<Button
key="refresh"
icon={<ReloadOutlined aria-hidden />}
loading={ipsLoading}
onClick={loadIps}
>
{t('refresh')}
</Button>,
<Button
+23 -18
View File
@@ -350,7 +350,7 @@ export default function ClientInfoModal({
<Button
size="small"
type="text"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyValue(client.subId!)}
/>
@@ -365,7 +365,7 @@ export default function ClientInfoModal({
<Button
size="small"
type="text"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyValue(client.uuid!)}
/>
@@ -380,7 +380,7 @@ export default function ClientInfoModal({
<Button
size="small"
type="text"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyValue(client.password!)}
/>
@@ -395,7 +395,7 @@ export default function ClientInfoModal({
<Button
size="small"
type="text"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyValue(client.auth!)}
/>
@@ -457,7 +457,7 @@ export default function ClientInfoModal({
<td>
<Button
size="small"
icon={<EyeOutlined />}
icon={<EyeOutlined aria-hidden />}
aria-label={t('pages.clients.ipLog')}
loading={ipsLoading}
onClick={openIpsModal}
@@ -489,7 +489,7 @@ export default function ClientInfoModal({
<td>
<Button
size="small"
icon={<EyeOutlined />}
icon={<EyeOutlined aria-hidden />}
aria-label={t('pages.clients.hwidLog')}
loading={hwidsLoading}
onClick={openHwidsModal}
@@ -591,7 +591,7 @@ export default function ClientInfoModal({
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyValue(subLink)}
/>
@@ -599,7 +599,7 @@ export default function ClientInfoModal({
<Tooltip title={t('download')}>
<Button
size="small"
icon={<DownloadOutlined />}
icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')}
loading={downloadingFormat === 'standard'}
disabled={downloadingFormat !== null}
@@ -621,7 +621,7 @@ export default function ClientInfoModal({
<Tooltip title={t('pages.clients.qrCode')}>
<Button
size="small"
icon={<QrcodeOutlined />}
icon={<QrcodeOutlined aria-hidden />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip>
@@ -646,7 +646,7 @@ export default function ClientInfoModal({
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyValue(subJsonLink)}
/>
@@ -654,7 +654,7 @@ export default function ClientInfoModal({
<Tooltip title={t('download')}>
<Button
size="small"
icon={<DownloadOutlined />}
icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')}
loading={downloadingFormat === 'json'}
disabled={downloadingFormat !== null}
@@ -676,7 +676,7 @@ export default function ClientInfoModal({
<Tooltip title={t('pages.clients.qrCode')}>
<Button
size="small"
icon={<QrcodeOutlined />}
icon={<QrcodeOutlined aria-hidden />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip>
@@ -704,7 +704,7 @@ export default function ClientInfoModal({
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyValue(subClashLink)}
/>
@@ -712,7 +712,7 @@ export default function ClientInfoModal({
<Tooltip title={t('download')}>
<Button
size="small"
icon={<DownloadOutlined />}
icon={<DownloadOutlined aria-hidden />}
aria-label={t('download')}
loading={downloadingFormat === 'clash'}
disabled={downloadingFormat !== null}
@@ -734,7 +734,7 @@ export default function ClientInfoModal({
<Tooltip title={t('pages.clients.qrCode')}>
<Button
size="small"
icon={<QrcodeOutlined />}
icon={<QrcodeOutlined aria-hidden />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip>
@@ -768,7 +768,7 @@ export default function ClientInfoModal({
<Tooltip title={t('copy')}>
<Button
size="small"
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
aria-label={t('copy')}
onClick={() => copyValue(link)}
/>
@@ -783,7 +783,7 @@ export default function ClientInfoModal({
<Tooltip title={t('pages.clients.qrCode')}>
<Button
size="small"
icon={<QrcodeOutlined />}
icon={<QrcodeOutlined aria-hidden />}
aria-label={t('pages.clients.qrCode')}
/>
</Tooltip>
@@ -853,7 +853,12 @@ export default function ClientInfoModal({
width={440}
onCancel={() => setIpsModalOpen(false)}
footer={[
<Button key="refresh" icon={<ReloadOutlined />} loading={ipsLoading} onClick={loadIps}>
<Button
key="refresh"
icon={<ReloadOutlined aria-hidden />}
loading={ipsLoading}
onClick={loadIps}
>
{t('refresh')}
</Button>,
<Button
+41 -31
View File
@@ -113,7 +113,7 @@ function UngroupIcon() {
height: '1em',
}}
>
<TagsOutlined />
<TagsOutlined aria-hidden />
<span
aria-hidden="true"
style={{
@@ -1081,6 +1081,7 @@ export default function ClientsPage() {
checked={!!record.enable}
size="small"
loading={togglingEmail === record.email}
aria-label={t('pages.clients.enabled')}
onChange={(next) => onToggleEnable(record, next)}
/>
),
@@ -1140,16 +1141,20 @@ export default function ClientsPage() {
render: (_v, record) => {
if (!record.group) return <Typography.Text type="secondary">—</Typography.Text>;
const isActive = filters.groups.includes(record.group);
const filterByGroup = () => {
if (!isActive) setFilters({ ...filters, groups: [...filters.groups, record.group!] });
};
return (
<Tag
color="geekblue"
style={{ margin: 0, cursor: 'pointer', opacity: isActive ? 0.6 : 1 }}
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
if (!isActive) {
setFilters({ ...filters, groups: [...filters.groups, record.group!] });
}
filterByGroup();
}}
onKeyDown={activateOnKey(filterByGroup)}
>
{record.group}
</Tag>
@@ -1327,7 +1332,7 @@ export default function ClientsPage() {
<SummaryStat
title={t('clients')}
value={summary.total}
prefix={<TeamOutlined />}
prefix={<TeamOutlined aria-hidden />}
onSelect={() => selectBucket(null)}
/>
</Col>
@@ -1393,7 +1398,7 @@ export default function ClientsPage() {
{selectedRowKeys.length === 0 ? (
<Button
type="primary"
icon={<PlusOutlined />}
icon={<PlusOutlined aria-hidden />}
onClick={onAdd}
aria-label={t('pages.clients.addClients')}
>
@@ -1418,20 +1423,20 @@ export default function ClientsPage() {
? [
{
key: 'attach',
icon: <UsergroupAddOutlined />,
icon: <UsergroupAddOutlined aria-hidden />,
label: t('pages.clients.attach'),
onClick: () => setBulkAttachOpen(true),
},
{
key: 'detach',
icon: <UsergroupDeleteOutlined />,
icon: <UsergroupDeleteOutlined aria-hidden />,
label: t('pages.clients.detach'),
danger: true,
onClick: () => setBulkDetachOpen(true),
},
{
key: 'addToGroup',
icon: <TagsOutlined />,
icon: <TagsOutlined aria-hidden />,
label: t('pages.clients.addToGroup'),
onClick: () => setBulkGroupOpen(true),
},
@@ -1445,26 +1450,26 @@ export default function ClientsPage() {
{ type: 'divider' as const },
{
key: 'enable',
icon: <CheckCircleOutlined />,
icon: <CheckCircleOutlined aria-hidden />,
label: t('pages.clients.enable'),
onClick: () => onBulkSetEnable(true),
},
{
key: 'disable',
icon: <StopOutlined />,
icon: <StopOutlined aria-hidden />,
label: t('pages.clients.disable'),
danger: true,
onClick: () => onBulkSetEnable(false),
},
{
key: 'adjust',
icon: <ClockCircleOutlined />,
icon: <ClockCircleOutlined aria-hidden />,
label: t('pages.clients.adjust'),
onClick: () => setBulkAdjustOpen(true),
},
{
key: 'subLinks',
icon: <LinkOutlined />,
icon: <LinkOutlined aria-hidden />,
label: t('pages.clients.subLinks'),
onClick: () => setSubLinksOpen(true),
},
@@ -1472,39 +1477,39 @@ export default function ClientsPage() {
: [
{
key: 'bulk',
icon: <UsergroupAddOutlined />,
icon: <UsergroupAddOutlined aria-hidden />,
label: t('pages.clients.bulk'),
onClick: () => setBulkAddOpen(true),
},
{
key: 'export',
icon: <DownloadOutlined />,
icon: <DownloadOutlined aria-hidden />,
label: t('pages.clients.exportClients'),
onClick: onExportClients,
},
{
key: 'import',
icon: <UploadOutlined />,
icon: <UploadOutlined aria-hidden />,
label: t('pages.clients.importClients'),
onClick: onImportClients,
},
{
key: 'resetAll',
icon: <RetweetOutlined />,
icon: <RetweetOutlined aria-hidden />,
label: t('pages.clients.resetAllTraffics'),
onClick: onResetAllTraffics,
},
{ type: 'divider' as const },
{
key: 'delDepleted',
icon: <RestOutlined />,
icon: <RestOutlined aria-hidden />,
label: t('pages.clients.delDepleted'),
danger: true,
onClick: onDelDepleted,
},
{
key: 'delOrphans',
icon: <DisconnectOutlined />,
icon: <DisconnectOutlined aria-hidden />,
label: t('pages.clients.delOrphans'),
danger: true,
onClick: onDeleteOrphans,
@@ -1512,14 +1517,14 @@ export default function ClientsPage() {
],
}}
>
<Button icon={<MoreOutlined />} aria-label={t('more')}>
<Button icon={<MoreOutlined aria-hidden />} aria-label={t('more')}>
{!isMobile && t('more')}
</Button>
</Dropdown>
{selectedRowKeys.length > 0 && (
<Button
danger
icon={<DeleteOutlined />}
icon={<DeleteOutlined aria-hidden />}
onClick={onBulkDelete}
style={{ marginInlineStart: 'auto' }}
aria-label={t('delete')}
@@ -1536,14 +1541,14 @@ export default function ClientsPage() {
onChange={(e) => setSearchKey(e.target.value)}
placeholder={t('pages.clients.searchPlaceholder')}
allowClear
prefix={<SearchOutlined />}
prefix={<SearchOutlined aria-hidden />}
size={isMobile ? 'small' : 'middle'}
style={{ maxWidth: 320 }}
aria-label={t('search')}
/>
<Badge count={activeCount} size="small" offset={[-4, 4]}>
<Button
icon={<FilterOutlined />}
icon={<FilterOutlined aria-hidden />}
size={isMobile ? 'small' : 'middle'}
onClick={() => setFilterDrawerOpen(true)}
type={activeCount > 0 ? 'primary' : 'default'}
@@ -1556,7 +1561,7 @@ export default function ClientsPage() {
value={sortValueFor(sortColumn, sortOrder)}
aria-label={t('sort')}
size={isMobile ? 'small' : 'middle'}
suffix={<SortAscendingOutlined />}
suffix={<SortAscendingOutlined aria-hidden />}
style={{ minWidth: isMobile ? 130 : 200 }}
onChange={(value) => {
const opt = SORT_OPTIONS.find((o) => o.value === value);
@@ -1709,7 +1714,10 @@ export default function ClientsPage() {
locale={{
emptyText: (
<div className="clients-empty">
<TeamOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<TeamOutlined
aria-hidden
style={{ fontSize: 32, marginBottom: 8 }}
/>
<div>{t('noData')}</div>
</div>
),
@@ -1734,7 +1742,7 @@ export default function ClientsPage() {
)}
{filteredClients.length === 0 && (
<div className="card-empty">
<TeamOutlined style={{ fontSize: 28, opacity: 0.5 }} />
<TeamOutlined aria-hidden style={{ fontSize: 28, opacity: 0.5 }} />
<div>{t('noData')}</div>
</div>
)}
@@ -1799,6 +1807,7 @@ export default function ClientsPage() {
checked={!!row.enable}
size="small"
loading={togglingEmail === row.email}
aria-label={t('pages.clients.enabled')}
onChange={(next) => onToggleEnable(row, next)}
/>
<Dropdown
@@ -1810,7 +1819,8 @@ export default function ClientsPage() {
key: 'qr',
label: (
<>
<QrcodeOutlined /> {t('pages.clients.qrCode')}
<QrcodeOutlined aria-hidden />{' '}
{t('pages.clients.qrCode')}
</>
),
onClick: () => onShowQr(row.email),
@@ -1819,7 +1829,7 @@ export default function ClientsPage() {
key: 'reset',
label: (
<>
<RetweetOutlined />{' '}
<RetweetOutlined aria-hidden />{' '}
{t('pages.inbounds.resetTraffic')}
</>
),
@@ -1829,7 +1839,7 @@ export default function ClientsPage() {
key: 'edit',
label: (
<>
<EditOutlined /> {t('edit')}
<EditOutlined aria-hidden /> {t('edit')}
</>
),
onClick: () => onEdit(row.email),
@@ -1839,7 +1849,7 @@ export default function ClientsPage() {
danger: true,
label: (
<>
<DeleteOutlined /> {t('delete')}
<DeleteOutlined aria-hidden /> {t('delete')}
</>
),
onClick: () => onDelete(row.email),
@@ -1851,7 +1861,7 @@ export default function ClientsPage() {
type="text"
size="small"
className="row-action-trigger"
icon={<MoreOutlined />}
icon={<MoreOutlined aria-hidden />}
aria-label={t('more')}
/>
</Dropdown>
+15 -7
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useId, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
@@ -46,6 +46,7 @@ export default function FilterDrawer({
nodes,
}: FilterDrawerProps) {
const { t } = useTranslation();
const uid = useId();
function patch<K extends keyof ClientFilters>(key: K, value: ClientFilters[K]) {
onChange({ ...filters, [key]: value });
@@ -113,8 +114,9 @@ export default function FilterDrawer({
</Checkbox.Group>
</Form.Item>
<Form.Item label={t('pages.inbounds.protocol')}>
<Form.Item label={t('pages.inbounds.protocol')} htmlFor={`${uid}-protocols`}>
<Select
id={`${uid}-protocols`}
mode="multiple"
value={filters.protocols}
onChange={(v) => patch('protocols', v as string[])}
@@ -125,8 +127,9 @@ export default function FilterDrawer({
/>
</Form.Item>
<Form.Item label={t('inbounds')}>
<Form.Item label={t('inbounds')} htmlFor={`${uid}-inbounds`}>
<Select
id={`${uid}-inbounds`}
mode="multiple"
value={filters.inboundIds}
onChange={(v) => patch('inboundIds', v as number[])}
@@ -140,8 +143,9 @@ export default function FilterDrawer({
</Form.Item>
{nodes.length > 0 && (
<Form.Item label={t('pages.clients.filters.nodes')}>
<Form.Item label={t('pages.clients.filters.nodes')} htmlFor={`${uid}-nodes`}>
<Select
id={`${uid}-nodes`}
mode="multiple"
value={filters.nodeIds}
onChange={(v) => patch('nodeIds', v as number[])}
@@ -155,8 +159,9 @@ export default function FilterDrawer({
</Form.Item>
)}
<Form.Item label={t('pages.clients.group')}>
<Form.Item label={t('pages.clients.group')} htmlFor={`${uid}-groups`}>
<Select
id={`${uid}-groups`}
mode="multiple"
value={filters.groups}
onChange={(v) => patch('groups', v as string[])}
@@ -169,8 +174,9 @@ export default function FilterDrawer({
/>
</Form.Item>
<Form.Item label={t('pages.clients.expiryTime')}>
<Form.Item label={t('pages.clients.expiryTime')} htmlFor={`${uid}-expiry`}>
<DatePicker.RangePicker
id={`${uid}-expiry`}
value={dateRange}
onChange={(range) => {
const from = range?.[0]?.startOf('day').valueOf();
@@ -182,10 +188,11 @@ export default function FilterDrawer({
/>
</Form.Item>
<Form.Item label={`${t('pages.clients.traffic')} (GB)`}>
<Form.Item label={`${t('pages.clients.traffic')} (GB)`} htmlFor={`${uid}-usage-from`}>
<Row gutter={8}>
<Col span={12}>
<InputNumber
id={`${uid}-usage-from`}
value={filters.usageFromGB}
min={0}
step={1}
@@ -196,6 +203,7 @@ export default function FilterDrawer({
</Col>
<Col span={12}>
<InputNumber
aria-label={`${t('pages.clients.traffic')} (GB) ${t('to')}`}
value={filters.usageToGB}
min={0}
step={1}
+5 -5
View File
@@ -44,7 +44,7 @@ export const ClientRowActions = memo(function ClientRowActions({
size="small"
type="text"
style={ICON_BUTTON_STYLE}
icon={<QrcodeOutlined />}
icon={<QrcodeOutlined aria-hidden />}
aria-label={t('pages.clients.qrCode')}
onClick={() => onShowQr(email)}
/>
@@ -54,7 +54,7 @@ export const ClientRowActions = memo(function ClientRowActions({
size="small"
type="text"
style={ICON_BUTTON_STYLE}
icon={<InfoCircleOutlined />}
icon={<InfoCircleOutlined aria-hidden />}
aria-label={t('pages.clients.clientInfo')}
onClick={() => onShowInfo(email)}
/>
@@ -64,7 +64,7 @@ export const ClientRowActions = memo(function ClientRowActions({
size="small"
type="text"
style={ICON_BUTTON_STYLE}
icon={<RetweetOutlined />}
icon={<RetweetOutlined aria-hidden />}
aria-label={t('pages.inbounds.resetTraffic')}
onClick={() => onResetTraffic(email)}
/>
@@ -74,7 +74,7 @@ export const ClientRowActions = memo(function ClientRowActions({
size="small"
type="text"
style={ICON_BUTTON_STYLE}
icon={<EditOutlined />}
icon={<EditOutlined aria-hidden />}
aria-label={t('edit')}
onClick={() => onEdit(email)}
/>
@@ -85,7 +85,7 @@ export const ClientRowActions = memo(function ClientRowActions({
type="text"
danger
style={ICON_BUTTON_STYLE}
icon={<DeleteOutlined />}
icon={<DeleteOutlined aria-hidden />}
aria-label={t('delete')}
onClick={() => onDelete(email)}
/>
+3 -3
View File
@@ -122,7 +122,7 @@ export default function SubLinksModal({
size="small"
type="text"
aria-label={t('copy')}
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
onClick={() => copy(row.link, t('copied'))}
/>
),
@@ -157,7 +157,7 @@ export default function SubLinksModal({
<Button onClick={() => onOpenChange(false)}>{t('close')}</Button>
<div style={{ display: 'flex', gap: 8 }}>
<Button
icon={<CopyOutlined />}
icon={<CopyOutlined aria-hidden />}
disabled={rows.length === 0}
onClick={() =>
copy(allText, t('pages.clients.subLinksCopiedAll', { count: rows.length }))
@@ -167,7 +167,7 @@ export default function SubLinksModal({
</Button>
<Button
type="primary"
icon={<DownloadOutlined />}
icon={<DownloadOutlined aria-hidden />}
disabled={rows.length === 0}
onClick={download}
>