@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
@@ -0,0 +1,727 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { Component, createRef, useState } from '@wordpress/element';
4
+ import * as Popover from '../index';
5
+
6
+ describe( 'Popover', () => {
7
+ describe( 'forwards ref', () => {
8
+ it( 'should forward ref on Trigger', () => {
9
+ const ref = createRef< HTMLButtonElement >();
10
+ render(
11
+ <Popover.Root>
12
+ <Popover.Trigger ref={ ref }>Open</Popover.Trigger>
13
+ <Popover.Popup>
14
+ <Popover.Title>Title</Popover.Title>
15
+ </Popover.Popup>
16
+ </Popover.Root>
17
+ );
18
+ expect( ref.current ).toBeInstanceOf( HTMLButtonElement );
19
+ } );
20
+
21
+ it( 'should forward ref on Popup', async () => {
22
+ const user = userEvent.setup();
23
+ const ref = createRef< HTMLDivElement >();
24
+
25
+ render(
26
+ <Popover.Root>
27
+ <Popover.Trigger>Open</Popover.Trigger>
28
+ <Popover.Popup ref={ ref }>
29
+ <Popover.Title>Title</Popover.Title>
30
+ </Popover.Popup>
31
+ </Popover.Root>
32
+ );
33
+
34
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
35
+
36
+ await waitFor( () => {
37
+ expect( ref.current ).toBeInstanceOf( HTMLDivElement );
38
+ } );
39
+ } );
40
+
41
+ it( 'should forward ref on Arrow', async () => {
42
+ const user = userEvent.setup();
43
+ const ref = createRef< HTMLDivElement >();
44
+
45
+ render(
46
+ <Popover.Root>
47
+ <Popover.Trigger>Open</Popover.Trigger>
48
+ <Popover.Popup>
49
+ <Popover.Title>Title</Popover.Title>
50
+ <Popover.Arrow ref={ ref } />
51
+ </Popover.Popup>
52
+ </Popover.Root>
53
+ );
54
+
55
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
56
+
57
+ await waitFor( () => {
58
+ expect( ref.current ).toBeInstanceOf( HTMLDivElement );
59
+ } );
60
+ } );
61
+
62
+ it( 'should forward ref on Title', async () => {
63
+ const user = userEvent.setup();
64
+ const ref = createRef< HTMLHeadingElement >();
65
+
66
+ render(
67
+ <Popover.Root>
68
+ <Popover.Trigger>Open</Popover.Trigger>
69
+ <Popover.Popup>
70
+ <Popover.Title ref={ ref }>Title</Popover.Title>
71
+ </Popover.Popup>
72
+ </Popover.Root>
73
+ );
74
+
75
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
76
+
77
+ await waitFor( () => {
78
+ expect( ref.current ).toBeInstanceOf( HTMLHeadingElement );
79
+ } );
80
+ } );
81
+
82
+ it( 'should forward ref on Description', async () => {
83
+ const user = userEvent.setup();
84
+ const ref = createRef< HTMLParagraphElement >();
85
+
86
+ render(
87
+ <Popover.Root>
88
+ <Popover.Trigger>Open</Popover.Trigger>
89
+ <Popover.Popup>
90
+ <Popover.Title>Title</Popover.Title>
91
+ <Popover.Description ref={ ref }>
92
+ Description
93
+ </Popover.Description>
94
+ </Popover.Popup>
95
+ </Popover.Root>
96
+ );
97
+
98
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
99
+
100
+ await waitFor( () => {
101
+ expect( ref.current ).toBeInstanceOf( HTMLParagraphElement );
102
+ } );
103
+ } );
104
+
105
+ it( 'should forward ref on Close', async () => {
106
+ const user = userEvent.setup();
107
+ const ref = createRef< HTMLButtonElement >();
108
+
109
+ render(
110
+ <Popover.Root>
111
+ <Popover.Trigger>Open</Popover.Trigger>
112
+ <Popover.Popup>
113
+ <Popover.Title>Title</Popover.Title>
114
+ <Popover.Close ref={ ref }>Close</Popover.Close>
115
+ </Popover.Popup>
116
+ </Popover.Root>
117
+ );
118
+
119
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
120
+
121
+ await waitFor( () => {
122
+ expect( ref.current ).toBeInstanceOf( HTMLButtonElement );
123
+ } );
124
+ } );
125
+ } );
126
+
127
+ describe( 'open and close behavior', () => {
128
+ it( 'should open the popover when the trigger is clicked', async () => {
129
+ const user = userEvent.setup();
130
+
131
+ render(
132
+ <Popover.Root>
133
+ <Popover.Trigger>Open</Popover.Trigger>
134
+ <Popover.Popup>
135
+ <Popover.Title>Title</Popover.Title>
136
+ Popover content
137
+ </Popover.Popup>
138
+ </Popover.Root>
139
+ );
140
+
141
+ expect(
142
+ screen.queryByText( 'Popover content' )
143
+ ).not.toBeInTheDocument();
144
+
145
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
146
+
147
+ expect(
148
+ await screen.findByText( 'Popover content' )
149
+ ).toBeVisible();
150
+ } );
151
+
152
+ it( 'should close the popover when clicking the trigger again', async () => {
153
+ const user = userEvent.setup();
154
+
155
+ render(
156
+ <Popover.Root>
157
+ <Popover.Trigger>Toggle</Popover.Trigger>
158
+ <Popover.Popup>
159
+ <Popover.Title>Title</Popover.Title>
160
+ Popover content
161
+ </Popover.Popup>
162
+ </Popover.Root>
163
+ );
164
+
165
+ const trigger = screen.getByRole( 'button', {
166
+ name: 'Toggle',
167
+ } );
168
+
169
+ await user.click( trigger );
170
+ expect(
171
+ await screen.findByText( 'Popover content' )
172
+ ).toBeVisible();
173
+
174
+ await user.click( trigger );
175
+ await waitFor( () => {
176
+ expect(
177
+ screen.queryByText( 'Popover content' )
178
+ ).not.toBeInTheDocument();
179
+ } );
180
+ } );
181
+
182
+ it( 'should close the popover when Escape is pressed', async () => {
183
+ const user = userEvent.setup();
184
+
185
+ render(
186
+ <Popover.Root>
187
+ <Popover.Trigger>Open</Popover.Trigger>
188
+ <Popover.Popup>
189
+ <Popover.Title>Title</Popover.Title>
190
+ Popover content
191
+ </Popover.Popup>
192
+ </Popover.Root>
193
+ );
194
+
195
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
196
+
197
+ expect(
198
+ await screen.findByText( 'Popover content' )
199
+ ).toBeVisible();
200
+
201
+ await user.keyboard( '{Escape}' );
202
+
203
+ await waitFor( () => {
204
+ expect(
205
+ screen.queryByText( 'Popover content' )
206
+ ).not.toBeInTheDocument();
207
+ } );
208
+ } );
209
+
210
+ it( 'should close the popover when the Close button is clicked', async () => {
211
+ const user = userEvent.setup();
212
+
213
+ render(
214
+ <Popover.Root>
215
+ <Popover.Trigger>Open</Popover.Trigger>
216
+ <Popover.Popup>
217
+ <Popover.Title>Title</Popover.Title>
218
+ Popover content
219
+ <Popover.Close>Close</Popover.Close>
220
+ </Popover.Popup>
221
+ </Popover.Root>
222
+ );
223
+
224
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
225
+
226
+ expect(
227
+ await screen.findByText( 'Popover content' )
228
+ ).toBeVisible();
229
+
230
+ await user.click( screen.getByRole( 'button', { name: 'Close' } ) );
231
+
232
+ await waitFor( () => {
233
+ expect(
234
+ screen.queryByText( 'Popover content' )
235
+ ).not.toBeInTheDocument();
236
+ } );
237
+ } );
238
+ } );
239
+
240
+ describe( 'controlled mode', () => {
241
+ function ControlledPopover() {
242
+ const [ open, setOpen ] = useState( false );
243
+ return (
244
+ <>
245
+ <button onClick={ () => setOpen( true ) }>
246
+ External open
247
+ </button>
248
+ <Popover.Root open={ open } onOpenChange={ setOpen }>
249
+ <Popover.Trigger>Trigger</Popover.Trigger>
250
+ <Popover.Popup>
251
+ <Popover.Title>Title</Popover.Title>
252
+ Controlled content
253
+ </Popover.Popup>
254
+ </Popover.Root>
255
+ </>
256
+ );
257
+ }
258
+
259
+ it( 'should open via external state', async () => {
260
+ const user = userEvent.setup();
261
+
262
+ render( <ControlledPopover /> );
263
+
264
+ expect(
265
+ screen.queryByText( 'Controlled content' )
266
+ ).not.toBeInTheDocument();
267
+
268
+ await user.click(
269
+ screen.getByRole( 'button', { name: 'External open' } )
270
+ );
271
+
272
+ expect(
273
+ await screen.findByText( 'Controlled content' )
274
+ ).toBeVisible();
275
+ } );
276
+ } );
277
+
278
+ describe( 'defaultOpen', () => {
279
+ it( 'should render open initially when defaultOpen is true', async () => {
280
+ render(
281
+ <Popover.Root defaultOpen>
282
+ <Popover.Trigger>Open</Popover.Trigger>
283
+ <Popover.Popup>
284
+ <Popover.Title>Title</Popover.Title>
285
+ Default open content
286
+ </Popover.Popup>
287
+ </Popover.Root>
288
+ );
289
+
290
+ expect(
291
+ await screen.findByText( 'Default open content' )
292
+ ).toBeVisible();
293
+ } );
294
+ } );
295
+
296
+ describe( 'onOpenChange callback', () => {
297
+ it( 'should call onOpenChange when the popover opens and closes', async () => {
298
+ const user = userEvent.setup();
299
+ const onOpenChange = jest.fn();
300
+
301
+ render(
302
+ <Popover.Root onOpenChange={ onOpenChange }>
303
+ <Popover.Trigger>Toggle</Popover.Trigger>
304
+ <Popover.Popup>
305
+ <Popover.Title>Title</Popover.Title>
306
+ </Popover.Popup>
307
+ </Popover.Root>
308
+ );
309
+
310
+ await user.click(
311
+ screen.getByRole( 'button', { name: 'Toggle' } )
312
+ );
313
+
314
+ await waitFor( () => {
315
+ expect( onOpenChange ).toHaveBeenCalledWith(
316
+ true,
317
+ expect.anything()
318
+ );
319
+ } );
320
+
321
+ await user.click(
322
+ screen.getByRole( 'button', { name: 'Toggle' } )
323
+ );
324
+
325
+ await waitFor( () => {
326
+ expect( onOpenChange ).toHaveBeenCalledWith(
327
+ false,
328
+ expect.anything()
329
+ );
330
+ } );
331
+ } );
332
+ } );
333
+
334
+ describe( 'accessibility', () => {
335
+ it( 'should associate title with the popup via aria-labelledby', async () => {
336
+ const user = userEvent.setup();
337
+ const ref = createRef< HTMLDivElement >();
338
+
339
+ render(
340
+ <Popover.Root>
341
+ <Popover.Trigger>Open</Popover.Trigger>
342
+ <Popover.Popup ref={ ref }>
343
+ <Popover.Title>My Title</Popover.Title>
344
+ </Popover.Popup>
345
+ </Popover.Root>
346
+ );
347
+
348
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
349
+
350
+ await waitFor( () => {
351
+ expect( ref.current ).toHaveAccessibleName( 'My Title' );
352
+ } );
353
+ } );
354
+
355
+ it( 'should associate description with the popup via aria-describedby', async () => {
356
+ const user = userEvent.setup();
357
+ const ref = createRef< HTMLDivElement >();
358
+
359
+ render(
360
+ <Popover.Root>
361
+ <Popover.Trigger>Open</Popover.Trigger>
362
+ <Popover.Popup ref={ ref }>
363
+ <Popover.Title>Title</Popover.Title>
364
+ <Popover.Description>
365
+ My Description
366
+ </Popover.Description>
367
+ </Popover.Popup>
368
+ </Popover.Root>
369
+ );
370
+
371
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
372
+
373
+ await waitFor( () => {
374
+ expect( ref.current ).toHaveAccessibleDescription(
375
+ 'My Description'
376
+ );
377
+ } );
378
+ } );
379
+ } );
380
+
381
+ describe( 'variant', () => {
382
+ it( 'should not apply popup styles when variant is unstyled', async () => {
383
+ const user = userEvent.setup();
384
+ const unstyledRef = createRef< HTMLDivElement >();
385
+
386
+ render(
387
+ <>
388
+ <Popover.Root>
389
+ <Popover.Trigger>Open unstyled</Popover.Trigger>
390
+ <Popover.Popup ref={ unstyledRef } variant="unstyled">
391
+ <Popover.Title>Title</Popover.Title>
392
+ Unstyled content
393
+ </Popover.Popup>
394
+ </Popover.Root>
395
+ <Popover.Root>
396
+ <Popover.Trigger>Open styled</Popover.Trigger>
397
+ <Popover.Popup data-testid="styled-popup">
398
+ <Popover.Title>Title</Popover.Title>
399
+ Styled content
400
+ </Popover.Popup>
401
+ </Popover.Root>
402
+ </>
403
+ );
404
+
405
+ await user.click(
406
+ screen.getByRole( 'button', { name: 'Open unstyled' } )
407
+ );
408
+ expect(
409
+ await screen.findByText( 'Unstyled content' )
410
+ ).toBeVisible();
411
+
412
+ await user.click(
413
+ screen.getByRole( 'button', { name: 'Open styled' } )
414
+ );
415
+ const styledPopup = await screen.findByTestId( 'styled-popup' );
416
+
417
+ const styledClasses = Array.from( styledPopup.classList );
418
+ for ( const cls of styledClasses ) {
419
+ expect( unstyledRef.current! ).not.toHaveClass( cls );
420
+ }
421
+ } );
422
+ } );
423
+
424
+ describe( 'inline (via container)', () => {
425
+ function InlinePopover() {
426
+ const containerRef = createRef< HTMLSpanElement >();
427
+ return (
428
+ <div data-testid="inline-wrapper">
429
+ <Popover.Root>
430
+ <Popover.Trigger>Open</Popover.Trigger>
431
+ <span
432
+ ref={ containerRef }
433
+ style={ { display: 'contents' } }
434
+ />
435
+ <Popover.Popup container={ containerRef }>
436
+ <Popover.Title>Title</Popover.Title>
437
+ Inline content
438
+ </Popover.Popup>
439
+ </Popover.Root>
440
+ </div>
441
+ );
442
+ }
443
+
444
+ it( 'should render inside the container when a local ref is used', async () => {
445
+ const user = userEvent.setup();
446
+
447
+ render( <InlinePopover /> );
448
+
449
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
450
+
451
+ const content = await screen.findByText( 'Inline content' );
452
+ expect( content ).toBeVisible();
453
+
454
+ expect( screen.getByTestId( 'inline-wrapper' ) ).toContainElement(
455
+ content
456
+ );
457
+ } );
458
+
459
+ it( 'should render with a portal by default', async () => {
460
+ const user = userEvent.setup();
461
+
462
+ render(
463
+ <div data-testid="portal-wrapper">
464
+ <Popover.Root>
465
+ <Popover.Trigger>Open</Popover.Trigger>
466
+ <Popover.Popup>
467
+ <Popover.Title>Title</Popover.Title>
468
+ Portal content
469
+ </Popover.Popup>
470
+ </Popover.Root>
471
+ </div>
472
+ );
473
+
474
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
475
+
476
+ const content = await screen.findByText( 'Portal content' );
477
+ expect( content ).toBeVisible();
478
+
479
+ expect(
480
+ screen.getByTestId( 'portal-wrapper' )
481
+ ).not.toContainElement( content );
482
+ } );
483
+ } );
484
+
485
+ describe( 'anchor', () => {
486
+ it( 'should render the popup when an anchor element is provided without a trigger', async () => {
487
+ function AnchorTest() {
488
+ const [ anchorEl, setAnchorEl ] =
489
+ useState< HTMLDivElement | null >( null );
490
+ return (
491
+ <>
492
+ <div ref={ setAnchorEl } data-testid="anchor">
493
+ Anchor element
494
+ </div>
495
+ <Popover.Root defaultOpen>
496
+ <Popover.Popup anchor={ anchorEl ?? undefined }>
497
+ <Popover.Title>Title</Popover.Title>
498
+ Anchored content
499
+ </Popover.Popup>
500
+ </Popover.Root>
501
+ </>
502
+ );
503
+ }
504
+
505
+ render( <AnchorTest /> );
506
+
507
+ expect(
508
+ await screen.findByText( 'Anchored content' )
509
+ ).toBeVisible();
510
+ } );
511
+ } );
512
+
513
+ describe( 'initialFocus', () => {
514
+ it( 'should focus the first content element, skipping the close button', async () => {
515
+ const user = userEvent.setup();
516
+
517
+ render(
518
+ <Popover.Root>
519
+ <Popover.Trigger>Open</Popover.Trigger>
520
+ <Popover.Popup>
521
+ <Popover.Title>Title</Popover.Title>
522
+ <Popover.Close>Close</Popover.Close>
523
+ <button>Content Button</button>
524
+ </Popover.Popup>
525
+ </Popover.Root>
526
+ );
527
+
528
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
529
+
530
+ await waitFor( () => {
531
+ expect(
532
+ screen.getByRole( 'button', {
533
+ name: 'Content Button',
534
+ } )
535
+ ).toHaveFocus();
536
+ } );
537
+ } );
538
+
539
+ it( 'should fall back to the close button when it is the only tabbable element', async () => {
540
+ const user = userEvent.setup();
541
+
542
+ render(
543
+ <Popover.Root>
544
+ <Popover.Trigger>Open</Popover.Trigger>
545
+ <Popover.Popup>
546
+ <Popover.Title>Title</Popover.Title>
547
+ <Popover.Close>Close</Popover.Close>
548
+ <p>No tabbable content here</p>
549
+ </Popover.Popup>
550
+ </Popover.Root>
551
+ );
552
+
553
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
554
+
555
+ await waitFor( () => {
556
+ expect(
557
+ screen.getByRole( 'button', { name: 'Close' } )
558
+ ).toHaveFocus();
559
+ } );
560
+ } );
561
+
562
+ it( 'should not move focus when initialFocus is false', async () => {
563
+ const user = userEvent.setup();
564
+
565
+ render(
566
+ <Popover.Root>
567
+ <Popover.Trigger>Open</Popover.Trigger>
568
+ <Popover.Popup initialFocus={ false }>
569
+ <Popover.Title>Title</Popover.Title>
570
+ <Popover.Close>Close</Popover.Close>
571
+ <button>Content Button</button>
572
+ </Popover.Popup>
573
+ </Popover.Root>
574
+ );
575
+
576
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
577
+
578
+ const contentButton = await screen.findByRole( 'button', {
579
+ name: 'Content Button',
580
+ } );
581
+ expect( contentButton ).toBeVisible();
582
+ expect( contentButton ).not.toHaveFocus();
583
+ expect(
584
+ screen.getByRole( 'button', { name: 'Close' } )
585
+ ).not.toHaveFocus();
586
+ } );
587
+
588
+ it( 'should use a custom initialFocus callback as-is', async () => {
589
+ const user = userEvent.setup();
590
+ const customFocus = jest.fn( () => false as const );
591
+
592
+ render(
593
+ <Popover.Root>
594
+ <Popover.Trigger>Open</Popover.Trigger>
595
+ <Popover.Popup initialFocus={ customFocus }>
596
+ <Popover.Title>Title</Popover.Title>
597
+ <Popover.Close>Close</Popover.Close>
598
+ <button>Content Button</button>
599
+ </Popover.Popup>
600
+ </Popover.Root>
601
+ );
602
+
603
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
604
+
605
+ expect(
606
+ await screen.findByRole( 'button', {
607
+ name: 'Content Button',
608
+ } )
609
+ ).toBeVisible();
610
+
611
+ expect( customFocus ).toHaveBeenCalled();
612
+ } );
613
+ } );
614
+
615
+ describe( 'title validation', () => {
616
+ it( 'should throw when Popover.Title is missing', async () => {
617
+ const user = userEvent.setup();
618
+ const onError = jest.fn();
619
+
620
+ // Suppress console.error from React error boundary
621
+ const spy = jest
622
+ .spyOn( console, 'error' )
623
+ .mockImplementation( () => {} );
624
+
625
+ render(
626
+ <ErrorBoundary onError={ onError }>
627
+ <Popover.Root>
628
+ <Popover.Trigger>Open</Popover.Trigger>
629
+ <Popover.Popup>No title here</Popover.Popup>
630
+ </Popover.Root>
631
+ </ErrorBoundary>
632
+ );
633
+
634
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
635
+
636
+ await waitFor( () => {
637
+ expect( onError ).toHaveBeenCalledWith(
638
+ expect.objectContaining( {
639
+ message: expect.stringContaining(
640
+ 'Missing <Popover.Title>'
641
+ ),
642
+ } )
643
+ );
644
+ } );
645
+
646
+ spy.mockRestore();
647
+ } );
648
+
649
+ it( 'should throw when Popover.Title is empty', async () => {
650
+ const user = userEvent.setup();
651
+ const onError = jest.fn();
652
+
653
+ const spy = jest
654
+ .spyOn( console, 'error' )
655
+ .mockImplementation( () => {} );
656
+
657
+ render(
658
+ <ErrorBoundary onError={ onError }>
659
+ <Popover.Root>
660
+ <Popover.Trigger>Open</Popover.Trigger>
661
+ <Popover.Popup>
662
+ <Popover.Title />
663
+ </Popover.Popup>
664
+ </Popover.Root>
665
+ </ErrorBoundary>
666
+ );
667
+
668
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
669
+
670
+ await waitFor( () => {
671
+ expect( onError ).toHaveBeenCalledWith(
672
+ expect.objectContaining( {
673
+ message: expect.stringContaining( 'cannot be empty' ),
674
+ } )
675
+ );
676
+ } );
677
+
678
+ spy.mockRestore();
679
+ } );
680
+
681
+ it( 'should not throw when Popover.Title is present', async () => {
682
+ const user = userEvent.setup();
683
+ const onError = jest.fn();
684
+
685
+ render(
686
+ <ErrorBoundary onError={ onError }>
687
+ <Popover.Root>
688
+ <Popover.Trigger>Open</Popover.Trigger>
689
+ <Popover.Popup>
690
+ <Popover.Title>Valid Title</Popover.Title>
691
+ </Popover.Popup>
692
+ </Popover.Root>
693
+ </ErrorBoundary>
694
+ );
695
+
696
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
697
+
698
+ await waitFor( () => {
699
+ expect( screen.getByText( 'Valid Title' ) ).toBeVisible();
700
+ } );
701
+
702
+ expect( onError ).not.toHaveBeenCalled();
703
+ } );
704
+ } );
705
+ } );
706
+
707
+ class ErrorBoundary extends Component<
708
+ { children: React.ReactNode; onError: ( error: Error ) => void },
709
+ { hasError: boolean }
710
+ > {
711
+ state = { hasError: false };
712
+
713
+ static getDerivedStateFromError() {
714
+ return { hasError: true };
715
+ }
716
+
717
+ componentDidCatch( error: Error ) {
718
+ this.props.onError( error );
719
+ }
720
+
721
+ render() {
722
+ if ( this.state.hasError ) {
723
+ return null;
724
+ }
725
+ return this.props.children;
726
+ }
727
+ }