diff --git a/mu-plugins/blocks/chapter-list/class-chapter-walker.php b/mu-plugins/blocks/chapter-list/class-chapter-walker.php new file mode 100644 index 00000000..0728d959 --- /dev/null +++ b/mu-plugins/blocks/chapter-list/class-chapter-walker.php @@ -0,0 +1,95 @@ + 0 specifies the number of display levels. + * + * NOTE: This is identical to `Walker::walk()` except that it ignores orphaned + * pages, which are essentially pages whose ancestor is not published. + * + * @param array $elements An array of elements. + * @param int $max_depth The maximum hierarchical depth. + * @param mixed ...$args Optional additional arguments. + * @return string The hierarchical item output. + */ + public function walk( $elements, $max_depth, ...$args ) { + $output = ''; + + // invalid parameter or nothing to walk. + if ( $max_depth < -1 || empty( $elements ) ) { + return $output; + } + + $parent_field = $this->db_fields['parent']; + + // flat display. + if ( -1 === $max_depth ) { + $empty_array = array(); + foreach ( $elements as $e ) { + $this->display_element( $e, $empty_array, 1, 0, $args, $output ); + } + return $output; + } + + /* + * Need to display in hierarchical order. + * Separate elements into two buckets: top level and children elements. + * Children_elements is two dimensional array, eg. + * Children_elements[10][] contains all sub-elements whose parent is 10. + */ + $top_level_elements = array(); + $children_elements = array(); + foreach ( $elements as $e ) { + if ( empty( $e->$parent_field ) ) { + $top_level_elements[] = $e; + } else { + $children_elements[ $e->$parent_field ][] = $e; + } + } + + /* + * When none of the elements is top level. + * Assume the first one must be root of the sub elements. + */ + if ( empty( $top_level_elements ) ) { + $first = array_slice( $elements, 0, 1 ); + $root = $first[0]; + + $top_level_elements = array(); + $children_elements = array(); + foreach ( $elements as $e ) { + if ( $root->$parent_field === $e->$parent_field ) { + $top_level_elements[] = $e; + } else { + $children_elements[ $e->$parent_field ][] = $e; + } + } + } + + foreach ( $top_level_elements as $e ) { + $this->display_element( $e, $children_elements, $max_depth, 0, $args, $output ); + } + + /* + * Here is where it differs from the original `walk()`. The original would + * automatically display orphans. + */ + + return $output; + } +} diff --git a/mu-plugins/blocks/chapter-list/index.php b/mu-plugins/blocks/chapter-list/index.php new file mode 100644 index 00000000..4aeffbb2 --- /dev/null +++ b/mu-plugins/blocks/chapter-list/index.php @@ -0,0 +1,80 @@ + __NAMESPACE__ . '\render', + ) + ); +} + +/** + * Render the block content. + * + * @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 ) { + if ( ! isset( $block->context['postId'] ) ) { + return ''; + } + + $post_id = $block->context['postId']; + $post_type = get_post_type( $post_id ); + + $args = array( + 'title_li' => '', + 'echo' => 0, + 'sort_column' => 'menu_order, title', + 'post_type' => $post_type, + + // Use custom walker that excludes display of orphaned pages (an ancestor + // of such a page is likely not published and thus this is not accessible). + 'walker' => new Chapter_Walker(), + ); + + $post_type_obj = get_post_type_object( $post_type ); + + if ( $post_type_obj && current_user_can( $post_type_obj->cap->read_private_posts ) ) { + $args['post_status'] = array( 'publish', 'private' ); + } + + $content = wp_list_pages( $args ); + + $header = '
'; + $header .= do_blocks( + ' +

' . esc_html__( 'Chapters', 'wporg' ) . '

+ ' + ); + $header .= ''; + $header .= '
'; + + $wrapper_attributes = get_block_wrapper_attributes(); + return sprintf( + '', + $wrapper_attributes, + $header, + $content + ); +} diff --git a/mu-plugins/blocks/chapter-list/src/block.json b/mu-plugins/blocks/chapter-list/src/block.json new file mode 100644 index 00000000..6542ea5f --- /dev/null +++ b/mu-plugins/blocks/chapter-list/src/block.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "wporg/chapter-list", + "version": "0.1.0", + "title": "Chapter Navigation", + "category": "widgets", + "icon": "smiley", + "description": "", + "usesContext": [ "postId" ], + "attributes": { + "postType": { + "type": "string" + } + }, + "supports": { + "html": false, + "spacing": { + "margin": [ + "top", + "bottom" + ], + "padding": true, + "blockGap": true + }, + "typography": { + "fontSize": true, + "lineHeight": true + } + }, + "textdomain": "wporg", + "editorScript": "file:./index.js", + "style": "file:./style-index.css", + "viewScript": "file:./view.js" +} diff --git a/mu-plugins/blocks/chapter-list/src/index.js b/mu-plugins/blocks/chapter-list/src/index.js new file mode 100644 index 00000000..2254aa23 --- /dev/null +++ b/mu-plugins/blocks/chapter-list/src/index.js @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { registerBlockType } from '@wordpress/blocks'; +import { useBlockProps } from '@wordpress/block-editor'; +import ServerSideRender from '@wordpress/server-side-render'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import './style.scss'; + +const Edit = ( { attributes, name } ) => { + const blockProps = useBlockProps(); + return ( +
+ +
+ ); +}; + +registerBlockType( metadata.name, { + edit: Edit, +} ); diff --git a/mu-plugins/blocks/chapter-list/src/style.scss b/mu-plugins/blocks/chapter-list/src/style.scss new file mode 100644 index 00000000..1d3a3dce --- /dev/null +++ b/mu-plugins/blocks/chapter-list/src/style.scss @@ -0,0 +1,176 @@ +.wp-block-wporg-chapter-list { + --local--icon-size: calc(var(--wp--custom--body--small--typography--line-height) * 1em); + + font-size: var(--wp--preset--font-size--small); + line-height: var(--wp--custom--body--small--typography--line-height); + + @media (max-width: 767px) { + border: 1px solid var(--wp--preset--color--light-grey-1); + border-radius: 2px; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + font-size: var(--wp--preset--font-size--normal) !important; + } + + a:where(:not(.wp-element-button)) { + color: var(--wp--preset--color--charcoal-4); + } + + .wporg-chapter-list__header { + position: relative; + + @media (max-width: 767px) { + padding: 15px var(--wp--preset--spacing--20); + } + + .wp-block-heading { + margin-bottom: 0; + margin-top: 0; + } + } + + .wporg-chapter-list__list { + + @media (max-width: 767px) { + display: none; + margin-top: 0; + padding: 0 var(--wp--preset--spacing--20) 15px; + } + } + + ul { + margin-top: 0; + margin-bottom: 0; + list-style-type: none; + padding-inline-start: 0; + } + + li { + margin-block: calc(var(--wp--preset--spacing--20) / 4); + color: var(--wp--preset--color--charcoal-4); + padding-inline-start: var(--local--icon-size); + position: relative; + + &::before { + content: ""; + display: inline-block; + position: absolute; + inset-inline-start: 0; + width: var(--local--icon-size); + height: var(--local--icon-size); + /* stylelint-disable-next-line function-url-quotes */ + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4' fill='none'%3E%3Ccircle cx='2' cy='2' r='1.5' fill='%23656A71'/%3E%3C/svg%3E%0A"); + mask-repeat: no-repeat; + mask-position: center; + background-color: var(--wp--preset--color--charcoal-4); + } + } + + .children { + + /* Shift the children to the left by half the icon size, allowing for the dot width of 4px. */ + margin-inline-start: calc((var(--local--icon-size) - 4px) * -0.5); + } + + a { + text-decoration: none; + color: inherit; + } + + &.has-js-control { + .page_item_has_children { + padding-inline-start: 0; + + &::before { + display: none; + } + } + + .children { + display: none; + padding-inline-start: var(--local--icon-size); + + &.is-open { + display: revert; + } + } + } + + .wporg-chapter-list__button-group { + display: flex; + align-items: flex-start; + } + + .wporg-chapter-list__toggle, + .wporg-chapter-list__button-group > button { + font-size: inherit; + background-color: transparent; + border: none; + padding: 0; + cursor: pointer; + height: var(--local--icon-size); + + &::before { + content: ""; + display: inline-block; + height: var(--local--icon-size); + width: var(--local--icon-size); + /* stylelint-disable-next-line function-url-quotes */ + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15.9899 10.8888L12.0018 14.3071L8.01367 10.8888L8.98986 9.74988L12.0018 12.3315L15.0137 9.74988L15.9899 10.8888Z' fill='%231E1E1E'/%3E%3C/svg%3E%0A"); + mask-repeat: no-repeat; + mask-position: center; + transform: rotate(-90deg); + background-color: var(--wp--preset--color--charcoal-4); + } + + &[aria-expanded="true"]::before { + transform: revert; + } + + &:focus-visible { + outline: 1px dashed var(--wp--preset--color--blueberry-1); + } + } + + .wporg-chapter-list__toggle { + display: flex; + justify-content: flex-end; + align-items: center; + position: absolute; + top: 0; + right: 0; + width: 100%; + height: 100%; + padding: 0 var(--wp--preset--spacing--20) 0 0; + + @media (min-width: 768px) { + display: none; + } + + &[aria-expanded="true"]::before { + background-color: var(--wp--preset--color--charcoal-1); + } + } + + /* Descendent is `span` if there are children, or `a` if not. */ + .current_page_item, + .current_page_item > span a, + .current_page_item > a { + color: var(--wp--preset--color--charcoal-1); + } + + .current_page_item > span a, + .current_page_item > a { + font-weight: 700; + } + + .current_page_item > span button::before { + background-color: var(--wp--preset--color--charcoal-1); + } +} diff --git a/mu-plugins/blocks/chapter-list/src/view.js b/mu-plugins/blocks/chapter-list/src/view.js new file mode 100644 index 00000000..727b0389 --- /dev/null +++ b/mu-plugins/blocks/chapter-list/src/view.js @@ -0,0 +1,83 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +const init = () => { + const container = document.querySelector( '.wp-block-wporg-chapter-list' ); + const toggleButton = container?.querySelector( '.wporg-chapter-list__toggle' ); + const list = container?.querySelector( '.wporg-chapter-list__list' ); + + if ( toggleButton && list ) { + toggleButton.addEventListener( 'click', function () { + if ( toggleButton.getAttribute( 'aria-expanded' ) === 'true' ) { + toggleButton.setAttribute( 'aria-expanded', false ); + list.removeAttribute( 'style' ); + } else { + toggleButton.setAttribute( 'aria-expanded', true ); + list.setAttribute( 'style', 'display:block;' ); + } + } ); + } + + if ( container ) { + container.classList.toggle( 'has-js-control' ); + + const parents = container.querySelectorAll( '.page_item_has_children' ); + parents.forEach( ( item ) => { + // Get link, remove (will re-ad later). + const link = item.querySelector( ':scope > a' ); + link.remove(); + + // Get submenu + const submenu = item.querySelector( ':scope > ul' ); + + // Create the toggle button. + const button = document.createElement( 'button' ); + button.setAttribute( 'aria-expanded', false ); + // translators: %s link title. + button.setAttribute( 'aria-label', sprintf( __( 'Open %s submenu', 'wporg' ), link.innerText ) ); + button.onclick = () => { + submenu.classList.toggle( 'is-open' ); + // This attribute returns a string. + const isOpen = button.getAttribute( 'aria-expanded' ); + button.setAttribute( 'aria-expanded', isOpen === 'false' ); + if ( isOpen === 'false' ) { + button.setAttribute( + 'aria-label', + // translators: %s link title. + sprintf( __( 'Close %s submenu', 'wporg' ), link.innerText ) + ); + } else { + button.setAttribute( + 'aria-label', + // translators: %s link title. + sprintf( __( 'Open %s submenu', 'wporg' ), link.innerText ) + ); + } + }; + + const buttonGroup = document.createElement( 'span' ); + buttonGroup.className = 'wporg-chapter-list__button-group'; + buttonGroup.append( button, link ); + + item.insertBefore( buttonGroup, submenu ); + + // Automatically open the trail to the current page. + if ( + item.classList.contains( 'current_page_item' ) || + item.classList.contains( 'current_page_ancestor' ) + ) { + submenu.classList.toggle( 'is-open' ); + button.setAttribute( 'aria-expanded', true ); + button.setAttribute( + 'aria-label', + // translators: %s link title. + sprintf( __( 'Close %s submenu', 'wporg' ), link.innerText ) + ); + } + } ); + } +}; + +window.addEventListener( 'load', init ); diff --git a/mu-plugins/loader.php b/mu-plugins/loader.php index 1317ab89..90fab566 100644 --- a/mu-plugins/loader.php +++ b/mu-plugins/loader.php @@ -27,6 +27,7 @@ } require_once __DIR__ . '/helpers/helpers.php'; +require_once __DIR__ . '/blocks/chapter-list/index.php'; require_once __DIR__ . '/blocks/favorite-button/index.php'; require_once __DIR__ . '/blocks/global-header-footer/blocks.php'; require_once __DIR__ . '/blocks/google-map/index.php';