/**
|
* Centralized Taxonomy Selector with DataStore Integration
|
* Handles all taxonomy selection fields using DataStore for state management
|
*/
|
class TaxonomySelector {
|
constructor() {
|
this.a11y = window.jvbA11y;
|
this.error = window.jvbError;
|
this.index = -1;
|
|
this.hasAutocomplete = false;
|
this.isInitializing = true;
|
this.taxonomiesToFetch = new Set();
|
|
this.store = new window.jvbStore({
|
name: `taxonomies`,
|
storeName: `terms`,
|
keyPath: 'id',
|
showLoading: false,
|
indexes: [
|
{name: 'taxonomy', keyPath: 'taxonomy'},
|
{name: 'parent', keyPath: 'parent'},
|
{name: 'slug', keyPath: 'slug', unique: true},
|
{name: 'count', keyPath: 'count'},
|
],
|
endpoint: 'terms',
|
TTL: 7200000, //2 hours
|
filters: {
|
taxonomy: '',
|
page: 1,
|
search: '',
|
parent: 0
|
},
|
required: 'taxonomy'
|
});
|
|
// Central field management
|
this.fields = new Map();
|
this.selectedTerms = new Map(); // Current modal selection
|
|
// Current modal context
|
this.activeField = null;
|
this.currentConfig = null;
|
this.currentSingular = null;
|
this.currentPlural = null;
|
this.activeStore = null;
|
|
// Modal state
|
this.disabled = false;
|
|
// Search debouncing
|
this.searchHandler = null;
|
this.autocompleteHandler = null;
|
this.isAutocompleteActive = false;
|
|
this.init();
|
}
|
|
/**
|
* Initialize the selector
|
*/
|
init() {
|
this.initModal();
|
this.scanExistingFields();
|
this.initGlobalListeners();
|
|
this.store.subscribe(this.handleStoreEvent.bind(this));
|
// Complete initialization
|
this.isInitializing = false;
|
this.batchFetchTaxonomies();
|
}
|
|
/**
|
* Handle DataStore events
|
*/
|
handleStoreEvent(event, data) {
|
switch (event) {
|
case 'data-loaded':
|
// Only render if modal is open OR if it's an autocomplete request
|
if (this.modal?.open) {
|
this.handleTermsLoaded(data);
|
}
|
// Handle autocomplete results
|
if (this.isAutocompleteActive && this.activeField) {
|
const field = this.fields.get(this.activeField);
|
const terms = data.data?.items || [];
|
const query = data.filters?.search || '';
|
this.showAutocompleteResults(field, terms, query);
|
this.isAutocompleteActive = false;
|
}
|
break;
|
|
case 'filters-changed':
|
// Modal UI updates happen here if needed
|
if (this.modal?.open) {
|
this.showLoading();
|
}
|
break;
|
|
case 'fetch-error':
|
if (this.isAutocompleteActive && this.activeField) {
|
this.showAutocompleteError(this.activeField);
|
this.isAutocompleteActive = false;
|
}
|
this.handleFetchError(data.error);
|
break;
|
}
|
}
|
|
/**
|
* Handle loaded terms from DataStore
|
*/
|
handleTermsLoaded(data) {
|
this.hideLoading();
|
const terms = data.data?.items || [];
|
const pagination = data.data?.pagination || {};
|
const isSearch = data.filters?.search && data.filters.search.length > 0;
|
const append = data.filters?.page > 1;
|
|
if (terms.length === 0) {
|
if (!append) {
|
this.showEmptyState(isSearch ? 'No results found.' : 'No items available.');
|
}
|
this.observer.unobserve(this.ui.sentinel);
|
} else {
|
this.renderTerms(terms, append, isSearch);
|
this.currentTerms = terms;
|
// Handle pagination
|
if (pagination.has_more) {
|
this.observer.observe(this.ui.sentinel);
|
} else {
|
this.observer.unobserve(this.ui.sentinel);
|
}
|
}
|
|
// Announce to screen readers
|
this.a11y?.announce(terms.length, append);
|
}
|
|
/**
|
* Handle fetch errors
|
*/
|
handleFetchError(error) {
|
console.error('Taxonomy fetch error:', error);
|
this.hideLoading();
|
|
if (this.error?.log) {
|
this.error.log(error, {
|
component: 'TaxonomySelector',
|
action: 'fetchTerms'
|
}, () => this.fetchCurrentTerms());
|
} else {
|
this.showEmptyState('Error loading terms. Please try again.');
|
}
|
}
|
|
/**
|
* Update fields when taxonomy items are updated
|
*/
|
updateFieldsForTaxonomy(taxonomy, items) {
|
this.fields.forEach(field => {
|
if (field.taxonomy === taxonomy && field.selectedTerms.size > 0) {
|
// Update display with fresh term data
|
field.selectedTerms.forEach(termId => {
|
const term = items.find(item => item.id === termId);
|
if (term) {
|
const selectedItem = field.selectedContainer.querySelector(`[data-id="${termId}"]`);
|
if (selectedItem) {
|
selectedItem.dataset.path = term.path;
|
selectedItem.querySelector('span').textContent = term.path;
|
}
|
}
|
});
|
}
|
});
|
}
|
|
/**
|
* Scan page for existing taxonomy fields and register them
|
*/
|
scanExistingFields(container = null) {
|
if (!container) {
|
container = document.body;
|
}
|
const selectors = container.querySelectorAll('.field.taxonomy, .field.post');
|
|
selectors.forEach(selector => {
|
try {
|
this.registerField(selector);
|
} catch (error) {
|
this.error.log(error, {
|
component: 'TaxonomySelector',
|
action: 'scanExistingFields',
|
container: selector.dataset.name
|
});
|
}
|
});
|
}
|
|
/**
|
* Register a taxonomy field
|
*/
|
registerField(field, options = {}) {
|
let input = field.querySelector('input[type=hidden]');
|
if (!input) {
|
return;
|
}
|
|
if (!('fieldId' in field.dataset)) {
|
field.dataset.fieldId = this.createFieldId(field);
|
}
|
let fieldId = field.dataset.fieldId;
|
let button = field.querySelector('button.taxonomy-toggle');
|
|
let config = {
|
id: fieldId,
|
input: input,
|
container: field,
|
taxonomy: button.dataset.taxonomy,
|
name: field.dataset.field,
|
maxSelection: parseInt(button.dataset.max) || 0,
|
canSearch: 'search' in button.dataset,
|
hasAutocomplete: 'autocomplete' in button.dataset,
|
autocompleteDropdown: field.querySelector('.autocomplete-dropdown')??false,
|
canCreate: 'creatable' in button.dataset,
|
isRequired: 'required' in button.dataset,
|
selectedTerms: new Set(),
|
toggle: button,
|
selectedContainer: field.querySelector('.selected-items'),
|
...options
|
};
|
|
if (!this.hasAutocomplete && config.hasAutocomplete) {
|
this.hasAutocomplete = true;
|
this.initAutocomplete();
|
}
|
|
// Parse initial selected values
|
const value = input.value.trim();
|
if (value !== '') {
|
const selectedIds = value.split(',')
|
.map(id => parseInt(id.trim()))
|
.filter(id => !isNaN(id));
|
selectedIds.forEach(id => config.selectedTerms.add(id));
|
}
|
|
this.fields.set(fieldId, config);
|
|
// Ensure store exists for this taxonomy
|
if (this.isInitializing) {
|
this.taxonomiesToFetch.add(config.taxonomy);
|
} else {
|
this.store.setFilter('taxonomy', config.taxonomy);
|
}
|
|
// Initialize display for any pre-selected values
|
if (config.selectedTerms.size > 0) {
|
this.initFieldDisplay(fieldId);
|
}
|
|
return fieldId;
|
}
|
|
/**
|
* Batch fetch all unique taxonomies collected during init
|
*/
|
async batchFetchTaxonomies() {
|
if (this.taxonomiesToFetch.size === 0) return;
|
|
const taxonomies = Array.from(this.taxonomiesToFetch);
|
this.taxonomiesToFetch.clear();
|
|
console.log(`Batch fetching ${taxonomies.length} unique taxonomies:`, taxonomies);
|
|
// Fetch each taxonomy sequentially (cache will prevent duplicates)
|
for (const taxonomy of taxonomies) {
|
await this.store.setFilters({
|
taxonomy: taxonomy,
|
page: 1,
|
search: '',
|
parent: 0
|
});
|
}
|
|
// Now initialize field displays
|
this.fields.forEach((config, fieldId) => {
|
if (config.selectedTerms.size > 0) {
|
this.initFieldDisplay(fieldId);
|
}
|
});
|
}
|
|
/**
|
* Create unique field ID
|
*/
|
createFieldId(field) {
|
this.index++;
|
return 'selector-' + this.index;
|
}
|
|
/**
|
* Initialize display for a field with existing values
|
*/
|
async initFieldDisplay(fieldId) {
|
const field = this.fields.get(fieldId);
|
if (!field || field.selectedTerms.size === 0) return;
|
|
const selectedIds = Array.from(field.selectedTerms);
|
const cachedTerms = [];
|
|
selectedIds.forEach(termId => {
|
const term = this.store.data.get(termId);
|
if (term) {
|
cachedTerms.push(term);
|
}
|
});
|
|
// Display all found terms
|
cachedTerms.forEach(term => {
|
this.addTermToDisplay(fieldId, term.id, term.name, term.path);
|
});
|
|
// Don't fetch missing terms here - they should be loaded by batchFetchTaxonomies
|
}
|
|
/**
|
* Initialize modal elements
|
*/
|
initModal() {
|
this.modalID = 'dialog#jvb-selector';
|
this.modal = document.querySelector(this.modalID);
|
|
if (!this.modal) {
|
console.warn('Taxonomy selector modal not found');
|
return;
|
}
|
|
this.initModalElements();
|
|
// Initialize modal instance
|
this.modalInstance = new window.jvbModal(this.modal, {
|
handleForm: false,
|
save: null,
|
open: null
|
});
|
this.modalInstance.subscribe((event, data) => {
|
switch (event) {
|
case 'modal-open':
|
console.log(data);
|
this.openModal(data);
|
break;
|
case 'modal-close':
|
this.closeModal(data);
|
break;
|
}
|
});
|
}
|
|
/**
|
* Initialize modal element references
|
*/
|
initModalElements() {
|
this.selectors = {
|
search: {
|
input: '[type=search]',
|
clear: '.clear-search',
|
container: '.search-wrapper'
|
},
|
termsList: '.items-container',
|
termsWrap: '.items-wrap',
|
breadcrumbs: {
|
nav: 'nav.term-navigation',
|
back: '.back-to-parent',
|
},
|
loading: {
|
loading: '.loading',
|
text: '.loading span'
|
},
|
selectedTerms: '.selected-items',
|
sentinel: '.scroll-sentinel',
|
modal: {
|
title: '#modal-title',
|
content: '.modal-content'
|
},
|
create: {
|
details: '.create-new-term',
|
parent: '#select_parent',
|
summary: '.create-new-term summary',
|
name: '#term_name',
|
button: '.submit-term',
|
label: {
|
name: '[for=term_name]',
|
parent: '[for=select_parent]'
|
}
|
},
|
favouriteTerms: '.favourite-terms'
|
}
|
|
this.ui = window.uiFromSelectors(this.selectors);
|
|
// Initialize intersection observer for infinite scroll
|
this.observer = new IntersectionObserver((entries) => {
|
entries.forEach(entry => {
|
if (entry.isIntersecting && this.activeStore) {
|
this.loadMoreTerms();
|
}
|
});
|
}, {
|
root: this.ui.termsWrap,
|
threshold: 0.5
|
});
|
}
|
|
/**
|
* Set up global event delegation
|
*/
|
initGlobalListeners() {
|
document.addEventListener('click', this.handleClick.bind(this));
|
document.addEventListener('change', this.handleChange.bind(this));
|
if (this.hasAutocomplete) {
|
this.initAutocomplete();
|
}
|
}
|
|
initAutocomplete()
|
{
|
console.log('Autocomplete init');
|
this.autocompleteHandler = window.debounce((e) => this.handleAutocomplete(e), 300);
|
document.addEventListener('input', this.autocompleteHandler);
|
document.addEventListener('blur', this.cleanupAutocomplete.bind(this));
|
}
|
|
/**
|
* Handle global click events
|
*/
|
handleClick(e) {
|
// Handle taxonomy toggle buttons
|
const toggleButton = window.targetCheck(e, '.taxonomy-toggle');
|
if (toggleButton) {
|
e.preventDefault();
|
this.handleToggleClick(toggleButton);
|
return;
|
}
|
|
// Handle remove selected term buttons
|
const removeButton = window.targetCheck(e, 'button.remove-item');
|
if (removeButton && e.target.closest('.jvb-selector')) {
|
const fieldId = this.getFieldId(removeButton);
|
const termId = removeButton.closest('.selected-item').dataset.id;
|
this.removeSelectedTerm(fieldId, termId);
|
return;
|
}
|
|
// Handle modal close button
|
if (e.target.matches('.modal-close')) {
|
if (this.modalInstance) {
|
this.modalInstance.handleClose();
|
}
|
return;
|
}
|
|
// Handle clicks within the modal
|
if (this.modal && this.modal.contains(e.target)) {
|
this.handleModalClick(e);
|
}
|
}
|
|
/**
|
* Handle global change events
|
*/
|
handleChange(e) {
|
// Handle hidden input changes for taxonomy fields
|
const taxonomyField = window.targetCheck(e, '.taxonomy.field, .post.field');
|
if (taxonomyField && e.target.type === 'hidden') {
|
const fieldId = this.getFieldId(e.target);
|
this.updateFieldFromInput(fieldId);
|
return;
|
}
|
|
// Handle modal changes
|
if (this.modal && this.modal.contains(e.target)) {
|
this.handleModalChange(e);
|
}
|
}
|
|
/**
|
* Handle toggle button click
|
*/
|
handleToggleClick(toggle) {
|
try {
|
const fieldId = this.getFieldId(toggle);
|
const field = this.fields.get(fieldId);
|
|
if (!field) {
|
console.error('Field not found for toggle:', fieldId);
|
return;
|
}
|
|
this.setActiveField(fieldId);
|
this.modalInstance.handleOpen();
|
|
} catch (error) {
|
console.error('Error handling toggle click:', error);
|
this.error?.handleError(error, {
|
component: 'TaxonomySelector',
|
action: 'handleToggleClick'
|
});
|
}
|
}
|
|
/**
|
* Set the active field for modal operations
|
*/
|
setActiveField(fieldId) {
|
this.activeField = fieldId;
|
this.currentConfig = this.fields.get(fieldId);
|
|
this.currentSingular = jvbSettings.labels[this.currentConfig.taxonomy].single;
|
this.currentPlural = jvbSettings.labels[this.currentConfig.taxonomy].plural;
|
|
// Get or create store for this taxonomy
|
this.store.setFilter('taxonomy', this.currentConfig.taxonomy);
|
|
// Clear modal selection state
|
this.selectedTerms.clear();
|
|
// Copy field's current selections to modal state
|
if (this.currentConfig.selectedTerms) {
|
let termsToFetch = [];
|
this.currentConfig.selectedTerms.forEach(termId => {
|
const term = this.store.getItem(termId);
|
if (term) {
|
this.selectedTerms.set(termId, {
|
id: termId,
|
name: term.name,
|
path: term.path
|
});
|
} else {
|
termsToFetch.push(termId);
|
}
|
});
|
if (termsToFetch.length > 0) {
|
let terms = this.fetchSpecificTerms(termsToFetch);
|
terms.forEach(term => {
|
this.selectedTerms.set(term.id, {
|
id: term.id,
|
name: term.name,
|
path: term.path
|
});
|
});
|
}
|
}
|
}
|
|
fetchSpecificTerms(terms) {
|
return [];
|
}
|
|
/**
|
* Handle clicks within modal
|
*/
|
handleModalClick(e) {
|
if (window.targetCheck(e, '.remove-item')) {
|
let selectedItem = window.targetCheck(e, '.selected-item');
|
if (selectedItem) {
|
this.removeSelectedTermFromModal(selectedItem.dataset.id);
|
}
|
} else if (window.targetCheck(e, '.back-to-parent')) {
|
this.navigateToParent();
|
} else if (window.targetCheck(e, '.toggle-children')) {
|
let termItem = e.target.closest('li');
|
this.navigateToChild(
|
parseInt(termItem.dataset.id),
|
termItem.querySelector('.term-name').textContent
|
);
|
} else if (window.targetCheck(e, '.path-level')) {
|
let pathLevel = window.targetCheck(e, '.path-level');
|
this.navigateToPath(pathLevel);
|
}
|
}
|
|
/**
|
* Handle changes within modal (checkboxes)
|
*/
|
handleModalChange(e) {
|
if (window.targetCheck(e, this.modalID) && e.target.type === 'checkbox') {
|
e.preventDefault();
|
e.stopPropagation();
|
|
const termId = parseInt(e.target.closest('li').dataset.id);
|
const label = e.target.closest('li').querySelector('label');
|
|
if (e.target.checked) {
|
this.addSelectedTermToModal(termId, label.title, label.dataset.path);
|
} else {
|
this.removeSelectedTermFromModal(termId);
|
}
|
}
|
}
|
|
/**
|
* Open modal for filtering (without a field)
|
* @param {string} taxonomy - The taxonomy to filter by
|
* @param {Function} callback - Callback when terms are selected
|
* @param {Array} preselected - Array of term IDs already selected
|
*/
|
openForFilter(taxonomy, callback, preselected = []) {
|
// Create a temporary virtual field config
|
const virtualFieldId = `filter-${taxonomy}-${Date.now()}`;
|
|
this.fields.set(virtualFieldId, {
|
id: virtualFieldId,
|
input: null, // No input for filter mode
|
container: null,
|
taxonomy: taxonomy,
|
name: `filter_${taxonomy}`,
|
maxSelection: 0, // No limit for filters
|
canSearch: true,
|
hasAutocomplete: false,
|
autocompleteDropdown: document.querySelector('.autocomplete-dropdown')??false,
|
canCreate: false, // Disable creation for filters
|
isRequired: false,
|
selectedTerms: new Set(preselected),
|
toggle: null,
|
selectedContainer: null,
|
isFilterMode: true, // Flag for filter mode
|
filterCallback: callback // Store the callback
|
});
|
|
this.setActiveField(virtualFieldId);
|
this.modalInstance.handleOpen();
|
}
|
|
/**
|
* Open modal and initialize
|
*/
|
openModal(config) {
|
this.activeField = config.fieldId;
|
this.currentConfig = config;
|
// Initialize creator if available
|
if (config.canCreate && 'jvbTaxCreator' in window) {
|
this.creator = new window.jvbTaxCreator(this);
|
} else if (this.creator) {
|
delete this.creator;
|
}
|
|
// Load selected terms into modal state
|
this.selectedTerms = new Set(config.selectedTerms);
|
|
// Only fetch if taxonomy changed
|
const currentTaxonomy = this.store.filters.taxonomy;
|
if (currentTaxonomy !== config.taxonomy) {
|
this.store.setFilters({
|
taxonomy: config.taxonomy,
|
page: 1,
|
search: '',
|
parent: 0
|
});
|
}
|
|
// Reset UI
|
window.removeChildren(this.ui.termsList);
|
this.ui.search.value = '';
|
this.updateSelectionCount();
|
|
this.modalInstance.open();
|
}
|
|
/**
|
* Close modal and save selections
|
*/
|
closeModal() {
|
this.observer.unobserve(this.ui.sentinel);
|
window.removeChildren(this.ui.termsList);
|
|
if (this.currentConfig?.isFilterMode) {
|
if (this.currentConfig.filterCallback) {
|
const selectedIds = Array.from(this.selectedTerms.keys());
|
this.currentConfig.filterCallback(selectedIds, this.currentConfig.taxonomy);
|
}
|
this.fields.delete(this.activeField);
|
} else if (this.activeField) {
|
this.saveSelectionsToField(this.activeField);
|
}
|
|
// Cleanup
|
if (this.currentConfig?.canSearch && this.searchHandler) {
|
this.ui.search.input.removeEventListener('input', this.searchHandler);
|
}
|
|
if (this.creator) {
|
delete this.creator;
|
}
|
|
// Remove: this.activeStore = null;
|
this.activeField = null;
|
this.currentConfig = null;
|
}
|
|
/**
|
* Reset modal state
|
*/
|
resetModalState() {
|
this.disabled = false;
|
|
window.removeChildren(this.ui.termsList);
|
window.removeChildren(this.ui.selectedTerms);
|
this.ui.search.input.value = '';
|
|
// Clear navigation breadcrumbs
|
window.removeChildren(this.ui.breadcrumbs.nav);
|
this.ui.breadcrumbs.nav.appendChild(this.ui.breadcrumbs.back);
|
this.ui.breadcrumbs.back.hidden = true;
|
}
|
|
/**
|
* Update modal content for current taxonomy
|
*/
|
updateModalForTaxonomy() {
|
if (!this.currentConfig) return;
|
|
this.ui.modal.title.textContent = `Select ${this.currentPlural}`;
|
|
if (this.ui.search.container) {
|
this.ui.search.container.style.display = this.currentConfig.canSearch ? 'block' : 'none';
|
}
|
|
if (this.ui.create.details) {
|
this.ui.create.details.style.display = this.currentConfig.canCreate ? 'block' : 'none';
|
this.ui.create.details.hidden = !this.currentConfig.canCreate;
|
|
if (this.ui.create.summary) {
|
this.ui.create.summary.textContent = `Add new ${this.currentSingular}`;
|
}
|
|
if (this.ui.create.label.name) {
|
this.ui.create.label.name.textContent = `Name this ${this.currentSingular}`;
|
}
|
if (this.ui.create.label.parent) {
|
this.ui.create.label.parent.textContent = `Nest it under`;
|
}
|
|
if (this.ui.create.parent) {
|
|
}
|
}
|
|
const openMessage = `Opened ${this.currentSingular} selection. Choose from checkboxes or search to filter results.`;
|
this.a11y?.announce(openMessage);
|
}
|
|
/**
|
* Update modal selections display
|
*/
|
updateModalSelections() {
|
window.removeChildren(this.ui.selectedTerms);
|
|
this.selectedTerms.forEach((termData, id) => {
|
this.addTermToModalDisplay(id, termData.name, termData.path);
|
});
|
|
this.checkSelectionLimits();
|
}
|
|
/**
|
* Add selected term to modal
|
*/
|
addSelectedTermToModal(id, name, path) {
|
this.selectedTerms.set(id, {
|
id: id,
|
name: name,
|
path: path
|
});
|
|
this.addTermToModalDisplay(id, name, path);
|
this.checkSelectionLimits();
|
|
// Check the corresponding checkbox
|
const checkbox = this.ui.termsList.querySelector(`input[value="${id}"]`);
|
if (checkbox) {
|
checkbox.checked = true;
|
}
|
}
|
|
/**
|
* Remove selected term from modal
|
*/
|
removeSelectedTermFromModal(id) {
|
this.selectedTerms.delete(parseInt(id));
|
|
// Remove from modal display
|
const selectedItem = this.ui.selectedTerms.querySelector(`[data-id="${id}"]`);
|
if (selectedItem) {
|
selectedItem.remove();
|
}
|
|
// Uncheck the corresponding checkbox
|
const checkbox = this.ui.termsList.querySelector(`input[value="${id}"]`);
|
if (checkbox) {
|
checkbox.checked = false;
|
}
|
|
this.checkSelectionLimits();
|
}
|
|
/**
|
* Add term to modal display
|
*/
|
addTermToModalDisplay(id, name, path) {
|
const item = window.getTemplate('selectedTerm').cloneNode(true);
|
item.dataset.id = id;
|
item.dataset.path = path;
|
item.dataset.name = name;
|
item.dataset.taxonomy = this.currentConfig.taxonomy;
|
item.querySelector('span').textContent = path;
|
item.querySelector('button').title = `Remove ${name}`;
|
|
this.ui.selectedTerms.appendChild(item);
|
}
|
|
/**
|
* Check selection limits and disable/enable checkboxes
|
*/
|
checkSelectionLimits() {
|
if (!this.currentConfig || this.currentConfig.maxSelection === 0) {
|
return;
|
}
|
|
this.disabled = this.selectedTerms.size >= this.currentConfig.maxSelection;
|
this.setCheckboxes(this.disabled);
|
}
|
|
/**
|
* Set checkbox disabled state
|
*/
|
setCheckboxes(disabled) {
|
this.ui.termsList.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
|
if (!checkbox.checked) {
|
checkbox.disabled = disabled;
|
}
|
});
|
}
|
|
/**
|
* Save modal selections to field
|
*/
|
saveSelectionsToField(fieldId) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
// Clear current field selections
|
field.selectedTerms.clear();
|
window.removeChildren(field.selectedContainer);
|
|
// Add modal selections to field
|
this.selectedTerms.forEach((termData, id) => {
|
field.selectedTerms.add(id);
|
this.addTermToDisplay(fieldId, id, termData.name, termData.path);
|
});
|
|
// Update hidden input
|
const selectedIds = Array.from(field.selectedTerms);
|
field.input.value = selectedIds.join(',');
|
field.input.dispatchEvent(new Event('change', { bubbles: true }));
|
}
|
|
/**
|
* Remove selected term from field
|
*/
|
removeSelectedTerm(fieldId, termId) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
const id = parseInt(termId);
|
field.selectedTerms.delete(id);
|
|
// Remove from display
|
const selectedItem = field.selectedContainer.querySelector(`[data-id="${id}"]`);
|
if (selectedItem) {
|
selectedItem.remove();
|
}
|
|
// Update hidden input
|
const selectedIds = Array.from(field.selectedTerms);
|
field.input.value = selectedIds.join(',');
|
field.input.dispatchEvent(new Event('change', { bubbles: true }));
|
}
|
|
/**
|
* Add term to field display
|
*/
|
addTermToDisplay(fieldId, id, name, path) {
|
const field = this.fields.get(fieldId);
|
if (!field || field.selectedContainer.querySelector(`[data-id="${id}"]`)) {
|
return; // Already displayed
|
}
|
|
const item = window.getTemplate('selectedTerm').cloneNode(true);
|
item.dataset.id = id;
|
item.dataset.path = path;
|
item.dataset.name = name;
|
item.dataset.taxonomy = field.taxonomy;
|
item.querySelector('span').textContent = path;
|
item.querySelector('button').title = `Remove ${name}`;
|
|
field.selectedContainer.appendChild(item);
|
}
|
|
/**
|
* Update field from hidden input value
|
*/
|
updateFieldFromInput(fieldId) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
const value = field.input.value.trim();
|
field.selectedTerms.clear();
|
window.removeChildren(field.selectedContainer);
|
|
if (value !== '') {
|
const selectedIds = value.split(',')
|
.map(id => parseInt(id.trim()))
|
.filter(id => !isNaN(id));
|
|
selectedIds.forEach(id => field.selectedTerms.add(id));
|
this.initFieldDisplay(fieldId);
|
}
|
}
|
|
/**
|
* Handle search input
|
*/
|
handleSearch(e) {
|
const query = e.target.value.trim();
|
|
// Clear existing debounce
|
if (this.searchHandler) {
|
clearTimeout(this.searchHandler);
|
}
|
|
this.searchHandler = setTimeout(() => {
|
// Single call - auto-fetches
|
this.store.setFilters({
|
search: query,
|
page: 1,
|
parent: query ? 0 : (this.store.filters.parent || 0)
|
});
|
|
window.removeChildren(this.ui.termsList);
|
}, 300);
|
}
|
|
async handleAutocomplete(e) {
|
if (!('autocomplete' in e.target.dataset)) {
|
return;
|
}
|
|
const fieldId = this.getFieldId(e.target);
|
const field = this.fields.get(fieldId);
|
|
if (!field) return;
|
|
// Store current value immediately (fixes fast typing issue)
|
const query = e.target.value.trim();
|
field.currentAutocompleteQuery = query;
|
|
if (query.length < 2) {
|
if (field.autocompleteDropdown) {
|
field.autocompleteDropdown.hidden = true;
|
}
|
this.isAutocompleteActive = false;
|
return;
|
}
|
|
this.activeField = fieldId;
|
this.isAutocompleteActive = true;
|
|
if (field.autocompleteDropdown) {
|
field.autocompleteDropdown.hidden = false;
|
}
|
|
this.store.setFilters({
|
taxonomy: field.taxonomy,
|
search: query,
|
page: 1
|
});
|
}
|
|
cleanupAutocomplete(e) {
|
if (!('autocomplete' in e.target.dataset)) {
|
return;
|
}
|
|
const fieldId = this.getFieldId(e.target);
|
const field = this.fields.get(fieldId);
|
|
if (!field) return;
|
|
if (this.creator) {
|
delete this.creator;
|
}
|
}
|
|
showAutocompleteError(fieldId) {
|
|
const field = this.fields.get(fieldId);
|
if (!field) {
|
return;
|
}
|
if (!field.config.autocompleteDropdown) {
|
field.config.autocompleteDropdown = field.element.querySelector('.autocomplete-dropdown');
|
}
|
const dropdown = field.config.autocompleteDropdown;
|
if (dropdown) {
|
window.removeChildren(dropdown);
|
this.showEmptyState('Hmmm... something went wrong', dropdown);
|
}
|
}
|
|
showAutocompleteResults(field, terms, query) {
|
if (!field || !field.autocompleteDropdown) {
|
return;
|
}
|
|
const dropdown = field.autocompleteDropdown;
|
window.removeChildren(dropdown);
|
|
if (terms.length === 0) {
|
this.showEmptyState('No items found.', dropdown);
|
} else {
|
terms.forEach(term => {
|
const element = this.createAutocompleteTermElement(field, term);
|
if (element) {
|
dropdown.appendChild(element);
|
}
|
});
|
}
|
|
// Use stored current query instead of debounced one
|
const currentQuery = field.currentAutocompleteQuery || query;
|
if (field.canCreate && currentQuery && window.jvbTaxCreator) {
|
const createOption = this.createNewTermOption(currentQuery);
|
dropdown.appendChild(createOption);
|
}
|
|
dropdown.hidden = false;
|
}
|
|
createNewTermOption(query) {
|
const button = document.createElement('button');
|
button.type = 'button';
|
button.className = 'autocomplete-item create-term';
|
button.dataset.query = query;
|
button.innerHTML = `<strong>Create:</strong> "${query}"`;
|
|
return button;
|
}
|
|
createAutocompleteTermElement(field, term) {
|
const item = document.createElement('button');
|
item.type = 'button';
|
item.className = 'autocomplete-item';
|
item.dataset.id = term.id;
|
item.dataset.name = term.name;
|
item.dataset.path = term.path || term.name;
|
item.textContent = term.path || term.name;
|
|
item.addEventListener('click', () => {
|
// Add term to field
|
field.selectedTerms.add(parseInt(term.id));
|
this.addTermToDisplay(field.id, term.id, term.name, term.path);
|
|
// Update input
|
field.input.value = Array.from(field.selectedTerms).join(',');
|
field.input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
// Clear and hide dropdown
|
field.autocompleteDropdown.hidden = true;
|
const input = field.container.querySelector('input[data-autocomplete]');
|
if (input) input.value = '';
|
});
|
|
return item;
|
}
|
|
/**
|
* Navigate to parent term
|
*/
|
navigateToParent() {
|
// Single call instead of two setFilter + manual fetch
|
this.store.setFilters({
|
parent: 0,
|
page: 1
|
});
|
|
window.removeChildren(this.ui.termsList);
|
this.ui.breadcrumbs.back.hidden = true;
|
}
|
|
/**
|
* Navigate to child term
|
*/
|
navigateToChild(termId, termName) {
|
// Single call - auto-fetches
|
this.store.setFilters({
|
parent: termId,
|
page: 1
|
});
|
|
window.removeChildren(this.ui.termsList);
|
this.updateBreadcrumbs(termId, termName);
|
this.ui.breadcrumbs.back.hidden = false;
|
}
|
|
/**
|
* Navigate to specific path level
|
*/
|
navigateToPath(pathLevel) {
|
const parentId = parseInt(pathLevel.dataset.id) || 0;
|
|
// Single call - auto-fetches
|
this.store.setFilters({
|
parent: parentId,
|
page: 1
|
});
|
|
window.removeChildren(this.ui.termsList);
|
this.ui.breadcrumbs.back.hidden = parentId === 0;
|
}
|
|
/**
|
* Load more terms (pagination)
|
*/
|
loadMoreTerms() {
|
if (!this.activeStore) return;
|
|
const currentPage = this.activeStore.filters.page || 1;
|
this.store.setFilter('page', currentPage + 1);
|
|
}
|
|
/**
|
* Render terms list
|
*/
|
renderTerms(terms, append = false, showPath = false) {
|
if (!append) {
|
window.removeChildren(this.ui.termsList);
|
}
|
|
if (terms.length === 0) {
|
if (!append) {
|
this.showEmptyState();
|
}
|
return;
|
}
|
|
// Use this.store instead of this.activeStore
|
const currentParent = this.store.filters.parent || 0;
|
this.ui.breadcrumbs.back.hidden = currentParent === 0;
|
|
terms.forEach(term => {
|
const element = this.createTermElement({
|
id: parseInt(term.id),
|
name: term.name,
|
hasChildren: term.hasChildren,
|
path: term.path || null,
|
show: showPath
|
});
|
|
if (element) {
|
this.ui.termsList.appendChild(element);
|
}
|
});
|
}
|
|
/**
|
* Create individual term element
|
*/
|
createTermElement(termData) {
|
if (!termData || !termData.name) return null;
|
|
const listItem = window.getTemplate('termListItem').cloneNode(true);
|
listItem.dataset.id = termData.id;
|
|
const isSelected = this.selectedTerms.has(termData.id);
|
const checkbox = listItem.querySelector('input');
|
const label = listItem.querySelector('label');
|
const nameSpan = listItem.querySelector('span, .term-name');
|
|
if (checkbox && label && nameSpan) {
|
checkbox.id = `${this.currentConfig.container.id}${termData.id}`;
|
checkbox.name = `${this.currentConfig.container.id}${this.currentConfig.taxonomy}-select`;
|
checkbox.value = termData.id;
|
checkbox.disabled = !isSelected && this.disabled;
|
checkbox.checked = isSelected;
|
|
label.htmlFor = checkbox.id;
|
label.title = termData.path || termData.name;
|
label.dataset.path = termData.path;
|
|
nameSpan.textContent = termData.show ? termData.path : termData.name;
|
}
|
|
if (termData.hasChildren) {
|
const childrenToggle = window.getTemplate ?
|
window.getTemplate('termChildrenToggle') :
|
this.createChildrenToggle();
|
|
if (childrenToggle) {
|
childrenToggle.ariaLabel = `View sub-terms of ${termData.name}`;
|
listItem.appendChild(childrenToggle);
|
}
|
}
|
|
return listItem;
|
}
|
|
/**
|
* Create children toggle button
|
*/
|
createChildrenToggle() {
|
const button = document.createElement('button');
|
button.type = 'button';
|
button.className = 'toggle-children';
|
button.innerHTML = '→';
|
return button;
|
}
|
|
/**
|
* Update breadcrumb navigation
|
*/
|
updateBreadcrumbs(termId, termName) {
|
// This is a simplified version - you'd want to maintain a proper breadcrumb trail
|
const breadcrumb = window.getTemplate('termBreadcrumb').cloneNode(true);
|
breadcrumb.dataset.id = termId;
|
breadcrumb.textContent = termName;
|
breadcrumb.title = termName;
|
|
// Remove any existing breadcrumbs after this level
|
const existingCrumb = this.ui.breadcrumbs.nav.querySelector(`[data-id="${termId}"]`);
|
if (existingCrumb) {
|
// Remove all breadcrumbs after this one
|
while (existingCrumb.nextElementSibling) {
|
existingCrumb.nextElementSibling.remove();
|
}
|
} else {
|
this.ui.breadcrumbs.nav.appendChild(breadcrumb);
|
}
|
}
|
|
/**
|
* Show loading state
|
*/
|
showLoading() {
|
this.ui.loading.loading.hidden = false;
|
this.modal.classList.add('loading');
|
|
const searchQuery = this.store?.filters?.search || '';
|
const currentParent = this.store?.filters?.parent || 0;
|
|
let message = searchQuery !== '' ?
|
`searching for "${searchQuery}" items` :
|
currentParent === 0 ?
|
'loading items' :
|
`loading child items`;
|
|
if (window.typeLoop) {
|
this.stopTyping = window.typeLoop(this.ui.loading.text, message);
|
} else {
|
this.ui.loading.text.textContent = message;
|
}
|
}
|
|
/**
|
* Hide loading state
|
*/
|
hideLoading() {
|
this.ui.loading.loading.hidden = true;
|
this.modal.classList.remove('loading');
|
|
if (this.stopTyping) {
|
this.stopTyping();
|
}
|
}
|
|
/**
|
* Show empty state message
|
*/
|
showEmptyState(message = 'No items found.', container = null) {
|
if (!container) {
|
container = this.ui.termsList;
|
}
|
const emptyElement = window.getTemplate('noResults').cloneNode(true);
|
|
if (message && emptyElement.querySelector('span')) {
|
emptyElement.querySelector('span').textContent = message;
|
}
|
|
container.appendChild(emptyElement);
|
}
|
|
/**
|
* Get field ID from any element within the field
|
*/
|
getFieldId(element) {
|
if (element.dataset.fieldId) {
|
return element.dataset.fieldId;
|
}
|
|
const fieldContainer = element.closest('[data-field-id]');
|
if (fieldContainer) {
|
return fieldContainer.dataset.fieldId;
|
}
|
|
return null;
|
}
|
|
/**
|
* Clean up
|
*/
|
destroy() {
|
// Remove event listeners
|
document.removeEventListener('click', this.handleClick);
|
document.removeEventListener('change', this.handleChange);
|
|
// Clear intervals and cleanup
|
this.observer?.disconnect();
|
|
// Destroy all stores
|
this.store.destroy();
|
|
// Clear all maps
|
this.fields.clear();
|
this.selectedTerms.clear();
|
}
|
}
|
|
/**
|
* Initialize singleton
|
*/
|
document.addEventListener('DOMContentLoaded', function() {
|
window.jvbSelector = new TaxonomySelector();
|
});
|