@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,1315 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { useId, useRef, useState } from '@wordpress/element';
3
+ import { SlotFillProvider, Slot } from '@wordpress/components';
4
+ import { close, info } from '@wordpress/icons';
5
+ import { Popover, VisuallyHidden } from '../..';
6
+ import { Icon } from '../../icon';
7
+ import { IconButton } from '../../icon-button';
8
+ import { GenericIframe, useMeasure } from './utils';
9
+
10
+ const meta: Meta< typeof Popover.Root > = {
11
+ title: 'Design System/Components/Popover',
12
+ component: Popover.Root,
13
+ subcomponents: {
14
+ 'Popover.Trigger': Popover.Trigger,
15
+ 'Popover.Popup': Popover.Popup,
16
+ 'Popover.Arrow': Popover.Arrow,
17
+ 'Popover.Title': Popover.Title,
18
+ 'Popover.Description': Popover.Description,
19
+ 'Popover.Close': Popover.Close,
20
+ },
21
+ argTypes: {
22
+ children: { control: false },
23
+ },
24
+ };
25
+ export default meta;
26
+
27
+ type Story = StoryObj< typeof Popover.Root >;
28
+
29
+ export const Default: Story = {
30
+ argTypes: {
31
+ children: { control: { type: 'text' } },
32
+ },
33
+ args: {
34
+ children: (
35
+ <>
36
+ <Popover.Trigger>Open Popover</Popover.Trigger>
37
+ <Popover.Popup>
38
+ <Popover.Arrow />
39
+ <Popover.Title
40
+ style={ {
41
+ marginBottom: 'var(--wpds-dimension-gap-xs)',
42
+ } }
43
+ >
44
+ Popover title
45
+ </Popover.Title>
46
+ <Popover.Description>
47
+ Popover description
48
+ </Popover.Description>
49
+ </Popover.Popup>
50
+ </>
51
+ ),
52
+ },
53
+ };
54
+
55
+ /**
56
+ * A popover without the arrow sub-component. Omit `Popover.Arrow`
57
+ * from the popup content when an arrow indicator is not desired.
58
+ */
59
+ export const NoArrow: Story = {
60
+ args: {
61
+ children: (
62
+ <>
63
+ <Popover.Trigger>Open Popover</Popover.Trigger>
64
+ <Popover.Popup>
65
+ <Popover.Title
66
+ style={ {
67
+ marginBottom: 'var(--wpds-dimension-gap-xs)',
68
+ } }
69
+ >
70
+ Popover title
71
+ </Popover.Title>
72
+ <Popover.Description>
73
+ Popover description
74
+ </Popover.Description>
75
+ </Popover.Popup>
76
+ </>
77
+ ),
78
+ },
79
+ };
80
+
81
+ /**
82
+ * All combinations of `side` and `align` props on `Popover.Popup`.
83
+ *
84
+ * Each row shows a side (`top`, `right`, `bottom`, `left`), and each column
85
+ * shows an alignment (`start`, `center`, `end`).
86
+ */
87
+ export const Positioning: Story = {
88
+ parameters: { controls: { disable: true } },
89
+ render: function Render() {
90
+ const sides = [ 'top', 'right', 'bottom', 'left' ] as const;
91
+ const aligns = [ 'start', 'center', 'end' ] as const;
92
+
93
+ return (
94
+ <div
95
+ style={ {
96
+ display: 'grid',
97
+ gridTemplateColumns: 'repeat(3, 1fr)',
98
+ gap: '6rem',
99
+ padding: '6rem 4rem',
100
+ justifyItems: 'center',
101
+ } }
102
+ >
103
+ { sides.flatMap( ( side ) =>
104
+ aligns.map( ( align ) => (
105
+ <Popover.Root key={ `${ side }-${ align }` } open>
106
+ <Popover.Trigger>
107
+ { side } / { align }
108
+ </Popover.Trigger>
109
+ <Popover.Popup
110
+ side={ side }
111
+ align={ align }
112
+ collisionAvoidance={ {
113
+ side: 'none',
114
+ align: 'none',
115
+ } }
116
+ >
117
+ <VisuallyHidden render={ <Popover.Title /> }>
118
+ { side } / { align }
119
+ </VisuallyHidden>
120
+ <Popover.Arrow />
121
+ <Popover.Description>
122
+ { side } side / { align } align
123
+ </Popover.Description>
124
+ </Popover.Popup>
125
+ </Popover.Root>
126
+ ) )
127
+ ) }
128
+ </div>
129
+ );
130
+ },
131
+ };
132
+
133
+ /**
134
+ * A popover with a close icon button, title, and description. The
135
+ * `Popover.Close` component renders a button that closes the popover when
136
+ * clicked. Here it wraps an `IconButton` for a properly sized, accessible
137
+ * close action — matching the Dialog close-icon pattern.
138
+ */
139
+ export const WithCloseButton: Story = {
140
+ args: {
141
+ children: (
142
+ <>
143
+ <Popover.Trigger>Settings</Popover.Trigger>
144
+ <Popover.Popup>
145
+ <Popover.Arrow />
146
+ <div
147
+ style={ {
148
+ display: 'flex',
149
+ justifyContent: 'space-between',
150
+ alignItems: 'center',
151
+ marginBottom: 'var(--wpds-dimension-gap-sm)',
152
+ } }
153
+ >
154
+ <Popover.Title>Settings</Popover.Title>
155
+ <Popover.Close
156
+ render={
157
+ <IconButton
158
+ variant="minimal"
159
+ size="compact"
160
+ tone="neutral"
161
+ icon={ close }
162
+ label="Close"
163
+ />
164
+ }
165
+ />
166
+ </div>
167
+ <Popover.Description>
168
+ Configure your notification preferences and display
169
+ settings.
170
+ </Popover.Description>
171
+ </Popover.Popup>
172
+ </>
173
+ ),
174
+ },
175
+ };
176
+
177
+ /**
178
+ * Use the `open` and `onOpenChange` props on `Popover.Root` to control the
179
+ * popover's visibility programmatically.
180
+ *
181
+ * The checkbox drives the popover state externally. The popover's trigger
182
+ * and click-outside dismiss both sync back to the same state via
183
+ * `onOpenChange`, keeping everything in sync.
184
+ */
185
+ export const Controlled: Story = {
186
+ argTypes: {
187
+ open: { control: false },
188
+ onOpenChange: { control: false },
189
+ defaultOpen: { control: false },
190
+ },
191
+ args: {
192
+ children: (
193
+ <>
194
+ <Popover.Trigger>Toggle Popover</Popover.Trigger>
195
+ <Popover.Popup>
196
+ <Popover.Arrow />
197
+ <Popover.Title
198
+ style={ {
199
+ marginBottom: 'var(--wpds-dimension-gap-xs)',
200
+ } }
201
+ >
202
+ Controlled Popover
203
+ </Popover.Title>
204
+ <Popover.Description>
205
+ This popover is controlled by external state.
206
+ </Popover.Description>
207
+ </Popover.Popup>
208
+ </>
209
+ ),
210
+ },
211
+ render: function Render( args ) {
212
+ const [ isOpen, setIsOpen ] = useState( false );
213
+ const checkboxId = useId();
214
+ const checkboxRef = useRef< HTMLInputElement >( null );
215
+ const labelRef = useRef< HTMLLabelElement >( null );
216
+
217
+ return (
218
+ <div
219
+ style={ {
220
+ display: 'flex',
221
+ gap: '1rem',
222
+ alignItems: 'center',
223
+ } }
224
+ >
225
+ <Popover.Root
226
+ { ...args }
227
+ open={ isOpen }
228
+ onOpenChange={ ( nextOpen, eventDetails ) => {
229
+ if (
230
+ [ 'outside-press', 'focus-out' ].includes(
231
+ eventDetails.reason
232
+ ) &&
233
+ !! eventDetails.event.target &&
234
+ (
235
+ [
236
+ checkboxRef.current,
237
+ labelRef.current,
238
+ ].filter( Boolean ) as EventTarget[]
239
+ ).includes( eventDetails.event.target )
240
+ ) {
241
+ return;
242
+ }
243
+
244
+ setIsOpen( nextOpen );
245
+ } }
246
+ />
247
+
248
+ <label htmlFor={ checkboxId } ref={ labelRef }>
249
+ <input
250
+ ref={ checkboxRef }
251
+ id={ checkboxId }
252
+ type="checkbox"
253
+ checked={ isOpen }
254
+ onChange={ ( e ) => setIsOpen( e.target.checked ) }
255
+ />
256
+ Open
257
+ </label>
258
+ </div>
259
+ );
260
+ },
261
+ };
262
+
263
+ /**
264
+ * Set `modal` to `true` to trap focus inside the popover when it is open.
265
+ * This is useful for complex popover content that requires user interaction,
266
+ * such as forms. Try tabbing through the fields — focus stays inside the
267
+ * popover until it is dismissed.
268
+ *
269
+ * **Note:** focus trapping requires a `Popover.Close` part inside the popup
270
+ * so that screen readers always have an escape route. It can be visually
271
+ * hidden if needed.
272
+ *
273
+ * Pass `backdrop` to `Popover.Popup` to display a semi-transparent overlay
274
+ * beneath the popover, signalling that the page is blocked.
275
+ */
276
+ export const Modal: Story = {
277
+ argTypes: { modal: { control: false } },
278
+ args: {
279
+ modal: true,
280
+ children: (
281
+ <>
282
+ <Popover.Trigger>Edit Settings</Popover.Trigger>
283
+ <Popover.Popup backdrop>
284
+ <Popover.Arrow />
285
+ <Popover.Title
286
+ style={ {
287
+ marginBottom: 'var(--wpds-dimension-gap-xs)',
288
+ } }
289
+ >
290
+ Settings
291
+ </Popover.Title>
292
+ <form
293
+ style={ {
294
+ display: 'flex',
295
+ flexDirection: 'column',
296
+ gap: 'var(--wpds-dimension-gap-sm)',
297
+ marginTop: 'var(--wpds-dimension-gap-sm)',
298
+ } }
299
+ onSubmit={ ( e ) => e.preventDefault() }
300
+ >
301
+ <label
302
+ htmlFor="popover-test-name-id"
303
+ style={ {
304
+ display: 'flex',
305
+ flexDirection: 'column',
306
+ gap: 'var(--wpds-dimension-gap-xs)',
307
+ fontSize: 'inherit',
308
+ } }
309
+ >
310
+ Name
311
+ <input
312
+ // eslint-disable-next-line no-restricted-syntax
313
+ id="popover-test-name-id"
314
+ type="text"
315
+ placeholder="Enter your name"
316
+ />
317
+ </label>
318
+ <label
319
+ htmlFor="popover-test-email-id"
320
+ style={ {
321
+ display: 'flex',
322
+ flexDirection: 'column',
323
+ gap: 'var(--wpds-dimension-gap-xs)',
324
+ fontSize: 'inherit',
325
+ } }
326
+ >
327
+ Email
328
+ <input
329
+ // eslint-disable-next-line no-restricted-syntax
330
+ id="popover-test-email-id"
331
+ type="email"
332
+ placeholder="Enter your email"
333
+ />
334
+ </label>
335
+ <div
336
+ style={ {
337
+ display: 'flex',
338
+ justifyContent: 'flex-end',
339
+ gap: 'var(--wpds-dimension-gap-sm)',
340
+ marginTop: 'var(--wpds-dimension-gap-xs)',
341
+ } }
342
+ >
343
+ <Popover.Close
344
+ style={ {
345
+ all: 'unset',
346
+ cursor: 'pointer',
347
+ } }
348
+ >
349
+ Cancel
350
+ </Popover.Close>
351
+ <button type="submit">Save</button>
352
+ </div>
353
+ </form>
354
+ </Popover.Popup>
355
+ </>
356
+ ),
357
+ },
358
+ };
359
+
360
+ /**
361
+ * The `variant="unstyled"` option strips all visual styling from the popup,
362
+ * making it a blank positioning container for fully custom content.
363
+ */
364
+ export const Unstyled: Story = {
365
+ args: {
366
+ children: (
367
+ <>
368
+ <Popover.Trigger>Open Unstyled</Popover.Trigger>
369
+ <Popover.Popup variant="unstyled">
370
+ <Popover.Title
371
+ style={ {
372
+ marginBottom: 'var(--wpds-dimension-gap-xs)',
373
+ } }
374
+ >
375
+ Custom Styled
376
+ </Popover.Title>
377
+ <Popover.Description>
378
+ This popup has no default styling — the consumer
379
+ controls all visual appearance.
380
+ </Popover.Description>
381
+ </Popover.Popup>
382
+ </>
383
+ ),
384
+ },
385
+ };
386
+
387
+ /**
388
+ * Overlay placement positions the popover centered on top of its trigger,
389
+ * effectively covering it. This is achieved by computing a negative
390
+ * `sideOffset` based on the measured sizes of the trigger and popup.
391
+ *
392
+ * This technique is useful when you want the popover to visually replace
393
+ * the trigger element in place.
394
+ */
395
+ export const OverlayPlacement: Story = {
396
+ args: { defaultOpen: true },
397
+ argTypes: { defaultOpen: { control: false } },
398
+ render: function Render( { children: _children, ...args } ) {
399
+ const [ popupRef, popupSize ] = useMeasure< HTMLDivElement >();
400
+ const [ triggerRef, triggerSize ] = useMeasure< HTMLButtonElement >();
401
+
402
+ return (
403
+ <div style={ { padding: '4rem', textAlign: 'center' } }>
404
+ <Popover.Root { ...args }>
405
+ <Popover.Trigger ref={ triggerRef }>
406
+ Trigger (covered by popover)
407
+ </Popover.Trigger>
408
+ <Popover.Popup
409
+ ref={ popupRef }
410
+ side="bottom"
411
+ align="center"
412
+ sideOffset={
413
+ -1 *
414
+ ( popupSize.height / 2 + triggerSize.height / 2 )
415
+ }
416
+ collisionAvoidance={ {
417
+ side: 'none',
418
+ align: 'none',
419
+ } }
420
+ >
421
+ <Popover.Title
422
+ style={ {
423
+ marginBottom: 'var(--wpds-dimension-gap-xs)',
424
+ } }
425
+ >
426
+ Overlay
427
+ </Popover.Title>
428
+ <Popover.Description>
429
+ This popover is centered over its trigger using a
430
+ negative sideOffset.
431
+ <br />
432
+ The trigger is currently hidden under the popover.
433
+ <br />
434
+ Try resizing the browser — collision avoidance is
435
+ disabled so the popover stays overlaid.
436
+ </Popover.Description>
437
+ </Popover.Popup>
438
+ </Popover.Root>
439
+ </div>
440
+ );
441
+ },
442
+ };
443
+
444
+ /**
445
+ * To render the popup inline (without a portal), create a local ref to a
446
+ * `<span>` with `display: contents` and pass it as the `container` prop.
447
+ * The popup will render inside the span rather than being portaled to
448
+ * `document.body`, while retaining all positioning behavior.
449
+ *
450
+ * **Note:** `backdrop` will not cover the full viewport in this mode.
451
+ */
452
+ export const Inline: Story = {
453
+ parameters: { controls: { disable: true } },
454
+ render: function Render() {
455
+ const inlineContainerRef = useRef< HTMLSpanElement >( null );
456
+
457
+ return (
458
+ <div data-testid="inline-wrapper">
459
+ <Popover.Root>
460
+ <Popover.Trigger>Open Inline</Popover.Trigger>
461
+ <span
462
+ ref={ inlineContainerRef }
463
+ style={ { display: 'contents' } }
464
+ />
465
+ <Popover.Popup container={ inlineContainerRef }>
466
+ <Popover.Arrow />
467
+ <Popover.Title
468
+ style={ {
469
+ marginBottom: 'var(--wpds-dimension-gap-xs)',
470
+ } }
471
+ >
472
+ Inline Popover
473
+ </Popover.Title>
474
+ <Popover.Description>
475
+ This popup is rendered in place — no portal is used.
476
+ Inspect the DOM to see it lives inside its parent.
477
+ </Popover.Description>
478
+ </Popover.Popup>
479
+ </Popover.Root>
480
+ </div>
481
+ );
482
+ },
483
+ };
484
+
485
+ /**
486
+ * Use the `collisionAvoidance` prop to control how the popover behaves when
487
+ * it collides with the edges of its collision boundary.
488
+ *
489
+ * Because the popup renders via a portal (outside the scrollable container),
490
+ * the container must be passed as `collisionBoundary` so Floating UI treats
491
+ * it as the clipping edge.
492
+ *
493
+ * - `side: 'flip'` flips to the opposite side (default).
494
+ * - `side: 'none'` disables collision handling.
495
+ *
496
+ * Scroll the container to see collision avoidance in action.
497
+ */
498
+ export const CollisionAvoidance: Story = {
499
+ parameters: { controls: { disable: true } },
500
+ render: function Render() {
501
+ const [ boundary, setBoundary ] = useState< HTMLElement | null >(
502
+ null
503
+ );
504
+
505
+ return (
506
+ <div
507
+ ref={ setBoundary }
508
+ style={ {
509
+ height: 300,
510
+ overflow: 'auto',
511
+ border: '1px solid #ccc',
512
+ padding: '200px 2rem',
513
+ } }
514
+ >
515
+ <div
516
+ style={ {
517
+ display: 'flex',
518
+ gap: '2rem',
519
+ justifyContent: 'center',
520
+ } }
521
+ >
522
+ <Popover.Root defaultOpen>
523
+ <Popover.Trigger>Flip (default)</Popover.Trigger>
524
+ <Popover.Popup
525
+ side="top"
526
+ collisionBoundary={ boundary ?? undefined }
527
+ >
528
+ <Popover.Title
529
+ style={ {
530
+ marginBottom:
531
+ 'var(--wpds-dimension-gap-xs)',
532
+ } }
533
+ >
534
+ Flip
535
+ </Popover.Title>
536
+ <Popover.Description>
537
+ Flips to bottom when clipped
538
+ </Popover.Description>
539
+ </Popover.Popup>
540
+ </Popover.Root>
541
+
542
+ <Popover.Root defaultOpen>
543
+ <Popover.Trigger>No collision</Popover.Trigger>
544
+ <Popover.Popup
545
+ side="top"
546
+ collisionBoundary={ boundary ?? undefined }
547
+ collisionAvoidance={ {
548
+ side: 'none',
549
+ align: 'none',
550
+ } }
551
+ >
552
+ <Popover.Title
553
+ style={ {
554
+ marginBottom:
555
+ 'var(--wpds-dimension-gap-xs)',
556
+ } }
557
+ >
558
+ None
559
+ </Popover.Title>
560
+ <Popover.Description>
561
+ Stays on top even when clipped
562
+ </Popover.Description>
563
+ </Popover.Popup>
564
+ </Popover.Root>
565
+ </div>
566
+ <div style={ { height: 600 } } />
567
+ </div>
568
+ );
569
+ },
570
+ };
571
+
572
+ /**
573
+ * When the popover's trigger lives inside an iframe but the popover should
574
+ * render in the parent document, pass a parent-document element to the
575
+ * `container` prop on `Popover.Popup`.
576
+ *
577
+ * This technique is used in Gutenberg where the block editor canvas is an
578
+ * iframe but toolbars and menus must appear outside it.
579
+ *
580
+ * Scroll inside the iframe to verify that the popover tracks the trigger
581
+ * position across document boundaries.
582
+ */
583
+ export const CrossIframe: Story = {
584
+ args: { defaultOpen: true },
585
+ argTypes: { defaultOpen: { control: false } },
586
+ render: function Render( { children: _children, ...args } ) {
587
+ const portalContainerRef = useRef< HTMLDivElement >( null );
588
+ const [ iframeBoundary, setIframeBoundary ] =
589
+ useState< HTMLIFrameElement | null >( null );
590
+
591
+ return (
592
+ <div>
593
+ <div ref={ portalContainerRef } />
594
+ <GenericIframe
595
+ ref={ setIframeBoundary }
596
+ style={ {
597
+ width: '100%',
598
+ height: 400,
599
+ border: 0,
600
+ outline: '1px solid purple',
601
+ } }
602
+ >
603
+ <div
604
+ style={ {
605
+ height: '200vh',
606
+ paddingTop: '10vh',
607
+ } }
608
+ >
609
+ <div
610
+ style={ {
611
+ maxWidth: 200,
612
+ marginTop: 100,
613
+ marginInline: 'auto',
614
+ } }
615
+ >
616
+ <Popover.Root { ...args }>
617
+ <Popover.Trigger
618
+ style={ {
619
+ padding: 8,
620
+ background: 'salmon',
621
+ } }
622
+ >
623
+ Popover&apos;s anchor (inside iframe)
624
+ </Popover.Trigger>
625
+ <Popover.Popup
626
+ container={
627
+ portalContainerRef as React.RefObject< HTMLElement >
628
+ }
629
+ collisionBoundary={
630
+ iframeBoundary ?? undefined
631
+ }
632
+ >
633
+ <Popover.Arrow />
634
+ <Popover.Title
635
+ style={ {
636
+ marginBottom:
637
+ 'var(--wpds-dimension-gap-xs)',
638
+ } }
639
+ >
640
+ Cross-Iframe Popover
641
+ </Popover.Title>
642
+ <Popover.Description>
643
+ This popup is rendered in the parent
644
+ document, not inside the iframe. Scroll
645
+ the iframe to see the popover track the
646
+ trigger.
647
+ </Popover.Description>
648
+ </Popover.Popup>
649
+ </Popover.Root>
650
+ </div>
651
+ </div>
652
+ </GenericIframe>
653
+ </div>
654
+ );
655
+ },
656
+ };
657
+
658
+ /**
659
+ * Same cross-iframe scenario, but using `SlotFillProvider` and `Slot` from
660
+ * `@wordpress/components` as the render target.
661
+ *
662
+ * The `Slot` renders a `div` in the parent document, and its forwarded ref
663
+ * is passed to `Popover.Popup`'s `container` prop so the popup portals into
664
+ * the slot element. This mirrors the legacy Popover's `WithSlotOutsideIframe`
665
+ * pattern.
666
+ */
667
+ export const CrossIframeWithSlotFill: Story = {
668
+ name: 'Cross-Iframe (SlotFill)',
669
+ args: { defaultOpen: true },
670
+ argTypes: { defaultOpen: { control: false } },
671
+ render: function Render( { children: _children, ...args } ) {
672
+ const slotRef = useRef< HTMLDivElement >( null );
673
+ const [ iframeBoundary, setIframeBoundary ] =
674
+ useState< HTMLIFrameElement | null >( null );
675
+
676
+ return (
677
+ <SlotFillProvider>
678
+ <Slot
679
+ name="popover-container"
680
+ bubblesVirtually
681
+ ref={ slotRef }
682
+ />
683
+ <GenericIframe
684
+ ref={ setIframeBoundary }
685
+ style={ {
686
+ width: '100%',
687
+ height: 400,
688
+ border: 0,
689
+ outline: '1px solid purple',
690
+ } }
691
+ >
692
+ <div
693
+ style={ {
694
+ height: '200vh',
695
+ paddingTop: '10vh',
696
+ } }
697
+ >
698
+ <div
699
+ style={ {
700
+ maxWidth: 200,
701
+ marginTop: 100,
702
+ marginInline: 'auto',
703
+ } }
704
+ >
705
+ <Popover.Root { ...args }>
706
+ <Popover.Trigger
707
+ style={ {
708
+ padding: 8,
709
+ background: 'salmon',
710
+ } }
711
+ >
712
+ Popover&apos;s anchor (inside iframe)
713
+ </Popover.Trigger>
714
+ <Popover.Popup
715
+ container={
716
+ slotRef as React.RefObject< HTMLElement >
717
+ }
718
+ collisionBoundary={
719
+ iframeBoundary ?? undefined
720
+ }
721
+ >
722
+ <Popover.Arrow />
723
+ <Popover.Title
724
+ style={ {
725
+ marginBottom:
726
+ 'var(--wpds-dimension-gap-xs)',
727
+ } }
728
+ >
729
+ Cross-Iframe (SlotFill)
730
+ </Popover.Title>
731
+ <Popover.Description>
732
+ This popup renders in the parent
733
+ document via a `Slot` from
734
+ `@wordpress/components`.
735
+ </Popover.Description>
736
+ </Popover.Popup>
737
+ </Popover.Root>
738
+ </div>
739
+ </div>
740
+ </GenericIframe>
741
+ </SlotFillProvider>
742
+ );
743
+ },
744
+ };
745
+
746
+ /**
747
+ * Popovers in Gutenberg are managed with explicit z-index values, which can
748
+ * create situations where a popover renders below another popover, when you
749
+ * want it to be rendered above.
750
+ *
751
+ * The `--wp-ui-popover-z-index` CSS variable, available on the
752
+ * `Popover.Popup` component, is an escape hatch that can be used to override
753
+ * the z-index of a given popover on a case-by-case basis.
754
+ */
755
+ export const WithCustomZIndex: Story = {
756
+ name: 'With Custom z-index',
757
+ args: {
758
+ children: (
759
+ <>
760
+ <Popover.Trigger>Open Popover</Popover.Trigger>
761
+ <Popover.Popup style={ { '--wp-ui-popover-z-index': '9999' } }>
762
+ <Popover.Arrow />
763
+ <Popover.Title
764
+ style={ {
765
+ marginBottom: 'var(--wpds-dimension-gap-xs)',
766
+ } }
767
+ >
768
+ Custom z-index
769
+ </Popover.Title>
770
+ <Popover.Description>
771
+ This popover&apos;s positioner has z-index: 9999 via the
772
+ `--wp-ui-popover-z-index` CSS custom property.
773
+ </Popover.Description>
774
+ </Popover.Popup>
775
+ </>
776
+ ),
777
+ },
778
+ };
779
+
780
+ /**
781
+ * Use the `anchor` prop on `Popover.Popup` to position the popover against an
782
+ * arbitrary element instead of the built-in trigger. Base UI accepts four
783
+ * anchor types:
784
+ *
785
+ * 1. **Element** — a direct DOM element reference.
786
+ * 2. **VirtualElement** — an object with a `getBoundingClientRect()` method.
787
+ * 3. **RefObject** — a `React.RefObject` pointing to an element.
788
+ * 4. **Callback** — a function returning an Element or VirtualElement.
789
+ *
790
+ * This is the most-used pattern in Gutenberg: block popovers anchor to
791
+ * selected block elements, the link popover anchors to the text selection, and
792
+ * data views anchor to right-click positions.
793
+ */
794
+ export const Anchor: Story = {
795
+ parameters: { controls: { disable: true } },
796
+ render: function Render() {
797
+ const [ elementAnchor, setElementAnchor ] =
798
+ useState< HTMLElement | null >( null );
799
+ const refAnchor = useRef< HTMLDivElement >( null );
800
+ const virtualAnchorLabel = useRef< HTMLDivElement >( null );
801
+ const callbackTarget = useRef< HTMLDivElement >( null );
802
+
803
+ const virtualAnchor = {
804
+ getBoundingClientRect: () =>
805
+ virtualAnchorLabel.current?.getBoundingClientRect() ??
806
+ new DOMRect(),
807
+ };
808
+
809
+ const anchorBoxStyle = {
810
+ padding: '8px 12px',
811
+ border: '2px dashed currentcolor',
812
+ borderRadius: 4,
813
+ fontSize: 12,
814
+ textAlign: 'center' as const,
815
+ };
816
+
817
+ const popupProps = {
818
+ collisionAvoidance: {
819
+ side: 'none' as const,
820
+ align: 'none' as const,
821
+ },
822
+ };
823
+
824
+ return (
825
+ <div
826
+ style={ {
827
+ display: 'grid',
828
+ gridTemplateColumns: '1fr 1fr',
829
+ gap: '4rem',
830
+ padding: '4rem 2rem',
831
+ } }
832
+ >
833
+ { /* 1. Element anchor */ }
834
+ <div>
835
+ <div ref={ setElementAnchor } style={ anchorBoxStyle }>
836
+ Element anchor
837
+ </div>
838
+ <Popover.Root open>
839
+ <Popover.Popup
840
+ anchor={ elementAnchor ?? undefined }
841
+ { ...popupProps }
842
+ >
843
+ <VisuallyHidden render={ <Popover.Title /> }>
844
+ Element anchor
845
+ </VisuallyHidden>
846
+ <Popover.Arrow />
847
+ <Popover.Description>
848
+ Anchored to a DOM element
849
+ </Popover.Description>
850
+ </Popover.Popup>
851
+ </Popover.Root>
852
+ </div>
853
+
854
+ { /* 2. VirtualElement anchor */ }
855
+ <div>
856
+ <div ref={ virtualAnchorLabel } style={ anchorBoxStyle }>
857
+ VirtualElement anchor
858
+ </div>
859
+ <Popover.Root open>
860
+ <Popover.Popup
861
+ anchor={ virtualAnchor }
862
+ { ...popupProps }
863
+ >
864
+ <VisuallyHidden render={ <Popover.Title /> }>
865
+ Virtual anchor
866
+ </VisuallyHidden>
867
+ <Popover.Arrow />
868
+ <Popover.Description>
869
+ Anchored to a virtual element
870
+ </Popover.Description>
871
+ </Popover.Popup>
872
+ </Popover.Root>
873
+ </div>
874
+
875
+ { /* 3. RefObject anchor */ }
876
+ <div>
877
+ <div ref={ refAnchor } style={ anchorBoxStyle }>
878
+ RefObject anchor
879
+ </div>
880
+ <Popover.Root open>
881
+ <Popover.Popup anchor={ refAnchor } { ...popupProps }>
882
+ <VisuallyHidden render={ <Popover.Title /> }>
883
+ Ref anchor
884
+ </VisuallyHidden>
885
+ <Popover.Arrow />
886
+ <Popover.Description>
887
+ Anchored via useRef
888
+ </Popover.Description>
889
+ </Popover.Popup>
890
+ </Popover.Root>
891
+ </div>
892
+
893
+ { /* 4. Callback anchor */ }
894
+ <div>
895
+ <div ref={ callbackTarget } style={ anchorBoxStyle }>
896
+ Callback anchor
897
+ </div>
898
+ <Popover.Root open>
899
+ <Popover.Popup
900
+ anchor={ () => callbackTarget.current }
901
+ { ...popupProps }
902
+ >
903
+ <VisuallyHidden render={ <Popover.Title /> }>
904
+ Callback anchor
905
+ </VisuallyHidden>
906
+ <Popover.Arrow />
907
+ <Popover.Description>
908
+ Anchored via callback function
909
+ </Popover.Description>
910
+ </Popover.Popup>
911
+ </Popover.Root>
912
+ </div>
913
+ </div>
914
+ );
915
+ },
916
+ };
917
+
918
+ /**
919
+ * Use `variant="unstyled"` and custom inline styles to replicate a toolbar-like
920
+ * appearance: high-contrast border, no shadow, and a smaller border radius.
921
+ *
922
+ * A first-class `variant="toolbar"` may be added in the future if this pattern
923
+ * becomes widespread.
924
+ */
925
+ export const ToolbarVariant: Story = {
926
+ args: {
927
+ children: (
928
+ <>
929
+ <Popover.Trigger>Open Toolbar</Popover.Trigger>
930
+ <Popover.Popup
931
+ variant="unstyled"
932
+ style={ {
933
+ display: 'flex',
934
+ gap: 'var(--wpds-dimension-gap-xs)',
935
+ padding: '4px 8px',
936
+ border: '1px solid #1e1e1e',
937
+ borderRadius: 2,
938
+ background: '#fff',
939
+ fontSize: 13,
940
+ } }
941
+ >
942
+ <VisuallyHidden render={ <Popover.Title /> }>
943
+ Formatting
944
+ </VisuallyHidden>
945
+ <button type="button">B</button>
946
+ <button type="button">I</button>
947
+ <button type="button">U</button>
948
+ <button type="button">Link</button>
949
+ </Popover.Popup>
950
+ </>
951
+ ),
952
+ },
953
+ };
954
+
955
+ /**
956
+ * Base UI's Positioner exposes `--available-height` and
957
+ * `--available-width` CSS variables representing the space
958
+ * between the anchor and the viewport edge. Apply them as `max-height` /
959
+ * `max-width` via the `style` prop (which targets the positioner) to
960
+ * constrain the popup size. Then add `overflow: auto` on an inner wrapper
961
+ * so scrolling happens inside the popup content area — this replaces the
962
+ * legacy Popover's `resize` prop.
963
+ *
964
+ * Open the popover and resize or scroll the container to see the popup shrink
965
+ * to fit.
966
+ */
967
+ export const ViewportConstrainedSize: Story = {
968
+ name: 'Viewport-Constrained Size',
969
+ args: { defaultOpen: true },
970
+ argTypes: { defaultOpen: { control: false } },
971
+ render: function Render( { children: _children, ...args } ) {
972
+ return (
973
+ <div
974
+ style={ {
975
+ height: 250,
976
+ overflow: 'auto',
977
+ border: '1px solid #ccc',
978
+ padding: '60px 2rem',
979
+ } }
980
+ >
981
+ <Popover.Root { ...args }>
982
+ <Popover.Trigger>Show Content</Popover.Trigger>
983
+ <Popover.Popup
984
+ side="bottom"
985
+ style={ {
986
+ maxHeight: 'var(--available-height, 300px)',
987
+ maxWidth: 'var(--available-width, 300px)',
988
+ } }
989
+ >
990
+ <div style={ { overflow: 'auto', height: '100%' } }>
991
+ <Popover.Title
992
+ style={ {
993
+ marginBottom:
994
+ 'var(--wpds-dimension-gap-xs)',
995
+ } }
996
+ >
997
+ Constrained
998
+ </Popover.Title>
999
+ <Popover.Description>
1000
+ This popup constrains its size using the
1001
+ `--available-height` and `--available-width` CSS
1002
+ variables exposed by the positioner.
1003
+ </Popover.Description>
1004
+ <div style={ { height: 400 } }>
1005
+ <p>
1006
+ Scroll inside this popup — its max-height is
1007
+ capped to the available viewport space.
1008
+ </p>
1009
+ </div>
1010
+ </div>
1011
+ </Popover.Popup>
1012
+ </Popover.Root>
1013
+ <div style={ { height: 600 } } />
1014
+ </div>
1015
+ );
1016
+ },
1017
+ };
1018
+
1019
+ /**
1020
+ * The `onOpenChange` callback on `Popover.Root` receives an `eventDetails`
1021
+ * object with a `reason` field that describes why the popover is
1022
+ * opening/closing. This replaces the legacy Popover's separate `onClose` and
1023
+ * `onFocusOutside` callbacks:
1024
+ *
1025
+ * - `reason === 'escape-key'` — user pressed Escape (was `onClose`)
1026
+ * - `reason === 'outside-press'` — user clicked outside (was `onClose`)
1027
+ * - `reason === 'focus-out'` — focus moved outside (was `onFocusOutside`)
1028
+ *
1029
+ * Open the popover, then dismiss it in different ways to see the logged reason.
1030
+ */
1031
+ export const OnOpenChangeDetails: Story = {
1032
+ name: 'onOpenChange Details',
1033
+ parameters: { controls: { disable: true } },
1034
+ render: function Render() {
1035
+ const [ log, setLog ] = useState< string[] >( [] );
1036
+
1037
+ return (
1038
+ <div style={ { display: 'flex', gap: '2rem' } }>
1039
+ <Popover.Root
1040
+ onOpenChange={ ( nextOpen, eventDetails ) => {
1041
+ setLog( ( prev ) => [
1042
+ ...prev.slice( -9 ),
1043
+ `open=${ nextOpen } reason=${ eventDetails.reason }`,
1044
+ ] );
1045
+ } }
1046
+ >
1047
+ <Popover.Trigger>Toggle</Popover.Trigger>
1048
+ <Popover.Popup>
1049
+ <Popover.Arrow />
1050
+ <Popover.Title
1051
+ style={ {
1052
+ marginBottom: 'var(--wpds-dimension-gap-xs)',
1053
+ } }
1054
+ >
1055
+ Event Log
1056
+ </Popover.Title>
1057
+ <Popover.Description>
1058
+ Dismiss this popover via Escape, click-outside, or
1059
+ moving focus away.
1060
+ </Popover.Description>
1061
+ </Popover.Popup>
1062
+ </Popover.Root>
1063
+
1064
+ <pre
1065
+ style={ {
1066
+ flex: 1,
1067
+ padding: 8,
1068
+ fontSize: 12,
1069
+ lineHeight: 1.5,
1070
+ background: '#f5f5f5',
1071
+ borderRadius: 4,
1072
+ minHeight: 100,
1073
+ margin: 0,
1074
+ } }
1075
+ >
1076
+ { log.length
1077
+ ? log.join( '\n' )
1078
+ : 'Interact with the popover to see events…' }
1079
+ </pre>
1080
+ </div>
1081
+ );
1082
+ },
1083
+ };
1084
+
1085
+ /**
1086
+ * Pass a ref to `initialFocus` on `Popover.Popup` to focus a specific element
1087
+ * when the popover opens. This replaces the legacy Popover's `focusOnMount`
1088
+ * prop.
1089
+ *
1090
+ * In this example, the Email field receives focus instead of the first
1091
+ * focusable element (Name).
1092
+ */
1093
+ export const InitialFocus: Story = {
1094
+ parameters: { controls: { disable: true } },
1095
+ render: function Render() {
1096
+ const emailRef = useRef< HTMLInputElement >( null );
1097
+ const nameId = useId();
1098
+ const emailId = useId();
1099
+
1100
+ return (
1101
+ <Popover.Root>
1102
+ <Popover.Trigger>Open Form</Popover.Trigger>
1103
+ <Popover.Popup initialFocus={ emailRef }>
1104
+ <Popover.Arrow />
1105
+ <Popover.Title
1106
+ style={ {
1107
+ marginBottom: 'var(--wpds-dimension-gap-xs)',
1108
+ } }
1109
+ >
1110
+ Contact
1111
+ </Popover.Title>
1112
+ <form
1113
+ style={ {
1114
+ display: 'flex',
1115
+ flexDirection: 'column',
1116
+ gap: 'var(--wpds-dimension-gap-sm)',
1117
+ } }
1118
+ onSubmit={ ( e ) => e.preventDefault() }
1119
+ >
1120
+ <label
1121
+ htmlFor={ nameId }
1122
+ style={ {
1123
+ display: 'flex',
1124
+ flexDirection: 'column',
1125
+ gap: 'var(--wpds-dimension-gap-xs)',
1126
+ fontSize: 'inherit',
1127
+ } }
1128
+ >
1129
+ Name
1130
+ </label>
1131
+ <input
1132
+ id={ nameId }
1133
+ type="text"
1134
+ placeholder="Enter name"
1135
+ />
1136
+ <label
1137
+ htmlFor={ emailId }
1138
+ style={ {
1139
+ display: 'flex',
1140
+ flexDirection: 'column',
1141
+ gap: 'var(--wpds-dimension-gap-xs)',
1142
+ fontSize: 'inherit',
1143
+ } }
1144
+ >
1145
+ Email (auto-focused)
1146
+ </label>
1147
+ <input
1148
+ id={ emailId }
1149
+ ref={ emailRef }
1150
+ type="email"
1151
+ placeholder="Enter email"
1152
+ />
1153
+ </form>
1154
+ </Popover.Popup>
1155
+ </Popover.Root>
1156
+ );
1157
+ },
1158
+ };
1159
+
1160
+ /**
1161
+ * Set `modal="trap-focus"` on `Popover.Root` to trap keyboard focus inside the
1162
+ * popover without making it fully modal. Unlike `modal={true}`, this mode:
1163
+ *
1164
+ * - Traps Tab/Shift+Tab cycling within the popover
1165
+ * - Does **not** lock page scroll
1166
+ * - Does **not** block pointer interaction outside
1167
+ *
1168
+ * A `Popover.Close` part must be rendered inside the popup so that screen
1169
+ * readers can escape. It can be visually hidden if not needed visually.
1170
+ *
1171
+ * This replaces the legacy Popover's `constrainTabbing` prop. Try tabbing
1172
+ * through the fields — focus stays inside — then click the button outside
1173
+ * to verify that pointer interaction still works.
1174
+ */
1175
+ export const TrapFocus: Story = {
1176
+ argTypes: { modal: { control: false } },
1177
+ args: {
1178
+ modal: 'trap-focus' as const,
1179
+ },
1180
+ render: function Render( args ) {
1181
+ return (
1182
+ <div style={ { display: 'flex', gap: '2rem' } }>
1183
+ <Popover.Root { ...args }>
1184
+ <Popover.Trigger>Open</Popover.Trigger>
1185
+ <Popover.Popup>
1186
+ <Popover.Arrow />
1187
+ <Popover.Title
1188
+ style={ {
1189
+ marginBottom: 'var(--wpds-dimension-gap-xs)',
1190
+ } }
1191
+ >
1192
+ Trap Focus
1193
+ </Popover.Title>
1194
+ <Popover.Description>
1195
+ Tab cycles within this popover, but clicking outside
1196
+ still works.
1197
+ </Popover.Description>
1198
+ <div
1199
+ style={ {
1200
+ display: 'flex',
1201
+ gap: 'var(--wpds-dimension-gap-sm)',
1202
+ marginTop: 'var(--wpds-dimension-gap-sm)',
1203
+ } }
1204
+ >
1205
+ <input placeholder="Field A" />
1206
+ <input placeholder="Field B" />
1207
+ <Popover.Close>Close</Popover.Close>
1208
+ </div>
1209
+ </Popover.Popup>
1210
+ </Popover.Root>
1211
+
1212
+ <button
1213
+ type="button"
1214
+ onClick={ () =>
1215
+ // eslint-disable-next-line no-alert
1216
+ window.alert( 'Outside button clicked!' )
1217
+ }
1218
+ >
1219
+ Outside button
1220
+ </button>
1221
+ </div>
1222
+ );
1223
+ },
1224
+ };
1225
+
1226
+ /**
1227
+ * Set `openOnHover` on `Popover.Trigger` to open the popover when the trigger
1228
+ * is hovered. The `delay` and `closeDelay` props control the timing (in ms).
1229
+ *
1230
+ * This is a capability the legacy Popover does not have natively — consumers
1231
+ * would need to wire up `mouseenter`/`mouseleave` handlers manually.
1232
+ */
1233
+ export const HoverTrigger: Story = {
1234
+ parameters: { controls: { disable: true } },
1235
+ render: function Render( args ) {
1236
+ return (
1237
+ <Popover.Root { ...args }>
1238
+ <Popover.Trigger openOnHover delay={ 200 } closeDelay={ 150 }>
1239
+ Hover me
1240
+ </Popover.Trigger>
1241
+ <Popover.Popup>
1242
+ <Popover.Arrow />
1243
+ <Popover.Title
1244
+ style={ {
1245
+ marginBottom: 'var(--wpds-dimension-gap-xs)',
1246
+ } }
1247
+ >
1248
+ Hover Popover
1249
+ </Popover.Title>
1250
+ <Popover.Description>
1251
+ This popover opens on hover with a 200ms delay and
1252
+ closes 150ms after the pointer leaves.
1253
+ </Popover.Description>
1254
+ </Popover.Popup>
1255
+ </Popover.Root>
1256
+ );
1257
+ },
1258
+ };
1259
+
1260
+ /**
1261
+ * Popups that open when hovering an info icon should use Popover with the
1262
+ * `openOnHover` prop on the trigger instead of a tooltip. This way, touch
1263
+ * users and screen reader users can access the content.
1264
+ *
1265
+ * To know when to reach for a popover instead of a tooltip, consider the
1266
+ * purpose of the trigger element: If the trigger's purpose is to open the
1267
+ * popup itself, it's a popover. If the trigger's purpose is unrelated to
1268
+ * opening the popup, it's a tooltip.
1269
+ */
1270
+ export const InfoTip: Story = {
1271
+ parameters: { controls: { disable: true } },
1272
+ render: function Render( args ) {
1273
+ return (
1274
+ <div
1275
+ style={ {
1276
+ display: 'flex',
1277
+ alignItems: 'center',
1278
+ gap: 'var(--wpds-dimension-gap-xs)',
1279
+ } }
1280
+ >
1281
+ <span>Label</span>
1282
+ <Popover.Root { ...args }>
1283
+ <Popover.Trigger
1284
+ openOnHover
1285
+ delay={ 200 }
1286
+ closeDelay={ 200 }
1287
+ aria-label="More information"
1288
+ style={ {
1289
+ background: 'none',
1290
+ border: 'none',
1291
+ padding: 0,
1292
+ cursor: 'var(--wpds-cursor-control)',
1293
+ display: 'inline-flex',
1294
+ alignItems: 'center',
1295
+ borderRadius: 'var(--wpds-border-radius-sm)',
1296
+ } }
1297
+ >
1298
+ <Icon icon={ info } size={ 20 } />
1299
+ </Popover.Trigger>
1300
+ <Popover.Popup>
1301
+ <Popover.Arrow />
1302
+ <VisuallyHidden render={ <Popover.Title /> }>
1303
+ More information
1304
+ </VisuallyHidden>
1305
+ <Popover.Description>
1306
+ This is additional context about the label. Unlike
1307
+ tooltips, this content is accessible to touch and
1308
+ screen reader users.
1309
+ </Popover.Description>
1310
+ </Popover.Popup>
1311
+ </Popover.Root>
1312
+ </div>
1313
+ );
1314
+ },
1315
+ };