@wordpress/ui 0.13.1-next.v.202605131032.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
@@ -0,0 +1,35 @@
1
+ @layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides;
2
+
3
+ /*
4
+ * Compat overlay slot — body-level positioned container that hosts
5
+ * `@wordpress/ui` overlays so they reliably stack in mixed-library
6
+ * compositions. See `getWpCompatOverlaySlot()` for the runtime side.
7
+ *
8
+ * The `.slot` rule is authored unlayered (outside `@layer wp-ui-*`) so the
9
+ * slot's z-index and positioning win against any `@layer`-scoped rule.
10
+ */
11
+
12
+ .slot {
13
+ /* Viewport-sized so `position: absolute` overlay positioners inside
14
+ * the slot have a non-zero containing block — otherwise `width: auto`
15
+ * popups collapse to their min-content width. */
16
+ position: fixed;
17
+ inset: 0;
18
+ /* Reserved band above the legacy z-index map in
19
+ * `packages/base-styles/_z-index.scss`. */
20
+ z-index: 1000000003;
21
+ isolation: isolate;
22
+ /* The slot itself doesn't capture clicks; portaled overlays opt back in
23
+ * via the `.slot > *` rule below. */
24
+ pointer-events: none;
25
+ }
26
+
27
+ /*
28
+ * Re-enable interaction for portaled overlays. Placed in the lowest layer
29
+ * so any consumer can override with a higher-layer (or unlayered) rule.
30
+ */
31
+ @layer wp-ui-utilities {
32
+ .slot > * {
33
+ pointer-events: auto;
34
+ }
35
+ }
@@ -27,5 +27,8 @@ export function renderSlotWithChildren(
27
27
  defaultSlot: ReactElement,
28
28
  children: ReactNode
29
29
  ): ReactElement {
30
- return cloneElement( slot ?? defaultSlot, { children } );
30
+ return cloneElement(
31
+ ( slot ?? defaultSlot ) as ReactElement< { children?: ReactNode } >,
32
+ { children }
33
+ );
31
34
  }
