@wordpress/ui 0.10.0 → 0.11.1-next.v.202604091042.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/CONTRIBUTING.md +25 -0
  3. package/README.md +22 -2
  4. package/build/alert-dialog/context.cjs +6 -1
  5. package/build/alert-dialog/context.cjs.map +2 -2
  6. package/build/alert-dialog/popup.cjs +105 -33
  7. package/build/alert-dialog/popup.cjs.map +4 -4
  8. package/build/alert-dialog/root.cjs +106 -6
  9. package/build/alert-dialog/root.cjs.map +2 -2
  10. package/build/alert-dialog/trigger.cjs +4 -14
  11. package/build/alert-dialog/trigger.cjs.map +3 -3
  12. package/build/alert-dialog/types.cjs.map +1 -1
  13. package/build/button/button.cjs +16 -6
  14. package/build/button/button.cjs.map +3 -3
  15. package/build/card/content.cjs +3 -3
  16. package/build/card/content.cjs.map +1 -1
  17. package/build/card/full-bleed.cjs +3 -3
  18. package/build/card/full-bleed.cjs.map +1 -1
  19. package/build/card/header.cjs +3 -3
  20. package/build/card/header.cjs.map +1 -1
  21. package/build/card/root.cjs +3 -3
  22. package/build/card/root.cjs.map +1 -1
  23. package/build/card/title.cjs +3 -3
  24. package/build/card/title.cjs.map +1 -1
  25. package/build/collapsible-card/header.cjs +3 -3
  26. package/build/collapsible-card/header.cjs.map +2 -2
  27. package/build/empty-state/title.cjs.map +2 -2
  28. package/build/form/primitives/field/description.cjs +17 -4
  29. package/build/form/primitives/field/description.cjs.map +3 -3
  30. package/build/form/primitives/field/details.cjs +3 -3
  31. package/build/form/primitives/field/details.cjs.map +2 -2
  32. package/build/form/primitives/field/label.cjs +3 -3
  33. package/build/form/primitives/field/label.cjs.map +2 -2
  34. package/build/form/primitives/fieldset/description.cjs +20 -4
  35. package/build/form/primitives/fieldset/description.cjs.map +3 -3
  36. package/build/form/primitives/fieldset/details.cjs +3 -3
  37. package/build/form/primitives/fieldset/details.cjs.map +2 -2
  38. package/build/form/primitives/fieldset/legend.cjs +3 -3
  39. package/build/form/primitives/fieldset/legend.cjs.map +2 -2
  40. package/build/form/primitives/input/input.cjs +23 -7
  41. package/build/form/primitives/input/input.cjs.map +3 -3
  42. package/build/form/primitives/input-layout/input-layout.cjs +10 -0
  43. package/build/form/primitives/input-layout/input-layout.cjs.map +3 -3
  44. package/build/form/primitives/select/trigger.cjs +3 -3
  45. package/build/form/primitives/select/trigger.cjs.map +2 -2
  46. package/build/form/primitives/textarea/textarea.cjs +20 -1
  47. package/build/form/primitives/textarea/textarea.cjs.map +3 -3
  48. package/build/index.cjs +3 -0
  49. package/build/index.cjs.map +2 -2
  50. package/build/link/link.cjs +16 -6
  51. package/build/link/link.cjs.map +3 -3
  52. package/build/popover/arrow.cjs +94 -0
  53. package/build/popover/arrow.cjs.map +7 -0
  54. package/build/popover/close.cjs +45 -0
  55. package/build/popover/close.cjs.map +7 -0
  56. package/build/popover/context.cjs +76 -0
  57. package/build/popover/context.cjs.map +7 -0
  58. package/build/popover/description.cjs +70 -0
  59. package/build/popover/description.cjs.map +7 -0
  60. package/build/popover/index.cjs +49 -0
  61. package/build/popover/index.cjs.map +7 -0
  62. package/build/popover/popup.cjs +138 -0
  63. package/build/popover/popup.cjs.map +7 -0
  64. package/build/popover/root.cjs +35 -0
  65. package/build/popover/root.cjs.map +7 -0
  66. package/build/popover/title.cjs +56 -0
  67. package/build/popover/title.cjs.map +7 -0
  68. package/build/popover/trigger.cjs +38 -0
  69. package/build/popover/trigger.cjs.map +7 -0
  70. package/build/popover/types.cjs +19 -0
  71. package/build/popover/types.cjs.map +7 -0
  72. package/build/text/text.cjs +20 -5
  73. package/build/text/text.cjs.map +3 -3
  74. package/build/utils/use-deprioritized-initial-focus.cjs.map +2 -2
  75. package/build-module/alert-dialog/context.mjs +6 -1
  76. package/build-module/alert-dialog/context.mjs.map +2 -2
  77. package/build-module/alert-dialog/popup.mjs +107 -33
  78. package/build-module/alert-dialog/popup.mjs.map +4 -4
  79. package/build-module/alert-dialog/root.mjs +113 -7
  80. package/build-module/alert-dialog/root.mjs.map +2 -2
  81. package/build-module/alert-dialog/trigger.mjs +4 -4
  82. package/build-module/alert-dialog/trigger.mjs.map +3 -3
  83. package/build-module/button/button.mjs +16 -6
  84. package/build-module/button/button.mjs.map +3 -3
  85. package/build-module/card/content.mjs +3 -3
  86. package/build-module/card/content.mjs.map +1 -1
  87. package/build-module/card/full-bleed.mjs +3 -3
  88. package/build-module/card/full-bleed.mjs.map +1 -1
  89. package/build-module/card/header.mjs +3 -3
  90. package/build-module/card/header.mjs.map +1 -1
  91. package/build-module/card/root.mjs +3 -3
  92. package/build-module/card/root.mjs.map +1 -1
  93. package/build-module/card/title.mjs +3 -3
  94. package/build-module/card/title.mjs.map +1 -1
  95. package/build-module/collapsible-card/header.mjs +3 -3
  96. package/build-module/collapsible-card/header.mjs.map +2 -2
  97. package/build-module/empty-state/title.mjs.map +2 -2
  98. package/build-module/form/primitives/field/description.mjs +17 -4
  99. package/build-module/form/primitives/field/description.mjs.map +3 -3
  100. package/build-module/form/primitives/field/details.mjs +3 -3
  101. package/build-module/form/primitives/field/details.mjs.map +2 -2
  102. package/build-module/form/primitives/field/label.mjs +3 -3
  103. package/build-module/form/primitives/field/label.mjs.map +2 -2
  104. package/build-module/form/primitives/fieldset/description.mjs +20 -4
  105. package/build-module/form/primitives/fieldset/description.mjs.map +3 -3
  106. package/build-module/form/primitives/fieldset/details.mjs +3 -3
  107. package/build-module/form/primitives/fieldset/details.mjs.map +2 -2
  108. package/build-module/form/primitives/fieldset/legend.mjs +3 -3
  109. package/build-module/form/primitives/fieldset/legend.mjs.map +2 -2
  110. package/build-module/form/primitives/input/input.mjs +23 -7
  111. package/build-module/form/primitives/input/input.mjs.map +3 -3
  112. package/build-module/form/primitives/input-layout/input-layout.mjs +10 -0
  113. package/build-module/form/primitives/input-layout/input-layout.mjs.map +3 -3
  114. package/build-module/form/primitives/select/trigger.mjs +3 -3
  115. package/build-module/form/primitives/select/trigger.mjs.map +2 -2
  116. package/build-module/form/primitives/textarea/textarea.mjs +20 -1
  117. package/build-module/form/primitives/textarea/textarea.mjs.map +3 -3
  118. package/build-module/index.mjs +2 -0
  119. package/build-module/index.mjs.map +2 -2
  120. package/build-module/link/link.mjs +16 -6
  121. package/build-module/link/link.mjs.map +3 -3
  122. package/build-module/popover/arrow.mjs +59 -0
  123. package/build-module/popover/arrow.mjs.map +7 -0
  124. package/build-module/popover/close.mjs +20 -0
  125. package/build-module/popover/close.mjs.map +7 -0
  126. package/build-module/popover/context.mjs +57 -0
  127. package/build-module/popover/context.mjs.map +7 -0
  128. package/build-module/popover/description.mjs +35 -0
  129. package/build-module/popover/description.mjs.map +7 -0
  130. package/build-module/popover/index.mjs +18 -0
  131. package/build-module/popover/index.mjs.map +7 -0
  132. package/build-module/popover/popup.mjs +105 -0
  133. package/build-module/popover/popup.mjs.map +7 -0
  134. package/build-module/popover/root.mjs +10 -0
  135. package/build-module/popover/root.mjs.map +7 -0
  136. package/build-module/popover/title.mjs +31 -0
  137. package/build-module/popover/title.mjs.map +7 -0
  138. package/build-module/popover/trigger.mjs +13 -0
  139. package/build-module/popover/trigger.mjs.map +7 -0
  140. package/build-module/popover/types.mjs +1 -0
  141. package/build-module/popover/types.mjs.map +7 -0
  142. package/build-module/text/text.mjs +20 -5
  143. package/build-module/text/text.mjs.map +3 -3
  144. package/build-module/utils/use-deprioritized-initial-focus.mjs.map +2 -2
  145. package/build-types/alert-dialog/context.d.ts +6 -3
  146. package/build-types/alert-dialog/context.d.ts.map +1 -1
  147. package/build-types/alert-dialog/popup.d.ts.map +1 -1
  148. package/build-types/alert-dialog/root.d.ts +2 -8
  149. package/build-types/alert-dialog/root.d.ts.map +1 -1
  150. package/build-types/alert-dialog/stories/index.story.d.ts +18 -6
  151. package/build-types/alert-dialog/stories/index.story.d.ts.map +1 -1
  152. package/build-types/alert-dialog/trigger.d.ts +2 -1
  153. package/build-types/alert-dialog/trigger.d.ts.map +1 -1
  154. package/build-types/alert-dialog/types.d.ts +57 -26
  155. package/build-types/alert-dialog/types.d.ts.map +1 -1
  156. package/build-types/button/button.d.ts.map +1 -1
  157. package/build-types/card/stories/index.story.d.ts.map +1 -1
  158. package/build-types/empty-state/title.d.ts.map +1 -1
  159. package/build-types/form/primitives/field/description.d.ts.map +1 -1
  160. package/build-types/form/primitives/fieldset/description.d.ts.map +1 -1
  161. package/build-types/form/primitives/input/input.d.ts.map +1 -1
  162. package/build-types/form/primitives/input-layout/input-layout.d.ts.map +1 -1
  163. package/build-types/form/primitives/textarea/textarea.d.ts.map +1 -1
  164. package/build-types/form/stories/shared.d.ts.map +1 -1
  165. package/build-types/index.d.ts +1 -0
  166. package/build-types/index.d.ts.map +1 -1
  167. package/build-types/link/link.d.ts.map +1 -1
  168. package/build-types/popover/arrow.d.ts +10 -0
  169. package/build-types/popover/arrow.d.ts.map +1 -0
  170. package/build-types/popover/close.d.ts +11 -0
  171. package/build-types/popover/close.d.ts.map +1 -0
  172. package/build-types/popover/context.d.ts +22 -0
  173. package/build-types/popover/context.d.ts.map +1 -0
  174. package/build-types/popover/description.d.ts +10 -0
  175. package/build-types/popover/description.d.ts.map +1 -0
  176. package/build-types/popover/index.d.ts +9 -0
  177. package/build-types/popover/index.d.ts.map +1 -0
  178. package/build-types/popover/popup.d.ts +11 -0
  179. package/build-types/popover/popup.d.ts.map +1 -0
  180. package/build-types/popover/root.d.ts +37 -0
  181. package/build-types/popover/root.d.ts.map +1 -0
  182. package/build-types/popover/stories/index.story.d.ts +211 -0
  183. package/build-types/popover/stories/index.story.d.ts.map +1 -0
  184. package/build-types/popover/stories/utils.d.ts +25 -0
  185. package/build-types/popover/stories/utils.d.ts.map +1 -0
  186. package/build-types/popover/test/index.test.d.ts +2 -0
  187. package/build-types/popover/test/index.test.d.ts.map +1 -0
  188. package/build-types/popover/title.d.ts +20 -0
  189. package/build-types/popover/title.d.ts.map +1 -0
  190. package/build-types/popover/trigger.d.ts +10 -0
  191. package/build-types/popover/trigger.d.ts.map +1 -0
  192. package/build-types/popover/types.d.ts +83 -0
  193. package/build-types/popover/types.d.ts.map +1 -0
  194. package/build-types/text/stories/index.story.d.ts +4 -0
  195. package/build-types/text/stories/index.story.d.ts.map +1 -1
  196. package/build-types/text/text.d.ts.map +1 -1
  197. package/build-types/utils/use-deprioritized-initial-focus.d.ts +6 -5
  198. package/build-types/utils/use-deprioritized-initial-focus.d.ts.map +1 -1
  199. package/package.json +11 -11
  200. package/src/alert-dialog/context.tsx +12 -4
  201. package/src/alert-dialog/popup.tsx +91 -33
  202. package/src/alert-dialog/root.tsx +191 -13
  203. package/src/alert-dialog/stories/index.story.tsx +116 -65
  204. package/src/alert-dialog/style.module.css +11 -0
  205. package/src/alert-dialog/test/index.test.tsx +1265 -347
  206. package/src/alert-dialog/trigger.tsx +2 -2
  207. package/src/alert-dialog/types.ts +59 -28
  208. package/src/button/button.tsx +2 -0
  209. package/src/button/style.module.css +4 -0
  210. package/src/card/stories/index.story.tsx +0 -1
  211. package/src/card/style.module.css +1 -1
  212. package/src/card/test/index.test.tsx +0 -1
  213. package/src/empty-state/title.tsx +0 -1
  214. package/src/form/primitives/field/description.tsx +6 -1
  215. package/src/form/primitives/fieldset/description.tsx +9 -1
  216. package/src/form/primitives/input/input.tsx +6 -1
  217. package/src/form/primitives/input/style.module.css +4 -0
  218. package/src/form/primitives/input-layout/input-layout.tsx +2 -0
  219. package/src/form/primitives/textarea/textarea.tsx +10 -1
  220. package/src/form/stories/shared.tsx +4 -2
  221. package/src/index.ts +1 -0
  222. package/src/link/link.tsx +2 -0
  223. package/src/link/style.module.css +10 -0
  224. package/src/popover/arrow.tsx +49 -0
  225. package/src/popover/close.tsx +24 -0
  226. package/src/popover/context.tsx +100 -0
  227. package/src/popover/description.tsx +34 -0
  228. package/src/popover/index.ts +9 -0
  229. package/src/popover/popup.tsx +106 -0
  230. package/src/popover/root.tsx +41 -0
  231. package/src/popover/stories/index.story.tsx +1315 -0
  232. package/src/popover/stories/utils.tsx +91 -0
  233. package/src/popover/style.module.css +64 -0
  234. package/src/popover/test/index.test.tsx +727 -0
  235. package/src/popover/title.tsx +50 -0
  236. package/src/popover/trigger.tsx +17 -0
  237. package/src/popover/types.ts +113 -0
  238. package/src/text/stories/index.story.tsx +4 -2
  239. package/src/text/style.module.css +26 -0
  240. package/src/text/test/index.test.tsx +1 -4
  241. package/src/text/text.tsx +8 -1
  242. package/src/utils/css/field.module.css +4 -1
  243. package/src/utils/css/focus.module.css +7 -5
  244. package/src/utils/css/global-css-defense.module.css +117 -0
  245. package/src/utils/use-deprioritized-initial-focus.ts +5 -4
