<?php
|
namespace JVBase\forms;
|
|
use JVBase\managers\CacheManager;
|
use WP_REST_Request;
|
use WP_REST_Response;
|
use WP_Term;
|
use WP_Query;
|
|
if (!defined('ABSPATH')) {
|
exit; // Exit if accessed directly
|
}
|
|
/**
|
* Single Modal Taxonomy Selector
|
*
|
* Complete replacement for individual taxonomy selector modals.
|
* Uses one shared modal instance with intelligent prefetching.
|
*
|
* @package TaxonomySelector
|
* @version 2.0.0
|
*/
|
|
class TaxonomySelector {
|
/**
|
* Track if any taxonomy selectors are present on the page
|
*/
|
private static $hasSelectors = false;
|
|
protected string $id;
|
protected string $name;
|
protected string $singular;
|
protected string $plural;
|
protected string $taxonomy;
|
protected string $base;
|
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->base = $config['base']??'';
|
|
$this->config = wp_parse_args($config, [
|
'types' => false, //for feed block implementation
|
'max' => 0,
|
'search' => true,
|
'createNew' => false,
|
'required' => false,
|
'hidden' => false,
|
'update' => false,
|
]);
|
|
$this->plural = JVB_TAXONOMY[$taxonomy]['plural'];
|
$this->singular = JVB_TAXONOMY[$taxonomy]['singular'];
|
|
|
}
|
|
/**
|
* Mark that selectors are present (called when rendering toggles)
|
*/
|
public static function markSelectorsPresent(): void {
|
self::$hasSelectors = true;
|
}
|
|
/**
|
* Get the full path for a term (for hierarchical taxonomies)
|
*
|
* @param WP_Term $term The term object
|
* @return string The full term path
|
*/
|
public static function getTermPath($term): string {
|
if (!$term || is_wp_error($term)) {
|
return '';
|
}
|
|
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 implode(' → ', $path);
|
}
|
|
/**
|
* Output the single modal dialog in footer
|
*/
|
public static function outputSelector(): void {
|
echo self::getSingleModalHTML();
|
remove_action('wp_footer', [self::class, 'outputSelector']);
|
}
|
|
/**
|
* Get the single modal HTML structure
|
*/
|
public static function getSingleModalHTML(): 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-section">
|
<div class="selected-items row" role="region" aria-label="Selected items">
|
<!-- Selected items will be populated here -->
|
</div>
|
</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>
|
|
<!-- Loading indicator -->
|
<p class="loading" hidden aria-live="polite">
|
<span>loading items</span>
|
</p>
|
|
<!-- 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-new-term" hidden>
|
<summary class="row btw">Add New Term</summary>
|
<div class="create-new-term-section">
|
<form class="create-term-form" 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 class="loading-message create-term" hidden>
|
<span id="typed-text"></span>
|
<span class="cursor">|</span>
|
</div>
|
</div>
|
</details>
|
<?= jvbModalActions(); ?>
|
</div>
|
</dialog>
|
<template class="loadingItems">
|
<p>{ <span>loading items</span> }</p>
|
</template>
|
<template class="noResults">
|
<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('add')?>
|
</button>
|
</template>
|
<template class="selectedTerm">
|
<div class="selected-item row">
|
<span class="item-name"></span>
|
<button type="button" class="remove-item row"><?=jvbIcon('close')?></button>
|
</div>
|
</template>
|
<template class="termBreadcrumb">
|
<button type="button" class="path-level"></button>
|
</template>
|
<?php
|
return ob_get_clean();
|
}
|
|
public function render(array $selected =[], string $extra = ''):string
|
{
|
// Mark that selectors are present for footer output
|
self::markSelectorsPresent();
|
|
$update = ($this->config['update']) ? '' : ' data-update="'.$this->config['update'].'"';
|
$max = ($this->config['max'] === 0) ? '' : ' data-max="'.$this->config['max'];
|
$search = ($this->config['search']) ? ' data-search' : '';
|
$creatable = ($this->config['createNew']) ? ' data-creatable' : '';
|
$required = ($this->config['required']) ? ' data-required' : '';
|
$hidden = ($this->config['hidden']) ? ' hidden' : '';
|
$for = ($this->config['types']) ? ' data-for="'.implode(',',$this->config['types']) : '';
|
$dataSelected = ' data-selected="'.implode(',',$selected).'"';
|
ob_start();
|
?>
|
|
<div class="jvb-selector <?= $this->name ?>"
|
id="<?= $this->id ?>"<?=$hidden?>>
|
<button type="button"
|
class="filter-toggle row taxonomy-toggle"
|
data-taxonomy="<?=$this->name?>"
|
data-single="<?=$this->singular?>"
|
data-plural="<?=$this->plural?>"
|
<?= $max.$search.$creatable.$required.$for.$dataSelected?>
|
aria-label="Select <?= esc_attr($this->plural) ?>">
|
<span class="button-text">Select <?= esc_html($this->plural) ?></span>
|
<?= jvbIcon('add') ?>
|
</button>
|
<div class="selected-items row" role="region" aria-label="Selected <?=$this->plural?>">
|
<?php if (!empty($selected)): ?>
|
<?php foreach ($selected as $termId ):
|
$term = get_term($termId, $this->taxonomy);
|
$termData = [
|
'name' => $term->name,
|
'path' => $this->getTermPath($term)
|
]; ?>
|
<div class="selected-item row" data-id="<?= esc_attr($termId) ?>">
|
<span><?= esc_html(is_array($termData) ? ($termData['path'] ?? $termData['name']) : $termData) ?></span>
|
<button type="button" class="remove-item row" aria-label="Remove term">×</button>
|
</div>
|
<?php endforeach; ?>
|
<?php endif; ?>
|
</div>
|
<?= $extra ?>
|
</div>
|
|
<?php
|
return ob_get_clean();
|
}
|
}
|