11// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
33import { 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' ;
55import type { ResultDialogSpec } from '@object-ui/core' ;
66import { 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.
2331vi . 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+ } ) ;
0 commit comments