diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index a23ed2f9934e27..d722604edda4cf 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -982,6 +982,15 @@ Edit the different global regions of your site, like the header, footer, sidebar - **Supports:** align, interactivity (clientNavigation), ~~html~~, ~~renaming~~, ~~reusable~~ - **Attributes:** area, slug, tagName, theme +## Term Count + +Displays the post count of a taxonomy term. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/term-count)) + +- **Name:** core/term-count +- **Category:** theme +- **Supports:** color (background, gradients, text), interactivity (clientNavigation), spacing (padding), typography (fontSize, lineHeight), ~~html~~ +- **Attributes:** bracketType + ## Term Description Display the description of categories, tags and custom taxonomies when viewing an archive. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/term-description)) diff --git a/lib/blocks.php b/lib/blocks.php index 236011b03abd0c..ca39b2e55bf4a9 100644 --- a/lib/blocks.php +++ b/lib/blocks.php @@ -124,6 +124,7 @@ function gutenberg_reregister_core_block_types() { 'table-of-contents.php' => 'core/table-of-contents', 'tag-cloud.php' => 'core/tag-cloud', 'template-part.php' => 'core/template-part', + 'term-count.php' => 'core/term-count', 'term-description.php' => 'core/term-description', 'term-name.php' => 'core/term-name', 'terms-query.php' => 'core/terms-query', diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index e56c6c6b00d32c..ef0b1b2ffde21c 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -128,6 +128,7 @@ import * as table from './table'; import * as tableOfContents from './table-of-contents'; import * as tagCloud from './tag-cloud'; import * as templatePart from './template-part'; +import * as termCount from './term-count'; import * as termDescription from './term-description'; import * as termName from './term-name'; import * as termsQuery from './terms-query'; @@ -250,6 +251,7 @@ const getAllBlocks = () => { tableOfContents, homeLink, logInOut, + termCount, termDescription, termName, queryTitle, diff --git a/packages/block-library/src/style.scss b/packages/block-library/src/style.scss index feb673ae47a477..3e40e89b8e0083 100644 --- a/packages/block-library/src/style.scss +++ b/packages/block-library/src/style.scss @@ -70,6 +70,7 @@ @import "./tag-cloud/style.scss"; @import "./table/style.scss"; @import "./table-of-contents/style.scss"; +@import "./term-count/style.scss"; @import "./term-description/style.scss"; @import "./term-name/style.scss"; @import "./term-template/style.scss"; diff --git a/packages/block-library/src/term-count/block.json b/packages/block-library/src/term-count/block.json new file mode 100644 index 00000000000000..c4de1e61f8d1f5 --- /dev/null +++ b/packages/block-library/src/term-count/block.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "core/term-count", + "title": "Term Count", + "category": "theme", + "description": "Displays the post count of a taxonomy term.", + "textdomain": "default", + "usesContext": [ "termId", "taxonomy" ], + "attributes": { + "bracketType": { + "type": "string", + "enum": [ "none", "round", "square", "curly", "angle" ], + "default": "round" + } + }, + "supports": { + "html": false, + "color": { + "gradients": true, + "__experimentalDefaultControls": { + "background": true, + "text": true + } + }, + "spacing": { + "padding": true + }, + "typography": { + "fontSize": true, + "lineHeight": true, + "__experimentalFontFamily": true, + "__experimentalFontWeight": true, + "__experimentalFontStyle": true, + "__experimentalTextTransform": true, + "__experimentalTextDecoration": true, + "__experimentalLetterSpacing": true, + "__experimentalDefaultControls": { + "fontSize": true + } + }, + "interactivity": { + "clientNavigation": true + }, + "__experimentalBorder": { + "radius": true, + "color": true, + "width": true, + "style": true, + "__experimentalDefaultControls": { + "color": true, + "width": true, + "style": true + } + } + }, + "style": "wp-block-term-count" +} diff --git a/packages/block-library/src/term-count/edit.js b/packages/block-library/src/term-count/edit.js new file mode 100644 index 00000000000000..19d90d630d2165 --- /dev/null +++ b/packages/block-library/src/term-count/edit.js @@ -0,0 +1,91 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useBlockProps, BlockControls } from '@wordpress/block-editor'; +import { ToolbarDropdownMenu } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { + bareNumber, + numberInParenthesis, + numberInSquareBrackets, + numberInCurlyBrackets, + numberInAngleBrackets, +} from './icons'; +import { useTermCount } from './use-term-count'; + +const BRACKET_TYPES = { + none: { label: __( 'No brackets' ), icon: bareNumber }, + round: { + label: __( 'Round brackets' ), + icon: numberInParenthesis, + before: '(', + after: ')', + }, + square: { + label: __( 'Square brackets' ), + icon: numberInSquareBrackets, + before: '[', + after: ']', + }, + curly: { + label: __( 'Curly brackets' ), + icon: numberInCurlyBrackets, + before: '{', + after: '}', + }, + angle: { + label: __( 'Angle brackets' ), + icon: numberInAngleBrackets, + before: '<', + after: '>', + }, +}; + +export default function TermCountEdit( { + attributes, + setAttributes, + context: { termId, taxonomy }, +} ) { + const { bracketType } = attributes; + const term = useTermCount( termId, taxonomy ); + + const termCount = term?.termCount || 0; + + const blockProps = useBlockProps(); + + const bracketTypeControls = Object.entries( BRACKET_TYPES ).map( + ( [ type, { label, icon } ] ) => ( { + role: 'menuitemradio', + title: label, + isActive: bracketType === type, + icon, + onClick: () => { + setAttributes( { bracketType: type } ); + }, + } ) + ); + + const formatTermCount = ( count, type ) => { + const { before = '', after = '' } = BRACKET_TYPES[ type ] || {}; + return `${ before }${ count }${ after }`; + }; + + return ( + <> + + + +
+ { formatTermCount( termCount, bracketType ) } +
+ + ); +} diff --git a/packages/block-library/src/term-count/icons.js b/packages/block-library/src/term-count/icons.js new file mode 100644 index 00000000000000..877acfe90e11b0 --- /dev/null +++ b/packages/block-library/src/term-count/icons.js @@ -0,0 +1,34 @@ +/** + * WordPress dependencies + */ +import { SVG, Path } from '@wordpress/components'; + +export const bareNumber = ( + + + +); + +export const numberInParenthesis = ( + + + +); + +export const numberInSquareBrackets = ( + + + +); + +export const numberInCurlyBrackets = ( + + + +); + +export const numberInAngleBrackets = ( + + + +); diff --git a/packages/block-library/src/term-count/index.js b/packages/block-library/src/term-count/index.js new file mode 100644 index 00000000000000..24481c946586d1 --- /dev/null +++ b/packages/block-library/src/term-count/index.js @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import { termCount 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/term-count/index.php b/packages/block-library/src/term-count/index.php new file mode 100644 index 00000000000000..9794896524eb43 --- /dev/null +++ b/packages/block-library/src/term-count/index.php @@ -0,0 +1,80 @@ +context['termId'] ) && isset( $block->context['taxonomy'] ) ) { + $term = get_term( $block->context['termId'], $block->context['taxonomy'] ); + } else { + $term = get_queried_object(); + if ( ! $term instanceof WP_Term ) { + $term = null; + } + } + + if ( ! $term || is_wp_error( $term ) ) { + return ''; + } + + $term_count = $term->count; + + // Format the term count based on bracket type. + switch ( $attributes['bracketType'] ) { + case 'none': + // No formatting needed. + break; + case 'round': + $term_count = "({$term_count})"; + break; + case 'square': + $term_count = "[{$term_count}]"; + break; + case 'curly': + $term_count = "{{$term_count}}"; + break; + case 'angle': + $term_count = "<{$term_count}>"; + break; + default: + // Default to no formatting for unknown types. + break; + } + + $wrapper_attributes = get_block_wrapper_attributes(); + + return sprintf( + '
%2$s
', + $wrapper_attributes, + $term_count + ); +} + +/** + * Registers the `core/term-count` block on the server. + * + * @since 6.9.0 + */ +function register_block_core_term_count() { + register_block_type_from_metadata( + __DIR__ . '/term-count', + array( + 'render_callback' => 'render_block_core_term_count', + ) + ); +} +add_action( 'init', 'register_block_core_term_count' ); diff --git a/packages/block-library/src/term-count/init.js b/packages/block-library/src/term-count/init.js new file mode 100644 index 00000000000000..79f0492c2cb2f8 --- /dev/null +++ b/packages/block-library/src/term-count/init.js @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import { init } from './'; + +export default init(); diff --git a/packages/block-library/src/term-count/style.scss b/packages/block-library/src/term-count/style.scss new file mode 100644 index 00000000000000..ab992d5e1b94bc --- /dev/null +++ b/packages/block-library/src/term-count/style.scss @@ -0,0 +1,4 @@ +.wp-block-term-count { + // This block has customizable padding, border-box makes that more predictable. + box-sizing: border-box; +} diff --git a/packages/block-library/src/term-count/use-term-count.js b/packages/block-library/src/term-count/use-term-count.js new file mode 100644 index 00000000000000..959d9b51d0b4e2 --- /dev/null +++ b/packages/block-library/src/term-count/use-term-count.js @@ -0,0 +1,101 @@ +/** + * WordPress dependencies + */ +import { store as coreStore, useEntityProp } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; + +/** + * Hook to fetch term count based on context or fallback to template parsing. + * + * This hook prioritizes context-provided termId and taxonomy, but falls back to + * template-based detection when no context is available. + * + * @param {string|number} termId The term ID from context + * @param {string} taxonomy The taxonomy name from context + */ +export function useTermCount( termId, taxonomy ) { + const [ count ] = useEntityProp( 'taxonomy', taxonomy, 'count', termId ); + + // Fallback approach: Parse template slug when no context is available. + const templateBasedData = useTemplateBasedTermData(); + + const hasContext = Boolean( termId && taxonomy ); + + return { + hasContext, + termCount: hasContext ? count || '' : templateBasedData, + }; +} + +/** + * Fallback hook to fetch term data from template context (backward compatibility). + * This maintains the same logic as the original implementation for cases where + * no termId/taxonomy context is provided. + */ +function useTemplateBasedTermData() { + const templateSlug = useSelect( ( select ) => { + // Access core/editor by string to avoid @wordpress/editor dependency. + // eslint-disable-next-line @wordpress/data-no-store-string-literals + const { getCurrentPostId, getCurrentPostType, getCurrentTemplateId } = + select( 'core/editor' ); + const currentPostType = getCurrentPostType(); + const templateId = + getCurrentTemplateId() || + ( currentPostType === 'wp_template' ? getCurrentPostId() : null ); + + return templateId + ? select( coreStore ).getEditedEntityRecord( + 'postType', + 'wp_template', + templateId + )?.slug + : null; + }, [] ); + + const taxonomyMatches = templateSlug?.match( + /^(category|tag|taxonomy-([^-]+))$|^(((category|tag)|taxonomy-([^-]+))-(.+))$/ + ); + + let taxonomy; + let termSlug; + + if ( taxonomyMatches ) { + // If it's for all taxonomies of a type (e.g., category, tag). + if ( taxonomyMatches[ 1 ] ) { + taxonomy = taxonomyMatches[ 2 ] + ? taxonomyMatches[ 2 ] + : taxonomyMatches[ 1 ]; + } + // If it's for a specific term (e.g., category-news, tag-featured). + else if ( taxonomyMatches[ 3 ] ) { + taxonomy = taxonomyMatches[ 6 ] + ? taxonomyMatches[ 6 ] + : taxonomyMatches[ 4 ]; + termSlug = taxonomyMatches[ 7 ]; + } + + taxonomy = taxonomy === 'tag' ? 'post_tag' : taxonomy; + } + + return useSelect( + ( select ) => { + if ( ! taxonomy || ! termSlug ) { + return ''; + } + + const { getEntityRecords } = select( coreStore ); + + const termRecords = getEntityRecords( 'taxonomy', taxonomy, { + slug: termSlug, + per_page: 1, + } ); + + if ( termRecords && termRecords[ 0 ] ) { + return termRecords[ 0 ].count || ''; + } + + return ''; + }, + [ taxonomy, termSlug ] + ); +} diff --git a/packages/icons/src/library/term-count.svg b/packages/icons/src/library/term-count.svg new file mode 100644 index 00000000000000..8b333407a02ef4 --- /dev/null +++ b/packages/icons/src/library/term-count.svg @@ -0,0 +1,4 @@ + + + + diff --git a/test/integration/fixtures/blocks/core__term-count.html b/test/integration/fixtures/blocks/core__term-count.html new file mode 100644 index 00000000000000..6b4f1fb433ec23 --- /dev/null +++ b/test/integration/fixtures/blocks/core__term-count.html @@ -0,0 +1 @@ + diff --git a/test/integration/fixtures/blocks/core__term-count.json b/test/integration/fixtures/blocks/core__term-count.json new file mode 100644 index 00000000000000..d1a5dcdea81557 --- /dev/null +++ b/test/integration/fixtures/blocks/core__term-count.json @@ -0,0 +1,10 @@ +[ + { + "name": "core/term-count", + "isValid": true, + "attributes": { + "bracketType": "round" + }, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__term-count.parsed.json b/test/integration/fixtures/blocks/core__term-count.parsed.json new file mode 100644 index 00000000000000..0e17d9047d41c0 --- /dev/null +++ b/test/integration/fixtures/blocks/core__term-count.parsed.json @@ -0,0 +1,9 @@ +[ + { + "blockName": "core/term-count", + "attrs": {}, + "innerBlocks": [], + "innerHTML": "", + "innerContent": [] + } +] diff --git a/test/integration/fixtures/blocks/core__term-count.serialized.html b/test/integration/fixtures/blocks/core__term-count.serialized.html new file mode 100644 index 00000000000000..6b4f1fb433ec23 --- /dev/null +++ b/test/integration/fixtures/blocks/core__term-count.serialized.html @@ -0,0 +1 @@ +