@wordpress/components 32.4.1-next.v.202603102151.0 → 32.5.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 (228) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/build/alignment-matrix-control/cell.cjs +2 -2
  3. package/build/alignment-matrix-control/cell.cjs.map +1 -1
  4. package/build/alignment-matrix-control/index.cjs +2 -2
  5. package/build/alignment-matrix-control/index.cjs.map +1 -1
  6. package/build/angle-picker-control/angle-circle.cjs +2 -2
  7. package/build/angle-picker-control/angle-circle.cjs.map +1 -1
  8. package/build/combobox-control/index.cjs +5 -1
  9. package/build/combobox-control/index.cjs.map +2 -2
  10. package/build/custom-gradient-picker/index.cjs +9 -1
  11. package/build/custom-gradient-picker/index.cjs.map +2 -2
  12. package/build/date-time/time/index.cjs +1 -1
  13. package/build/date-time/time/index.cjs.map +2 -2
  14. package/build/date-time/utils.cjs +9 -0
  15. package/build/date-time/utils.cjs.map +2 -2
  16. package/build/form-token-field/token-input.cjs +2 -1
  17. package/build/form-token-field/token-input.cjs.map +2 -2
  18. package/build/radio-control/index.cjs +1 -0
  19. package/build/radio-control/index.cjs.map +2 -2
  20. package/build/toggle-group-control/toggle-group-control/as-button-group.cjs +1 -0
  21. package/build/toggle-group-control/toggle-group-control/as-button-group.cjs.map +2 -2
  22. package/build/toggle-group-control/toggle-group-control/component.cjs +15 -9
  23. package/build/toggle-group-control/toggle-group-control/component.cjs.map +3 -3
  24. package/build/toggle-group-control/toggle-group-control/styles.cjs +6 -32
  25. package/build/toggle-group-control/toggle-group-control/styles.cjs.map +3 -3
  26. package/build/validated-form-controls/control-with-error.cjs +26 -3
  27. package/build/validated-form-controls/control-with-error.cjs.map +2 -2
  28. package/build/validated-form-controls/validity-indicator.cjs +2 -0
  29. package/build/validated-form-controls/validity-indicator.cjs.map +2 -2
  30. package/build-module/alignment-matrix-control/cell.mjs +2 -2
  31. package/build-module/alignment-matrix-control/cell.mjs.map +1 -1
  32. package/build-module/alignment-matrix-control/index.mjs +2 -2
  33. package/build-module/alignment-matrix-control/index.mjs.map +1 -1
  34. package/build-module/angle-picker-control/angle-circle.mjs +2 -2
  35. package/build-module/angle-picker-control/angle-circle.mjs.map +1 -1
  36. package/build-module/combobox-control/index.mjs +5 -1
  37. package/build-module/combobox-control/index.mjs.map +2 -2
  38. package/build-module/custom-gradient-picker/index.mjs +10 -2
  39. package/build-module/custom-gradient-picker/index.mjs.map +2 -2
  40. package/build-module/date-time/time/index.mjs +2 -2
  41. package/build-module/date-time/time/index.mjs.map +2 -2
  42. package/build-module/date-time/utils.mjs +8 -0
  43. package/build-module/date-time/utils.mjs.map +2 -2
  44. package/build-module/form-token-field/token-input.mjs +2 -1
  45. package/build-module/form-token-field/token-input.mjs.map +2 -2
  46. package/build-module/radio-control/index.mjs +1 -0
  47. package/build-module/radio-control/index.mjs.map +2 -2
  48. package/build-module/toggle-group-control/toggle-group-control/as-button-group.mjs +1 -0
  49. package/build-module/toggle-group-control/toggle-group-control/as-button-group.mjs.map +2 -2
  50. package/build-module/toggle-group-control/toggle-group-control/component.mjs +17 -11
  51. package/build-module/toggle-group-control/toggle-group-control/component.mjs.map +2 -2
  52. package/build-module/toggle-group-control/toggle-group-control/styles.mjs +6 -21
  53. package/build-module/toggle-group-control/toggle-group-control/styles.mjs.map +2 -2
  54. package/build-module/validated-form-controls/control-with-error.mjs +27 -4
  55. package/build-module/validated-form-controls/control-with-error.mjs.map +2 -2
  56. package/build-module/validated-form-controls/validity-indicator.mjs +2 -0
  57. package/build-module/validated-form-controls/validity-indicator.mjs.map +2 -2
  58. package/build-style/style-rtl.css +14 -8
  59. package/build-style/style.css +14 -8
  60. package/build-types/alignment-matrix-control/stories/index.story.d.ts +1 -1
  61. package/build-types/alignment-matrix-control/stories/index.story.d.ts.map +1 -1
  62. package/build-types/angle-picker-control/stories/index.story.d.ts +1 -1
  63. package/build-types/animate/stories/index.story.d.ts +7 -7
  64. package/build-types/animate/stories/index.story.d.ts.map +1 -1
  65. package/build-types/base-control/stories/index.story.d.ts +1 -1
  66. package/build-types/border-box-control/stories/index.story.d.ts +1 -1
  67. package/build-types/border-control/stories/index.story.d.ts +5 -5
  68. package/build-types/box-control/stories/index.story.d.ts +7 -7
  69. package/build-types/box-control/stories/index.story.d.ts.map +1 -1
  70. package/build-types/button/stories/e2e/index.story.d.ts +1 -1
  71. package/build-types/button/stories/e2e/index.story.d.ts.map +1 -1
  72. package/build-types/button/stories/index.story.d.ts +7 -7
  73. package/build-types/button/stories/index.story.d.ts.map +1 -1
  74. package/build-types/circular-option-picker/stories/index.story.d.ts +5 -5
  75. package/build-types/circular-option-picker/stories/index.story.d.ts.map +1 -1
  76. package/build-types/combobox-control/index.d.ts.map +1 -1
  77. package/build-types/combobox-control/stories/index.story.d.ts +4 -4
  78. package/build-types/combobox-control/stories/index.story.d.ts.map +1 -1
  79. package/build-types/confirm-dialog/stories/index.story.d.ts +2 -2
  80. package/build-types/confirm-dialog/stories/index.story.d.ts.map +1 -1
  81. package/build-types/custom-gradient-picker/index.d.ts.map +1 -1
  82. package/build-types/custom-gradient-picker/stories/index.story.d.ts +1 -1
  83. package/build-types/custom-gradient-picker/stories/index.story.d.ts.map +1 -1
  84. package/build-types/custom-gradient-picker/test/index.d.ts +2 -0
  85. package/build-types/custom-gradient-picker/test/index.d.ts.map +1 -0
  86. package/build-types/custom-select-control/stories/index.story.d.ts +3 -3
  87. package/build-types/custom-select-control/stories/index.story.d.ts.map +1 -1
  88. package/build-types/custom-select-control-v2/stories/index.story.d.ts +3 -3
  89. package/build-types/date-time/stories/time.story.d.ts +1 -1
  90. package/build-types/date-time/stories/time.story.d.ts.map +1 -1
  91. package/build-types/date-time/time/index.d.ts.map +1 -1
  92. package/build-types/date-time/utils.d.ts +9 -0
  93. package/build-types/date-time/utils.d.ts.map +1 -1
  94. package/build-types/drop-zone/stories/index.story.d.ts +1 -1
  95. package/build-types/drop-zone/stories/index.story.d.ts.map +1 -1
  96. package/build-types/duotone-picker/stories/duotone-picker.story.d.ts +1 -1
  97. package/build-types/duotone-picker/stories/duotone-picker.story.d.ts.map +1 -1
  98. package/build-types/duotone-picker/stories/duotone-swatch.story.d.ts +3 -3
  99. package/build-types/duotone-picker/stories/duotone-swatch.story.d.ts.map +1 -1
  100. package/build-types/focal-point-picker/stories/index.story.d.ts +4 -4
  101. package/build-types/form-file-upload/stories/index.story.d.ts +5 -5
  102. package/build-types/form-file-upload/stories/index.story.d.ts.map +1 -1
  103. package/build-types/form-token-field/token-input.d.ts.map +1 -1
  104. package/build-types/guide/stories/index.story.d.ts +1 -1
  105. package/build-types/guide/stories/index.story.d.ts.map +1 -1
  106. package/build-types/icon/stories/index.story.d.ts +4 -4
  107. package/build-types/icon/stories/index.story.d.ts.map +1 -1
  108. package/build-types/input-control/stories/index.story.d.ts +7 -7
  109. package/build-types/input-control/stories/index.story.d.ts.map +1 -1
  110. package/build-types/keyboard-shortcuts/stories/index.story.d.ts +1 -1
  111. package/build-types/keyboard-shortcuts/stories/index.story.d.ts.map +1 -1
  112. package/build-types/menu-group/stories/index.story.d.ts +1 -1
  113. package/build-types/menu-group/stories/index.story.d.ts.map +1 -1
  114. package/build-types/menu-item/stories/index.story.d.ts +4 -4
  115. package/build-types/navigation/stories/index.story.d.ts +6 -6
  116. package/build-types/navigation/stories/index.story.d.ts.map +1 -1
  117. package/build-types/notice/stories/index.story.d.ts +5 -5
  118. package/build-types/notice/stories/index.story.d.ts.map +1 -1
  119. package/build-types/number-control/stories/index.story.d.ts +1 -1
  120. package/build-types/palette-edit/stories/index.story.d.ts +2 -2
  121. package/build-types/palette-edit/stories/index.story.d.ts.map +1 -1
  122. package/build-types/progress-bar/stories/index.story.d.ts +1 -1
  123. package/build-types/progress-bar/stories/index.story.d.ts.map +1 -1
  124. package/build-types/query-controls/stories/index.story.d.ts +1 -1
  125. package/build-types/query-controls/stories/index.story.d.ts.map +1 -1
  126. package/build-types/radio-control/index.d.ts.map +1 -1
  127. package/build-types/resizable-box/stories/index.story.d.ts +2 -2
  128. package/build-types/responsive-wrapper/stories/index.story.d.ts +1 -1
  129. package/build-types/responsive-wrapper/stories/index.story.d.ts.map +1 -1
  130. package/build-types/sandbox/stories/index.story.d.ts +1 -1
  131. package/build-types/sandbox/stories/index.story.d.ts.map +1 -1
  132. package/build-types/search-control/stories/index.story.d.ts +1 -1
  133. package/build-types/select-control/stories/index.story.d.ts +5 -5
  134. package/build-types/shortcut/stories/index.story.d.ts +1 -1
  135. package/build-types/shortcut/stories/index.story.d.ts.map +1 -1
  136. package/build-types/tab-panel/stories/index.story.d.ts +4 -4
  137. package/build-types/tab-panel/stories/index.story.d.ts.map +1 -1
  138. package/build-types/tabs/stories/index.story.d.ts +7 -7
  139. package/build-types/tabs/stories/index.story.d.ts.map +1 -1
  140. package/build-types/text/stories/index.story.d.ts +3 -3
  141. package/build-types/theme/stories/index.story.d.ts +1 -1
  142. package/build-types/toggle-control/stories/index.story.d.ts +2 -2
  143. package/build-types/toggle-group-control/stories/index.story.d.ts.map +1 -1
  144. package/build-types/toggle-group-control/toggle-group-control/as-button-group.d.ts.map +1 -1
  145. package/build-types/toggle-group-control/toggle-group-control/component.d.ts.map +1 -1
  146. package/build-types/toggle-group-control/toggle-group-control/styles.d.ts +0 -4
  147. package/build-types/toggle-group-control/toggle-group-control/styles.d.ts.map +1 -1
  148. package/build-types/toolbar/stories/index.story.d.ts +3 -3
  149. package/build-types/toolbar/stories/index.story.d.ts.map +1 -1
  150. package/build-types/tooltip/stories/index.story.d.ts +1 -1
  151. package/build-types/tooltip/stories/index.story.d.ts.map +1 -1
  152. package/build-types/tree-grid/stories/index.story.d.ts +1 -1
  153. package/build-types/tree-grid/stories/index.story.d.ts.map +1 -1
  154. package/build-types/tree-select/stories/index.story.d.ts +1 -1
  155. package/build-types/tree-select/stories/index.story.d.ts.map +1 -1
  156. package/build-types/v-stack/stories/index.story.d.ts +1 -1
  157. package/build-types/validated-form-controls/control-with-error.d.ts.map +1 -1
  158. package/build-types/validated-form-controls/test/checkbox-control.d.ts +2 -0
  159. package/build-types/validated-form-controls/test/checkbox-control.d.ts.map +1 -0
  160. package/build-types/validated-form-controls/test/combobox-control.d.ts +2 -0
  161. package/build-types/validated-form-controls/test/combobox-control.d.ts.map +1 -0
  162. package/build-types/validated-form-controls/test/custom-select-control.d.ts +2 -0
  163. package/build-types/validated-form-controls/test/custom-select-control.d.ts.map +1 -0
  164. package/build-types/validated-form-controls/test/form-token-field.d.ts +2 -0
  165. package/build-types/validated-form-controls/test/form-token-field.d.ts.map +1 -0
  166. package/build-types/validated-form-controls/test/input-control.d.ts +2 -0
  167. package/build-types/validated-form-controls/test/input-control.d.ts.map +1 -0
  168. package/build-types/validated-form-controls/test/number-control.d.ts +2 -0
  169. package/build-types/validated-form-controls/test/number-control.d.ts.map +1 -0
  170. package/build-types/validated-form-controls/test/radio-control.d.ts +2 -0
  171. package/build-types/validated-form-controls/test/radio-control.d.ts.map +1 -0
  172. package/build-types/validated-form-controls/test/range-control.d.ts +2 -0
  173. package/build-types/validated-form-controls/test/range-control.d.ts.map +1 -0
  174. package/build-types/validated-form-controls/test/select-control.d.ts +2 -0
  175. package/build-types/validated-form-controls/test/select-control.d.ts.map +1 -0
  176. package/build-types/validated-form-controls/test/text-control.d.ts +2 -0
  177. package/build-types/validated-form-controls/test/text-control.d.ts.map +1 -0
  178. package/build-types/validated-form-controls/test/textarea-control.d.ts +2 -0
  179. package/build-types/validated-form-controls/test/textarea-control.d.ts.map +1 -0
  180. package/build-types/validated-form-controls/test/toggle-control.d.ts +2 -0
  181. package/build-types/validated-form-controls/test/toggle-control.d.ts.map +1 -0
  182. package/build-types/validated-form-controls/test/toggle-group-control.d.ts +2 -0
  183. package/build-types/validated-form-controls/test/toggle-group-control.d.ts.map +1 -0
  184. package/build-types/validated-form-controls/validity-indicator.d.ts +2 -1
  185. package/build-types/validated-form-controls/validity-indicator.d.ts.map +1 -1
  186. package/package.json +24 -24
  187. package/src/button/style.scss +16 -5
  188. package/src/button-group/stories/index.story.tsx +1 -1
  189. package/src/combobox-control/index.tsx +6 -0
  190. package/src/combobox-control/stories/index.story.tsx +3 -2
  191. package/src/combobox-control/test/index.tsx +16 -9
  192. package/src/composite/legacy/stories/index.story.tsx +1 -1
  193. package/src/custom-gradient-picker/index.tsx +15 -4
  194. package/src/custom-gradient-picker/test/index.tsx +81 -0
  195. package/src/date-time/test/utils.test.ts +8 -11
  196. package/src/date-time/time/index.tsx +2 -12
  197. package/src/date-time/time/test/index.tsx +69 -0
  198. package/src/date-time/utils.ts +18 -0
  199. package/src/form-token-field/token-input.tsx +7 -1
  200. package/src/guide/style.scss +3 -0
  201. package/src/modal/style.scss +4 -7
  202. package/src/navigation/stories/index.story.tsx +1 -1
  203. package/src/radio-control/index.tsx +1 -0
  204. package/src/radio-control/test/index.tsx +5 -5
  205. package/src/radio-group/stories/index.story.tsx +1 -1
  206. package/src/snackbar/style.scss +1 -1
  207. package/src/toggle-group-control/stories/index.story.tsx +1 -0
  208. package/src/toggle-group-control/test/__snapshots__/index.tsx.snap +124 -164
  209. package/src/toggle-group-control/test/index.tsx +54 -0
  210. package/src/toggle-group-control/toggle-group-control/as-button-group.tsx +1 -0
  211. package/src/toggle-group-control/toggle-group-control/component.tsx +13 -8
  212. package/src/toggle-group-control/toggle-group-control/styles.ts +0 -6
  213. package/src/validated-form-controls/control-with-error.tsx +44 -4
  214. package/src/validated-form-controls/test/checkbox-control.tsx +49 -0
  215. package/src/validated-form-controls/test/combobox-control.tsx +61 -0
  216. package/src/validated-form-controls/test/control-with-error.tsx +182 -1
  217. package/src/validated-form-controls/test/custom-select-control.tsx +60 -0
  218. package/src/validated-form-controls/test/form-token-field.tsx +52 -0
  219. package/src/validated-form-controls/test/input-control.tsx +42 -0
  220. package/src/validated-form-controls/test/number-control.tsx +44 -0
  221. package/src/validated-form-controls/test/radio-control.tsx +61 -0
  222. package/src/validated-form-controls/test/range-control.tsx +73 -0
  223. package/src/validated-form-controls/test/select-control.tsx +57 -0
  224. package/src/validated-form-controls/test/text-control.tsx +49 -0
  225. package/src/validated-form-controls/test/textarea-control.tsx +51 -0
  226. package/src/validated-form-controls/test/toggle-control.tsx +49 -0
  227. package/src/validated-form-controls/test/toggle-group-control.tsx +28 -0
  228. package/src/validated-form-controls/validity-indicator.tsx +3 -0
