Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Easy to use and lightweight, built using the WordPress Interactivity API.

* Add a query block. This can anyhere that the query block is supported e.g. page, template, or pattern.
* Add one of the filter blocks and configure as required:
* Taxonomy filter. Select which taxonomy to to use, customise the label (and whether it's shown), and customise the text used when none is selected.
* Taxonomy filter. Select which taxonomy to to use, customise the label (and whether it's shown), and customise the text used when none is selected. For a hierarchical taxonomy, choose how the tree is shown: a flat list (the default), nested beneath parents, or nested with each parent's children collapsed behind a toggle. Selecting a parent also matches posts in its children.
* Post type filter. Customise the label (and whether it's shown), as well as the text used when no filter is applied.
* Search block. No extra options.

Expand Down
87 changes: 87 additions & 0 deletions inc/namespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,93 @@ function get_filter_terms( array $attributes ) : array {
return $terms;
}

/**
* Resolve the hierarchy mode a taxonomy filter block should render with.
*
* If the saved attribute is incompatible with the taxonomy type (a flat
* taxonomy has no tree view, and a select control has no rows to collapse)
* the value falls back to the nearest relevant mode.
*
* @param array $attributes Taxonomy filter block attributes.
* @param string $taxonomy Taxonomy name.
* @return string One of `flat`, `nested` or `collapsed`.
*/
function get_hierarchy_mode( array $attributes, string $taxonomy ) : string {
$mode = $attributes['hierarchy'] ?? 'flat';

if ( ! in_array( $mode, [ 'flat', 'nested', 'collapsed' ], true ) ) {
return 'flat';
}

if ( $mode !== 'flat' && ! is_taxonomy_hierarchical( $taxonomy ) ) {
return 'flat';
}

if ( $mode === 'collapsed' && ( $attributes['displayType'] ?? 'select' ) === 'select' ) {
return 'nested';
}

return $mode;
}

/**
* Nest a flat list of terms by parent.
*
* Each node is `[ 'term' => WP_Term, 'children' => array ]`, so that a term
* object is never given ad hoc properties. A term whose parent is not in the
* list is promoted to a root, which keeps a curated or filtered list whole
* instead of silently dropping the branches whose parents were left out.
* Sibling order follows the order of the input.
*
* @param \WP_Term[] $terms Terms to nest.
* @return array[] Root nodes, each carrying its descendants.
*/
function build_term_tree( array $terms ) : array {
$ids = array_column( $terms, 'term_id' );
$children_of = [];

foreach ( $terms as $term ) {
$parent = in_array( $term->parent, $ids, true ) ? $term->parent : 0;
$children_of[ $parent ][] = $term;
}

$build = function ( int $parent_id ) use ( &$build, $children_of ) : array {
return array_map(
fn ( \WP_Term $term ) => [
'term' => $term,
'children' => $build( $term->term_id ),
],
$children_of[ $parent_id ] ?? []
);
};

return $build( 0 );
}

/**
* Flatten a term tree back into depth-first order, recording each depth.
*
* Used where nested markup is not possible, such as `<option>` elements, so
* the tree can still be conveyed by indentation.
*
* @param array[] $tree Nodes as returned by build_term_tree().
* @param int $depth Depth of the nodes passed, zero for roots.
* @return array[] Rows of `[ 'term' => WP_Term, 'depth' => int ]`.
*/
function flatten_term_tree( array $tree, int $depth = 0 ) : array {
$rows = [];

foreach ( $tree as $node ) {
$rows[] = [
'term' => $node['term'],
'depth' => $depth,
];
$rows = array_merge( $rows, flatten_term_tree( $node['children'], $depth + 1 ) );
}

return $rows;
}

/**
* Filters the settings determined from the block type metadata.
*
Expand Down
68 changes: 68 additions & 0 deletions src/filters.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,71 @@
flex-wrap: wrap;
gap: 1rem;
}

/* The display rules above outrank the browser's own [hidden] styling, so the
interactivity bindings that hide overflow terms and collapsed branches need
an explicit rule to take effect. */
.wp-block-query-filter [hidden] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice bugfix, in the midst of this

display: none;
}

/* Hierarchical term lists (nested and collapsed modes) */
.wp-block-query-filter__term-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}

.wp-block-query-filter__term-list[data-depth]:not([data-depth="0"]) {
margin-top: 0.5rem;
padding-left: 1.5rem;
}

.wp-block-query-filter__term {
display: flex;
flex-wrap: wrap;
align-items: center;
}

.wp-block-query-filter__term > .wp-block-query-filter__term-list {
flex-basis: 100%;
}

.wp-block-query-filter__toggle-children {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
margin-left: auto;
padding: 0;
border: 0;
background: none;
color: inherit;
cursor: pointer;
}

.wp-block-query-filter__toggle-children::before {
content: "";
width: 0.5em;
height: 0.5em;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: translateY(-25%) rotate(45deg);
transition: transform 0.15s ease;
}

.wp-block-query-filter__toggle-children[aria-expanded="true"]::before {
transform: translateY(25%) rotate(-135deg);
}

/* Horizontal layout keeps top level branches on one row */
.wp-block-query-filter__radio-group.horizontal > .wp-block-query-filter__term-list[data-depth="0"],
.wp-block-query-filter__checkbox-group.horizontal > .wp-block-query-filter__term-list[data-depth="0"] {
flex-direction: row;
flex-wrap: wrap;
gap: 1rem;
}
5 changes: 5 additions & 0 deletions src/taxonomy/block.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@
"type": "string",
"default": "vertical"
},
"hierarchy": {
"type": "string",
"enum": [ "flat", "nested", "collapsed" ],
"default": "flat"
},
"includeTerms": {
"type": "array",
"items": { "type": "string" },
Expand Down
150 changes: 132 additions & 18 deletions src/taxonomy/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default function Edit( { attributes, setAttributes } ) {
showLabel,
displayType,
layoutDirection,
hierarchy,
includeTerms,
excludeTerms,
maxVisibleTerms,
Expand Down Expand Up @@ -79,6 +80,89 @@ export default function Edit( { attributes, setAttributes } ) {
.filter( Boolean )
: terms.filter( ( term ) => ! excludeTerms.includes( term.slug ) );

const isHierarchical = !! taxonomies?.find(
( tax ) => tax.slug === taxonomy
)?.hierarchical;

// Mirror the server side fallbacks: a flat taxonomy has no tree to show,
// and a select control has no rows to collapse.
let hierarchyMode = isHierarchical ? hierarchy : 'flat';
if ( hierarchyMode === 'collapsed' && displayType === 'select' ) {
hierarchyMode = 'nested';
}

// Nest the preview terms by parent, promoting orphans to roots, in the
// same way build_term_tree() does on the server.
const buildTree = ( list ) => {
const ids = list.map( ( term ) => term.id );
const childrenOf = {};
list.forEach( ( term ) => {
const parent = ids.includes( term.parent ) ? term.parent : 0;
childrenOf[ parent ] = [ ...( childrenOf[ parent ] || [] ), term ];
} );
const build = ( parent ) =>
( childrenOf[ parent ] || [] ).map( ( term ) => ( {
term,
children: build( term.id ),
} ) );
return build( 0 );
};

const flattenTree = ( nodes, depth = 0 ) =>
nodes.flatMap( ( node ) => [
{ term: node.term, depth },
...flattenTree( node.children, depth + 1 ),
] );

const previewTree =
hierarchyMode === 'flat'
? previewTerms.map( ( term ) => ( { term, children: [] } ) )
: buildTree( previewTerms );

const hierarchyOptions = [
{ label: __( 'Flat list', 'query-filter' ), value: 'flat' },
{ label: __( 'Nested', 'query-filter' ), value: 'nested' },
];
if ( displayType !== 'select' ) {
hierarchyOptions.push( {
label: __( 'Nested, children collapsed', 'query-filter' ),
value: 'collapsed',
} );
}

const renderPreviewBranch = ( nodes, depth ) => (
<ul className="wp-block-query-filter__term-list" data-depth={ depth }>
{ nodes.map( ( node ) => (
<li
key={ node.term.slug }
className={ `wp-block-query-filter__term${
node.children.length ? ' has-children' : ''
}` }
>
<label>
<input
type={ displayType }
name="taxonomy-preview"
inert="true"
/>
{ node.term.name }
</label>
{ node.children.length > 0 &&
hierarchyMode === 'collapsed' && (
<button
type="button"
className="wp-block-query-filter__toggle-children"
aria-expanded="true"
inert="true"
/>
) }
{ node.children.length > 0 &&
renderPreviewBranch( node.children, depth + 1 ) }
</li>
) ) }
</ul>
);

return (
<>
<InspectorControls>
Expand Down Expand Up @@ -160,6 +244,20 @@ export default function Edit( { attributes, setAttributes } ) {
/>
</ToggleGroupControl>
) }
{ isHierarchical && (
<SelectControl
label={ __( 'Hierarchy', 'query-filter' ) }
value={ hierarchyMode }
options={ hierarchyOptions }
onChange={ ( value ) =>
setAttributes( { hierarchy: value } )
}
help={ __(
'Show child terms beneath their parents. Selecting a parent also matches posts in its children.',
'query-filter'
) }
/>
) }
<TextControl
label={ __( 'Label', 'query-filter' ) }
value={ label }
Expand Down Expand Up @@ -269,8 +367,10 @@ export default function Edit( { attributes, setAttributes } ) {
<option>
{ emptyLabel || __( 'All', 'query-filter' ) }
</option>
{ previewTerms.map( ( term ) => (
<option key={ term.slug }>{ term.name }</option>
{ flattenTree( previewTree ).map( ( { term, depth } ) => (
<option key={ term.slug }>
{ '— '.repeat( depth ) + term.name }
</option>
) ) }
</select>
) }
Expand All @@ -280,6 +380,10 @@ export default function Edit( { attributes, setAttributes } ) {
layoutDirection === 'horizontal'
? ' horizontal'
: ''
}${
hierarchyMode !== 'flat'
? ` is-hierarchy-${ hierarchyMode }`
: ''
}` }
>
<label>
Expand All @@ -291,16 +395,19 @@ export default function Edit( { attributes, setAttributes } ) {
/>
{ emptyLabel || __( 'All', 'query-filter' ) }
</label>
{ previewTerms.map( ( term ) => (
<label key={ term.slug }>
<input
type="radio"
name="taxonomy-preview"
inert="true"
/>
{ term.name }
</label>
) ) }
{ hierarchyMode === 'flat' &&
previewTerms.map( ( term ) => (
<label key={ term.slug }>
<input
type="radio"
name="taxonomy-preview"
inert="true"
/>
{ term.name }
</label>
) ) }
{ hierarchyMode !== 'flat' &&
renderPreviewBranch( previewTree, 0 ) }
</div>
) }
{ displayType === 'checkbox' && (
Expand All @@ -309,14 +416,21 @@ export default function Edit( { attributes, setAttributes } ) {
layoutDirection === 'horizontal'
? ' horizontal'
: ''
}${
hierarchyMode !== 'flat'
? ` is-hierarchy-${ hierarchyMode }`
: ''
}` }
>
{ previewTerms.map( ( term ) => (
<label key={ term.slug }>
<input type="checkbox" inert="true" />
{ term.name }
</label>
) ) }
{ hierarchyMode === 'flat' &&
previewTerms.map( ( term ) => (
<label key={ term.slug }>
<input type="checkbox" inert="true" />
{ term.name }
</label>
) ) }
{ hierarchyMode !== 'flat' &&
renderPreviewBranch( previewTree, 0 ) }
</div>
) }
</div>
Expand Down
Loading
Loading