diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md
index 9880bbe5c92a84..f71283f97d3c1f 100644
--- a/packages/dataviews/CHANGELOG.md
+++ b/packages/dataviews/CHANGELOG.md
@@ -11,6 +11,7 @@
- Introduce a new `array` DataForm Edit control that supports multi-selection. [#71136](https://github.com/WordPress/gutenberg/pull/71136)
- Add `enableMoving` option to the `table` layout to allow or disallow column moving left and right. [#71120](https://github.com/WordPress/gutenberg/pull/71120)
- Add infinite scroll support across all layout types (grid, list, table). Enable infinite scroll by providing an `infiniteScrollHandler` function in the `paginationInfo` prop and toggling the feature in the view configuration. ([#70955](https://github.com/WordPress/gutenberg/pull/70955))
+- Add support for modal in DataForm panel layouts. [#71212](https://github.com/WordPress/gutenberg/pull/71212)
### Enhancements
diff --git a/packages/dataviews/src/components/dataform/stories/index.story.tsx b/packages/dataviews/src/components/dataform/stories/index.story.tsx
index e749cbebfd8cba..e2ce8f25e00b83 100644
--- a/packages/dataviews/src/components/dataform/stories/index.story.tsx
+++ b/packages/dataviews/src/components/dataform/stories/index.story.tsx
@@ -30,6 +30,9 @@ type SamplePost = {
filesize?: number;
dimensions?: string;
tags?: string[];
+ address1?: string;
+ address2?: string;
+ city?: string;
};
const meta = {
@@ -47,6 +50,11 @@ const meta = {
description: 'Chooses the label position of the layout.',
options: [ 'default', 'top', 'side', 'none' ],
},
+ openAs: {
+ control: { type: 'select' },
+ description: 'Chooses the type of panel to use.',
+ options: [ 'default', 'dropdown', 'modal' ],
+ },
},
};
export default meta;
@@ -158,14 +166,31 @@ const fields = [
{ value: 'travel', label: 'Travel' },
],
},
+ {
+ id: 'address1',
+ label: 'Address 1',
+ type: 'text' as const,
+ },
+ {
+ id: 'address2',
+ label: 'Address 2',
+ type: 'text' as const,
+ },
+ {
+ id: 'city',
+ label: 'City',
+ type: 'text' as const,
+ },
] as Field< SamplePost >[];
export const Default = ( {
type,
labelPosition,
+ openAs,
}: {
type: 'default' | 'regular' | 'panel' | 'card';
labelPosition: 'default' | 'top' | 'side' | 'none';
+ openAs: 'default' | 'dropdown' | 'modal';
} ) => {
const [ post, setPost ] = useState( {
title: 'Hello, World!',
@@ -188,6 +213,7 @@ export const Default = ( {
layout: {
type,
labelPosition,
+ openAs,
},
fields: [
'title',
@@ -206,7 +232,7 @@ export const Default = ( {
'tags',
],
} ),
- [ type, labelPosition ]
+ [ type, labelPosition, openAs ]
) as Form;
return (
@@ -227,9 +253,11 @@ export const Default = ( {
const CombinedFieldsComponent = ( {
type,
labelPosition,
+ openAs,
}: {
type: 'default' | 'regular' | 'panel' | 'card';
labelPosition: 'default' | 'top' | 'side' | 'none';
+ openAs: 'default' | 'dropdown' | 'modal';
} ) => {
const [ post, setPost ] = useState< SamplePost >( {
title: 'Hello, World!',
@@ -242,6 +270,9 @@ const CombinedFieldsComponent = ( {
filesize: 1024,
dimensions: '1920x1080',
tags: [ 'photography' ],
+ address1: '123 Main St',
+ address2: 'Apt 4B',
+ city: 'New York',
} );
const form = useMemo(
@@ -249,6 +280,7 @@ const CombinedFieldsComponent = ( {
layout: {
type,
labelPosition,
+ openAs,
},
fields: [
'title',
@@ -262,9 +294,14 @@ const CombinedFieldsComponent = ( {
'filesize',
'dimensions',
'tags',
+ {
+ id: 'address1',
+ label: 'Combined Address',
+ children: [ 'address1', 'address2', 'city' ],
+ },
],
} ),
- [ type, labelPosition ]
+ [ type, labelPosition, openAs ]
) as Form;
return (
@@ -725,7 +762,11 @@ const LayoutMixedComponent = () => {
fields: [
{
id: 'title',
- layout: { type: 'panel', labelPosition: 'top' },
+ layout: {
+ type: 'panel',
+ labelPosition: 'top',
+ openAs: 'dropdown',
+ },
},
'status',
{ id: 'order', layout: { type: 'card' } },
diff --git a/packages/dataviews/src/dataforms-layouts/panel/dropdown.tsx b/packages/dataviews/src/dataforms-layouts/panel/dropdown.tsx
new file mode 100644
index 00000000000000..1367cdb93105de
--- /dev/null
+++ b/packages/dataviews/src/dataforms-layouts/panel/dropdown.tsx
@@ -0,0 +1,160 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ __experimentalVStack as VStack,
+ __experimentalHStack as HStack,
+ __experimentalHeading as Heading,
+ __experimentalSpacer as Spacer,
+ Dropdown,
+ Button,
+} from '@wordpress/components';
+import { sprintf, __, _x } from '@wordpress/i18n';
+import { useMemo } from '@wordpress/element';
+import { closeSmall } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import type { Form, FormField, NormalizedField } from '../../types';
+import { DataFormLayout } from '../data-form-layout';
+import { isCombinedField } from '../is-combined-field';
+import { DEFAULT_LAYOUT } from '../../normalize-form-fields';
+
+function DropdownHeader( {
+ title,
+ onClose,
+}: {
+ title?: string;
+ onClose: () => void;
+} ) {
+ return (
+
+
+ { title && (
+
+ { title }
+
+ ) }
+
+ { onClose && (
+
+ ) }
+
+
+ );
+}
+
+function PanelDropdown< Item >( {
+ fieldDefinition,
+ popoverAnchor,
+ labelPosition = 'side',
+ data,
+ onChange,
+ field,
+}: {
+ fieldDefinition: NormalizedField< Item >;
+ popoverAnchor: HTMLElement | null;
+ labelPosition: 'side' | 'top' | 'none';
+ data: Item;
+ onChange: ( value: any ) => void;
+ field: FormField;
+} ) {
+ const fieldLabel = isCombinedField( field )
+ ? field.label
+ : fieldDefinition?.label;
+
+ const form: Form = useMemo(
+ (): Form => ( {
+ layout: DEFAULT_LAYOUT,
+ fields: isCombinedField( field )
+ ? field.children
+ : // If not explicit children return the field id itself.
+ [ { id: field.id } ],
+ } ),
+ [ field ]
+ );
+
+ // Memoize popoverProps to avoid returning a new object every time.
+ const popoverProps = useMemo(
+ () => ( {
+ // Anchor the popover to the middle of the entire row so that it doesn't
+ // move around when the label changes.
+ anchor: popoverAnchor,
+ placement: 'left-start',
+ offset: 36,
+ shift: true,
+ } ),
+ [ popoverAnchor ]
+ );
+
+ return (
+ (
+
+ ) }
+ renderContent={ ( { onClose } ) => (
+ <>
+
+
+ { ( FieldLayout, nestedField ) => (
+
+ ) }
+
+ >
+ ) }
+ />
+ );
+}
+
+export default PanelDropdown;
diff --git a/packages/dataviews/src/dataforms-layouts/panel/index.tsx b/packages/dataviews/src/dataforms-layouts/panel/index.tsx
index f9c99e26c6ee83..e6a27570bf8b1f 100644
--- a/packages/dataviews/src/dataforms-layouts/panel/index.tsx
+++ b/packages/dataviews/src/dataforms-layouts/panel/index.tsx
@@ -9,166 +9,22 @@ import clsx from 'clsx';
import {
__experimentalVStack as VStack,
__experimentalHStack as HStack,
- __experimentalHeading as Heading,
- __experimentalSpacer as Spacer,
- Dropdown,
- Button,
} from '@wordpress/components';
-import { sprintf, __, _x } from '@wordpress/i18n';
-import { useState, useMemo, useContext } from '@wordpress/element';
-import { closeSmall } from '@wordpress/icons';
+import { useState, useContext } from '@wordpress/element';
/**
* Internal dependencies
*/
import type {
- Form,
- FormField,
FieldLayoutProps,
NormalizedPanelLayout,
- NormalizedField,
SimpleFormField,
} from '../../types';
import DataFormContext from '../../components/dataform-context';
-import { DataFormLayout } from '../data-form-layout';
import { isCombinedField } from '../is-combined-field';
-import { DEFAULT_LAYOUT, normalizeLayout } from '../../normalize-form-fields';
-
-function DropdownHeader( {
- title,
- onClose,
-}: {
- title?: string;
- onClose: () => void;
-} ) {
- return (
-
-
- { title && (
-
- { title }
-
- ) }
-
- { onClose && (
-
- ) }
-
-
- );
-}
-
-function PanelDropdown< Item >( {
- fieldDefinition,
- popoverAnchor,
- labelPosition = 'side',
- data,
- onChange,
- field,
-}: {
- fieldDefinition: NormalizedField< Item >;
- popoverAnchor: HTMLElement | null;
- labelPosition: 'side' | 'top' | 'none';
- data: Item;
- onChange: ( value: any ) => void;
- field: FormField;
-} ) {
- const fieldLabel = isCombinedField( field )
- ? field.label
- : fieldDefinition?.label;
-
- const form: Form = useMemo(
- (): Form => ( {
- layout: DEFAULT_LAYOUT,
- fields: isCombinedField( field )
- ? field.children
- : // If not explicit children return the field id itself.
- [ { id: field.id } ],
- } ),
- [ field ]
- );
-
- // Memoize popoverProps to avoid returning a new object every time.
- const popoverProps = useMemo(
- () => ( {
- // Anchor the popover to the middle of the entire row so that it doesn't
- // move around when the label changes.
- anchor: popoverAnchor,
- placement: 'left-start',
- offset: 36,
- shift: true,
- } ),
- [ popoverAnchor ]
- );
-
- return (
- (
-
- ) }
- renderContent={ ( { onClose } ) => (
- <>
-
-
- { ( FieldLayout, nestedField ) => (
-
- ) }
-
- >
- ) }
- />
- );
-}
+import { normalizeLayout } from '../../normalize-form-fields';
+import PanelDropdown from './dropdown';
+import PanelModal from './modal';
export default function FormPanelField< Item >( {
data,
@@ -192,6 +48,7 @@ export default function FormPanelField< Item >( {
typeof simpleChildren[ 0 ] === 'string'
? simpleChildren[ 0 ]
: simpleChildren[ 0 ].id;
+
return _field.id === firstChildFieldId;
}
@@ -222,6 +79,26 @@ export default function FormPanelField< Item >( {
? field.label
: fieldDefinition?.label;
+ const renderedControl =
+ layout.openAs === 'modal' ? (
+
+ ) : (
+
+ );
+
if ( labelPosition === 'top' ) {
return (
@@ -232,14 +109,7 @@ export default function FormPanelField< Item >( {
{ fieldLabel }
);
@@ -248,14 +118,7 @@ export default function FormPanelField< Item >( {
if ( labelPosition === 'none' ) {
return (
);
}
@@ -268,14 +131,7 @@ export default function FormPanelField< Item >( {
>
{ fieldLabel }
);
diff --git a/packages/dataviews/src/dataforms-layouts/panel/modal.tsx b/packages/dataviews/src/dataforms-layouts/panel/modal.tsx
new file mode 100644
index 00000000000000..d0dfe14fb99806
--- /dev/null
+++ b/packages/dataviews/src/dataforms-layouts/panel/modal.tsx
@@ -0,0 +1,165 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ __experimentalHStack as HStack,
+ __experimentalSpacer as Spacer,
+ Button,
+ Modal,
+} from '@wordpress/components';
+import { __, sprintf, _x } from '@wordpress/i18n';
+import { useState, useMemo } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import type { Form, FormField, NormalizedField } from '../../types';
+import { DataFormLayout } from '../data-form-layout';
+import { isCombinedField } from '../is-combined-field';
+import { DEFAULT_LAYOUT } from '../../normalize-form-fields';
+
+function ModalContent< Item >( {
+ data,
+ form,
+ fieldLabel,
+ onChange,
+ onClose,
+}: {
+ data: Item;
+ form: Form;
+ fieldLabel: string;
+ onChange: ( data: Partial< Item > ) => void;
+ onClose: () => void;
+} ) {
+ const [ changes, setChanges ] = useState< Partial< Item > >( {} );
+
+ const onApply = () => {
+ onChange( changes );
+ onClose();
+ };
+
+ const handleOnChange = ( value: Partial< Item > ) => {
+ setChanges( ( prev ) => ( { ...prev, ...value } ) );
+ };
+
+ // Merge original data with local changes for display
+ const displayData = { ...data, ...changes };
+
+ return (
+
+
+ { ( FieldLayout, nestedField ) => (
+
+ ) }
+
+
+
+
+
+
+
+ );
+}
+
+function PanelModal< Item >( {
+ fieldDefinition,
+ labelPosition,
+ data,
+ onChange,
+ field,
+}: {
+ fieldDefinition: NormalizedField< Item >;
+ labelPosition: 'side' | 'top' | 'none';
+ data: Item;
+ onChange: ( value: any ) => void;
+ field: FormField;
+} ) {
+ const [ isOpen, setIsOpen ] = useState( false );
+
+ const fieldLabel = isCombinedField( field )
+ ? field.label
+ : fieldDefinition?.label;
+
+ const form: Form = useMemo(
+ (): Form => ( {
+ layout: DEFAULT_LAYOUT,
+ fields: isCombinedField( field )
+ ? field.children
+ : // If not explicit children return the field id itself.
+ [ { id: field.id } ],
+ } ),
+ [ field ]
+ );
+
+ return (
+ <>
+
+ { isOpen && (
+ setIsOpen( false ) }
+ />
+ ) }
+ >
+ );
+}
+
+export default PanelModal;
diff --git a/packages/dataviews/src/dataforms-layouts/panel/style.scss b/packages/dataviews/src/dataforms-layouts/panel/style.scss
index c43ef030af268a..63ea2f67e10c7b 100644
--- a/packages/dataviews/src/dataforms-layouts/panel/style.scss
+++ b/packages/dataviews/src/dataforms-layouts/panel/style.scss
@@ -52,6 +52,10 @@
margin-bottom: $grid-unit-20;
}
+.dataforms-layouts-panel__modal-footer {
+ margin-top: $grid-unit-20;
+}
+
.components-popover.components-dropdown__content.dataforms-layouts-panel__field-dropdown {
z-index: z-index(".components-popover.components-dropdown__content.dataforms-layouts-panel__field-dropdown");
}
diff --git a/packages/dataviews/src/normalize-form-fields.ts b/packages/dataviews/src/normalize-form-fields.ts
index 9af7332c275b43..0338ddafb4eba7 100644
--- a/packages/dataviews/src/normalize-form-fields.ts
+++ b/packages/dataviews/src/normalize-form-fields.ts
@@ -38,6 +38,7 @@ export function normalizeLayout( layout?: Layout ): NormalizedLayout {
normalizedLayout = {
type: 'panel',
labelPosition: layout?.labelPosition ?? 'side',
+ openAs: layout?.openAs ?? 'dropdown',
} satisfies NormalizedPanelLayout;
} else if ( layout?.type === 'card' ) {
if ( layout.withHeader === false ) {
diff --git a/packages/dataviews/src/test/dataform.tsx b/packages/dataviews/src/test/dataform.tsx
index af5c6f326b2109..027c4bb6b37864 100644
--- a/packages/dataviews/src/test/dataform.tsx
+++ b/packages/dataviews/src/test/dataform.tsx
@@ -218,6 +218,175 @@ describe( 'DataForm component', () => {
}
} );
+ it( 'should use dropdown panel type by default', async () => {
+ render(
+
+ );
+
+ const user = await userEvent.setup();
+ const titleButton = fieldsSelector.title.view();
+ await user.click( titleButton );
+
+ // Should show dropdown content (not modal)
+ expect(
+ screen.getByRole( 'textbox', { name: /title/i } )
+ ).toBeInTheDocument();
+ // Should not have modal dialog
+ expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
+ // Should not have modal buttons (Cancel/Apply)
+ expect(
+ screen.queryByRole( 'button', { name: /cancel/i } )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'button', { name: /apply/i } )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'should use dropdown panel type when explicitly set', async () => {
+ const formWithDropdownPanel = {
+ ...form,
+ layout: {
+ type: 'panel',
+ labelPosition: 'side',
+ openAs: 'dropdown',
+ } as const,
+ };
+
+ render(
+
+ );
+
+ const user = await userEvent.setup();
+ const titleButton = fieldsSelector.title.view();
+ await user.click( titleButton );
+
+ // Should show dropdown content
+ expect(
+ screen.getByRole( 'textbox', { name: /title/i } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should use modal panel type when set', async () => {
+ const formWithModalPanel = {
+ ...form,
+ layout: {
+ type: 'panel',
+ labelPosition: 'side',
+ openAs: 'modal',
+ } as const,
+ };
+
+ render(
+
+ );
+
+ const user = await userEvent.setup();
+ const titleButton = fieldsSelector.title.view();
+ await user.click( titleButton );
+
+ // Should show modal content
+ expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'textbox', { name: /title/i } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'should close modal when cancel button is clicked', async () => {
+ const formWithModalPanel = {
+ ...form,
+ layout: {
+ type: 'panel',
+ labelPosition: 'side',
+ openAs: 'modal',
+ } as const,
+ };
+
+ render(
+
+ );
+
+ const user = await userEvent.setup();
+ const titleButton = fieldsSelector.title.view();
+ await user.click( titleButton );
+
+ // Modal should be open
+ expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument();
+
+ // Click cancel button
+ const cancelButton = screen.getByRole( 'button', {
+ name: /cancel/i,
+ } );
+ await user.click( cancelButton );
+
+ // Modal should be closed
+ expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'should apply changes and close modal when apply button is clicked', async () => {
+ const onChange = jest.fn();
+ const formWithModalPanel = {
+ ...form,
+ layout: {
+ type: 'panel',
+ labelPosition: 'side',
+ openAs: 'modal',
+ } as const,
+ };
+
+ render(
+
+ );
+
+ const user = await userEvent.setup();
+ const titleButton = fieldsSelector.title.view();
+ await user.click( titleButton );
+
+ // Modal should be open
+ expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument();
+
+ // Type in the input
+ const titleInput = screen.getByRole( 'textbox', {
+ name: /title/i,
+ } );
+ await user.clear( titleInput );
+ await user.type( titleInput, 'New Title' );
+
+ // Click apply button
+ const applyButton = screen.getByRole( 'button', {
+ name: /apply/i,
+ } );
+ await user.click( applyButton );
+
+ // Modal should be closed and onChange should be called
+ expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
+ expect( onChange ).toHaveBeenCalledWith( { title: 'New Title' } );
+ } );
+
it( 'should call onChange with the correct value for each typed character', async () => {
const onChange = jest.fn();
render(
diff --git a/packages/dataviews/src/test/normalize-form-fields.ts b/packages/dataviews/src/test/normalize-form-fields.ts
index d75f8f26137cbe..3511fbf2ebe427 100644
--- a/packages/dataviews/src/test/normalize-form-fields.ts
+++ b/packages/dataviews/src/test/normalize-form-fields.ts
@@ -100,7 +100,11 @@ describe( 'normalizeFormFields', () => {
expect( result ).toEqual( [
{
id: 'field1',
- layout: { type: 'panel', labelPosition: 'side' },
+ layout: {
+ type: 'panel',
+ labelPosition: 'side',
+ openAs: 'dropdown',
+ },
},
] );
} );
@@ -114,7 +118,11 @@ describe( 'normalizeFormFields', () => {
expect( result ).toEqual( [
{
id: 'field1',
- layout: { type: 'panel', labelPosition: 'top' },
+ layout: {
+ type: 'panel',
+ labelPosition: 'top',
+ openAs: 'dropdown',
+ },
},
] );
} );
@@ -195,7 +203,11 @@ describe( 'normalizeFormFields', () => {
},
{
id: 'field2',
- layout: { type: 'panel', labelPosition: 'side' },
+ layout: {
+ type: 'panel',
+ labelPosition: 'side',
+ openAs: 'dropdown',
+ },
},
] );
} );
diff --git a/packages/dataviews/src/types.ts b/packages/dataviews/src/types.ts
index d44e126e3ed178..86b6b86322224c 100644
--- a/packages/dataviews/src/types.ts
+++ b/packages/dataviews/src/types.ts
@@ -689,10 +689,12 @@ export type NormalizedRegularLayout = {
export type PanelLayout = {
type: 'panel';
labelPosition?: LabelPosition;
+ openAs?: 'dropdown' | 'modal';
};
export type NormalizedPanelLayout = {
type: 'panel';
labelPosition: LabelPosition;
+ openAs: 'dropdown' | 'modal';
};
export type CardLayout =