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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/CONTRIBUTING.md +25 -0
  3. package/README.md +22 -2
  4. package/build/alert-dialog/context.cjs +6 -1
  5. package/build/alert-dialog/context.cjs.map +2 -2
  6. package/build/alert-dialog/popup.cjs +105 -33
  7. package/build/alert-dialog/popup.cjs.map +4 -4
  8. package/build/alert-dialog/root.cjs +106 -6
  9. package/build/alert-dialog/root.cjs.map +2 -2
  10. package/build/alert-dialog/trigger.cjs +4 -14
  11. package/build/alert-dialog/trigger.cjs.map +3 -3
  12. package/build/alert-dialog/types.cjs.map +1 -1
  13. package/build/button/button.cjs +16 -6
  14. package/build/button/button.cjs.map +3 -3
  15. package/build/card/content.cjs +3 -3
  16. package/build/card/content.cjs.map +1 -1
  17. package/build/card/full-bleed.cjs +3 -3
  18. package/build/card/full-bleed.cjs.map +1 -1
  19. package/build/card/header.cjs +3 -3
  20. package/build/card/header.cjs.map +1 -1
  21. package/build/card/root.cjs +3 -3
  22. package/build/card/root.cjs.map +1 -1
  23. package/build/card/title.cjs +3 -3
  24. package/build/card/title.cjs.map +1 -1
  25. package/build/collapsible-card/header.cjs +3 -3
  26. package/build/collapsible-card/header.cjs.map +2 -2
  27. package/build/empty-state/title.cjs.map +2 -2
  28. package/build/form/primitives/field/description.cjs +17 -4
  29. package/build/form/primitives/field/description.cjs.map +3 -3
  30. package/build/form/primitives/field/details.cjs +3 -3
  31. package/build/form/primitives/field/details.cjs.map +2 -2
  32. package/build/form/primitives/field/label.cjs +3 -3
  33. package/build/form/primitives/field/label.cjs.map +2 -2
  34. package/build/form/primitives/fieldset/description.cjs +20 -4
  35. package/build/form/primitives/fieldset/description.cjs.map +3 -3
  36. package/build/form/primitives/fieldset/details.cjs +3 -3
  37. package/build/form/primitives/fieldset/details.cjs.map +2 -2
  38. package/build/form/primitives/fieldset/legend.cjs +3 -3
  39. package/build/form/primitives/fieldset/legend.cjs.map +2 -2
  40. package/build/form/primitives/input/input.cjs +23 -7
  41. package/build/form/primitives/input/input.cjs.map +3 -3
  42. package/build/form/primitives/input-layout/input-layout.cjs +10 -0
  43. package/build/form/primitives/input-layout/input-layout.cjs.map +3 -3
  44. package/build/form/primitives/select/trigger.cjs +3 -3
  45. package/build/form/primitives/select/trigger.cjs.map +2 -2
  46. package/build/form/primitives/textarea/textarea.cjs +20 -1
  47. package/build/form/primitives/textarea/textarea.cjs.map +3 -3
  48. package/build/index.cjs +3 -0
  49. package/build/index.cjs.map +2 -2
  50. package/build/link/link.cjs +16 -6
  51. package/build/link/link.cjs.map +3 -3
  52. package/build/popover/arrow.cjs +94 -0
  53. package/build/popover/arrow.cjs.map +7 -0
  54. package/build/popover/close.cjs +45 -0
  55. package/build/popover/close.cjs.map +7 -0
  56. package/build/popover/context.cjs +76 -0
  57. package/build/popover/context.cjs.map +7 -0
  58. package/build/popover/description.cjs +70 -0
  59. package/build/popover/description.cjs.map +7 -0
  60. package/build/popover/index.cjs +49 -0
  61. package/build/popover/index.cjs.map +7 -0
  62. package/build/popover/popup.cjs +138 -0
  63. package/build/popover/popup.cjs.map +7 -0
  64. package/build/popover/root.cjs +35 -0
  65. package/build/popover/root.cjs.map +7 -0
  66. package/build/popover/title.cjs +56 -0
  67. package/build/popover/title.cjs.map +7 -0
  68. package/build/popover/trigger.cjs +38 -0
  69. package/build/popover/trigger.cjs.map +7 -0
  70. package/build/popover/types.cjs +19 -0
  71. package/build/popover/types.cjs.map +7 -0
  72. package/build/text/text.cjs +20 -5
  73. package/build/text/text.cjs.map +3 -3
  74. package/build/utils/use-deprioritized-initial-focus.cjs.map +2 -2
  75. package/build-module/alert-dialog/context.mjs +6 -1
  76. package/build-module/alert-dialog/context.mjs.map +2 -2
  77. package/build-module/alert-dialog/popup.mjs +107 -33
  78. package/build-module/alert-dialog/popup.mjs.map +4 -4
  79. package/build-module/alert-dialog/root.mjs +113 -7
  80. package/build-module/alert-dialog/root.mjs.map +2 -2
  81. package/build-module/alert-dialog/trigger.mjs +4 -4
  82. package/build-module/alert-dialog/trigger.mjs.map +3 -3
  83. package/build-module/button/button.mjs +16 -6
  84. package/build-module/button/button.mjs.map +3 -3
  85. package/build-module/card/content.mjs +3 -3
  86. package/build-module/card/content.mjs.map +1 -1
  87. package/build-module/card/full-bleed.mjs +3 -3
  88. package/build-module/card/full-bleed.mjs.map +1 -1
  89. package/build-module/card/header.mjs +3 -3
  90. package/build-module/card/header.mjs.map +1 -1
  91. package/build-module/card/root.mjs +3 -3
  92. package/build-module/card/root.mjs.map +1 -1
  93. package/build-module/card/title.mjs +3 -3
  94. package/build-module/card/title.mjs.map +1 -1
  95. package/build-module/collapsible-card/header.mjs +3 -3
  96. package/build-module/collapsible-card/header.mjs.map +2 -2
  97. package/build-module/empty-state/title.mjs.map +2 -2
  98. package/build-module/form/primitives/field/description.mjs +17 -4
  99. package/build-module/form/primitives/field/description.mjs.map +3 -3
  100. package/build-module/form/primitives/field/details.mjs +3 -3
  101. package/build-module/form/primitives/field/details.mjs.map +2 -2
  102. package/build-module/form/primitives/field/label.mjs +3 -3
  103. package/build-module/form/primitives/field/label.mjs.map +2 -2
  104. package/build-module/form/primitives/fieldset/description.mjs +20 -4
  105. package/build-module/form/primitives/fieldset/description.mjs.map +3 -3
  106. package/build-module/form/primitives/fieldset/details.mjs +3 -3
  107. package/build-module/form/primitives/fieldset/details.mjs.map +2 -2
  108. package/build-module/form/primitives/fieldset/legend.mjs +3 -3
  109. package/build-module/form/primitives/fieldset/legend.mjs.map +2 -2
  110. package/build-module/form/primitives/input/input.mjs +23 -7
  111. package/build-module/form/primitives/input/input.mjs.map +3 -3
  112. package/build-module/form/primitives/input-layout/input-layout.mjs +10 -0
  113. package/build-module/form/primitives/input-layout/input-layout.mjs.map +3 -3
  114. package/build-module/form/primitives/select/trigger.mjs +3 -3
  115. package/build-module/form/primitives/select/trigger.mjs.map +2 -2
  116. package/build-module/form/primitives/textarea/textarea.mjs +20 -1
  117. package/build-module/form/primitives/textarea/textarea.mjs.map +3 -3
  118. package/build-module/index.mjs +2 -0
  119. package/build-module/index.mjs.map +2 -2
  120. package/build-module/link/link.mjs +16 -6
  121. package/build-module/link/link.mjs.map +3 -3
  122. package/build-module/popover/arrow.mjs +59 -0
  123. package/build-module/popover/arrow.mjs.map +7 -0
  124. package/build-module/popover/close.mjs +20 -0
  125. package/build-module/popover/close.mjs.map +7 -0
  126. package/build-module/popover/context.mjs +57 -0
  127. package/build-module/popover/context.mjs.map +7 -0
  128. package/build-module/popover/description.mjs +35 -0
  129. package/build-module/popover/description.mjs.map +7 -0
  130. package/build-module/popover/index.mjs +18 -0
  131. package/build-module/popover/index.mjs.map +7 -0
  132. package/build-module/popover/popup.mjs +105 -0
  133. package/build-module/popover/popup.mjs.map +7 -0
  134. package/build-module/popover/root.mjs +10 -0
  135. package/build-module/popover/root.mjs.map +7 -0
  136. package/build-module/popover/title.mjs +31 -0
  137. package/build-module/popover/title.mjs.map +7 -0
  138. package/build-module/popover/trigger.mjs +13 -0
  139. package/build-module/popover/trigger.mjs.map +7 -0
  140. package/build-module/popover/types.mjs +1 -0
  141. package/build-module/popover/types.mjs.map +7 -0
  142. package/build-module/text/text.mjs +20 -5
  143. package/build-module/text/text.mjs.map +3 -3
  144. package/build-module/utils/use-deprioritized-initial-focus.mjs.map +2 -2
  145. package/build-types/alert-dialog/context.d.ts +6 -3
  146. package/build-types/alert-dialog/context.d.ts.map +1 -1
  147. package/build-types/alert-dialog/popup.d.ts.map +1 -1
  148. package/build-types/alert-dialog/root.d.ts +2 -8
  149. package/build-types/alert-dialog/root.d.ts.map +1 -1
  150. package/build-types/alert-dialog/stories/index.story.d.ts +18 -6
  151. package/build-types/alert-dialog/stories/index.story.d.ts.map +1 -1
  152. package/build-types/alert-dialog/trigger.d.ts +2 -1
  153. package/build-types/alert-dialog/trigger.d.ts.map +1 -1
  154. package/build-types/alert-dialog/types.d.ts +57 -26
  155. package/build-types/alert-dialog/types.d.ts.map +1 -1
  156. package/build-types/button/button.d.ts.map +1 -1
  157. package/build-types/card/stories/index.story.d.ts.map +1 -1
  158. package/build-types/empty-state/title.d.ts.map +1 -1
  159. package/build-types/form/primitives/field/description.d.ts.map +1 -1
  160. package/build-types/form/primitives/fieldset/description.d.ts.map +1 -1
  161. package/build-types/form/primitives/input/input.d.ts.map +1 -1
  162. package/build-types/form/primitives/input-layout/input-layout.d.ts.map +1 -1
  163. package/build-types/form/primitives/textarea/textarea.d.ts.map +1 -1
  164. package/build-types/form/stories/shared.d.ts.map +1 -1
  165. package/build-types/index.d.ts +1 -0
  166. package/build-types/index.d.ts.map +1 -1
  167. package/build-types/link/link.d.ts.map +1 -1
  168. package/build-types/popover/arrow.d.ts +10 -0
  169. package/build-types/popover/arrow.d.ts.map +1 -0
  170. package/build-types/popover/close.d.ts +11 -0
  171. package/build-types/popover/close.d.ts.map +1 -0
  172. package/build-types/popover/context.d.ts +22 -0
  173. package/build-types/popover/context.d.ts.map +1 -0
  174. package/build-types/popover/description.d.ts +10 -0
  175. package/build-types/popover/description.d.ts.map +1 -0
  176. package/build-types/popover/index.d.ts +9 -0
  177. package/build-types/popover/index.d.ts.map +1 -0
  178. package/build-types/popover/popup.d.ts +11 -0
  179. package/build-types/popover/popup.d.ts.map +1 -0
  180. package/build-types/popover/root.d.ts +37 -0
  181. package/build-types/popover/root.d.ts.map +1 -0
  182. package/build-types/popover/stories/index.story.d.ts +211 -0
  183. package/build-types/popover/stories/index.story.d.ts.map +1 -0
  184. package/build-types/popover/stories/utils.d.ts +25 -0
  185. package/build-types/popover/stories/utils.d.ts.map +1 -0
  186. package/build-types/popover/test/index.test.d.ts +2 -0
  187. package/build-types/popover/test/index.test.d.ts.map +1 -0
  188. package/build-types/popover/title.d.ts +20 -0
  189. package/build-types/popover/title.d.ts.map +1 -0
  190. package/build-types/popover/trigger.d.ts +10 -0
  191. package/build-types/popover/trigger.d.ts.map +1 -0
  192. package/build-types/popover/types.d.ts +83 -0
  193. package/build-types/popover/types.d.ts.map +1 -0
  194. package/build-types/text/stories/index.story.d.ts +4 -0
  195. package/build-types/text/stories/index.story.d.ts.map +1 -1
  196. package/build-types/text/text.d.ts.map +1 -1
  197. package/build-types/utils/use-deprioritized-initial-focus.d.ts +6 -5
  198. package/build-types/utils/use-deprioritized-initial-focus.d.ts.map +1 -1
  199. package/package.json +11 -11
  200. package/src/alert-dialog/context.tsx +12 -4
  201. package/src/alert-dialog/popup.tsx +91 -33
  202. package/src/alert-dialog/root.tsx +191 -13
  203. package/src/alert-dialog/stories/index.story.tsx +116 -65
  204. package/src/alert-dialog/style.module.css +11 -0
  205. package/src/alert-dialog/test/index.test.tsx +1265 -347
  206. package/src/alert-dialog/trigger.tsx +2 -2
  207. package/src/alert-dialog/types.ts +59 -28
  208. package/src/button/button.tsx +2 -0
  209. package/src/button/style.module.css +4 -0
  210. package/src/card/stories/index.story.tsx +0 -1
  211. package/src/card/style.module.css +1 -1
  212. package/src/card/test/index.test.tsx +0 -1
  213. package/src/empty-state/title.tsx +0 -1
  214. package/src/form/primitives/field/description.tsx +6 -1
  215. package/src/form/primitives/fieldset/description.tsx +9 -1
  216. package/src/form/primitives/input/input.tsx +6 -1
  217. package/src/form/primitives/input/style.module.css +4 -0
  218. package/src/form/primitives/input-layout/input-layout.tsx +2 -0
  219. package/src/form/primitives/textarea/textarea.tsx +10 -1
  220. package/src/form/stories/shared.tsx +4 -2
  221. package/src/index.ts +1 -0
  222. package/src/link/link.tsx +2 -0
  223. package/src/link/style.module.css +10 -0
  224. package/src/popover/arrow.tsx +49 -0
  225. package/src/popover/close.tsx +24 -0
  226. package/src/popover/context.tsx +100 -0
  227. package/src/popover/description.tsx +34 -0
  228. package/src/popover/index.ts +9 -0
  229. package/src/popover/popup.tsx +106 -0
  230. package/src/popover/root.tsx +41 -0
  231. package/src/popover/stories/index.story.tsx +1315 -0
  232. package/src/popover/stories/utils.tsx +91 -0
  233. package/src/popover/style.module.css +64 -0
  234. package/src/popover/test/index.test.tsx +727 -0
  235. package/src/popover/title.tsx +50 -0
  236. package/src/popover/trigger.tsx +17 -0
  237. package/src/popover/types.ts +113 -0
  238. package/src/text/stories/index.story.tsx +4 -2
  239. package/src/text/style.module.css +26 -0
  240. package/src/text/test/index.test.tsx +1 -4
  241. package/src/text/text.tsx +8 -1
  242. package/src/utils/css/field.module.css +4 -1
  243. package/src/utils/css/focus.module.css +7 -5
  244. package/src/utils/css/global-css-defense.module.css +117 -0
  245. package/src/utils/use-deprioritized-initial-focus.ts +5 -4
