@wordpress/ui 0.13.1-next.v.202605131006.0 → 0.14.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 (282) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/CONTRIBUTING.md +34 -0
  3. package/README.md +15 -0
  4. package/build/alert-dialog/portal.cjs.map +2 -2
  5. package/build/alert-dialog/types.cjs.map +1 -1
  6. package/build/button/button.cjs +1 -1
  7. package/build/button/button.cjs.map +2 -2
  8. package/build/card/content.cjs +1 -1
  9. package/build/card/content.cjs.map +2 -2
  10. package/build/card/full-bleed.cjs +1 -1
  11. package/build/card/full-bleed.cjs.map +2 -2
  12. package/build/card/header.cjs +1 -1
  13. package/build/card/header.cjs.map +2 -2
  14. package/build/card/root.cjs +1 -1
  15. package/build/card/root.cjs.map +2 -2
  16. package/build/collapsible-card/header.cjs.map +2 -2
  17. package/build/dialog/portal.cjs.map +2 -2
  18. package/build/dialog/types.cjs.map +1 -1
  19. package/build/drawer/portal.cjs.map +2 -2
  20. package/build/drawer/types.cjs.map +1 -1
  21. package/build/form/primitives/autocomplete/clear.cjs +4 -1
  22. package/build/form/primitives/autocomplete/clear.cjs.map +2 -2
  23. package/build/form/primitives/autocomplete/empty.cjs +1 -1
  24. package/build/form/primitives/autocomplete/empty.cjs.map +2 -2
  25. package/build/form/primitives/autocomplete/index.cjs +4 -1
  26. package/build/form/primitives/autocomplete/index.cjs.map +2 -2
  27. package/build/form/primitives/autocomplete/item.cjs +1 -1
  28. package/build/form/primitives/autocomplete/item.cjs.map +2 -2
  29. package/build/form/primitives/autocomplete/list-body.cjs +1 -1
  30. package/build/form/primitives/autocomplete/list-body.cjs.map +2 -2
  31. package/build/form/primitives/autocomplete/list.cjs +1 -1
  32. package/build/form/primitives/autocomplete/list.cjs.map +2 -2
  33. package/build/form/primitives/autocomplete/popup.cjs +14 -31
  34. package/build/form/primitives/autocomplete/popup.cjs.map +3 -3
  35. package/build/form/primitives/autocomplete/portal.cjs +10 -2
  36. package/build/form/primitives/autocomplete/portal.cjs.map +2 -2
  37. package/build/form/primitives/autocomplete/positioner.cjs +158 -0
  38. package/build/form/primitives/autocomplete/positioner.cjs.map +7 -0
  39. package/build/form/primitives/autocomplete/types.cjs.map +1 -1
  40. package/build/form/primitives/constants.cjs.map +2 -2
  41. package/build/form/primitives/select/index.cjs +4 -1
  42. package/build/form/primitives/select/index.cjs.map +2 -2
  43. package/build/form/primitives/select/item.cjs +1 -1
  44. package/build/form/primitives/select/item.cjs.map +2 -2
  45. package/build/form/primitives/select/popup.cjs +18 -36
  46. package/build/form/primitives/select/popup.cjs.map +3 -3
  47. package/build/form/primitives/select/portal.cjs +11 -5
  48. package/build/form/primitives/select/portal.cjs.map +2 -2
  49. package/build/form/primitives/select/positioner.cjs +159 -0
  50. package/build/form/primitives/select/positioner.cjs.map +7 -0
  51. package/build/form/primitives/select/types.cjs.map +1 -1
  52. package/build/icon-button/icon-button.cjs +1 -1
  53. package/build/icon-button/icon-button.cjs.map +2 -2
  54. package/build/index.cjs +7 -1
  55. package/build/index.cjs.map +2 -2
  56. package/build/popover/index.cjs +3 -0
  57. package/build/popover/index.cjs.map +2 -2
  58. package/build/popover/popup.cjs +23 -51
  59. package/build/popover/popup.cjs.map +3 -3
  60. package/build/popover/portal.cjs.map +2 -2
  61. package/build/popover/positioner.cjs +168 -0
  62. package/build/popover/positioner.cjs.map +7 -0
  63. package/build/popover/root.cjs.map +2 -2
  64. package/build/popover/types.cjs.map +1 -1
  65. package/build/tooltip/portal.cjs +10 -2
  66. package/build/tooltip/portal.cjs.map +2 -2
  67. package/build/tooltip/positioner.cjs.map +2 -2
  68. package/build/tooltip/types.cjs.map +1 -1
  69. package/build/utils/create-overlay-title-validation.cjs.map +2 -2
  70. package/build/utils/render-slot-with-children.cjs +4 -1
  71. package/build/utils/render-slot-with-children.cjs.map +2 -2
  72. package/build/utils/use-enable-wp-compat-overlay-slot.cjs +39 -0
  73. package/build/utils/use-enable-wp-compat-overlay-slot.cjs.map +7 -0
  74. package/build/utils/wp-compat-overlay-slot.cjs +177 -0
  75. package/build/utils/wp-compat-overlay-slot.cjs.map +7 -0
  76. package/build-module/alert-dialog/portal.mjs.map +2 -2
  77. package/build-module/button/button.mjs +1 -1
  78. package/build-module/button/button.mjs.map +2 -2
  79. package/build-module/card/content.mjs +1 -1
  80. package/build-module/card/content.mjs.map +2 -2
  81. package/build-module/card/full-bleed.mjs +1 -1
  82. package/build-module/card/full-bleed.mjs.map +2 -2
  83. package/build-module/card/header.mjs +1 -1
  84. package/build-module/card/header.mjs.map +2 -2
  85. package/build-module/card/root.mjs +1 -1
  86. package/build-module/card/root.mjs.map +2 -2
  87. package/build-module/collapsible-card/header.mjs.map +2 -2
  88. package/build-module/dialog/portal.mjs.map +2 -2
  89. package/build-module/drawer/portal.mjs.map +2 -2
  90. package/build-module/form/primitives/autocomplete/clear.mjs +4 -1
  91. package/build-module/form/primitives/autocomplete/clear.mjs.map +2 -2
  92. package/build-module/form/primitives/autocomplete/empty.mjs +1 -1
  93. package/build-module/form/primitives/autocomplete/empty.mjs.map +2 -2
  94. package/build-module/form/primitives/autocomplete/index.mjs +3 -1
  95. package/build-module/form/primitives/autocomplete/index.mjs.map +2 -2
  96. package/build-module/form/primitives/autocomplete/item.mjs +1 -1
  97. package/build-module/form/primitives/autocomplete/item.mjs.map +2 -2
  98. package/build-module/form/primitives/autocomplete/list-body.mjs +1 -1
  99. package/build-module/form/primitives/autocomplete/list-body.mjs.map +2 -2
  100. package/build-module/form/primitives/autocomplete/list.mjs +1 -1
  101. package/build-module/form/primitives/autocomplete/list.mjs.map +2 -2
  102. package/build-module/form/primitives/autocomplete/popup.mjs +14 -31
  103. package/build-module/form/primitives/autocomplete/popup.mjs.map +3 -3
  104. package/build-module/form/primitives/autocomplete/portal.mjs +10 -2
  105. package/build-module/form/primitives/autocomplete/portal.mjs.map +2 -2
  106. package/build-module/form/primitives/autocomplete/positioner.mjs +123 -0
  107. package/build-module/form/primitives/autocomplete/positioner.mjs.map +7 -0
  108. package/build-module/form/primitives/constants.mjs.map +2 -2
  109. package/build-module/form/primitives/select/index.mjs +3 -1
  110. package/build-module/form/primitives/select/index.mjs.map +2 -2
  111. package/build-module/form/primitives/select/item.mjs +1 -1
  112. package/build-module/form/primitives/select/item.mjs.map +2 -2
  113. package/build-module/form/primitives/select/popup.mjs +18 -36
  114. package/build-module/form/primitives/select/popup.mjs.map +3 -3
  115. package/build-module/form/primitives/select/portal.mjs +11 -5
  116. package/build-module/form/primitives/select/portal.mjs.map +2 -2
  117. package/build-module/form/primitives/select/positioner.mjs +124 -0
  118. package/build-module/form/primitives/select/positioner.mjs.map +7 -0
  119. package/build-module/icon-button/icon-button.mjs +1 -1
  120. package/build-module/icon-button/icon-button.mjs.map +2 -2
  121. package/build-module/index.mjs +5 -1
  122. package/build-module/index.mjs.map +2 -2
  123. package/build-module/popover/index.mjs +2 -0
  124. package/build-module/popover/index.mjs.map +2 -2
  125. package/build-module/popover/popup.mjs +23 -51
  126. package/build-module/popover/popup.mjs.map +3 -3
  127. package/build-module/popover/portal.mjs.map +2 -2
  128. package/build-module/popover/positioner.mjs +133 -0
  129. package/build-module/popover/positioner.mjs.map +7 -0
  130. package/build-module/popover/root.mjs.map +2 -2
  131. package/build-module/tooltip/portal.mjs +10 -2
  132. package/build-module/tooltip/portal.mjs.map +2 -2
  133. package/build-module/tooltip/positioner.mjs.map +2 -2
  134. package/build-module/utils/create-overlay-title-validation.mjs.map +2 -2
  135. package/build-module/utils/render-slot-with-children.mjs +4 -1
  136. package/build-module/utils/render-slot-with-children.mjs.map +2 -2
  137. package/build-module/utils/use-enable-wp-compat-overlay-slot.mjs +14 -0
  138. package/build-module/utils/use-enable-wp-compat-overlay-slot.mjs.map +7 -0
  139. package/build-module/utils/wp-compat-overlay-slot.mjs +148 -0
  140. package/build-module/utils/wp-compat-overlay-slot.mjs.map +7 -0
  141. package/build-types/alert-dialog/portal.d.ts +8 -5
  142. package/build-types/alert-dialog/portal.d.ts.map +1 -1
  143. package/build-types/alert-dialog/types.d.ts +2 -2
  144. package/build-types/alert-dialog/types.d.ts.map +1 -1
  145. package/build-types/badge/stories/e2e/index.story.d.ts +7 -0
  146. package/build-types/badge/stories/e2e/index.story.d.ts.map +1 -0
  147. package/build-types/button/stories/e2e/index.story.d.ts +8 -0
  148. package/build-types/button/stories/e2e/index.story.d.ts.map +1 -0
  149. package/build-types/card/full-bleed.d.ts +18 -0
  150. package/build-types/card/full-bleed.d.ts.map +1 -1
  151. package/build-types/card/stories/index.story.d.ts +23 -0
  152. package/build-types/card/stories/index.story.d.ts.map +1 -1
  153. package/build-types/collapsible-card/header.d.ts +2 -0
  154. package/build-types/collapsible-card/header.d.ts.map +1 -1
  155. package/build-types/collapsible-card/stories/index.story.d.ts +14 -0
  156. package/build-types/collapsible-card/stories/index.story.d.ts.map +1 -1
  157. package/build-types/dialog/portal.d.ts +8 -6
  158. package/build-types/dialog/portal.d.ts.map +1 -1
  159. package/build-types/dialog/types.d.ts +2 -2
  160. package/build-types/dialog/types.d.ts.map +1 -1
  161. package/build-types/drawer/portal.d.ts +8 -6
  162. package/build-types/drawer/portal.d.ts.map +1 -1
  163. package/build-types/drawer/types.d.ts +2 -2
  164. package/build-types/drawer/types.d.ts.map +1 -1
  165. package/build-types/form/primitives/autocomplete/clear.d.ts.map +1 -1
  166. package/build-types/form/primitives/autocomplete/index.d.ts +2 -1
  167. package/build-types/form/primitives/autocomplete/index.d.ts.map +1 -1
  168. package/build-types/form/primitives/autocomplete/popup.d.ts +1 -0
  169. package/build-types/form/primitives/autocomplete/popup.d.ts.map +1 -1
  170. package/build-types/form/primitives/autocomplete/portal.d.ts +9 -4
  171. package/build-types/form/primitives/autocomplete/portal.d.ts.map +1 -1
  172. package/build-types/form/primitives/autocomplete/positioner.d.ts +12 -0
  173. package/build-types/form/primitives/autocomplete/positioner.d.ts.map +1 -0
  174. package/build-types/form/primitives/autocomplete/stories/index.story.d.ts.map +1 -1
  175. package/build-types/form/primitives/autocomplete/types.d.ts +11 -9
  176. package/build-types/form/primitives/autocomplete/types.d.ts.map +1 -1
  177. package/build-types/form/primitives/constants.d.ts +9 -4
  178. package/build-types/form/primitives/constants.d.ts.map +1 -1
  179. package/build-types/form/primitives/select/index.d.ts +2 -1
  180. package/build-types/form/primitives/select/index.d.ts.map +1 -1
  181. package/build-types/form/primitives/select/popup.d.ts +1 -0
  182. package/build-types/form/primitives/select/popup.d.ts.map +1 -1
  183. package/build-types/form/primitives/select/portal.d.ts +9 -4
  184. package/build-types/form/primitives/select/portal.d.ts.map +1 -1
  185. package/build-types/form/primitives/select/positioner.d.ts +12 -0
  186. package/build-types/form/primitives/select/positioner.d.ts.map +1 -0
  187. package/build-types/form/primitives/select/stories/index.story.d.ts.map +1 -1
  188. package/build-types/form/primitives/select/types.d.ts +11 -2
  189. package/build-types/form/primitives/select/types.d.ts.map +1 -1
  190. package/build-types/icon/stories/index.story.d.ts.map +1 -1
  191. package/build-types/index.d.ts +2 -0
  192. package/build-types/index.d.ts.map +1 -1
  193. package/build-types/popover/index.d.ts +2 -1
  194. package/build-types/popover/index.d.ts.map +1 -1
  195. package/build-types/popover/popup.d.ts +2 -1
  196. package/build-types/popover/popup.d.ts.map +1 -1
  197. package/build-types/popover/portal.d.ts +8 -5
  198. package/build-types/popover/portal.d.ts.map +1 -1
  199. package/build-types/popover/positioner.d.ts +12 -0
  200. package/build-types/popover/positioner.d.ts.map +1 -0
  201. package/build-types/popover/root.d.ts +5 -2
  202. package/build-types/popover/root.d.ts.map +1 -1
  203. package/build-types/popover/stories/index.story.d.ts +10 -21
  204. package/build-types/popover/stories/index.story.d.ts.map +1 -1
  205. package/build-types/popover/types.d.ts +12 -3
  206. package/build-types/popover/types.d.ts.map +1 -1
  207. package/build-types/tooltip/portal.d.ts +9 -4
  208. package/build-types/tooltip/portal.d.ts.map +1 -1
  209. package/build-types/tooltip/positioner.d.ts +8 -5
  210. package/build-types/tooltip/positioner.d.ts.map +1 -1
  211. package/build-types/tooltip/types.d.ts +3 -3
  212. package/build-types/tooltip/types.d.ts.map +1 -1
  213. package/build-types/utils/render-slot-with-children.d.ts.map +1 -1
  214. package/build-types/utils/test/use-enable-wp-compat-overlay-slot.test.d.ts +2 -0
  215. package/build-types/utils/test/use-enable-wp-compat-overlay-slot.test.d.ts.map +1 -0
  216. package/build-types/utils/test/wp-compat-overlay-slot.test.d.ts +2 -0
  217. package/build-types/utils/test/wp-compat-overlay-slot.test.d.ts.map +1 -0
  218. package/build-types/utils/use-deprioritized-initial-focus.d.ts +1 -1
  219. package/build-types/utils/use-enable-wp-compat-overlay-slot.d.ts +17 -0
  220. package/build-types/utils/use-enable-wp-compat-overlay-slot.d.ts.map +1 -0
  221. package/build-types/utils/wp-compat-overlay-slot.d.ts +30 -0
  222. package/build-types/utils/wp-compat-overlay-slot.d.ts.map +1 -0
  223. package/package.json +14 -14
  224. package/src/alert-dialog/portal.tsx +1 -4
  225. package/src/alert-dialog/types.ts +2 -4
  226. package/src/badge/stories/e2e/index.story.tsx +20 -0
  227. package/src/button/stories/e2e/index.story.tsx +130 -0
  228. package/src/button/stories/index.story.tsx +1 -1
  229. package/src/button/style.module.css +17 -12
  230. package/src/card/full-bleed.tsx +18 -0
  231. package/src/card/stories/index.story.tsx +115 -1
  232. package/src/card/style.module.css +16 -0
  233. package/src/card/test/index.test.tsx +18 -1
  234. package/src/collapsible-card/header.tsx +2 -0
  235. package/src/collapsible-card/stories/index.story.tsx +66 -0
  236. package/src/dialog/portal.tsx +1 -5
  237. package/src/dialog/types.ts +2 -2
  238. package/src/drawer/portal.tsx +1 -5
  239. package/src/drawer/types.ts +2 -2
  240. package/src/form/primitives/autocomplete/clear.tsx +10 -4
  241. package/src/form/primitives/autocomplete/index.ts +2 -1
  242. package/src/form/primitives/autocomplete/popup.tsx +17 -21
  243. package/src/form/primitives/autocomplete/portal.tsx +11 -5
  244. package/src/form/primitives/autocomplete/positioner.tsx +29 -0
  245. package/src/form/primitives/autocomplete/stories/index.story.tsx +1 -0
  246. package/src/form/primitives/autocomplete/test/index.test.tsx +219 -0
  247. package/src/form/primitives/autocomplete/types.ts +15 -15
  248. package/src/form/primitives/constants.ts +7 -4
  249. package/src/form/primitives/select/index.ts +2 -1
  250. package/src/form/primitives/select/popup.tsx +30 -34
  251. package/src/form/primitives/select/portal.tsx +15 -8
  252. package/src/form/primitives/select/positioner.tsx +33 -0
  253. package/src/form/primitives/select/stories/index.story.tsx +1 -0
  254. package/src/form/primitives/select/test/index.test.tsx +134 -0
  255. package/src/form/primitives/select/types.ts +12 -2
  256. package/src/form/select-control/test/index.test.tsx +64 -0
  257. package/src/icon/stories/index.story.tsx +3 -2
  258. package/src/icon-button/icon-button.tsx +1 -1
  259. package/src/icon-button/test/index.test.tsx +10 -10
  260. package/src/index.ts +2 -0
  261. package/src/popover/index.ts +12 -1
  262. package/src/popover/popup.tsx +28 -45
  263. package/src/popover/portal.tsx +1 -4
  264. package/src/popover/positioner.tsx +42 -0
  265. package/src/popover/root.tsx +5 -2
  266. package/src/popover/stories/index.story.tsx +85 -138
  267. package/src/popover/test/index.test.tsx +36 -1
  268. package/src/popover/types.ts +13 -15
  269. package/src/tabs/stories/index.story.tsx +2 -2
  270. package/src/tooltip/portal.tsx +11 -5
  271. package/src/tooltip/positioner.tsx +1 -4
  272. package/src/tooltip/style.module.css +1 -1
  273. package/src/tooltip/test/index.test.tsx +110 -0
  274. package/src/tooltip/types.ts +3 -5
  275. package/src/utils/create-overlay-title-validation.tsx +1 -1
  276. package/src/utils/css/item-popup.module.css +7 -4
  277. package/src/utils/css/wp-compat-overlay-slot.module.css +35 -0
  278. package/src/utils/render-slot-with-children.ts +4 -1
  279. package/src/utils/test/use-enable-wp-compat-overlay-slot.test.tsx +74 -0
  280. package/src/utils/test/wp-compat-overlay-slot.test.ts +300 -0
  281. package/src/utils/use-enable-wp-compat-overlay-slot.ts +32 -0
  282. package/src/utils/wp-compat-overlay-slot.ts +129 -0
