@wordpress/components 30.2.1-next.f34ab90e9.0 → 30.3.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 (176) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/build/card/context.js +1 -0
  3. package/build/card/context.js.map +1 -1
  4. package/build/circular-option-picker/circular-option-picker-context.js +1 -0
  5. package/build/circular-option-picker/circular-option-picker-context.js.map +1 -1
  6. package/build/composite/context.js +1 -0
  7. package/build/composite/context.js.map +1 -1
  8. package/build/context/context-connect.js.map +1 -1
  9. package/build/context/context-system-provider.js +1 -0
  10. package/build/context/context-system-provider.js.map +1 -1
  11. package/build/custom-select-control-v2/custom-select.js +1 -0
  12. package/build/custom-select-control-v2/custom-select.js.map +1 -1
  13. package/build/disabled/index.js +1 -0
  14. package/build/disabled/index.js.map +1 -1
  15. package/build/item-group/context.js +1 -0
  16. package/build/item-group/context.js.map +1 -1
  17. package/build/menu/context.js +1 -0
  18. package/build/menu/context.js.map +1 -1
  19. package/build/modal/index.js +1 -0
  20. package/build/modal/index.js.map +1 -1
  21. package/build/navigation/context.js +1 -0
  22. package/build/navigation/context.js.map +1 -1
  23. package/build/navigation/group/context.js +1 -0
  24. package/build/navigation/group/context.js.map +1 -1
  25. package/build/navigation/menu/context.js +1 -0
  26. package/build/navigation/menu/context.js.map +1 -1
  27. package/build/navigator/context.js +1 -0
  28. package/build/navigator/context.js.map +1 -1
  29. package/build/popover/index.js +1 -0
  30. package/build/popover/index.js.map +1 -1
  31. package/build/radio-group/context.js +1 -0
  32. package/build/radio-group/context.js.map +1 -1
  33. package/build/slot-fill/bubbles-virtually/slot-fill-context.js +1 -0
  34. package/build/slot-fill/bubbles-virtually/slot-fill-context.js.map +1 -1
  35. package/build/slot-fill/context.js +1 -0
  36. package/build/slot-fill/context.js.map +1 -1
  37. package/build/tabs/context.js +1 -0
  38. package/build/tabs/context.js.map +1 -1
  39. package/build/tabs/styles.js +5 -5
  40. package/build/tabs/styles.js.map +1 -1
  41. package/build/toggle-group-control/context.js +1 -0
  42. package/build/toggle-group-control/context.js.map +1 -1
  43. package/build/toolbar/toolbar-context/index.js +1 -0
  44. package/build/toolbar/toolbar-context/index.js.map +1 -1
  45. package/build/tools-panel/context.js +1 -0
  46. package/build/tools-panel/context.js.map +1 -1
  47. package/build/tooltip/index.js +1 -0
  48. package/build/tooltip/index.js.map +1 -1
  49. package/build/tree-grid/roving-tab-index-context.js +1 -0
  50. package/build/tree-grid/roving-tab-index-context.js.map +1 -1
  51. package/build/utils/font-size.js.map +1 -1
  52. package/build/utils/get-valid-children.js.map +1 -1
  53. package/build/validated-form-controls/control-with-error.js +16 -16
  54. package/build/validated-form-controls/control-with-error.js.map +1 -1
  55. package/build-module/card/context.js +1 -0
  56. package/build-module/card/context.js.map +1 -1
  57. package/build-module/circular-option-picker/circular-option-picker-context.js +1 -0
  58. package/build-module/circular-option-picker/circular-option-picker-context.js.map +1 -1
  59. package/build-module/composite/context.js +1 -0
  60. package/build-module/composite/context.js.map +1 -1
  61. package/build-module/context/context-connect.js.map +1 -1
  62. package/build-module/context/context-system-provider.js +1 -0
  63. package/build-module/context/context-system-provider.js.map +1 -1
  64. package/build-module/custom-select-control-v2/custom-select.js +1 -0
  65. package/build-module/custom-select-control-v2/custom-select.js.map +1 -1
  66. package/build-module/disabled/index.js +1 -0
  67. package/build-module/disabled/index.js.map +1 -1
  68. package/build-module/item-group/context.js +1 -0
  69. package/build-module/item-group/context.js.map +1 -1
  70. package/build-module/menu/context.js +1 -0
  71. package/build-module/menu/context.js.map +1 -1
  72. package/build-module/modal/index.js +1 -0
  73. package/build-module/modal/index.js.map +1 -1
  74. package/build-module/navigation/context.js +1 -0
  75. package/build-module/navigation/context.js.map +1 -1
  76. package/build-module/navigation/group/context.js +1 -0
  77. package/build-module/navigation/group/context.js.map +1 -1
  78. package/build-module/navigation/menu/context.js +1 -0
  79. package/build-module/navigation/menu/context.js.map +1 -1
  80. package/build-module/navigator/context.js +1 -0
  81. package/build-module/navigator/context.js.map +1 -1
  82. package/build-module/popover/index.js +1 -0
  83. package/build-module/popover/index.js.map +1 -1
  84. package/build-module/radio-group/context.js +1 -0
  85. package/build-module/radio-group/context.js.map +1 -1
  86. package/build-module/slot-fill/bubbles-virtually/slot-fill-context.js +1 -0
  87. package/build-module/slot-fill/bubbles-virtually/slot-fill-context.js.map +1 -1
  88. package/build-module/slot-fill/context.js +1 -0
  89. package/build-module/slot-fill/context.js.map +1 -1
  90. package/build-module/tabs/context.js +1 -0
  91. package/build-module/tabs/context.js.map +1 -1
  92. package/build-module/tabs/styles.js +6 -6
  93. package/build-module/tabs/styles.js.map +1 -1
  94. package/build-module/toggle-group-control/context.js +1 -0
  95. package/build-module/toggle-group-control/context.js.map +1 -1
  96. package/build-module/toolbar/toolbar-context/index.js +1 -0
  97. package/build-module/toolbar/toolbar-context/index.js.map +1 -1
  98. package/build-module/tools-panel/context.js +1 -0
  99. package/build-module/tools-panel/context.js.map +1 -1
  100. package/build-module/tooltip/index.js +1 -0
  101. package/build-module/tooltip/index.js.map +1 -1
  102. package/build-module/tree-grid/roving-tab-index-context.js +1 -0
  103. package/build-module/tree-grid/roving-tab-index-context.js.map +1 -1
  104. package/build-module/utils/font-size.js.map +1 -1
  105. package/build-module/utils/get-valid-children.js.map +1 -1
  106. package/build-module/validated-form-controls/control-with-error.js +16 -16
  107. package/build-module/validated-form-controls/control-with-error.js.map +1 -1
  108. package/build-types/calendar/stories/date-calendar.story.d.ts.map +1 -1
  109. package/build-types/calendar/stories/date-range-calendar.story.d.ts.map +1 -1
  110. package/build-types/card/context.d.ts.map +1 -1
  111. package/build-types/composite/context.d.ts.map +1 -1
  112. package/build-types/context/context-connect.d.ts +2 -2
  113. package/build-types/context/context-connect.d.ts.map +1 -1
  114. package/build-types/context/context-system-provider.d.ts.map +1 -1
  115. package/build-types/custom-select-control-v2/custom-select.d.ts.map +1 -1
  116. package/build-types/disabled/index.d.ts.map +1 -1
  117. package/build-types/item-group/context.d.ts.map +1 -1
  118. package/build-types/modal/index.d.ts.map +1 -1
  119. package/build-types/navigation/context.d.ts.map +1 -1
  120. package/build-types/navigation/group/context.d.ts.map +1 -1
  121. package/build-types/navigation/menu/context.d.ts.map +1 -1
  122. package/build-types/popover/index.d.ts +1 -1
  123. package/build-types/popover/index.d.ts.map +1 -1
  124. package/build-types/popover/stories/e2e/index.story.d.ts +1 -1
  125. package/build-types/slot-fill/bubbles-virtually/slot-fill-context.d.ts.map +1 -1
  126. package/build-types/slot-fill/context.d.ts.map +1 -1
  127. package/build-types/tabs/context.d.ts.map +1 -1
  128. package/build-types/tabs/styles.d.ts.map +1 -1
  129. package/build-types/toggle-group-control/context.d.ts.map +1 -1
  130. package/build-types/toolbar/toolbar-context/index.d.ts.map +1 -1
  131. package/build-types/tools-panel/context.d.ts.map +1 -1
  132. package/build-types/tooltip/index.d.ts.map +1 -1
  133. package/build-types/tree-grid/roving-tab-index-context.d.ts.map +1 -1
  134. package/build-types/utils/font-size.d.ts +2 -2
  135. package/build-types/utils/font-size.d.ts.map +1 -1
  136. package/build-types/utils/get-valid-children.d.ts +2 -2
  137. package/build-types/utils/get-valid-children.d.ts.map +1 -1
  138. package/build-types/validated-form-controls/components/stories/overview.story.d.ts.map +1 -1
  139. package/build-types/validated-form-controls/control-with-error.d.ts.map +1 -1
  140. package/build-types/validated-form-controls/test/control-with-error.d.ts +2 -0
  141. package/build-types/validated-form-controls/test/control-with-error.d.ts.map +1 -0
  142. package/package.json +20 -20
  143. package/src/calendar/stories/date-calendar.story.tsx +1 -0
  144. package/src/calendar/stories/date-range-calendar.story.tsx +1 -0
  145. package/src/card/context.ts +2 -0
  146. package/src/circular-option-picker/circular-option-picker-context.tsx +1 -0
  147. package/src/composite/context.tsx +1 -0
  148. package/src/context/context-connect.ts +2 -2
  149. package/src/context/context-system-provider.js +2 -0
  150. package/src/custom-select-control-v2/custom-select.tsx +1 -0
  151. package/src/disabled/index.tsx +2 -0
  152. package/src/item-group/context.ts +1 -0
  153. package/src/menu/context.tsx +1 -0
  154. package/src/modal/index.tsx +1 -0
  155. package/src/navigation/context.tsx +3 -0
  156. package/src/navigation/group/context.tsx +1 -0
  157. package/src/navigation/menu/context.tsx +2 -0
  158. package/src/navigator/context.ts +1 -0
  159. package/src/popover/index.tsx +1 -0
  160. package/src/radio-group/context.tsx +1 -0
  161. package/src/slot-fill/bubbles-virtually/slot-fill-context.ts +1 -0
  162. package/src/slot-fill/context.ts +1 -0
  163. package/src/tabs/context.ts +1 -0
  164. package/src/tabs/styles.ts +2 -1
  165. package/src/toggle-group-control/context.ts +2 -0
  166. package/src/toolbar/toolbar-context/index.ts +1 -0
  167. package/src/tools-panel/context.ts +1 -0
  168. package/src/tools-panel/stories/index.story.tsx +3 -3
  169. package/src/tooltip/index.tsx +1 -0
  170. package/src/tree-grid/roving-tab-index-context.ts +2 -0
  171. package/src/utils/font-size.ts +2 -2
  172. package/src/utils/get-valid-children.ts +4 -2
  173. package/src/validated-form-controls/components/stories/overview.story.tsx +109 -27
  174. package/src/validated-form-controls/control-with-error.tsx +19 -18
  175. package/src/validated-form-controls/test/control-with-error.tsx +224 -0
  176. package/tsconfig.tsbuildinfo +1 -1
