| | |
| | | <?php |
| | | namespace JVBase\forms; |
| | | |
| | | use JVBase\managers\CacheManager; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use JVBase\registrar\Registrar; |
| | | 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. |
| | | * Taxonomy Selector |
| | | * |
| | | * @package TaxonomySelector |
| | | * @version 2.0.0 |
| | |
| | | /** |
| | | * Track if any taxonomy selectors are present on the page |
| | | */ |
| | | private static $hasSelectors = false; |
| | | |
| | | protected string $id; |
| | | protected string $name; |
| | |
| | | protected string $plural; |
| | | protected string $taxonomy; |
| | | protected string $base; |
| | | protected string $title; |
| | | protected array $config; |
| | | protected Registrar $registrar; |
| | | |
| | | 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']??''; |
| | | |
| | | $registrar = Registrar::getInstance($this->name); |
| | | if ($registrar) { |
| | | $this->registrar = $registrar; |
| | | } |
| | | |
| | | $this->title = $registrar->getPlural(); |
| | | $this->base = $config['base']??''; |
| | | $this->config = wp_parse_args($config, [ |
| | | 'types' => false, //for feed block implementation |
| | | 'max' => 0, |
| | | '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, |
| | | 'update' => false, |
| | | 'base' => '', |
| | | 'name' => $this->taxonomy, |
| | | 'update' => true, // Whether to update on close |
| | | ]); |
| | | |
| | | $this->plural = JVB_TAXONOMY[$taxonomy]['plural']; |
| | | $this->singular = JVB_TAXONOMY[$taxonomy]['singular']; |
| | | |
| | | |
| | | $this->plural = $registrar->getPlural(); |
| | | $this->singular = $registrar->getSingular(); |
| | | } |
| | | |
| | | /** |
| | | * 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 |
| | | * @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($term): string { |
| | | if (!$term || is_wp_error($term)) { |
| | | return ''; |
| | | } |
| | | |
| | | public static function getTermPath(WP_Term $term, bool $returnArray = false): string|array { |
| | | if (!is_taxonomy_hierarchical($term->taxonomy)) { |
| | | return $term->name; |
| | | return html_entity_decode($term->name); |
| | | } |
| | | |
| | | $path = []; |
| | | $currentTerm = $term; |
| | | |
| | | while ($currentTerm) { |
| | | array_unshift($path, $currentTerm->name); |
| | | array_unshift($path, html_entity_decode($currentTerm->name)); |
| | | |
| | | if ($currentTerm->parent) { |
| | | $currentTerm = get_term($currentTerm->parent); |
| | |
| | | break; |
| | | } |
| | | } |
| | | |
| | | return implode(' → ', $path); |
| | | return ($returnArray) ? $path : 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 { |
| | | public static function outputSelectorModal(): string { |
| | | ob_start(); |
| | | ?> |
| | | <dialog id="jvb-selector" aria-labelledby="modal-title" aria-modal="true"> |
| | |
| | | <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="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> |
| | | <summary class="title row x-btw">Your Go Tos:</summary> |
| | | <ul class="favourite-list row x-btw"></ul> |
| | | </details> |
| | | |
| | | <!-- Pagination info --> |
| | |
| | | </button> |
| | | </nav> |
| | | |
| | | |
| | | <p class="message" hidden aria-live="polite"> |
| | | { <span>loading items</span> } |
| | | </p> |
| | | <!-- Terms list --> |
| | | <ul class="items-container col start" role="listbox" aria-label="Available terms"> |
| | | <ul class="items-container col top" 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> |
| | | <button class="submit-term" hidden data-ignore><strong>Create: </strong> "<span></span>"</button> |
| | | |
| | | <!-- Infinite scroll sentinel --> |
| | | <div class="scroll-sentinel" aria-hidden="true"></div> |
| | |
| | | <!-- Search section --> |
| | | <div class="search-wrapper"> |
| | | <div class="search-bar"> |
| | | <?= jvbSearch('Search terms') ?> |
| | | <?= jvbSearch('Search terms', 'search-terms') ?> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- Create new term section --> |
| | | <details class="create-new-term" hidden> |
| | | <summary class="row btw">Add New Term</summary> |
| | | <details class="create-term" hidden> |
| | | <summary class="row x-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"> |
| | | <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> |
| | |
| | | <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(); ?> |
| | |
| | | <template class="loadingItems"> |
| | | <p>{ <span>loading items</span> }</p> |
| | | </template> |
| | | <template class="noResults"> |
| | | <template class="autocompleteItem"> |
| | | <li class="autocomplete item btn"></li> |
| | | </template> |
| | | <template class="noTermResults"> |
| | | <p>{ <span>nothing found</span> }</p> |
| | | </template> |
| | | <template class="termListItem"> |
| | | <li> |
| | | <input type ="checkbox"> |
| | | <input type="checkbox"> |
| | | <label> |
| | | <span class="term-name"></span> |
| | | </label> |
| | |
| | | </template> |
| | | <template class="termChildrenToggle"> |
| | | <button type="button" class="toggle-children" aria-expanded="false"> |
| | | <?=jvbIcon('add')?> |
| | | <?=jvbIcon('plus-square')?> |
| | | </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> |
| | | <button type="button" class="remove-term row"><?=jvbIcon('x')?></button> |
| | | </div> |
| | | </template> |
| | | <template class="termBreadcrumb"> |
| | |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | public function render(array $selected =[], string $extra = ''):string |
| | | { |
| | | // Mark that selectors are present for footer output |
| | | self::markSelectorsPresent(); |
| | | /** |
| | | * 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 { |
| | | |
| | | $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).'"'; |
| | | 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 <?= $this->name ?>" |
| | | id="<?= $this->id ?>"<?=$hidden?>> |
| | | <button type="button" |
| | | <div class="jvb-selector <?= esc_attr($this->name) ?>" |
| | | id="<?= esc_attr($this->id) ?>"<?= $hidden ?>> |
| | | <div class="field-group-header row x-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="<?=$this->name?>" |
| | | data-single="<?=$this->singular?>" |
| | | data-plural="<?=$this->plural?>" |
| | | <?= $max.$search.$creatable.$required.$for.$dataSelected?> |
| | | 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) ?>"> |
| | | <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; ?> |
| | | <?= 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> |
| | | <p class="message" hidden aria-live="polite"> |
| | | { <span>loading items</span> } |
| | | </p> |
| | | <div class="auto-wrapper" hidden> |
| | | <ul class="search-results"> |
| | | </ul> |
| | | <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 sprintf( |
| | | '<button type="button" data-icon="%s" data-filter="taxonomy" data-taxonomy="%s" data-type="selector" data-single="%s" data-plural="%s" title="Filter by %s">%s<span class="label">%s</span></button>', |
| | | $this->registrar->getIcon(), |
| | | $this->name, |
| | | $this->singular, |
| | | $this->plural, |
| | | $this->singular, |
| | | jvbIcon($this->registrar->getIcon()), |
| | | $this->singular |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * 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 <?= html_entity_decode($term->name) ?>"> |
| | | <?= jvbIcon('x') ?> |
| | | </button> |
| | | </div> |
| | | <?php |
| | | } |
| | | } |