@@ -1,7 +1,9 @@
1
1
  import { render, screen, waitFor } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { createRef } from '@wordpress/element';
4
+ import type { ReactNode } from 'react';
4
5
  import * as Autocomplete from '../index';
6
+ import { useEnableWpCompatOverlaySlot } from '../../../../utils/use-enable-wp-compat-overlay-slot';
5
7
 
6
8
  const ITEMS = [
7
9
  { id: '1', value: 'Item 1' },
@@ -9,6 +11,15 @@ const ITEMS = [
9
11
  { id: '3', value: 'Item 3' },
10
12
  ];
11
13
 
14
+ function renderDisabledAutocompleteWithClear() {
15
+ return render(
16
+ <Autocomplete.Root items={ ITEMS } disabled defaultValue="Item 1">
17
+ <Autocomplete.Input placeholder="Search" />
18
+ <Autocomplete.Clear />
19
+ </Autocomplete.Root>
20
+ );
21
+ }
22
+
12
23
  describe( 'Autocomplete', () => {
13
24
  it( 'forwards ref', async () => {
14
25
  const user = userEvent.setup();
@@ -159,4 +170,212 @@ describe( 'Autocomplete', () => {
159
170
  );
160
171
  } );
161
172
  } );
173
+
174
+ describe( 'positioner', () => {
175
+ it( 'should render the custom positioner element wrapping the popup content', async () => {
176
+ const user = userEvent.setup();
177
+
178
+ render(
179
+ <Autocomplete.Root items={ ITEMS }>
180
+ <Autocomplete.Input placeholder="Search" />
181
+ <Autocomplete.Popup
182
+ positioner={
183
+ <Autocomplete.Positioner data-testid="custom-positioner" />
184
+ }
185
+ >
186
+ <Autocomplete.List>
187
+ <Autocomplete.ListBody>
188
+ <Autocomplete.Collection>
189
+ { ( item ) => (
190
+ <Autocomplete.Item
191
+ key={ item.id }
192
+ value={ item }
193
+ >
194
+ { item.value }
195
+ </Autocomplete.Item>
196
+ ) }
197
+ </Autocomplete.Collection>
198
+ </Autocomplete.ListBody>
199
+ </Autocomplete.List>
200
+ </Autocomplete.Popup>
201
+ </Autocomplete.Root>
202
+ );
203
+
204
+ await user.type( screen.getByRole( 'combobox' ), 'Item 1' );
205
+
206
+ const item = await screen.findByRole( 'option', {
207
+ name: 'Item 1',
208
+ } );
209
+ const positioner = screen.getByTestId( 'custom-positioner' );
210
+
211
+ expect( positioner ).toContainElement( item );
212
+ } );
213
+ } );
214
+
215
+ // Slot is identified by a data attribute, not a user-facing role/text.
216
+ /* eslint-disable testing-library/no-node-access */
217
+ describe( 'wp compat overlay slot', () => {
218
+ const SLOT_SELECTOR = '[data-wp-compat-overlay-slot]';
219
+
220
+ // Exercises the public opt-in path rather than poking the flag.
221
+ function WithSlotEnabled( { children }: { children: ReactNode } ) {
222
+ useEnableWpCompatOverlaySlot();
223
+ return <>{ children }</>;
224
+ }
225
+
226
+ afterEach( () => {
227
+ // The hook is one-way at runtime; reset explicitly between tests.
228
+ delete ( window as { __wpUiCompatOverlaySlotEnabled?: boolean } )
229
+ .__wpUiCompatOverlaySlotEnabled;
230
+ document
231
+ .querySelectorAll( SLOT_SELECTOR )
232
+ .forEach( ( el ) => el.remove() );
233
+ } );
234
+
235
+ it( 'portals the popup into the slot when the consumer opts in', async () => {
236
+ const user = userEvent.setup();
237
+
238
+ render(
239
+ <WithSlotEnabled>
240
+ <Autocomplete.Root items={ ITEMS }>
241
+ <Autocomplete.Input placeholder="Search" />
242
+ <Autocomplete.Popup>
243
+ <Autocomplete.List>
244
+ <Autocomplete.ListBody>
245
+ <Autocomplete.Collection>
246
+ { ( item ) => (
247
+ <Autocomplete.Item
248
+ key={ item.id }
249
+ value={ item }
250
+ >
251
+ { item.value }
252
+ </Autocomplete.Item>
253
+ ) }
254
+ </Autocomplete.Collection>
255
+ </Autocomplete.ListBody>
256
+ </Autocomplete.List>
257
+ </Autocomplete.Popup>
258
+ </Autocomplete.Root>
259
+ </WithSlotEnabled>
260
+ );
261
+
262
+ await user.type( screen.getByRole( 'combobox' ), 'Item 1' );
263
+
264
+ const item = await screen.findByRole( 'option', {
265
+ name: 'Item 1',
266
+ } );
267
+ expect( item ).toBeVisible();
268
+
269
+ const slot = document.querySelector( SLOT_SELECTOR );
270
+ expect( slot ).not.toBeNull();
271
+ expect( slot ).toContainElement( item );
272
+ } );
273
+
274
+ it( 'does not create a slot when the consumer has not opted in (dormant default)', async () => {
275
+ const user = userEvent.setup();
276
+
277
+ render(
278
+ <Autocomplete.Root items={ ITEMS }>
279
+ <Autocomplete.Input placeholder="Search" />
280
+ <Autocomplete.Popup>
281
+ <Autocomplete.List>
282
+ <Autocomplete.ListBody>
283
+ <Autocomplete.Collection>
284
+ { ( item ) => (
285
+ <Autocomplete.Item
286
+ key={ item.id }
287
+ value={ item }
288
+ >
289
+ { item.value }
290
+ </Autocomplete.Item>
291
+ ) }
292
+ </Autocomplete.Collection>
293
+ </Autocomplete.ListBody>
294
+ </Autocomplete.List>
295
+ </Autocomplete.Popup>
296
+ </Autocomplete.Root>
297
+ );
298
+
299
+ await user.type( screen.getByRole( 'combobox' ), 'Item 1' );
300
+
301
+ const item = await screen.findByRole( 'option', {
302
+ name: 'Item 1',
303
+ } );
304
+ expect( item ).toBeVisible();
305
+
306
+ expect( document.querySelector( SLOT_SELECTOR ) ).toBeNull();
307
+ } );
308
+
309
+ it( 'lets a caller-supplied portal container override the slot', async () => {
310
+ const user = userEvent.setup();
311
+ const containerRef = createRef< HTMLDivElement >();
312
+
313
+ render(
314
+ <WithSlotEnabled>
315
+ <Autocomplete.Root items={ ITEMS }>
316
+ <Autocomplete.Input placeholder="Search" />
317
+ <div
318
+ ref={ containerRef }
319
+ data-testid="custom-container"
320
+ />
321
+ <Autocomplete.Popup
322
+ portal={
323
+ <Autocomplete.Portal
324
+ container={ containerRef }
325
+ />
326
+ }
327
+ >
328
+ <Autocomplete.List>
329
+ <Autocomplete.ListBody>
330
+ <Autocomplete.Collection>
331
+ { ( item ) => (
332
+ <Autocomplete.Item
333
+ key={ item.id }
334
+ value={ item }
335
+ >
336
+ { item.value }
337
+ </Autocomplete.Item>
338
+ ) }
339
+ </Autocomplete.Collection>
340
+ </Autocomplete.ListBody>
341
+ </Autocomplete.List>
342
+ </Autocomplete.Popup>
343
+ </Autocomplete.Root>
344
+ </WithSlotEnabled>
345
+ );
346
+
347
+ await user.type( screen.getByRole( 'combobox' ), 'Item 1' );
348
+
349
+ const item = await screen.findByRole( 'option', {
350
+ name: 'Item 1',
351
+ } );
352
+ expect( item ).toBeVisible();
353
+ expect( screen.getByTestId( 'custom-container' ) ).toContainElement(
354
+ item
355
+ );
356
+ } );
357
+ } );
358
+ /* eslint-enable testing-library/no-node-access */
359
+
360
+ describe( 'when disabled', () => {
361
+ it( 'hides the clear button from screen readers', () => {
362
+ renderDisabledAutocompleteWithClear();
363
+
364
+ expect(
365
+ screen.queryByRole( 'button', { name: 'Clear' } )
366
+ ).not.toBeInTheDocument();
367
+ } );
368
+
369
+ it( 'does not show a tooltip when the clear button is hovered', async () => {
370
+ const user = userEvent.setup( { pointerEventsCheck: 0 } );
371
+ renderDisabledAutocompleteWithClear();
372
+
373
+ const clearButton = screen.getByLabelText( 'Clear', {
374
+ selector: 'button',
375
+ } );
376
+ await user.hover( clearButton );
377
+
378
+ expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
379
+ } );
380
+ } );
162
381
  } );