@@ -0,0 +1,74 @@
1
+ import { render } from '@testing-library/react';
2
+ import {
3
+ WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE,
4
+ getWpCompatOverlaySlot,
5
+ __resetWpCompatOverlaySlotCacheForTests,
6
+ } from '../wp-compat-overlay-slot';
7
+ import { useEnableWpCompatOverlaySlot } from '../use-enable-wp-compat-overlay-slot';
8
+
9
+ const internalWindow = window as unknown as {
10
+ __wpUiCompatOverlaySlotEnabled?: boolean;
11
+ };
12
+
13
+ // Slot is identified by a data attribute, not a user-facing role/text.
14
+ /* eslint-disable testing-library/no-node-access */
15
+
16
+ function findSlots(): HTMLElement[] {
17
+ return Array.from(
18
+ document.querySelectorAll< HTMLElement >(
19
+ `[${ WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE }]`
20
+ )
21
+ );
22
+ }
23
+
24
+ function HookHost() {
25
+ useEnableWpCompatOverlaySlot();
26
+ return null;
27
+ }
28
+
29
+ describe( 'useEnableWpCompatOverlaySlot', () => {
30
+ afterEach( () => {
31
+ __resetWpCompatOverlaySlotCacheForTests();
32
+ findSlots().forEach( ( el ) => el.remove() );
33
+ delete internalWindow.__wpUiCompatOverlaySlotEnabled;
34
+ } );
35
+
36
+ it( 'enables the slot once mounted, so getWpCompatOverlaySlot() returns the slot', () => {
37
+ expect( getWpCompatOverlaySlot() ).toBeUndefined();
38
+
39
+ render( <HookHost /> );
40
+
41
+ const slot = getWpCompatOverlaySlot();
42
+ expect( slot ).toBeDefined();
43
+ expect( slot?.parentElement ).toBe( document.body );
44
+ expect( findSlots() ).toHaveLength( 1 );
45
+ } );
46
+
47
+ it( 'is idempotent across multiple components calling the hook', () => {
48
+ render(
49
+ <>
50
+ <HookHost />
51
+ <HookHost />
52
+ <HookHost />
53
+ </>
54
+ );
55
+
56
+ expect( getWpCompatOverlaySlot() ).toBeDefined();
57
+ expect( findSlots() ).toHaveLength( 1 );
58
+ } );
59
+
60
+ it( 'leaves the slot enabled after the hook caller unmounts (one-way opt-in)', () => {
61
+ // Pins the one-way behavior — unmounting must not flip the gate
62
+ // back off; the slot is shared infrastructure.
63
+ const { unmount } = render( <HookHost /> );
64
+
65
+ expect( getWpCompatOverlaySlot() ).toBeDefined();
66
+
67
+ unmount();
68
+
69
+ expect( internalWindow.__wpUiCompatOverlaySlotEnabled ).toBe( true );
70
+ expect( getWpCompatOverlaySlot() ).toBeDefined();
71
+ } );
72
+ } );
73
+
74
+ /* eslint-enable testing-library/no-node-access */
@@ -0,0 +1,300 @@
1
+ import {
2
+ getWpCompatOverlaySlot,
3
+ WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE,
4
+ __resetWpCompatOverlaySlotCacheForTests,
5
+ } from '../wp-compat-overlay-slot';
6
+
7
+ // Typed accessors mirroring the helper's local casts: the flag and the
8
+ // `wp` global are both intentionally undeclared on `Window` so the
9
+ // package's published types don't leak augmentations.
10
+ const internalWindow = window as unknown as {
11
+ __wpUiCompatOverlaySlotEnabled?: unknown;
12
+ };
13
+ const wpEnvWindow = window as unknown as {
14
+ wp?: { components?: unknown };
15
+ };
16
+
17
+ function findSlots(): HTMLElement[] {
18
+ return Array.from(
19
+ document.querySelectorAll< HTMLElement >(
20
+ `[${ WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE }]`
21
+ )
22
+ );
23
+ }
24
+
25
+ describe( 'getWpCompatOverlaySlot', () => {
26
+ afterEach( () => {
27
+ __resetWpCompatOverlaySlotCacheForTests();
28
+ findSlots().forEach( ( el ) => el.remove() );
29
+ delete internalWindow.__wpUiCompatOverlaySlotEnabled;
30
+ delete wpEnvWindow.wp;
31
+ } );
32
+
33
+ describe( 'explicit opt-in via internal flag', () => {
34
+ it( 'returns undefined when no gate is open', () => {
35
+ expect( getWpCompatOverlaySlot() ).toBeUndefined();
36
+ expect( findSlots() ).toHaveLength( 0 );
37
+ } );
38
+
39
+ it( 'returns undefined when the flag is explicitly false', () => {
40
+ internalWindow.__wpUiCompatOverlaySlotEnabled = false;
41
+
42
+ expect( getWpCompatOverlaySlot() ).toBeUndefined();
43
+ expect( findSlots() ).toHaveLength( 0 );
44
+ } );
45
+
46
+ it.each( [
47
+ [ '1', 1 ],
48
+ [ "'yes'", 'yes' ],
49
+ [ 'null', null ],
50
+ [ 'undefined', undefined ],
51
+ ] )(
52
+ 'returns undefined when the flag is %s (strict-equality gate)',
53
+ ( _label, value ) => {
54
+ internalWindow.__wpUiCompatOverlaySlotEnabled = value;
55
+
56
+ expect( getWpCompatOverlaySlot() ).toBeUndefined();
57
+ expect( findSlots() ).toHaveLength( 0 );
58
+ }
59
+ );
60
+
61
+ it( 'creates and returns the slot when the flag is true', () => {
62
+ internalWindow.__wpUiCompatOverlaySlotEnabled = true;
63
+
64
+ const slot = getWpCompatOverlaySlot();
65
+
66
+ expect( slot ).toBeDefined();
67
+ expect( slot ).toBeInstanceOf( HTMLDivElement );
68
+ expect( slot?.parentElement ).toBe( document.body );
69
+ expect(
70
+ slot?.hasAttribute( WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE )
71
+ ).toBe( true );
72
+ expect( findSlots() ).toHaveLength( 1 );
73
+ } );
74
+ } );
75
+
76
+ describe( 'WordPress environment auto-detection', () => {
77
+ it( 'auto-enables when window.wp.components is an object', () => {
78
+ wpEnvWindow.wp = { components: {} };
79
+
80
+ const slot = getWpCompatOverlaySlot();
81
+
82
+ expect( slot ).toBeDefined();
83
+ expect( findSlots() ).toHaveLength( 1 );
84
+ } );
85
+
86
+ it.each( [
87
+ [ 'a string', 'something' ],
88
+ [ 'a number', 42 ],
89
+ [ 'a boolean', true ],
90
+ [ 'undefined', undefined ],
91
+ ] )(
92
+ 'does not auto-enable when window.wp.components is %s',
93
+ ( _label, value ) => {
94
+ wpEnvWindow.wp = { components: value };
95
+
96
+ expect( getWpCompatOverlaySlot() ).toBeUndefined();
97
+ expect( findSlots() ).toHaveLength( 0 );
98
+ }
99
+ );
100
+
101
+ it( 'does not auto-enable when window.wp.components is null', () => {
102
+ // `typeof null === 'object'` — pins the explicit null guard.
103
+ wpEnvWindow.wp = { components: null };
104
+
105
+ expect( getWpCompatOverlaySlot() ).toBeUndefined();
106
+ expect( findSlots() ).toHaveLength( 0 );
107
+ } );
108
+
109
+ it( 'does not auto-enable when window.wp itself is missing', () => {
110
+ expect( getWpCompatOverlaySlot() ).toBeUndefined();
111
+ expect( findSlots() ).toHaveLength( 0 );
112
+ } );
113
+
114
+ it( 'opens the gate even with the explicit flag absent', () => {
115
+ wpEnvWindow.wp = { components: {} };
116
+ expect(
117
+ internalWindow.__wpUiCompatOverlaySlotEnabled
118
+ ).toBeUndefined();
119
+
120
+ expect( getWpCompatOverlaySlot() ).toBeDefined();
121
+ } );
122
+
123
+ // The cross-origin `window.top` throw path isn't unit-tested:
124
+ // jsdom's `window.top` is a non-configurable, non-writable getter,
125
+ // so the throw can't be simulated. Same-origin happy path is
126
+ // covered by every other auto-detect test; cross-origin is
127
+ // validated via manual smoke testing.
128
+ } );
129
+
130
+ describe( 'singleton caching', () => {
131
+ beforeEach( () => {
132
+ internalWindow.__wpUiCompatOverlaySlotEnabled = true;
133
+ } );
134
+
135
+ it( 'returns the same element on repeated calls', () => {
136
+ const first = getWpCompatOverlaySlot();
137
+ const second = getWpCompatOverlaySlot();
138
+ const third = getWpCompatOverlaySlot();
139
+
140
+ expect( first ).toBeDefined();
141
+ expect( second ).toBe( first );
142
+ expect( third ).toBe( first );
143
+ expect( findSlots() ).toHaveLength( 1 );
144
+ } );
145
+
146
+ it( 'creates a fresh element when the previous one was removed from the DOM, and re-caches it', () => {
147
+ const first = getWpCompatOverlaySlot();
148
+ expect( first ).toBeDefined();
149
+
150
+ first?.remove();
151
+ expect( findSlots() ).toHaveLength( 0 );
152
+
153
+ const second = getWpCompatOverlaySlot();
154
+
155
+ expect( second ).toBeDefined();
156
+ expect( second ).not.toBe( first );
157
+ expect( second?.isConnected ).toBe( true );
158
+ expect( findSlots() ).toHaveLength( 1 );
159
+
160
+ // A third call returns the cached recreated slot directly.
161
+ const third = getWpCompatOverlaySlot();
162
+ expect( third ).toBe( second );
163
+ expect( findSlots() ).toHaveLength( 1 );
164
+ } );
165
+
166
+ it( 'returns undefined after the gate is closed, even if a slot was previously created', () => {
167
+ const slot = getWpCompatOverlaySlot();
168
+ expect( slot ).toBeDefined();
169
+
170
+ delete internalWindow.__wpUiCompatOverlaySlotEnabled;
171
+
172
+ expect( getWpCompatOverlaySlot() ).toBeUndefined();
173
+ } );
174
+
175
+ it( 'invalidates the cache and detaches the stale slot when the cached element belongs to a different document', () => {
176
+ // Exercises the foreign-document cleanup branch by moving the
177
+ // cached slot into a parsed foreign document, so it stays
178
+ // `isConnected` but `ownerDocument` differs from the helper's
179
+ // local `document`.
180
+ const first = getWpCompatOverlaySlot();
181
+ expect( first ).toBeDefined();
182
+
183
+ const foreignDocument = new DOMParser().parseFromString(
184
+ '<!DOCTYPE html><html><body></body></html>',
185
+ 'text/html'
186
+ );
187
+ foreignDocument.body.appendChild(
188
+ foreignDocument.adoptNode( first! )
189
+ );
190
+ expect( first?.ownerDocument ).toBe( foreignDocument );
191
+ expect( first?.isConnected ).toBe( true );
192
+ expect( findSlots() ).toHaveLength( 0 );
193
+
194
+ const second = getWpCompatOverlaySlot();
195
+
196
+ expect( second ).toBeDefined();
197
+ expect( second ).not.toBe( first );
198
+ expect( second?.ownerDocument ).toBe( document );
199
+ expect( second?.parentElement ).toBe( document.body );
200
+ expect( first?.isConnected ).toBe( false );
201
+ expect( foreignDocument.body.children ).toHaveLength( 0 );
202
+ expect( findSlots() ).toHaveLength( 1 );
203
+ } );
204
+ } );
205
+
206
+ describe( 'DOM-level singleton (cross-instance coordination)', () => {
207
+ beforeEach( () => {
208
+ internalWindow.__wpUiCompatOverlaySlotEnabled = true;
209
+ } );
210
+
211
+ it( 'adopts a pre-existing slot element rather than appending a duplicate', () => {
212
+ // Simulates a second `@wordpress/ui` instance creating the slot
213
+ // first: `cachedSlot` is null but the slot already exists in the DOM.
214
+ const preExisting = document.createElement( 'div' );
215
+ preExisting.setAttribute( WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE, '' );
216
+ document.body.appendChild( preExisting );
217
+
218
+ const slot = getWpCompatOverlaySlot();
219
+
220
+ expect( slot ).toBe( preExisting );
221
+ expect( findSlots() ).toHaveLength( 1 );
222
+ } );
223
+
224
+ it( 'caches the adopted slot for subsequent calls', () => {
225
+ const preExisting = document.createElement( 'div' );
226
+ preExisting.setAttribute( WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE, '' );
227
+ document.body.appendChild( preExisting );
228
+
229
+ const first = getWpCompatOverlaySlot();
230
+ const second = getWpCompatOverlaySlot();
231
+
232
+ expect( first ).toBe( preExisting );
233
+ expect( second ).toBe( preExisting );
234
+ expect( findSlots() ).toHaveLength( 1 );
235
+ } );
236
+ } );
237
+
238
+ describe( 'document.body unavailable', () => {
239
+ beforeEach( () => {
240
+ internalWindow.__wpUiCompatOverlaySlotEnabled = true;
241
+ } );
242
+
243
+ it( 'returns undefined without throwing when document.body is missing', () => {
244
+ const realBody = document.body;
245
+ const bodyDescriptor = Object.getOwnPropertyDescriptor(
246
+ Document.prototype,
247
+ 'body'
248
+ );
249
+
250
+ Object.defineProperty( document, 'body', {
251
+ configurable: true,
252
+ get: () => null,
253
+ } );
254
+
255
+ try {
256
+ expect( () => getWpCompatOverlaySlot() ).not.toThrow();
257
+ expect( getWpCompatOverlaySlot() ).toBeUndefined();
258
+ } finally {
259
+ if ( bodyDescriptor ) {
260
+ Object.defineProperty( document, 'body', bodyDescriptor );
261
+ } else {
262
+ // Fallback if `body` wasn't on Document.prototype.
263
+ delete ( document as unknown as { body: unknown } ).body;
264
+ }
265
+ expect( document.body ).toBe( realBody );
266
+ }
267
+ } );
268
+ } );
269
+
270
+ describe( 'DOM identification', () => {
271
+ beforeEach( () => {
272
+ internalWindow.__wpUiCompatOverlaySlotEnabled = true;
273
+ } );
274
+
275
+ it( 'tags the element with the data-wp-compat-overlay-slot attribute (no value)', () => {
276
+ const slot = getWpCompatOverlaySlot();
277
+
278
+ expect(
279
+ slot?.getAttribute( WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE )
280
+ ).toBe( '' );
281
+ } );
282
+
283
+ it( 'is discoverable via [data-wp-compat-overlay-slot] selector', () => {
284
+ const slot = getWpCompatOverlaySlot();
285
+
286
+ expect(
287
+ document.querySelector(
288
+ `[${ WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE }]`
289
+ )
290
+ ).toBe( slot );
291
+ } );
292
+
293
+ it( 'appends the slot to the local document body', () => {
294
+ const slot = getWpCompatOverlaySlot();
295
+
296
+ expect( slot?.ownerDocument ).toBe( document );
297
+ expect( slot?.parentElement ).toBe( document.body );
298
+ } );
299
+ } );
300
+ } );
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Opts the host application into the `@wordpress/ui` compat overlay slot —
3
+ * a body-level container into which `@wordpress/ui` overlays portal so they
4
+ * reliably stack above `@wordpress/components` overlays in mixed-library
5
+ * compositions.
6
+ *
7
+ * Call once from a component that mounts for the lifetime of the app
8
+ * (typically the root). Idempotent and one-way: a single caller should not
9
+ * be able to turn off shared infrastructure for everyone else; if the slot
10
+ * isn't wanted, simply don't call this hook.
11
+ *
12
+ * Where `window.wp.components` is on the global — the typical setup for
13
+ * plugins enqueueing `wp-components` through WordPress's script-loader —
14
+ * the slot auto-enables and this hook is a no-op.
15
+ */
16
+ export function useEnableWpCompatOverlaySlot(): void {
17
+ if ( typeof window === 'undefined' ) {
18
+ return;
19
+ }
20
+
21
+ // Applied during render (not in `useLayoutEffect`) so descendants in
22
+ // the same render pass — e.g. `Tooltip.Portal`, which reads
23
+ // `getWpCompatOverlaySlot()` on every render — see the gate open on
24
+ // first mount. Safe to write during render: the value is an idempotent
25
+ // boolean.
26
+ const internalWindow = window as {
27
+ __wpUiCompatOverlaySlotEnabled?: boolean;
28
+ };
29
+ if ( internalWindow.__wpUiCompatOverlaySlotEnabled !== true ) {
30
+ internalWindow.__wpUiCompatOverlaySlotEnabled = true;
31
+ }
32
+ }
@@ -0,0 +1,129 @@
1
+ import styles from './css/wp-compat-overlay-slot.module.css';
2
+
3
+ // Local casts for the auto-detect heuristic and the shared opt-in flag,
4
+ // kept off the global `Window` interface so this package's `.d.ts` doesn't
5
+ // leak `Window.wp` / `Window.__wpUiCompatOverlaySlotEnabled` augmentations.
6
+ type WpEnvironmentWindow = {
7
+ wp?: {
8
+ components?: unknown;
9
+ };
10
+ };
11
+ type CompatOverlaySlotInternalWindow = {
12
+ __wpUiCompatOverlaySlotEnabled?: boolean;
13
+ };
14
+
15
+ /**
16
+ * Marker attribute on the compat overlay slot element.
17
+ */
18
+ export const WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE = 'data-wp-compat-overlay-slot';
19
+
20
+ function resolveOwnerDocument(): Document | null {
21
+ // Always the local document — not `window.top?.document`, which would
22
+ // put the slot in a document where this bundle's CSS modules aren't
23
+ // loaded (e.g. Storybook's preview iframe).
24
+ return typeof document === 'undefined' ? null : document;
25
+ }
26
+
27
+ function isInWordPressEnvironment(): boolean {
28
+ let topWp: WpEnvironmentWindow[ 'wp' ];
29
+ try {
30
+ // Try the top window first so an iframe (e.g. the editor canvas)
31
+ // inherits the parent's WP environment.
32
+ topWp = ( window.top as WpEnvironmentWindow | undefined )?.wp;
33
+ } catch {
34
+ // Cross-origin top window — fall through to the local window.
35
+ }
36
+ const wp = topWp ?? ( window as WpEnvironmentWindow ).wp;
37
+ // Stricter than `!== undefined` so a stray non-object `components`
38
+ // doesn't trigger auto-enable. Explicit null check covers
39
+ // `typeof null === 'object'`.
40
+ return typeof wp?.components === 'object' && wp.components !== null;
41
+ }
42
+
43
+ // Revalidated on each call against the current owner document and the
44
+ // slot's connection state.
45
+ let cachedSlot: HTMLDivElement | null = null;
46
+
47
+ function createSlot( ownerDocument: Document ): HTMLDivElement {
48
+ const element = ownerDocument.createElement( 'div' );
49
+ element.setAttribute( WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE, '' );
50
+ if ( styles.slot ) {
51
+ element.classList.add( styles.slot );
52
+ }
53
+ ownerDocument.body.appendChild( element );
54
+ return element;
55
+ }
56
+
57
+ /**
58
+ * Returns the body-level compat overlay slot when the runtime opts in,
59
+ * lazily creating it on first call. Returns `undefined` otherwise — so the
60
+ * return value can be forwarded straight to a `container` prop, leaving the
61
+ * default portal container in effect.
62
+ *
63
+ * Two opt-in paths:
64
+ *
65
+ * - Auto-detected when `window.wp.components` is on the global — the
66
+ * typical script-loader setup for WordPress plugins and admin screens.
67
+ * - Explicit, via `useEnableWpCompatOverlaySlot()` — for hosts that bundle
68
+ * `@wordpress/components` (or only `@wordpress/ui`) directly rather than
69
+ * relying on the global.
70
+ *
71
+ * The slot is a single `<div data-wp-compat-overlay-slot>` appended to the
72
+ * local document's body. Subsequent calls return the same element; if it's
73
+ * been removed it's recreated, and a slot created by another
74
+ * `@wordpress/ui` instance in the same document is adopted rather than
75
+ * duplicated.
76
+ */
77
+ export function getWpCompatOverlaySlot(): HTMLDivElement | undefined {
78
+ if ( typeof window === 'undefined' ) {
79
+ return undefined;
80
+ }
81
+
82
+ if (
83
+ ! isInWordPressEnvironment() &&
84
+ ( window as CompatOverlaySlotInternalWindow )
85
+ .__wpUiCompatOverlaySlotEnabled !== true
86
+ ) {
87
+ return undefined;
88
+ }
89
+
90
+ const ownerDocument = resolveOwnerDocument();
91
+ // `document.body` can be null if this runs before `<body>` is parsed
92
+ // (e.g. a `<script>` in `<head>`). Bail rather than throw in `createSlot`.
93
+ if ( ! ownerDocument || ! ownerDocument.body ) {
94
+ return undefined;
95
+ }
96
+
97
+ if (
98
+ cachedSlot &&
99
+ cachedSlot.ownerDocument === ownerDocument &&
100
+ cachedSlot.isConnected
101
+ ) {
102
+ return cachedSlot;
103
+ }
104
+
105
+ // Prefer an existing slot in the document over creating a duplicate —
106
+ // this is how multiple `@wordpress/ui` instances share one slot.
107
+ const existing = ownerDocument.querySelector< HTMLDivElement >(
108
+ `[${ WP_COMPAT_OVERLAY_SLOT_ATTRIBUTE }]`
109
+ );
110
+ if ( existing instanceof HTMLDivElement ) {
111
+ cachedSlot = existing;
112
+ return existing;
113
+ }
114
+
115
+ // Don't orphan a cached slot still attached to a foreign document.
116
+ if ( cachedSlot?.isConnected ) {
117
+ cachedSlot.remove();
118
+ }
119
+
120
+ cachedSlot = createSlot( ownerDocument );
121
+ return cachedSlot;
122
+ }
123
+
124
+ /**
125
+ * Test-only escape hatch that drops the cached singleton.
126
+ */
127
+ export function __resetWpCompatOverlaySlotCacheForTests(): void {
128
+ cachedSlot = null;
129
+ }