@wordpress/dataviews 11.2.1-next.v.0 → 11.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 (161) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/build/components/dataform-controls/combobox.cjs +80 -0
  3. package/build/components/dataform-controls/combobox.cjs.map +7 -0
  4. package/build/components/dataform-controls/date.cjs +35 -10
  5. package/build/components/dataform-controls/date.cjs.map +2 -2
  6. package/build/components/dataform-controls/index.cjs +2 -0
  7. package/build/components/dataform-controls/index.cjs.map +3 -3
  8. package/build/components/dataform-layouts/card/index.cjs +58 -3
  9. package/build/components/dataform-layouts/card/index.cjs.map +3 -3
  10. package/build/components/dataform-layouts/panel/dropdown.cjs +18 -8
  11. package/build/components/dataform-layouts/panel/dropdown.cjs.map +3 -3
  12. package/build/components/dataform-layouts/panel/index.cjs +5 -3
  13. package/build/components/dataform-layouts/panel/index.cjs.map +2 -2
  14. package/build/components/dataform-layouts/panel/modal.cjs +16 -10
  15. package/build/components/dataform-layouts/panel/modal.cjs.map +3 -3
  16. package/build/components/dataviews-bulk-actions/index.cjs +16 -18
  17. package/build/components/dataviews-bulk-actions/index.cjs.map +3 -3
  18. package/build/components/dataviews-item-actions/index.cjs +4 -1
  19. package/build/components/dataviews-item-actions/index.cjs.map +2 -2
  20. package/build/components/dataviews-layouts/activity/activity-item.cjs +6 -1
  21. package/build/components/dataviews-layouts/activity/activity-item.cjs.map +2 -2
  22. package/build/components/dataviews-layouts/table/column-header-menu.cjs +73 -66
  23. package/build/components/dataviews-layouts/table/column-header-menu.cjs.map +2 -2
  24. package/build/components/dataviews-layouts/table/index.cjs +3 -2
  25. package/build/components/dataviews-layouts/table/index.cjs.map +2 -2
  26. package/build/components/dataviews-picker-footer/index.cjs +8 -15
  27. package/build/components/dataviews-picker-footer/index.cjs.map +3 -3
  28. package/build/field-types/index.cjs +2 -0
  29. package/build/field-types/index.cjs.map +3 -3
  30. package/build/field-types/utils/get-filter.cjs +36 -0
  31. package/build/field-types/utils/get-filter.cjs.map +7 -0
  32. package/build/hooks/use-report-validity.cjs +39 -0
  33. package/build/hooks/use-report-validity.cjs.map +7 -0
  34. package/build/types/field-api.cjs.map +1 -1
  35. package/build/utils/filter-sort-and-paginate.cjs +6 -174
  36. package/build/utils/filter-sort-and-paginate.cjs.map +2 -2
  37. package/build/utils/get-footer-message.cjs +49 -0
  38. package/build/utils/get-footer-message.cjs.map +7 -0
  39. package/build/utils/operators.cjs +203 -24
  40. package/build/utils/operators.cjs.map +2 -2
  41. package/build-module/components/dataform-controls/combobox.mjs +49 -0
  42. package/build-module/components/dataform-controls/combobox.mjs.map +7 -0
  43. package/build-module/components/dataform-controls/date.mjs +35 -10
  44. package/build-module/components/dataform-controls/date.mjs.map +2 -2
  45. package/build-module/components/dataform-controls/index.mjs +2 -0
  46. package/build-module/components/dataform-controls/index.mjs.map +2 -2
  47. package/build-module/components/dataform-layouts/card/index.mjs +59 -3
  48. package/build-module/components/dataform-layouts/card/index.mjs.map +2 -2
  49. package/build-module/components/dataform-layouts/panel/dropdown.mjs +20 -10
  50. package/build-module/components/dataform-layouts/panel/dropdown.mjs.map +2 -2
  51. package/build-module/components/dataform-layouts/panel/index.mjs +5 -3
  52. package/build-module/components/dataform-layouts/panel/index.mjs.map +2 -2
  53. package/build-module/components/dataform-layouts/panel/modal.mjs +18 -12
  54. package/build-module/components/dataform-layouts/panel/modal.mjs.map +2 -2
  55. package/build-module/components/dataviews-bulk-actions/index.mjs +17 -19
  56. package/build-module/components/dataviews-bulk-actions/index.mjs.map +2 -2
  57. package/build-module/components/dataviews-item-actions/index.mjs +4 -1
  58. package/build-module/components/dataviews-item-actions/index.mjs.map +2 -2
  59. package/build-module/components/dataviews-layouts/activity/activity-item.mjs +6 -1
  60. package/build-module/components/dataviews-layouts/activity/activity-item.mjs.map +2 -2
  61. package/build-module/components/dataviews-layouts/table/column-header-menu.mjs +74 -67
  62. package/build-module/components/dataviews-layouts/table/column-header-menu.mjs.map +2 -2
  63. package/build-module/components/dataviews-layouts/table/index.mjs +4 -3
  64. package/build-module/components/dataviews-layouts/table/index.mjs.map +2 -2
  65. package/build-module/components/dataviews-picker-footer/index.mjs +8 -15
  66. package/build-module/components/dataviews-picker-footer/index.mjs.map +2 -2
  67. package/build-module/field-types/index.mjs +2 -0
  68. package/build-module/field-types/index.mjs.map +2 -2
  69. package/build-module/field-types/utils/get-filter.mjs +15 -0
  70. package/build-module/field-types/utils/get-filter.mjs.map +7 -0
  71. package/build-module/hooks/use-report-validity.mjs +18 -0
  72. package/build-module/hooks/use-report-validity.mjs.map +7 -0
  73. package/build-module/utils/filter-sort-and-paginate.mjs +7 -198
  74. package/build-module/utils/filter-sort-and-paginate.mjs.map +2 -2
  75. package/build-module/utils/get-footer-message.mjs +28 -0
  76. package/build-module/utils/get-footer-message.mjs.map +7 -0
  77. package/build-module/utils/operators.mjs +203 -24
  78. package/build-module/utils/operators.mjs.map +2 -2
  79. package/build-style/style-rtl.css +15 -16
  80. package/build-style/style.css +15 -16
  81. package/build-types/components/dataform-controls/combobox.d.ts +6 -0
  82. package/build-types/components/dataform-controls/combobox.d.ts.map +1 -0
  83. package/build-types/components/dataform-controls/date.d.ts.map +1 -1
  84. package/build-types/components/dataform-controls/index.d.ts.map +1 -1
  85. package/build-types/components/dataform-layouts/card/index.d.ts +2 -0
  86. package/build-types/components/dataform-layouts/card/index.d.ts.map +1 -1
  87. package/build-types/components/dataform-layouts/panel/dropdown.d.ts +3 -2
  88. package/build-types/components/dataform-layouts/panel/dropdown.d.ts.map +1 -1
  89. package/build-types/components/dataform-layouts/panel/index.d.ts.map +1 -1
  90. package/build-types/components/dataform-layouts/panel/modal.d.ts +3 -2
  91. package/build-types/components/dataform-layouts/panel/modal.d.ts.map +1 -1
  92. package/build-types/components/dataviews-bulk-actions/index.d.ts.map +1 -1
  93. package/build-types/components/dataviews-item-actions/index.d.ts.map +1 -1
  94. package/build-types/components/dataviews-layouts/activity/activity-item.d.ts.map +1 -1
  95. package/build-types/components/dataviews-layouts/table/column-header-menu.d.ts.map +1 -1
  96. package/build-types/components/dataviews-layouts/table/index.d.ts.map +1 -1
  97. package/build-types/components/dataviews-picker-footer/index.d.ts.map +1 -1
  98. package/build-types/dataform/stories/content.story.d.ts +14 -0
  99. package/build-types/dataform/stories/content.story.d.ts.map +1 -0
  100. package/build-types/dataform/stories/index.story.d.ts +1 -1
  101. package/build-types/dataform/stories/index.story.d.ts.map +1 -1
  102. package/build-types/dataform/stories/validation.d.ts +1 -1
  103. package/build-types/dataform/stories/validation.d.ts.map +1 -1
  104. package/build-types/dataviews/stories/fixtures.d.ts.map +1 -1
  105. package/build-types/dataviews/stories/index.story.d.ts +4 -1
  106. package/build-types/dataviews/stories/index.story.d.ts.map +1 -1
  107. package/build-types/dataviews/stories/layout-custom.d.ts +11 -0
  108. package/build-types/dataviews/stories/layout-custom.d.ts.map +1 -0
  109. package/build-types/dataviews-picker/stories/fixtures.d.ts.map +1 -1
  110. package/build-types/dataviews-picker/stories/index.story.d.ts +1 -1
  111. package/build-types/dataviews-picker/stories/index.story.d.ts.map +1 -1
  112. package/build-types/field-types/index.d.ts.map +1 -1
  113. package/build-types/field-types/stories/index.story.d.ts +1 -1
  114. package/build-types/field-types/stories/index.story.d.ts.map +1 -1
  115. package/build-types/field-types/utils/get-filter.d.ts +7 -0
  116. package/build-types/field-types/utils/get-filter.d.ts.map +1 -0
  117. package/build-types/hooks/use-report-validity.d.ts +14 -0
  118. package/build-types/hooks/use-report-validity.d.ts.map +1 -0
  119. package/build-types/types/field-api.d.ts +3 -0
  120. package/build-types/types/field-api.d.ts.map +1 -1
  121. package/build-types/utils/filter-sort-and-paginate.d.ts.map +1 -1
  122. package/build-types/utils/get-footer-message.d.ts +10 -0
  123. package/build-types/utils/get-footer-message.d.ts.map +1 -0
  124. package/build-types/utils/operators.d.ts +2 -1
  125. package/build-types/utils/operators.d.ts.map +1 -1
  126. package/build-wp/index.js +2730 -2179
  127. package/package.json +22 -20
  128. package/src/components/dataform-controls/combobox.tsx +58 -0
  129. package/src/components/dataform-controls/date.tsx +45 -10
  130. package/src/components/dataform-controls/index.tsx +2 -0
  131. package/src/components/dataform-layouts/card/index.tsx +81 -3
  132. package/src/components/dataform-layouts/panel/dropdown.tsx +26 -11
  133. package/src/components/dataform-layouts/panel/index.tsx +6 -4
  134. package/src/components/dataform-layouts/panel/modal.tsx +24 -12
  135. package/src/components/dataviews-bulk-actions/index.tsx +23 -20
  136. package/src/components/dataviews-bulk-actions/style.scss +0 -3
  137. package/src/components/dataviews-item-actions/index.tsx +6 -1
  138. package/src/components/dataviews-layouts/activity/activity-item.tsx +8 -1
  139. package/src/components/dataviews-layouts/table/column-header-menu.tsx +99 -73
  140. package/src/components/dataviews-layouts/table/index.tsx +12 -3
  141. package/src/components/dataviews-layouts/table/style.scss +14 -7
  142. package/src/components/dataviews-picker-footer/index.tsx +8 -18
  143. package/src/dataform/stories/content.story.mdx +159 -0
  144. package/src/dataform/stories/content.story.tsx +390 -0
  145. package/src/dataform/stories/index.story.tsx +8 -1
  146. package/src/dataform/stories/validation.tsx +98 -5
  147. package/src/dataviews/stories/best-practices.story.mdx +55 -0
  148. package/src/dataviews/stories/fixtures.tsx +1 -3
  149. package/src/dataviews/stories/index.story.tsx +6 -1
  150. package/src/dataviews/stories/layout-custom.tsx +140 -0
  151. package/src/dataviews/test/dataviews.tsx +66 -1
  152. package/src/dataviews-picker/stories/fixtures.tsx +1 -3
  153. package/src/dataviews-picker/stories/index.story.tsx +1 -1
  154. package/src/field-types/index.tsx +2 -0
  155. package/src/field-types/stories/index.story.tsx +2 -0
  156. package/src/field-types/utils/get-filter.ts +18 -0
  157. package/src/hooks/use-report-validity.ts +32 -0
  158. package/src/types/field-api.ts +11 -0
  159. package/src/utils/filter-sort-and-paginate.ts +11 -306
  160. package/src/utils/get-footer-message.ts +41 -0
  161. package/src/utils/operators.tsx +303 -31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wordpress/dataviews",
