Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/28186-result-dialog-copy-button-names.md
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 54 additions & 21 deletions ui/packages/app-shell/src/views/ActionResultDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -183,11 +192,13 @@ function ResultField({
{label ? (
<div className="text-sm font-medium text-foreground/80">{label}</div>
) : null}
{safeFormat === 'qrcode' ? <QrcodeBlock value={value as string} /> : null}
{safeFormat === 'code-list' ? <CodeListBlock value={value as string[]} /> : null}
{safeFormat === 'secret' ? <SecretBlock value={value as string} /> : null}
{safeFormat === 'text' ? <TextBlock value={value as string} /> : null}
{safeFormat === 'json' ? <JsonBlock value={value} /> : null}
{safeFormat === 'qrcode' ? <QrcodeBlock value={value as string} copyName={copyName} /> : null}
{safeFormat === 'code-list' ? (
<CodeListBlock value={value as string[]} copyItemName={copyItemName} />
) : null}
{safeFormat === 'secret' ? <SecretBlock value={value as string} copyName={copyName} /> : null}
{safeFormat === 'text' ? <TextBlock value={value as string} copyName={copyName} /> : null}
{safeFormat === 'json' ? <JsonBlock value={value} copyName={copyName} /> : null}
</div>
);
}
Expand All @@ -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 (
<Button type="button" variant="outline" size="sm" onClick={copy}>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{label ? <span className="ml-2">{label}</span> : null}
</Button>
<>
<Button type="button" variant="outline" size="sm" onClick={copy} aria-label={label ? undefined : name}>
{copied ? <Check className="w-4 h-4" aria-hidden="true" /> : <Copy className="w-4 h-4" aria-hidden="true" />}
{label ? <span className="ml-2">{label}</span> : null}
</Button>
<span role="status" className="sr-only">
{copied ? t('actions.resultDialog.copied') : ''}
</span>
</>
);
}

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 <canvas>) — never round-trip to a third
// party. Falls back to plaintext if rendering fails (e.g. value too long
Expand All @@ -247,13 +274,19 @@ function QrcodeBlock({ value }: { value: string }) {
<code className="flex-1 truncate rounded bg-muted px-2 py-1 text-xs font-mono">
{value}
</code>
<CopyButton value={value} />
<CopyButton value={value} name={copyName} />
</div>
</div>
);
}

function CodeListBlock({ value }: { value: string[] }) {
function CodeListBlock({
value,
copyItemName,
}: {
value: string[];
copyItemName: (index: number) => string;
}) {
const { t } = useObjectTranslation();
const joined = value.join('\n');
return (
Expand All @@ -265,7 +298,7 @@ function CodeListBlock({ value }: { value: string[] }) {
<code className="flex-1 rounded bg-background px-2 py-1 text-sm font-mono">
{code}
</code>
<CopyButton value={code} />
<CopyButton value={code} name={copyItemName(i + 1)} />
</li>
))}
</ul>
Expand All @@ -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 (
Expand All @@ -294,23 +327,23 @@ function SecretBlock({ value }: { value: string }) {
>
{revealed ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
<CopyButton value={value} />
<CopyButton value={value} name={copyName} />
</div>
);
}

function TextBlock({ value }: { value: string }) {
function TextBlock({ value, copyName }: { value: string; copyName: string }) {
return (
<div className="flex items-center gap-2">
<code className="flex-1 rounded bg-muted px-2 py-1 text-sm font-mono break-all">
{value}
</code>
<CopyButton value={value} />
<CopyButton value={value} name={copyName} />
</div>
);
}

function JsonBlock({ value }: { value: unknown }) {
function JsonBlock({ value, copyName }: { value: unknown; copyName: string }) {
const text = useMemo(() => {
try {
return JSON.stringify(value, null, 2);
Expand All @@ -324,7 +357,7 @@ function JsonBlock({ value }: { value: unknown }) {
{text}
</pre>
<div className="flex justify-end">
<CopyButton value={text} />
<CopyButton value={text} name={copyName} />
</div>
</div>
);
Expand Down
112 changes: 110 additions & 2 deletions ui/packages/app-shell/src/views/__tests__/ActionResultDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<string, unknown>) =>
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() }));
Expand Down Expand Up @@ -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');
});
});
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: 'غير متاح لهذا السجل',
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: 'このレコードでは利用できません',
Expand Down
Loading
Loading