Skip to content

Commit 1b5fbd4

Browse files
committed
fix(app-shell): name the result dialog's copy buttons and announce the copied state
Each icon-only copy button carries an accessible name built from its field's label (or its position in a code list), from new actions.resultDialog keys in all ten packs. A polite live region, mounted empty, announces the copied state. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9qibvSA9x5ZD2RncR2GRx
1 parent f40b777 commit 1b5fbd4

12 files changed

Lines changed: 234 additions & 23 deletions

File tree

‎ui/packages/app-shell/src/views/ActionResultDialog.tsx‎

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,20 @@ function ResultField({
162162
value: unknown;
163163
defaultFormat?: FieldFormat;
164164
}) {
165-
const { language } = useObjectTranslation();
165+
const { t, language } = useObjectTranslation();
166166
const format: FieldFormat = field.format ?? defaultFormat ?? 'json';
167167
// Same `I18nLabel` contract as the dialog's own three label members, same
168168
// resolution — a field label is the fourth member the block declares as one.
169169
const label = resolveInlineI18nLabel(field.label, language);
170+
// The accessible name of this field's icon-only copy buttons: the field's
171+
// label when it has one, so two fields' buttons are told apart by name.
172+
const copyName = label
173+
? t('actions.resultDialog.copyField', { label })
174+
: t('actions.resultDialog.copyValue');
175+
const copyItemName = (index: number) =>
176+
label
177+
? t('actions.resultDialog.copyFieldItem', { label, index })
178+
: t('actions.resultDialog.copyItem', { index });
170179

171180
// Type-guard each renderer rather than crash if the server returned an
172181
// unexpected shape (e.g. format=qrcode but value is undefined). We
@@ -183,11 +192,13 @@ function ResultField({
183192
{label ? (
184193
<div className="text-sm font-medium text-foreground/80">{label}</div>
185194
) : null}
186-
{safeFormat === 'qrcode' ? <QrcodeBlock value={value as string} /> : null}
187-
{safeFormat === 'code-list' ? <CodeListBlock value={value as string[]} /> : null}
188-
{safeFormat === 'secret' ? <SecretBlock value={value as string} /> : null}
189-
{safeFormat === 'text' ? <TextBlock value={value as string} /> : null}
190-
{safeFormat === 'json' ? <JsonBlock value={value} /> : null}
195+
{safeFormat === 'qrcode' ? <QrcodeBlock value={value as string} copyName={copyName} /> : null}
196+
{safeFormat === 'code-list' ? (
197+
<CodeListBlock value={value as string[]} copyItemName={copyItemName} />
198+
) : null}
199+
{safeFormat === 'secret' ? <SecretBlock value={value as string} copyName={copyName} /> : null}
200+
{safeFormat === 'text' ? <TextBlock value={value as string} copyName={copyName} /> : null}
201+
{safeFormat === 'json' ? <JsonBlock value={value} copyName={copyName} /> : null}
191202
</div>
192203
);
193204
}
@@ -212,17 +223,33 @@ function useCopy(value: string | undefined) {
212223
return { copy, copied };
213224
}
214225

215-
function CopyButton({ value, label }: { value: string | undefined; label?: string }) {
226+
/**
227+
* A copy button. With a visible `label` that text is its accessible name;
228+
* an icon-only button takes `name` as its `aria-label` instead. The copied
229+
* state is announced through a polite live region, mounted empty before it
230+
* fills — a region inserted together with its text is not reliably read.
231+
*/
232+
function CopyButton({
233+
value,
234+
label,
235+
name,
236+
}: { value: string | undefined } & ({ label: string; name?: never } | { label?: never; name: string })) {
237+
const { t } = useObjectTranslation();
216238
const { copy, copied } = useCopy(value);
217239
return (
218-
<Button type="button" variant="outline" size="sm" onClick={copy}>
219-
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
220-
{label ? <span className="ml-2">{label}</span> : null}
221-
</Button>
240+
<>
241+
<Button type="button" variant="outline" size="sm" onClick={copy} aria-label={label ? undefined : name}>
242+
{copied ? <Check className="w-4 h-4" aria-hidden="true" /> : <Copy className="w-4 h-4" aria-hidden="true" />}
243+
{label ? <span className="ml-2">{label}</span> : null}
244+
</Button>
245+
<span role="status" className="sr-only">
246+
{copied ? t('actions.resultDialog.copied') : ''}
247+
</span>
248+
</>
222249
);
223250
}
224251

225-
function QrcodeBlock({ value }: { value: string }) {
252+
function QrcodeBlock({ value, copyName }: { value: string; copyName: string }) {
226253
// QR codes for 2FA carry the shared secret. Render entirely client-side
227254
// (toCanvas writes into a local <canvas>) — never round-trip to a third
228255
// party. Falls back to plaintext if rendering fails (e.g. value too long
@@ -247,13 +274,19 @@ function QrcodeBlock({ value }: { value: string }) {
247274
<code className="flex-1 truncate rounded bg-muted px-2 py-1 text-xs font-mono">
248275
{value}
249276
</code>
250-
<CopyButton value={value} />
277+
<CopyButton value={value} name={copyName} />
251278
</div>
252279
</div>
253280
);
254281
}
255282

256-
function CodeListBlock({ value }: { value: string[] }) {
283+
function CodeListBlock({
284+
value,
285+
copyItemName,
286+
}: {
287+
value: string[];
288+
copyItemName: (index: number) => string;
289+
}) {
257290
const { t } = useObjectTranslation();
258291
const joined = value.join('\n');
259292
return (
@@ -265,7 +298,7 @@ function CodeListBlock({ value }: { value: string[] }) {
265298
<code className="flex-1 rounded bg-background px-2 py-1 text-sm font-mono">
266299
{code}
267300
</code>
268-
<CopyButton value={code} />
301+
<CopyButton value={code} name={copyItemName(i + 1)} />
269302
</li>
270303
))}
271304
</ul>
@@ -277,7 +310,7 @@ function CodeListBlock({ value }: { value: string[] }) {
277310
);
278311
}
279312

280-
function SecretBlock({ value }: { value: string }) {
313+
function SecretBlock({ value, copyName }: { value: string; copyName: string }) {
281314
const { t } = useObjectTranslation();
282315
const [revealed, setRevealed] = useState(false);
283316
return (
@@ -294,23 +327,23 @@ function SecretBlock({ value }: { value: string }) {
294327
>
295328
{revealed ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
296329
</Button>
297-
<CopyButton value={value} />
330+
<CopyButton value={value} name={copyName} />
298331
</div>
299332
);
300333
}
301334

302-
function TextBlock({ value }: { value: string }) {
335+
function TextBlock({ value, copyName }: { value: string; copyName: string }) {
303336
return (
304337
<div className="flex items-center gap-2">
305338
<code className="flex-1 rounded bg-muted px-2 py-1 text-sm font-mono break-all">
306339
{value}
307340
</code>
308-
<CopyButton value={value} />
341+
<CopyButton value={value} name={copyName} />
309342
</div>
310343
);
311344
}
312345

313-
function JsonBlock({ value }: { value: unknown }) {
346+
function JsonBlock({ value, copyName }: { value: unknown; copyName: string }) {
314347
const text = useMemo(() => {
315348
try {
316349
return JSON.stringify(value, null, 2);
@@ -324,7 +357,7 @@ function JsonBlock({ value }: { value: unknown }) {
324357
{text}
325358
</pre>
326359
<div className="flex justify-end">
327-
<CopyButton value={text} />
360+
<CopyButton value={text} name={copyName} />
328361
</div>
329362
</div>
330363
);

‎ui/packages/app-shell/src/views/__tests__/ActionResultDialog.test.tsx‎

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect, vi } from 'vitest';
4-
import { render, screen } from '@testing-library/react';
4+
import { render, screen, fireEvent, act, within } from '@testing-library/react';
55
import type { ResultDialogSpec } from '@object-ui/core';
66
import { ActionResultDialog } from '../ActionResultDialog';
77

@@ -17,7 +17,15 @@ vi.mock('@object-ui/i18n', async (importOriginal) => ({
1717
// `language` is the source every other `resolveI18nLabel` caller in this
1818
// package threads (`UnifiedSidebar`, `resolveActionParams`, and
1919
// metadata-admin's `useMetadataLocale`), so the stub carries it too.
20-
useObjectTranslation: () => ({ t: (key: string) => key, language: i18nState.language }),
20+
// Interpolation params are echoed after the key, so a name built from a
21+
// field label shows which label reached it.
22+
useObjectTranslation: () => ({
23+
t: (key: string, params?: Record<string, unknown>) =>
24+
params
25+
? `${key}(${Object.entries(params).map(([k, v]) => `${k}=${String(v)}`).join(', ')})`
26+
: key,
27+
language: i18nState.language,
28+
}),
2129
}));
2230
// jsdom has no canvas; the qrcode path is not under test.
2331
vi.mock('qrcode', () => ({ toCanvas: vi.fn() }));
@@ -131,3 +139,103 @@ describe('ActionResultDialog — an inline per-locale map resolves against the d
131139
expect(screen.getByText('actions.resultDialog.defaultTitle')).toBeInTheDocument();
132140
});
133141
});
142+
143+
/**
144+
* objectstack#28186 — the copy buttons are icon-only, so each one carries an
145+
* accessible name built from its field's label, and the copied state is
146+
* announced through a polite live region. The names are read the way a
147+
* screen-reader or voice-control user reaches the buttons: by role and name.
148+
*/
149+
describe('ActionResultDialog — every copy button has an accessible name', () => {
150+
it('names each field\'s copy button after the field label', () => {
151+
i18nState.language = 'en';
152+
open(
153+
{
154+
title: 'User Created',
155+
fields: [
156+
{ path: 'user.email', label: 'Email', format: 'text' },
157+
{ path: 'temporaryPassword', label: 'Temporary Password', format: 'secret' },
158+
{ path: 'link', label: 'Invite link', format: 'qrcode' },
159+
{ path: 'meta', label: 'Response', format: 'json' },
160+
],
161+
},
162+
{ user: { email: 'a@example.com' }, temporaryPassword: 'p@ss', link: 'https://x.test/i/1', meta: { id: 1 } },
163+
);
164+
165+
for (const label of ['Email', 'Temporary Password', 'Invite link', 'Response']) {
166+
expect(
167+
screen.getByRole('button', { name: `actions.resultDialog.copyField(label=${label})` }),
168+
).toBeInTheDocument();
169+
}
170+
});
171+
172+
it('names an unlabelled value\'s copy button generically', () => {
173+
i18nState.language = 'en';
174+
open({ title: 'Done' }, { anything: 1 });
175+
176+
expect(screen.getByRole('button', { name: 'actions.resultDialog.copyValue' })).toBeInTheDocument();
177+
});
178+
179+
it('names each code of a code list by its position, and keeps the visible name of Copy all', () => {
180+
i18nState.language = 'en';
181+
open(
182+
{ title: 'Backup codes', fields: [{ path: 'codes', label: 'Backup codes', format: 'code-list' }] },
183+
{ codes: ['aaaa-1111', 'bbbb-2222'] },
184+
);
185+
186+
expect(
187+
screen.getByRole('button', { name: 'actions.resultDialog.copyFieldItem(label=Backup codes, index=1)' }),
188+
).toBeInTheDocument();
189+
expect(
190+
screen.getByRole('button', { name: 'actions.resultDialog.copyFieldItem(label=Backup codes, index=2)' }),
191+
).toBeInTheDocument();
192+
expect(screen.getByRole('button', { name: 'actions.resultDialog.copyAll' })).toBeInTheDocument();
193+
});
194+
195+
it('names the codes of an unlabelled code list by position alone', () => {
196+
i18nState.language = 'en';
197+
open({ title: 'Codes', fields: [{ path: 'codes', format: 'code-list' }] }, { codes: ['a', 'b'] });
198+
199+
expect(screen.getByRole('button', { name: 'actions.resultDialog.copyItem(index=2)' })).toBeInTheDocument();
200+
});
201+
202+
it('leaves no button in the dialog without a name', () => {
203+
i18nState.language = 'en';
204+
open(
205+
{
206+
title: 'Everything',
207+
fields: [
208+
{ path: 'a', label: 'A', format: 'text' },
209+
{ path: 'b', format: 'secret' },
210+
{ path: 'c', format: 'code-list' },
211+
{ path: 'd', format: 'json' },
212+
],
213+
},
214+
{ a: 'x', b: 'y', c: ['z'], d: { k: 1 } },
215+
);
216+
217+
const dialog = screen.getByRole('dialog');
218+
const all = within(dialog).getAllByRole('button');
219+
const named = within(dialog).getAllByRole('button', { name: /\S/ });
220+
expect(named).toHaveLength(all.length);
221+
});
222+
223+
it('announces the copied state in a live region that is mounted before it fills', async () => {
224+
i18nState.language = 'en';
225+
const writeText = vi.fn().mockResolvedValue(undefined);
226+
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
227+
open({ title: 'Secret', fields: [{ path: 'secret', label: 'Secret', format: 'text' }] }, { secret: 's3cr3t' });
228+
229+
const region = screen.getByRole('status');
230+
expect(region).toHaveTextContent('');
231+
232+
await act(async () => {
233+
fireEvent.click(screen.getByRole('button', { name: 'actions.resultDialog.copyField(label=Secret)' }));
234+
});
235+
236+
expect(writeText).toHaveBeenCalledWith('s3cr3t');
237+
// The same node, so assistive technology already watching it reads the change.
238+
expect(screen.getByRole('status')).toBe(region);
239+
expect(region).toHaveTextContent('actions.resultDialog.copied');
240+
});
241+
});

‎ui/packages/i18n/src/locales/ar.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,13 @@ const ar = {
150150
defaultTitle: 'احفظ هذه القيمة الآن',
151151
acknowledge: 'لقد حفظتها',
152152
copyAll: 'نسخ الكل',
153+
// Accessible names of the icon-only copy buttons, and the polite
154+
// announcement once a value reaches the clipboard.
155+
copyField: 'نسخ {{label}}',
156+
copyValue: 'نسخ القيمة',
157+
copyFieldItem: 'نسخ {{label}}، العنصر {{index}}',
158+
copyItem: 'نسخ العنصر {{index}}',
159+
copied: 'تم النسخ إلى الحافظة',
153160
},
154161
notAvailableHere: '"{{action}}" غير متاح في الصفحة الحالية.',
155162
notAvailableForRecord: 'غير متاح لهذا السجل',

‎ui/packages/i18n/src/locales/de.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,13 @@ const de = {
136136
defaultTitle: 'Diesen Wert jetzt speichern',
137137
acknowledge: 'Ich habe ihn gespeichert',
138138
copyAll: 'Alle kopieren',
139+
// Accessible names of the icon-only copy buttons, and the polite
140+
// announcement once a value reaches the clipboard.
141+
copyField: '{{label}} kopieren',
142+
copyValue: 'Wert kopieren',
143+
copyFieldItem: '{{label}}, Eintrag {{index}} kopieren',
144+
copyItem: 'Eintrag {{index}} kopieren',
145+
copied: 'In die Zwischenablage kopiert',
139146
},
140147
notAvailableHere: '„{{action}}“ ist auf der aktuellen Seite nicht verfügbar.',
141148
notAvailableForRecord: 'Für diesen Datensatz nicht verfügbar',

‎ui/packages/i18n/src/locales/en.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,13 @@ const en = {
153153
defaultTitle: 'Save this value now',
154154
acknowledge: 'I have saved this',
155155
copyAll: 'Copy all',
156+
// Accessible names of the icon-only copy buttons, and the polite
157+
// announcement once a value reaches the clipboard.
158+
copyField: 'Copy {{label}}',
159+
copyValue: 'Copy value',
160+
copyFieldItem: 'Copy {{label}}, item {{index}}',
161+
copyItem: 'Copy item {{index}}',
162+
copied: 'Copied to clipboard',
156163
},
157164
// The refusal notice for an action whose `autoTrigger` its own declared
158165
// `visible` gate outranks (objectui#4191) — the deep link or host asked

‎ui/packages/i18n/src/locales/es.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ const es = {
138138
defaultTitle: 'Guarda este valor ahora',
139139
acknowledge: 'Lo he guardado',
140140
copyAll: 'Copiar todo',
141+
// Accessible names of the icon-only copy buttons, and the polite
142+
// announcement once a value reaches the clipboard.
143+
copyField: 'Copiar {{label}}',
144+
copyValue: 'Copiar valor',
145+
copyFieldItem: 'Copiar {{label}}, elemento {{index}}',
146+
copyItem: 'Copiar elemento {{index}}',
147+
copied: 'Copiado al portapapeles',
141148
},
142149
notAvailableHere: '«{{action}}» no está disponible en la página actual.',
143150
notAvailableForRecord: 'No disponible para este registro',

‎ui/packages/i18n/src/locales/fr.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ const fr = {
138138
defaultTitle: 'Enregistrez cette valeur maintenant',
139139
acknowledge: 'Je l\'ai enregistrée',
140140
copyAll: 'Tout copier',
141+
// Accessible names of the icon-only copy buttons, and the polite
142+
// announcement once a value reaches the clipboard.
143+
copyField: 'Copier {{label}}',
144+
copyValue: 'Copier la valeur',
145+
copyFieldItem: 'Copier {{label}}, élément {{index}}',
146+
copyItem: 'Copier l\'élément {{index}}',
147+
copied: 'Copié dans le presse-papiers',
141148
},
142149
notAvailableHere: '« {{action}} » n\'est pas disponible sur la page actuelle.',
143150
notAvailableForRecord: 'Non disponible pour cet enregistrement',

‎ui/packages/i18n/src/locales/ja.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,13 @@ const ja = {
136136
defaultTitle: 'この値を今すぐ保存してください',
137137
acknowledge: '保存しました',
138138
copyAll: 'すべてコピー',
139+
// Accessible names of the icon-only copy buttons, and the polite
140+
// announcement once a value reaches the clipboard.
141+
copyField: '{{label}}をコピー',
142+
copyValue: '値をコピー',
143+
copyFieldItem: '{{label}}の項目 {{index}} をコピー',
144+
copyItem: '項目 {{index}} をコピー',
145+
copied: 'クリップボードにコピーしました',
139146
},
140147
notAvailableHere: '「{{action}}」は現在のページでは利用できません。',
141148
notAvailableForRecord: 'このレコードでは利用できません',

‎ui/packages/i18n/src/locales/ko.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,13 @@ const ko = {
136136
defaultTitle: '이 값을 지금 저장하세요',
137137
acknowledge: '저장했습니다',
138138
copyAll: '모두 복사',
139+
// Accessible names of the icon-only copy buttons, and the polite
140+
// announcement once a value reaches the clipboard.
141+
copyField: '{{label}} 복사',
142+
copyValue: '값 복사',
143+
copyFieldItem: '{{label}} 항목 {{index}} 복사',
144+
copyItem: '항목 {{index}} 복사',
145+
copied: '클립보드에 복사되었습니다',
139146
},
140147
notAvailableHere: '"{{action}}"은(는) 현재 페이지에서 사용할 수 없습니다.',
141148
notAvailableForRecord: '이 레코드에서는 사용할 수 없습니다',

‎ui/packages/i18n/src/locales/pt.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ const pt = {
138138
defaultTitle: 'Guarde este valor agora',
139139
acknowledge: 'Eu o salvei',
140140
copyAll: 'Copiar tudo',
141+
// Accessible names of the icon-only copy buttons, and the polite
142+
// announcement once a value reaches the clipboard.
143+
copyField: 'Copiar {{label}}',
144+
copyValue: 'Copiar valor',
145+
copyFieldItem: 'Copiar {{label}}, item {{index}}',
146+
copyItem: 'Copiar item {{index}}',
147+
copied: 'Copiado para a área de transferência',
141148
},
142149
notAvailableHere: '"{{action}}" não está disponível na página atual.',
143150
notAvailableForRecord: 'Não disponível para este registro',

0 commit comments

Comments
 (0)