@@ -1,56 +1,114 @@
1
+ import { AlertDialog as _AlertDialog } from '@base-ui/react/alert-dialog';
2
+ import clsx from 'clsx';
1
3
  import { forwardRef, useContext } from '@wordpress/element';
2
4
  import { __ } from '@wordpress/i18n';
5
+ import {
6
+ type ThemeProvider as ThemeProviderType,
7
+ privateApis as themePrivateApis,
8
+ } from '@wordpress/theme';
9
+
3
10
  import { Button } from '../button';
4
- import * as Dialog from '../dialog';
11
+ import dialogStyles from '../dialog/style.module.css';
12
+ import { unlock } from '../lock-unlock';
13
+ import { Stack } from '../stack';
14
+ import { Text } from '../text';
5
15
  import { AlertDialogContext } from './context';
6
- import styles from './style.module.css';
16
+ import alertDialogStyles from './style.module.css';
7
17
  import type { PopupProps } from './types';
8
18
 
19
+ const ThemeProvider: typeof ThemeProviderType =
20
+ unlock( themePrivateApis ).ThemeProvider;
21
+
9
22
  const Popup = forwardRef< HTMLDivElement, PopupProps >(
10
23
  function AlertDialogPopup(
11
24
  {
25
+ className,
26
+ intent = 'default',
12
27
  title,
28
+ description,
13
29
  children,
14
- onConfirm,
15
30
  confirmButtonText = __( 'OK' ),
16
31
  cancelButtonText = __( 'Cancel' ),
17
- loading,
32
+ ...props
18
33
  },
19
34
  ref
20
35
  ) {
21
- const { intent } = useContext( AlertDialogContext );
36
+ const { phase, showSpinner, errorMessage, confirm } =
37
+ useContext( AlertDialogContext );
22
38
 
23
- // When `loading` is provided, the consumer controls when the dialog
24
- // closes (async flow). Use a plain Button so clicking confirm doesn't
25
- // auto-close — the consumer sets `open={false}` after their operation.
26
- const ConfirmButton = loading !== undefined ? Button : Dialog.Action;
39
+ const confirmClassName =
40
+ intent === 'irreversible'
41
+ ? alertDialogStyles[ 'irreversible-action' ]
42
+ : undefined;
43
+
44
+ const buttonsDisabled = phase !== 'idle' || undefined;
27
45
 
28
46
  return (
29
- <Dialog.Popup ref={ ref }>
30
- <Dialog.Header>
31
- <Dialog.Title>{ title }</Dialog.Title>
32
- </Dialog.Header>
33
- { children }
34
- <Dialog.Footer>
35
- <Dialog.Action
36
- variant="minimal"
37
- disabled={ loading || undefined }
38
- >
39
- { cancelButtonText }
40
- </Dialog.Action>
41
- <ConfirmButton
42
- className={
43
- intent === 'irreversible'
44
- ? styles[ 'irreversible-action' ]
45
- : undefined
46
- }
47
- onClick={ onConfirm }
48
- loading={ loading }
47
+ <_AlertDialog.Portal>
48
+ <_AlertDialog.Backdrop className={ dialogStyles.backdrop } />
49
+ <ThemeProvider>
50
+ <_AlertDialog.Popup
51
+ ref={ ref }
52
+ className={ clsx(
53
+ dialogStyles.popup,
54
+ className,
55
+ dialogStyles[ 'is-medium' ]
56
+ ) }
57
+ { ...props }
49
58
  >
50
- { confirmButtonText }
51
- </ConfirmButton>
52
- </Dialog.Footer>
53
- </Dialog.Popup>
59
+ <Stack
60
+ direction="column"
61
+ gap="sm"
62
+ className={ alertDialogStyles.header }
63
+ >
64
+ <Text
65
+ variant="heading-xl"
66
+ render={ <_AlertDialog.Title /> }
67
+ className={ dialogStyles.title }
68
+ >
69
+ { title }
70
+ </Text>
71
+ { description && (
72
+ <Text
73
+ variant="body-md"
74
+ render={ <_AlertDialog.Description /> }
75
+ >
76
+ { description }
77
+ </Text>
78
+ ) }
79
+ </Stack>
80
+ { children }
81
+ <Stack direction="column" gap="md">
82
+ <div className={ dialogStyles.footer }>
83
+ <_AlertDialog.Close
84
+ render={ <Button variant="minimal" /> }
85
+ disabled={ buttonsDisabled }
86
+ >
87
+ { cancelButtonText }
88
+ </_AlertDialog.Close>
89
+ <Button
90
+ className={ confirmClassName }
91
+ onClick={ confirm }
92
+ loading={ showSpinner || undefined }
93
+ disabled={ buttonsDisabled }
94
+ >
95
+ { confirmButtonText }
96
+ </Button>
97
+ </div>
98
+ { errorMessage && (
99
+ <Text
100
+ variant="body-sm"
101
+ className={
102
+ alertDialogStyles[ 'error-message' ]
103
+ }
104
+ >
105
+ { errorMessage }
106
+ </Text>
107
+ ) }
108
+ </Stack>
109
+ </_AlertDialog.Popup>
110
+ </ThemeProvider>
111
+ </_AlertDialog.Portal>
54
112
  );
55
113
  }
56
114
  );
