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.
This commit is contained in:
Alireza Ahmadi
2026-11-01 16:49:19 +01:00
parent cbf596d889
commit 1bdb517b88
73 changed files with 672 additions and 48 deletions
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Modal, Select, Typography, message } from 'antd';
import { SelectAllClearButtons } from '@/components/form';
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
import type { InboundOption } from '@/hooks/useClients';
import { formatInboundLabel } from '@/lib/inbounds/label';
import type { BulkAttachResult } from '@/schemas/client';
@@ -106,6 +106,7 @@ export default function BulkAttachInboundsModal({
/>
<Select
mode="multiple"
tagRender={selectTagRender}
style={{ width: '100%' }}
value={targetIds}
onChange={setTargetIds}
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Modal, Select, Typography, message } from 'antd';
import { SelectAllClearButtons } from '@/components/form';
import { SelectAllClearButtons, selectTagRender } from '@/components/form';
import type { InboundOption } from '@/hooks/useClients';
import { formatInboundLabel } from '@/lib/inbounds/label';
import type { BulkDetachResult } from '@/schemas/client';
@@ -106,6 +106,7 @@ export default function BulkDetachInboundsModal({
/>
<Select
mode="multiple"
tagRender={selectTagRender}
style={{ width: '100%' }}
value={targetIds}
onChange={setTargetIds}
@@ -21,7 +21,7 @@ import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { RandomUtil, SizeFormatter } from '@/utils';
import { formatInboundLabel } from '@/lib/inbounds/label';
import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
import { DateTimePicker, SelectAllClearButtons, selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
import { useClients, type InboundOption } from '@/hooks/useClients';
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
@@ -268,6 +268,7 @@ export default function ClientBulkAddModal({
<Select
id={`${uid}-inbounds`}
mode="multiple"
tagRender={selectTagRender}
value={inboundIds}
onChange={(v) => methods.setValue('inboundIds', v)}
options={inboundOptions}
@@ -38,7 +38,7 @@ import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
import { useDatepicker } from '@/hooks/useDatepicker';
import { useClientHwids } from '@/hooks/useClientHwids';
import { useRawHostsQuery } from '@/api/queries/useRawHostsQuery';
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
import { DateTimePicker, SelectAllClearButtons, selectTagRender } from '@/components/form';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import ClientHwidListModal from '@/components/clients/ClientHwidList';
import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
@@ -1168,11 +1168,11 @@ export default function ClientFormModal({
<Select
id={`${uid}-host-rule-override`}
mode="multiple"
tagRender={selectTagRender}
value={clientHostRuleIds || []}
onChange={(v) => methods.setValue('clientHostRuleIds', v)}
options={hostRuleOptions}
placeholder={t('pages.clients.hostRuleOverridePlaceholder')}
maxTagCount="responsive"
placement="topLeft"
listHeight={220}
allowClear
@@ -1226,11 +1226,11 @@ export default function ClientFormModal({
<Select
id={`${uid}-inbounds`}
mode="multiple"
tagRender={selectTagRender}
value={inboundIds}
onChange={handleInboundChange}
options={inboundOptions}
placeholder={t('pages.clients.selectInbound')}
maxTagCount="responsive"
placement="topLeft"
listHeight={220}
showSearch={{
+2 -2
View File
@@ -163,7 +163,7 @@ function SubscriptionQrPresentation({
showIcon
title={t('pages.clients.happLinkDisclosure')}
/>
<Spin spinning={happLoading}>
<Spin aria-live="off" spinning={happLoading}>
<div style={{ minHeight: happLoading ? 48 : undefined }}>
{happLink ? (
<>
@@ -563,7 +563,7 @@ function ClientQrModalContent({
centered
onCancel={() => onOpenChange(false)}
>
<Spin spinning={loading}>
<Spin aria-live="off" spinning={loading}>
{!client?.subId && !loading && (
<div style={{ padding: 24, textAlign: 'center', opacity: 0.6 }}>
{t('pages.clients.noSubId')}
+2 -1
View File
@@ -1305,6 +1305,7 @@ export default function ClientsPage() {
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin
aria-live="off"
spinning={!fetched || !hostsFetched}
delay={200}
description={t('loading')}
@@ -1724,7 +1725,7 @@ export default function ClientsPage() {
}}
/>
) : (
<Spin spinning={transitioning}>
<Spin aria-live="off" spinning={transitioning}>
<div className="client-cards">
{filteredClients.length > 0 && (
<div className="card-bulk-bar">
+5 -4
View File
@@ -17,6 +17,7 @@ import {
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import { selectTagRender } from '@/components/form';
import type { InboundOption } from '@/hooks/useClients';
import type { NodeRecord } from '@/schemas/node';
import { formatInboundLabel } from '@/lib/inbounds/label';
@@ -118,11 +119,11 @@ export default function FilterDrawer({
<Select
id={`${uid}-protocols`}
mode="multiple"
tagRender={selectTagRender}
value={filters.protocols}
onChange={(v) => patch('protocols', v as string[])}
options={protocolOptions}
placeholder={t('pages.inbounds.protocol')}
maxTagCount="responsive"
allowClear
/>
</Form.Item>
@@ -131,11 +132,11 @@ export default function FilterDrawer({
<Select
id={`${uid}-inbounds`}
mode="multiple"
tagRender={selectTagRender}
value={filters.inboundIds}
onChange={(v) => patch('inboundIds', v as number[])}
options={inboundOptions}
placeholder={t('inbounds')}
maxTagCount="responsive"
allowClear
showSearch={{ optionFilterProp: 'label' }}
listHeight={220}
@@ -147,11 +148,11 @@ export default function FilterDrawer({
<Select
id={`${uid}-nodes`}
mode="multiple"
tagRender={selectTagRender}
value={filters.nodeIds}
onChange={(v) => patch('nodeIds', v as number[])}
options={nodeOptions}
placeholder={t('pages.clients.filters.nodes')}
maxTagCount="responsive"
allowClear
showSearch={{ optionFilterProp: 'label' }}
listHeight={220}
@@ -163,11 +164,11 @@ export default function FilterDrawer({
<Select
id={`${uid}-groups`}
mode="multiple"
tagRender={selectTagRender}
value={filters.groups}
onChange={(v) => patch('groups', v as string[])}
options={groupOptions}
placeholder={t('pages.clients.groupPlaceholder')}
maxTagCount="responsive"
allowClear
showSearch={{ optionFilterProp: 'label' }}
listHeight={220}