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 ? : }
- {label ? {label} : null}
-
+ <>
+
+ {copied ? : }
+ {label ? {label} : null}
+
+
+ {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: '对此记录不可用',