diff --git a/packages/dataviews/src/components/dataform-layouts/panel/context.ts b/packages/dataviews/src/components/dataform-layouts/panel/context.ts new file mode 100644 index 00000000000000..c2fb8f346d9661 --- /dev/null +++ b/packages/dataviews/src/components/dataform-layouts/panel/context.ts @@ -0,0 +1,14 @@ +/** + * WordPress dependencies + */ +import { createContext, useContext } from '@wordpress/element'; + +const PanelMenuContext = createContext< { onClose: () => void } >( { + onClose: () => {}, +} ); + +export function usePanelMenuOnClose() { + return useContext( PanelMenuContext ).onClose; +} + +export default PanelMenuContext; diff --git a/packages/dataviews/src/components/dataform-layouts/panel/index.tsx b/packages/dataviews/src/components/dataform-layouts/panel/index.tsx index fbe81eb3c2e93f..dceeb5bb9b6ad0 100644 --- a/packages/dataviews/src/components/dataform-layouts/panel/index.tsx +++ b/packages/dataviews/src/components/dataform-layouts/panel/index.tsx @@ -4,6 +4,7 @@ import type { FieldLayoutProps, NormalizedPanelLayout } from '../../../types'; import PanelModal from './modal'; import PanelDropdown from './dropdown'; +import PanelMenu from './menu'; export default function FormPanelField< Item >( { data, @@ -24,6 +25,17 @@ export default function FormPanelField< Item >( { ); } + if ( layout.openAs === 'menu' ) { + return ( + + ); + } + return ( ( { + data, + field, + onChange, +}: FieldLayoutProps< Item > ) { + // Use internal state instead of a ref to make sure that the component + // re-renders when the popover's anchor updates. + const [ popoverAnchor, setPopoverAnchor ] = useState< HTMLElement | null >( + null + ); + // Memoize popoverProps to avoid returning a new object every time. + const popoverProps = useMemo( + () => ( { + anchor: popoverAnchor, + placement: 'left-start' as const, + offset: 36, + shift: true, + } ), + [ popoverAnchor ] + ); + + const { fieldDefinition, fieldLabel, summaryFields } = + useFieldFromFormField( field ); + + if ( ! fieldDefinition || ! fieldDefinition.Edit ) { + return null; + } + + return ( +
+ ( + + ) } + renderContent={ ( { onClose } ) => ( + + ) } + /> +
+ ); +} + +function MenuGroupContent< Item >( { + data, + fieldDefinition, + onChange, + onClose, +}: { + data: Item; + fieldDefinition: NormalizedField< Item >; + onChange: FieldLayoutProps< Item >[ 'onChange' ]; + onClose: () => void; +} ) { + const wrappedOnChange = useCallback( + ( value: any ) => { + onChange( value ); + onClose(); + }, + [ onChange, onClose ] + ); + + const contextValue = useMemo( () => ( { onClose } ), [ onClose ] ); + + // We know Edit is non-null because PanelMenu checks before rendering. + const EditComponent = fieldDefinition.Edit!; + + return ( + + + + ); +} + +export default PanelMenu; diff --git a/packages/dataviews/src/components/dataform-layouts/panel/style.scss b/packages/dataviews/src/components/dataform-layouts/panel/style.scss index ea3e8466a268a1..2d1566c9af47ac 100644 --- a/packages/dataviews/src/components/dataform-layouts/panel/style.scss +++ b/packages/dataviews/src/components/dataform-layouts/panel/style.scss @@ -159,6 +159,11 @@ margin-top: $grid-unit-20; } +.dataforms-layouts-panel__field-dropdown--menu .components-popover__content { + padding: 0; + min-width: 200px; +} + .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/components/dataform-layouts/panel/summary-button.tsx b/packages/dataviews/src/components/dataform-layouts/panel/summary-button.tsx index 64eb207dc3e660..477ed2e3d2c6c3 100644 --- a/packages/dataviews/src/components/dataform-layouts/panel/summary-button.tsx +++ b/packages/dataviews/src/components/dataform-layouts/panel/summary-button.tsx @@ -34,6 +34,7 @@ export default function SummaryButton< Item >( { disabled, onClick, 'aria-expanded': ariaExpanded, + 'aria-haspopup': ariaHasPopup = 'dialog', }: { data: Item; field: NormalizedFormField; @@ -44,6 +45,7 @@ export default function SummaryButton< Item >( { disabled?: boolean; onClick: () => void; 'aria-expanded'?: boolean; + 'aria-haspopup'?: 'dialog' | 'menu'; } ) { const labelPosition = ( field.layout as NormalizedPanelLayout ) .labelPosition; @@ -127,7 +129,7 @@ export default function SummaryButton< Item >( { className="dataforms-layouts-panel__field-trigger-icon" aria-label={ ariaLabel } aria-expanded={ ariaExpanded } - aria-haspopup="dialog" + aria-haspopup={ ariaHasPopup } aria-describedby={ `${ controlId }` } onClick={ onClick } > diff --git a/packages/dataviews/src/dataform/stories/index.story.tsx b/packages/dataviews/src/dataform/stories/index.story.tsx index c6cf2e068b097f..e440efeee0690b 100644 --- a/packages/dataviews/src/dataform/stories/index.story.tsx +++ b/packages/dataviews/src/dataform/stories/index.story.tsx @@ -60,7 +60,7 @@ export const LayoutPanel = { openAs: { control: { type: 'select' }, description: 'Chooses how to open the panel.', - options: [ 'default', 'dropdown', 'modal' ], + options: [ 'default', 'dropdown', 'modal', 'menu' ], }, }, }; diff --git a/packages/dataviews/src/dataform/stories/layout-panel.tsx b/packages/dataviews/src/dataform/stories/layout-panel.tsx index 9f13077d6fb5be..a2142d6e3cf14c 100644 --- a/packages/dataviews/src/dataform/stories/layout-panel.tsx +++ b/packages/dataviews/src/dataform/stories/layout-panel.tsx @@ -264,7 +264,7 @@ const getPanelLayoutFromStoryArgs = ( { }: { summary?: string[]; labelPosition?: 'default' | 'top' | 'side' | 'none'; - openAs?: 'default' | 'dropdown' | 'modal'; + openAs?: 'default' | 'dropdown' | 'modal' | 'menu'; } ): Layout | undefined => { const panelLayout: PanelLayout = { type: 'panel', @@ -291,7 +291,7 @@ const LayoutPanelComponent = ( { }: { type: 'default' | 'regular' | 'panel' | 'card'; labelPosition: 'default' | 'top' | 'side' | 'none'; - openAs: 'default' | 'dropdown' | 'modal'; + openAs: 'default' | 'dropdown' | 'modal' | 'menu'; } ) => { const [ post, setPost ] = useState< SamplePost >( { title: 'Hello, World!', diff --git a/packages/dataviews/src/index.ts b/packages/dataviews/src/index.ts index 6e10b72674b0b2..db6a89c1e69a54 100644 --- a/packages/dataviews/src/index.ts +++ b/packages/dataviews/src/index.ts @@ -3,5 +3,6 @@ export { default as DataViewsPicker } from './dataviews-picker'; export { default as DataForm } from './dataform'; export { default as filterSortAndPaginate } from './utils/filter-sort-and-paginate'; export { useFormValidity } from './hooks'; +export { usePanelMenuOnClose } from './components/dataform-layouts/panel/context'; export { VIEW_LAYOUTS } from './components/dataviews-layouts'; export type * from './types'; diff --git a/packages/dataviews/src/types/dataform.ts b/packages/dataviews/src/types/dataform.ts index db568f5e432fad..3f096fee50c198 100644 --- a/packages/dataviews/src/types/dataform.ts +++ b/packages/dataviews/src/types/dataform.ts @@ -24,13 +24,13 @@ export type NormalizedRegularLayout = { export type PanelLayout = { type: 'panel'; labelPosition?: LabelPosition; - openAs?: 'dropdown' | 'modal'; + openAs?: 'dropdown' | 'modal' | 'menu'; summary?: PanelSummaryField; }; export type NormalizedPanelLayout = { type: 'panel'; labelPosition: LabelPosition; - openAs: 'dropdown' | 'modal'; + openAs: 'dropdown' | 'modal' | 'menu'; summary: NormalizedPanelSummaryField; }; diff --git a/packages/edit-site/src/components/post-list/quick-edit-modal.js b/packages/edit-site/src/components/post-list/quick-edit-modal.js index bf4184581bcbb8..aab017fb286c50 100644 --- a/packages/edit-site/src/components/post-list/quick-edit-modal.js +++ b/packages/edit-site/src/components/post-list/quick-edit-modal.js @@ -100,8 +100,8 @@ export function QuickEditModal( { postType, postId, closeModal } ) { label: __( 'Template' ), id: 'template', layout: { - type: 'regular', - labelPosition: 'side', + type: 'panel', + openAs: 'menu', }, }, ]; diff --git a/packages/fields/src/fields/template/index.ts b/packages/fields/src/fields/template/index.ts index dd4cbb28f94382..db6b7616015dd7 100644 --- a/packages/fields/src/fields/template/index.ts +++ b/packages/fields/src/fields/template/index.ts @@ -9,11 +9,13 @@ import type { Field } from '@wordpress/dataviews'; import { __ } from '@wordpress/i18n'; import type { BasePost } from '../../types'; import { TemplateEdit } from './template-edit'; +import { TemplateView } from './template-view'; const templateField: Field< BasePost > = { id: 'template', type: 'text', label: __( 'Template' ), + render: TemplateView, Edit: TemplateEdit, enableSorting: false, filterBy: false, diff --git a/packages/fields/src/fields/template/template-edit.tsx b/packages/fields/src/fields/template/template-edit.tsx index eef5b0734c9977..44707b3abee130 100644 --- a/packages/fields/src/fields/template/template-edit.tsx +++ b/packages/fields/src/fields/template/template-edit.tsx @@ -7,26 +7,21 @@ import { parse } from '@wordpress/blocks'; import type { WpTemplate } from '@wordpress/core-data'; import { store as coreStore } from '@wordpress/core-data'; import type { DataFormControlProps } from '@wordpress/dataviews'; +import { usePanelMenuOnClose } from '@wordpress/dataviews'; /** * Internal dependencies */ -// @ts-expect-error block-editor is not typed correctly. -import { __experimentalBlockPatternsList as BlockPatternsList } from '@wordpress/block-editor'; -import { - Button, - Dropdown, - MenuGroup, - MenuItem, - Modal, -} from '@wordpress/components'; +import { __experimentalBlockPatternsList } from '@wordpress/block-editor'; +import { MenuGroup, MenuItem, Modal } from '@wordpress/components'; import { useAsyncList } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; import { decodeEntities } from '@wordpress/html-entities'; import { __ } from '@wordpress/i18n'; -import { getItemTitle } from '../../actions/utils'; -import type { BasePost } from '../../types'; import { unlock } from '../../lock-unlock'; +import type { BasePost } from '../../types'; + +const BlockPatternsList = __experimentalBlockPatternsList as any; const EMPTY_ARRAY: [] = []; @@ -39,9 +34,8 @@ export const TemplateEdit = ( { const postType = data.type; const postId = typeof data.id === 'number' ? data.id : parseInt( data.id, 10 ); - const slug = data.slug; - const { canSwitchTemplate, templates } = useSelect( + const { templates, canSwitchTemplate } = useSelect( ( select ) => { const allTemplates = select( coreStore ).getEntityRecords< WpTemplate >( @@ -94,47 +88,9 @@ export const TemplateEdit = ( { const shownTemplates = useAsyncList( templatesAsPatterns ); const value = field.getValue( { item: data } ); - const foundTemplate = templates.find( - ( template ) => template.slug === value - ); - - const currentTemplate = useSelect( - ( select ) => { - if ( foundTemplate ) { - return foundTemplate; - } - - let slugToCheck; - // In `draft` status we might not have a slug available, so we use the `single` - // post type templates slug(ex page, single-post, single-product etc..). - // Pages do not need the `single` prefix in the slug to be prioritized - // through template hierarchy. - if ( slug ) { - slugToCheck = - postType === 'page' - ? `${ postType }-${ slug }` - : `single-${ postType }-${ slug }`; - } else { - slugToCheck = - postType === 'page' ? 'page' : `single-${ postType }`; - } - - if ( postType ) { - const templateId = select( coreStore ).getDefaultTemplateId( { - slug: slugToCheck, - } ); - - return select( coreStore ).getEntityRecord( - 'postType', - 'wp_template', - templateId - ); - } - }, - [ foundTemplate, postType, slug ] - ); const [ showModal, setShowModal ] = useState( false ); + const onClosePanelMenu = usePanelMenuOnClose(); const onChangeControl = useCallback( ( newValue: string ) => @@ -145,47 +101,29 @@ export const TemplateEdit = ( { ); return ( -
- ( - - ) } - renderContent={ ( { onToggle } ) => ( - + <> + + { + onClosePanelMenu(); + setShowModal( true ); + } } + > + { __( 'Change template' ) } + + { + // The default template in a post is indicated by an empty string + value !== '' && ( { - setShowModal( true ); - onToggle(); + onChangeControl( '' ); } } > - { __( 'Change template' ) } + { __( 'Use default template' ) } - { - // The default template in a post is indicated by an empty string - value !== '' && ( - { - onChangeControl( '' ); - onToggle(); - } } - > - { __( 'Use default template' ) } - - ) - } - - ) } - /> + ) + } + { showModal && ( { @@ -208,6 +145,6 @@ export const TemplateEdit = ( { ) } -
+ ); }; diff --git a/packages/fields/src/fields/template/template-view.tsx b/packages/fields/src/fields/template/template-view.tsx new file mode 100644 index 00000000000000..4e8ee6a3d3aa6f --- /dev/null +++ b/packages/fields/src/fields/template/template-view.tsx @@ -0,0 +1,74 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import type { WpTemplate } from '@wordpress/core-data'; +import type { DataViewRenderFieldProps } from '@wordpress/dataviews'; +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Internal dependencies + */ +import type { BasePost } from '../../types'; + +export const TemplateView = ( { + item, +}: DataViewRenderFieldProps< BasePost > ) => { + const template = item.template; + const postType = item.type; + const slug = item.slug; + + const templateTitle = useSelect( + ( select ) => { + const { getEntityRecords, getDefaultTemplateId, getEntityRecord } = + select( coreStore ); + + // If a specific template slug is set, find it directly. + if ( template ) { + const templates = getEntityRecords< WpTemplate >( + 'postType', + 'wp_template', + { per_page: -1, post_type: postType } + ); + const found = templates?.find( ( t ) => t.slug === template ); + if ( found ) { + return decodeEntities( found.title.rendered ); + } + } + + // Resolve the default template for this post type. + let slugToCheck; + if ( slug ) { + slugToCheck = + postType === 'page' + ? `${ postType }-${ slug }` + : `single-${ postType }-${ slug }`; + } else { + slugToCheck = + postType === 'page' ? 'page' : `single-${ postType }`; + } + + if ( postType ) { + const templateId = getDefaultTemplateId( { + slug: slugToCheck, + } ); + if ( templateId ) { + const defaultTemplate = getEntityRecord< WpTemplate >( + 'postType', + 'wp_template', + templateId + ); + if ( defaultTemplate ) { + return decodeEntities( defaultTemplate.title.rendered ); + } + } + } + + return ''; + }, + [ template, postType, slug ] + ); + + return <>{ templateTitle }; +};