3
- "version": "11.2.1-next.v.0+500f87dd8",
3
+ "version": "11.3.0",
4
4
  "description": "DataViews is a component that provides an API to render datasets using different types of layouts (table, grid, list, etc.).",
5
5
  "author": "The WordPress Contributors",
6
6
  "license": "GPL-2.0-or-later",
@@ -53,23 +53,23 @@
53
53
  "sideEffects": false,
54
54
  "dependencies": {
55
55
  "@ariakit/react": "^0.4.15",
56
- "@wordpress/base-styles": "^6.13.2-next.v.0+500f87dd8",
57
- "@wordpress/components": "^32.0.1-next.v.0+500f87dd8",
58
- "@wordpress/compose": "^7.37.1-next.v.0+500f87dd8",
59
- "@wordpress/data": "^10.37.1-next.v.0+500f87dd8",
60
- "@wordpress/date": "^5.37.2-next.v.0+500f87dd8",
61
- "@wordpress/deprecated": "^4.37.1-next.v.0+500f87dd8",
62
- "@wordpress/dom": "^4.37.1-next.v.0+500f87dd8",
63
- "@wordpress/element": "^6.37.1-next.v.0+500f87dd8",
64
- "@wordpress/i18n": "^6.10.1-next.v.0+500f87dd8",
65
- "@wordpress/icons": "^11.4.1-next.v.0+500f87dd8",
66
- "@wordpress/keycodes": "^4.38.1-next.v.0+500f87dd8",
67
- "@wordpress/primitives": "^4.37.1-next.v.0+500f87dd8",
68
- "@wordpress/private-apis": "^1.37.1-next.v.0+500f87dd8",
69
- "@wordpress/theme": "^0.5.1-next.v.0+500f87dd8",
70
- "@wordpress/ui": "^0.5.1-next.v.0+500f87dd8",
71
- "@wordpress/url": "^4.37.1-next.v.0+500f87dd8",
72
- "@wordpress/warning": "^3.37.1-next.v.0+500f87dd8",
56
+ "@wordpress/base-styles": "^6.15.0",
57
+ "@wordpress/components": "^32.1.0",
58
+ "@wordpress/compose": "^7.39.0",
59
+ "@wordpress/data": "^10.39.0",
60
+ "@wordpress/date": "^5.39.0",
61
+ "@wordpress/deprecated": "^4.39.0",
62
+ "@wordpress/dom": "^4.39.0",
63
+ "@wordpress/element": "^6.39.0",
64
+ "@wordpress/i18n": "^6.12.0",
65
+ "@wordpress/icons": "^11.6.0",
66
+ "@wordpress/keycodes": "^4.39.0",
67
+ "@wordpress/primitives": "^4.39.0",
68
+ "@wordpress/private-apis": "^1.39.0",
69
+ "@wordpress/theme": "^0.6.0",
70
+ "@wordpress/ui": "^0.6.0",
71
+ "@wordpress/url": "^4.39.0",
72
+ "@wordpress/warning": "^3.39.0",
73
73
  "clsx": "^2.1.1",
74
74
  "colord": "^2.7.0",
75
75
  "date-fns": "^4.1.0",
@@ -78,10 +78,12 @@
78
78
  "remove-accents": "^0.5.0"
79
79
  },
