diff --git a/.changeset/28186-result-dialog-copy-button-names.md b/.changeset/28186-result-dialog-copy-button-names.md new file mode 100644 index 00000000000..7c21fffbcee --- /dev/null +++ b/.changeset/28186-result-dialog-copy-button-names.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(app-shell): every copy button in an action's result dialog has an accessible name, and the copied state is announced + +Clause-②: no (a renderer accessibility fix; no schema, accept set, error code or result field changes) +Metadata-protocol: none + +The result dialog shows a value the user must copy now, such as an invite link, a new API key or a 2FA secret. Its per-value copy buttons were icon-only with no name, so a screen-reader or voice-control user reached a bare "button" and could not ask for it by name. Nothing announced that the copy worked either. + +- **Names.** An icon-only copy button is named after its field: "Copy Email" when the field has a label, "Copy value" when it has none. Each code of a code list is named by its position ("Copy Backup codes, item 2", or "Copy item 2" when unlabelled). "Copy all" keeps its visible text as its name. +- **Copied state.** A polite live region next to each copy button, mounted empty, reads "Copied to clipboard" when the value reaches the clipboard. +- **Translations.** The new `actions.resultDialog` keys `copyField`, `copyValue`, `copyFieldItem`, `copyItem` and `copied` are in all ten packs. diff --git a/ui/packages/app-shell/src/views/ActionResultDialog.tsx b/ui/packages/app-shell/src/views/ActionResultDialog.tsx index ba933646ed4..ea7539776ac 100644 --- a/ui/packages/app-shell/src/views/ActionResultDialog.tsx +++ b/ui/packages/app-shell/src/views/ActionResultDialog.tsx @@ -162,11 +162,20 @@ function ResultField({ value: unknown; defaultFormat?: FieldFormat; }) { - const { language } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); const format: FieldFormat = field.format ?? defaultFormat ?? 'json'; // Same `I18nLabel` contract as the dialog's own three label members, same // resolution — a field label is the fourth member the block declares as one. const label = resolveInlineI18nLabel(field.label, language); + // The accessible name of this field's icon-only copy buttons: the field's + // label when it has one, so two fields' buttons are told apart by name. + const copyName = label + ? t('actions.resultDialog.copyField', { label }) + : t('actions.resultDialog.copyValue'); + const copyItemName = (index: number) => + label + ? t('actions.resultDialog.copyFieldItem', { label, index }) + : t('actions.resultDialog.copyItem', { index }); // Type-guard each renderer rather than crash if the server returned an // unexpected shape (e.g. format=qrcode but value is undefined). We @@ -183,11 +192,13 @@ function ResultField({ {label ? (
{label}
) : null} - {safeFormat === 'qrcode' ? : null} - {safeFormat === 'code-list' ? : null} - {safeFormat === 'secret' ? : null} - {safeFormat === 'text' ? : null} - {safeFormat === 'json' ? : null} + {safeFormat === 'qrcode' ? : null} + {safeFormat === 'code-list' ? ( + + ) : null} + {safeFormat === 'secret' ? : null} + {safeFormat === 'text' ? : null} + {safeFormat === 'json' ? : null} ); } @@ -212,17 +223,33 @@ function useCopy(value: string | undefined) { return { copy, copied }; } -function CopyButton({ value, label }: { value: string | undefined; label?: string }) { +/** + * A copy button. With a visible `label` that text is its accessible name; + * an icon-only button takes `name` as its `aria-label` instead. The copied + * state is announced through a polite live region, mounted empty before it + * fills — a region inserted together with its text is not reliably read. + */ +function CopyButton({ + value, + label, + name, +}: { value: string | undefined } & ({ label: string; name?: never } | { label?: never; name: string })) { + const { t } = useObjectTranslation(); const { copy, copied } = useCopy(value); return ( - + <> + + + {copied ? t('actions.resultDialog.copied') : ''} + + ); } -function QrcodeBlock({ value }: { value: string }) { +function QrcodeBlock({ value, copyName }: { value: string; copyName: string }) { // QR codes for 2FA carry the shared secret. Render entirely client-side // (toCanvas writes into a local ) — never round-trip to a third // party. Falls back to plaintext if rendering fails (e.g. value too long @@ -247,13 +274,19 @@ function QrcodeBlock({ value }: { value: string }) { {value} - + ); } -function CodeListBlock({ value }: { value: string[] }) { +function CodeListBlock({ + value, + copyItemName, +}: { + value: string[]; + copyItemName: (index: number) => string; +}) { const { t } = useObjectTranslation(); const joined = value.join('\n'); return ( @@ -265,7 +298,7 @@ function CodeListBlock({ value }: { value: string[] }) { {code} - + ))} @@ -277,7 +310,7 @@ function CodeListBlock({ value }: { value: string[] }) { ); } -function SecretBlock({ value }: { value: string }) { +function SecretBlock({ value, copyName }: { value: string; copyName: string }) { const { t } = useObjectTranslation(); const [revealed, setRevealed] = useState(false); return ( @@ -294,23 +327,23 @@ function SecretBlock({ value }: { value: string }) { > {revealed ? : } - + ); } -function TextBlock({ value }: { value: string }) { +function TextBlock({ value, copyName }: { value: string; copyName: string }) { return (
{value} - +
); } -function JsonBlock({ value }: { value: unknown }) { +function JsonBlock({ value, copyName }: { value: unknown; copyName: string }) { const text = useMemo(() => { try { return JSON.stringify(value, null, 2); @@ -324,7 +357,7 @@ function JsonBlock({ value }: { value: unknown }) { {text}
- +
); diff --git a/ui/packages/app-shell/src/views/__tests__/ActionResultDialog.test.tsx b/ui/packages/app-shell/src/views/__tests__/ActionResultDialog.test.tsx index 96ce54bc636..93b1c47df15 100644 --- a/ui/packages/app-shell/src/views/__tests__/ActionResultDialog.test.tsx +++ b/ui/packages/app-shell/src/views/__tests__/ActionResultDialog.test.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, act, within } from '@testing-library/react'; import type { ResultDialogSpec } from '@object-ui/core'; import { ActionResultDialog } from '../ActionResultDialog'; @@ -17,7 +17,15 @@ vi.mock('@object-ui/i18n', async (importOriginal) => ({ // `language` is the source every other `resolveI18nLabel` caller in this // package threads (`UnifiedSidebar`, `resolveActionParams`, and // metadata-admin's `useMetadataLocale`), so the stub carries it too. - useObjectTranslation: () => ({ t: (key: string) => key, language: i18nState.language }), + // Interpolation params are echoed after the key, so a name built from a + // field label shows which label reached it. + useObjectTranslation: () => ({ + t: (key: string, params?: Record) => + params + ? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${String(v)}`).join(', ')})` + : key, + language: i18nState.language, + }), })); // jsdom has no canvas; the qrcode path is not under test. vi.mock('qrcode', () => ({ toCanvas: vi.fn() })); @@ -131,3 +139,103 @@ describe('ActionResultDialog — an inline per-locale map resolves against the d expect(screen.getByText('actions.resultDialog.defaultTitle')).toBeInTheDocument(); }); }); + +/** + * objectstack#28186 — the copy buttons are icon-only, so each one carries an + * accessible name built from its field's label, and the copied state is + * announced through a polite live region. The names are read the way a + * screen-reader or voice-control user reaches the buttons: by role and name. + */ +describe('ActionResultDialog — every copy button has an accessible name', () => { + it('names each field\'s copy button after the field label', () => { + i18nState.language = 'en'; + open( + { + title: 'User Created', + fields: [ + { path: 'user.email', label: 'Email', format: 'text' }, + { path: 'temporaryPassword', label: 'Temporary Password', format: 'secret' }, + { path: 'link', label: 'Invite link', format: 'qrcode' }, + { path: 'meta', label: 'Response', format: 'json' }, + ], + }, + { user: { email: 'a@example.com' }, temporaryPassword: 'p@ss', link: 'https://x.test/i/1', meta: { id: 1 } }, + ); + + for (const label of ['Email', 'Temporary Password', 'Invite link', 'Response']) { + expect( + screen.getByRole('button', { name: `actions.resultDialog.copyField(label=${label})` }), + ).toBeInTheDocument(); + } + }); + + it('names an unlabelled value\'s copy button generically', () => { + i18nState.language = 'en'; + open({ title: 'Done' }, { anything: 1 }); + + expect(screen.getByRole('button', { name: 'actions.resultDialog.copyValue' })).toBeInTheDocument(); + }); + + it('names each code of a code list by its position, and keeps the visible name of Copy all', () => { + i18nState.language = 'en'; + open( + { title: 'Backup codes', fields: [{ path: 'codes', label: 'Backup codes', format: 'code-list' }] }, + { codes: ['aaaa-1111', 'bbbb-2222'] }, + ); + + expect( + screen.getByRole('button', { name: 'actions.resultDialog.copyFieldItem(label=Backup codes, index=1)' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'actions.resultDialog.copyFieldItem(label=Backup codes, index=2)' }), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'actions.resultDialog.copyAll' })).toBeInTheDocument(); + }); + + it('names the codes of an unlabelled code list by position alone', () => { + i18nState.language = 'en'; + open({ title: 'Codes', fields: [{ path: 'codes', format: 'code-list' }] }, { codes: ['a', 'b'] }); + + expect(screen.getByRole('button', { name: 'actions.resultDialog.copyItem(index=2)' })).toBeInTheDocument(); + }); + + it('leaves no button in the dialog without a name', () => { + i18nState.language = 'en'; + open( + { + title: 'Everything', + fields: [ + { path: 'a', label: 'A', format: 'text' }, + { path: 'b', format: 'secret' }, + { path: 'c', format: 'code-list' }, + { path: 'd', format: 'json' }, + ], + }, + { a: 'x', b: 'y', c: ['z'], d: { k: 1 } }, + ); + + const dialog = screen.getByRole('dialog'); + const all = within(dialog).getAllByRole('button'); + const named = within(dialog).getAllByRole('button', { name: /\S/ }); + expect(named).toHaveLength(all.length); + }); + + it('announces the copied state in a live region that is mounted before it fills', async () => { + i18nState.language = 'en'; + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + open({ title: 'Secret', fields: [{ path: 'secret', label: 'Secret', format: 'text' }] }, { secret: 's3cr3t' }); + + const region = screen.getByRole('status'); + expect(region).toHaveTextContent(''); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'actions.resultDialog.copyField(label=Secret)' })); + }); + + expect(writeText).toHaveBeenCalledWith('s3cr3t'); + // The same node, so assistive technology already watching it reads the change. + expect(screen.getByRole('status')).toBe(region); + expect(region).toHaveTextContent('actions.resultDialog.copied'); + }); +}); diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index c19bf174d54..9bdbabda477 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -150,6 +150,13 @@ const ar = { defaultTitle: 'احفظ هذه القيمة الآن', acknowledge: 'لقد حفظتها', copyAll: 'نسخ الكل', + // Accessible names of the icon-only copy buttons, and the polite + // announcement once a value reaches the clipboard. + copyField: 'نسخ {{label}}', + copyValue: 'نسخ القيمة', + copyFieldItem: 'نسخ {{label}}، العنصر {{index}}', + copyItem: 'نسخ العنصر {{index}}', + copied: 'تم النسخ إلى الحافظة', }, notAvailableHere: '"{{action}}" غير متاح في الصفحة الحالية.', notAvailableForRecord: 'غير متاح لهذا السجل', diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index c766450a3a9..d70554eb849 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -136,6 +136,13 @@ const de = { defaultTitle: 'Diesen Wert jetzt speichern', acknowledge: 'Ich habe ihn gespeichert', copyAll: 'Alle kopieren', + // Accessible names of the icon-only copy buttons, and the polite + // announcement once a value reaches the clipboard. + copyField: '{{label}} kopieren', + copyValue: 'Wert kopieren', + copyFieldItem: '{{label}}, Eintrag {{index}} kopieren', + copyItem: 'Eintrag {{index}} kopieren', + copied: 'In die Zwischenablage kopiert', }, notAvailableHere: '„{{action}}“ ist auf der aktuellen Seite nicht verfügbar.', notAvailableForRecord: 'Für diesen Datensatz nicht verfügbar', diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 4fcbc36eed4..4bb942c9fd5 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -153,6 +153,13 @@ const en = { defaultTitle: 'Save this value now', acknowledge: 'I have saved this', copyAll: 'Copy all', + // Accessible names of the icon-only copy buttons, and the polite + // announcement once a value reaches the clipboard. + copyField: 'Copy {{label}}', + copyValue: 'Copy value', + copyFieldItem: 'Copy {{label}}, item {{index}}', + copyItem: 'Copy item {{index}}', + copied: 'Copied to clipboard', }, // The refusal notice for an action whose `autoTrigger` its own declared // `visible` gate outranks (objectui#4191) — the deep link or host asked diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index c0cce3b4689..4222ba287ff 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -138,6 +138,13 @@ const es = { defaultTitle: 'Guarda este valor ahora', acknowledge: 'Lo he guardado', copyAll: 'Copiar todo', + // Accessible names of the icon-only copy buttons, and the polite + // announcement once a value reaches the clipboard. + copyField: 'Copiar {{label}}', + copyValue: 'Copiar valor', + copyFieldItem: 'Copiar {{label}}, elemento {{index}}', + copyItem: 'Copiar elemento {{index}}', + copied: 'Copiado al portapapeles', }, notAvailableHere: '«{{action}}» no está disponible en la página actual.', notAvailableForRecord: 'No disponible para este registro', diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index e91a6456300..d8b03881cdf 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -138,6 +138,13 @@ const fr = { defaultTitle: 'Enregistrez cette valeur maintenant', acknowledge: 'Je l\'ai enregistrée', copyAll: 'Tout copier', + // Accessible names of the icon-only copy buttons, and the polite + // announcement once a value reaches the clipboard. + copyField: 'Copier {{label}}', + copyValue: 'Copier la valeur', + copyFieldItem: 'Copier {{label}}, élément {{index}}', + copyItem: 'Copier l\'élément {{index}}', + copied: 'Copié dans le presse-papiers', }, notAvailableHere: '« {{action}} » n\'est pas disponible sur la page actuelle.', notAvailableForRecord: 'Non disponible pour cet enregistrement', diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 95de633f02e..b7adb84de03 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -136,6 +136,13 @@ const ja = { defaultTitle: 'この値を今すぐ保存してください', acknowledge: '保存しました', copyAll: 'すべてコピー', + // Accessible names of the icon-only copy buttons, and the polite + // announcement once a value reaches the clipboard. + copyField: '{{label}}をコピー', + copyValue: '値をコピー', + copyFieldItem: '{{label}}の項目 {{index}} をコピー', + copyItem: '項目 {{index}} をコピー', + copied: 'クリップボードにコピーしました', }, notAvailableHere: '「{{action}}」は現在のページでは利用できません。', notAvailableForRecord: 'このレコードでは利用できません', diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 8e9c410743d..6ba05315839 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -136,6 +136,13 @@ const ko = { defaultTitle: '이 값을 지금 저장하세요', acknowledge: '저장했습니다', copyAll: '모두 복사', + // Accessible names of the icon-only copy buttons, and the polite + // announcement once a value reaches the clipboard. + copyField: '{{label}} 복사', + copyValue: '값 복사', + copyFieldItem: '{{label}} 항목 {{index}} 복사', + copyItem: '항목 {{index}} 복사', + copied: '클립보드에 복사되었습니다', }, notAvailableHere: '"{{action}}"은(는) 현재 페이지에서 사용할 수 없습니다.', notAvailableForRecord: '이 레코드에서는 사용할 수 없습니다', diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 21481e876ec..299910f8c30 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -138,6 +138,13 @@ const pt = { defaultTitle: 'Guarde este valor agora', acknowledge: 'Eu o salvei', copyAll: 'Copiar tudo', + // Accessible names of the icon-only copy buttons, and the polite + // announcement once a value reaches the clipboard. + copyField: 'Copiar {{label}}', + copyValue: 'Copiar valor', + copyFieldItem: 'Copiar {{label}}, item {{index}}', + copyItem: 'Copiar item {{index}}', + copied: 'Copiado para a área de transferência', }, notAvailableHere: '"{{action}}" não está disponível na página atual.', notAvailableForRecord: 'Não disponível para este registro', diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index d1781608d9a..52813c287cd 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -143,6 +143,13 @@ const ru = { defaultTitle: 'Сохраните это значение сейчас', acknowledge: 'Я сохранил(а) это', copyAll: 'Копировать всё', + // Accessible names of the icon-only copy buttons, and the polite + // announcement once a value reaches the clipboard. + copyField: 'Копировать: {{label}}', + copyValue: 'Копировать значение', + copyFieldItem: 'Копировать: {{label}}, элемент {{index}}', + copyItem: 'Копировать элемент {{index}}', + copied: 'Скопировано в буфер обмена', }, notAvailableHere: '«{{action}}» недоступно на текущей странице.', notAvailableForRecord: 'Недоступно для этой записи', diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 1b9980e210d..d5f9384dc03 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -141,6 +141,13 @@ const zh = { defaultTitle: '请立即保存此值', acknowledge: '我已保存', copyAll: '全部复制', + // Accessible names of the icon-only copy buttons, and the polite + // announcement once a value reaches the clipboard. + copyField: '复制{{label}}', + copyValue: '复制值', + copyFieldItem: '复制{{label}}第 {{index}} 项', + copyItem: '复制第 {{index}} 项', + copied: '已复制到剪贴板', }, notAvailableHere: '「{{action}}」在当前页面不可用。', notAvailableForRecord: '对此记录不可用',