@@ -11,6 +11,8 @@ import type { ToggleGroupControlContextProps } from './types';
11
11
  const ToggleGroupControlContext = createContext(
12
12
  {} as ToggleGroupControlContextProps
13
13
  );
14
+ ToggleGroupControlContext.displayName = 'ToggleGroupControlContext';
15
+
14
16
  export const useToggleGroupControlContext = () =>
15
17
  useContext( ToggleGroupControlContext );
16
18
  export default ToggleGroupControlContext;
@@ -11,5 +11,6 @@ import { createContext } from '@wordpress/element';
11
11
  const ToolbarContext = createContext< Ariakit.ToolbarStore | undefined >(
12
12
  undefined
13
13
  );
14
+ ToolbarContext.displayName = 'ToolbarContext';
14
15
 
15
16
  export default ToolbarContext;
@@ -22,6 +22,7 @@ export const ToolsPanelContext = createContext< ToolsPanelContextType >( {
22
22
  deregisterResetAllFilter: noop,
23
23
  areAllOptionalControlsHidden: true,
24
24
  } );
25
+ ToolsPanelContext.displayName = 'ToolsPanelContext';
25
26
 
26
27
  export const useToolsPanelContext = () =>
27
28
  useContext< ToolsPanelContextType >( ToolsPanelContext );