@@ -1,8 +1,25 @@
1
1
  import { AlertDialog as _AlertDialog } from '@base-ui/react/alert-dialog';
2
- import { useMemo } from '@wordpress/element';
2
+ import { speak } from '@wordpress/a11y';
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from '@wordpress/element';
10
+
3
11
  import { AlertDialogContext } from './context';
12
+ import type { Phase } from './context';
4
13
  import type { RootProps } from './types';
5
14
 
15
+ function isThenable( value: unknown ): value is PromiseLike< unknown > {
16
+ return (
17
+ value !== null &&
18
+ value !== undefined &&
19
+ typeof ( value as PromiseLike< unknown > ).then === 'function'
20
+ );
21
+ }
22
+
6
23
  /**
7
24
  * A dialog that requires a user response to proceed.
8
25
  *
@@ -11,32 +28,193 @@ import type { RootProps } from './types';
11
28
  * The `AlertDialog.Trigger` is optional — the dialog can also be controlled
12
29
  * via `open` / `onOpenChange` props.
13
30
  *
14
- * ## Use cases
15
- *
16
- * - **Default intent**: Standard confirmation dialog for reversible actions.
17
- * - **Irreversible intent**: Confirmation dialog for irreversible actions that
18
- * cannot be undone. The confirm button uses error/danger coloring.
19
- *
20
31
  * For use cases outside the standard confirm/cancel pattern, use the lower-level
21
32
  * `Dialog` component directly.
22
33
  *
23
- * See the [Destructive Actions guidelines](?path=/docs/design-system-patterns-destructive-actions--docs)
34
+ * See the [Destructive Actions guidelines](https://wordpress.github.io/gutenberg/?path=/docs/design-system-patterns-destructive-actions--docs)
24
35
  * for more details on when to use each pattern.
25
36
  */