@@ -1,12 +1,12 @@
1
1
  import type { Autocomplete as _Autocomplete } from '@base-ui/react/autocomplete';
2
- import type { ComponentPropsWithoutRef, ReactElement } from 'react';
2
+ import type { ReactElement } from 'react';
3
3
  import type { ComponentProps } from '../../../utils/types';
4
4
 
5
5
  export type AutocompleteCollectionProps = _Autocomplete.Collection.Props;
6
6
 
7
- export type PortalProps = ComponentPropsWithoutRef<
8
- typeof _Autocomplete.Portal
9
- >;
7
+ export type PortalProps = ComponentProps< typeof _Autocomplete.Portal >;
8
+
9
+ export type PositionerProps = ComponentProps< typeof _Autocomplete.Positioner >;
10
10
 
11
11
  export type AutocompleteClearProps = ComponentProps<
12
12
  typeof _Autocomplete.Clear
@@ -58,17 +58,17 @@ export type AutocompletePopupProps = ComponentProps<
58
58
  * portal element; they would be ignored.
59
59
  */
60
60
  portal?: ReactElement< Omit< PortalProps, 'children' > >;
61
- };
62
-
63
- export type AutocompleteRootProps = ComponentProps<
64
- typeof _Autocomplete.Root
65
- > & {
66
- children?: React.ReactNode;
67
- };
68
-
69
- export type AutocompleteValueProps = {
70
61
  /**
71
- * Can be used to override the current value of the autocomplete.
62
+ * Optional positioner element, typically `<Autocomplete.Positioner />`
63
+ * with custom positioning props (`side`, `align`, `sideOffset`, collision
64
+ * settings, etc.). When omitted, `Autocomplete.Popup` uses
65
+ * `Autocomplete.Positioner` with default props. Do not pass `children` on
66
+ * the positioner element; they would be ignored.
72
67
  */
73
- children?: _Autocomplete.Value.Props[ 'children' ];
68
+ positioner?: ReactElement< Omit< PositionerProps, 'children' > >;
74
69
  };
