@wordpress/ui 0.11.0 → 0.12.1-next.v.202604201441.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 (207) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +4 -4
  3. package/build/alert-dialog/popup.cjs +4 -4
  4. package/build/alert-dialog/popup.cjs.map +2 -2
  5. package/build/collapsible-card/header.cjs +10 -0
  6. package/build/collapsible-card/header.cjs.map +3 -3
  7. package/build/dialog/context.cjs +21 -9
  8. package/build/dialog/context.cjs.map +2 -2
  9. package/build/dialog/footer.cjs +4 -4
  10. package/build/dialog/footer.cjs.map +2 -2
  11. package/build/dialog/header.cjs +4 -4
  12. package/build/dialog/header.cjs.map +2 -2
  13. package/build/dialog/popup.cjs +4 -4
  14. package/build/dialog/popup.cjs.map +2 -2
  15. package/build/dialog/title.cjs +9 -6
  16. package/build/dialog/title.cjs.map +2 -2
  17. package/build/form/primitives/select/item.cjs +3 -3
  18. package/build/form/primitives/select/item.cjs.map +2 -2
  19. package/build/form/primitives/select/popup.cjs +3 -3
  20. package/build/form/primitives/select/popup.cjs.map +2 -2
  21. package/build/link/link.cjs +8 -18
  22. package/build/link/link.cjs.map +2 -2
  23. package/build/link/types.cjs.map +1 -1
  24. package/build/notice/action-button.cjs +3 -3
  25. package/build/notice/action-button.cjs.map +2 -2
  26. package/build/notice/action-link.cjs +8 -7
  27. package/build/notice/action-link.cjs.map +2 -2
  28. package/build/notice/actions.cjs +3 -3
  29. package/build/notice/actions.cjs.map +2 -2
  30. package/build/notice/close-icon.cjs +3 -3
  31. package/build/notice/close-icon.cjs.map +2 -2
  32. package/build/notice/description.cjs +3 -3
  33. package/build/notice/description.cjs.map +2 -2
  34. package/build/notice/root.cjs +3 -3
  35. package/build/notice/root.cjs.map +2 -2
  36. package/build/notice/title.cjs +3 -3
  37. package/build/notice/title.cjs.map +2 -2
  38. package/build/popover/arrow.cjs +4 -4
  39. package/build/popover/arrow.cjs.map +2 -2
  40. package/build/popover/context.cjs +21 -9
  41. package/build/popover/context.cjs.map +2 -2
  42. package/build/popover/description.cjs +4 -4
  43. package/build/popover/description.cjs.map +2 -2
  44. package/build/popover/popup.cjs +8 -5
  45. package/build/popover/popup.cjs.map +2 -2
  46. package/build/popover/title.cjs +5 -2
  47. package/build/popover/title.cjs.map +2 -2
  48. package/build/tabs/context.cjs +9 -22
  49. package/build/tabs/context.cjs.map +2 -2
  50. package/build/tabs/list.cjs +4 -4
  51. package/build/tabs/list.cjs.map +2 -2
  52. package/build/tabs/panel.cjs +19 -6
  53. package/build/tabs/panel.cjs.map +3 -3
  54. package/build/tabs/tab.cjs +4 -4
  55. package/build/tabs/tab.cjs.map +2 -2
  56. package/build/tooltip/popup.cjs +4 -4
  57. package/build/tooltip/popup.cjs.map +2 -2
  58. package/build/utils/use-schedule-validation.cjs +59 -0
  59. package/build/utils/use-schedule-validation.cjs.map +7 -0
  60. package/build-module/alert-dialog/popup.mjs +4 -4
  61. package/build-module/alert-dialog/popup.mjs.map +2 -2
  62. package/build-module/collapsible-card/header.mjs +10 -0
  63. package/build-module/collapsible-card/header.mjs.map +3 -3
  64. package/build-module/dialog/context.mjs +21 -9
  65. package/build-module/dialog/context.mjs.map +2 -2
  66. package/build-module/dialog/footer.mjs +4 -4
  67. package/build-module/dialog/footer.mjs.map +2 -2
  68. package/build-module/dialog/header.mjs +4 -4
  69. package/build-module/dialog/header.mjs.map +2 -2
  70. package/build-module/dialog/popup.mjs +4 -4
  71. package/build-module/dialog/popup.mjs.map +2 -2
  72. package/build-module/dialog/title.mjs +10 -7
  73. package/build-module/dialog/title.mjs.map +2 -2
  74. package/build-module/form/primitives/select/item.mjs +3 -3
  75. package/build-module/form/primitives/select/item.mjs.map +2 -2
  76. package/build-module/form/primitives/select/popup.mjs +3 -3
  77. package/build-module/form/primitives/select/popup.mjs.map +2 -2
  78. package/build-module/link/link.mjs +8 -18
  79. package/build-module/link/link.mjs.map +2 -2
  80. package/build-module/notice/action-button.mjs +3 -3
  81. package/build-module/notice/action-button.mjs.map +2 -2
  82. package/build-module/notice/action-link.mjs +8 -7
  83. package/build-module/notice/action-link.mjs.map +2 -2
  84. package/build-module/notice/actions.mjs +3 -3
  85. package/build-module/notice/actions.mjs.map +2 -2
  86. package/build-module/notice/close-icon.mjs +3 -3
  87. package/build-module/notice/close-icon.mjs.map +2 -2
  88. package/build-module/notice/description.mjs +3 -3
  89. package/build-module/notice/description.mjs.map +2 -2
  90. package/build-module/notice/root.mjs +3 -3
  91. package/build-module/notice/root.mjs.map +2 -2
  92. package/build-module/notice/title.mjs +3 -3
  93. package/build-module/notice/title.mjs.map +2 -2
  94. package/build-module/popover/arrow.mjs +4 -4
  95. package/build-module/popover/arrow.mjs.map +2 -2
  96. package/build-module/popover/context.mjs +21 -9
  97. package/build-module/popover/context.mjs.map +2 -2
  98. package/build-module/popover/description.mjs +4 -4
  99. package/build-module/popover/description.mjs.map +2 -2
  100. package/build-module/popover/popup.mjs +8 -5
  101. package/build-module/popover/popup.mjs.map +2 -2
  102. package/build-module/popover/title.mjs +6 -3
  103. package/build-module/popover/title.mjs.map +2 -2
  104. package/build-module/tabs/context.mjs +11 -24
  105. package/build-module/tabs/context.mjs.map +2 -2
  106. package/build-module/tabs/list.mjs +4 -4
  107. package/build-module/tabs/list.mjs.map +2 -2
  108. package/build-module/tabs/panel.mjs +19 -6
  109. package/build-module/tabs/panel.mjs.map +3 -3
  110. package/build-module/tabs/tab.mjs +4 -4
  111. package/build-module/tabs/tab.mjs.map +2 -2
  112. package/build-module/tooltip/popup.mjs +4 -4
  113. package/build-module/tooltip/popup.mjs.map +2 -2
  114. package/build-module/utils/use-schedule-validation.mjs +34 -0
  115. package/build-module/utils/use-schedule-validation.mjs.map +7 -0
  116. package/build-types/alert-dialog/stories/index.story.d.ts +1 -1
  117. package/build-types/alert-dialog/stories/index.story.d.ts.map +1 -1
  118. package/build-types/badge/stories/index.story.d.ts.map +1 -1
  119. package/build-types/collapsible-card/header.d.ts.map +1 -1
  120. package/build-types/dialog/context.d.ts +1 -1
  121. package/build-types/dialog/context.d.ts.map +1 -1
  122. package/build-types/dialog/title.d.ts.map +1 -1
  123. package/build-types/empty-state/stories/index.story.d.ts +1 -1
  124. package/build-types/empty-state/stories/index.story.d.ts.map +1 -1
  125. package/build-types/form/input-control/stories/index.story.d.ts +1 -1
  126. package/build-types/form/input-control/stories/index.story.d.ts.map +1 -1
  127. package/build-types/form/primitives/field/stories/index.story.d.ts +1 -1
  128. package/build-types/form/primitives/field/stories/index.story.d.ts.map +1 -1
  129. package/build-types/form/primitives/fieldset/stories/index.story.d.ts +1 -1
  130. package/build-types/form/primitives/fieldset/stories/index.story.d.ts.map +1 -1
  131. package/build-types/form/primitives/input/stories/index.story.d.ts +1 -1
  132. package/build-types/form/primitives/input/stories/index.story.d.ts.map +1 -1
  133. package/build-types/form/primitives/input-layout/stories/index.story.d.ts +1 -1
  134. package/build-types/form/primitives/input-layout/stories/index.story.d.ts.map +1 -1
  135. package/build-types/form/primitives/select/stories/index.story.d.ts +1 -1
  136. package/build-types/form/primitives/select/stories/index.story.d.ts.map +1 -1
  137. package/build-types/link/link.d.ts.map +1 -1
  138. package/build-types/link/types.d.ts +1 -2
  139. package/build-types/link/types.d.ts.map +1 -1
  140. package/build-types/notice/action-link.d.ts.map +1 -1
  141. package/build-types/popover/context.d.ts +1 -1
  142. package/build-types/popover/context.d.ts.map +1 -1
  143. package/build-types/popover/popup.d.ts.map +1 -1
  144. package/build-types/popover/stories/index.story.d.ts +1 -1
  145. package/build-types/popover/stories/index.story.d.ts.map +1 -1
  146. package/build-types/popover/title.d.ts.map +1 -1
  147. package/build-types/stack/stories/index.story.d.ts.map +1 -1
  148. package/build-types/tabs/context.d.ts.map +1 -1
  149. package/build-types/tabs/panel.d.ts.map +1 -1
  150. package/build-types/tabs/stories/index.story.d.ts +1 -1
  151. package/build-types/tabs/stories/index.story.d.ts.map +1 -1
  152. package/build-types/text/stories/index.story.d.ts.map +1 -1
  153. package/build-types/tooltip/stories/index.story.d.ts +1 -1
  154. package/build-types/tooltip/stories/index.story.d.ts.map +1 -1
  155. package/build-types/tooltip/stories/usage-guidelines.story.d.ts.map +1 -1
  156. package/build-types/utils/use-schedule-validation.d.ts +13 -0
  157. package/build-types/utils/use-schedule-validation.d.ts.map +1 -0
  158. package/package.json +11 -11
  159. package/src/alert-dialog/stories/index.story.tsx +2 -2
  160. package/src/badge/stories/choosing-intent.story.tsx +1 -1
  161. package/src/badge/stories/index.story.tsx +1 -0
  162. package/src/collapsible-card/header.tsx +2 -0
  163. package/src/dialog/context.tsx +28 -15
  164. package/src/dialog/style.module.css +12 -0
  165. package/src/dialog/test/index.test.tsx +222 -142
  166. package/src/dialog/title.tsx +6 -4
  167. package/src/empty-state/stories/index.story.tsx +2 -1
  168. package/src/form/input-control/stories/index.story.tsx +4 -1
  169. package/src/form/primitives/field/stories/index.story.tsx +1 -1
  170. package/src/form/primitives/fieldset/stories/index.story.tsx +1 -1
  171. package/src/form/primitives/input/stories/index.story.tsx +2 -1
  172. package/src/form/primitives/input-layout/stories/index.story.tsx +2 -1
  173. package/src/form/primitives/select/stories/index.story.tsx +1 -1
  174. package/src/link/link.tsx +12 -26
  175. package/src/link/style.module.css +4 -16
  176. package/src/link/test/index.test.tsx +31 -27
  177. package/src/link/types.ts +1 -2
  178. package/src/notice/action-link.tsx +7 -4
  179. package/src/notice/style.module.css +5 -5
  180. package/src/popover/context.tsx +28 -12
  181. package/src/popover/popup.tsx +4 -1
  182. package/src/popover/stories/index.story.tsx +2 -1
  183. package/src/popover/style.module.css +23 -1
  184. package/src/popover/test/index.test.tsx +146 -70
  185. package/src/popover/title.tsx +6 -3
  186. package/src/stack/stories/index.story.tsx +1 -0
  187. package/src/tabs/context.tsx +14 -34
  188. package/src/tabs/panel.tsx +7 -2
  189. package/src/tabs/stories/index.story.tsx +2 -1
  190. package/src/tabs/style.module.css +0 -17
  191. package/src/tabs/test/index.test.tsx +7 -3
  192. package/src/text/stories/index.story.tsx +1 -0
  193. package/src/tooltip/stories/index.story.tsx +2 -1
  194. package/src/tooltip/stories/usage-guidelines.story.tsx +5 -1
  195. package/src/tooltip/style.module.css +12 -0
  196. package/src/utils/css/item-popup.module.css +12 -0
  197. package/src/utils/use-schedule-validation.ts +45 -0
  198. package/build/types/css-modules.d.cjs +0 -2
  199. package/build/types/css-modules.d.cjs.map +0 -7
  200. package/build/types/react.d.cjs +0 -5
  201. package/build/types/react.d.cjs.map +0 -7
  202. package/build-module/types/css-modules.d.mjs +0 -1
  203. package/build-module/types/css-modules.d.mjs.map +0 -7
  204. package/build-module/types/react.d.mjs +0 -3
  205. package/build-module/types/react.d.mjs.map +0 -7
  206. package/src/types/css-modules.d.ts +0 -4
  207. package/src/types/react.d.ts +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wordpress/ui",