26
37
  function Root( {
27
- intent = 'default',
28
38
  children,
29
- open,
39
+ open: openProp,
30
40
  onOpenChange,
31
41
  defaultOpen,
42
+ onConfirm,
32
43
  }: RootProps ) {
33
- const contextValue = useMemo( () => ( { intent } ), [ intent ] );
44
+ const [ internalOpen, setInternalOpen ] = useState( defaultOpen ?? false );
45
+
46
+ // Internal state machine for the confirm-and-close lifecycle.
47
+ //
48
+ // Phase transitions:
49
+ //
50
+ // idle ──> pending ──> closing ──> idle
51
+ // (confirm (success, (animation
52
+ // clicked) close) complete)
53
+ //
54
+ // idle ──> pending ──> idle
55
+ // (confirm (error, or
56
+ // clicked) {close:false})
57
+ //
58
+ // idle ──> closing ──> idle
59
+ // (cancel/ (animation
60
+ // escape) complete)
61
+ //
62
+ // `showSpinner` tracks whether the confirm button shows a loading
63
+ // indicator. It is orthogonal to `phase`:
64
+ //
65
+ // Scenario | pending | closing
66
+ // --------------------------+---------+---------
67
+ // Sync onConfirm | false | false
68
+ // Async onConfirm (success) | true | true
69
+ // Async onConfirm (error) | true | n/a (-> idle)
70
+ // Cancel / Escape | n/a | false
71
+ //
72
+ // Buttons are disabled whenever phase !== 'idle'.
73
+ // Dismiss (Escape / Cancel) is blocked during 'pending'.
74
+ const [ phase, setPhase ] = useState< Phase >( 'idle' );
75
+ const [ showSpinner, setShowSpinner ] = useState( false );
76
+ const [ errorMessage, setErrorMessage ] = useState< string >();
77
+
78
+ const actionsRef = useRef< _AlertDialog.Root.Actions | null >( null );
79
+
80
+ const onConfirmRef = useRef( onConfirm );
81
+ onConfirmRef.current = onConfirm;
82
+
83
+ // Ref keeps phase accessible synchronously from callbacks that may
84
+ // run between a setState call and the subsequent React re-render.
85
+ const phaseRef = useRef( phase );
86
+ phaseRef.current = phase;
87
+
88
+ // Generation counter — safety net for the edge case where the component
89
+ // unmounts while an async confirm is in flight. Also incremented when
90
+ // the dialog finishes closing, so a stale promise settling after a
91
+ // dismiss+reopen cycle is silently discarded.
92
+ const confirmIdRef = useRef( 0 );
93
+
94
+ const effectiveOpen = openProp ?? internalOpen;
95
+
96
+ // Safety net: if the consumer keeps `open={true}` after a confirm
97
+ // (i.e. does not react to `onOpenChange`), the phase would be stuck
98
+ // at 'closing'. Detect the contradiction and reset to idle.
99
+ useEffect( () => {
100
+ if ( effectiveOpen && phase === 'closing' ) {
101
+ phaseRef.current = 'idle';
102
+ setPhase( 'idle' );
103
+ setShowSpinner( false );
104
+ }
105
+ }, [ effectiveOpen, phase ] );
106
+
107
+ const handleOpenChange = useCallback(
108
+ (
109
+ nextOpen: boolean,
110
+ eventDetails: _AlertDialog.Root.ChangeEventDetails
111
+ ) => {
112
+ // Block dismiss while a confirm action is pending.
113
+ if ( ! nextOpen && phaseRef.current === 'pending' ) {
114
+ return;
115
+ }
116
+
117
+ if ( ! nextOpen && phaseRef.current === 'idle' ) {
118
+ phaseRef.current = 'closing';
119
+ setPhase( 'closing' );
120
+ }
121
+
122
+ setInternalOpen( nextOpen );
123
+ onOpenChange?.( nextOpen, eventDetails );
124
+ },
125
+ [ onOpenChange ]
126
+ );
127
+
128
+ const confirm = useCallback( async () => {
129
+ if ( phaseRef.current !== 'idle' ) {
130
+ return;
131
+ }
132
+
133
+ phaseRef.current = 'pending';
134
+ setPhase( 'pending' );
135
+ setErrorMessage( undefined );
136
+
137
+ const id = ++confirmIdRef.current;
138
+
139
+ try {
140
+ const rawResult = onConfirmRef.current?.();
141
+
142
+ // Show spinner only for async handlers (Promises).
143
+ // Sync handlers resolve in the same tick — no spinner needed.
144
+ if ( isThenable( rawResult ) ) {
145
+ setShowSpinner( true );
146
+ }
147
+
148
+ const result = await Promise.resolve( rawResult );
149
+
150
+ // Discard if the component unmounted or the dialog was
151
+ // dismissed and reopened while the promise was in flight.
152
+ if ( confirmIdRef.current !== id ) {
153
+ return;
154
+ }
155
+
156
+ // An error message implies the dialog should stay open.
157
+ if ( result?.error ) {
158
+ phaseRef.current = 'idle';
159
+ setPhase( 'idle' );
160
+ setShowSpinner( false );
161
+ setErrorMessage( result.error );
162
+ speak( result.error, 'assertive' );
163
+ return;
164
+ }
165
+
166
+ const shouldClose = result?.close !== false;
167
+
168
+ if ( shouldClose ) {
169
+ phaseRef.current = 'closing';
170
+ setPhase( 'closing' );
171
+ actionsRef.current?.close();
172
+ } else {
173
+ phaseRef.current = 'idle';
174
+ setPhase( 'idle' );
175
+ setShowSpinner( false );
176
+ }
177
+ } catch ( error ) {
178
+ if ( confirmIdRef.current !== id ) {
179
+ return;
180
+ }
181
+ phaseRef.current = 'idle';
182
+ setPhase( 'idle' );
183
+ setShowSpinner( false );
184
+ // eslint-disable-next-line no-console
185
+ console.error( error );
186
+ }
187
+ }, [] );
188
+
189
+ const handleOpenChangeComplete = useCallback( ( open: boolean ) => {
190
+ if ( ! open ) {
191
+ // Invalidate any in-flight async so a stale promise settling
192
+ // after dismiss+reopen doesn't close the new session.
193
+ confirmIdRef.current++;
194
+ phaseRef.current = 'idle';
195
+ setPhase( 'idle' );
196
+ setShowSpinner( false );
197
+ setErrorMessage( undefined );
198
+ }
199
+ }, [] );
200
+
201
+ const contextValue = useMemo(
202
+ () => ( {
203
+ phase,
204
+ showSpinner,
205
+ errorMessage,
206
+ confirm,
207
+ } ),
208
+ [ phase, showSpinner, errorMessage, confirm ]
209
+ );
34
210
 
35
211
  return (
36
212
  <_AlertDialog.Root
37
- open={ open }
38
- onOpenChange={ onOpenChange }
213
+ open={ effectiveOpen }
39
214
  defaultOpen={ defaultOpen }
215
+ onOpenChange={ handleOpenChange }
216
+ onOpenChangeComplete={ handleOpenChangeComplete }
217
+ actionsRef={ actionsRef }
40
218
  >
41
219
  <AlertDialogContext.Provider value={ contextValue }>
42
220
  { children }
@@ -1,10 +1,10 @@
1
1
  import { Menu } from '@base-ui/react/menu';
2
- import { useState } from '@wordpress/element';
2
+ import { useId, useState } from '@wordpress/element';
3
3
  import type { Meta, StoryObj } from '@storybook/react-vite';
4
4
  import { action } from 'storybook/actions';
5
5
  import { fn } from 'storybook/test';
6
6
 
7
- import { AlertDialog } from '../..';
7
+ import { AlertDialog, Text } from '../..';
8
8
 
9
9
  const meta: Meta< typeof AlertDialog.Root > = {
10
10
  title: 'Design System/Components/AlertDialog',
@@ -14,6 +14,7 @@ const meta: Meta< typeof AlertDialog.Root > = {
14
14
  'AlertDialog.Popup': AlertDialog.Popup,
15
15
  },
16
16
  argTypes: {
17
+ onConfirm: { action: fn() },
17
18
  onOpenChange: { action: fn() },
18
19
  },
19
20
  };
@@ -33,10 +34,8 @@ export const Default: Story = {
33
34
  <AlertDialog.Trigger>Move to trash</AlertDialog.Trigger>
34
35
  <AlertDialog.Popup
35
36
  title="Move to trash?"
36
- onConfirm={ action( 'onConfirm' ) }
37
- >
38
- This post will be moved to trash. You can restore it later.
39
- </AlertDialog.Popup>
37
+ description="This post will be moved to trash. You can restore it later."
38
+ />
40
39
  </>
41
40
  ),
42
41
  },
@@ -48,38 +47,64 @@ export const Default: Story = {
48
47
  */
49
48
  export const Irreversible: Story = {
50
49
  args: {
51
- intent: 'irreversible',
52
50
  children: (
53
51
  <>
54
52
  <AlertDialog.Trigger>Delete permanently</AlertDialog.Trigger>
55
53
  <AlertDialog.Popup
54
+ intent="irreversible"
56
55
  title="Delete permanently?"
57
- onConfirm={ action( 'onConfirm' ) }
56
+ description="This action cannot be undone. All data will be lost."
58
57
  confirmButtonText="Delete permanently"
59
- >
60
- This action cannot be undone. All data will be lost.
61
- </AlertDialog.Popup>
58
+ />
62
59
  </>
63
60
  ),
64
61
  },
65
62
  };
66
63
 
67
64
  /**
68
- * Example with custom button text for both confirm and cancel buttons.
65
+ * Example with custom button labels for both confirm and cancel buttons.
69
66
  */
70
- export const CustomButtonText: Story = {
67
+ export const CustomLabels: Story = {
71
68
  args: {
72
69
  children: (
73
70
  <>
74
71
  <AlertDialog.Trigger>Send feedback</AlertDialog.Trigger>
75
72
  <AlertDialog.Popup
76
73
  title="Send feedback?"
77
- onConfirm={ action( 'onConfirm' ) }
74
+ description="Your feedback helps us improve. Would you like to send it now?"
78
75
  confirmButtonText="Send feedback"
79
76
  cancelButtonText="Not now"
77
+ />
78
+ </>
79
+ ),
80
+ },
81
+ };
82
+
83
+ /**
84
+ * Use `children` to render custom content between the description and the
85
+ * action buttons. The `description` should be self-contained for
86
+ * accessibility (`aria-describedby`); `children` adds supplementary detail.
87
+ */
88
+ export const WithCustomContent: Story = {
89
+ args: {
90
+ children: (
91
+ <>
92
+ <AlertDialog.Trigger>Remove pages</AlertDialog.Trigger>
93
+ <AlertDialog.Popup
94
+ title="Remove 3 pages?"
95
+ description="These pages will be moved to trash."
96
+ confirmButtonText="Delete pages"
80
97
  >
81
- Your feedback helps us improve. Would you like to send it
82
- now?
98
+ <ul
99
+ style={ {
100
+ margin: 0,
101
+ paddingInlineStart: 'var(--wpds-dimension-gap-lg)',
102
+ } }
103
+ >
104
+ <Text render={ <li /> }>About us</Text>
105
+ <Text render={ <li /> }>Contact</Text>
106
+ <Text render={ <li /> }>Privacy policy</Text>
107
+ </ul>
83
108
  </AlertDialog.Popup>
84
109
  </>
85
110
  ),
@@ -117,10 +142,7 @@ const menuItemStyles: React.CSSProperties = {
117
142
  * component (not ready yet).
118
143
  */
119
144
  export const MenuTrigger: Story = {
120
- args: {
121
- intent: 'irreversible',
122
- },
123
- render: ( args ) => {
145
+ render: () => {
124
146
  const [ menuOpen, setMenuOpen ] = useState( false );
125
147
  return (
126
148
  <>
@@ -132,7 +154,12 @@ export const MenuTrigger: Story = {
132
154
  <Menu.Item style={ menuItemStyles }>
133
155
  Edit
134
156
  </Menu.Item>
135
- <AlertDialog.Root { ...args }>
157
+ <AlertDialog.Root
158
+ onConfirm={ () => {
159
+ setMenuOpen( false );
160
+ action( 'onConfirm' )();
161
+ } }
162
+ >
136
163
  <Menu.Item
137
164
  render={
138
165
  <AlertDialog.Trigger
@@ -147,16 +174,11 @@ export const MenuTrigger: Story = {
147
174
  >
148
175
  Delete...
149
176
  <AlertDialog.Popup
177
+ intent="irreversible"
150
178
  title="Delete permanently?"
151
- onConfirm={ () => {
152
- setMenuOpen( false );
153
- action( 'onConfirm' )();
154
- } }
179
+ description="This action cannot be undone. All data will be lost."
155
180
  confirmButtonText="Delete permanently"
156
- >
157
- This action cannot be undone. All
158
- data will be lost.
159
- </AlertDialog.Popup>
181
+ />
160
182
  </Menu.Item>
161
183
  </AlertDialog.Root>
162
184
  </Menu.Popup>
@@ -168,56 +190,88 @@ export const MenuTrigger: Story = {
168
190
  },
169
191
  };
170
192
 
193
+ function sleep( ms: number ) {
194
+ return new Promise< void >( ( resolve ) => setTimeout( resolve, ms ) );
195
+ }
196
+
171
197
  /**
172
- * Consumer-driven async confirm flow. The consumer uses controlled mode to
173
- * keep the dialog open while the async operation is in progress, and passes
174
- * `loading` to show a spinner on the confirm button and disable the cancel
175
- * button.
198
+ * Async confirm flow. The consumer returns a promise from `onConfirm`.
199
+ * The dialog automatically manages the pending state: buttons are disabled
200
+ * and a spinner appears on the confirm button. Toggle between success and
201
+ * failure to test both outcomes.
202
+ *
203
+ * On failure, the consumer catches the error and returns
204
+ * `{ close: false, error: '...' }`. The component displays the message
205
+ * below the action buttons and announces it to screen readers. The error
206
+ * is automatically cleared on the next confirm attempt or when the dialog
207
+ * reopens.
176
208
  */
177
209
  export const AsyncConfirm: Story = {
178
210
  render: function AsyncConfirm( args ) {
179
- const [ isOpen, setIsOpen ] = useState( false );
180
- const [ isLoading, setIsLoading ] = useState( false );
211
+ const [ shouldFail, setShouldFail ] = useState( false );
212
+ const successId = useId();
213
+ const failureId = useId();
181
214
 
182
215
  return (
183
216
  <>
184
- <button onClick={ () => setIsOpen( true ) }>
185
- Delete permanently
186
- </button>
217
+ <fieldset>
218
+ <legend>Async task outcome</legend>
219
+ <label htmlFor={ successId }>
220
+ <input
221
+ id={ successId }
222
+ type="radio"
223
+ name="async-outcome"
224
+ checked={ ! shouldFail }
225
+ onChange={ () => setShouldFail( false ) }
226
+ />
227
+ Success (closes dialog)
228
+ </label>
229
+ <label
230
+ htmlFor={ failureId }
231
+ style={ { marginInlineStart: 12 } }
232
+ >
233
+ <input
234
+ id={ failureId }
235
+ type="radio"
236
+ name="async-outcome"
237
+ checked={ shouldFail }
238
+ onChange={ () => setShouldFail( true ) }
239
+ />
240
+ Failure (dialog stays open, shows error)
241
+ </label>
242
+ </fieldset>
243
+ <br />
187
244
  <AlertDialog.Root
188
245
  { ...args }
189
- open={ isOpen }
190
- onOpenChange={ ( open, eventDetails ) => {
191
- if ( ! isLoading ) {
192
- setIsOpen( open );
246
+ onConfirm={ async () => {
247
+ action( 'onConfirm' )();
248
+ try {
249
+ await sleep( 2000 );
250
+ if ( shouldFail ) {
251
+ throw new Error( 'Task failed' );
252
+ }
253
+ } catch {
254
+ return {
255
+ close: false,
256
+ error: 'Something went wrong. Please try again.',
257
+ };
193
258
  }
194
- args.onOpenChange?.( open, eventDetails );
259
+ return undefined;
195
260
  } }
196
261
  >
262
+ <AlertDialog.Trigger>
263
+ Delete permanently
264
+ </AlertDialog.Trigger>
197
265
  <AlertDialog.Popup
266
+ intent="irreversible"
198
267
  title="Delete permanently?"
199
- loading={ isLoading }
200
- onConfirm={ () => {
201
- action( 'onConfirm' )();
202
- setIsLoading( true );
203
- new Promise< void >( ( resolve ) =>
204
- setTimeout( resolve, 2000 )
205
- ).then( () => {
206
- setIsLoading( false );
207
- setIsOpen( false );
208
- } );
209
- } }
268
+ description="This action cannot be undone. All data will be lost."
210
269
  confirmButtonText="Delete permanently"
211
- >
212
- This action cannot be undone. All data will be lost.
213
- </AlertDialog.Popup>
270
+ />
214
271
  </AlertDialog.Root>
215
272
  </>
216
273
  );
217
274
  },
218
- args: {
219
- intent: 'irreversible',
220
- },
221
275
  };
222
276
 
223
277
  /**
@@ -242,11 +296,8 @@ export const Controlled: Story = {
242
296
  >
243
297
  <AlertDialog.Popup
244
298
  title="Move to trash?"
245
- onConfirm={ action( 'onConfirm' ) }
246
- >
247
- This post will be moved to trash. You can restore it
248
- later.
249
- </AlertDialog.Popup>
299
+ description="This post will be moved to trash. You can restore it later."
300
+ />
250
301
  </AlertDialog.Root>
251
302
  </>
252
303
  );
@@ -1,5 +1,16 @@
1
1
  @layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides;
2
2
 
3
+ @layer wp-ui-components {
4
+ .header {
5
+ margin-bottom: var(--wpds-dimension-gap-lg);
6
+ }
7
+
8
+ .error-message {
9
+ align-self: flex-end;
10
+ color: var(--wpds-color-fg-content-error);
11
+ }
12
+ }
13
+
3
14
  @layer wp-ui-compositions {
4
15
  .irreversible-action {
5
16
  --wp-ui-button-background-color: var(--wpds-color-bg-interactive-error-strong);