Files
3x-ui/frontend/src/pages/clients/BulkAttachInboundsModal.tsx
T
Alireza Ahmadi 1bdb517b88 fix(a11y): silence live stats and make Select tags removable for screen readers
Ant Design's Spin wraps its content in aria-live="polite", so every
WebSocket tick on the dashboard, inbounds, clients and nodes pages was
announced, and only the changed number was read, with no label.
Pass aria-live="off" on every Spin that wraps content; aria-busy still
signals loading. Each vital tile is now a group named by its metric,
so "47.2 %" is read as CPU.

rc-select renders each tag's remove "x" as an aria-hidden span with no
tabIndex, so screen-reader users could only Backspace the last value
away, never remove one SNI or inbound from the middle.
selectTagRender swaps in a real button named "Remove <value>" and
moves focus to the next tag after removal. It is applied to every
tags/multiple Select. maxTagCount="responsive" is dropped because it
hid selected inbounds behind "+N", where no screen reader could reach
them.

Two oxlint rules (antd-a11y/spin-content-not-live and
antd-a11y/select-tag-remove) keep new code from regressing either fix.
2026-11-01 16:49:19 +01:00

125 lines
3.7 KiB
TypeScript

import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Modal, Select, Typography, message } from 'antd';
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
import type { InboundOption } from '@/hooks/useClients';
import { formatInboundLabel } from '@/lib/inbounds/label';
import type { BulkAttachResult } from '@/schemas/client';
const MULTI_USER_PROTOCOLS = new Set([
'vmess',
'vless',
'trojan',
'hysteria',
'shadowsocks',
'wireguard',
'mtproto',
'amneziawg',
'tuic',
'masque',
]);
interface BulkAttachInboundsModalProps {
open: boolean;
count: number;
inbounds: InboundOption[];
onOpenChange: (open: boolean) => void;
onSubmit: (inboundIds: number[]) => Promise<BulkAttachResult | null>;
}
export default function BulkAttachInboundsModal({
open,
count,
inbounds,
onOpenChange,
onSubmit,
}: BulkAttachInboundsModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [targetIds, setTargetIds] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
// React resets this during render rather than in an effect so the modal's
// first open frame already shows cleared fields.
const [wasOpen, setWasOpen] = useState(false);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setTargetIds([]);
}
const targetOptions = useMemo(() => {
return (inbounds || [])
.filter((ib) => MULTI_USER_PROTOCOLS.has((ib.protocol || '').toLowerCase()))
.map((ib) => ({
value: ib.id,
label: formatInboundLabel(ib.tag, ib.remark),
}));
}, [inbounds]);
async function submit() {
if (targetIds.length === 0 || count === 0) return;
setSubmitting(true);
try {
const result = await onSubmit(targetIds);
if (!result) return;
const attached = result.attached?.length ?? 0;
const skipped = result.skipped?.length ?? 0;
const errors = result.errors?.length ?? 0;
if (errors > 0) {
messageApi.warning(
t('pages.inbounds.attachClientsResultMixed', { attached, skipped, errors }),
);
} else {
messageApi.success(t('pages.inbounds.attachClientsResult', { attached, skipped }));
}
onOpenChange(false);
} finally {
setSubmitting(false);
}
}
return (
<>
{messageContextHolder}
<Modal
open={open}
title={t('pages.clients.attachToInboundsTitle', { count })}
okText={t('pages.inbounds.attachClients')}
cancelText={t('cancel')}
okButtonProps={{ disabled: targetIds.length === 0, loading: submitting }}
onCancel={() => onOpenChange(false)}
onOk={submit}
destroyOnHidden
>
<Typography.Paragraph type="secondary">
{t('pages.clients.attachToInboundsDesc', { count })}
</Typography.Paragraph>
{targetOptions.length === 0 ? (
<Alert type="info" showIcon title={t('pages.clients.attachToInboundsNoTargets')} />
) : (
<>
<SelectAllClearButtons
options={targetOptions}
value={targetIds}
onChange={setTargetIds}
/>
<Select
mode="multiple"
tagRender={selectTagRender}
style={{ width: '100%' }}
value={targetIds}
onChange={setTargetIds}
options={targetOptions}
placeholder={t('pages.clients.attachToInboundsTargets')}
aria-label={t('pages.clients.attachToInboundsTargets')}
showSearch={{ optionFilterProp: 'label' }}
autoFocus
/>
</>
)}
</Modal>
</>
);
}