diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json index 7e31aebd5..ece14d759 100644 --- a/frontend/.oxlintrc.json +++ b/frontend/.oxlintrc.json @@ -11,7 +11,8 @@ "jsPlugins": [ "./tools/oxlint/input-number-guard.mjs", "./tools/oxlint/control-label-guard.mjs", - "./tools/oxlint/icon-a11y-guard.mjs" + "./tools/oxlint/icon-a11y-guard.mjs", + "./tools/oxlint/antd-a11y-guard.mjs" ], "categories": { "correctness": "error" @@ -52,7 +53,9 @@ "input-number/no-synthetic-clear": "off", "jsx-a11y/prefer-tag-over-role": "off", "control-label/has-name": "off", - "antd-icon/explicit-a11y": "off" + "antd-icon/explicit-a11y": "off", + "antd-a11y/spin-content-not-live": "off", + "antd-a11y/select-tag-remove": "off" }, "overrides": [ { @@ -78,7 +81,9 @@ ], "rules": { "control-label/has-name": "error", - "antd-icon/explicit-a11y": "error" + "antd-icon/explicit-a11y": "error", + "antd-a11y/spin-content-not-live": "error", + "antd-a11y/select-tag-remove": "error" } }, { @@ -88,7 +93,9 @@ ], "rules": { "control-label/has-name": "off", - "antd-icon/explicit-a11y": "off" + "antd-icon/explicit-a11y": "off", + "antd-a11y/spin-content-not-live": "off", + "antd-a11y/select-tag-remove": "off" } } ] diff --git a/frontend/src/components/form/CipherSuitesSelect.tsx b/frontend/src/components/form/CipherSuitesSelect.tsx index 4bace3d45..090288f0a 100644 --- a/frontend/src/components/form/CipherSuitesSelect.tsx +++ b/frontend/src/components/form/CipherSuitesSelect.tsx @@ -3,6 +3,7 @@ import type { SelectProps } from 'antd'; import { useTranslation } from 'react-i18next'; import { TLS_CIPHER_OPTION, TLS_CIPHER_PRESETS } from '@/schemas/primitives'; +import { selectTagRender } from './SelectTag'; const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v })); @@ -46,6 +47,7 @@ export default function CipherSuitesSelect({ tokenSeparators={[':', ',']} {...rest} mode="tags" + tagRender={selectTagRender} options={[ { label: t('pages.inbounds.form.presets'), options: PRESET_OPTIONS }, { label: t('pages.inbounds.form.cipherSuites'), options: CIPHER_SUITE_OPTIONS }, diff --git a/frontend/src/components/form/SelectAllClearButtons.stories.tsx b/frontend/src/components/form/SelectAllClearButtons.stories.tsx index d2f695e72..5bc7f16eb 100644 --- a/frontend/src/components/form/SelectAllClearButtons.stories.tsx +++ b/frontend/src/components/form/SelectAllClearButtons.stories.tsx @@ -4,6 +4,7 @@ import { expect } from 'storybook/test'; import { Select } from 'antd'; import SelectAllClearButtons from './SelectAllClearButtons'; +import { selectTagRender } from './SelectTag'; const inboundOptions: Array<{ value: number; label: string }> = [ { value: 1, label: 'VLESS Reality — 443' }, @@ -64,13 +65,13 @@ function InboundPickerDemo() { ); diff --git a/frontend/src/components/form/SelectTag.css b/frontend/src/components/form/SelectTag.css new file mode 100644 index 000000000..025fe3e5a --- /dev/null +++ b/frontend/src/components/form/SelectTag.css @@ -0,0 +1,14 @@ +.select-tag-remove { + border: 0; + background: none; + padding: 0; + font: inherit; + color: inherit; + cursor: pointer; +} + +.select-tag-remove:focus-visible { + outline: 2px solid var(--ant-color-primary); + outline-offset: 1px; + border-radius: 2px; +} diff --git a/frontend/src/components/form/SelectTag.stories.tsx b/frontend/src/components/form/SelectTag.stories.tsx new file mode 100644 index 000000000..d31ccb2aa --- /dev/null +++ b/frontend/src/components/form/SelectTag.stories.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, waitFor } from 'storybook/test'; +import { Select } from 'antd'; + +import { selectTagRender } from './SelectTag'; + +const meta = { + title: 'Form/SelectTag', + tags: ['autodocs'], + parameters: { + layout: 'padded', + docs: { + description: { + component: + 'Tag renderer for every `mode="tags"` / `mode="multiple"` Select. Ant Design draws the tag "x" as an aria-hidden, unfocusable span; `tagRender={selectTagRender}` swaps in a real button named "Remove ", so keyboard and screen-reader users can remove any tag, and focus moves to the next one.', + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +function SniDemo() { + const [value, setValue] = useState(['www.microsoft.com', 'www.apple.com', 'dl.google.com']); + return ( +
+ +
+ ); +} + +export const FreeTextTags: Story = { + render: () => , + play: async ({ canvas, userEvent }) => { + await userEvent.tab(); + await userEvent.tab(); + await expect(canvas.getByRole('button', { name: 'Remove www.apple.com' })).toHaveFocus(); + await userEvent.keyboard('{Enter}'); + await expect(canvas.queryByText('www.apple.com')).toBeNull(); + await waitFor(() => + expect(canvas.getByRole('button', { name: 'Remove dl.google.com' })).toHaveFocus(), + ); + await expect(canvas.getByRole('combobox', { name: 'SNI' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + }, +}; + +export const OptionLabels: Story = { + render: () => , +}; diff --git a/frontend/src/components/form/SelectTag.tsx b/frontend/src/components/form/SelectTag.tsx new file mode 100644 index 000000000..c6cff692e --- /dev/null +++ b/frontend/src/components/form/SelectTag.tsx @@ -0,0 +1,66 @@ +import { useContext } from 'react'; +import type { KeyboardEvent, MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ConfigProvider } from 'antd'; +import type { SelectProps } from 'antd'; +import { CloseOutlined } from '@ant-design/icons'; +import './SelectTag.css'; + +type SelectTagProps = Parameters>[0]; + +const REMOVE_CLASS = 'select-tag-remove'; + +// Stops rc-select from opening the dropdown or eating Enter/Space before the click fires. +function keepFromSelect(e: KeyboardEvent | MouseEvent) { + if ('key' in e && e.key === 'Tab') return; + e.stopPropagation(); + if (e.type === 'mousedown') e.preventDefault(); +} + +function SelectTag({ label, value, closable, onClose }: SelectTagProps) { + const { t } = useTranslation(); + const { getPrefixCls } = useContext(ConfigProvider.ConfigContext); + const selectCls = getPrefixCls('select'); + const itemCls = `${selectCls}-selection-item`; + const text = + typeof label === 'string' || typeof label === 'number' ? String(label) : String(value); + + const remove = (e: MouseEvent) => { + const button = e.currentTarget; + const root = button.closest(`.${selectCls}`); + const hadFocus = document.activeElement === button; + const index = root ? [...root.querySelectorAll(`.${REMOVE_CLASS}`)].indexOf(button) : -1; + onClose(e); + if (!root || !hadFocus) return; + // Keyboard users would otherwise land on once this tag unmounts. + requestAnimationFrame(() => { + const rest = root.querySelectorAll(`.${REMOVE_CLASS}`); + const next = rest[Math.min(index, rest.length - 1)] ?? root.querySelector('input'); + next?.focus(); + }); + }; + + return ( + + {label} + {closable && ( + + )} + + ); +} + +// Ant Design renders each tag's "x" as an aria-hidden, unfocusable span, so screen-reader +// and keyboard users cannot remove a tag; this swaps in a real, named button. +export function selectTagRender(props: SelectTagProps) { + return ; +} diff --git a/frontend/src/components/form/index.ts b/frontend/src/components/form/index.ts index 9e42c980e..7c2e6e5f0 100644 --- a/frontend/src/components/form/index.ts +++ b/frontend/src/components/form/index.ts @@ -7,3 +7,4 @@ export { default as CipherSuitesSelect } from './CipherSuitesSelect'; export { default as RemarkTemplateField } from './RemarkTemplateField'; export { default as RemarkVarPicker } from './RemarkVarPicker'; export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList'; +export { selectTagRender } from './SelectTag'; diff --git a/frontend/src/lib/xray/forms/SniffingFields.tsx b/frontend/src/lib/xray/forms/SniffingFields.tsx index 1cb930682..c4ec22ca5 100644 --- a/frontend/src/lib/xray/forms/SniffingFields.tsx +++ b/frontend/src/lib/xray/forms/SniffingFields.tsx @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'; import { Form, Select, Switch } from 'antd'; import type { FormInstance } from 'antd/es/form'; +import { selectTagRender } from '@/components/form/SelectTag'; import { SNIFFING_OPTION } from '@/schemas/primitives'; const DEST_OPTIONS = Object.entries(SNIFFING_OPTION).map(([label, value]) => ({ value, label })); @@ -36,6 +37,7 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel + + value.join(',')} rules={[{ required: true, message: 'pick at least one mode' }]} > - - ); diff --git a/frontend/src/lib/xray/forms/transport/XDriveForm.tsx b/frontend/src/lib/xray/forms/transport/XDriveForm.tsx index dc4020543..fd32c732f 100644 --- a/frontend/src/lib/xray/forms/transport/XDriveForm.tsx +++ b/frontend/src/lib/xray/forms/transport/XDriveForm.tsx @@ -4,6 +4,7 @@ import { Input, InputNumber, Select, Switch, Typography } from 'antd'; import { useFormContext, useWatch } from 'react-hook-form'; import { JsonEditor } from '@/components/form'; +import { selectTagRender } from '@/components/form/SelectTag'; import { FormField } from '@/components/form/rhf'; import { XDriveServiceSchema } from '@/schemas/protocols/stream/xdrive'; @@ -161,7 +162,7 @@ export default function XDriveForm() { tooltip={t('pages.inbounds.form.xdrive.secretsDesc')} name={[...BASE, 'secrets']} > - methods.setValue('inboundIds', v)} options={inboundOptions} diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 620ac9d29..e079ca354 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -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({ - +
{happLink ? ( <> @@ -563,7 +563,7 @@ function ClientQrModalContent({ centered onCancel={() => onOpenChange(false)} > - + {!client?.subId && !loading && (
{t('pages.clients.noSubId')} diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index c48b72316..c7e7754c5 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -1305,6 +1305,7 @@ export default function ClientsPage() { ) : ( - +
{filteredClients.length > 0 && (
diff --git a/frontend/src/pages/clients/FilterDrawer.tsx b/frontend/src/pages/clients/FilterDrawer.tsx index 9ee926adc..0c367610b 100644 --- a/frontend/src/pages/clients/FilterDrawer.tsx +++ b/frontend/src/pages/clients/FilterDrawer.tsx @@ -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({ 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({ patch('groups', v as string[])} options={groupOptions} placeholder={t('pages.clients.groupPlaceholder')} - maxTagCount="responsive" allowClear showSearch={{ optionFilterProp: 'label' }} listHeight={220} diff --git a/frontend/src/pages/groups/GroupsPage.tsx b/frontend/src/pages/groups/GroupsPage.tsx index 5663ba162..ba49175ab 100644 --- a/frontend/src/pages/groups/GroupsPage.tsx +++ b/frontend/src/pages/groups/GroupsPage.tsx @@ -496,7 +496,13 @@ export default function GroupsPage() { - + {!fetched ? (
) : fetchError ? ( diff --git a/frontend/src/pages/hosts/HostFormModal.tsx b/frontend/src/pages/hosts/HostFormModal.tsx index e29b84e54..9089d711c 100644 --- a/frontend/src/pages/hosts/HostFormModal.tsx +++ b/frontend/src/pages/hosts/HostFormModal.tsx @@ -28,7 +28,7 @@ import type { HostRecord } from '@/api/queries/useHostsQuery'; import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host'; import type { InboundOption } from '@/schemas/client'; import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives'; -import { CipherSuitesSelect } from '@/components/form'; +import { CipherSuitesSelect, selectTagRender } from '@/components/form'; import DynamicEchHelper from '@/components/form/DynamicEchHelper'; import { FormField, rhfZodValidate } from '@/components/form/rhf'; import { useNodesQuery } from '@/api/queries/useNodesQuery'; @@ -291,6 +291,7 @@ export default function HostFormModal({ > - - - ) : ( - + diff --git a/frontend/src/pages/inbounds/form/protocols/masque.tsx b/frontend/src/pages/inbounds/form/protocols/masque.tsx index a5d5a4e27..8cbee25e9 100644 --- a/frontend/src/pages/inbounds/form/protocols/masque.tsx +++ b/frontend/src/pages/inbounds/form/protocols/masque.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import { Input, InputNumber, Select } from 'antd'; +import { selectTagRender } from '@/components/form'; import { FormField } from '@/components/form/rhf'; import { MASQUE_DEFAULT_PATH } from '@/schemas/protocols/stream/masque'; @@ -14,7 +15,7 @@ export function MasqueSettingsFields() { name={['settings', 'address']} required > - diff --git a/frontend/src/pages/inbounds/form/protocols/tuic.tsx b/frontend/src/pages/inbounds/form/protocols/tuic.tsx index 295f6e545..4e7d27b34 100644 --- a/frontend/src/pages/inbounds/form/protocols/tuic.tsx +++ b/frontend/src/pages/inbounds/form/protocols/tuic.tsx @@ -16,6 +16,7 @@ import { import { CloudDownloadOutlined, SyncOutlined } from '@ant-design/icons'; import { useFormContext, useWatch } from 'react-hook-form'; +import { selectTagRender } from '@/components/form'; import { FormField } from '@/components/form/rhf'; import { HttpUtil } from '@/utils'; @@ -210,6 +211,7 @@ export default function TuicFields() { + ({ value: a, label: a }))} @@ -392,6 +393,7 @@ export default function TlsForm({ > {modalContextHolder} {messageContextHolder} - +
{modalContextHolder} - + +
{icon} - {label} + + {label} +
diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx index 2d80f5ff7..234ad85af 100644 --- a/frontend/src/pages/nodes/NodeFormModal.tsx +++ b/frontend/src/pages/nodes/NodeFormModal.tsx @@ -14,6 +14,7 @@ import { message, } from 'antd'; import { FormProvider, useForm, useWatch } from 'react-hook-form'; +import { selectTagRender } from '@/components/form'; import type { NodeRecord } from '@/api/queries/useNodesQuery'; import type { RemoteInboundOption } from '@/api/queries/useNodeMutations'; import type { Msg } from '@/utils'; @@ -431,6 +432,7 @@ export default function NodeFormModal({ >
- + {!apiTokens.length && !apiTokensLoading && ( setRuleValues('direct', 'ip', DEFAULT_DIRECT_RULES[1], v)} @@ -434,6 +435,7 @@ export default function SubscriptionFormatsTab({ > setRuleValues('block', 'domain', DEFAULT_BLOCK_RULES[0], v)} @@ -476,6 +479,7 @@ export default function SubscriptionFormatsTab({ ({ value: tg, label: tg }))} /> diff --git a/frontend/src/pages/xray/dns/DnsTab.tsx b/frontend/src/pages/xray/dns/DnsTab.tsx index f154c1273..b52193936 100644 --- a/frontend/src/pages/xray/dns/DnsTab.tsx +++ b/frontend/src/pages/xray/dns/DnsTab.tsx @@ -23,6 +23,7 @@ import { SettingOutlined, } from '@ant-design/icons'; +import { selectTagRender } from '@/components/form'; import { onNumber } from '@/utils/onNumber'; import { SettingListItem } from '@/components/ui'; import { useMediaQuery } from '@/hooks/useMediaQuery'; @@ -417,6 +418,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab /> diff --git a/frontend/src/pages/xray/outbounds/protocols/masque.tsx b/frontend/src/pages/xray/outbounds/protocols/masque.tsx index cf7edc9ac..3d5f56526 100644 --- a/frontend/src/pages/xray/outbounds/protocols/masque.tsx +++ b/frontend/src/pages/xray/outbounds/protocols/masque.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import { Select } from 'antd'; +import { selectTagRender } from '@/components/form'; import { FormField } from '@/components/form/rhf'; export default function MasqueFields() { @@ -9,6 +10,7 @@ export default function MasqueFields() { + diff --git a/frontend/src/pages/xray/outbounds/transport/masque.tsx b/frontend/src/pages/xray/outbounds/transport/masque.tsx index 67b2d5326..e0416fb75 100644 --- a/frontend/src/pages/xray/outbounds/transport/masque.tsx +++ b/frontend/src/pages/xray/outbounds/transport/masque.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Form, Input, Select, Switch } from 'antd'; import { useFormContext, useWatch } from 'react-hook-form'; -import { HeaderMapEditor } from '@/components/form'; +import { HeaderMapEditor, selectTagRender } from '@/components/form'; import { FormField } from '@/components/form/rhf'; import { MASQUE_DEFAULT_PATH } from '@/schemas/protocols/stream/masque'; @@ -79,6 +79,7 @@ export default function MasqueForm() { > ({ value: p, label: p }))} /> + { + setValue(next); + onValue?.(next); + }} + /> + ); +} + +// Ant Design's own tag "x" is an aria-hidden span with no tabIndex, so a screen-reader +// user could only Backspace the last SNI away, never one from the middle. +describe('selectTagRender', () => { + it('gives every tag a named, focusable remove button', () => { + render(); + const names = screen.getAllByRole('button').map((b) => b.getAttribute('aria-label')); + expect(names).toEqual([ + 'Remove www.microsoft.com', + 'Remove www.apple.com', + 'Remove dl.google.com', + ]); + }); + + it('removes a middle tag and moves focus to the next remove button', async () => { + let latest: string[] = []; + render( (latest = v)} />); + const middle = screen.getByRole('button', { name: 'Remove www.apple.com' }); + middle.focus(); + fireEvent.click(middle); + expect(latest).toEqual(['www.microsoft.com', 'dl.google.com']); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('button', { name: 'Remove dl.google.com' }), + ), + ); + }); + + it('falls back to the search input after removing the only tag', async () => { + function Single() { + const [value, setValue] = useState(['example.com']); + return ( + ; +export const HiddenTags = () => ; +export const Tags = () => ; +export const NotAntd = () => x; diff --git a/frontend/tools/oxlint/__fixtures__/antd-a11y.oxlintrc.json b/frontend/tools/oxlint/__fixtures__/antd-a11y.oxlintrc.json new file mode 100644 index 000000000..a1e6da053 --- /dev/null +++ b/frontend/tools/oxlint/__fixtures__/antd-a11y.oxlintrc.json @@ -0,0 +1,9 @@ +{ + "plugins": [], + "categories": {}, + "jsPlugins": ["../antd-a11y-guard.mjs"], + "rules": { + "antd-a11y/spin-content-not-live": "error", + "antd-a11y/select-tag-remove": "error" + } +} diff --git a/frontend/tools/oxlint/antd-a11y-guard.mjs b/frontend/tools/oxlint/antd-a11y-guard.mjs new file mode 100644 index 000000000..66bb87ac5 --- /dev/null +++ b/frontend/tools/oxlint/antd-a11y-guard.mjs @@ -0,0 +1,74 @@ +// Ant Design Spin wraps its content in aria-live="polite", so every live stat inside is +// read aloud; Select tags hide their remove "x" from screen readers and keyboards. +const SPIN_LIVE = + 'Spin wraps its content in aria-live="polite", so every update inside is read aloud; pass aria-live="off".'; +const NO_TAG_RENDER = + 'Select tag remove buttons are aria-hidden and unfocusable; pass tagRender={selectTagRender}.'; +const HIDDEN_TAGS = + 'maxTagCount hides selected values behind "+N", out of reach of screen readers; show every tag.'; + +function attrs(node) { + const map = new Map(); + let spread = false; + for (const attr of node.attributes) { + if (attr.type === 'JSXAttribute') map.set(attr.name.name, attr); + else spread = true; + } + return { map, spread }; +} + +function hasContent(element) { + return element.children.some((c) => c.type !== 'JSXText' || c.value.trim() !== ''); +} + +export default { + meta: { name: 'antd-a11y' }, + rules: { + 'spin-content-not-live': { + create(context) { + const spins = new Set(); + return { + ImportDeclaration(node) { + if (node.source.value !== 'antd') return; + for (const spec of node.specifiers) { + if (spec.type === 'ImportSpecifier' && spec.imported.name === 'Spin') { + spins.add(spec.local.name); + } + } + }, + JSXElement(node) { + const opening = node.openingElement; + if (opening.name.type !== 'JSXIdentifier' || !spins.has(opening.name.name)) return; + if (!hasContent(node)) return; + const { map, spread } = attrs(opening); + if (!spread && !map.has('aria-live')) + context.report({ node: opening, message: SPIN_LIVE }); + }, + }; + }, + }, + 'select-tag-remove': { + create(context) { + const selects = new Set(); + return { + ImportDeclaration(node) { + if (node.source.value !== 'antd') return; + for (const spec of node.specifiers) { + if (spec.type === 'ImportSpecifier' && spec.imported.name === 'Select') { + selects.add(spec.local.name); + } + } + }, + JSXOpeningElement(node) { + if (node.name.type !== 'JSXIdentifier' || !selects.has(node.name.name)) return; + const { map, spread } = attrs(node); + if (!map.has('mode')) return; + if (map.has('maxTagCount')) context.report({ node, message: HIDDEN_TAGS }); + else if (!spread && !map.has('tagRender')) + context.report({ node, message: NO_TAG_RENDER }); + }, + }; + }, + }, + }, +}; diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 47f90a788..aaf4a08b0 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -10,6 +10,7 @@ "create": "إنشاء", "add": "إضافة", "remove": "إزالة", + "removeItem": "إزالة {item}", "update": "تحديث", "copy": "نسخ", "copied": "اتنسخ", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 27f14cf38..ef6394054 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -10,6 +10,7 @@ "create": "Create", "add": "Add", "remove": "Remove", + "removeItem": "Remove {item}", "update": "Update", "copy": "Copy", "copied": "Copied", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index a933ce3ae..71b9b5bcb 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -10,6 +10,7 @@ "create": "Crear", "add": "Añadir", "remove": "Quitar", + "removeItem": "Quitar {item}", "update": "Actualizar", "copy": "Copiar", "copied": "Copiado", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index a04d621d7..b2a066f51 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -10,6 +10,7 @@ "create": "ایجاد", "add": "افزودن", "remove": "حذف", + "removeItem": "حذف {item}", "update": "به‌روزرسانی", "copy": "کپی", "copied": "کپی شد", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 45d730e1d..98749c161 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -10,6 +10,7 @@ "create": "Buat", "add": "Tambah", "remove": "Hapus", + "removeItem": "Hapus {item}", "update": "Perbarui", "copy": "Salin", "copied": "Tersalin", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 28f018b7e..1d2892a5a 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -10,6 +10,7 @@ "create": "作成", "add": "追加", "remove": "削除", + "removeItem": "{item} を削除", "update": "更新", "copy": "コピー", "copied": "コピー済み", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 58296ab40..7aeb1b267 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -10,6 +10,7 @@ "create": "Criar", "add": "Adicionar", "remove": "Remover", + "removeItem": "Remover {item}", "update": "Atualizar", "copy": "Copiar", "copied": "Copiado", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index c0ff714ca..ad655026b 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -10,6 +10,7 @@ "create": "Создать", "add": "Добавить", "remove": "Удалить", + "removeItem": "Удалить {item}", "update": "Обновить", "copy": "Копировать", "copied": "Скопировано", diff --git a/internal/web/translation/sk-SK.json b/internal/web/translation/sk-SK.json index 19b9a472a..8171c6c3e 100644 --- a/internal/web/translation/sk-SK.json +++ b/internal/web/translation/sk-SK.json @@ -10,6 +10,7 @@ "create": "Vytvoriť", "add": "Pridať", "remove": "Odstrániť", + "removeItem": "Odstrániť {item}", "update": "Aktualizovať", "copy": "Kopírovať", "copied": "Skopírované", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 56500bc01..0a15c420c 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -10,6 +10,7 @@ "create": "Oluştur", "add": "Ekle", "remove": "Kaldır", + "removeItem": "{item} öğesini kaldır", "update": "Güncelle", "copy": "Kopyala", "copied": "Kopyalandı", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 15c1e011b..3f05b7be7 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -10,6 +10,7 @@ "create": "Створити", "add": "Додати", "remove": "Видалити", + "removeItem": "Видалити {item}", "update": "Оновити", "copy": "Копіювати", "copied": "Скопійовано", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 18aa3db53..bda4c03b1 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -10,6 +10,7 @@ "create": "Tạo", "add": "Thêm", "remove": "Xóa", + "removeItem": "Xóa {item}", "update": "Cập nhật", "copy": "Sao chép", "copied": "Đã sao chép", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 9d760b324..42ad46c23 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -10,6 +10,7 @@ "create": "创建", "add": "添加", "remove": "移除", + "removeItem": "移除 {item}", "update": "更新", "copy": "复制", "copied": "已复制", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 71e993a22..6cbe1a117 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -10,6 +10,7 @@ "create": "建立", "add": "新增", "remove": "移除", + "removeItem": "移除 {item}", "update": "更新", "copy": "複製", "copied": "已複製",