From e2184777a0edaf17a7d87f1f5ab40a35da399b1b Mon Sep 17 00:00:00 2001 From: Alireza Ahmadi Date: Fri, 23 Oct 2026 18:29:47 +0200 Subject: [PATCH] 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. --- frontend/.oxlintrc.json | 27 +- .../src/components/clients/ClientHwidList.tsx | 9 +- .../src/components/clients/ConfigBlock.tsx | 15 +- .../command-palette/CommandPalette.tsx | 78 +++--- .../src/components/feedback/TextModal.tsx | 4 +- .../src/components/form/DateTimePicker.tsx | 17 +- .../src/components/form/HeaderMapEditor.tsx | 6 +- .../components/form/RemarkTemplateField.tsx | 14 +- .../src/components/form/rhf/FormField.tsx | 16 +- .../components/geodata/GeoBrowserModal.tsx | 2 + .../src/components/geodata/GeoTokenInput.tsx | 5 +- .../src/components/sponsor/SponsorCard.tsx | 6 +- .../ui/notifications/DiscordNotifications.tsx | 10 +- .../ui/notifications/EmailNotifications.tsx | 10 +- .../notifications/TelegramNotifications.tsx | 10 +- frontend/src/entries/login.tsx | 13 +- frontend/src/entries/subpage.tsx | 13 +- frontend/src/i18n/AntdRootConfig.tsx | 41 +++ frontend/src/i18n/react.ts | 4 + frontend/src/layouts/AppSidebar.tsx | 64 +++-- .../forms/transport/CustomSockoptList.tsx | 2 +- .../xray/forms/transport/FinalMaskForm.tsx | 235 +++++++++++++----- frontend/src/main.tsx | 13 +- .../pages/clients/BulkAttachInboundsModal.tsx | 1 + .../pages/clients/BulkDetachInboundsModal.tsx | 1 + .../src/pages/clients/ClientBulkAddModal.tsx | 27 +- .../src/pages/clients/ClientFormModal.tsx | 112 +++++++-- .../src/pages/clients/ClientInfoModal.tsx | 41 +-- frontend/src/pages/clients/ClientsPage.tsx | 72 +++--- frontend/src/pages/clients/FilterDrawer.tsx | 22 +- frontend/src/pages/clients/RowCells.tsx | 10 +- frontend/src/pages/clients/SubLinksModal.tsx | 6 +- .../src/pages/groups/GroupAddClientsModal.tsx | 1 + .../pages/groups/GroupRemoveClientsModal.tsx | 1 + frontend/src/pages/groups/GroupsPage.tsx | 45 ++-- frontend/src/pages/hosts/HostFormModal.tsx | 20 +- frontend/src/pages/hosts/HostList.tsx | 21 +- frontend/src/pages/hosts/HostsPage.tsx | 12 +- frontend/src/pages/inbounds/InboundsPage.tsx | 10 +- .../src/pages/inbounds/form/FallbacksCard.tsx | 19 +- .../pages/inbounds/form/InboundFormModal.tsx | 91 +++---- .../inbounds/form/protocols/accounts-list.tsx | 8 +- .../inbounds/form/protocols/amneziawg.tsx | 14 +- .../inbounds/form/protocols/hysteria.tsx | 5 +- .../inbounds/form/protocols/shadowsocks.tsx | 8 +- .../pages/inbounds/form/protocols/tuic.tsx | 24 +- .../src/pages/inbounds/form/protocols/tun.tsx | 39 +-- .../pages/inbounds/form/protocols/vless.tsx | 7 +- .../inbounds/form/protocols/wireguard.tsx | 16 +- .../security/RealityTargetScannerModal.tsx | 1 + .../pages/inbounds/form/security/reality.tsx | 41 ++- .../src/pages/inbounds/form/security/tls.tsx | 29 ++- .../src/pages/inbounds/form/transport/raw.tsx | 5 +- .../pages/inbounds/form/transport/sockopt.tsx | 7 + .../pages/inbounds/info/InboundInfoModal.tsx | 26 +- .../src/pages/inbounds/list/InboundList.tsx | 27 +- .../src/pages/inbounds/list/RowActions.tsx | 43 ++-- .../pages/inbounds/list/useInboundColumns.tsx | 8 +- frontend/src/pages/inbounds/qr/QrPanel.tsx | 11 +- .../src/pages/index/AmneziaWGLogModal.tsx | 9 +- frontend/src/pages/index/BackupModal.tsx | 6 +- frontend/src/pages/index/GeodataSection.tsx | 23 +- frontend/src/pages/index/IndexPage.tsx | 12 +- frontend/src/pages/index/LogModal.tsx | 4 +- .../src/pages/index/OverviewActionBar.tsx | 29 ++- frontend/src/pages/index/PanelUpdateModal.tsx | 9 +- .../src/pages/index/SystemHistoryModal.tsx | 19 +- frontend/src/pages/index/SystemStrip.tsx | 6 +- frontend/src/pages/index/ThroughputCard.tsx | 4 +- frontend/src/pages/index/XrayLogModal.tsx | 9 +- frontend/src/pages/index/XrayMetricsModal.tsx | 14 +- frontend/src/pages/login/LoginPage.tsx | 16 +- frontend/src/pages/nodes/NodeList.tsx | 47 ++-- frontend/src/pages/nodes/NodesPage.tsx | 15 +- frontend/src/pages/settings/DiscordTab.tsx | 14 +- frontend/src/pages/settings/EmailTab.tsx | 14 +- frontend/src/pages/settings/GeneralTab.tsx | 32 ++- .../pages/settings/HappSettingsContent.tsx | 11 +- .../pages/settings/IncySettingsContent.tsx | 14 +- frontend/src/pages/settings/SecretInput.tsx | 3 + frontend/src/pages/settings/SecurityTab.tsx | 25 +- .../pages/settings/SubBalancerFormModal.tsx | 9 +- .../settings/SubscriptionBalancersTab.tsx | 22 +- .../pages/settings/SubscriptionFormatsTab.tsx | 18 +- .../pages/settings/SubscriptionGeneralTab.tsx | 26 +- frontend/src/pages/settings/TelegramTab.tsx | 14 +- frontend/src/pages/settings/catTabLabel.tsx | 6 +- frontend/src/pages/sponsors/SponsorsPage.tsx | 7 +- frontend/src/pages/sub/SubAppsTab.tsx | 4 +- frontend/src/pages/sub/SubConfigsTab.tsx | 4 +- frontend/src/pages/sub/SubHeader.tsx | 12 +- frontend/src/pages/sub/SubLinksTab.tsx | 4 +- frontend/src/pages/sub/SubPage.tsx | 10 +- frontend/src/pages/sub/SubQrButton.tsx | 17 +- .../xray/balancers/BalancerFormModal.tsx | 13 +- .../src/pages/xray/balancers/BalancersTab.tsx | 28 ++- frontend/src/pages/xray/basics/BasicsTab.tsx | 25 +- .../src/pages/xray/dns/DnsServerModal.tsx | 18 +- frontend/src/pages/xray/dns/DnsTab.tsx | 38 +-- frontend/src/pages/xray/dns/useDnsColumns.tsx | 13 +- .../src/pages/xray/outbounds/CountryPill.tsx | 2 +- .../pages/xray/outbounds/OutboundCardList.tsx | 20 +- .../xray/outbounds/OutboundFormModal.tsx | 15 +- .../src/pages/xray/outbounds/OutboundsTab.tsx | 108 +++++--- .../xray/outbounds/SubscriptionOutbounds.tsx | 4 +- .../xray/outbounds/TestResultPopover.tsx | 2 +- .../xray/outbounds/protocols/amneziawg.tsx | 8 +- .../pages/xray/outbounds/protocols/dns.tsx | 2 +- .../xray/outbounds/protocols/freedom.tsx | 12 +- .../xray/outbounds/protocols/wireguard.tsx | 8 +- .../xray/outbounds/transport/hysteria.tsx | 5 +- .../pages/xray/outbounds/transport/masque.tsx | 13 +- .../pages/xray/outbounds/transport/raw.tsx | 5 +- .../xray/outbounds/transport/sockopt.tsx | 8 +- .../xray/outbounds/useOutboundColumns.tsx | 19 +- .../src/pages/xray/overrides/NordModal.tsx | 4 +- .../src/pages/xray/overrides/PiaModal.tsx | 2 +- .../src/pages/xray/overrides/WarpModal.tsx | 17 +- .../src/pages/xray/routing/RouteTester.tsx | 2 +- .../src/pages/xray/routing/RoutingTab.tsx | 22 +- .../src/pages/xray/routing/RuleCardList.tsx | 11 +- .../src/pages/xray/routing/RuleFormModal.tsx | 68 ++--- .../pages/xray/routing/useRoutingColumns.tsx | 18 +- frontend/src/test/antd-locale.test.tsx | 30 +++ frontend/src/test/cat-tab-label.test.tsx | 29 +++ frontend/src/test/control-label-guard.test.ts | 62 +++++ .../src/test/date-time-picker-label.test.tsx | 52 ++++ frontend/src/test/document-lang.test.tsx | 34 +++ frontend/src/test/form-field-hint.test.tsx | 36 +++ frontend/src/test/icon-a11y-guard.test.ts | 61 +++++ frontend/src/test/language-scope.test.ts | 1 + frontend/src/test/node-list-a11y.test.tsx | 56 +++++ .../overview-update-tag-keyboard.test.tsx | 44 ++++ frontend/src/test/rule-form-hints.test.tsx | 35 +++ frontend/src/test/test-utils.tsx | 5 +- .../tools/oxlint/__fixtures__/icon-marked.tsx | 9 + .../oxlint/__fixtures__/icon-unmarked.tsx | 8 + .../oxlint/__fixtures__/icon.oxlintrc.json | 6 + .../oxlint/__fixtures__/label.oxlintrc.json | 6 + .../oxlint/__fixtures__/labelled-control.tsx | 43 ++++ .../oxlint/__fixtures__/unlabeled-control.tsx | 39 +++ frontend/tools/oxlint/control-label-guard.mjs | 88 +++++++ frontend/tools/oxlint/icon-a11y-guard.mjs | 32 +++ internal/web/translation/ar-EG.json | 9 +- internal/web/translation/en-US.json | 9 +- internal/web/translation/es-ES.json | 9 +- internal/web/translation/fa-IR.json | 9 +- internal/web/translation/id-ID.json | 9 +- internal/web/translation/ja-JP.json | 9 +- internal/web/translation/pt-BR.json | 9 +- internal/web/translation/ru-RU.json | 9 +- internal/web/translation/tr-TR.json | 9 +- internal/web/translation/uk-UA.json | 9 +- internal/web/translation/vi-VN.json | 9 +- internal/web/translation/zh-CN.json | 9 +- internal/web/translation/zh-TW.json | 9 +- 156 files changed, 2373 insertions(+), 863 deletions(-) create mode 100644 frontend/src/i18n/AntdRootConfig.tsx create mode 100644 frontend/src/test/antd-locale.test.tsx create mode 100644 frontend/src/test/cat-tab-label.test.tsx create mode 100644 frontend/src/test/control-label-guard.test.ts create mode 100644 frontend/src/test/date-time-picker-label.test.tsx create mode 100644 frontend/src/test/document-lang.test.tsx create mode 100644 frontend/src/test/form-field-hint.test.tsx create mode 100644 frontend/src/test/icon-a11y-guard.test.ts create mode 100644 frontend/src/test/node-list-a11y.test.tsx create mode 100644 frontend/src/test/overview-update-tag-keyboard.test.tsx create mode 100644 frontend/src/test/rule-form-hints.test.tsx create mode 100644 frontend/tools/oxlint/__fixtures__/icon-marked.tsx create mode 100644 frontend/tools/oxlint/__fixtures__/icon-unmarked.tsx create mode 100644 frontend/tools/oxlint/__fixtures__/icon.oxlintrc.json create mode 100644 frontend/tools/oxlint/__fixtures__/label.oxlintrc.json create mode 100644 frontend/tools/oxlint/__fixtures__/labelled-control.tsx create mode 100644 frontend/tools/oxlint/__fixtures__/unlabeled-control.tsx create mode 100644 frontend/tools/oxlint/control-label-guard.mjs create mode 100644 frontend/tools/oxlint/icon-a11y-guard.mjs diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json index 0782b6164..7e31aebd5 100644 --- a/frontend/.oxlintrc.json +++ b/frontend/.oxlintrc.json @@ -9,7 +9,9 @@ "jsx-a11y" ], "jsPlugins": [ - "./tools/oxlint/input-number-guard.mjs" + "./tools/oxlint/input-number-guard.mjs", + "./tools/oxlint/control-label-guard.mjs", + "./tools/oxlint/icon-a11y-guard.mjs" ], "categories": { "correctness": "error" @@ -48,7 +50,9 @@ "react-hooks/exhaustive-deps": "error", "jsx-a11y/no-autofocus": "off", "input-number/no-synthetic-clear": "off", - "jsx-a11y/prefer-tag-over-role": "off" + "jsx-a11y/prefer-tag-over-role": "off", + "control-label/has-name": "off", + "antd-icon/explicit-a11y": "off" }, "overrides": [ { @@ -67,6 +71,25 @@ "rules": { "input-number/no-synthetic-clear": "off" } + }, + { + "files": [ + "src/**/*.tsx" + ], + "rules": { + "control-label/has-name": "error", + "antd-icon/explicit-a11y": "error" + } + }, + { + "files": [ + "src/**/*.stories.tsx", + "src/test/**" + ], + "rules": { + "control-label/has-name": "off", + "antd-icon/explicit-a11y": "off" + } } ] } diff --git a/frontend/src/components/clients/ClientHwidList.tsx b/frontend/src/components/clients/ClientHwidList.tsx index 5b3302831..c1444ca34 100644 --- a/frontend/src/components/clients/ClientHwidList.tsx +++ b/frontend/src/components/clients/ClientHwidList.tsx @@ -44,7 +44,12 @@ export default function ClientHwidListModal({ zIndex={zIndex} onCancel={onClose} footer={[ - , } + icon={} loading={deletingId === entry.id} /> diff --git a/frontend/src/components/clients/ConfigBlock.tsx b/frontend/src/components/clients/ConfigBlock.tsx index f9b06d9c2..553980028 100644 --- a/frontend/src/components/clients/ConfigBlock.tsx +++ b/frontend/src/components/clients/ConfigBlock.tsx @@ -38,12 +38,17 @@ export default function ConfigBlock({ /* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
e.stopPropagation()}> - )} - diff --git a/frontend/src/components/form/DateTimePicker.tsx b/frontend/src/components/form/DateTimePicker.tsx index aa30aaf9b..7ef43328f 100644 --- a/frontend/src/components/form/DateTimePicker.tsx +++ b/frontend/src/components/form/DateTimePicker.tsx @@ -19,6 +19,9 @@ interface DateTimePickerProps { disabled?: boolean; allowClear?: boolean; maxDate?: Dayjs; + id?: string; + 'aria-label'?: string; + 'aria-describedby'?: string; } const LIGHT_THEME = { @@ -57,6 +60,9 @@ export default function DateTimePicker({ disabled = false, allowClear = true, maxDate, + id, + 'aria-label': ariaLabel, + 'aria-describedby': ariaDescribedBy, }: DateTimePickerProps) { const { t } = useTranslation(); const { datepicker } = useDatepicker(); @@ -97,7 +103,11 @@ export default function DateTimePicker({ useEffect(() => { if (datepicker !== 'jalalian') return; const input = jalaliRef.current?.querySelector('input'); - if (input) input.placeholder = placeholder; + if (!input) return; + input.placeholder = placeholder; + if (id) input.id = id; + if (ariaLabel) input.setAttribute('aria-label', ariaLabel); + if (ariaDescribedBy) input.setAttribute('aria-describedby', ariaDescribedBy); }); if (datepicker === 'jalalian') { @@ -137,7 +147,7 @@ export default function DateTimePicker({ setClearNonce((n) => n + 1); }} > - + )}
@@ -156,6 +166,9 @@ export default function DateTimePicker({ disabled={disabled} allowClear={allowClear} maxDate={maxDate} + id={id} + aria-label={ariaLabel} + aria-describedby={ariaDescribedBy} style={{ width: '100%' }} /> ); diff --git a/frontend/src/components/form/HeaderMapEditor.tsx b/frontend/src/components/form/HeaderMapEditor.tsx index 76c3c8607..8a30751b1 100644 --- a/frontend/src/components/form/HeaderMapEditor.tsx +++ b/frontend/src/components/form/HeaderMapEditor.tsx @@ -125,21 +125,23 @@ export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEdit setRow(idx, { name: e.target.value })} /> setRow(idx, { value: e.target.value })} /> diff --git a/frontend/src/components/form/RemarkTemplateField.tsx b/frontend/src/components/form/RemarkTemplateField.tsx index 546509309..894514e40 100644 --- a/frontend/src/components/form/RemarkTemplateField.tsx +++ b/frontend/src/components/form/RemarkTemplateField.tsx @@ -22,6 +22,9 @@ interface RemarkTemplateFieldProps { multiline?: boolean; rows?: number; metadataOnly?: boolean; + id?: string; + 'aria-label'?: string; + 'aria-labelledby'?: string; } /** @@ -37,6 +40,9 @@ export default function RemarkTemplateField({ multiline = false, rows, metadataOnly = false, + id, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, }: RemarkTemplateFieldProps) { const { t } = useTranslation(); const inputRef = useRef(null); @@ -76,7 +82,7 @@ export default function RemarkTemplateField({ )} diff --git a/frontend/src/components/ui/notifications/DiscordNotifications.tsx b/frontend/src/components/ui/notifications/DiscordNotifications.tsx index 3aca818cf..2b99902e3 100644 --- a/frontend/src/components/ui/notifications/DiscordNotifications.tsx +++ b/frontend/src/components/ui/notifications/DiscordNotifications.tsx @@ -13,7 +13,7 @@ import type { NotificationGroupConfig } from './types'; const GROUPS: NotificationGroupConfig[] = [ { - icon: , + icon: , title: 'eventGroupOutbound', events: [ { key: 'outbound.down', label: 'eventOutboundDown', settingKey: '' }, @@ -21,12 +21,12 @@ const GROUPS: NotificationGroupConfig[] = [ ], }, { - icon: , + icon: , title: 'eventGroupXray', events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }], }, { - icon: , + icon: , title: 'eventGroupNode', events: [ { key: 'node.down', label: 'eventNodeDown', settingKey: '' }, @@ -34,7 +34,7 @@ const GROUPS: NotificationGroupConfig[] = [ ], }, { - icon: , + icon: , title: 'eventGroupSystem', events: [ { @@ -72,7 +72,7 @@ const GROUPS: NotificationGroupConfig[] = [ ], }, { - icon: , + icon: , title: 'eventGroupSecurity', events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }], }, diff --git a/frontend/src/components/ui/notifications/EmailNotifications.tsx b/frontend/src/components/ui/notifications/EmailNotifications.tsx index 902e34e3c..abcdd1e46 100644 --- a/frontend/src/components/ui/notifications/EmailNotifications.tsx +++ b/frontend/src/components/ui/notifications/EmailNotifications.tsx @@ -13,7 +13,7 @@ import type { NotificationGroupConfig } from './types'; const GROUPS: NotificationGroupConfig[] = [ { - icon: , + icon: , title: 'eventGroupOutbound', events: [ { @@ -36,12 +36,12 @@ const GROUPS: NotificationGroupConfig[] = [ ], }, { - icon: , + icon: , title: 'eventGroupXray', events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }], }, { - icon: , + icon: , title: 'eventGroupNode', events: [ { key: 'node.down', label: 'eventNodeDown', settingKey: '' }, @@ -49,7 +49,7 @@ const GROUPS: NotificationGroupConfig[] = [ ], }, { - icon: , + icon: , title: 'eventGroupSystem', events: [ { @@ -87,7 +87,7 @@ const GROUPS: NotificationGroupConfig[] = [ ], }, { - icon: , + icon: , title: 'eventGroupSecurity', events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }], }, diff --git a/frontend/src/components/ui/notifications/TelegramNotifications.tsx b/frontend/src/components/ui/notifications/TelegramNotifications.tsx index 0af52831d..826ee08ed 100644 --- a/frontend/src/components/ui/notifications/TelegramNotifications.tsx +++ b/frontend/src/components/ui/notifications/TelegramNotifications.tsx @@ -13,7 +13,7 @@ import type { NotificationGroupConfig } from './types'; const GROUPS: NotificationGroupConfig[] = [ { - icon: , + icon: , title: 'eventGroupOutbound', events: [ { @@ -36,12 +36,12 @@ const GROUPS: NotificationGroupConfig[] = [ ], }, { - icon: , + icon: , title: 'eventGroupXray', events: [{ key: 'xray.crash', label: 'eventXrayCrash', settingKey: '' }], }, { - icon: , + icon: , title: 'eventGroupNode', events: [ { key: 'node.down', label: 'eventNodeDown', settingKey: '' }, @@ -49,7 +49,7 @@ const GROUPS: NotificationGroupConfig[] = [ ], }, { - icon: , + icon: , title: 'eventGroupSystem', events: [ { @@ -87,7 +87,7 @@ const GROUPS: NotificationGroupConfig[] = [ ], }, { - icon: , + icon: , title: 'eventGroupSecurity', events: [{ key: 'login.attempt', label: 'eventLoginAttempt', settingKey: '' }], }, diff --git a/frontend/src/entries/login.tsx b/frontend/src/entries/login.tsx index 7107bf210..7d7e3127b 100644 --- a/frontend/src/entries/login.tsx +++ b/frontend/src/entries/login.tsx @@ -5,6 +5,7 @@ import 'antd/dist/reset.css'; import { setupHttp } from '@/api/http-init'; import { applyDocumentTitle } from '@/utils'; import { readyI18n } from '@/i18n/react'; +import { AntdRootConfig } from '@/i18n/AntdRootConfig'; import { ThemeProvider } from '@/hooks/useTheme'; import { QueryProvider } from '@/api/QueryProvider'; import LoginPage from '@/pages/login/LoginPage'; @@ -21,11 +22,13 @@ readyI18n().then(() => { const root = document.getElementById('app'); if (root) { createRoot(root).render( - - - - - , + + + + + + + , ); } }); diff --git a/frontend/src/entries/subpage.tsx b/frontend/src/entries/subpage.tsx index 1c7be6b9f..d77b557aa 100644 --- a/frontend/src/entries/subpage.tsx +++ b/frontend/src/entries/subpage.tsx @@ -3,6 +3,7 @@ import { message } from 'antd'; import 'antd/dist/reset.css'; import { readyI18n } from '@/i18n/react'; +import { AntdRootConfig } from '@/i18n/AntdRootConfig'; import { ThemeProvider } from '@/hooks/useTheme'; import { QueryProvider } from '@/api/QueryProvider'; import SubPage from '@/pages/sub/SubPage'; @@ -16,11 +17,13 @@ readyI18n('subscription').then(() => { const root = document.getElementById('app'); if (root) { createRoot(root).render( - - - - - , + + + + + + + , ); } }); diff --git a/frontend/src/i18n/AntdRootConfig.tsx b/frontend/src/i18n/AntdRootConfig.tsx new file mode 100644 index 000000000..ea33cc4f9 --- /dev/null +++ b/frontend/src/i18n/AntdRootConfig.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react'; +import { QuestionCircleOutlined } from '@ant-design/icons'; +import { ConfigProvider } from 'antd'; +import type { ConfigProviderProps } from 'antd/es/config-provider'; +import type { Locale } from 'antd/es/locale'; + +const antdLocaleLoaders: Record Promise<{ default: Locale }>> = { + 'ar-EG': () => import('antd/es/locale/ar_EG'), + 'es-ES': () => import('antd/es/locale/es_ES'), + 'fa-IR': () => import('antd/es/locale/fa_IR'), + 'id-ID': () => import('antd/es/locale/id_ID'), + 'ja-JP': () => import('antd/es/locale/ja_JP'), + 'pt-BR': () => import('antd/es/locale/pt_BR'), + 'ru-RU': () => import('antd/es/locale/ru_RU'), + 'tr-TR': () => import('antd/es/locale/tr_TR'), + 'uk-UA': () => import('antd/es/locale/uk_UA'), + 'vi-VN': () => import('antd/es/locale/vi_VN'), + 'zh-CN': () => import('antd/es/locale/zh_CN'), + 'zh-TW': () => import('antd/es/locale/zh_TW'), +}; + +// Undefined keeps antd's built-in en_US. +let antdLocale: Locale | undefined; + +export async function loadAntdLocale(language: string): Promise { + antdLocale = (await antdLocaleLoaders[language]?.())?.default; +} + +// A Form.Item tooltip icon sits inside its