70
+
71
+ export type AutocompleteRootProps< Value = unknown > =
72
+ _Autocomplete.Root.Props< Value >;
73
+
74
+ export type AutocompleteValueProps = _Autocomplete.Value.Props;
@@ -1,10 +1,13 @@
1
- import type { Select as _Select } from '@base-ui/react/select';
2
-
3
1
  /**
4
- * Shared positioning props for item popups (Select, Combobox, etc.).
2
+ * Shared positioning defaults for item-list popups. Consumed as individual
3
+ * default values by `Select.Positioner` and `Autocomplete.Positioner`. Each
4
+ * key is validated by the consuming positioner's prop types at the use
5
+ * site, so a value here that does not satisfy either Base UI positioner's
6
+ * type will surface as a type error in `select/positioner.tsx` or
7
+ * `autocomplete/positioner.tsx`.
5
8
  */
6
9
  export const ITEM_POPUP_POSITIONER_PROPS = {
7
10
  align: 'start',
8
11
  sideOffset: 8,
9
12
  collisionPadding: 12,
10
- } satisfies Partial< _Select.Positioner.Props >;
13
+ } as const;
@@ -1,5 +1,6 @@
1
1
  export { Item } from './item';
2
- export { Portal } from './portal';
3
2
  export { Popup } from './popup';
