diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 43aabae2aca086..73872bc67d3aab 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -45,6 +45,16 @@ Create and save content to reuse across your site. Update the block, and the cha - **Supports:** ~~customClassName~~, ~~html~~, ~~inserter~~ - **Attributes:** ref +## Breadcrumbs + +Displays breadcrumbs of a page's hierarchy, or a post's categories ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/breadcrumbs)) + +- **Name:** core/breadcrumbs +- **Experimental:** fse +- **Category:** theme +- **Supports:** align (full, wide), color (link, text, ~~background~~), typography (fontSize, lineHeight), ~~html~~ +- **Attributes:** contentJustification, separator, showCurrentPageTitle, showLeadingSeparator, showSiteTitle, siteTitleOverride + ## Button Prompt visitors to take action with a button-style link. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/button)) diff --git a/lib/blocks.php b/lib/blocks.php index fdbc555a2f9443..f355095cc2eb04 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -49,6 +49,7 @@ function gutenberg_reregister_core_block_types() { 'archives.php' => 'core/archives', 'avatar.php' => 'core/avatar', 'block.php' => 'core/block', + 'breadcrumbs.php' => 'core/breadcrumbs', 'calendar.php' => 'core/calendar', 'categories.php' => 'core/categories', 'cover.php' => 'core/cover', diff --git a/packages/block-library/src/breadcrumbs/block.json b/packages/block-library/src/breadcrumbs/block.json new file mode 100644 index 00000000000000..7beed377ea5758 --- /dev/null +++ b/packages/block-library/src/breadcrumbs/block.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "__experimental": "fse", + "name": "core/breadcrumbs", + "title": "Breadcrumbs", + "category": "theme", + "description": "Displays breadcrumbs of a page's hierarchy, or a post's categories", + "textdomain": "default", + "usesContext": [ "postId", "postType" ], + "attributes": { + "contentJustification": { + "type": "string" + }, + "separator": { + "type": "string", + "default": "/" + }, + "showCurrentPageTitle": { + "type": "boolean", + "default": false + }, + "showLeadingSeparator": { + "type": "boolean", + "default": false + }, + "showSiteTitle": { + "type": "boolean", + "default": true + }, + "siteTitleOverride": { + "type": "string" + } + }, + "supports": { + "align": [ "wide", "full" ], + "color": { + "background": false, + "link": true, + "__experimentalDefaultControls": { + "text": true, + "link": true + } + }, + "html": false, + "typography": { + "fontSize": true, + "lineHeight": true, + "__experimentalFontFamily": true, + "__experimentalFontStyle": true, + "__experimentalFontWeight": true, + "__experimentalTextTransform": true, + "__experimentalDefaultControls": { + "fontSize": true + } + } + }, + "editorStyle": "wp-block-breadcrumbs-editor", + "style": "wp-block-breadcrumbs" +} diff --git a/packages/block-library/src/breadcrumbs/edit.js b/packages/block-library/src/breadcrumbs/edit.js new file mode 100644 index 00000000000000..7cf7048c4db188 --- /dev/null +++ b/packages/block-library/src/breadcrumbs/edit.js @@ -0,0 +1,311 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { + BlockControls, + InspectorControls, + JustifyContentControl, + RichText, + useBlockProps, +} from '@wordpress/block-editor'; +import { PanelBody, ToggleControl } from '@wordpress/components'; +import { useEffect, useState, useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { store as coreStore } from '@wordpress/core-data'; +import { decodeEntities } from '@wordpress/html-entities'; + +function Breadcrumb( { + addLeadingSeparator, + crumbTitle, + editableTitleField = undefined, + isSelected, + placeholder, + separator, + setAttributes, + showSeparator, +} ) { + let crumbAnchor; + let separatorSpan; + + // Keep track of whether or not the title field has been edited. + // This allows the default site title to be rendered as full "real" text. + // Then, when it's edited, if the title is removed, it is displayed as a placeholder, + // until the block is de-selected, where it is then treated as real text again. + const [ isDirty, setIsDirty ] = useState(); + + useEffect( () => { + if ( ! isSelected ) { + setIsDirty( false ); + } + }, [ isSelected ] ); + + if ( separator || isSelected ) { + separatorSpan = ( + + + setAttributes( { separator: html } ) + } + /> + + ); + } + + if ( editableTitleField ) { + /* eslint-disable jsx-a11y/anchor-is-valid */ + crumbAnchor = ( + event.preventDefault() }> + { isSelected ? ( + { + setIsDirty( true ); + setAttributes( { [ editableTitleField ]: html } ); + } } + /> + ) : ( + crumbTitle || placeholder + ) } + + ); + /* eslint-enable */ + } else if ( crumbTitle ) { + /* eslint-disable jsx-a11y/anchor-is-valid */ + crumbAnchor = ( + event.preventDefault() }> + { crumbTitle } + + ); + /* eslint-enable */ + } + + return ( +
  • + { addLeadingSeparator ? separatorSpan : null } + { crumbAnchor } + { showSeparator ? separatorSpan : null } +
  • + ); +} + +export default function BreadcrumbsEdit( { + attributes, + isSelected, + setAttributes, + context: { postType, postId }, +} ) { + const { + contentJustification, + separator, + showCurrentPageTitle, + showLeadingSeparator, + showSiteTitle, + siteTitleOverride, + } = attributes; + + const { categories, parents, post, siteTitle } = useSelect( + ( select ) => { + const { getEntityRecord, getEditedEntityRecord } = + select( coreStore ); + + const siteData = getEntityRecord( 'root', '__unstableBase' ); + const currentPost = getEditedEntityRecord( + 'postType', + postType, + postId + ); + + const parentCategories = []; + const parentEntities = []; + let categoryId = currentPost?.categories?.[ 0 ]; + let currentParentId = currentPost?.parent; + + while ( currentParentId ) { + const nextParent = getEntityRecord( + 'postType', + postType, + currentParentId + ); + + currentParentId = null; + + if ( nextParent ) { + parentEntities.push( nextParent ); + currentParentId = nextParent?.parent || null; + } + } + + while ( categoryId ) { + const nextCategory = getEntityRecord( + 'taxonomy', + 'category', + categoryId + ); + + categoryId = null; + + if ( nextCategory ) { + parentCategories.push( nextCategory ); + categoryId = nextCategory?.parent || null; + } + } + + return { + categories: parentCategories, + post: currentPost, + parents: parentEntities.reverse(), + siteTitle: decodeEntities( siteData?.name ), + }; + }, + [ postId, postType ] + ); + + // Construct breadcrumbs. + const breadcrumbs = useMemo( () => { + // Set breadcrumb names to real hierarchical post titles if available, and + // fall back to category names, or placeholder content if neither exists. + + const crumbs = []; + let breadcrumbTitles; + + if ( parents?.length ) { + breadcrumbTitles = parents.map( + ( parent ) => parent?.title?.rendered || ' ' + ); + } else if ( categories?.length ) { + breadcrumbTitles = categories.map( + ( category ) => category?.name || ' ' + ); + } else { + breadcrumbTitles = [ __( 'Top-level page' ), __( 'Child page' ) ]; + } + + // Prepend the site title or site title override if specified. + if ( showSiteTitle && siteTitle ) { + crumbs.push( + + ); + } + + // Append current page title if set. + if ( showCurrentPageTitle ) { + breadcrumbTitles.push( post?.title || __( 'Current page' ) ); + } + + breadcrumbTitles.forEach( ( item, index ) => { + crumbs.push( + + ); + } ); + + return crumbs; + }, [ + categories, + isSelected, + parents, + post?.title, + separator, + setAttributes, + showCurrentPageTitle, + showLeadingSeparator, + showSiteTitle, + siteTitle, + siteTitleOverride, + ] ); + + const blockProps = useBlockProps( { + className: classnames( { + [ `is-content-justification-${ contentJustification }` ]: + contentJustification, + } ), + } ); + + return ( + <> + + + setAttributes( { contentJustification: value } ) + } + popoverProps={ { + position: 'bottom right', + isAlternate: true, + } } + /> + + + + + setAttributes( { + showLeadingSeparator: ! showLeadingSeparator, + } ) + } + /> + + setAttributes( { + showCurrentPageTitle: ! showCurrentPageTitle, + } ) + } + /> + + setAttributes( { + showSiteTitle: ! showSiteTitle, + } ) + } + /> + + + + + ); +} diff --git a/packages/block-library/src/breadcrumbs/editor.scss b/packages/block-library/src/breadcrumbs/editor.scss new file mode 100644 index 00000000000000..551a1edcb9c92b --- /dev/null +++ b/packages/block-library/src/breadcrumbs/editor.scss @@ -0,0 +1,11 @@ +/** + * Editor only CSS. + */ + +// Undo default editor styles. +// These need extra specificity. +.editor-styles-wrapper .wp-block-breadcrumbs { + ol { + padding-left: 0; + } +} diff --git a/packages/block-library/src/breadcrumbs/index.js b/packages/block-library/src/breadcrumbs/index.js new file mode 100644 index 00000000000000..d8de83008cfc84 --- /dev/null +++ b/packages/block-library/src/breadcrumbs/index.js @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import { listView as icon } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import initBlock from '../utils/init-block'; +import metadata from './block.json'; +import edit from './edit'; + +const { name } = metadata; +export { metadata, name }; + +export const settings = { + icon, + edit, +}; + +export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/breadcrumbs/index.php b/packages/block-library/src/breadcrumbs/index.php new file mode 100644 index 00000000000000..9036a1556b13a4 --- /dev/null +++ b/packages/block-library/src/breadcrumbs/index.php @@ -0,0 +1,218 @@ +context['postId'] ) ) { + return ''; + } + + $post_id = $block->context['postId']; + $post_type = get_post_type( $post_id ); + + if ( false === $post_type ) { + return ''; + } + + $ancestor_ids = array(); + $has_post_hierarchy = is_post_type_hierarchical( $post_type ); + $show_site_title = ! empty( $attributes['showSiteTitle'] ); + $show_current_page = ! empty( $attributes['showCurrentPageTitle'] ); + + if ( $has_post_hierarchy ) { + $ancestor_ids = get_post_ancestors( $post_id ); + + if ( + empty( $ancestor_ids ) && + ! ( $show_site_title && $show_current_page ) + ) { + return ''; + } + } else { + $terms = get_the_terms( $post_id, 'category' ); + + if ( empty( $terms ) || is_wp_error( $terms ) ) { + return ''; + } + + $term = get_term( $terms[0], 'category' ); + + $ancestor_ids[] = $term->term_id; + $ancestor_ids = array_merge( $ancestor_ids, get_ancestors( $term->term_id, 'category' ) ); + } + + $breadcrumbs = array(); + + // Prepend site title breadcrumb if available and set to show. + $site_title = get_bloginfo( 'name' ); + if ( $site_title && $show_site_title ) { + $site_title = ! empty( $attributes['siteTitleOverride'] ) ? + $attributes['siteTitleOverride'] : + $site_title; + + $breadcrumbs[] = array( + 'url' => get_bloginfo( 'url' ), + 'title' => $site_title, + ); + } + + if ( $has_post_hierarchy ) { + // Construct remaining breadcrumbs from ancestor ids. + foreach ( array_reverse( $ancestor_ids ) as $ancestor_id ) { + $breadcrumbs[] = array( + 'url' => get_the_permalink( $ancestor_id ), + 'title' => get_the_title( $ancestor_id ), + ); + } + } else { + foreach ( array_reverse( $ancestor_ids ) as $ancestor_id ) { + $breadcrumbs[] = array( + 'url' => get_category_link( $ancestor_id ), + 'title' => get_cat_name( $ancestor_id ), + ); + } + } + + // Append current page title if set to show. + if ( $show_current_page ) { + $breadcrumbs[] = array( + 'url' => get_the_permalink( $post_id ), + 'title' => get_the_title( $post_id ), + ); + } + + $inner_markup = ''; + + /** + * Filters the list of breadcrumb links within the Breadcrumbs block render callback. + * + * @since 6.3.0 + * + * @param array[] An array of Breadcrumb arrays with `url` and `title` keys. + */ + $breadcrumbs = apply_filters( 'block_core_breadcrumbs_links', $breadcrumbs ); + + foreach ( $breadcrumbs as $index => $breadcrumb ) { + $show_separator = $index < count( $breadcrumbs ) - 1; + $inner_markup .= build_block_core_breadcrumbs_inner_markup_item( + $breadcrumb['url'], + $breadcrumb['title'], + $attributes, + $index, + $show_separator, + ( $show_current_page && count( $breadcrumbs ) - 1 === $index ) + ); + } + + $classnames = ''; + + if ( ! empty( $attributes['contentJustification'] ) ) { + if ( 'left' === $attributes['contentJustification'] ) { + $classnames = 'is-content-justification-left'; + } + + if ( 'center' === $attributes['contentJustification'] ) { + $classnames = 'is-content-justification-center'; + } + + if ( 'right' === $attributes['contentJustification'] ) { + $classnames = 'is-content-justification-right'; + } + } + + $wrapper_attributes = get_block_wrapper_attributes( + array( + 'class' => $classnames, + 'aria-label' => __( 'Breadcrumb' ), + ) + ); + + return sprintf( + '', + $wrapper_attributes, + $inner_markup + ); +} + +/** + * Builds the markup for a single Breadcrumb item. + * + * Used when iterating over a list of breadcrumb urls and titles. + * + * @param string $url The url for the link in the breadcrumb. + * @param string $title The label/title for the breadcrumb item. + * @param array $attributes Block attributes. + * @param int $index The position in a list of ids. + * @param bool $show_separator Whether to show the separator character where available. + * @param bool $is_current_page Whether to mark the breadcrumb item as the current page. + * + * @return string The markup for a single breadcrumb item wrapped in an `li` element. + */ +function build_block_core_breadcrumbs_inner_markup_item( $url, $title, $attributes, $index, $show_separator = true, $is_current_page = false ) { + $li_class = 'wp-block-breadcrumbs__item'; + $separator_class = 'wp-block-breadcrumbs__separator'; + + $markup = ''; + + // Render leading separator if specified. + if ( + ! empty( $attributes['showLeadingSeparator'] ) && + ! empty( $attributes['separator'] ) && + 0 === $index + ) { + $markup .= sprintf( + '%2$s', + $separator_class, + wp_kses_post( $attributes['separator'] ) + ); + } + + $markup .= sprintf( + '%s', + esc_url( $url ), + $is_current_page ? ' aria-current="page"' : '', + wp_kses_post( $title ) + ); + + if ( + $show_separator && + ! empty( $attributes['separator'] ) + ) { + $markup .= sprintf( + '', + $separator_class, + wp_kses_post( $attributes['separator'] ) + ); + } + + return sprintf( + '
  • %2$s
  • ', + $li_class, + $markup + ); +} + +/** + * Registers the `core/post-title` block on the server. + */ +function register_block_core_breadcrumbs() { + register_block_type_from_metadata( + __DIR__ . '/breadcrumbs', + array( + 'render_callback' => 'render_block_core_breadcrumbs', + ) + ); +} +add_action( 'init', 'register_block_core_breadcrumbs' ); diff --git a/packages/block-library/src/breadcrumbs/style.scss b/packages/block-library/src/breadcrumbs/style.scss new file mode 100644 index 00000000000000..bcf742537b2c58 --- /dev/null +++ b/packages/block-library/src/breadcrumbs/style.scss @@ -0,0 +1,27 @@ +.wp-block-breadcrumbs { + ol { + list-style-type: none; + padding-left: 0; + margin: 0; + display: flex; + flex-wrap: wrap; + gap: 0.5em; + } + + &.is-content-justification-left ol { + justify-content: flex-start; + } + + &.is-content-justification-center ol { + justify-content: center; + } + + &.is-content-justification-right ol { + justify-content: flex-end; + } + + .wp-block-breadcrumbs__item { + display: flex; + gap: 0.5em; + } +} diff --git a/packages/block-library/src/editor.scss b/packages/block-library/src/editor.scss index 07c58599c50980..61ab2eb41a87e0 100644 --- a/packages/block-library/src/editor.scss +++ b/packages/block-library/src/editor.scss @@ -2,6 +2,7 @@ @import "./audio/editor.scss"; @import "./avatar/editor.scss"; @import "./block/editor.scss"; +@import "./breadcrumbs/editor.scss"; @import "./button/editor.scss"; @import "./buttons/editor.scss"; @import "./categories/editor.scss"; diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index 73c2f1eb1140a2..456337aa61263b 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -116,6 +116,7 @@ import * as termDescription from './term-description'; import * as textColumns from './text-columns'; import * as verse from './verse'; import * as video from './video'; +import * as breadcrumbs from './breadcrumbs'; import isBlockMetadataExperimental from './utils/is-block-metadata-experimental'; @@ -226,6 +227,7 @@ const getAllBlocks = () => { termDescription, queryTitle, postAuthorBiography, + breadcrumbs, ]; return blocks.filter( Boolean ); }; diff --git a/packages/block-library/src/style.scss b/packages/block-library/src/style.scss index c5277995d10dab..5318e12cce8863 100644 --- a/packages/block-library/src/style.scss +++ b/packages/block-library/src/style.scss @@ -1,6 +1,7 @@ @import "./archives/style.scss"; @import "./avatar/style.scss"; @import "./audio/style.scss"; +@import "./breadcrumbs/style.scss"; @import "./button/style.scss"; @import "./buttons/style.scss"; @import "./calendar/style.scss"; diff --git a/test/integration/fixtures/blocks/core__breadcrumbs.html b/test/integration/fixtures/blocks/core__breadcrumbs.html new file mode 100644 index 00000000000000..80bbc89da1daa9 --- /dev/null +++ b/test/integration/fixtures/blocks/core__breadcrumbs.html @@ -0,0 +1 @@ + diff --git a/test/integration/fixtures/blocks/core__breadcrumbs.json b/test/integration/fixtures/blocks/core__breadcrumbs.json new file mode 100644 index 00000000000000..a57d7b17ab55f2 --- /dev/null +++ b/test/integration/fixtures/blocks/core__breadcrumbs.json @@ -0,0 +1,13 @@ +[ + { + "name": "core/breadcrumbs", + "isValid": true, + "attributes": { + "separator": "|", + "showCurrentPageTitle": false, + "showLeadingSeparator": true, + "showSiteTitle": true + }, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__breadcrumbs.parsed.json b/test/integration/fixtures/blocks/core__breadcrumbs.parsed.json new file mode 100644 index 00000000000000..1cd27717679445 --- /dev/null +++ b/test/integration/fixtures/blocks/core__breadcrumbs.parsed.json @@ -0,0 +1,12 @@ +[ + { + "blockName": "core/breadcrumbs", + "attrs": { + "separator": "|", + "showLeadingSeparator": true + }, + "innerBlocks": [], + "innerHTML": "", + "innerContent": [] + } +] diff --git a/test/integration/fixtures/blocks/core__breadcrumbs.serialized.html b/test/integration/fixtures/blocks/core__breadcrumbs.serialized.html new file mode 100644 index 00000000000000..80bbc89da1daa9 --- /dev/null +++ b/test/integration/fixtures/blocks/core__breadcrumbs.serialized.html @@ -0,0 +1 @@ +