@@ -353,6 +353,60 @@ describe.each( [
353
353
  );
354
354
  }
355
355
 
356
+ it( 'should render the label', () => {
357
+ render(
358
+ <Component label="Test Toggle Group Control">{ options }</Component>
359
+ );
360
+
361
+ expect( screen.getByText( 'Test Toggle Group Control' ) ).toBeVisible();
362
+ } );
363
+
364
+ it( 'should still label the control accessibly when hideLabelFromVision is true', () => {
365
+ render(
366
+ <Component label="Test Toggle Group Control" hideLabelFromVision>
367
+ { options }
368
+ </Component>
369
+ );
370
+
371
+ expect(
372
+ screen.getByRole( 'radiogroup', {
373
+ name: 'Test Toggle Group Control',
374
+ } )
375
+ ).toBeVisible();
376
+ } );
377
+
378
+ it( 'should accessibly associate the help text', () => {
379
+ render(
380
+ <Component label="Test Toggle Group Control" help="Help text">
381
+ { options }
382
+ </Component>
383
+ );
384
+
385
+ expect(
386
+ screen.getByRole( 'radiogroup', {
387
+ description: 'Help text',
388
+ } )
389
+ ).toBeVisible();
390
+ } );
391
+
392
+ it( 'should accessibly associate the help text when isDeselectable', () => {
393
+ render(
394
+ <Component
395
+ label="Test Toggle Group Control"
396
+ help="Help text"
397
+ isDeselectable
398
+ >
399
+ { options }
400
+ </Component>
401
+ );
402
+
403
+ expect(
404
+ screen.getByRole( 'group', {
405
+ description: 'Help text',
406
+ } )
407
+ ).toBeVisible();
408
+ } );
409
+
356
410
  describe( 'isDeselectable', () => {
357
411
  describe( 'isDeselectable = false', () => {
358
412
  it( 'should not be deselectable', async () => {
@@ -83,6 +83,7 @@ function UnforwardedToggleGroupControlAsButtonGroup(
83
83
  { ...otherProps }
84
84
  ref={ forwardedRef }
85
85
  role="group"
86
+ id={ baseId }
86
87
  >
87
88
  { children }
88
89
  </View>
@@ -15,9 +15,8 @@ import { useMergeRefs } from '@wordpress/compose';
15
15
  import type { WordPressComponentProps } from '../../context';
16
16
  import { contextConnect, useContextSystem } from '../../context';
17
17
  import { useCx } from '../../utils/hooks';
18
- import BaseControl from '../../base-control';
18
+ import BaseControl, { useBaseControlProps } from '../../base-control';
19
19
  import type { ToggleGroupControlProps } from '../types';
20
- import { VisualLabelWrapper } from './styles';
21
20
  import * as styles from './styles';
22
21
  import { ToggleGroupControlAsRadioGroup } from './as-radio-group';
23
22
  import { ToggleGroupControlAsButtonGroup } from './as-button-group';
@@ -37,6 +36,7 @@ function UnconnectedToggleGroupControl(
37
36
  isAdaptiveWidth = false,
38
37
  isBlock = false,
39
38
  isDeselectable = false,
39
+ id,
40
40
  label,
41
41
  hideLabelFromVision = false,
42
42
  help,
@@ -47,6 +47,13 @@ function UnconnectedToggleGroupControl(
47
47
  ...otherProps
48
48
  } = useContextSystem( props, 'ToggleGroupControl' );
49
49
 
50
+ const { baseControlProps, controlProps } = useBaseControlProps( {
51
+ id,
52
+ help,
53
+ label,
54
+ hideLabelFromVision,
55
+ } );
56
+
50
57
  const normalizedSize =
51
58
  __next40pxDefaultSize && size === 'default' ? '__unstable-large' : size;
52
59
 
@@ -91,17 +98,15 @@ function UnconnectedToggleGroupControl(
91
98
  } );
92
99
 
93
100
  return (
94
- <BaseControl help={ help }>
95
- { ! hideLabelFromVision && (
96
- <VisualLabelWrapper>
97
- <BaseControl.VisualLabel>{ label }</BaseControl.VisualLabel>
98
- </VisualLabelWrapper>
99
- ) }
101
+ <BaseControl { ...baseControlProps }>
100
102
  <MainControl
101
103
  { ...otherProps }
104
+ { ...controlProps }
102
105
  setSelectedElement={ setSelectedElement }
103
106
  className={ classes }
104
107
  isAdaptiveWidth={ isAdaptiveWidth }
108
+ // `label` is used for `aria-label` on the inner control.
109
+ // This is separate from the visual label rendered by `BaseControl`.
105
110
  label={ label }
106
111
  onChange={ onChange }
107
112
  ref={ refs }
@@ -2,7 +2,6 @@
2
2
  * External dependencies
3
3
  */
4
4
  import { css } from '@emotion/react';
5
- import styled from '@emotion/styled';
6
5
 
7
6
  /**
8
7
  * Internal dependencies
@@ -100,8 +99,3 @@ export const block = css`
100
99
  display: flex;
101
100
  width: 100%;
102
101
  `;
103
-
104
- export const VisualLabelWrapper = styled.div`
105
- // Makes the inline label be the correct height, equivalent to setting line-height: 0
106
- display: flex;
107
- `;
@@ -6,6 +6,7 @@ import {
6
6
  cloneElement,
7
7
  forwardRef,
8
8
  useEffect,
9
+ useId,
9
10
  useState,
10
11
  } from '@wordpress/element';
11
12
 
@@ -238,22 +239,61 @@ function UnforwardedControlWithError< C extends React.ReactElement >(
238
239
  }
239
240
  };
240
241
 
241
- const message = () => {
242
+ const messageId = useId();
243
+
244
+ const message = ( () => {
242
245
  if ( errorMessage ) {
243
246
  return (
244
- <ValidityIndicator type="invalid" message={ errorMessage } />
247
+ <ValidityIndicator
248
+ id={ messageId }
249
+ type="invalid"
250
+ message={ errorMessage }
251
+ />
245
252
  );
246
253
  }
247
254
  if ( statusMessage?.type ) {
248
255
  return (
249
256
  <ValidityIndicator
257
+ id={ messageId }
250
258
  type={ statusMessage.type }
251
259
  message={ statusMessage.message }
252
260
  />
253
261
  );
254
262
  }
255
263
  return null;
256
- };
264
+ } )();
265
+
266
+ const visibleMessage = showMessage ? message : null;
267
+
268
+ // Imperatively manage `aria-describedby` on the validity target so we
269
+ // merge with any value the child control sets internally (e.g. from a
270
+ // `help` prop), rather than competing with it at the props level.
271
+ useEffect( () => {
272
+ const target = getValidityTarget();
273
+ if ( ! target ) {
274
+ return;
275
+ }
276
+
277
+ function setDescribedBy( el: Element, shouldAdd: boolean ) {
278
+ const ids = ( el.getAttribute( 'aria-describedby' ) ?? '' )
279
+ .split( ' ' )
280
+ .filter( ( id ) => id && id !== messageId );
281
+
282
+ if ( shouldAdd ) {
283
+ ids.push( messageId );
284
+ }
285
+
286
+ if ( ids.length ) {
287
+ el.setAttribute( 'aria-describedby', ids.join( ' ' ) );
288
+ } else {
289
+ el.removeAttribute( 'aria-describedby' );
290
+ }
291
+ }
292
+
293
+ setDescribedBy( target, !! visibleMessage );
294
+
295
+ return () => setDescribedBy( target, false );
296
+ }, [ visibleMessage, messageId, getValidityTarget ] );
257
297
 
258
298
  return (
259
299
  <div className={ className } ref={ forwardedRef } onBlur={ onBlur }>
@@ -265,7 +305,7 @@ function UnforwardedControlWithError< C extends React.ReactElement >(
265
305
  ),
266
306
  required,
267
307
  } ) }
268
- <div aria-live="polite">{ showMessage && message() }</div>
308
+ <div aria-live="polite">{ visibleMessage }</div>
269
309
  </div>
270
310
  );
271
311
  }
@@ -0,0 +1,49 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { ValidatedCheckboxControl } from '../components';
4
+
5
+ describe( 'ValidatedCheckboxControl', () => {
6
+ it( 'should preserve the help description', () => {
7
+ render(
8
+ <ValidatedCheckboxControl
9
+ label="Agree"
10
+ help="You must agree to continue."
11
+ onChange={ () => {} }
12
+ />
13
+ );
14
+
15
+ expect(
16
+ screen.getByRole( 'checkbox', { name: 'Agree' } )
17
+ ).toHaveAccessibleDescription( 'You must agree to continue.' );
18
+ } );
19
+
20
+ it( 'should append the validation error alongside the help description', async () => {
21
+ const user = userEvent.setup();
22
+ render(
23
+ <form>
24
+ <ValidatedCheckboxControl
25
+ label="Agree"
26
+ help="You must agree to continue."
27
+ onChange={ () => {} }
28
+ required
29
+ />
30
+ <button type="submit">Submit</button>
31
+ </form>
32
+ );
33
+
34
+ const checkbox = screen.getByRole( 'checkbox', {
35
+ name: /^Agree/,
36
+ } );
37
+
38
+ await user.click( screen.getByRole( 'button', { name: 'Submit' } ) );
39
+
40
+ await waitFor( () => {
41
+ expect( checkbox ).toHaveAccessibleDescription(
42
+ expect.stringContaining( 'Constraints not satisfied' )
43
+ );
44
+ } );
45
+ expect( checkbox ).toHaveAccessibleDescription(
46
+ expect.stringContaining( 'You must agree to continue.' )
47
+ );
48
+ } );
49
+ } );
@@ -0,0 +1,61 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { ValidatedComboboxControl } from '../components';
4
+
5
+ // The `help` prop is rendered visually by BaseControl but is not
6
+ // programmatically associated with the combobox input via aria-describedby.
7
+ // This is a pre-existing bug in ComboboxControl, not caused by ControlWithError.
8
+ describe( 'ValidatedComboboxControl', () => {
9
+ const options = [
10
+ { label: 'Apple', value: 'apple' },
11
+ { label: 'Banana', value: 'banana' },
12
+ ];
13
+
14
+ // eslint-disable-next-line jest/no-disabled-tests
15
+ it.skip( 'should preserve the help description', () => {
16
+ render(
17
+ <ValidatedComboboxControl
18
+ label="Fruit"
19
+ help="Pick a fruit."
20
+ options={ options }
21
+ onChange={ () => {} }
22
+ />
23
+ );
24
+
25
+ expect(
26
+ screen.getByRole( 'combobox', { name: 'Fruit' } )
27
+ ).toHaveAccessibleDescription( 'Pick a fruit.' );
28
+ } );
29
+
30
+ // eslint-disable-next-line jest/no-disabled-tests
31
+ it.skip( 'should append the validation error alongside the help description', async () => {
32
+ const user = userEvent.setup();
33
+ render(
34
+ <form>
35
+ <ValidatedComboboxControl
36
+ label="Fruit"
37
+ help="Pick a fruit."
38
+ options={ options }
39
+ onChange={ () => {} }
40
+ required
41
+ />
42
+ <button type="submit">Submit</button>
43
+ </form>
44
+ );
45
+
46
+ const combobox = screen.getByRole( 'combobox', {
47
+ name: /^Fruit/,
48
+ } );
49
+
50
+ await user.click( screen.getByRole( 'button', { name: 'Submit' } ) );
51
+
52
+ await waitFor( () => {
53
+ expect( combobox ).toHaveAccessibleDescription(
54
+ expect.stringContaining( 'Constraints not satisfied' )
55
+ );
56
+ } );
57
+ expect( combobox ).toHaveAccessibleDescription(
58
+ expect.stringContaining( 'Pick a fruit.' )
59
+ );
60
+ } );
61
+ } );
@@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
7
7
  /**
8
8
  * WordPress dependencies
9
9
  */
10
- import { useState, useCallback, useRef } from '@wordpress/element';
10
+ import { useState, useCallback, useId, useRef } from '@wordpress/element';
11
11
 
12
12
  /**
13
13
  * Internal dependencies
@@ -283,6 +283,187 @@ describe( 'ControlWithError', () => {
283
283
  } );
284
284
  } );
285
285
 
286
+ describe( 'aria-describedby', () => {
287
+ it( 'should connect the error message to the input via aria-describedby', async () => {
288
+ const user = userEvent.setup();
289
+ render(
290
+ <form>
291
+ <ValidatedInputControl label="URL" required />
292
+ <button type="submit">Submit</button>
293
+ </form>
294
+ );
295
+
296
+ const input = screen.getByRole( 'textbox', { name: /^URL/ } );
297
+
298
+ expect( input ).not.toHaveAttribute( 'aria-describedby' );
299
+
300
+ await user.click(
301
+ screen.getByRole( 'button', { name: 'Submit' } )
302
+ );
303
+
304
+ await waitFor( () => {
305
+ expect( input ).toHaveAccessibleDescription(
306
+ expect.stringContaining( 'Constraints not satisfied' )
307
+ );
308
+ } );
309
+ } );
310
+
311
+ it( 'should preserve existing aria-describedby values', async () => {
312
+ const user = userEvent.setup();
313
+
314
+ function TestComponent() {
315
+ const hintId = useId();
316
+ return (
317
+ <form>
318
+ <ValidatedInputControl
319
+ label="URL"
320
+ required
321
+ aria-describedby={ hintId }
322
+ />
323
+ <p id={ hintId }>Enter a full URL.</p>
324
+ <button type="submit">Submit</button>
325
+ </form>
326
+ );
327
+ }
328
+
329
+ render( <TestComponent /> );
330
+
331
+ const input = screen.getByRole( 'textbox', { name: /^URL/ } );
332
+
333
+ expect( input ).toHaveAccessibleDescription( 'Enter a full URL.' );
334
+
335
+ await user.click(
336
+ screen.getByRole( 'button', { name: 'Submit' } )
337
+ );
338
+
339
+ await waitFor( () => {
340
+ expect( input ).toHaveAccessibleDescription(
341
+ expect.stringContaining( 'Constraints not satisfied' )
342
+ );
343
+ } );
344
+ expect( input ).toHaveAccessibleDescription(
345
+ expect.stringContaining( 'Enter a full URL.' )
346
+ );
347
+ } );
348
+
349
+ it( 'should connect a custom validity error to the input via aria-describedby', async () => {
350
+ const user = userEvent.setup();
351
+
352
+ function TestComponent() {
353
+ const [ customValidity, setCustomValidity ] =
354
+ useState<
355
+ React.ComponentProps<
356
+ typeof ValidatedInputControl
357
+ >[ 'customValidity' ]
358
+ >( undefined );
359
+ const inputRef = useRef< HTMLInputElement >( null );
360
+
361
+ return (
362
+ <>
363
+ <ValidatedInputControl
364
+ ref={ inputRef }
365
+ label="URL"
366
+ customValidity={ customValidity }
367
+ />
368
+ <button
369
+ type="button"
370
+ onClick={ () => {
371
+ setCustomValidity( {
372
+ type: 'invalid',
373
+ message: 'Please enter a valid URL.',
374
+ } );
375
+ requestAnimationFrame(
376
+ () => inputRef.current?.reportValidity()
377
+ );
378
+ } }
379
+ >
380
+ Validate
381
+ </button>
382
+ </>
383
+ );
384
+ }
385
+
386
+ render( <TestComponent /> );
387
+
388
+ const input = screen.getByRole( 'textbox', { name: 'URL' } );
389
+ expect( input ).not.toHaveAttribute( 'aria-describedby' );
390
+
391
+ await user.click(
392
+ screen.getByRole( 'button', { name: 'Validate' } )
393
+ );
394
+
395
+ await waitFor( () => {
396
+ expect( input ).toHaveAccessibleDescription(
397
+ expect.stringContaining( 'Please enter a valid URL.' )
398
+ );
399
+ } );
400
+ } );
401
+
402
+ it( 'should remove aria-describedby when the error is resolved', async () => {
403
+ const user = userEvent.setup();
404
+
405
+ function TestComponent() {
406
+ const [ customValidity, setCustomValidity ] =
407
+ useState<
408
+ React.ComponentProps<
409
+ typeof ValidatedInputControl
410
+ >[ 'customValidity' ]
411
+ >( undefined );
412
+ const inputRef = useRef< HTMLInputElement >( null );
413
+
414
+ return (
415
+ <>
416
+ <ValidatedInputControl
417
+ ref={ inputRef }
418
+ label="URL"
419
+ customValidity={ customValidity }
420
+ />
421
+ <button
422
+ type="button"
423
+ onClick={ () => {
424
+ setCustomValidity( {
425
+ type: 'invalid',
426
+ message: 'Please enter a valid URL.',
427
+ } );
428
+ requestAnimationFrame(
429
+ () => inputRef.current?.reportValidity()
430
+ );
431
+ } }
432
+ >
433
+ Validate
434
+ </button>
435
+ <button
436
+ type="button"
437
+ onClick={ () => setCustomValidity( undefined ) }
438
+ >
439
+ Clear
440
+ </button>
441
+ </>
442
+ );
443
+ }
444
+
445
+ render( <TestComponent /> );
446
+
447
+ const input = screen.getByRole( 'textbox', { name: 'URL' } );
448
+
449
+ await user.click(
450
+ screen.getByRole( 'button', { name: 'Validate' } )
451
+ );
452
+
453
+ await waitFor( () => {
454
+ expect( input ).toHaveAccessibleDescription(
455
+ expect.stringContaining( 'Please enter a valid URL.' )
456
+ );
457
+ } );
458
+
459
+ await user.click( screen.getByRole( 'button', { name: 'Clear' } ) );
460
+
461
+ await waitFor( () => {
462
+ expect( input ).not.toHaveAttribute( 'aria-describedby' );
463
+ } );
464
+ } );
465
+ } );
466
+
286
467
  describe( 'Focus behavior', () => {
287
468
  it( 'should focus the first error in the form', async () => {
288
469
  const user = userEvent.setup();
@@ -0,0 +1,60 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { ValidatedCustomSelectControl } from '../components';
4
+
5
+ describe( 'ValidatedCustomSelectControl', () => {
6
+ const options = [
7
+ { key: 'small', name: 'Small' },
8
+ { key: 'large', name: 'Large' },
9
+ ];
10
+
11
+ it( 'should preserve the built-in "Currently selected" description', async () => {
12
+ render(
13
+ <ValidatedCustomSelectControl
14
+ label="Font Size"
15
+ options={ options }
16
+ value={ options[ 0 ] }
17
+ onChange={ () => {} }
18
+ />
19
+ );
20
+
21
+ await waitFor( () => {
22
+ expect(
23
+ screen.getByRole( 'combobox', { name: 'Font Size' } )
24
+ ).toHaveAccessibleDescription( 'Currently selected: Small' );
25
+ } );
26
+ } );
27
+
28
+ it( 'should preserve the built-in description when validation is active', async () => {
29
+ const user = userEvent.setup();
30
+ render(
31
+ <form onSubmit={ ( e ) => e.preventDefault() }>
32
+ <ValidatedCustomSelectControl
33
+ label="Font Size"
34
+ options={ options }
35
+ value={ options[ 0 ] }
36
+ onChange={ () => {} }
37
+ required
38
+ />
39
+ <button type="submit">Submit</button>
40
+ </form>
41
+ );
42
+
43
+ const combobox = await waitFor( () => {
44
+ return screen.getByRole( 'combobox', {
45
+ name: /^Font Size/,
46
+ } );
47
+ } );
48
+
49
+ await user.click( screen.getByRole( 'button', { name: 'Submit' } ) );
50
+
51
+ // The validation error targets the hidden delegate <select>, not
52
+ // the interactive combobox. The combobox's built-in description
53
+ // should be unaffected.
54
+ await waitFor( () => {
55
+ expect( combobox ).toHaveAccessibleDescription(
56
+ 'Currently selected: Small'
57
+ );
58
+ } );
59
+ } );
60
+ } );
@@ -0,0 +1,52 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { ValidatedFormTokenField } from '../components';
4
+
5
+ describe( 'ValidatedFormTokenField', () => {
6
+ it( 'should preserve the built-in howto description', () => {
7
+ render(
8
+ <ValidatedFormTokenField
9
+ label="Tags"
10
+ value={ [] }
11
+ onChange={ () => {} }
12
+ />
13
+ );
14
+
15
+ expect(
16
+ screen.getByRole( 'combobox', { name: 'Tags' } )
17
+ ).toHaveAccessibleDescription(
18
+ expect.stringContaining( 'Separate with commas or the Enter key.' )
19
+ );
20
+ } );
21
+
22
+ it( 'should preserve the built-in howto description when validation is active', async () => {
23
+ const user = userEvent.setup();
24
+ render(
25
+ <form>
26
+ <ValidatedFormTokenField
27
+ label="Tags"
28
+ value={ [] }
29
+ onChange={ () => {} }
30
+ required
31
+ />
32
+ <button type="submit">Submit</button>
33
+ </form>
34
+ );
35
+
36
+ const input = screen.getByRole( 'combobox', { name: /^Tags/ } );
37
+
38
+ await user.click( screen.getByRole( 'button', { name: 'Submit' } ) );
39
+
40
+ // The validation error targets the hidden delegate input, not the
41
+ // interactive combobox. The combobox's built-in description should
42
+ // be unaffected.
43
+ await waitFor( () => {
44
+ expect(
45
+ screen.getByText( 'Constraints not satisfied' )
46
+ ).toBeVisible();
47
+ } );
48
+ expect( input ).toHaveAccessibleDescription(
49
+ expect.stringContaining( 'Separate with commas or the Enter key.' )
50
+ );
51
+ } );
52
+ } );
@@ -0,0 +1,42 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { ValidatedInputControl } from '../components';
4
+
5
+ describe( 'ValidatedInputControl', () => {
6
+ it( 'should preserve the help description', () => {
7
+ render(
8
+ <ValidatedInputControl label="URL" help="Enter a full URL." />
9
+ );
10
+
11
+ expect(
12
+ screen.getByRole( 'textbox', { name: 'URL' } )
13
+ ).toHaveAccessibleDescription( 'Enter a full URL.' );
14
+ } );
15
+
16
+ it( 'should append the validation error alongside the help description', async () => {
17
+ const user = userEvent.setup();
18
+ render(
19
+ <form>
20
+ <ValidatedInputControl
21
+ label="URL"
22
+ help="Enter a full URL."
23
+ required
24
+ />
25
+ <button type="submit">Submit</button>
26
+ </form>
27
+ );
28
+
29
+ const input = screen.getByRole( 'textbox', { name: /^URL/ } );
30
+
31
+ await user.click( screen.getByRole( 'button', { name: 'Submit' } ) );
32
+
33
+ await waitFor( () => {
34
+ expect( input ).toHaveAccessibleDescription(
35
+ expect.stringContaining( 'Constraints not satisfied' )
36
+ );
37
+ } );
38
+ expect( input ).toHaveAccessibleDescription(
39
+ expect.stringContaining( 'Enter a full URL.' )
40
+ );
41
+ } );
42
+ } );