<?php
|
namespace JVBase\forms;
|
|
use WP_Term;
|
|
if (!defined('ABSPATH')) {
|
exit; // Exit if accessed directly
|
}
|
|
/**
|
* Taxonomy Selector
|
*
|
* @package TaxonomySelector
|
* @version 2.0.0
|
*/
|
|
class TaxonomySelector {
|
/**
|
* Track if any taxonomy selectors are present on the page
|
*/
|
|
protected string $id;
|
protected string $name;
|
protected string $singular;
|
protected string $plural;
|
protected string $taxonomy;
|
protected string $base;
|
protected string $title;
|
protected array $config;
|
|
public function __construct(string $id, string $taxonomy, array $config = []) {
|
$this->id = sanitize_key($id);
|
$this->taxonomy = jvbCheckBase($taxonomy);
|
$this->name = jvbNoBase($taxonomy);
|
|
$this->title = JVB_TAXONOMY[$this->name]['plural'];
|
$this->base = $config['base']??'';
|
$this->config = wp_parse_args($config, [
|
'types' => false, // for feed block implementation
|
'max' => 0, // 0 = unlimited
|
'search' => true,
|
'label' => $this->name,
|
'icon' => false,
|
'autocomplete' => false,
|
'createNew' => false,
|
'required' => false,
|
'hidden' => false,
|
'base' => '',
|
'name' => $this->taxonomy,
|
'update' => true, // Whether to update on close
|
]);
|
|
$this->plural = JVB_TAXONOMY[$taxonomy]['plural'];
|
$this->singular = JVB_TAXONOMY[$taxonomy]['singular'];
|
}
|
|
|
/**
|
* Get the full path for a term (for hierarchical taxonomies)
|
*
|
* @param WP_Term $term The term object
|
* @param bool $returnArray if true, returns the array. If false, a string of terms separated by ' → '
|
* @return string|array An array of terms or the full term path
|
*/
|
public static function getTermPath(WP_Term $term, bool $returnArray = false): string|array {
|
if (!is_taxonomy_hierarchical($term->taxonomy)) {
|
return $term->name;
|
}
|
|
$path = [];
|
$currentTerm = $term;
|
|
while ($currentTerm) {
|
array_unshift($path, $currentTerm->name);
|
|
if ($currentTerm->parent) {
|
$currentTerm = get_term($currentTerm->parent);
|
if (is_wp_error($currentTerm)) {
|
break;
|
}
|
} else {
|
break;
|
}
|
}
|
return ($returnArray) ? $path : implode(' → ', $path);
|
}
|
|
|
/**
|
* Get the single modal HTML structure
|
*/
|
public static function outputSelectorModal(): string {
|
ob_start();
|
?>
|
<dialog id="jvb-selector" aria-labelledby="modal-title" aria-modal="true">
|
<div class="wrap col">
|
<header class="modal-header">
|
<h3 id="modal-title">Select Taxonomy</h3>
|
</header>
|
|
|
<div class="selected-items row" role="region" aria-label="Selected items"></div>
|
|
<div class="items-wrap">
|
<!-- Common/Favorite terms section -->
|
<details class="favourite-terms" hidden>
|
<summary class="title row btw">Your Go Tos:</summary>
|
<ul class="favourite-list row btw"></ul>
|
</details>
|
|
<!-- Pagination info -->
|
<p class="pagination-info" hidden></p>
|
|
<!-- Navigation breadcrumbs -->
|
<nav class="term-navigation row" aria-label="Term navigation">
|
<button type="button" class="back-to-parent" hidden>
|
<span aria-hidden="true">←</span> Back
|
</button>
|
</nav>
|
|
<!-- Terms list -->
|
<ul class="items-container col start" role="listbox" aria-label="Available terms">
|
<!-- Terms will be populated here -->
|
</ul>
|
|
<p class="message" hidden aria-live="polite">
|
{ <span>loading items</span> }
|
</p>
|
<button class="submit-term" hidden data-ignore><strong>Create: </strong> "<span></span>"</button>
|
|
<!-- Infinite scroll sentinel -->
|
<div class="scroll-sentinel" aria-hidden="true"></div>
|
</div>
|
|
<!-- Search section -->
|
<div class="search-wrapper">
|
<div class="search-bar">
|
<?= jvbSearch('Search terms') ?>
|
</div>
|
</div>
|
|
<!-- Create new term section -->
|
<details class="create-term" hidden>
|
<summary class="row btw">Add New Term</summary>
|
<div class="create-new-term-section">
|
<form class="create-term" data-nocache data-form-id="create-term" data-save="terms">
|
<div class="form-group">
|
<label for="term_name">Term Name:</label>
|
<input type="text" name="term_name" id="term_name" required>
|
</div>
|
|
<div class="form-group">
|
<label for="select_parent">Nest it under:</label>
|
<select name="parent" id="select_parent">
|
<option value="0">None (Top Level)</option>
|
</select>
|
</div>
|
|
<button type="button" class="submit-term">Add Term</button>
|
</form>
|
|
</div>
|
</details>
|
<?= jvbModalActions(); ?>
|
</div>
|
</dialog>
|
<template class="loadingItems">
|
<p>{ <span>loading items</span> }</p>
|
</template>
|
<template class="autocompleteItem">
|
<button class="autocomplete item" type="button" data-autocomplete-select></button>
|
</template>
|
<template class="noTermResults">
|
<p>{ <span>nothing found</span> }</p>
|
</template>
|
<template class="termListItem">
|
<li>
|
<input type ="checkbox">
|
<label>
|
<span class="term-name"></span>
|
</label>
|
</li>
|
</template>
|
<template class="termChildrenToggle">
|
<button type="button" class="toggle-children" aria-expanded="false">
|
<?=jvbIcon('plus-square')?>
|
</button>
|
</template>
|
<template class="selectedTerm">
|
<div class="selected-item row">
|
<span class="item-name"></span>
|
<button type="button" class="remove-term row"><?=jvbIcon('x')?></button>
|
</div>
|
</template>
|
<template class="termBreadcrumb">
|
<button type="button" class="path-level"></button>
|
</template>
|
<?php
|
return ob_get_clean();
|
}
|
|
/**
|
* Render the taxonomy selector toggle and display
|
*
|
* @param array $selected Array of term IDs that are already selected
|
* @param string $extra Additional HTML to append (optional)
|
* @return string The rendered HTML
|
*/
|
public function render(array $selected = [], string $extra = ''): string {
|
|
if (array_key_exists('output', $this->config) && $this->config['output'] === 'minimal') {
|
return $this->renderTaxonomyToggle($selected, $extra);
|
}
|
// Build data attributes
|
$dataAttrs = $this->buildDataAttributes($selected);
|
|
$hasAutocomplete = ($this->config['autocomplete']) ? ' data-autocomplete' : '';
|
|
// Hidden attribute
|
$hidden = $this->config['hidden'] ? ' hidden' : '';
|
|
ob_start();
|
?>
|
<div class="jvb-selector <?= esc_attr($this->name) ?>"
|
id="<?= esc_attr($this->id) ?>"<?= $hidden ?>>
|
<div class="field-group-header row btw">
|
<label for="<?= $this->base ?><?= esc_attr($this->config['name']) ?>-autocomplete">
|
<?= ($this->config['icon']) ? jvbIcon($this->config['icon']) : '' ?>
|
<span><?= $this->config['label'] ?></span>
|
</label>
|
<button type="button"
|
class="filter-toggle row taxonomy-toggle"
|
data-taxonomy="<?= esc_attr($this->name) ?>"
|
data-single="<?= esc_attr($this->singular) ?>"
|
data-plural="<?= esc_attr($this->plural) ?>"
|
<?= $dataAttrs ?>
|
<?= $hasAutocomplete ?>
|
title="Open <?= $this->singular ?> Selector"
|
aria-label="Select <?= esc_attr($this->plural) ?>">
|
<?= jvbIcon('plus-square') ?>
|
</button>
|
<?php if ($hasAutocomplete !== '') { ?>
|
<input type="text" id="<?= $this->base ?><?= esc_attr($this->config['name']) ?>-autocomplete" autocomplete="off" data-ignore data-autocomplete>
|
<div class="auto-wrapper" hidden>
|
<ul class="search-results">
|
</ul>
|
<p class="message" hidden aria-live="polite">
|
{ <span>loading items</span> }
|
</p>
|
<button class="submit-term" hidden data-ignore><strong>Create: </strong>"<span></span>"</button>
|
</div>
|
|
<?php } ?>
|
</div>
|
<?php
|
$selectedItems = '';
|
if (!empty($selected)):
|
ob_start();
|
foreach ($selected as $termId):
|
$this->renderSelectedTerm($termId);
|
endforeach;
|
$selectedItems = ob_get_clean();
|
endif;
|
?>
|
<div class="selected-items row" role="region" aria-label="Selected <?= esc_attr($this->plural) ?>"><?=$selectedItems?></div>
|
<?= $extra ?>
|
</div>
|
<?php
|
return ob_get_clean();
|
}
|
|
protected function renderTaxonomyToggle(array $selected = [], string $extra = ''): string
|
{
|
return '<button type="button" data-filter="taxonomy" data-taxonomy="'.$this->name.'" title="Filter by '.$this->singular.'">'.jvbIcon($this->config['icon']).'<span class="label">'.$this->singular.'</span></button>';
|
}
|
|
/**
|
* Build data attributes string for the toggle button
|
*/
|
private function buildDataAttributes(array $selected): string {
|
$attrs = [];
|
|
// Update behavior
|
if (!$this->config['update']) {
|
$attrs[] = 'data-update="false"';
|
}
|
|
// Max selection
|
if ($this->config['max'] > 0) {
|
$attrs[] = 'data-max="' . esc_attr($this->config['max']) . '"';
|
}
|
|
// Search capability
|
if ($this->config['search']) {
|
$attrs[] = 'data-search';
|
}
|
|
// Create new capability
|
if ($this->config['createNew']) {
|
$attrs[] = 'data-creatable';
|
}
|
|
// Required
|
if ($this->config['required']) {
|
$attrs[] = 'data-required';
|
}
|
|
// Post types filter (for feed blocks)
|
if ($this->config['types'] && is_array($this->config['types'])) {
|
$attrs[] = 'data-for="' . esc_attr(implode(',', $this->config['types'])) . '"';
|
}
|
|
// Selected items
|
if (!empty($selected)) {
|
$attrs[] = 'data-selected="' . esc_attr(implode(',', $selected)) . '"';
|
}
|
|
return implode(' ', $attrs);
|
}
|
|
/**
|
* Render a single selected term
|
*/
|
private function renderSelectedTerm(int $termId): void {
|
$term = get_term($termId, $this->taxonomy);
|
|
if (!$term || is_wp_error($term)) {
|
return;
|
}
|
|
$termPath = self::getTermPath($term);
|
?>
|
<div class="selected-item row" data-id="<?= esc_attr($termId) ?>">
|
<span><?= esc_html($termPath) ?></span>
|
<button type="button"
|
class="remove-term row"
|
aria-label="Remove <?= esc_attr($term->name) ?>">
|
<?= jvbIcon('x') ?>
|
</button>
|
</div>
|
<?php
|
}
|
}
|