@@ -54,7 +54,7 @@ export const Default: StoryFn< typeof ToolsPanel > = ( {
54
54
  const [ height, setHeight ] = useState< string | undefined >();
55
55
  const [ minHeight, setMinHeight ] = useState< string | undefined >();
56
56
  const [ width, setWidth ] = useState< string | undefined >();
57
- const [ scale, setScale ] = useState< React.ReactText | undefined >();
57
+ const [ scale, setScale ] = useState< number | string | undefined >();
58
58
 
59
59
  const resetAll: typeof resetAllProp = ( filters ) => {
60
60
  setHeight( undefined );
@@ -414,7 +414,7 @@ export const WithConditionalDefaultControl: StoryFn< typeof ToolsPanel > = ( {
414
414
  } ) => {
415
415
  const [ attributes, setAttributes ] = useState< {
416
416
  height?: string;
417
- scale?: React.ReactText;
417
+ scale?: number | string;
418
418
  } >( {} );
419
419
  const { height, scale } = attributes;
420
420
 
@@ -512,7 +512,7 @@ export const WithConditionallyRenderedControl: StoryFn<
512
512
  > = ( { resetAll: resetAllProp, panelId, ...props } ) => {
513
513
  const [ attributes, setAttributes ] = useState< {
514
514
  height?: string;
515
- scale?: React.ReactText;
515
+ scale?: number | string;
516
516
  } >( {} );
517
517
  const { height, scale } = attributes;
518
518
 
@@ -30,6 +30,7 @@ import { positionToPlacement } from '../popover/utils';
30
30
  const TooltipInternalContext = createContext< TooltipInternalContextType >( {
31
31
  isNestedInTooltip: false,
32
32
  } );
33
+ TooltipInternalContext.displayName = 'TooltipInternalContext';
33
34
 
34
35
  /**
35
36
  * Time over anchor to wait before showing tooltip
@@ -12,6 +12,8 @@ const RovingTabIndexContext = createContext<
12
12
  }
13
13
  | undefined
14
14
  >( undefined );
15
+ RovingTabIndexContext.displayName = 'RovingTabIndexContext';
16
+
15
17
  export const useRovingTabIndexContext = () =>
16
18
  useContext( RovingTabIndexContext );
17
19
  export const RovingTabIndexProvider = RovingTabIndexContext.Provider;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * External dependencies
3
3
  */
4
- import type { CSSProperties, ReactText } from 'react';
4
+ import type { CSSProperties } from 'react';
5
5
 
6
6
  /**
7
7
  * Internal dependencies
@@ -61,7 +61,7 @@ export function getFontSize(
61
61
  return `calc(${ ratio } * ${ CONFIG.fontSize })`;
62
62
  }
63
63
 
64
- export function getHeadingFontSize( size: ReactText = 3 ): string {
64
+ export function getHeadingFontSize( size: number | string = 3 ): string {
65
65
  if ( ! HEADING_FONT_SIZES.includes( size as HeadingSize ) ) {
66
66
  return getFontSize( size );
67
67
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * External dependencies
3
3
  */
4
- import type { ReactNode, ReactChild, ReactFragment, ReactPortal } from 'react';
4
+ import type { ReactNode, ReactElement, ReactPortal } from 'react';
5
5
 
6
6
  /**
7
7
  * WordPress dependencies
@@ -17,7 +17,9 @@ import { Children, isValidElement } from '@wordpress/element';
17
17
  */
18
18
  export function getValidChildren(
19
19
  children: ReactNode
20
- ): Array< ReactChild | ReactFragment | ReactPortal > {
20
+ ): Array<
21
+ ReactElement | number | string | Iterable< ReactNode > | ReactPortal
22
+ > {
21
23
  if ( typeof children === 'string' ) {
22
24
  return [ children ];
23
25
  }
@@ -1,12 +1,14 @@
1
1
  /**
2
- * WordPress dependencies
2
+ * External dependencies
3
3
  */
4
- import { useRef, useCallback, useState } from '@wordpress/element';
4
+ import type { Meta, StoryObj } from '@storybook/react';
5
+ import { expect, userEvent, waitFor, within } from '@storybook/test';
5
6
 
6
7
  /**
7
- * External dependencies
8
+ * WordPress dependencies
8
9
  */
9
- import type { Meta, StoryObj } from '@storybook/react';
10
+ import { useRef, useCallback, useState } from '@wordpress/element';
11
+ import { debounce } from '@wordpress/compose';
10
12
 
11
13
  /**
12
14
  * Internal dependencies
@@ -14,7 +16,6 @@ import type { Meta, StoryObj } from '@storybook/react';
14
16
  import { ValidatedInputControl } from '..';
15
17
  import { formDecorator } from './story-utils';
16
18
  import type { ControlWithError } from '../../control-with-error';
17
- import { debounce } from '@wordpress/compose';
18
19
 
19
20
  const meta: Meta< typeof ControlWithError > = {
20
21
  title: 'Components/Selection & Input/Validated Form Controls/Overview',
@@ -166,24 +167,19 @@ export const AsyncValidation: StoryObj< typeof ValidatedInputControl > = {
166
167
  } );
167
168
 
168
169
  clearTimeout( timeoutRef.current );
169
- timeoutRef.current = setTimeout(
170
- () => {
171
- if ( v?.toString().toLowerCase() === 'error' ) {
172
- setCustomValidity( {
173
- type: 'invalid',
174
- message: 'The word "error" is not allowed.',
175
- } );
176
- } else {
177
- setCustomValidity( {
178
- type: 'valid',
179
- message: 'Validated',
180
- } );
181
- }
182
- },
183
- // Mimics a random server response time.
184
- // eslint-disable-next-line no-restricted-syntax
185
- Math.random() < 0.5 ? 1500 : 300
186
- );
170
+ timeoutRef.current = setTimeout( () => {
171
+ if ( v?.toString().toLowerCase() === 'error' ) {
172
+ setCustomValidity( {
173
+ type: 'invalid',
174
+ message: 'The word "error" is not allowed.',
175
+ } );
176
+ } else {
177
+ setCustomValidity( {
178
+ type: 'valid',
179
+ message: 'Validated',
180
+ } );
181
+ }
182
+ }, 1500 );
187
183
  }, 500 ),
188
184
  []
189
185
  );
@@ -200,9 +196,95 @@ export const AsyncValidation: StoryObj< typeof ValidatedInputControl > = {
200
196
  />
201
197
  );
202
198
  },
199
+ args: {
200
+ label: 'Text',
201
+ help: 'The word "error" will trigger an error asynchronously.',
202
+ required: true,
203
+ },
203
204
  };
204
- AsyncValidation.args = {
205
- label: 'Text',
206
- help: 'The word "error" will trigger an error asynchronously.',
207
- required: true,
205
+
206
+ // Not exported - Only for testing purposes.
207
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
208
+ const AsyncValidationWithTest: StoryObj< typeof ValidatedInputControl > = {
209
+ ...AsyncValidation,
210
+ play: async ( { canvasElement } ) => {
211
+ const canvas = within( canvasElement );
212
+ await userEvent.click( canvas.getByRole( 'textbox' ) );
213
+ await userEvent.type( canvas.getByRole( 'textbox' ), 'valid text', {
214
+ delay: 10,
215
+ } );
216
+ await userEvent.tab();
217
+
218
+ await waitFor(
219
+ () => {
220
+ expect( canvas.getByText( 'Validated' ) ).toBeVisible();
221
+ },
222
+ { timeout: 2500 }
223
+ );
224
+
225
+ await new Promise( ( resolve ) => setTimeout( resolve, 500 ) );
226
+ await userEvent.clear( canvas.getByRole( 'textbox' ) );
227
+
228
+ // Should show validating state when transitioning from valid to invalid.
229
+ await waitFor(
230
+ () => {
231
+ expect( canvas.getByText( 'Validating...' ) ).toBeVisible();
232
+ },
233
+ { timeout: 2500 }
234
+ );
235
+
236
+ await waitFor(
237
+ () => {
238
+ expect(
239
+ canvas.getByText( 'Please fill out this field.' )
240
+ ).toBeVisible();
241
+ },
242
+ { timeout: 2500 }
243
+ );
244
+
245
+ // Should not show validating state if there were no changes
246
+ // after a valid/invalid state was already shown.
247
+ await new Promise( ( resolve ) => setTimeout( resolve, 1500 ) );
248
+ await expect(
249
+ canvas.queryByText( 'Validating...' )
250
+ ).not.toBeInTheDocument();
251
+
252
+ await userEvent.type( canvas.getByRole( 'textbox' ), 'e', {
253
+ delay: 10,
254
+ } );
255
+
256
+ // Should not show valid state if server has not yet responded.
257
+ await expect(
258
+ canvas.queryByText( 'Validated' )
259
+ ).not.toBeInTheDocument();
260
+
261
+ // Should show validating state when transitioning from invalid to valid.
262
+ await waitFor(
263
+ () => {
264
+ expect( canvas.getByText( 'Validating...' ) ).toBeVisible();
265
+ },
266
+ { timeout: 2500 }
267
+ );
268
+
269
+ await waitFor(
270
+ () => {
271
+ expect( canvas.getByText( 'Validated' ) ).toBeVisible();
272
+ },
273
+ { timeout: 2500 }
274
+ );
275
+
276
+ await new Promise( ( resolve ) => setTimeout( resolve, 1000 ) );
277
+ await userEvent.type( canvas.getByRole( 'textbox' ), 'rror', {
278
+ delay: 10,
279
+ } );
280
+
281
+ await waitFor(
282
+ () => {
283
+ expect(
284
+ canvas.getByText( 'The word "error" is not allowed.' )
285
+ ).toBeVisible();
286
+ },
287
+ { timeout: 2500 }
288
+ );
289
+ },
208
290
  };
@@ -1,11 +1,8 @@
1
1
  /**
2
2
  * WordPress dependencies
3
3
  */
4
+ import { usePrevious } from '@wordpress/compose';
4
5
  import { __ } from '@wordpress/i18n';
5
-
6
- /**
7
- * External dependencies
8
- */
9
6
  import {
10
7
  cloneElement,
11
8
  forwardRef,
@@ -98,6 +95,7 @@ function UnforwardedControlWithError< C extends React.ReactElement >(
98
95
  | undefined
99
96
  >();
100
97
  const [ isTouched, setIsTouched ] = useState( false );
98
+ const previousCustomValidityType = usePrevious( customValidity?.type );
101
99
 
102
100
  // Ensure that error messages are visible after user attemps to submit a form
103
101
  // with multiple invalid fields.
@@ -116,7 +114,7 @@ function UnforwardedControlWithError< C extends React.ReactElement >(
116
114
  };
117
115
  } );
118
116
 
119
- useEffect( () => {
117
+ useEffect( (): ReturnType< React.EffectCallback > => {
120
118
  if ( ! isTouched ) {
121
119
  return;
122
120
  }
@@ -134,6 +132,9 @@ function UnforwardedControlWithError< C extends React.ReactElement >(
134
132
  case 'validating': {
135
133
  // Wait before showing a validating state.
136
134
  const timer = setTimeout( () => {
135
+ validityTarget?.setCustomValidity( '' );
136
+ setErrorMessage( undefined );
137
+
137
138
  setStatusMessage( {
138
139
  type: 'validating',
139
140
  message: customValidity.message,
@@ -143,6 +144,12 @@ function UnforwardedControlWithError< C extends React.ReactElement >(
143
144
  return () => clearTimeout( timer );
144
145
  }
145
146
  case 'valid': {
147
+ // Ensures that we wait for any async responses before showing
148
+ // a synchronously valid state.
149
+ if ( previousCustomValidityType === 'valid' ) {
150
+ break;
151
+ }
152
+
146
153
  validityTarget?.setCustomValidity( '' );
147
154
  setErrorMessage( validityTarget?.validationMessage );
148
155
 
@@ -150,7 +157,7 @@ function UnforwardedControlWithError< C extends React.ReactElement >(
150
157
  type: 'valid',
151
158
  message: customValidity.message,
152
159
  } );
153
- return;
160
+ break;
154
161
  }
155
162
  case 'invalid': {
156
163
  validityTarget?.setCustomValidity(
@@ -159,7 +166,7 @@ function UnforwardedControlWithError< C extends React.ReactElement >(
159
166
  setErrorMessage( validityTarget?.validationMessage );
160
167
 
161
168
  setStatusMessage( undefined );
162
- return undefined;
169
+ break;
163
170
  }
164
171
  }
165
172
  }, [
@@ -167,9 +174,14 @@ function UnforwardedControlWithError< C extends React.ReactElement >(
167
174
  customValidity?.type,
168
175
  customValidity?.message,
169
176
  getValidityTarget,
177
+ previousCustomValidityType,
170
178
  ] );
171
179
 
172
180
  const onBlur = ( event: React.FocusEvent< HTMLDivElement > ) => {
181
+ if ( isTouched ) {
182
+ return;
183
+ }
184
+
173
185
  // Only consider "blurred from the component" if focus has fully left the wrapping div.
174
186
  // This prevents unnecessary blurs from components with multiple focusable elements.
175
187
  if (
@@ -177,17 +189,6 @@ function UnforwardedControlWithError< C extends React.ReactElement >(
177
189
  ! event.currentTarget.contains( event.relatedTarget )
178
190
  ) {
179
191
  setIsTouched( true );
180
-
181
- const validityTarget = getValidityTarget();
182
-
183
- // Prevents a double flash of the native error tooltip when the control is already showing one.
184
- if ( ! validityTarget?.validity.valid ) {
185
- if ( ! errorMessage ) {
186
- setErrorMessage( validityTarget?.validationMessage );
187
- }
188
- return;
189
- }
190
-
191
192
  onValidate?.();
192
193
  }
193
194
  };
@@ -0,0 +1,224 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { render, screen, waitFor, act } from '@testing-library/react';
5
+ import userEvent from '@testing-library/user-event';
6
+
7
+ /**
8
+ * WordPress dependencies
9
+ */
10
+ import { useState, useCallback } from '@wordpress/element';
11
+
12
+ /**
13
+ * Internal dependencies
14
+ */
15
+ import { ValidatedInputControl } from '../components';
16
+
17
+ describe( 'ControlWithError', () => {
18
+ describe( 'Async Validation', () => {
19
+ beforeEach( () => {
20
+ jest.useFakeTimers();
21
+ } );
22
+
23
+ afterEach( () => {
24
+ jest.useRealTimers();
25
+ } );
26
+
27
+ const AsyncValidatedInputControl = ( {
28
+ serverDelayMs,
29
+ ...restProps
30
+ }: {
31
+ serverDelayMs: number;
32
+ } & React.ComponentProps< typeof ValidatedInputControl > ) => {
33
+ const [ text, setText ] = useState( '' );
34
+ const [ customValidity, setCustomValidity ] =
35
+ useState<
36
+ React.ComponentProps<
37
+ typeof ValidatedInputControl
38
+ >[ 'customValidity' ]
39
+ >( undefined );
40
+
41
+ const onValidate = useCallback(
42
+ ( value?: string ) => {
43
+ setCustomValidity( {
44
+ type: 'validating',
45
+ message: 'Validating...',
46
+ } );
47
+
48
+ // Simulate delayed server response
49
+ setTimeout( () => {
50
+ if ( value?.toLowerCase() === 'error' ) {
51
+ setCustomValidity( {
52
+ type: 'invalid',
53
+ message: 'The word "error" is not allowed.',
54
+ } );
55
+ } else {
56
+ setCustomValidity( {
57
+ type: 'valid',
58
+ message: 'Validated',
59
+ } );
60
+ }
61
+ }, serverDelayMs );
62
+ },
63
+ [ serverDelayMs ]
64
+ );
65
+
66
+ return (
67
+ <ValidatedInputControl
68
+ label="Text"
69
+ value={ text }
70
+ onChange={ ( newValue ) => {
71
+ setText( newValue ?? '' );
72
+ } }
73
+ onValidate={ onValidate }
74
+ customValidity={ customValidity }
75
+ { ...restProps }
76
+ />
77
+ );
78
+ };
79
+
80
+ it( 'should not show "validating" state if it takes less than 1000ms', async () => {
81
+ const user = userEvent.setup( {
82
+ advanceTimers: jest.advanceTimersByTime,
83
+ } );
84
+ render( <AsyncValidatedInputControl serverDelayMs={ 500 } /> );
85
+
86
+ const input = screen.getByRole( 'textbox' );
87
+
88
+ await user.type( input, 'valid text' );
89
+
90
+ // Blur to trigger validation
91
+ await user.tab();
92
+
93
+ // Fast-forward to right before the server response
94
+ act( () => jest.advanceTimersByTime( 499 ) );
95
+
96
+ // The validating state should not be shown
97
+ await waitFor( () => {
98
+ expect(
99
+ screen.queryByText( 'Validating...' )
100
+ ).not.toBeInTheDocument();
101
+ } );
102
+
103
+ // Fast-forward past the server delay to show validation result
104
+ act( () => jest.advanceTimersByTime( 1 ) );
105
+
106
+ await waitFor( () => {
107
+ expect( screen.getByText( 'Validated' ) ).toBeVisible();
108
+ } );
109
+ } );
110
+
111
+ it( 'should show "validating" state if it takes more than 1000ms', async () => {
112
+ const user = userEvent.setup( {
113
+ advanceTimers: jest.advanceTimersByTime,
114
+ } );
115
+ render( <AsyncValidatedInputControl serverDelayMs={ 1200 } /> );
116
+
117
+ const input = screen.getByRole( 'textbox' );
118
+
119
+ await user.type( input, 'valid text' );
120
+
121
+ // Blur to trigger validation
122
+ await user.tab();
123
+
124
+ // Initially, no validating message should be shown (before 1s delay)
125
+ expect(
126
+ screen.queryByText( 'Validating...' )
127
+ ).not.toBeInTheDocument();
128
+
129
+ // Fast-forward past the 1s delay to show validating state
130
+ act( () => jest.advanceTimersByTime( 1000 ) );
131
+
132
+ await waitFor( () => {
133
+ expect( screen.getByText( 'Validating...' ) ).toBeVisible();
134
+ } );
135
+
136
+ // Fast-forward past the server delay to show validation result
137
+ act( () => jest.advanceTimersByTime( 200 ) );
138
+
139
+ await waitFor( () => {
140
+ expect( screen.getByText( 'Validated' ) ).toBeVisible();
141
+ } );
142
+
143
+ // Test error case
144
+ await user.clear( input );
145
+ await user.type( input, 'error' );
146
+
147
+ // Blur to trigger validation
148
+ await user.tab();
149
+
150
+ act( () => jest.advanceTimersByTime( 1000 ) );
151
+
152
+ await waitFor( () => {
153
+ expect( screen.getByText( 'Validating...' ) ).toBeVisible();
154
+ } );
155
+
156
+ act( () => jest.advanceTimersByTime( 200 ) );
157
+
158
+ await waitFor( () => {
159
+ expect(
160
+ screen.getByText( 'The word "error" is not allowed.' )
161
+ ).toBeVisible();
162
+ } );
163
+
164
+ // Test editing after error
165
+ await user.type( input, '{backspace}' );
166
+
167
+ act( () => jest.advanceTimersByTime( 1000 ) );
168
+
169
+ await waitFor( () => {
170
+ expect( screen.getByText( 'Validating...' ) ).toBeVisible();
171
+ } );
172
+
173
+ act( () => jest.advanceTimersByTime( 200 ) );
174
+
175
+ await waitFor( () => {
176
+ expect( screen.getByText( 'Validated' ) ).toBeVisible();
177
+ } );
178
+ } );
179
+
180
+ it( 'should not show a "valid" state until the server response is received, even if locally valid', async () => {
181
+ const user = userEvent.setup( {
182
+ advanceTimers: jest.advanceTimersByTime,
183
+ } );
184
+ render(
185
+ <AsyncValidatedInputControl serverDelayMs={ 1200 } required />
186
+ );
187
+
188
+ const input = screen.getByRole( 'textbox' );
189
+
190
+ await user.type( input, 'valid text' );
191
+
192
+ await user.tab();
193
+ act( () => jest.advanceTimersByTime( 1200 ) );
194
+
195
+ await waitFor( () => {
196
+ expect( screen.getByText( 'Validated' ) ).toBeVisible();
197
+ } );
198
+
199
+ await user.clear( input );
200
+
201
+ act( () => jest.advanceTimersByTime( 1000 ) );
202
+
203
+ await waitFor( () => {
204
+ expect(
205
+ screen.getByText( 'Constraints not satisfied' )
206
+ ).toBeVisible();
207
+ } );
208
+
209
+ await user.type( input, 'error' );
210
+
211
+ act( () => jest.advanceTimersByTime( 200 ) );
212
+
213
+ expect( screen.queryByText( 'Validated' ) ).not.toBeInTheDocument();
214
+
215
+ act( () => jest.advanceTimersByTime( 1000 ) );
216
+
217
+ await waitFor( () => {
218
+ expect(
219
+ screen.getByText( 'The word "error" is not allowed.' )
220
+ ).toBeVisible();
221
+ } );
222
+ } );
223
+ } );
224
+ } );