80
80
  "devDependencies": {
81
+ "@storybook/addon-docs": "^10.1.11",
82
+ "@storybook/react-vite": "^10.1.11",
81
83
  "@testing-library/jest-dom": "^6.6.3",
82
84
  "@types/jest": "^29.5.14",
83
85
  "esbuild": "^0.27.2",
84
- "storybook": "^9.1.17"
86
+ "storybook": "^10.1.11"
85
87
  },
86
88
  "peerDependencies": {
87
89
  "react": "^18.0.0",
@@ -93,5 +95,5 @@
93
95
  "scripts": {
94
96
  "build:wp": "node build.cjs"
95
97
  },
96
- "gitHead": "ca0db0ee8ac2116cd307650136027d26d0cdd9bd"
98
+ "gitHead": "eee1cfb1472f11183e40fb77465a5f13145df7ad"
97
99
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { privateApis, Spinner } from '@wordpress/components';
5
+ import { useCallback } from '@wordpress/element';
6
+
7
+ /**
8
+ * Internal dependencies
9
+ */
10
+ import type { DataFormControlProps } from '../../types';
11
+ import useElements from '../../hooks/use-elements';
12
+ import { unlock } from '../../lock-unlock';
13
+ import getCustomValidity from './utils/get-custom-validity';
14
+
15
+ const { ValidatedComboboxControl } = unlock( privateApis );
16
+
17
+ export default function Combobox< Item >( {
18
+ data,
19
+ field,
20
+ onChange,
21
+ hideLabelFromVision,
22
+ validity,
23
+ }: DataFormControlProps< Item > ) {
24
+ const { label, description, placeholder, getValue, setValue, isValid } =
25
+ field;
26
+ const value = getValue( { item: data } ) ?? '';
27
+
28
+ const onChangeControl = useCallback(
29
+ ( newValue: string | null ) =>
30
+ onChange( setValue( { item: data, value: newValue ?? '' } ) ),
31
+ [ data, onChange, setValue ]
32
+ );
33
+
34
+ const { elements, isLoading } = useElements( {
35
+ elements: field.elements,
36
+ getElements: field.getElements,
37
+ } );
38
+
39
+ if ( isLoading ) {
40
+ return <Spinner />;
41
+ }
42
+
43
+ return (
44
+ <ValidatedComboboxControl
45
+ required={ !! field.isValid?.required }
46
+ customValidity={ getCustomValidity( isValid, validity ) }
47
+ label={ label }
48
+ value={ value }
49
+ help={ description }
50
+ placeholder={ placeholder }
51
+ options={ elements }
52
+ onChange={ onChangeControl }
53
+ hideLabelFromVision={ hideLabelFromVision }
54
+ allowReset
55
+ expandOnFocus
56
+ />
57
+ );
58
+ }
@@ -191,18 +191,53 @@ function ValidatedDateControl< Item >( {
191
191
  setCustomValidity( undefined );
192
192
  }, [ inputRefs ] );
193
193
 
194
+ // Sync React-level validation to native inputs.
194
195
  useEffect( () => {
195
- if ( isTouched ) {
196
- const timeoutId = setTimeout( () => {
197
- if ( validity ) {
198
- setCustomValidity( getCustomValidity( isValid, validity ) );
199
- } else {
200
- validateRefs();
201
- }
202
- }, 0 );
203
- return () => clearTimeout( timeoutId );
196
+ const refs = Array.isArray( inputRefs ) ? inputRefs : [ inputRefs ];
197
+ const result = validity
198
+ ? getCustomValidity( isValid, validity )
199
+ : undefined;
200
+ for ( const ref of refs ) {
201
+ const input = ref.current;
202
+ if ( input ) {
203
+ input.setCustomValidity(
204
+ result?.type === 'invalid' && result.message
205
+ ? result.message
206
+ : ''
207
+ );
208
+ }
209
+ }
210
+ }, [ inputRefs, isValid, validity ] );
211
+
212
+ // Listen for 'invalid' events (e.g., from reportValidity() on card re-expand).
213
+ useEffect( () => {
214
+ const refs = Array.isArray( inputRefs ) ? inputRefs : [ inputRefs ];
215
+ const handleInvalid = ( event: Event ) => {
216
+ event.preventDefault();
217
+ setIsTouched( true );
218
+ };
219
+ for ( const ref of refs ) {
220
+ ref.current?.addEventListener( 'invalid', handleInvalid );
221
+ }
222
+ return () => {
223
+ for ( const ref of refs ) {
224
+ ref.current?.removeEventListener( 'invalid', handleInvalid );
225
+ }
226
+ };
227
+ }, [ inputRefs, setIsTouched ] );
228
+
229
+ useEffect( () => {
230
+ if ( ! isTouched ) {
231
+ return;
232
+ }
233
+ const result = validity
234
+ ? getCustomValidity( isValid, validity )
235
+ : undefined;
236
+ if ( result ) {
237
+ setCustomValidity( result );
238
+ } else {
239
+ validateRefs();
204
240
  }
205
- return undefined;
206
241
  }, [ isTouched, isValid, validity, validateRefs ] );
207
242
 
208
243
  const onBlur = ( event: React.FocusEvent< HTMLDivElement > ) => {
@@ -8,6 +8,7 @@ import type { ComponentType } from 'react';
8
8
  */
9
9
  import type { DataFormControlProps, Field, EditConfig } from '../../types';
10
10
  import checkbox from './checkbox';
11
+ import combobox from './combobox';
11
12
  import datetime from './datetime';
12
13
  import date from './date';
13
14
  import email from './email';
@@ -34,6 +35,7 @@ const FORM_CONTROLS: FormControls = {
34
35
  array,
35
36
  checkbox,
36
37
  color,
38
+ combobox,
37
39
  datetime,
38
40
  date,
39
41
  email,
@@ -7,14 +7,17 @@ import {
7
7
  CardBody,
8
8
  CardHeader as OriginalCardHeader,
9
9
  } from '@wordpress/components';
10
+ import { Badge } from '@wordpress/ui';
10
11
  import {
11
12
  useCallback,
12
13
  useContext,
13
14
  useEffect,
14
15
  useMemo,
16
+ useRef,
15
17
  useState,
16
18
  } from '@wordpress/element';
17
19
  import { chevronDown, chevronUp } from '@wordpress/icons';
20
+ import { sprintf, _n } from '@wordpress/i18n';
18
21
 
19
22
  /**
20
23
  * Internal dependencies
@@ -23,6 +26,7 @@ import { getFormFieldLayout } from '..';
23
26
  import DataFormContext from '../../dataform-context';
24
27
  import type {
25
28
  FieldLayoutProps,
29
+ FieldValidity,
26
30
  NormalizedCardLayout,
27
31
  NormalizedField,
28
32
  NormalizedForm,
@@ -31,6 +35,34 @@ import type {
31
35
  import { DataFormLayout } from '../data-form-layout';
32
36
  import { DEFAULT_LAYOUT } from '../normalize-form';
33
37
  import { getSummaryFields } from '../get-summary-fields';
38
+ import useReportValidity from '../../../hooks/use-report-validity';
39
+
40
+ function countInvalidFields( validity: FieldValidity | undefined ): number {
41
+ if ( ! validity ) {
42
+ return 0;
43
+ }
44
+
45
+ let count = 0;
46
+ const validityRules = Object.keys( validity ).filter(
47
+ ( key ) => key !== 'children'
48
+ );
49
+
50
+ for ( const key of validityRules ) {
51
+ const rule = validity[ key as keyof Omit< FieldValidity, 'children' > ];
52
+ if ( rule?.type === 'invalid' ) {
53
+ count++;
54
+ }
55
+ }
56
+
57
+ // Count children recursively
58
+ if ( validity.children ) {
59
+ for ( const childValidity of Object.values( validity.children ) ) {
60
+ count += countInvalidFields( childValidity );
61
+ }
62
+ }
63
+
64
+ return count;
65
+ }
34
66
 
35
67
  const NonCollapsibleCardHeader = ( {
36
68
  children,
@@ -56,6 +88,7 @@ const NonCollapsibleCardHeader = ( {
56
88
  export function useCardHeader( layout: NormalizedCardLayout ) {
57
89
  const { isOpened, isCollapsible } = layout;
58
90
  const [ isOpen, setIsOpen ] = useState( isOpened );
91
+ const [ touched, setTouched ] = useState( false );
59
92
 
60
93
  // Sync internal state when the isOpened prop changes.
61
94
  // This is unlikely to happen in production, but it helps with storybook controls.
@@ -64,8 +97,12 @@ export function useCardHeader( layout: NormalizedCardLayout ) {
64
97
  }, [ isOpened ] );
65
98
 
66
99
  const toggle = useCallback( () => {
100
+ // Mark as touched when collapsing (going from open to closed)
101
+ if ( isOpen ) {
102
+ setTouched( true );
103
+ }
67
104
  setIsOpen( ( prev ) => ! prev );
68
- }, [] );
105
+ }, [ isOpen ] );
69
106
 
70
107
  const CollapsibleCardHeader = useCallback(
71
108
  ( {
@@ -111,7 +148,12 @@ export function useCardHeader( layout: NormalizedCardLayout ) {
111
148
  ? CollapsibleCardHeader
112
149
  : NonCollapsibleCardHeader;
113
150
 
114
- return { isOpen: effectiveIsOpen, CardHeader: CardHeaderComponent };
151
+ return {
152
+ isOpen: effectiveIsOpen,
153
+ CardHeader: CardHeaderComponent,
154
+ touched,
155
+ setTouched,
156
+ };
115
157
  }
116
158
 
117
159
  function isSummaryFieldVisible< Item >(
@@ -174,6 +216,7 @@ export default function FormCardField< Item >( {
174
216
  }: FieldLayoutProps< Item > ) {
175
217
  const { fields } = useContext( DataFormContext );
176
218
  const layout = field.layout as NormalizedCardLayout;
219
+ const cardBodyRef = useRef< HTMLDivElement >( null );
177
220
 
178
221
  const form: NormalizedForm = useMemo(
179
222
  () => ( {
@@ -183,7 +226,17 @@ export default function FormCardField< Item >( {
183
226
  [ field ]
184
227
  );
185
228
 
186
- const { isOpen, CardHeader } = useCardHeader( layout );
229
+ const { isOpen, CardHeader, touched, setTouched } = useCardHeader( layout );
230
+
231
+ // Mark the card as touched when any field inside it is blurred.
232
+ // This aligns with how validated controls show errors on blur.
233
+ const handleBlur = useCallback( () => {
234
+ setTouched( true );
235
+ }, [ setTouched ] );
236
+
237
+ // When the card is expanded after being touched (collapsed with errors),
238
+ // trigger reportValidity to show field-level errors.
239
+ useReportValidity( cardBodyRef, isOpen && touched );
187
240
 
188
241
  const summaryFields = getSummaryFields< Item >( layout.summary, fields );
189
242
 
@@ -191,6 +244,25 @@ export default function FormCardField< Item >( {
191
244
  isSummaryFieldVisible( summaryField, layout.summary, isOpen )
192
245
  );
193
246
 
247
+ // Count invalid fields for validation badge
248
+ const invalidCount = countInvalidFields( validity );
249
+ const showValidationBadge =
250
+ touched && invalidCount > 0 && layout.isCollapsible;
251
+
252
+ const validationBadge = showValidationBadge ? (
253
+ <Badge intent="high">
254
+ { sprintf(
255
+ /* translators: %d: Number of fields that need attention */
256
+ _n(
257
+ '%d field needs attention',
258
+ '%d fields need attention',
259
+ invalidCount
260
+ ),
261
+ invalidCount
262
+ ) }
263
+ </Badge>
264
+ ) : null;
265
+
194
266
  const sizeCard = {
195
267
  blockStart: 'medium' as const,
196
268
  blockEnd: 'medium' as const,
@@ -217,6 +289,7 @@ export default function FormCardField< Item >( {
217
289
  <span className="dataforms-layouts-card__field-header-label">
218
290
  { field.label }
219
291
  </span>
292
+ { validationBadge }
220
293
  { visibleSummaryFields.length > 0 &&
221
294
  layout.withHeader && (
222
295
  <div className="dataforms-layouts-card__field-summary">
@@ -239,6 +312,8 @@ export default function FormCardField< Item >( {
239
312
  <CardBody
240
313
  size={ sizeCardBody }
241
314
  className="dataforms-layouts-card__field-control"
315
+ ref={ cardBodyRef }
316
+ onBlur={ handleBlur }
242
317
  >
243
318
  { field.description && (
244
319
  <div className="dataforms-layouts-card__field-description">
@@ -285,6 +360,7 @@ export default function FormCardField< Item >( {
285
360
  <span className="dataforms-layouts-card__field-header-label">
286
361
  { fieldDefinition.label }
287
362
  </span>
363
+ { validationBadge }
288
364
  { visibleSummaryFields.length > 0 && layout.withHeader && (
289
365
  <div className="dataforms-layouts-card__field-summary">
290
366
  { visibleSummaryFields.map( ( summaryField ) => (
@@ -304,6 +380,8 @@ export default function FormCardField< Item >( {
304
380
  <CardBody
305
381
  size={ sizeCardBody }
306
382
  className="dataforms-layouts-card__field-control"
383
+ ref={ cardBodyRef }
384
+ onBlur={ handleBlur }
307
385
  >
308
386
  <RegularLayout
309
387
  data={ data }
@@ -8,7 +8,7 @@ import {
8
8
  Button,
9
9
  } from '@wordpress/components';
10
10
  import { __ } from '@wordpress/i18n';
11
- import { useMemo } from '@wordpress/element';
11
+ import { useMemo, useRef } from '@wordpress/element';
12
12
  import { closeSmall } from '@wordpress/icons';
13
13
  import { useFocusOnMount } from '@wordpress/compose';
14
14
  import { Stack } from '@wordpress/ui';
@@ -26,6 +26,7 @@ import type {
26
26
  import { DataFormLayout } from '../data-form-layout';
27
27
  import { DEFAULT_LAYOUT } from '../normalize-form';
28
28
  import SummaryButton from './summary-button';
29
+ import useReportValidity from '../../../hooks/use-report-validity';
29
30
 
30
31
  function DropdownHeader( {
31
32
  title,
@@ -60,6 +61,18 @@ function DropdownHeader( {
60
61
  );
61
62
  }
62
63
 
64
+ function DropdownContentWithValidation( {
65
+ touched,
66
+ children,
67
+ }: {
68
+ touched: boolean;
69
+ children: React.ReactNode;
70
+ } ) {
71
+ const ref = useRef< HTMLDivElement >( null );
72
+ useReportValidity( ref, touched );
73
+ return <div ref={ ref }>{ children }</div>;
74
+ }
75
+
63
76
  function PanelDropdown< Item >( {
64
77
  data,
65
78
  field,
@@ -69,7 +82,8 @@ function PanelDropdown< Item >( {
69
82
  summaryFields,
70
83
  fieldDefinition,
71
84
  popoverAnchor,
72
- onOpen,
85
+ onClose: onCloseCallback,
86
+ touched,
73
87
  }: {
74
88
  data: Item;
75
89
  field: NormalizedFormField;
@@ -79,7 +93,8 @@ function PanelDropdown< Item >( {
79
93
  summaryFields: NormalizedField< Item >[];
80
94
  fieldDefinition: NormalizedField< Item >;
81
95
  popoverAnchor: HTMLElement | null;
82
- onOpen?: () => void;
96
+ onClose?: () => void;
97
+ touched: boolean;
83
98
  } ) {
84
99
  const fieldLabel = !! field.children ? field.label : fieldDefinition?.label;
85
100
 
@@ -125,6 +140,11 @@ function PanelDropdown< Item >( {
125
140
  contentClassName="dataforms-layouts-panel__field-dropdown"
126
141
  popoverProps={ popoverProps }
127
142
  focusOnMount={ false }
143
+ onToggle={ ( willOpen ) => {
144
+ if ( ! willOpen ) {
145
+ onCloseCallback?.();
146
+ }
147
+ } }
128
148
  toggleProps={ {
129
149
  size: 'compact',
130
150
  variant: 'tertiary',
@@ -137,17 +157,12 @@ function PanelDropdown< Item >( {
137
157
  labelPosition={ labelPosition }
138
158
  fieldLabel={ fieldLabel }
139
159
  disabled={ fieldDefinition.readOnly === true }
140
- onClick={ () => {
141
- if ( ! isOpen && onOpen ) {
142
- onOpen();
143
- }
144
- onToggle();
145
- } }
160
+ onClick={ onToggle }
146
161
  aria-expanded={ isOpen }
147
162
  />
148
163
  ) }
149
164
  renderContent={ ( { onClose } ) => (
150
- <>
165
+ <DropdownContentWithValidation touched={ touched }>
151
166
  <DropdownHeader title={ fieldLabel } onClose={ onClose } />
152
167
  <div ref={ focusOnMountRef }>
153
168
  <DataFormLayout
@@ -174,7 +189,7 @@ function PanelDropdown< Item >( {
174
189
  ) }
175
190
  </DataFormLayout>
176
191
  </div>
177
- </>
192
+ </DropdownContentWithValidation>
178
193
  ) }
179
194
  />
180
195
  );
@@ -147,9 +147,9 @@ export default function FormPanelField< Item >( {
147
147
  null
148
148
  );
149
149
 
150
- // Track if the panel has been opened (touched) to only show errors after interaction.
150
+ // Track if the panel has been closed (touched) to only show errors after interaction.
151
151
  const [ touched, setTouched ] = useState( false );
152
- const handleOpen = () => setTouched( true );
152
+ const handleClose = () => setTouched( true );
153
153
 
154
154
  const { fieldDefinition, summaryFields } =
155
155
  getFieldDefinitionAndSummaryFields( layout, field, fields );
@@ -193,7 +193,8 @@ export default function FormPanelField< Item >( {
193
193
  labelPosition={ labelPosition }
194
194
  summaryFields={ summaryFields }
195
195
  fieldDefinition={ fieldDefinition }
196
- onOpen={ handleOpen }
196
+ onClose={ handleClose }
197
+ touched={ touched }
197
198
  />
198
199
  ) : (
199
200
  <PanelDropdown
@@ -205,7 +206,8 @@ export default function FormPanelField< Item >( {
205
206
  summaryFields={ summaryFields }
206
207
  fieldDefinition={ fieldDefinition }
207
208
  popoverAnchor={ popoverAnchor }
208
- onOpen={ handleOpen }
209
+ onClose={ handleClose }
210
+ touched={ touched }
209
211
  />
210
212
  );
211
213
 
@@ -12,8 +12,8 @@ import {
12
12
  Modal,
13
13
  } from '@wordpress/components';
14
14
  import { __ } from '@wordpress/i18n';
15
- import { useContext, useState, useMemo } from '@wordpress/element';
16
- import { useFocusOnMount } from '@wordpress/compose';
15
+ import { useContext, useMemo, useRef, useState } from '@wordpress/element';
16
+ import { useFocusOnMount, useMergeRefs } from '@wordpress/compose';
17
17
  import { Stack } from '@wordpress/ui';
18
18
 
19
19
  /**
@@ -29,6 +29,7 @@ import { DataFormLayout } from '../data-form-layout';
29
29
  import { DEFAULT_LAYOUT } from '../normalize-form';
30
30
  import SummaryButton from './summary-button';
31
31
  import useFormValidity from '../../../hooks/use-form-validity';
32
+ import useReportValidity from '../../../hooks/use-report-validity';
32
33
  import DataFormContext from '../../dataform-context';
33
34
 
34
35
  function ModalContent< Item >( {
@@ -37,12 +38,14 @@ function ModalContent< Item >( {
37
38
  onChange,
38
39
  fieldLabel,
39
40
  onClose,
41
+ touched,
40
42
  }: {
41
43
  data: Item;
42
44
  field: NormalizedFormField;
43
45
  onChange: ( data: Partial< Item > ) => void;
44
46
  onClose: () => void;
45
47
  fieldLabel: string;
48
+ touched: boolean;
46
49
  } ) {
47
50
  const { fields } = useContext( DataFormContext );
48
51
  const [ changes, setChanges ] = useState< Partial< Item > >( {} );
@@ -92,6 +95,12 @@ function ModalContent< Item >( {
92
95
  };
93
96
 
94
97
  const focusOnMountRef = useFocusOnMount( 'firstInputElement' );
98
+ const contentRef = useRef< HTMLDivElement >( null );
99
+ const mergedRef = useMergeRefs( [ focusOnMountRef, contentRef ] );
100
+
101
+ // When the modal is opened after being previously closed (touched),
102
+ // trigger reportValidity to show field-level errors.
103
+ useReportValidity( contentRef, touched );
95
104
 
96
105
  return (
97
106
  <Modal
@@ -101,7 +110,7 @@ function ModalContent< Item >( {
101
110
  title={ fieldLabel }
102
111
  size="medium"
103
112
  >
104
- <div ref={ focusOnMountRef }>
113
+ <div ref={ mergedRef }>
105
114
  <DataFormLayout
106
115
  data={ modalData }
107
116
  form={ form }
@@ -152,7 +161,8 @@ function PanelModal< Item >( {
152
161
  labelPosition,
153
162
  summaryFields,
154
163
  fieldDefinition,
155
- onOpen,
164
+ onClose: onCloseCallback,
165
+ touched,
156
166
  }: {
157
167
  data: Item;
158
168
  field: NormalizedFormField;
@@ -160,12 +170,18 @@ function PanelModal< Item >( {
160
170
  labelPosition: 'side' | 'top' | 'none';
161
171
  summaryFields: NormalizedField< Item >[];
162
172
  fieldDefinition: NormalizedField< Item >;
163
- onOpen?: () => void;
173
+ onClose?: () => void;
174
+ touched: boolean;
164
175
  } ) {
165
176
  const [ isOpen, setIsOpen ] = useState( false );
166
177
 
167
178
  const fieldLabel = !! field.children ? field.label : fieldDefinition?.label;
168
179
 
180
+ const handleClose = () => {
181
+ setIsOpen( false );
182
+ onCloseCallback?.();
183
+ };
184
+
169
185
  return (
170
186
  <>
171
187
  <SummaryButton
@@ -174,12 +190,7 @@ function PanelModal< Item >( {
174
190
  labelPosition={ labelPosition }
175
191
  fieldLabel={ fieldLabel }
176
192
  disabled={ fieldDefinition.readOnly === true }
177
- onClick={ () => {
178
- if ( onOpen ) {
179
- onOpen();
180
- }
181
- setIsOpen( true );
182
- } }
193
+ onClick={ () => setIsOpen( true ) }
183
194
  aria-expanded={ isOpen }
184
195
  />
185
196
  { isOpen && (
@@ -188,7 +199,8 @@ function PanelModal< Item >( {
188
199
  field={ field }
189
200
  onChange={ onChange }
190
201
  fieldLabel={ fieldLabel ?? '' }
191
- onClose={ () => setIsOpen( false ) }
202
+ onClose={ handleClose }
203
+ touched={ touched }
192
204
  />
193
205
  ) }
194
206
  </>