diff --git a/mu-plugins/blocks/modal/icons/close.svg b/mu-plugins/blocks/modal/icons/close.svg new file mode 100644 index 000000000..1c7123ec0 --- /dev/null +++ b/mu-plugins/blocks/modal/icons/close.svg @@ -0,0 +1,3 @@ + diff --git a/mu-plugins/blocks/modal/index.php b/mu-plugins/blocks/modal/index.php new file mode 100644 index 000000000..527f61d7a --- /dev/null +++ b/mu-plugins/blocks/modal/index.php @@ -0,0 +1,137 @@ + __NAMESPACE__ . '\render', + ) + ); + register_block_type( + __DIR__ . '/build/inner-content', + array( + 'render_callback' => __NAMESPACE__ . '\render_inner_content', + ) + ); +} + +/** + * Returns a local SVG icon. + * + * @param string $icon Name of the icon to render, corresponds to file name. + * @return string + */ +function render_icon( $icon ) { + $file_path = __DIR__ . '/icons/' . $icon . '.svg'; + if ( file_exists( $file_path ) ) { + return file_get_contents( $file_path ); + } +} + +/** + * Render the block content for the modal/popover/inline container. + * + * The modal requires more HTML for micromodal support, but inline and popover + * use the same markup with slightly different CSS. + * + * @param array $attributes Block attributes. + * @param string $content Block default content. + * @param WP_Block $block Block instance. + * + * @return string Returns the block markup. + */ +function render_inner_content( $attributes, $content, $block ) { + // Fetch the type from the parent block. + $type = $block->context['wporg/modal/type'] ?? ''; + if ( ! $type ) { + return; + } + + if ( 'inline' === $type || 'popover' === $type ) { + $wrapper_attributes = get_block_wrapper_attributes( array( 'class' => 'wporg-modal__modal alignwide' ) ); + return sprintf( + '
%2$s
', + $wrapper_attributes, + $content + ); + } + + $wrapper_attributes = get_block_wrapper_attributes(); + $close_icon = render_icon( 'close' ); + + return << +
+ +
+ +HTML; +} + +/** + * Render the block content for the parent Modal block (button). The modal + * container itself is rendered by the child block. + * + * @param array $attributes Block attributes. + * @param string $content Block default content. + * @param WP_Block $block Block instance. + * + * @return string Returns the block markup. + */ +function render( $attributes, $content, $block ) { + $type = $attributes['type']; + $class = 'is-type-' . $type; + + // Replace the button block's link with an HTML button, retain all block styles. + // This only replaces the first link, so the modal can still contain links/buttons. + if ( preg_match( '/(]*)>)(.*?)(<\/a>)/i', $content, $matches ) ) { + $link = $matches[0]; // full match. + // Add in the modal button class, used by the view script. + $attributes = str_replace( 'class="', 'class="wporg-modal__button ', $matches[2] ); + $button = sprintf( + '', + $attributes, + $matches[3] // innerText. + ); + $content = str_replace( $link, $button, $content ); + } + + $wrapper_attributes = get_block_wrapper_attributes( array( 'class' => $class ) ); + return sprintf( + '
%2$s
', + $wrapper_attributes, + $content + ); +} diff --git a/mu-plugins/blocks/modal/postcss/editor-style.pcss b/mu-plugins/blocks/modal/postcss/editor-style.pcss new file mode 100644 index 000000000..cc7892e3a --- /dev/null +++ b/mu-plugins/blocks/modal/postcss/editor-style.pcss @@ -0,0 +1,9 @@ +.wporg-modal__modal { + border: 1px dashed #000; + padding: 8px; +} + +.wp-block-wporg-modal.is-selected .wporg-modal__modal, +.wp-block-wporg-modal.has-child-selected .wporg-modal__modal { + display: block; +} diff --git a/mu-plugins/blocks/modal/postcss/style.pcss b/mu-plugins/blocks/modal/postcss/style.pcss new file mode 100644 index 000000000..d6913dde2 --- /dev/null +++ b/mu-plugins/blocks/modal/postcss/style.pcss @@ -0,0 +1,93 @@ +.wporg-modal__modal { + display: none; +} + +.wporg-modal__modal.is-open { + display: block; +} + +.wporg-modal__overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 99999; /* Needs to be at leas this to overlay admin bar. */ + background: rgba(0, 0, 0, 0.6); + display: flex; + justify-content: center; + align-items: center; +} + +.wporg-modal__container { + width: 100%; + max-width: var(--wp--style--global--content-size); + max-height: 100vh; + overflow-y: auto; + box-sizing: border-box; + + /* This extra div is needed to prevent clicks inside the container padding from closing the modal. */ + & > div { + position: relative; + padding: var(--wp--preset--spacing--40); + background-color: var(--wp--preset--color--white); + + & > button + * { + margin-top: 0; + } + + & > *:last-child { + margin-bottom: 0; + } + } +} + +.wporg-modal__button[data-micromodal-close] { + position: absolute; + top: var(--wp--preset--spacing--10); + right: var(--wp--preset--spacing--10); + width: calc(24px + var(--wp--preset--spacing--10)); + height: calc(24px + var(--wp--preset--spacing--10)); + line-height: 1; + padding: 0; + box-shadow: none; + text-decoration: none; + border: none; + color: currentColor; + background-color: transparent; + background-image: none; + cursor: pointer; + z-index: 10; /* Just enough to raise it above the other modal content. */ + + & svg { + vertical-align: middle; + fill: currentColor; + } +} + +.wp-block-wporg-modal.is-type-popover { + position: relative; + + & .wporg-modal__modal { + position: absolute; + top: 100%; + left: 0; + z-index: 2; + + opacity: 0; + overflow: hidden; + visibility: hidden; + height: 0; + width: 0; + + &.is-open { + opacity: 1; + overflow: visible; + visibility: visible; + + height: auto; + min-width: 200px; + width: 100%; + } + } +} diff --git a/mu-plugins/blocks/modal/src/inner-content/block.json b/mu-plugins/blocks/modal/src/inner-content/block.json new file mode 100644 index 000000000..d8eb55464 --- /dev/null +++ b/mu-plugins/blocks/modal/src/inner-content/block.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "wporg/modal-inner-content", + "title": "Modal Content", + "icon": "text-page", + "category": "layout", + "description": "Hidden content which appears when a button is clicked.", + "textdomain": "wporg", + "attributes": {}, + "usesContext": [ "wporg/modal/type" ], + "parent": [ "wporg/modal" ], + "supports": { + "color": { + "text": true, + "background": true, + "link": true + }, + "spacing": { + "margin": true, + "padding": true, + "__experimentalDefaultControls": { + "margin": true, + "padding": true + } + }, + "typography": { + "fontSize": true, + "lineHeight": true + }, + "__experimentalBorder": { + "color": true, + "radius": true, + "style": true, + "width": true, + "__experimentalDefaultControls": { + "color": true, + "radius": true, + "style": true, + "width": true + } + } + }, + "editorScript": "file:./index.js" +} diff --git a/mu-plugins/blocks/modal/src/inner-content/index.js b/mu-plugins/blocks/modal/src/inner-content/index.js new file mode 100644 index 000000000..bb0165bf4 --- /dev/null +++ b/mu-plugins/blocks/modal/src/inner-content/index.js @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import { registerBlockType } from '@wordpress/blocks'; +import { InnerBlocks, useBlockProps } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; + +function Edit() { + return ( +
+
+ +
+
+ ); +} + +registerBlockType( metadata.name, { + edit: Edit, + save: () => { + return ; + }, +} ); diff --git a/mu-plugins/blocks/modal/src/modal/block.json b/mu-plugins/blocks/modal/src/modal/block.json new file mode 100644 index 000000000..cc2e745c9 --- /dev/null +++ b/mu-plugins/blocks/modal/src/modal/block.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "wporg/modal", + "title": "Modal", + "icon": "button", + "category": "layout", + "description": "A container hidden behind a button, which pops up on click.", + "textdomain": "wporg", + "attributes": { + "label": { + "type": "string" + }, + "type": { + "type": "string", + "default": "modal", + "enum": [ "inline", "modal", "popover" ] + } + }, + "supports": { + "align": true, + "spacing": { + "margin": true, + "padding": true + } + }, + "providesContext": { + "wporg/modal/type": "type" + }, + "editorScript": "file:./index.js", + "editorStyle": "file:../editor-style.css", + "style": "file:../style.css", + "viewScript": "file:./view.js" +} diff --git a/mu-plugins/blocks/modal/src/modal/index.js b/mu-plugins/blocks/modal/src/modal/index.js new file mode 100644 index 000000000..71495f5cc --- /dev/null +++ b/mu-plugins/blocks/modal/src/modal/index.js @@ -0,0 +1,50 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { registerBlockType } from '@wordpress/blocks'; +import { InnerBlocks, useBlockProps } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; + +function Edit() { + return ( +
+ +
+ ); +} + +registerBlockType( metadata.name, { + edit: Edit, + save: () => { + return ; + }, + variations: [ + { + name: 'default', + title: metadata.title, + attributes: { type: 'modal' }, + scope: [ 'inserter', 'transform' ], + isDefault: true, + isActive: [ 'type' ], + }, + { + name: 'inline', + title: __( 'Collapsed', 'wporg' ), + attributes: { type: 'inline' }, + scope: [ 'inserter', 'transform' ], + isActive: [ 'type' ], + }, + { + name: 'popover', + title: __( 'Popover', 'wporg' ), + attributes: { type: 'popover' }, + scope: [ 'inserter', 'transform' ], + isActive: [ 'type' ], + }, + ], +} ); diff --git a/mu-plugins/blocks/modal/src/modal/view.js b/mu-plugins/blocks/modal/src/modal/view.js new file mode 100644 index 000000000..f92124ac1 --- /dev/null +++ b/mu-plugins/blocks/modal/src/modal/view.js @@ -0,0 +1,109 @@ +/** + * External dependencies + */ +import MicroModal from 'micromodal'; + +let idCounter = 0; +function getModalId( prefix = 'modal-' ) { + idCounter++; + return prefix + idCounter; +} + +/** + * Set up the modal behavior for the modal type. + * + * @param {HTMLElement} container + */ +function intializeModal( container ) { + const modalId = getModalId( 'wporg-modal-' ); + container.querySelector( '.wporg-modal__button' ).setAttribute( 'data-micromodal-trigger', modalId ); + container.querySelector( '.wporg-modal__modal' ).id = modalId; + + MicroModal.init( { + onShow: ( modal ) => { + const button = container.querySelector( + `.wporg-modal__button[data-micromodal-trigger=${ modal.id }]` + ); + button.setAttribute( 'aria-expanded', true ); + }, + onClose: ( modal ) => { + const button = container.querySelector( + `.wporg-modal__button[data-micromodal-trigger=${ modal.id }]` + ); + button.setAttribute( 'aria-expanded', false ); + }, + } ); +} + +/** + * Set up the handlers for opening/closing the popover drawer. + * + * @param {HTMLElement} container + */ +function intializePopover( container ) { + intializeInline( container, true ); +} + +/** + * Set up the click handler for opening/closing the inline drawer. + * + * If `shouldAutoClose` is true (for the "popover" style), it adds handlers + * for closing when click or focus moves out of the popover. + * + * See the navigation block submenu behavior. + * + * @param {HTMLElement} container + * @param {boolean} shouldAutoClose + */ +function intializeInline( container, shouldAutoClose = false ) { + const button = container.querySelector( '* > .wporg-modal__button' ); + const content = container.querySelector( '.wporg-modal__modal' ); + + if ( ! button || ! content ) { + return; + } + + button.addEventListener( 'click', () => { + if ( button.getAttribute( 'aria-expanded' ) === 'true' ) { + button.setAttribute( 'aria-expanded', false ); + content.classList.remove( 'is-open' ); + } else { + button.setAttribute( 'aria-expanded', true ); + content.classList.add( 'is-open' ); + } + } ); + + if ( shouldAutoClose ) { + // Close on click outside. + document.addEventListener( 'click', function ( event ) { + if ( ! container.contains( event.target ) ) { + button.setAttribute( 'aria-expanded', false ); + content.classList.remove( 'is-open' ); + } + } ); + + // Close on focus outside or escape key. + document.addEventListener( 'keyup', function ( event ) { + if ( event.key === 'Escape' || ! container.contains( event.target ) ) { + button.setAttribute( 'aria-expanded', false ); + content.classList.remove( 'is-open' ); + } + } ); + } +} + +function init() { + const containers = document.querySelectorAll( '.wp-block-wporg-modal' ); + + containers.forEach( ( container ) => { + if ( container.classList.contains( 'is-type-modal' ) ) { + intializeModal( container ); + } else if ( container.classList.contains( 'is-type-popover' ) ) { + intializePopover( container ); + } else { + intializeInline( container ); + } + } ); +} + +window.addEventListener( 'load', init ); diff --git a/mu-plugins/loader.php b/mu-plugins/loader.php index c92cb8d0c..08812897b 100644 --- a/mu-plugins/loader.php +++ b/mu-plugins/loader.php @@ -19,6 +19,7 @@ require_once __DIR__ . '/blocks/global-header-footer/blocks.php'; require_once __DIR__ . '/blocks/horizontal-slider/horizontal-slider.php'; require_once __DIR__ . '/blocks/language-suggest/language-suggest.php'; +require_once __DIR__ . '/blocks/modal/index.php'; require_once __DIR__ . '/blocks/latest-news/latest-news.php'; require_once __DIR__ . '/blocks/notice/index.php'; require_once __DIR__ . '/blocks/screenshot-preview/block.php'; diff --git a/package.json b/package.json index 5ca2130e9..8ba682876 100644 --- a/package.json +++ b/package.json @@ -64,5 +64,7 @@ "selector-class-pattern": null } }, - "dependencies": {} + "dependencies": { + "micromodal": "0.4.10" + } }