3
- "version": "0.11.0",
3
+ "version": "0.12.1-next.v.202604201441.0+dab6d8c07",
4
4
  "description": "Themeable React UI components for the WordPress Design System.",
5
5
  "author": "The WordPress Contributors",
6
6
  "license": "GPL-2.0-or-later",
@@ -44,15 +44,15 @@
44
44
  "sideEffects": false,
45
45
  "dependencies": {
46
46
  "@base-ui/react": "^1.4.0",
47
- "@wordpress/a11y": "^4.44.0",
48
- "@wordpress/compose": "^7.44.0",
49
- "@wordpress/element": "^6.44.0",
50
- "@wordpress/i18n": "^6.17.0",
51
- "@wordpress/icons": "^12.2.0",
52
- "@wordpress/keycodes": "^4.44.0",
53
- "@wordpress/primitives": "^4.44.0",
54
- "@wordpress/private-apis": "^1.44.0",
55
- "@wordpress/theme": "^0.11.0",
47
+ "@wordpress/a11y": "^4.44.1-next.v.202604201441.0+dab6d8c07",
48
+ "@wordpress/compose": "^7.44.1-next.v.202604201441.0+dab6d8c07",
49
+ "@wordpress/element": "^6.44.1-next.v.202604201441.0+dab6d8c07",
50
+ "@wordpress/i18n": "^6.17.1-next.v.202604201441.0+dab6d8c07",
51
+ "@wordpress/icons": "^12.2.1-next.v.202604201441.0+dab6d8c07",
52
+ "@wordpress/keycodes": "^4.44.1-next.v.202604201441.0+dab6d8c07",
53
+ "@wordpress/primitives": "^4.44.1-next.v.202604201441.0+dab6d8c07",
54
+ "@wordpress/private-apis": "^1.44.1-next.v.202604201441.0+dab6d8c07",
55
+ "@wordpress/theme": "^0.11.1-next.v.202604201441.0+dab6d8c07",
56
56
  "clsx": "^2.1.1",
57
57
  "tabbable": "^6.4.0"
58
58
  },