3
+ export { Portal } from './portal';
4
+ export { Positioner } from './positioner';
4
5
  export { Root } from './root';
5
6
  export { Trigger } from './trigger';
@@ -7,49 +7,45 @@ import {
7
7
  } from '@wordpress/theme';
8
8
  import { unlock } from '../../../lock-unlock';
9
9
  import { Portal } from './portal';
10
+ import { Positioner } from './positioner';
10
11
  import { renderSlotWithChildren } from '../../../utils/render-slot-with-children';
11
12
  import itemPopupStyles from '../../../utils/css/item-popup.module.css';
12
- import resetStyles from '../../../utils/css/resets.module.css';
13
- import styles from './style.module.css';
14
13
  import type { SelectPopupProps } from './types';
15
- import { ITEM_POPUP_POSITIONER_PROPS } from '../constants';
16
14
 
17
15
  const ThemeProvider: typeof ThemeProviderType =
18
16
  unlock( themePrivateApis ).ThemeProvider;
19
17
 
20
18
  export const Popup = forwardRef< HTMLDivElement, SelectPopupProps >(
21
- function Popup( { className, portal, children, ...restProps }, ref ) {
22
- const portalChildren = (
23
- <_Select.Positioner
24
- { ...ITEM_POPUP_POSITIONER_PROPS }
25
- alignItemWithTrigger={ false }
26
- className={ clsx(
27
- resetStyles[ 'box-sizing' ],
28
- styles.positioner
29
- ) }
30
- >
31
- <ThemeProvider>
32
- <_Select.Popup
33
- ref={ ref }
34
- className={ clsx( itemPopupStyles.popup, className ) }
35
- { ...restProps }
36
- >
37
- <_Select.List className={ itemPopupStyles.list }>
38
- <div
39
- className={
40
- itemPopupStyles[
41
- 'list-scrollable-container'
42
- ]
43
- }
44
- >
45
- { children }
46
- </div>
47
- </_Select.List>
48
- </_Select.Popup>
49
- </ThemeProvider>
50
- </_Select.Positioner>
19
+ function Popup(
20
+ { className, portal, positioner, children, ...restProps },
21
+ ref
22
+ ) {
23
+ const popupContent = (
24
+ <ThemeProvider>
25
+ <_Select.Popup
26
+ ref={ ref }
27
+ className={ clsx( itemPopupStyles.popup, className ) }
28
+ { ...restProps }
29
+ >
30
+ <_Select.List className={ itemPopupStyles.list }>
31
+ <div
32
+ className={
33
+ itemPopupStyles[ 'list-scrollable-container' ]
34
+ }
35
+ >
36
+ { children }
37
+ </div>
38
+ </_Select.List>
39
+ </_Select.Popup>
40
+ </ThemeProvider>
51
41
  );
52
42
 
53
- return renderSlotWithChildren( portal, <Portal />, portalChildren );
43
+ const positionedPopup = renderSlotWithChildren(
44
+ positioner,
45
+ <Positioner />,
46
+ popupContent
47
+ );
48
+
49
+ return renderSlotWithChildren( portal, <Portal />, positionedPopup );
54
50
  }
55
51
  );
@@ -1,16 +1,23 @@
1
1
  import { Select as _Select } from '@base-ui/react/select';
2
2
  import { forwardRef } from '@wordpress/element';
3
3
  import type { PortalProps } from './types';
4
+ import { getWpCompatOverlaySlot } from '../../../utils/wp-compat-overlay-slot';
4
5
 
5
6
  /**
6
- * Root element that portals `Select` listbox content. Pass to
7
- * `Select.Popup`'s `portal` prop. When `portal` is omitted, `Select.Popup`
8
- * uses this component with default props.
7
+ * Used to apply custom portal behavior to `Select`'s listbox content.
8
+ * `container` defaults to the `@wordpress/ui` compat overlay slot.
9
9
  */
10
- const Portal = forwardRef< HTMLDivElement, PortalProps >(
11
- function SelectPortal( props, ref ) {
12
- return <_Select.Portal ref={ ref } { ...props } />;
13
- }
14
- );
10
+ const Portal = forwardRef< HTMLDivElement, PortalProps >( function SelectPortal(
11
+ { container, ...restProps },
12
+ ref
13
+ ) {
14
+ return (
15
+ <_Select.Portal
16
+ container={ container ?? getWpCompatOverlaySlot() }
17
+ { ...restProps }
18
+ ref={ ref }
19
+ />
20
+ );
21
+ } );
15
22
 
16
23
  export { Portal };
@@ -0,0 +1,33 @@
1
+ import clsx from 'clsx';
2
+ import { Select as _Select } from '@base-ui/react/select';
3
+ import { forwardRef } from '@wordpress/element';
4
+ import type { PositionerProps } from './types';
5
+ import resetStyles from '../../../utils/css/resets.module.css';
6
+ import styles from './style.module.css';
7
+ import { ITEM_POPUP_POSITIONER_PROPS } from '../constants';
8
+
9
+ /**
10
+ * Used to apply custom positioning to `Select`'s listbox content.
11
+ */
12
+ const Positioner = forwardRef< HTMLDivElement, PositionerProps >(
13
+ function SelectPositioner( { className, ...props }, ref ) {
14
+ return (
15
+ <_Select.Positioner
16
+ { ...ITEM_POPUP_POSITIONER_PROPS }
17
+ // Override Base UI's `true` default so the popup is placed
18
+ // relative to the trigger rather than aligned with the
19
+ // highlighted item. Consumers can opt back in by passing `true`.
20
+ alignItemWithTrigger={ false }
21
+ { ...props }
22
+ ref={ ref }
23
+ className={ clsx(
24
+ resetStyles[ 'box-sizing' ],
25
+ styles.positioner,
26
+ className
27
+ ) }
28
+ />
29
+ );
30
+ }
31
+ );
32
+
33
+ export { Positioner };
@@ -7,6 +7,7 @@ const meta: Meta< typeof Select.Root > = {
7
7
  subcomponents: {
8
8
  'Select.Trigger': Select.Trigger,
9
9
  'Select.Portal': Select.Portal,
10
+ 'Select.Positioner': Select.Positioner,
10
11
  'Select.Popup': Select.Popup,
11
12
  'Select.Item': Select.Item,
12
13
  },
@@ -1,7 +1,9 @@
1
1
  import { render, screen, within } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { createRef } from '@wordpress/element';
4
+ import type { ReactNode } from 'react';
4
5
  import * as Select from '../index';
6
+ import { useEnableWpCompatOverlaySlot } from '../../../../utils/use-enable-wp-compat-overlay-slot';
5
7
 
6
8
  describe( 'Select', () => {
7
9
  it( 'supports object item values', async () => {
@@ -211,4 +213,136 @@ describe( 'Select', () => {
211
213
  );
212
214
  } );
213
215
  } );
216
+
217
+ describe( 'positioner', () => {
218
+ it( 'should render the custom positioner element wrapping the popup content', async () => {
219
+ const user = userEvent.setup();
220
+
221
+ render(
222
+ <Select.Root>
223
+ <Select.Trigger />
224
+ <Select.Popup
225
+ positioner={
226
+ <Select.Positioner data-testid="custom-positioner" />
227
+ }
228
+ >
229
+ <Select.Item value="Item 1">Item 1</Select.Item>
230
+ </Select.Popup>
231
+ </Select.Root>
232
+ );
233
+
234
+ await user.click( screen.getByRole( 'combobox' ) );
235
+
236
+ const item = await screen.findByRole( 'option', {
237
+ name: 'Item 1',
238
+ } );
239
+ const positioner = screen.getByTestId( 'custom-positioner' );
240
+
241
+ expect( positioner ).toContainElement( item );
242
+ } );
243
+ } );
244
+
245
+ // Slot is identified by a data attribute, not a user-facing role/text.
246
+ /* eslint-disable testing-library/no-node-access */
247
+ describe( 'wp compat overlay slot', () => {
248
+ const SLOT_SELECTOR = '[data-wp-compat-overlay-slot]';
249
+
250
+ // Exercises the public opt-in path rather than poking the flag.
251
+ function WithSlotEnabled( { children }: { children: ReactNode } ) {
252
+ useEnableWpCompatOverlaySlot();
253
+ return <>{ children }</>;
254
+ }
255
+
256
+ afterEach( () => {
257
+ // The hook is one-way at runtime; reset explicitly between tests.
258
+ delete ( window as { __wpUiCompatOverlaySlotEnabled?: boolean } )
259
+ .__wpUiCompatOverlaySlotEnabled;
260
+ document
261
+ .querySelectorAll( SLOT_SELECTOR )
262
+ .forEach( ( el ) => el.remove() );
263
+ } );
264
+
265
+ it( 'portals the popup into the slot when the consumer opts in', async () => {
266
+ const user = userEvent.setup();
267
+
268
+ render(
269
+ <WithSlotEnabled>
270
+ <Select.Root>
271
+ <Select.Trigger />
272
+ <Select.Popup>
273
+ <Select.Item value="Item 1">Item 1</Select.Item>
274
+ </Select.Popup>
275
+ </Select.Root>
276
+ </WithSlotEnabled>
277
+ );
278
+
279
+ await user.click( screen.getByRole( 'combobox' ) );
280
+
281
+ const item = await screen.findByRole( 'option', {
282
+ name: 'Item 1',
283
+ } );
284
+ expect( item ).toBeVisible();
285
+
286
+ const slot = document.querySelector( SLOT_SELECTOR );
287
+ expect( slot ).not.toBeNull();
288
+ expect( slot ).toContainElement( item );
289
+ } );
290
+
291
+ it( 'does not create a slot when the consumer has not opted in (dormant default)', async () => {
292
+ const user = userEvent.setup();
293
+
294
+ render(
295
+ <Select.Root>
296
+ <Select.Trigger />
297
+ <Select.Popup>
298
+ <Select.Item value="Item 1">Item 1</Select.Item>
299
+ </Select.Popup>
300
+ </Select.Root>
301
+ );
302
+
303
+ await user.click( screen.getByRole( 'combobox' ) );
304
+
305
+ const item = await screen.findByRole( 'option', {
306
+ name: 'Item 1',
307
+ } );
308
+ expect( item ).toBeVisible();
309
+
310
+ expect( document.querySelector( SLOT_SELECTOR ) ).toBeNull();
311
+ } );
312
+
313
+ it( 'lets a caller-supplied portal container override the slot', async () => {
314
+ const user = userEvent.setup();
315
+ const containerRef = createRef< HTMLDivElement >();
316
+
317
+ render(
318
+ <WithSlotEnabled>
319
+ <Select.Root>
320
+ <Select.Trigger />
321
+ <div
322
+ ref={ containerRef }
323
+ data-testid="custom-container"
324
+ />
325
+ <Select.Popup
326
+ portal={
327
+ <Select.Portal container={ containerRef } />
328
+ }
329
+ >
330
+ <Select.Item value="Item 1">Item 1</Select.Item>
331
+ </Select.Popup>
332
+ </Select.Root>
333
+ </WithSlotEnabled>
334
+ );
335
+
336
+ await user.click( screen.getByRole( 'combobox' ) );
337
+
338
+ const item = await screen.findByRole( 'option', {
339
+ name: 'Item 1',
340
+ } );
341
+ expect( item ).toBeVisible();
342
+ expect( screen.getByTestId( 'custom-container' ) ).toContainElement(
343
+ item
344
+ );
345
+ } );
346
+ } );
347
+ /* eslint-enable testing-library/no-node-access */
214
348
  } );
@@ -1,10 +1,12 @@
1
1
  import type { Select as _Select } from '@base-ui/react/select';
2
- import type { ComponentPropsWithoutRef, ReactElement } from 'react';
2
+ import type { ReactElement } from 'react';
3
3
 
4
4
  import type { ComponentProps } from '../../../utils/types';
5
5
  import type { InputLayoutProps } from '../input-layout/types';
6
6
 
7
- export type PortalProps = ComponentPropsWithoutRef< typeof _Select.Portal >;
7
+ export type PortalProps = ComponentProps< typeof _Select.Portal >;
8
+
9
+ export type PositionerProps = ComponentProps< typeof _Select.Positioner >;
8
10
 
9
11
  // The second type parameter is the `multiple` flag (currently disabled).
10
12
  export type SelectRootProps< Value = unknown > = Omit<
@@ -54,6 +56,14 @@ export type SelectPopupProps = ComponentProps< typeof _Select.Popup > & {
54
56
  * be ignored.
55
57
  */
56
58
  portal?: ReactElement< Omit< PortalProps, 'children' > >;
59
+ /**
60
+ * Optional positioner element, typically `<Select.Positioner />` with
61
+ * custom positioning props (`side`, `align`, `sideOffset`, collision
62
+ * settings, etc.). When omitted, `Select.Popup` uses `Select.Positioner`
63
+ * with default props. Do not pass `children` on the positioner element;
64
+ * they would be ignored.
65
+ */
66
+ positioner?: ReactElement< Omit< PositionerProps, 'children' > >;
57
67
  };
58
68
 
59
69
  export type SelectItemProps = Omit<