@@ -1,8 +1,24 @@
1
- import { render, screen, waitFor } from '@testing-library/react';
1
+ import { speak } from '@wordpress/a11y';
2
+ import { act, render, screen, waitFor } from '@testing-library/react';
2
3
  import userEvent from '@testing-library/user-event';
3
- import { createRef, useState } from '@wordpress/element';
4
+ import { createRef } from '@wordpress/element';
4
5
 
5
6
  import * as AlertDialog from '..';
7
+ import type { ConfirmResult } from '../types';
8
+
9
+ jest.mock( '@wordpress/a11y', () => ( {
10
+ speak: jest.fn(),
11
+ } ) );
12
+
13
+ function createDeferred() {
14
+ let resolve!: ( value?: ConfirmResult ) => void;
15
+ let reject!: ( reason?: unknown ) => void;
16
+ const promise = new Promise< ConfirmResult >( ( res, rej ) => {
17
+ resolve = res;
18
+ reject = rej;
19
+ } );
20
+ return { promise, resolve, reject };
21
+ }
6
22
 
7
23
  describe( 'AlertDialog', () => {
8
24
  it( 'forwards ref', () => {
@@ -14,12 +30,8 @@ describe( 'AlertDialog', () => {
14
30
  <AlertDialog.Trigger ref={ triggerRef }>
15
31
  Open
16
32
  </AlertDialog.Trigger>
17
- <AlertDialog.Popup
18
- ref={ popupRef }
19
- title="Test Title"
20
- onConfirm={ jest.fn() }
21
- >
22
- Test message content
33
+ <AlertDialog.Popup ref={ popupRef } title="Test Title">
34
+ Content
23
35
  </AlertDialog.Popup>
24
36
  </AlertDialog.Root>
25
37
  );
@@ -28,10 +40,10 @@ describe( 'AlertDialog', () => {
28
40
  expect( popupRef.current ).toBeInstanceOf( HTMLDivElement );
29
41
  } );
30
42
 
31
- it( 'renders with title, message, and default buttons', async () => {
43
+ it( 'renders with title, children, and default buttons', async () => {
32
44
  render(
33
45
  <AlertDialog.Root open onOpenChange={ jest.fn() }>
34
- <AlertDialog.Popup title="Test Title" onConfirm={ jest.fn() }>
46
+ <AlertDialog.Popup title="Test Title">
35
47
  Test message content
36
48
  </AlertDialog.Popup>
37
49
  </AlertDialog.Root>
@@ -51,34 +63,27 @@ describe( 'AlertDialog', () => {
51
63
  ).toBeVisible();
52
64
  } );
53
65
 
54
- it( 'renders with role="alertdialog" for default intent', async () => {
66
+ it( 'renders description when provided', async () => {
55
67
  render(
56
68
  <AlertDialog.Root open onOpenChange={ jest.fn() }>
57
69
  <AlertDialog.Popup
58
- title="Default Dialog"
59
- onConfirm={ jest.fn() }
70
+ title="Test Title"
71
+ description="This is a description"
60
72
  >
61
- Content
73
+ Body content
62
74
  </AlertDialog.Popup>
63
75
  </AlertDialog.Root>
64
76
  );
65
77
 
66
78
  await waitFor( () => {
67
- expect( screen.getByRole( 'alertdialog' ) ).toBeVisible();
79
+ expect( screen.getByText( 'This is a description' ) ).toBeVisible();
68
80
  } );
69
81
  } );
70
82
 
71
- it( 'renders with role="alertdialog" for irreversible intent', async () => {
83
+ it( 'renders with role="alertdialog" for default intent', async () => {
72
84
  render(
73
- <AlertDialog.Root
74
- intent="irreversible"
75
- open
76
- onOpenChange={ jest.fn() }
77
- >
78
- <AlertDialog.Popup
79
- title="Irreversible Dialog"
80
- onConfirm={ jest.fn() }
81
- >
85
+ <AlertDialog.Root open onOpenChange={ jest.fn() }>
86
+ <AlertDialog.Popup title="Default Dialog">
82
87
  Content
83
88
  </AlertDialog.Popup>
84
89
  </AlertDialog.Root>
@@ -89,449 +94,1362 @@ describe( 'AlertDialog', () => {
89
94
  } );
90
95
  } );
91
96
 
92
- it( 'calls onConfirm and onOpenChange when confirm button is clicked', async () => {
93
- const onConfirm = jest.fn();
94
- const onOpenChange = jest.fn();
95
-
97
+ it( 'renders with role="alertdialog" for irreversible intent', async () => {
96
98
  render(
97
- <AlertDialog.Root open onOpenChange={ onOpenChange }>
99
+ <AlertDialog.Root open onOpenChange={ jest.fn() }>
98
100
  <AlertDialog.Popup
99
- title="Confirm Action"
100
- onConfirm={ onConfirm }
101
+ intent="irreversible"
102
+ title="Irreversible Dialog"
101
103
  >
102
- Are you sure?
104
+ Content
103
105
  </AlertDialog.Popup>
104
106
  </AlertDialog.Root>
105
107
  );
106
108
 
107
109
  await waitFor( () => {
108
- expect(
109
- screen.getByRole( 'button', { name: 'OK' } )
110
- ).toBeVisible();
110
+ expect( screen.getByRole( 'alertdialog' ) ).toBeVisible();
111
111
  } );
112
-
113
- await userEvent.click( screen.getByRole( 'button', { name: 'OK' } ) );
114
-
115
- expect( onConfirm ).toHaveBeenCalledTimes( 1 );
116
- expect( onOpenChange ).toHaveBeenCalledWith(
117
- false,
118
- expect.objectContaining( { reason: 'close-press' } )
119
- );
120
112
  } );
121
113
 
122
- it( 'calls onOpenChange when cancel button is clicked', async () => {
123
- const onConfirm = jest.fn();
124
- const onOpenChange = jest.fn();
125
-
114
+ it( 'uses custom button labels', async () => {
126
115
  render(
127
- <AlertDialog.Root open onOpenChange={ onOpenChange }>
116
+ <AlertDialog.Root open onOpenChange={ jest.fn() }>
128
117
  <AlertDialog.Popup
129
- title="Confirm Action"
130
- onConfirm={ onConfirm }
118
+ title="Custom Labels"
119
+ confirmButtonText="Yes, do it"
120
+ cancelButtonText="No, go back"
131
121
  >
132
- Are you sure?
122
+ Content
133
123
  </AlertDialog.Popup>
134
124
  </AlertDialog.Root>
135
125
  );
136
126
 
137
127
  await waitFor( () => {
138
128
  expect(
139
- screen.getByRole( 'button', { name: 'Cancel' } )
129
+ screen.getByRole( 'button', { name: 'Yes, do it' } )
140
130
  ).toBeVisible();
141
131
  } );
142
132
 
143
- await userEvent.click(
144
- screen.getByRole( 'button', { name: 'Cancel' } )
145
- );
146
-
147
- expect( onOpenChange ).toHaveBeenCalledWith(
148
- false,
149
- expect.objectContaining( { reason: 'close-press' } )
150
- );
151
- expect( onConfirm ).not.toHaveBeenCalled();
133
+ expect(
134
+ screen.getByRole( 'button', { name: 'No, go back' } )
135
+ ).toBeVisible();
152
136
  } );
153
137
 
154
- it( 'calls onOpenChange on escape key for default intent', async () => {
155
- const onOpenChange = jest.fn();
156
-
138
+ it( 'opens dialog when Trigger is clicked', async () => {
157
139
  render(
158
- <AlertDialog.Root open onOpenChange={ onOpenChange }>
159
- <AlertDialog.Popup
160
- title="Default Dialog"
161
- onConfirm={ jest.fn() }
162
- >
163
- Content
140
+ <AlertDialog.Root>
141
+ <AlertDialog.Trigger>Open</AlertDialog.Trigger>
142
+ <AlertDialog.Popup title="Trigger Test">
143
+ Dialog content
164
144
  </AlertDialog.Popup>
165
145
  </AlertDialog.Root>
166
146
  );
167
147
 
148
+ expect(
149
+ screen.queryByText( 'Dialog content' )
150
+ ).not.toBeInTheDocument();
151
+
152
+ await userEvent.click( screen.getByRole( 'button', { name: 'Open' } ) );
153
+
168
154
  await waitFor( () => {
169
- expect( screen.getByText( 'Default Dialog' ) ).toBeVisible();
155
+ expect( screen.getByText( 'Trigger Test' ) ).toBeVisible();
170
156
  } );
171
157
 
172
- await userEvent.keyboard( '{Escape}' );
173
-
174
- expect( onOpenChange ).toHaveBeenCalledWith(
175
- false,
176
- expect.objectContaining( { reason: 'escape-key' } )
177
- );
158
+ expect( screen.getByText( 'Dialog content' ) ).toBeVisible();
178
159
  } );
179
160
 
180
- it( 'does not call onOpenChange on backdrop click for default intent', async () => {
181
- const onOpenChange = jest.fn();
161
+ describe( 'sync confirm flow', () => {
162
+ it( 'calls onConfirm and closes on confirm click', async () => {
163
+ const onConfirm = jest.fn();
164
+ const onOpenChange = jest.fn();
182
165
 
183
- render(
184
- <AlertDialog.Root open onOpenChange={ onOpenChange }>
185
- <AlertDialog.Popup
186
- title="Default Dialog"
187
- onConfirm={ jest.fn() }
166
+ render(
167
+ <AlertDialog.Root
168
+ open
169
+ onOpenChange={ onOpenChange }
170
+ onConfirm={ onConfirm }
188
171
  >
189
- Content
190
- </AlertDialog.Popup>
191
- </AlertDialog.Root>
192
- );
193
-
194
- await waitFor( () => {
195
- expect( screen.getByText( 'Default Dialog' ) ).toBeVisible();
196
- } );
197
-
198
- await userEvent.click( document.body );
172
+ <AlertDialog.Popup title="Sync Test">
173
+ Content
174
+ </AlertDialog.Popup>
175
+ </AlertDialog.Root>
176
+ );
199
177
 
200
- expect( onOpenChange ).not.toHaveBeenCalled();
201
- } );
178
+ await waitFor( () => {
179
+ expect(
180
+ screen.getByRole( 'button', { name: 'OK' } )
181
+ ).toBeVisible();
182
+ } );
202
183
 
203
- it( 'renders with title, message, and default buttons for irreversible intent', async () => {
204
- render(
205
- <AlertDialog.Root
206
- intent="irreversible"
207
- open
208
- onOpenChange={ jest.fn() }
209
- >
210
- <AlertDialog.Popup
211
- title="Irreversible Dialog"
212
- onConfirm={ jest.fn() }
213
- >
214
- Irreversible message content
215
- </AlertDialog.Popup>
216
- </AlertDialog.Root>
217
- );
184
+ await userEvent.click(
185
+ screen.getByRole( 'button', { name: 'OK' } )
186
+ );
218
187
 
219
- await waitFor( () => {
220
- expect( screen.getByText( 'Irreversible Dialog' ) ).toBeVisible();
188
+ expect( onConfirm ).toHaveBeenCalledTimes( 1 );
189
+ await waitFor( () => {
190
+ expect( onOpenChange ).toHaveBeenCalledWith(
191
+ false,
192
+ expect.objectContaining( {
193
+ reason: 'imperative-action',
194
+ } )
195
+ );
196
+ } );
221
197
  } );
222
198
 
223
- expect(
224
- screen.getByText( 'Irreversible message content' )
225
- ).toBeVisible();
226
- expect(
227
- screen.queryByRole( 'button', { name: 'Close' } )
228
- ).not.toBeInTheDocument();
229
- expect( screen.getByRole( 'button', { name: 'OK' } ) ).toBeVisible();
230
- expect(
231
- screen.getByRole( 'button', { name: 'Cancel' } )
232
- ).toBeVisible();
233
- } );
234
-
235
- it( 'calls onOpenChange on escape key for irreversible intent', async () => {
236
- const onOpenChange = jest.fn();
199
+ it( 'provides well-formed event details on confirm close', async () => {
200
+ const onOpenChange = jest.fn();
237
201
 
238
- render(
239
- <AlertDialog.Root
240
- intent="irreversible"
241
- open
242
- onOpenChange={ onOpenChange }
243
- >
244
- <AlertDialog.Popup
245
- title="Irreversible Dialog"
202
+ render(
203
+ <AlertDialog.Root
204
+ open
205
+ onOpenChange={ onOpenChange }
246
206
  onConfirm={ jest.fn() }
247
207
  >
248
- Content
249
- </AlertDialog.Popup>
250
- </AlertDialog.Root>
251
- );
208
+ <AlertDialog.Popup title="Details Test">
209
+ Content
210
+ </AlertDialog.Popup>
211
+ </AlertDialog.Root>
212
+ );
252
213
 
253
- await waitFor( () => {
254
- expect( screen.getByText( 'Irreversible Dialog' ) ).toBeVisible();
255
- } );
214
+ await waitFor( () => {
215
+ expect(
216
+ screen.getByRole( 'button', { name: 'OK' } )
217
+ ).toBeVisible();
218
+ } );
256
219
 
257
- await userEvent.keyboard( '{Escape}' );
220
+ await userEvent.click(
221
+ screen.getByRole( 'button', { name: 'OK' } )
222
+ );
258
223
 
259
- expect( onOpenChange ).toHaveBeenCalledWith(
260
- false,
261
- expect.objectContaining( { reason: 'escape-key' } )
262
- );
263
- } );
224
+ await waitFor( () => {
225
+ expect( onOpenChange ).toHaveBeenCalledWith(
226
+ false,
227
+ expect.objectContaining( {
228
+ reason: 'imperative-action',
229
+ } )
230
+ );
231
+ } );
232
+
233
+ const details = onOpenChange.mock.calls.find(
234
+ ( [ open ]: [ boolean ] ) => ! open
235
+ )?.[ 1 ];
236
+
237
+ expect( details ).toBeDefined();
238
+ expect( typeof details.cancel ).toBe( 'function' );
239
+ expect( typeof details.allowPropagation ).toBe( 'function' );
240
+ expect( typeof details.preventUnmountOnClose ).toBe( 'function' );
241
+ expect( details.event ).toBeInstanceOf( Event );
242
+ } );
264
243
 
265
- it( 'does not call onOpenChange on backdrop click for irreversible intent', async () => {
266
- const onOpenChange = jest.fn();
244
+ it( 'closes without onConfirm when no handler is provided', async () => {
245
+ const onOpenChange = jest.fn();
267
246
 
268
- render(
269
- <AlertDialog.Root
270
- intent="irreversible"
271
- open
272
- onOpenChange={ onOpenChange }
273
- >
274
- <AlertDialog.Popup
275
- title="Irreversible Dialog"
276
- onConfirm={ jest.fn() }
277
- >
278
- Content
279
- </AlertDialog.Popup>
280
- </AlertDialog.Root>
281
- );
247
+ render(
248
+ <AlertDialog.Root open onOpenChange={ onOpenChange }>
249
+ <AlertDialog.Popup title="No Handler">
250
+ Content
251
+ </AlertDialog.Popup>
252
+ </AlertDialog.Root>
253
+ );
282
254
 
283
- await waitFor( () => {
284
- expect( screen.getByText( 'Irreversible Dialog' ) ).toBeVisible();
285
- } );
255
+ await waitFor( () => {
256
+ expect(
257
+ screen.getByRole( 'button', { name: 'OK' } )
258
+ ).toBeVisible();
259
+ } );
286
260
 
287
- await userEvent.click( document.body );
261
+ await userEvent.click(
262
+ screen.getByRole( 'button', { name: 'OK' } )
263
+ );
288
264
 
289
- expect( onOpenChange ).not.toHaveBeenCalled();
265
+ await waitFor( () => {
266
+ expect( onOpenChange ).toHaveBeenCalledWith(
267
+ false,
268
+ expect.objectContaining( {
269
+ reason: 'imperative-action',
270
+ } )
271
+ );
272
+ } );
273
+ } );
290
274
  } );
291
275
 
292
- it( 'calls onOpenChange on cancel button click for irreversible intent', async () => {
293
- const onOpenChange = jest.fn();
294
- const onConfirm = jest.fn();
276
+ describe( 'cancel and dismiss', () => {
277
+ it( 'closes on cancel click without calling onConfirm', async () => {
278
+ const onConfirm = jest.fn();
279
+ const onOpenChange = jest.fn();
295
280
 
296
- render(
297
- <AlertDialog.Root
298
- intent="irreversible"
299
- open
300
- onOpenChange={ onOpenChange }
301
- >
302
- <AlertDialog.Popup
303
- title="Irreversible Dialog"
281
+ render(
282
+ <AlertDialog.Root
283
+ open
284
+ onOpenChange={ onOpenChange }
304
285
  onConfirm={ onConfirm }
305
286
  >
306
- Content
307
- </AlertDialog.Popup>
308
- </AlertDialog.Root>
309
- );
287
+ <AlertDialog.Popup title="Cancel Test">
288
+ Content
289
+ </AlertDialog.Popup>
290
+ </AlertDialog.Root>
291
+ );
310
292
 
311
- await waitFor( () => {
312
- expect(
293
+ await waitFor( () => {
294
+ expect(
295
+ screen.getByRole( 'button', { name: 'Cancel' } )
296
+ ).toBeVisible();
297
+ } );
298
+
299
+ await userEvent.click(
313
300
  screen.getByRole( 'button', { name: 'Cancel' } )
314
- ).toBeVisible();
301
+ );
302
+
303
+ expect( onOpenChange ).toHaveBeenCalledWith(
304
+ false,
305
+ expect.objectContaining( { reason: 'close-press' } )
306
+ );
307
+ expect( onConfirm ).not.toHaveBeenCalled();
315
308
  } );
316
309
 
317
- await userEvent.click(
318
- screen.getByRole( 'button', { name: 'Cancel' } )
319
- );
310
+ it( 'closes on escape key', async () => {
311
+ const onOpenChange = jest.fn();
320
312
 
321
- expect( onOpenChange ).toHaveBeenCalledWith(
322
- false,
323
- expect.objectContaining( { reason: 'close-press' } )
324
- );
325
- expect( onConfirm ).not.toHaveBeenCalled();
326
- } );
313
+ render(
314
+ <AlertDialog.Root open onOpenChange={ onOpenChange }>
315
+ <AlertDialog.Popup title="Escape Test">
316
+ Content
317
+ </AlertDialog.Popup>
318
+ </AlertDialog.Root>
319
+ );
327
320
 
328
- it( 'calls onConfirm and onOpenChange on confirm button click for irreversible intent', async () => {
329
- const onOpenChange = jest.fn();
330
- const onConfirm = jest.fn();
321
+ await waitFor( () => {
322
+ expect( screen.getByText( 'Escape Test' ) ).toBeVisible();
323
+ } );
331
324
 
332
- render(
333
- <AlertDialog.Root
334
- intent="irreversible"
335
- open
336
- onOpenChange={ onOpenChange }
337
- >
338
- <AlertDialog.Popup
339
- title="Irreversible Dialog"
340
- onConfirm={ onConfirm }
341
- >
342
- Content
343
- </AlertDialog.Popup>
344
- </AlertDialog.Root>
345
- );
325
+ await userEvent.keyboard( '{Escape}' );
346
326
 
347
- await waitFor( () => {
348
- expect(
349
- screen.getByRole( 'button', { name: 'OK' } )
350
- ).toBeVisible();
327
+ expect( onOpenChange ).toHaveBeenCalledWith(
328
+ false,
329
+ expect.objectContaining( { reason: 'escape-key' } )
330
+ );
351
331
  } );
352
332
 
353
- await userEvent.click( screen.getByRole( 'button', { name: 'OK' } ) );
333
+ it( 'does not close on backdrop click', async () => {
334
+ const onOpenChange = jest.fn();
354
335
 
355
- expect( onConfirm ).toHaveBeenCalledTimes( 1 );
356
- expect( onOpenChange ).toHaveBeenCalledWith(
357
- false,
358
- expect.objectContaining( { reason: 'close-press' } )
359
- );
360
- } );
361
-
362
- it( 'disables both buttons when loading', async () => {
363
- render(
364
- <AlertDialog.Root open onOpenChange={ jest.fn() }>
365
- <AlertDialog.Popup
366
- title="Loading Test"
367
- onConfirm={ jest.fn() }
368
- loading
369
- >
370
- Content
371
- </AlertDialog.Popup>
372
- </AlertDialog.Root>
373
- );
336
+ render(
337
+ <AlertDialog.Root open onOpenChange={ onOpenChange }>
338
+ <AlertDialog.Popup title="Backdrop Test">
339
+ Content
340
+ </AlertDialog.Popup>
341
+ </AlertDialog.Root>
342
+ );
374
343
 
375
- await waitFor( () => {
376
- expect(
377
- screen.getByRole( 'button', { name: 'OK' } )
378
- ).toBeVisible();
379
- } );
344
+ await waitFor( () => {
345
+ expect( screen.getByText( 'Backdrop Test' ) ).toBeVisible();
346
+ } );
380
347
 
381
- expect( screen.getByRole( 'button', { name: 'OK' } ) ).toHaveAttribute(
382
- 'aria-disabled',
383
- 'true'
384
- );
348
+ await userEvent.click( document.body );
385
349
 
386
- expect(
387
- screen.getByRole( 'button', { name: 'Cancel' } )
388
- ).toHaveAttribute( 'aria-disabled', 'true' );
350
+ expect( onOpenChange ).not.toHaveBeenCalled();
351
+ } );
389
352
  } );
390
353
 
391
- it( 'does not disable buttons when loading is false', async () => {
392
- render(
393
- <AlertDialog.Root open onOpenChange={ jest.fn() }>
394
- <AlertDialog.Popup
395
- title="No Loading"
396
- onConfirm={ jest.fn() }
397
- loading={ false }
398
- >
399
- Content
400
- </AlertDialog.Popup>
401
- </AlertDialog.Root>
402
- );
354
+ describe( 'irreversible intent', () => {
355
+ it( 'renders title and buttons', async () => {
356
+ render(
357
+ <AlertDialog.Root open onOpenChange={ jest.fn() }>
358
+ <AlertDialog.Popup
359
+ intent="irreversible"
360
+ title="Irreversible Dialog"
361
+ >
362
+ Irreversible message content
363
+ </AlertDialog.Popup>
364
+ </AlertDialog.Root>
365
+ );
403
366
 
404
- await waitFor( () => {
367
+ await waitFor( () => {
368
+ expect(
369
+ screen.getByText( 'Irreversible Dialog' )
370
+ ).toBeVisible();
371
+ } );
372
+
373
+ expect(
374
+ screen.getByText( 'Irreversible message content' )
375
+ ).toBeVisible();
405
376
  expect(
406
377
  screen.getByRole( 'button', { name: 'OK' } )
407
378
  ).toBeVisible();
379
+ expect(
380
+ screen.getByRole( 'button', { name: 'Cancel' } )
381
+ ).toBeVisible();
408
382
  } );
409
383
 
410
- expect(
411
- screen.getByRole( 'button', { name: 'OK' } )
412
- ).not.toHaveAttribute( 'aria-disabled', 'true' );
384
+ it( 'closes on escape key', async () => {
385
+ const onOpenChange = jest.fn();
413
386
 
414
- expect(
415
- screen.getByRole( 'button', { name: 'Cancel' } )
416
- ).not.toHaveAttribute( 'aria-disabled', 'true' );
417
- } );
387
+ render(
388
+ <AlertDialog.Root open onOpenChange={ onOpenChange }>
389
+ <AlertDialog.Popup
390
+ intent="irreversible"
391
+ title="Irreversible Dialog"
392
+ >
393
+ Content
394
+ </AlertDialog.Popup>
395
+ </AlertDialog.Root>
396
+ );
418
397
 
419
- it( 'uses custom button text when provided', async () => {
420
- render(
421
- <AlertDialog.Root open onOpenChange={ jest.fn() }>
422
- <AlertDialog.Popup
423
- title="Custom Text"
424
- onConfirm={ jest.fn() }
425
- confirmButtonText="Yes, do it"
426
- cancelButtonText="No, go back"
427
- >
428
- Custom message
429
- </AlertDialog.Popup>
430
- </AlertDialog.Root>
431
- );
398
+ await waitFor( () => {
399
+ expect(
400
+ screen.getByText( 'Irreversible Dialog' )
401
+ ).toBeVisible();
402
+ } );
432
403
 
433
- await waitFor( () => {
434
- expect(
435
- screen.getByRole( 'button', { name: 'Yes, do it' } )
436
- ).toBeVisible();
404
+ await userEvent.keyboard( '{Escape}' );
405
+
406
+ expect( onOpenChange ).toHaveBeenCalledWith(
407
+ false,
408
+ expect.objectContaining( { reason: 'escape-key' } )
409
+ );
437
410
  } );
438
411
 
439
- expect(
440
- screen.getByRole( 'button', { name: 'No, go back' } )
441
- ).toBeVisible();
412
+ it( 'does not close on backdrop click', async () => {
413
+ const onOpenChange = jest.fn();
414
+
415
+ render(
416
+ <AlertDialog.Root open onOpenChange={ onOpenChange }>
417
+ <AlertDialog.Popup
418
+ intent="irreversible"
419
+ title="Irreversible Dialog"
420
+ >
421
+ Content
422
+ </AlertDialog.Popup>
423
+ </AlertDialog.Root>
424
+ );
425
+
426
+ await waitFor( () => {
427
+ expect(
428
+ screen.getByText( 'Irreversible Dialog' )
429
+ ).toBeVisible();
430
+ } );
431
+
432
+ await userEvent.click( document.body );
433
+
434
+ expect( onOpenChange ).not.toHaveBeenCalled();
435
+ } );
442
436
  } );
443
437
 
444
- it( 'keeps dialog open when confirm is clicked with loading prop (async flow)', async () => {
445
- function AsyncDialog() {
446
- const [ isOpen, setIsOpen ] = useState( true );
447
- const [ isLoading, setIsLoading ] = useState( false );
438
+ describe( 'async confirm flow', () => {
439
+ it( 'disables buttons while confirm is pending', async () => {
440
+ const deferred = createDeferred();
448
441
 
449
- return (
442
+ render(
450
443
  <AlertDialog.Root
451
- open={ isOpen }
452
- onOpenChange={ ( open ) => {
453
- if ( ! isLoading ) {
454
- setIsOpen( open );
455
- }
456
- } }
444
+ open
445
+ onOpenChange={ jest.fn() }
446
+ onConfirm={ () => deferred.promise }
457
447
  >
458
- <AlertDialog.Popup
459
- title="Async Test"
460
- loading={ isLoading }
461
- onConfirm={ () => setIsLoading( true ) }
462
- >
448
+ <AlertDialog.Popup title="Async Test">
463
449
  Content
464
450
  </AlertDialog.Popup>
465
451
  </AlertDialog.Root>
466
452
  );
467
- }
468
453
 
469
- render( <AsyncDialog /> );
454
+ await waitFor( () => {
455
+ expect(
456
+ screen.getByRole( 'button', { name: 'OK' } )
457
+ ).toBeVisible();
458
+ } );
470
459
 
471
- await waitFor( () => {
472
- expect( screen.getByText( 'Async Test' ) ).toBeVisible();
460
+ await userEvent.click(
461
+ screen.getByRole( 'button', { name: 'OK' } )
462
+ );
463
+
464
+ await waitFor( () => {
465
+ expect(
466
+ screen.getByRole( 'button', { name: 'OK' } )
467
+ ).toHaveAttribute( 'aria-disabled', 'true' );
468
+ } );
469
+
470
+ expect(
471
+ screen.getByRole( 'button', { name: 'Cancel' } )
472
+ ).toHaveAttribute( 'aria-disabled', 'true' );
473
+
474
+ await act( async () => {
475
+ deferred.resolve();
476
+ } );
473
477
  } );
474
478
 
475
- await userEvent.click( screen.getByRole( 'button', { name: 'OK' } ) );
479
+ it( 'closes dialog when async confirm resolves', async () => {
480
+ const deferred = createDeferred();
481
+ const onOpenChange = jest.fn();
476
482
 
477
- expect( screen.getByText( 'Async Test' ) ).toBeVisible();
478
- expect( screen.getByRole( 'button', { name: 'OK' } ) ).toHaveAttribute(
479
- 'aria-disabled',
480
- 'true'
481
- );
483
+ render(
484
+ <AlertDialog.Root
485
+ open
486
+ onOpenChange={ onOpenChange }
487
+ onConfirm={ () => deferred.promise }
488
+ >
489
+ <AlertDialog.Popup title="Async Resolve">
490
+ Content
491
+ </AlertDialog.Popup>
492
+ </AlertDialog.Root>
493
+ );
494
+
495
+ await waitFor( () => {
496
+ expect(
497
+ screen.getByRole( 'button', { name: 'OK' } )
498
+ ).toBeVisible();
499
+ } );
500
+
501
+ await userEvent.click(
502
+ screen.getByRole( 'button', { name: 'OK' } )
503
+ );
504
+
505
+ await act( async () => {
506
+ deferred.resolve();
507
+ } );
508
+
509
+ await waitFor( () => {
510
+ expect( onOpenChange ).toHaveBeenCalledWith(
511
+ false,
512
+ expect.objectContaining( {
513
+ reason: 'imperative-action',
514
+ } )
515
+ );
516
+ } );
517
+ } );
518
+
519
+ it( 're-enables buttons when async confirm rejects (task failure)', async () => {
520
+ const deferred = createDeferred();
521
+ const consoleSpy = jest
522
+ .spyOn( console, 'error' )
523
+ .mockImplementation( () => {} );
524
+
525
+ render(
526
+ <AlertDialog.Root
527
+ open
528
+ onOpenChange={ jest.fn() }
529
+ onConfirm={ () => deferred.promise }
530
+ >
531
+ <AlertDialog.Popup title="Async Reject">
532
+ Content
533
+ </AlertDialog.Popup>
534
+ </AlertDialog.Root>
535
+ );
536
+
537
+ await waitFor( () => {
538
+ expect(
539
+ screen.getByRole( 'button', { name: 'OK' } )
540
+ ).toBeVisible();
541
+ } );
542
+
543
+ await userEvent.click(
544
+ screen.getByRole( 'button', { name: 'OK' } )
545
+ );
546
+
547
+ await waitFor( () => {
548
+ expect(
549
+ screen.getByRole( 'button', { name: 'OK' } )
550
+ ).toHaveAttribute( 'aria-disabled', 'true' );
551
+ } );
552
+
553
+ await act( async () => {
554
+ deferred.reject( new Error( 'Task failed' ) );
555
+ } );
556
+
557
+ // The error is caught and logged via console.error in Root.
558
+ await waitFor( () => {
559
+ expect( consoleSpy ).toHaveBeenCalledWith(
560
+ expect.objectContaining( { message: 'Task failed' } )
561
+ );
562
+ } );
563
+
564
+ await waitFor( () => {
565
+ expect(
566
+ screen.getByRole( 'button', { name: 'OK' } )
567
+ ).not.toHaveAttribute( 'aria-disabled', 'true' );
568
+ } );
569
+
570
+ expect(
571
+ screen.getByRole( 'button', { name: 'Cancel' } )
572
+ ).not.toHaveAttribute( 'aria-disabled', 'true' );
573
+
574
+ expect( screen.getByText( 'Async Reject' ) ).toBeVisible();
575
+
576
+ // Throws do NOT render a visible error message.
577
+ expect(
578
+ screen.queryByText( 'Task failed' )
579
+ ).not.toBeInTheDocument();
580
+
581
+ consoleSpy.mockRestore();
582
+ } );
583
+
584
+ it( 'keeps dialog open when confirm returns { close: false }', async () => {
585
+ const onOpenChange = jest.fn();
586
+
587
+ render(
588
+ <AlertDialog.Root
589
+ open
590
+ onOpenChange={ onOpenChange }
591
+ onConfirm={ () => ( { close: false } ) }
592
+ >
593
+ <AlertDialog.Popup title="Keep Open">
594
+ Content
595
+ </AlertDialog.Popup>
596
+ </AlertDialog.Root>
597
+ );
598
+
599
+ await waitFor( () => {
600
+ expect(
601
+ screen.getByRole( 'button', { name: 'OK' } )
602
+ ).toBeVisible();
603
+ } );
604
+
605
+ await userEvent.click(
606
+ screen.getByRole( 'button', { name: 'OK' } )
607
+ );
608
+
609
+ await waitFor( () => {
610
+ expect(
611
+ screen.getByRole( 'button', { name: 'OK' } )
612
+ ).not.toHaveAttribute( 'aria-disabled', 'true' );
613
+ } );
614
+
615
+ expect( onOpenChange ).not.toHaveBeenCalledWith(
616
+ false,
617
+ expect.anything()
618
+ );
619
+ expect( screen.getByText( 'Keep Open' ) ).toBeVisible();
620
+ } );
621
+
622
+ it( 'keeps dialog open when async confirm returns { close: false }', async () => {
623
+ const deferred = createDeferred();
624
+ const onOpenChange = jest.fn();
625
+
626
+ render(
627
+ <AlertDialog.Root
628
+ open
629
+ onOpenChange={ onOpenChange }
630
+ onConfirm={ () => deferred.promise }
631
+ >
632
+ <AlertDialog.Popup title="Async Keep Open">
633
+ Content
634
+ </AlertDialog.Popup>
635
+ </AlertDialog.Root>
636
+ );
637
+
638
+ await waitFor( () => {
639
+ expect(
640
+ screen.getByRole( 'button', { name: 'OK' } )
641
+ ).toBeVisible();
642
+ } );
643
+
644
+ await userEvent.click(
645
+ screen.getByRole( 'button', { name: 'OK' } )
646
+ );
647
+
648
+ await waitFor( () => {
649
+ expect(
650
+ screen.getByRole( 'button', { name: 'OK' } )
651
+ ).toHaveAttribute( 'aria-disabled', 'true' );
652
+ } );
653
+
654
+ await act( async () => {
655
+ deferred.resolve( { close: false } );
656
+ } );
657
+
658
+ await waitFor( () => {
659
+ expect(
660
+ screen.getByRole( 'button', { name: 'OK' } )
661
+ ).not.toHaveAttribute( 'aria-disabled', 'true' );
662
+ } );
663
+
664
+ expect( onOpenChange ).not.toHaveBeenCalledWith(
665
+ false,
666
+ expect.anything()
667
+ );
668
+ } );
669
+
670
+ it( 'blocks dismiss while pending by default', async () => {
671
+ const deferred = createDeferred();
672
+ const onOpenChange = jest.fn();
673
+
674
+ render(
675
+ <AlertDialog.Root
676
+ open
677
+ onOpenChange={ onOpenChange }
678
+ onConfirm={ () => deferred.promise }
679
+ >
680
+ <AlertDialog.Popup title="Block Dismiss">
681
+ Content
682
+ </AlertDialog.Popup>
683
+ </AlertDialog.Root>
684
+ );
685
+
686
+ await waitFor( () => {
687
+ expect(
688
+ screen.getByRole( 'button', { name: 'OK' } )
689
+ ).toBeVisible();
690
+ } );
691
+
692
+ await userEvent.click(
693
+ screen.getByRole( 'button', { name: 'OK' } )
694
+ );
695
+
696
+ await waitFor( () => {
697
+ expect(
698
+ screen.getByRole( 'button', { name: 'OK' } )
699
+ ).toHaveAttribute( 'aria-disabled', 'true' );
700
+ } );
701
+
702
+ await userEvent.keyboard( '{Escape}' );
703
+
704
+ expect( onOpenChange ).not.toHaveBeenCalledWith(
705
+ false,
706
+ expect.anything()
707
+ );
708
+
709
+ await act( async () => {
710
+ deferred.resolve();
711
+ } );
712
+ } );
713
+
714
+ it( 'ignores duplicate confirm clicks while pending', async () => {
715
+ const onConfirm = jest.fn(
716
+ () =>
717
+ new Promise< void >( () => {
718
+ // Never resolves
719
+ } )
720
+ );
721
+
722
+ render(
723
+ <AlertDialog.Root
724
+ open
725
+ onOpenChange={ jest.fn() }
726
+ onConfirm={ onConfirm }
727
+ >
728
+ <AlertDialog.Popup title="Double Click">
729
+ Content
730
+ </AlertDialog.Popup>
731
+ </AlertDialog.Root>
732
+ );
733
+
734
+ await waitFor( () => {
735
+ expect(
736
+ screen.getByRole( 'button', { name: 'OK' } )
737
+ ).toBeVisible();
738
+ } );
739
+
740
+ await userEvent.click(
741
+ screen.getByRole( 'button', { name: 'OK' } )
742
+ );
743
+ await userEvent.click(
744
+ screen.getByRole( 'button', { name: 'OK' } )
745
+ );
746
+
747
+ expect( onConfirm ).toHaveBeenCalledTimes( 1 );
748
+ } );
482
749
  } );
483
750
 
484
- it( 'does not auto-close on confirm click when loading is false (manual-close mode)', async () => {
485
- function ManualCloseDialog() {
486
- const [ isOpen, setIsOpen ] = useState( true );
751
+ describe( 'uncontrolled mode', () => {
752
+ it( 'renders dialog open when defaultOpen is true', async () => {
753
+ render(
754
+ <AlertDialog.Root defaultOpen>
755
+ <AlertDialog.Trigger>Open</AlertDialog.Trigger>
756
+ <AlertDialog.Popup title="Default Open">
757
+ Dialog content
758
+ </AlertDialog.Popup>
759
+ </AlertDialog.Root>
760
+ );
761
+
762
+ await waitFor( () => {
763
+ expect( screen.getByRole( 'alertdialog' ) ).toBeVisible();
764
+ } );
765
+ expect( screen.getByText( 'Default Open' ) ).toBeVisible();
766
+ expect( screen.getByText( 'Dialog content' ) ).toBeVisible();
767
+ } );
768
+
769
+ it( 'allows closing and reopening after defaultOpen', async () => {
770
+ render(
771
+ <AlertDialog.Root defaultOpen>
772
+ <AlertDialog.Trigger>Open</AlertDialog.Trigger>
773
+ <AlertDialog.Popup title="Reopen Test">
774
+ Content
775
+ </AlertDialog.Popup>
776
+ </AlertDialog.Root>
777
+ );
778
+
779
+ await waitFor( () => {
780
+ expect( screen.getByRole( 'alertdialog' ) ).toBeVisible();
781
+ } );
782
+
783
+ await userEvent.click(
784
+ screen.getByRole( 'button', { name: 'Cancel' } )
785
+ );
786
+
787
+ await waitFor( () => {
788
+ expect(
789
+ screen.queryByRole( 'alertdialog' )
790
+ ).not.toBeInTheDocument();
791
+ } );
792
+
793
+ await userEvent.click(
794
+ screen.getByRole( 'button', { name: 'Open' } )
795
+ );
796
+
797
+ await waitFor( () => {
798
+ expect( screen.getByRole( 'alertdialog' ) ).toBeVisible();
799
+ } );
800
+ } );
801
+
802
+ it( 'opens and closes via cancel', async () => {
803
+ const onConfirm = jest.fn();
804
+
805
+ render(
806
+ <AlertDialog.Root onConfirm={ onConfirm }>
807
+ <AlertDialog.Trigger>Open</AlertDialog.Trigger>
808
+ <AlertDialog.Popup title="Uncontrolled">
809
+ Content
810
+ </AlertDialog.Popup>
811
+ </AlertDialog.Root>
812
+ );
813
+
814
+ expect( screen.queryByText( 'Content' ) ).not.toBeInTheDocument();
815
+
816
+ await userEvent.click(
817
+ screen.getByRole( 'button', { name: 'Open' } )
818
+ );
819
+
820
+ await waitFor( () => {
821
+ expect( screen.getByText( 'Uncontrolled' ) ).toBeVisible();
822
+ } );
823
+
824
+ await userEvent.click(
825
+ screen.getByRole( 'button', { name: 'Cancel' } )
826
+ );
827
+
828
+ await waitFor( () => {
829
+ expect(
830
+ screen.queryByText( 'Uncontrolled' )
831
+ ).not.toBeInTheDocument();
832
+ } );
833
+ } );
834
+
835
+ it( 'closes and unmounts dialog via confirm click', async () => {
836
+ const onConfirm = jest.fn();
837
+
838
+ render(
839
+ <AlertDialog.Root onConfirm={ onConfirm }>
840
+ <AlertDialog.Trigger>Open</AlertDialog.Trigger>
841
+ <AlertDialog.Popup title="Uncontrolled Confirm">
842
+ Content
843
+ </AlertDialog.Popup>
844
+ </AlertDialog.Root>
845
+ );
846
+
847
+ await userEvent.click(
848
+ screen.getByRole( 'button', { name: 'Open' } )
849
+ );
850
+
851
+ await waitFor( () => {
852
+ expect(
853
+ screen.getByText( 'Uncontrolled Confirm' )
854
+ ).toBeVisible();
855
+ } );
856
+
857
+ await userEvent.click(
858
+ screen.getByRole( 'button', { name: 'OK' } )
859
+ );
860
+
861
+ expect( onConfirm ).toHaveBeenCalledTimes( 1 );
862
+
863
+ await waitFor( () => {
864
+ expect(
865
+ screen.queryByRole( 'alertdialog' )
866
+ ).not.toBeInTheDocument();
867
+ } );
868
+ } );
869
+ } );
870
+
871
+ describe( 'edge cases', () => {
872
+ it( 'does not error when unmounted during pending', async () => {
873
+ const deferred = createDeferred();
874
+
875
+ const { unmount } = render(
876
+ <AlertDialog.Root
877
+ open
878
+ onOpenChange={ jest.fn() }
879
+ onConfirm={ () => deferred.promise }
880
+ >
881
+ <AlertDialog.Popup title="Unmount Test">
882
+ Content
883
+ </AlertDialog.Popup>
884
+ </AlertDialog.Root>
885
+ );
886
+
887
+ await waitFor( () => {
888
+ expect(
889
+ screen.getByRole( 'button', { name: 'OK' } )
890
+ ).toBeVisible();
891
+ } );
892
+
893
+ await userEvent.click(
894
+ screen.getByRole( 'button', { name: 'OK' } )
895
+ );
896
+
897
+ await waitFor( () => {
898
+ expect(
899
+ screen.getByRole( 'button', { name: 'OK' } )
900
+ ).toHaveAttribute( 'aria-disabled', 'true' );
901
+ } );
902
+
903
+ // Unmount while pending — should not throw
904
+ unmount();
905
+
906
+ // Resolve the deferred — should be a no-op after unmount
907
+ await act( async () => {
908
+ deferred.resolve();
909
+ } );
910
+ } );
911
+
912
+ it( 'controlled mode: recovers to idle when consumer keeps dialog open after confirm', async () => {
913
+ const onConfirm = jest.fn();
914
+
915
+ render(
916
+ <AlertDialog.Root
917
+ open
918
+ onOpenChange={ jest.fn() }
919
+ onConfirm={ onConfirm }
920
+ >
921
+ <AlertDialog.Popup title="Deadlock Test">
922
+ Content
923
+ </AlertDialog.Popup>
924
+ </AlertDialog.Root>
925
+ );
926
+
927
+ await waitFor( () => {
928
+ expect(
929
+ screen.getByRole( 'button', { name: 'OK' } )
930
+ ).toBeVisible();
931
+ } );
932
+
933
+ await userEvent.click(
934
+ screen.getByRole( 'button', { name: 'OK' } )
935
+ );
936
+
937
+ expect( onConfirm ).toHaveBeenCalledTimes( 1 );
938
+
939
+ // Consumer passes open={true} and does NOT update it in
940
+ // onOpenChange, so phase would be stuck at 'closing'.
941
+ // The safety-net useEffect should recover phase to 'idle'.
942
+ await waitFor( () => {
943
+ expect(
944
+ screen.getByRole( 'button', { name: 'OK' } )
945
+ ).not.toHaveAttribute( 'aria-disabled', 'true' );
946
+ } );
947
+
948
+ expect(
949
+ screen.getByRole( 'button', { name: 'Cancel' } )
950
+ ).not.toHaveAttribute( 'aria-disabled', 'true' );
951
+ } );
952
+
953
+ it( 'recovers when onConfirm throws synchronously', async () => {
954
+ const onConfirm = jest.fn( () => {
955
+ throw new Error( 'Sync error' );
956
+ } );
957
+ const onOpenChange = jest.fn();
958
+ const consoleSpy = jest
959
+ .spyOn( console, 'error' )
960
+ .mockImplementation( () => {} );
487
961
 
488
- return (
962
+ render(
489
963
  <AlertDialog.Root
490
- open={ isOpen }
491
- onOpenChange={ ( open ) => setIsOpen( open ) }
964
+ open
965
+ onOpenChange={ onOpenChange }
966
+ onConfirm={ onConfirm }
492
967
  >
968
+ <AlertDialog.Popup title="Throw Test">
969
+ Content
970
+ </AlertDialog.Popup>
971
+ </AlertDialog.Root>
972
+ );
973
+
974
+ await waitFor( () => {
975
+ expect(
976
+ screen.getByRole( 'button', { name: 'OK' } )
977
+ ).toBeVisible();
978
+ } );
979
+
980
+ await userEvent.click(
981
+ screen.getByRole( 'button', { name: 'OK' } )
982
+ );
983
+
984
+ // The error is caught and logged via console.error in Root.
985
+ await waitFor( () => {
986
+ expect( consoleSpy ).toHaveBeenCalledWith(
987
+ expect.objectContaining( { message: 'Sync error' } )
988
+ );
989
+ } );
990
+
991
+ // Dialog stays open and buttons return to idle
992
+ await waitFor( () => {
993
+ expect(
994
+ screen.getByRole( 'button', { name: 'OK' } )
995
+ ).not.toHaveAttribute( 'aria-disabled', 'true' );
996
+ } );
997
+
998
+ expect( onOpenChange ).not.toHaveBeenCalledWith(
999
+ false,
1000
+ expect.anything()
1001
+ );
1002
+ expect( screen.getByText( 'Throw Test' ) ).toBeVisible();
1003
+
1004
+ // Throws do NOT render a visible error message.
1005
+ expect(
1006
+ screen.queryByText( 'Sync error' )
1007
+ ).not.toBeInTheDocument();
1008
+
1009
+ consoleSpy.mockRestore();
1010
+ } );
1011
+
1012
+ it( 'sets aria-describedby when description is provided', async () => {
1013
+ render(
1014
+ <AlertDialog.Root open onOpenChange={ jest.fn() }>
493
1015
  <AlertDialog.Popup
494
- title="Manual Close"
495
- loading={ false }
496
- onConfirm={ jest.fn() }
1016
+ title="Describedby Test"
1017
+ description="A helpful description"
497
1018
  >
498
1019
  Content
499
1020
  </AlertDialog.Popup>
500
1021
  </AlertDialog.Root>
501
1022
  );
502
- }
503
1023
 
504
- render( <ManualCloseDialog /> );
1024
+ await waitFor( () => {
1025
+ expect( screen.getByRole( 'alertdialog' ) ).toBeVisible();
1026
+ } );
505
1027
 
506
- await waitFor( () => {
507
- expect( screen.getByText( 'Manual Close' ) ).toBeVisible();
1028
+ const dialog = screen.getByRole( 'alertdialog' );
1029
+ expect( dialog ).toHaveAccessibleDescription(
1030
+ 'A helpful description'
1031
+ );
508
1032
  } );
509
1033
 
510
- await userEvent.click( screen.getByRole( 'button', { name: 'OK' } ) );
1034
+ it( 'allows re-confirm after { close: false, error }', async () => {
1035
+ const deferred = createDeferred();
1036
+ const onOpenChange = jest.fn();
1037
+
1038
+ render(
1039
+ <AlertDialog.Root
1040
+ open
1041
+ onOpenChange={ onOpenChange }
1042
+ onConfirm={ () => deferred.promise }
1043
+ >
1044
+ <AlertDialog.Popup title="Error Retry">
1045
+ Content
1046
+ </AlertDialog.Popup>
1047
+ </AlertDialog.Root>
1048
+ );
1049
+
1050
+ await waitFor( () => {
1051
+ expect(
1052
+ screen.getByRole( 'button', { name: 'OK' } )
1053
+ ).toBeVisible();
1054
+ } );
1055
+
1056
+ // First confirm — returns error
1057
+ await userEvent.click(
1058
+ screen.getByRole( 'button', { name: 'OK' } )
1059
+ );
1060
+
1061
+ await act( async () => {
1062
+ deferred.resolve( {
1063
+ close: false,
1064
+ error: 'Validation failed',
1065
+ } );
1066
+ } );
1067
+
1068
+ await waitFor( () => {
1069
+ expect( screen.getByText( 'Validation failed' ) ).toBeVisible();
1070
+ } );
1071
+
1072
+ // Buttons are re-enabled, user can retry
1073
+ expect(
1074
+ screen.getByRole( 'button', { name: 'OK' } )
1075
+ ).not.toHaveAttribute( 'aria-disabled', 'true' );
1076
+ } );
1077
+
1078
+ it( 'allows re-confirm after { close: false }', async () => {
1079
+ let callCount = 0;
1080
+ const onConfirm = jest.fn( (): { close: boolean } | undefined => {
1081
+ callCount++;
1082
+ if ( callCount === 1 ) {
1083
+ return { close: false };
1084
+ }
1085
+ return undefined;
1086
+ } );
1087
+ const onOpenChange = jest.fn();
1088
+
1089
+ render(
1090
+ <AlertDialog.Root
1091
+ open
1092
+ onOpenChange={ onOpenChange }
1093
+ onConfirm={ onConfirm }
1094
+ >
1095
+ <AlertDialog.Popup title="Retry Test">
1096
+ Content
1097
+ </AlertDialog.Popup>
1098
+ </AlertDialog.Root>
1099
+ );
1100
+
1101
+ await waitFor( () => {
1102
+ expect(
1103
+ screen.getByRole( 'button', { name: 'OK' } )
1104
+ ).toBeVisible();
1105
+ } );
1106
+
1107
+ // First confirm — returns { close: false }
1108
+ await userEvent.click(
1109
+ screen.getByRole( 'button', { name: 'OK' } )
1110
+ );
1111
+
1112
+ await waitFor( () => {
1113
+ expect(
1114
+ screen.getByRole( 'button', { name: 'OK' } )
1115
+ ).not.toHaveAttribute( 'aria-disabled', 'true' );
1116
+ } );
1117
+
1118
+ expect( onOpenChange ).not.toHaveBeenCalledWith(
1119
+ false,
1120
+ expect.anything()
1121
+ );
511
1122
 
512
- expect( screen.getByText( 'Manual Close' ) ).toBeVisible();
1123
+ // Second confirm returns void → should close
1124
+ await userEvent.click(
1125
+ screen.getByRole( 'button', { name: 'OK' } )
1126
+ );
1127
+
1128
+ await waitFor( () => {
1129
+ expect( onOpenChange ).toHaveBeenCalledWith(
1130
+ false,
1131
+ expect.objectContaining( {
1132
+ reason: 'imperative-action',
1133
+ } )
1134
+ );
1135
+ } );
1136
+
1137
+ expect( onConfirm ).toHaveBeenCalledTimes( 2 );
1138
+ } );
513
1139
  } );
514
1140
 
515
- it( 'opens dialog when Trigger is clicked', async () => {
516
- render(
517
- <AlertDialog.Root>
518
- <AlertDialog.Trigger>Open</AlertDialog.Trigger>
519
- <AlertDialog.Popup title="Trigger Test" onConfirm={ jest.fn() }>
520
- Dialog content
521
- </AlertDialog.Popup>
522
- </AlertDialog.Root>
523
- );
1141
+ describe( 'error handling', () => {
1142
+ beforeEach( () => {
1143
+ ( speak as jest.Mock ).mockClear();
1144
+ } );
524
1145
 
525
- expect(
526
- screen.queryByText( 'Dialog content' )
527
- ).not.toBeInTheDocument();
1146
+ it( 'displays error message when onConfirm returns { close: false, error }', async () => {
1147
+ render(
1148
+ <AlertDialog.Root
1149
+ open
1150
+ onOpenChange={ jest.fn() }
1151
+ onConfirm={ () => ( {
1152
+ close: false,
1153
+ error: 'Something went wrong.',
1154
+ } ) }
1155
+ >
1156
+ <AlertDialog.Popup title="Error Test">
1157
+ Content
1158
+ </AlertDialog.Popup>
1159
+ </AlertDialog.Root>
1160
+ );
528
1161
 
529
- await userEvent.click( screen.getByRole( 'button', { name: 'Open' } ) );
1162
+ await waitFor( () => {
1163
+ expect(
1164
+ screen.getByRole( 'button', { name: 'OK' } )
1165
+ ).toBeVisible();
1166
+ } );
530
1167
 
531
- await waitFor( () => {
532
- expect( screen.getByText( 'Trigger Test' ) ).toBeVisible();
1168
+ await userEvent.click(
1169
+ screen.getByRole( 'button', { name: 'OK' } )
1170
+ );
1171
+
1172
+ await waitFor( () => {
1173
+ expect(
1174
+ screen.getByText( 'Something went wrong.' )
1175
+ ).toBeVisible();
1176
+ } );
533
1177
  } );
534
1178
 
535
- expect( screen.getByText( 'Dialog content' ) ).toBeVisible();
1179
+ it( 'displays error message from async onConfirm', async () => {
1180
+ const deferred = createDeferred();
1181
+
1182
+ render(
1183
+ <AlertDialog.Root
1184
+ open
1185
+ onOpenChange={ jest.fn() }
1186
+ onConfirm={ () => deferred.promise }
1187
+ >
1188
+ <AlertDialog.Popup title="Async Error">
1189
+ Content
1190
+ </AlertDialog.Popup>
1191
+ </AlertDialog.Root>
1192
+ );
1193
+
1194
+ await waitFor( () => {
1195
+ expect(
1196
+ screen.getByRole( 'button', { name: 'OK' } )
1197
+ ).toBeVisible();
1198
+ } );
1199
+
1200
+ await userEvent.click(
1201
+ screen.getByRole( 'button', { name: 'OK' } )
1202
+ );
1203
+
1204
+ await act( async () => {
1205
+ deferred.resolve( {
1206
+ close: false,
1207
+ error: 'Server error occurred.',
1208
+ } );
1209
+ } );
1210
+
1211
+ await waitFor( () => {
1212
+ expect(
1213
+ screen.getByText( 'Server error occurred.' )
1214
+ ).toBeVisible();
1215
+ } );
1216
+
1217
+ // Buttons return to idle
1218
+ expect(
1219
+ screen.getByRole( 'button', { name: 'OK' } )
1220
+ ).not.toHaveAttribute( 'aria-disabled', 'true' );
1221
+ } );
1222
+
1223
+ it( 'stays open when error is returned without explicit close: false', async () => {
1224
+ const onOpenChange = jest.fn();
1225
+
1226
+ render(
1227
+ <AlertDialog.Root
1228
+ open
1229
+ onOpenChange={ onOpenChange }
1230
+ onConfirm={ () => ( { error: 'Implicit stay open.' } ) }
1231
+ >
1232
+ <AlertDialog.Popup title="Implicit Close">
1233
+ Content
1234
+ </AlertDialog.Popup>
1235
+ </AlertDialog.Root>
1236
+ );
1237
+
1238
+ await waitFor( () => {
1239
+ expect(
1240
+ screen.getByRole( 'button', { name: 'OK' } )
1241
+ ).toBeVisible();
1242
+ } );
1243
+
1244
+ await userEvent.click(
1245
+ screen.getByRole( 'button', { name: 'OK' } )
1246
+ );
1247
+
1248
+ await waitFor( () => {
1249
+ expect(
1250
+ screen.getByText( 'Implicit stay open.' )
1251
+ ).toBeVisible();
1252
+ } );
1253
+
1254
+ // Dialog stays open — onOpenChange(false) was not called
1255
+ expect( onOpenChange ).not.toHaveBeenCalledWith(
1256
+ false,
1257
+ expect.anything()
1258
+ );
1259
+ } );
1260
+
1261
+ it( 'clears error message on next confirm attempt', async () => {
1262
+ let callCount = 0;
1263
+ const onConfirm = jest.fn( (): ConfirmResult => {
1264
+ callCount++;
1265
+ if ( callCount === 1 ) {
1266
+ return {
1267
+ close: false,
1268
+ error: 'First attempt failed.',
1269
+ };
1270
+ }
1271
+ return undefined;
1272
+ } );
1273
+
1274
+ render(
1275
+ <AlertDialog.Root
1276
+ open
1277
+ onOpenChange={ jest.fn() }
1278
+ onConfirm={ onConfirm }
1279
+ >
1280
+ <AlertDialog.Popup title="Clear Error">
1281
+ Content
1282
+ </AlertDialog.Popup>
1283
+ </AlertDialog.Root>
1284
+ );
1285
+
1286
+ await waitFor( () => {
1287
+ expect(
1288
+ screen.getByRole( 'button', { name: 'OK' } )
1289
+ ).toBeVisible();
1290
+ } );
1291
+
1292
+ // First confirm — shows error
1293
+ await userEvent.click(
1294
+ screen.getByRole( 'button', { name: 'OK' } )
1295
+ );
1296
+
1297
+ await waitFor( () => {
1298
+ expect(
1299
+ screen.getByText( 'First attempt failed.' )
1300
+ ).toBeVisible();
1301
+ } );
1302
+
1303
+ // Second confirm — error should be cleared
1304
+ await userEvent.click(
1305
+ screen.getByRole( 'button', { name: 'OK' } )
1306
+ );
1307
+
1308
+ await waitFor( () => {
1309
+ expect(
1310
+ screen.queryByText( 'First attempt failed.' )
1311
+ ).not.toBeInTheDocument();
1312
+ } );
1313
+ } );
1314
+
1315
+ it( 'clears error message when dialog reopens', async () => {
1316
+ render(
1317
+ <AlertDialog.Root
1318
+ onConfirm={ () => ( {
1319
+ close: false,
1320
+ error: 'Persistent error.',
1321
+ } ) }
1322
+ >
1323
+ <AlertDialog.Trigger>Open</AlertDialog.Trigger>
1324
+ <AlertDialog.Popup title="Reopen Clear">
1325
+ Content
1326
+ </AlertDialog.Popup>
1327
+ </AlertDialog.Root>
1328
+ );
1329
+
1330
+ // Open dialog
1331
+ await userEvent.click(
1332
+ screen.getByRole( 'button', { name: 'Open' } )
1333
+ );
1334
+
1335
+ await waitFor( () => {
1336
+ expect(
1337
+ screen.getByRole( 'button', { name: 'OK' } )
1338
+ ).toBeVisible();
1339
+ } );
1340
+
1341
+ // Trigger error
1342
+ await userEvent.click(
1343
+ screen.getByRole( 'button', { name: 'OK' } )
1344
+ );
1345
+
1346
+ await waitFor( () => {
1347
+ expect( screen.getByText( 'Persistent error.' ) ).toBeVisible();
1348
+ } );
1349
+
1350
+ // Close via cancel
1351
+ await userEvent.click(
1352
+ screen.getByRole( 'button', { name: 'Cancel' } )
1353
+ );
1354
+
1355
+ await waitFor( () => {
1356
+ expect(
1357
+ screen.queryByRole( 'alertdialog' )
1358
+ ).not.toBeInTheDocument();
1359
+ } );
1360
+
1361
+ // Reopen — error should be gone
1362
+ await userEvent.click(
1363
+ screen.getByRole( 'button', { name: 'Open' } )
1364
+ );
1365
+
1366
+ await waitFor( () => {
1367
+ expect(
1368
+ screen.getByRole( 'button', { name: 'OK' } )
1369
+ ).toBeVisible();
1370
+ } );
1371
+
1372
+ expect(
1373
+ screen.queryByText( 'Persistent error.' )
1374
+ ).not.toBeInTheDocument();
1375
+ } );
1376
+
1377
+ it( 'announces error message to screen readers via speak()', async () => {
1378
+ render(
1379
+ <AlertDialog.Root
1380
+ open
1381
+ onOpenChange={ jest.fn() }
1382
+ onConfirm={ () => ( {
1383
+ close: false,
1384
+ error: 'Announced error.',
1385
+ } ) }
1386
+ >
1387
+ <AlertDialog.Popup title="Speak Test">
1388
+ Content
1389
+ </AlertDialog.Popup>
1390
+ </AlertDialog.Root>
1391
+ );
1392
+
1393
+ await waitFor( () => {
1394
+ expect(
1395
+ screen.getByRole( 'button', { name: 'OK' } )
1396
+ ).toBeVisible();
1397
+ } );
1398
+
1399
+ await userEvent.click(
1400
+ screen.getByRole( 'button', { name: 'OK' } )
1401
+ );
1402
+
1403
+ await waitFor( () => {
1404
+ expect( speak ).toHaveBeenCalledWith(
1405
+ 'Announced error.',
1406
+ 'assertive'
1407
+ );
1408
+ } );
1409
+ } );
1410
+
1411
+ it( 'does not show error message when onConfirm throws', async () => {
1412
+ const consoleSpy = jest
1413
+ .spyOn( console, 'error' )
1414
+ .mockImplementation( () => {} );
1415
+
1416
+ render(
1417
+ <AlertDialog.Root
1418
+ open
1419
+ onOpenChange={ jest.fn() }
1420
+ onConfirm={ () => {
1421
+ throw new Error( 'Unhandled throw' );
1422
+ } }
1423
+ >
1424
+ <AlertDialog.Popup title="No Error Display">
1425
+ Content
1426
+ </AlertDialog.Popup>
1427
+ </AlertDialog.Root>
1428
+ );
1429
+
1430
+ await waitFor( () => {
1431
+ expect(
1432
+ screen.getByRole( 'button', { name: 'OK' } )
1433
+ ).toBeVisible();
1434
+ } );
1435
+
1436
+ await userEvent.click(
1437
+ screen.getByRole( 'button', { name: 'OK' } )
1438
+ );
1439
+
1440
+ await waitFor( () => {
1441
+ expect( consoleSpy ).toHaveBeenCalledWith(
1442
+ expect.objectContaining( { message: 'Unhandled throw' } )
1443
+ );
1444
+ } );
1445
+
1446
+ // No error message rendered — throws don't trigger the error UI
1447
+ expect(
1448
+ screen.queryByText( 'Unhandled throw' )
1449
+ ).not.toBeInTheDocument();
1450
+ expect( speak ).not.toHaveBeenCalled();
1451
+
1452
+ consoleSpy.mockRestore();
1453
+ } );
536
1454
  } );
537
1455
  } );