@@ -71,5 +71,5 @@
71
71
  "publishConfig": {
72
72
  "access": "public"
73
73
  },
74
- "gitHead": "b862d8c84121a47bbeff882f6c87e61681ce2e0d"
74
+ "gitHead": "c788005ba4ee2a34851c1217c51602656aa7c3a6"
75
75
  }
@@ -3,8 +3,8 @@ 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
-
7
- import { AlertDialog, Text } from '../..';
6
+ import * as AlertDialog from '../';
7
+ import { Text } from '../../text';
8
8
 
9
9
  const meta: Meta< typeof AlertDialog.Root > = {
10
10
  title: 'Design System/Components/AlertDialog',
@@ -15,7 +15,7 @@ const meta: Meta< typeof Badge > = {
15
15
  parameters: {
16
16
  controls: { disable: true },
17
17
  },
18
- tags: [ '!dev' /* Hide individual story pages from sidebar */ ],
18
+ tags: [ '!dev' /* Hide individual story pages from sidebar */, 'manifest' ],
19
19
  };
20
20
  export default meta;
21
21
 
@@ -3,6 +3,7 @@ import { Fragment } from '@wordpress/element';
3
3
  import { Badge } from '../index';
4
4
 
5
5
  const meta: Meta< typeof Badge > = {
6
+ tags: [ 'manifest' ],
6
7
  title: 'Design System/Components/Badge',
7
8
  component: Badge,
8
9
  };
@@ -5,6 +5,7 @@ import * as Card from '../card';
5
5
  import * as Collapsible from '../collapsible';
6
6
  import { Icon } from '../icon';
7
7
  import styles from './style.module.css';
8
+ import defenseStyles from '../utils/css/global-css-defense.module.css';
8
9
  import focusStyles from '../utils/css/focus.module.css';
9
10
  import { HeaderDescriptionIdContext } from './context';
10
11
  import type { HeaderProps } from './types';
@@ -55,6 +56,7 @@ export const Header = forwardRef< HTMLDivElement, HeaderProps >(
55
56
  <div
56
57
  className={ clsx(
57
58
  styles[ 'header-trigger-wrapper' ],
59
+ defenseStyles.div,
58
60
  // While the interactive trigger element is the whole header,
59
61
  // the focus ring will be displayed only on the icon to visually
60
62
  // emulate it being the button.
@@ -6,6 +6,7 @@ import {
6
6
  useMemo,
7
7
  useRef,
8
8
  } from '@wordpress/element';
9
+ import { useScheduleValidation } from '../utils/use-schedule-validation';
9
10
 
10
11
  /**
11
12
  * Whether validation is enabled. This is a build-time constant that allows
@@ -14,7 +15,7 @@ import {
14
15
  const VALIDATION_ENABLED = process.env.NODE_ENV !== 'production';
15
16
 
16
17
  type DialogValidationContextType = {
17
- registerTitle: ( element: HTMLElement | null ) => void;
18
+ registerTitle: ( element: HTMLElement | null ) => () => void;
18
19
  };
19
20
 
20
21
  // Context is only created in development mode.
@@ -54,19 +55,7 @@ function DialogValidationProviderDev( {
54
55
  } ) {
55
56
  const titleElementRef = useRef< HTMLElement | null >( null );
56
57
 
57
- const registerTitle = useCallback( ( element: HTMLElement | null ) => {
58
- titleElementRef.current = element;
59
- }, [] );
60
-
61
- const contextValue = useMemo(
62
- () => ( { registerTitle } ),
63
- [ registerTitle ]
64
- );
65
-
66
- // Validate that Dialog.Title is rendered with non-empty text content
67
- useEffect( () => {
68
- // useLayoutEffect in Title runs before this useEffect,
69
- // so titleElementRef should already be set if Title is present
58
+ const scheduleValidation = useScheduleValidation( () => {
70
59
  const titleElement = titleElementRef.current;
71
60
 
72
61
  if ( ! titleElement ) {
@@ -84,7 +73,31 @@ function DialogValidationProviderDev( {
84
73
  'Provide meaningful text content for the dialog title.'
85
74
  );
86
75
  }
87
- }, [] );
76
+ } );
77
+
78
+ const registerTitle = useCallback(
79
+ ( element: HTMLElement | null ) => {
80
+ titleElementRef.current = element;
81
+ scheduleValidation();
82
+
83
+ return () => {
84
+ titleElementRef.current = null;
85
+ scheduleValidation();
86
+ };
87
+ },
88
+ [ scheduleValidation ]
89
+ );
90
+
91
+ // Schedule an initial validation on mount to catch missing titles
92
+ // (when no Title component is rendered, registerTitle is never called).
93
+ useEffect( () => {
94
+ scheduleValidation();
95
+ }, [ scheduleValidation ] );
96
+
97
+ const contextValue = useMemo(
98
+ () => ( { registerTitle } ),
99
+ [ registerTitle ]
100
+ );
88
101
 
89
102
  return (
90
103
  <DialogValidationContext.Provider value={ contextValue }>
@@ -1,5 +1,17 @@
1
1
  @layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides;
2
2
 
3
+ /*
4
+ * Temporary workaround for a Base UI tabbability regression with
5
+ * checkVisibility() and display: contents.
6
+ * See: https://github.com/mui/base-ui/issues/4622
7
+ *
8
+ * This must stay outside the CSS layers to override ThemeProvider's
9
+ * unlayered display: contents.
10
+ */
11
+ [data-wpds-theme-provider-id]:has(> .popup) {
12
+ display: block;
13
+ }
14
+
3
15
  @layer wp-ui-components {
4
16
  .backdrop {
5
17
  position: fixed;
@@ -1,36 +1,19 @@
1
1
  import { render, screen, waitFor } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
- import { Component, createRef } from '@wordpress/element';
4
- import type { ReactNode } from 'react';
3
+ import { createRef, useState } from '@wordpress/element';
5
4
  import * as Dialog from '../index';
6
5
 
7
- class TestErrorBoundary extends Component<
8
- { children: ReactNode; onError: ( error: Error ) => void },
9
- { hasError: boolean }
10
- > {
11
- constructor( props: {
12
- children: ReactNode;
13
- onError: ( error: Error ) => void;
14
- } ) {
15
- super( props );
16
- this.state = { hasError: false };
17
- }
18
-
19
- static getDerivedStateFromError() {
20
- return { hasError: true };
21
- }
22
-
23
- componentDidCatch( error: Error ) {
24
- this.props.onError( error );
25
- }
26
-
27
- render() {
28
- if ( this.state.hasError ) {
29
- return null;
30
- }
31
-
32
- return this.props.children;
33
- }
6
+ function collectUncaughtErrors() {
7
+ const errors: Error[] = [];
8
+ const handler = ( event: ErrorEvent ) => {
9
+ event.preventDefault();
10
+ errors.push( event.error );
11
+ };
12
+ window.addEventListener( 'error', handler );
13
+ return {
14
+ errors,
15
+ cleanup: () => window.removeEventListener( 'error', handler ),
16
+ };
34
17
  }
35
18
 
36
19
  describe( 'Dialog', () => {
@@ -81,7 +64,9 @@ describe( 'Dialog', () => {
81
64
  } );
82
65
 
83
66
  describe( 'Development mode validation', () => {
84
- // Suppress React's error boundary logging for these tests.
67
+ // Suppress console.error from React act() warnings and jsdom
68
+ // unhandled-error logging. Validation errors are caught via
69
+ // collectUncaughtErrors (window 'error' event) instead.
85
70
  let originalConsoleError: typeof console.error;
86
71
 
87
72
  beforeEach( () => {
@@ -98,210 +83,305 @@ describe( 'Dialog', () => {
98
83
 
99
84
  it( 'should throw when Dialog.Title is missing', async () => {
100
85
  const user = userEvent.setup();
101
- const onError = jest.fn();
86
+ const { errors, cleanup } = collectUncaughtErrors();
102
87
 
103
88
  render(
104
- <TestErrorBoundary onError={ onError }>
105
- <Dialog.Root>
106
- <Dialog.Trigger>Open Dialog</Dialog.Trigger>
107
- <Dialog.Popup>
108
- <Dialog.Header>
109
- { /* Missing Dialog.Title */ }
110
- </Dialog.Header>
111
- <p>Content without a title</p>
112
- <Dialog.Footer>
113
- <Dialog.Action>Close</Dialog.Action>
114
- </Dialog.Footer>
115
- </Dialog.Popup>
116
- </Dialog.Root>
117
- </TestErrorBoundary>
89
+ <Dialog.Root>
90
+ <Dialog.Trigger>Open Dialog</Dialog.Trigger>
91
+ <Dialog.Popup>
92
+ <Dialog.Header>
93
+ { /* Missing Dialog.Title */ }
94
+ </Dialog.Header>
95
+ <p>Content without a title</p>
96
+ <Dialog.Footer>
97
+ <Dialog.Action>Close</Dialog.Action>
98
+ </Dialog.Footer>
99
+ </Dialog.Popup>
100
+ </Dialog.Root>
118
101
  );
119
102
 
120
- // Open the dialog - this will trigger the error in useEffect
121
103
  await user.click(
122
104
  screen.getByRole( 'button', { name: 'Open Dialog' } )
123
105
  );
124
106
 
125
107
  await waitFor( () => {
126
- expect( onError ).toHaveBeenCalled();
108
+ expect( errors.length ).toBeGreaterThan( 0 );
127
109
  } );
128
110
 
129
- expect( onError.mock.calls[ 0 ][ 0 ] ).toBeInstanceOf( Error );
130
- expect( ( onError.mock.calls[ 0 ][ 0 ] as Error ).message ).toBe(
111
+ expect( errors[ 0 ].message ).toBe(
131
112
  'Dialog: Missing <Dialog.Title>. ' +
132
113
  'For accessibility, every dialog requires a title. ' +
133
114
  'If needed, the title can be visually hidden but must not be omitted.'
134
115
  );
116
+
117
+ cleanup();
135
118
  } );
136
119
 
137
120
  it( 'should not throw before opening the dialog', async () => {
138
- const onError = jest.fn();
121
+ const { errors, cleanup } = collectUncaughtErrors();
139
122
 
140
123
  render(
141
- <TestErrorBoundary onError={ onError }>
142
- <Dialog.Root>
143
- <Dialog.Trigger>Open Dialog</Dialog.Trigger>
144
- <Dialog.Popup>
145
- <Dialog.Header>
146
- <Dialog.Title>My Title</Dialog.Title>
147
- </Dialog.Header>
148
- <p>Content with a title</p>
149
- <Dialog.Footer>
150
- <Dialog.Action>Close</Dialog.Action>
151
- </Dialog.Footer>
152
- </Dialog.Popup>
153
- </Dialog.Root>
154
- </TestErrorBoundary>
124
+ <Dialog.Root>
125
+ <Dialog.Trigger>Open Dialog</Dialog.Trigger>
126
+ <Dialog.Popup>
127
+ <Dialog.Header>
128
+ <Dialog.Title>My Title</Dialog.Title>
129
+ </Dialog.Header>
130
+ <p>Content with a title</p>
131
+ <Dialog.Footer>
132
+ <Dialog.Action>Close</Dialog.Action>
133
+ </Dialog.Footer>
134
+ </Dialog.Popup>
135
+ </Dialog.Root>
155
136
  );
156
137
 
157
- // Check that the dialog itself hasn't been rendered in the DOM.
158
138
  await expect( screen.findByRole( 'dialog' ) ).rejects.toThrow();
139
+ expect( errors ).toHaveLength( 0 );
159
140
 
160
- expect( onError ).not.toHaveBeenCalled();
141
+ cleanup();
161
142
  } );
162
143
 
163
144
  it( 'should not throw when Dialog.Title is present', async () => {
164
145
  const user = userEvent.setup();
165
- const onError = jest.fn();
146
+ const { errors, cleanup } = collectUncaughtErrors();
166
147
 
167
148
  render(
168
- <TestErrorBoundary onError={ onError }>
169
- <Dialog.Root>
170
- <Dialog.Trigger>Open Dialog</Dialog.Trigger>
171
- <Dialog.Popup>
172
- <Dialog.Header>
173
- <Dialog.Title>My Title</Dialog.Title>
174
- </Dialog.Header>
175
- <p>Content with a title</p>
176
- <Dialog.Footer>
177
- <Dialog.Action>Close</Dialog.Action>
178
- </Dialog.Footer>
179
- </Dialog.Popup>
180
- </Dialog.Root>
181
- </TestErrorBoundary>
149
+ <Dialog.Root>
150
+ <Dialog.Trigger>Open Dialog</Dialog.Trigger>
151
+ <Dialog.Popup>
152
+ <Dialog.Header>
153
+ <Dialog.Title>My Title</Dialog.Title>
154
+ </Dialog.Header>
155
+ <p>Content with a title</p>
156
+ <Dialog.Footer>
157
+ <Dialog.Action>Close</Dialog.Action>
158
+ </Dialog.Footer>
159
+ </Dialog.Popup>
160
+ </Dialog.Root>
182
161
  );
183
162
 
184
- // Open the dialog - should not throw
185
163
  await user.click(
186
164
  screen.getByRole( 'button', { name: 'Open Dialog' } )
187
165
  );
188
166
 
189
- // Wait for the dialog to appear and ensure validation does not trigger errors
190
167
  await waitFor( () => {
191
168
  expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument();
192
169
  } );
193
- expect( onError ).not.toHaveBeenCalled();
170
+
171
+ // Allow deferred validation to settle.
172
+ await new Promise( ( resolve ) => setTimeout( resolve, 50 ) );
173
+ expect( errors ).toHaveLength( 0 );
174
+
175
+ cleanup();
194
176
  } );
195
177
 
196
178
  it( 'should throw when Dialog.Title is empty', async () => {
197
179
  const user = userEvent.setup();
198
- const onError = jest.fn();
180
+ const { errors, cleanup } = collectUncaughtErrors();
199
181
 
200
182
  render(
201
- <TestErrorBoundary onError={ onError }>
202
- <Dialog.Root>
203
- <Dialog.Trigger>Open Dialog</Dialog.Trigger>
204
- <Dialog.Popup>
205
- <Dialog.Header>
206
- { /* Empty title */ }
207
- <Dialog.Title />
208
- </Dialog.Header>
209
- <p>Content with empty title</p>
210
- <Dialog.Footer>
211
- <Dialog.Action>Close</Dialog.Action>
212
- </Dialog.Footer>
213
- </Dialog.Popup>
214
- </Dialog.Root>
215
- </TestErrorBoundary>
183
+ <Dialog.Root>
184
+ <Dialog.Trigger>Open Dialog</Dialog.Trigger>
185
+ <Dialog.Popup>
186
+ <Dialog.Header>
187
+ <Dialog.Title />
188
+ </Dialog.Header>
189
+ <p>Content with empty title</p>
190
+ <Dialog.Footer>
191
+ <Dialog.Action>Close</Dialog.Action>
192
+ </Dialog.Footer>
193
+ </Dialog.Popup>
194
+ </Dialog.Root>
216
195
  );
217
196
 
218
- // Open the dialog - this will trigger the error
219
197
  await user.click(
220
198
  screen.getByRole( 'button', { name: 'Open Dialog' } )
221
199
  );
222
200
 
223
201
  await waitFor( () => {
224
- expect( onError ).toHaveBeenCalled();
202
+ expect( errors.length ).toBeGreaterThan( 0 );
225
203
  } );
226
204
 
227
- expect( onError.mock.calls[ 0 ][ 0 ] ).toBeInstanceOf( Error );
228
- expect( ( onError.mock.calls[ 0 ][ 0 ] as Error ).message ).toBe(
205
+ expect( errors[ 0 ].message ).toBe(
229
206
  'Dialog: <Dialog.Title> cannot be empty. ' +
230
207
  'Provide meaningful text content for the dialog title.'
231
208
  );
209
+
210
+ cleanup();
232
211
  } );
233
212
 
234
213
  it( 'should throw when Dialog.Title contains only whitespace', async () => {
235
214
  const user = userEvent.setup();
236
- const onError = jest.fn();
215
+ const { errors, cleanup } = collectUncaughtErrors();
237
216
 
238
217
  render(
239
- <TestErrorBoundary onError={ onError }>
240
- <Dialog.Root>
241
- <Dialog.Trigger>Open Dialog</Dialog.Trigger>
242
- <Dialog.Popup>
243
- <Dialog.Header>
244
- <Dialog.Title> </Dialog.Title>
245
- </Dialog.Header>
246
- <p>Content with whitespace-only title</p>
247
- <Dialog.Footer>
248
- <Dialog.Action>Close</Dialog.Action>
249
- </Dialog.Footer>
250
- </Dialog.Popup>
251
- </Dialog.Root>
252
- </TestErrorBoundary>
218
+ <Dialog.Root>
219
+ <Dialog.Trigger>Open Dialog</Dialog.Trigger>
220
+ <Dialog.Popup>
221
+ <Dialog.Header>
222
+ <Dialog.Title> </Dialog.Title>
223
+ </Dialog.Header>
224
+ <p>Content with whitespace-only title</p>
225
+ <Dialog.Footer>
226
+ <Dialog.Action>Close</Dialog.Action>
227
+ </Dialog.Footer>
228
+ </Dialog.Popup>
229
+ </Dialog.Root>
253
230
  );
254
231
 
255
- // Open the dialog - this will trigger the error
256
232
  await user.click(
257
233
  screen.getByRole( 'button', { name: 'Open Dialog' } )
258
234
  );
259
235
 
260
236
  await waitFor( () => {
261
- expect( onError ).toHaveBeenCalled();
237
+ expect( errors.length ).toBeGreaterThan( 0 );
262
238
  } );
263
239
 
264
- expect( onError.mock.calls[ 0 ][ 0 ] ).toBeInstanceOf( Error );
265
- expect( ( onError.mock.calls[ 0 ][ 0 ] as Error ).message ).toBe(
240
+ expect( errors[ 0 ].message ).toBe(
266
241
  'Dialog: <Dialog.Title> cannot be empty. ' +
267
242
  'Provide meaningful text content for the dialog title.'
268
243
  );
244
+
245
+ cleanup();
269
246
  } );
270
247
 
271
248
  it( 'should not throw when Dialog.Title contains mixed content with text', async () => {
272
249
  const user = userEvent.setup();
273
- const onError = jest.fn();
250
+ const { errors, cleanup } = collectUncaughtErrors();
274
251
 
275
252
  render(
276
- <TestErrorBoundary onError={ onError }>
253
+ <Dialog.Root>
254
+ <Dialog.Trigger>Open Dialog</Dialog.Trigger>
255
+ <Dialog.Popup>
256
+ <Dialog.Header>
257
+ <Dialog.Title>
258
+ <span aria-hidden="true">🎉</span>
259
+ Settings
260
+ </Dialog.Title>
261
+ </Dialog.Header>
262
+ <p>Content with icon and text title</p>
263
+ <Dialog.Footer>
264
+ <Dialog.Action>Close</Dialog.Action>
265
+ </Dialog.Footer>
266
+ </Dialog.Popup>
267
+ </Dialog.Root>
268
+ );
269
+
270
+ await user.click(
271
+ screen.getByRole( 'button', { name: 'Open Dialog' } )
272
+ );
273
+
274
+ await waitFor( () => {
275
+ expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument();
276
+ } );
277
+
278
+ await new Promise( ( resolve ) => setTimeout( resolve, 50 ) );
279
+ expect( errors ).toHaveLength( 0 );
280
+
281
+ cleanup();
282
+ } );
283
+
284
+ it( 'should throw when title is removed after mount', async () => {
285
+ const user = userEvent.setup();
286
+ const { errors, cleanup } = collectUncaughtErrors();
287
+
288
+ function Test() {
289
+ const [ showTitle, setShowTitle ] = useState( true );
290
+ return (
277
291
  <Dialog.Root>
278
- <Dialog.Trigger>Open Dialog</Dialog.Trigger>
292
+ <Dialog.Trigger>Open</Dialog.Trigger>
279
293
  <Dialog.Popup>
280
- <Dialog.Header>
281
- <Dialog.Title>
282
- <span aria-hidden="true">🎉</span>
283
- Settings
284
- </Dialog.Title>
285
- </Dialog.Header>
286
- <p>Content with icon and text title</p>
287
- <Dialog.Footer>
288
- <Dialog.Action>Close</Dialog.Action>
289
- </Dialog.Footer>
294
+ { showTitle && (
295
+ <Dialog.Title>My Title</Dialog.Title>
296
+ ) }
297
+ <button onClick={ () => setShowTitle( false ) }>
298
+ Remove Title
299
+ </button>
290
300
  </Dialog.Popup>
291
301
  </Dialog.Root>
292
- </TestErrorBoundary>
293
- );
302
+ );
303
+ }
294
304
 
295
- // Open the dialog - should not throw
305
+ render( <Test /> );
306
+
307
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
308
+
309
+ await waitFor( () => {
310
+ expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument();
311
+ } );
312
+
313
+ // Let initial validation settle — no errors expected.
314
+ await new Promise( ( resolve ) => setTimeout( resolve, 50 ) );
315
+ expect( errors ).toHaveLength( 0 );
316
+
317
+ // Remove the title.
296
318
  await user.click(
297
- screen.getByRole( 'button', { name: 'Open Dialog' } )
319
+ screen.getByRole( 'button', { name: 'Remove Title' } )
298
320
  );
299
321
 
300
- // Wait for the dialog to appear and ensure validation does not trigger errors
322
+ await waitFor( () => {
323
+ expect( errors.length ).toBeGreaterThan( 0 );
324
+ } );
325
+
326
+ expect( errors[ 0 ].message ).toBe(
327
+ 'Dialog: Missing <Dialog.Title>. ' +
328
+ 'For accessibility, every dialog requires a title. ' +
329
+ 'If needed, the title can be visually hidden but must not be omitted.'
330
+ );
331
+
332
+ cleanup();
333
+ } );
334
+
335
+ it( 'should recover when title is added back', async () => {
336
+ const user = userEvent.setup();
337
+ const { errors, cleanup } = collectUncaughtErrors();
338
+
339
+ function Test() {
340
+ const [ showTitle, setShowTitle ] = useState( false );
341
+ return (
342
+ <Dialog.Root>
343
+ <Dialog.Trigger>Open</Dialog.Trigger>
344
+ <Dialog.Popup>
345
+ { showTitle && (
346
+ <Dialog.Title>My Title</Dialog.Title>
347
+ ) }
348
+ <button
349
+ onClick={ () => setShowTitle( ( s ) => ! s ) }
350
+ >
351
+ Toggle Title
352
+ </button>
353
+ </Dialog.Popup>
354
+ </Dialog.Root>
355
+ );
356
+ }
357
+
358
+ render( <Test /> );
359
+
360
+ await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
361
+
301
362
  await waitFor( () => {
302
363
  expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument();
303
364
  } );
304
- expect( onError ).not.toHaveBeenCalled();
365
+
366
+ // Initially no title — should error.
367
+ await waitFor( () => {
368
+ expect( errors.length ).toBeGreaterThan( 0 );
369
+ } );
370
+
371
+ const errorCountAfterInitial = errors.length;
372
+
373
+ // Add the title back.
374
+ await user.click(
375
+ screen.getByRole( 'button', { name: 'Toggle Title' } )
376
+ );
377
+
378
+ // Wait for deferred validation to settle.
379
+ await new Promise( ( resolve ) => setTimeout( resolve, 50 ) );
380
+
381
+ // No new errors should have been thrown.
382
+ expect( errors ).toHaveLength( errorCountAfterInitial );
383
+
384
+ cleanup();
305
385
  } );
306
386
  } );
307
387