diff --git a/lib/compat/wordpress-7.0/rest-api.php b/lib/compat/wordpress-7.0/rest-api.php index 75301fdac94d7d..ff5a757f1907f8 100644 --- a/lib/compat/wordpress-7.0/rest-api.php +++ b/lib/compat/wordpress-7.0/rest-api.php @@ -65,3 +65,71 @@ function gutenberg_parse_pattern_blocks_in_block_templates( $query_result, $quer } add_filter( 'get_block_templates', 'gutenberg_parse_pattern_blocks_in_block_templates', 10, 3 ); + +/** + * Registers the 'overlay' template part area when the experiment is enabled. + * + * @param array $areas Array of template part area definitions. + * @return array Modified array of template part area definitions. + */ +function gutenberg_register_overlay_template_part_area( $areas ) { + if ( ! gutenberg_is_experiment_enabled( 'gutenberg-customizable-navigation-overlays' ) ) { + return $areas; + } + + $areas[] = array( + 'area' => 'overlay', + 'label' => __( 'Overlay', 'gutenberg' ), + 'description' => __( 'Custom overlay area for navigation overlays.', 'gutenberg' ), + 'icon' => 'overlay', + 'area_tag' => 'div', + ); + + return $areas; +} +add_filter( 'default_wp_template_part_areas', 'gutenberg_register_overlay_template_part_area' ); + +/** + * Registers the 'overlay' pattern category when the experiment is enabled. + */ +function gutenberg_register_overlay_pattern_category() { + if ( ! gutenberg_is_experiment_enabled( 'gutenberg-customizable-navigation-overlays' ) ) { + return; + } + + register_block_pattern_category( + 'overlay', + array( 'label' => __( 'Overlay', 'gutenberg' ) ) + ); +} +add_action( 'init', 'gutenberg_register_overlay_pattern_category' ); + +/** + * Registers the default overlay pattern when the experiment is enabled. + */ +function gutenberg_register_overlay_pattern() { + if ( ! gutenberg_is_experiment_enabled( 'gutenberg-customizable-navigation-overlays' ) ) { + return; + } + + register_block_pattern( + 'gutenberg/overlay-default', + array( + 'title' => __( 'Overlay', 'gutenberg' ), + 'categories' => array( 'overlay' ), + 'blockTypes' => array( 'core/template-part/overlay' ), + 'content' => ' +
+
+
+
+ + + +
+
+', + ) + ); +} +add_action( 'init', 'gutenberg_register_overlay_pattern' ); diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index e71d6d1b651ac7..29d5bea26ea4c1 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -82,6 +82,7 @@ import * as navigation from './navigation'; import * as navigationLink from './navigation-link'; import * as navigationSubmenu from './navigation-submenu'; import * as nextpage from './nextpage'; +import * as overlayClose from './overlay-close'; import * as pattern from './pattern'; import * as pageList from './page-list'; import * as pageListItem from './page-list-item'; @@ -187,6 +188,7 @@ const getAllBlocks = () => { missing, more, nextpage, + overlayClose, pageList, pageListItem, pattern, diff --git a/packages/block-library/src/navigation/block.json b/packages/block-library/src/navigation/block.json index 1315cce9318fbb..7ccb7578263cd2 100644 --- a/packages/block-library/src/navigation/block.json +++ b/packages/block-library/src/navigation/block.json @@ -84,6 +84,9 @@ "templateLock": { "type": [ "string", "boolean" ], "enum": [ "all", "insert", "contentOnly", false ] + }, + "overlayTemplatePartId": { + "type": "string" } }, "providesContext": { diff --git a/packages/block-library/src/navigation/edit/index.js b/packages/block-library/src/navigation/edit/index.js index 74b29f2fc4a07d..62f0512748bb17 100644 --- a/packages/block-library/src/navigation/edit/index.js +++ b/packages/block-library/src/navigation/edit/index.js @@ -29,6 +29,7 @@ import { BlockControls, } from '@wordpress/block-editor'; import { EntityProvider, store as coreStore } from '@wordpress/core-data'; +import { store as editorStore } from '@wordpress/editor'; import { useDispatch, useSelect } from '@wordpress/data'; import { @@ -43,6 +44,7 @@ import { Notice, ToolbarButton, ToolbarGroup, + PanelBody, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { speak } from '@wordpress/a11y'; @@ -77,8 +79,11 @@ import MenuInspectorControls from './menu-inspector-controls'; import DeletedNavigationWarning from './deleted-navigation-warning'; import AccessibleDescription from './accessible-description'; import AccessibleMenuDescription from './accessible-menu-description'; +import OverlaySelector from './overlay-selector'; import { unlock } from '../../lock-unlock'; import { useToolsPanelDropdownMenuProps } from '../../utils/hooks'; + +const TEMPLATE_PART_POST_TYPE = 'wp_template_part'; import { DEFAULT_BLOCK } from '../constants'; /** @@ -262,7 +267,7 @@ function Navigation( { } ) { const { openSubmenusOnClick, - overlayMenu, + overlayMenu: savedOverlayMenu, showSubmenuIcon, templateLock, layout: { @@ -272,8 +277,37 @@ function Navigation( { } = {}, hasIcon, icon = 'handle', + overlayTemplatePartId: savedOverlayTemplatePartId, } = attributes; + // Check if we're inside an overlay template part + const isInOverlayTemplatePart = useSelect( ( select ) => { + const { getCurrentPostType, getCurrentPostId } = unlock( + select( editorStore ) + ); + const { getEditedEntityRecord } = select( coreStore ); + const postType = getCurrentPostType(); + const postId = getCurrentPostId(); + + if ( postType !== TEMPLATE_PART_POST_TYPE || ! postId ) { + return false; + } + + const templatePart = getEditedEntityRecord( + 'postType', + TEMPLATE_PART_POST_TYPE, + postId + ); + + return templatePart?.area === 'overlay'; + }, [] ); + + // Ignore overlay attributes if we're inside an overlay template part + const overlayMenu = isInOverlayTemplatePart ? 'never' : savedOverlayMenu; + const overlayTemplatePartId = isInOverlayTemplatePart + ? undefined + : savedOverlayTemplatePartId; + const ref = attributes.ref; const setRef = useCallback( @@ -641,82 +675,91 @@ function Navigation( { const dropdownMenuProps = useToolsPanelDropdownMenuProps(); + // Get navigation function for overlay template parts + const onNavigateToEntityRecord = useSelect( + ( select ) => + select( blockEditorStore ).getSettings().onNavigateToEntityRecord, + [] + ); + + // Check if the experiment is enabled + const isExperimentEnabled = + typeof window !== 'undefined' && + window.__experimentalNavigationOverlays; + const stylingInspectorControls = ( <> - - { hasSubmenuIndicatorSetting && ( - { - setAttributes( { - showSubmenuIcon: true, - openSubmenusOnClick: false, - overlayMenu: 'mobile', - hasIcon: true, - icon: 'handle', - } ); - } } - dropdownMenuProps={ dropdownMenuProps } - > - { isResponsive && ( - <> - - { overlayMenuPreview && ( - + ) } + { isResponsive && ( + <> + + { overlayMenuPreview && ( + + + ) } + + ) } - overlayMenu !== 'mobile' } - label={ __( 'Overlay Menu' ) } - onDeselect={ () => - setAttributes( { overlayMenu: 'mobile' } ) - } - isShownByDefault - > - + { /* + * Hide custom overlay controls when overlay visibility is "Off". + * Attributes are preserved (not modified) so that if the user + * toggles back to "Mobile" or "Always", their configured overlay + * will still be available. + * + * Note: PHP rendering code will need to account for this situation + * and should not render custom overlays when overlayMenu is "never", + * even if overlayTemplatePartId is set. + */ } + { overlayMenu !== 'never' && ( + { + setAttributes( { + overlayTemplatePartId: newValue, + } ); + } } + /> + ) } + + + + ) } + + { hasSubmenuIndicatorSetting && ( + { + setAttributes( { + showSubmenuIcon: true, + openSubmenusOnClick: false, + hasIcon: true, + icon: 'handle', + } ); + } } + dropdownMenuProps={ dropdownMenuProps } + > { hasSubmenus && ( <>

@@ -880,6 +960,8 @@ function Navigation( { isHiddenByDefault={ isHiddenByDefault } overlayBackgroundColor={ overlayBackgroundColor } overlayTextColor={ overlayTextColor } + overlayTemplatePartId={ overlayTemplatePartId } + onNavigateToEntityRecord={ onNavigateToEntityRecord } > { isEntityAvailable && ( + select( blockEditorStore ).getSettings().onNavigateToEntityRecord, + [] + ); + + const overlayParts = + templateParts?.filter( ( part ) => part.area === 'overlay' ) || []; + + const options = [ + { label: __( 'None' ), value: '' }, + ...overlayParts.map( ( part ) => { + const templatePartId = createTemplatePartId( + part.theme, + part.slug + ); + return { + label: part.title?.rendered || part.slug || __( 'Untitled' ), + value: templatePartId, + }; + } ), + ]; + + const handleEditClick = () => { + if ( value && onNavigateToEntityRecord ) { + onNavigateToEntityRecord( { + postId: value, + postType: 'wp_template_part', + } ); + } + }; + + const handleCreateNew = async () => { + setIsCreating( true ); + + // Generate a unique title by checking existing overlay template parts + const baseTitle = __( 'Overlay' ); + const existingTitles = overlayParts.map( + ( part ) => part.title?.rendered || '' + ); + + // Find the next available number + let titleNumber = 1; + let uniqueTitle = baseTitle; + while ( existingTitles.includes( uniqueTitle ) ) { + titleNumber++; + uniqueTitle = `${ baseTitle } ${ titleNumber }`; + } + + const cleanSlug = + kebabCase( uniqueTitle ).replace( /[^\w-]+/g, '' ) || + 'wp-custom-overlay'; + + try { + const templatePart = await saveEntityRecord( + 'postType', + 'wp_template_part', + { + title: uniqueTitle, + slug: cleanSlug, + content: serialize( [] ), + area: 'overlay', + }, + { throwOnError: true } + ); + + // Create the proper template part ID format (theme//slug) + const templatePartId = createTemplatePartId( + templatePart.theme, + templatePart.slug + ); + + // Set the new template part as the selected overlay + onChange( templatePartId ); + + // Navigate to the new template part for editing + if ( onNavigateToEntityRecord && templatePartId ) { + onNavigateToEntityRecord( { + postId: templatePartId, + postType: 'wp_template_part', + } ); + } + } catch ( error ) { + console.error( 'Failed to create overlay template part:', error ); + } finally { + setIsCreating( false ); + } + }; + + // Get the selected template part entity + const selectedTemplatePart = useSelect( + ( select ) => { + if ( ! value ) { + return null; + } + const { getEditedEntityRecord } = select( coreStore ); + try { + return getEditedEntityRecord( + 'postType', + 'wp_template_part', + value + ); + } catch { + return null; + } + }, + [ value ] + ); + + // Parse template part content to blocks for preview + const previewBlocks = useMemo( () => { + if ( ! selectedTemplatePart ) { + return null; + } + // Content can be either a string directly or content.raw + const contentString = + selectedTemplatePart.content?.raw || selectedTemplatePart.content; + if ( ! contentString || typeof contentString !== 'string' ) { + return null; + } + try { + const blocks = parse( contentString ); + // Filter out null blocks that parse can return + const validBlocks = blocks?.filter( Boolean ) || []; + return validBlocks.length > 0 ? validBlocks : null; + } catch ( error ) { + console.error( 'Error parsing blocks:', error ); + return null; + } + }, [ selectedTemplatePart ] ); + + // Check if we can edit (value exists, template part exists, and navigation is available) + const canEdit = + value && + selectedTemplatePart && + onNavigateToEntityRecord && + ! isCreating; + + return ( + + { value && + ! isCreating && + previewBlocks && + previewBlocks.length > 0 && ( +
+ +
{ + if ( + ( event.key === 'Enter' || + event.key === ' ' ) && + canEdit + ) { + event.preventDefault(); + handleEditClick(); + } + } } + aria-label={ __( 'Edit overlay' ) } + > + + + +
+
+
+ ) } + { + onChange( newValue === '' ? undefined : newValue ); + } } + disabled={ isCreating } + help={ + <> + { __( + 'Select a template part to use as the custom overlay or ' + ) } + + . + + } + /> + +
+ ); +} diff --git a/packages/block-library/src/navigation/edit/responsive-wrapper.js b/packages/block-library/src/navigation/edit/responsive-wrapper.js index 886e808965ce4b..e3785641098bbd 100644 --- a/packages/block-library/src/navigation/edit/responsive-wrapper.js +++ b/packages/block-library/src/navigation/edit/responsive-wrapper.js @@ -27,6 +27,8 @@ export default function ResponsiveWrapper( { overlayTextColor, hasIcon, icon, + overlayTemplatePartId, + onNavigateToEntityRecord, } ) { if ( ! isResponsive ) { return children; @@ -75,6 +77,18 @@ export default function ResponsiveWrapper( { } ), }; + const handleOpenClick = () => { + // If there's a custom overlay template part, navigate to it + if ( overlayTemplatePartId && onNavigateToEntityRecord && ! isOpen ) { + onNavigateToEntityRecord( { + postId: overlayTemplatePartId, + postType: 'wp_template_part', + } ); + } else { + onToggle( true ); + } + }; + return ( <> { ! isOpen && ( @@ -83,7 +97,7 @@ export default function ResponsiveWrapper( { aria-haspopup="true" aria-label={ hasIcon && __( 'Open menu' ) } className={ openButtonClasses } - onClick={ () => onToggle( true ) } + onClick={ handleOpenClick } > { hasIcon && } { ! hasIcon && __( 'Menu' ) } diff --git a/packages/block-library/src/overlay-close/block.json b/packages/block-library/src/overlay-close/block.json new file mode 100644 index 00000000000000..d7dc325a9916b4 --- /dev/null +++ b/packages/block-library/src/overlay-close/block.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "core/overlay-close", + "title": "Overlay Close", + "category": "design", + "description": "A close button for navigation overlays.", + "textdomain": "default", + "attributes": { + "displayMode": { + "type": "string", + "default": "icon" + } + }, + "supports": { + "html": false, + "anchor": true, + "color": { + "gradients": true, + "__experimentalDefaultControls": { + "background": true, + "text": true + } + }, + "spacing": { + "margin": true, + "padding": true + } + }, + "editorStyle": "wp-block-overlay-close-editor", + "style": "wp-block-overlay-close" +} + diff --git a/packages/block-library/src/overlay-close/edit.js b/packages/block-library/src/overlay-close/edit.js new file mode 100644 index 00000000000000..648dc6d06829d7 --- /dev/null +++ b/packages/block-library/src/overlay-close/edit.js @@ -0,0 +1,81 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ +import { + InspectorControls, + useBlockProps, + __experimentalUseColorProps as useColorProps, +} from '@wordpress/block-editor'; +import { + Button, + PanelBody, + __experimentalToggleGroupControl as ToggleGroupControl, + __experimentalToggleGroupControlOption as ToggleGroupControlOption, +} from '@wordpress/components'; +import { close } from '@wordpress/icons'; +import { __ } from '@wordpress/i18n'; + +export default function OverlayCloseEdit( { attributes, setAttributes } ) { + const { displayMode = 'icon' } = attributes; + const colorProps = useColorProps( attributes ); + const blockProps = useBlockProps( { + className: 'wp-block-overlay-close', + style: { + ...colorProps.style, + }, + } ); + + const showIcon = displayMode === 'icon' || displayMode === 'both'; + const showText = displayMode === 'text' || displayMode === 'both'; + + return ( + <> + + + + setAttributes( { displayMode: value } ) + } + isBlock + > + + + + + + +
+ +
+ + ); +} diff --git a/packages/block-library/src/overlay-close/editor.scss b/packages/block-library/src/overlay-close/editor.scss new file mode 100644 index 00000000000000..82d2760bd180b3 --- /dev/null +++ b/packages/block-library/src/overlay-close/editor.scss @@ -0,0 +1,17 @@ +.wp-block-overlay-close { + .wp-block-overlay-close__button { + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem; + background: transparent; + border: none; + cursor: pointer; + color: currentColor; + + &:hover { + opacity: 0.8; + } + } +} + diff --git a/packages/block-library/src/overlay-close/index.js b/packages/block-library/src/overlay-close/index.js new file mode 100644 index 00000000000000..bc3359bd7d1934 --- /dev/null +++ b/packages/block-library/src/overlay-close/index.js @@ -0,0 +1,63 @@ +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; +import { close } from '@wordpress/icons'; +import { select as dataSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { store as coreStore } from '@wordpress/core-data'; +import { unlock } from '../lock-unlock'; + +/** + * Internal dependencies + */ +import initBlock from '../utils/init-block'; +import metadata from './block.json'; +import edit from './edit'; +import save from './save'; + +const { name } = metadata; +const TEMPLATE_PART_POST_TYPE = 'wp_template_part'; + +export { metadata, name }; + +export const settings = { + icon: close, + edit, + save, +}; + +export const init = () => { + initBlock( { name, metadata, settings } ); + + // Restrict overlay-close block to only overlay template parts + addFilter( + 'blockEditor.__unstableCanInsertBlockType', + 'core/overlay-close/restrict-to-overlay', + ( canInsert, blockType ) => { + if ( blockType.name !== 'core/overlay-close' ) { + return canInsert; + } + + // Check if we're in an overlay template part + const { getCurrentPostType, getCurrentPostId } = unlock( + dataSelect( editorStore ) + ); + const { getEditedEntityRecord } = dataSelect( coreStore ); + const postType = getCurrentPostType(); + const postId = getCurrentPostId(); + + if ( postType !== TEMPLATE_PART_POST_TYPE || ! postId ) { + return false; + } + + const templatePart = getEditedEntityRecord( + 'postType', + TEMPLATE_PART_POST_TYPE, + postId + ); + + return templatePart?.area === 'overlay'; + } + ); +}; diff --git a/packages/block-library/src/overlay-close/save.js b/packages/block-library/src/overlay-close/save.js new file mode 100644 index 00000000000000..ce15f4e152fc41 --- /dev/null +++ b/packages/block-library/src/overlay-close/save.js @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import clsx from 'clsx'; + +/** + * WordPress dependencies + */ +import { + useBlockProps, + __experimentalGetColorClassesAndStyles as getColorClassesAndStyles, +} from '@wordpress/block-editor'; + +export default function OverlayCloseSave( { attributes, className } ) { + const { displayMode = 'icon' } = attributes; + const colorProps = getColorClassesAndStyles( attributes ); + + const showIcon = displayMode === 'icon' || displayMode === 'both'; + const showText = displayMode === 'text' || displayMode === 'both'; + + return ( +
+ +
+ ); +} diff --git a/packages/block-library/src/overlay-close/style.scss b/packages/block-library/src/overlay-close/style.scss new file mode 100644 index 00000000000000..7a6853f5b64984 --- /dev/null +++ b/packages/block-library/src/overlay-close/style.scss @@ -0,0 +1,26 @@ +.wp-block-overlay-close { + .wp-block-overlay-close__button { + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem; + background: transparent; + border: none; + cursor: pointer; + color: currentColor; + + &:hover { + opacity: 0.8; + } + } + + .wp-block-overlay-close__icon { + font-size: 1.5rem; + line-height: 1; + } + + .wp-block-overlay-close__text { + margin-left: 0.5rem; + } +} + diff --git a/packages/block-library/src/overlay-close/use-template-part-area.js b/packages/block-library/src/overlay-close/use-template-part-area.js new file mode 100644 index 00000000000000..5dc5a79c2a34d6 --- /dev/null +++ b/packages/block-library/src/overlay-close/use-template-part-area.js @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { store as coreStore } from '@wordpress/core-data'; +import { unlock } from '../lock-unlock'; + +const TEMPLATE_PART_POST_TYPE = 'wp_template_part'; + +/** + * Hook to check if we're currently editing an overlay template part. + * + * @return {boolean} True if editing an overlay template part, false otherwise. + */ +export function useIsOverlayTemplatePart() { + return useSelect( ( select ) => { + const { getCurrentPostType, getCurrentPostId } = unlock( + select( editorStore ) + ); + const { getEditedEntityRecord } = select( coreStore ); + const postType = getCurrentPostType(); + const postId = getCurrentPostId(); + + if ( postType !== TEMPLATE_PART_POST_TYPE || ! postId ) { + return false; + } + + const templatePart = getEditedEntityRecord( + 'postType', + TEMPLATE_PART_POST_TYPE, + postId + ); + + return templatePart?.area === 'overlay'; + }, [] ); +} diff --git a/packages/editor/src/components/visual-editor/index.js b/packages/editor/src/components/visual-editor/index.js index 9c2d1326394cf5..12f3dd3a7b5044 100644 --- a/packages/editor/src/components/visual-editor/index.js +++ b/packages/editor/src/components/visual-editor/index.js @@ -117,6 +117,7 @@ function VisualEditor( { isPreview, styles, canvasMinHeight, + isOverlayTemplatePart, } = useSelect( ( select ) => { const { getCurrentPostId, @@ -150,6 +151,20 @@ function VisualEditor( { ) : undefined; + // Check if we're editing an overlay template part + let _isOverlayTemplatePart = false; + if ( postTypeSlug === TEMPLATE_PART_POST_TYPE ) { + const currentPostId = getCurrentPostId(); + const templatePart = currentPostId + ? getEditedEntityRecord( + 'postType', + TEMPLATE_PART_POST_TYPE, + currentPostId + ) + : null; + _isOverlayTemplatePart = templatePart?.area === 'overlay'; + } + return { renderingMode: _renderingMode, postContentAttributes: editorSettings.postContentAttributes, @@ -168,6 +183,7 @@ function VisualEditor( { isPreview: editorSettings.isPreviewMode, styles: editorSettings.styles, canvasMinHeight: getCanvasMinHeight(), + isOverlayTemplatePart: _isOverlayTemplatePart, }; }, [] ); const { isCleanNewPost } = useSelect( editorStore ); @@ -357,6 +373,11 @@ function VisualEditor( { ); const iframeStyles = useMemo( () => { + // Full-height styles for overlay template parts + const overlayFullHeightCSS = isOverlayTemplatePart + ? `.block-editor-iframe__html{height:100vh;overflow:hidden;}.block-editor-iframe__body{height:100vh;min-height:100vh;display:flex;flex-direction:column;}.is-root-container{min-height:100vh;height:100%;flex:1;display:flex;flex-direction:column;}` + : ''; + return [ ...( styles ?? [] ), { @@ -377,12 +398,19 @@ function VisualEditor( { enableResizing ? `.block-editor-iframe__html{background:var(--wp-editor-canvas-background);display:flex;align-items:center;justify-content:center;min-height:100vh;}.block-editor-iframe__body{width:100%;}` : '' - }`, + } + ${ overlayFullHeightCSS }`, // The CSS above centers the body content vertically when resizing is enabled and applies a background // color to the iframe HTML element to match the background color of the editor canvas. }, ]; - }, [ styles, enableResizing, calculatedMinHeight, paddingStyle ] ); + }, [ + styles, + enableResizing, + calculatedMinHeight, + paddingStyle, + isOverlayTemplatePart, + ] ); const typewriterRef = useTypewriter(); contentRef = useMergeRefs( [ @@ -411,6 +439,7 @@ function VisualEditor( { 'has-padding': isFocusedEntity || enableResizing, 'is-resizable': enableResizing, 'is-iframed': ! disableIframe, + 'is-overlay-template-part': isOverlayTemplatePart, } ) } >