/** * 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; // DataStore instances per taxonomy this.stores = new Map(); this.storeSubscriptions = new Map(); // 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.init(); } /** * Initialize the selector */ init() { this.initModal(); this.scanExistingFields(); this.initGlobalListeners(); } /** * Get or create a DataStore for a taxonomy */ getOrCreateStore(taxonomy) { if (!this.stores.has(taxonomy)) { const store = new window.jvbStore({ name: `tax_${taxonomy}`, endpoint: 'terms', TTL: 3600000, // 1 hour cache filters: { taxonomy: taxonomy, page: 1, search: '', parent: 0 } }); // Subscribe to store events const unsubscribe = store.subscribe((event, data) => { this.handleStoreEvent(taxonomy, event, data); }); this.stores.set(taxonomy, store); this.storeSubscriptions.set(taxonomy, unsubscribe); } return this.stores.get(taxonomy); } /** * Handle DataStore events */ handleStoreEvent(taxonomy, event, data) { // Only process events for the active taxonomy in modal if (this.activeStore && this.activeStore.config.name === `tax_${taxonomy}`) { switch (event) { case 'items-loaded': case 'data-fetched': case 'data-cached': case 'stale-cache-used': this.handleTermsLoaded(data); break; case 'fetch-error': this.handleFetchError(data.error); break; case 'filters-changed': // Could trigger UI updates for active filters break; } } // Handle field-specific updates outside modal if (event === 'items-updated' || event === 'items-loaded') { this.updateFieldsForTaxonomy(taxonomy, data.items); } } /** * 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() { const selectors = document.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, canCreate: 'creatable' in button.dataset, isRequired: 'required' in button.dataset, selectedTerms: new Set(), toggle: button, selectedContainer: field.querySelector('.selected-items'), ...options }; // 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 this.getOrCreateStore(config.taxonomy); // Initialize display for any pre-selected values if (config.selectedTerms.size > 0) { this.initFieldDisplay(fieldId); } return 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 store = this.getOrCreateStore(field.taxonomy); const selectedIds = Array.from(field.selectedTerms); // Check store for cached terms first const cachedTerms = []; const needsFetch = []; selectedIds.forEach(termId => { const term = store.getItem(termId); if (term) { cachedTerms.push(term); } else { needsFetch.push(termId); } }); // Display cached terms immediately cachedTerms.forEach(term => { this.addTermToDisplay(fieldId, term.id, term.name, term.path); }); // Fetch missing terms if needed if (needsFetch.length > 0) { try { const response = await store.fetch('terms', { filters: { taxonomy: field.taxonomy, termIDs: needsFetch.join(',') } }); if (response.terms) { response.terms.forEach(term => { store.setItem(term.id, term); this.addTermToDisplay(fieldId, term.id, term.name, term.path); }); } } catch (error) { console.error('Failed to fetch missing terms:', error); } } } /** * 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)); } /** * 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); console.log('Current Taxonomy:',this.currentConfig.taxonomy); console.log('Labels: ',jvbSettings.labels[this.currentConfig.taxonomy]); this.currentSingular = jvbSettings.labels[this.currentConfig.taxonomy].single; this.currentPlural = jvbSettings.labels[this.currentConfig.taxonomy].plural; // Get or create store for this taxonomy this.activeStore = this.getOrCreateStore(this.currentConfig.taxonomy); // Clear modal selection state this.selectedTerms.clear(); // Copy field's current selections to modal state if (this.currentConfig.selectedTerms) { this.currentConfig.selectedTerms.forEach(termId => { const term = this.activeStore.getItem(termId); if (term) { this.selectedTerms.set(termId, { id: termId, name: term.name, path: term.path }); } else { // If not in store, create minimal entry this.selectedTerms.set(termId, { id: termId, name: `Term ${termId}`, path: `Term ${termId}` }); } }); } } /** * 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, 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() { if (!this.activeField || !this.currentConfig) { console.error('No active field set for modal'); return; } this.resetModalState(); this.updateModalForTaxonomy(); // Reset store filters to default state this.activeStore.clearFilters(); // Set up search if enabled if (this.currentConfig.canSearch) { this.ui.search.input.focus(); this.searchHandler = window.debounce(() => this.handleSearch(), 300); this.ui.search.input.addEventListener('input', this.searchHandler); } // Initialize creator if available if (this.currentConfig.canCreate && 'jvbTaxCreator' in window) { this.creator = new window.jvbTaxCreator(this); } // Display current selections this.updateModalSelections(); // Start observing for infinite scroll this.observer.observe(this.ui.sentinel); // Fetch initial terms this.fetchCurrentTerms(); } /** * Close modal and save selections */ closeModal() { this.observer.unobserve(this.ui.sentinel); window.removeChildren(this.ui.termsList); if (this.currentConfig?.isFilterMode) { // Call the filter callback with selected terms if (this.currentConfig.filterCallback) { const selectedIds = Array.from(this.selectedTerms.keys()); this.currentConfig.filterCallback(selectedIds, this.currentConfig.taxonomy); } // Clean up the virtual field 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; } 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() { const query = this.ui.searchInput.value.trim(); if (query.length >= 2 || query.length === 0) { // Reset pagination when searching this.activeStore.setFilter('page', 1); this.activeStore.setFilter('search', query); window.removeChildren(this.ui.termsList); if (query.length >= 2) { this.showLoading(); this.fetchCurrentTerms(); } else if (query.length === 0) { // Clear search and reload this.showLoading(); this.fetchCurrentTerms(); } } else { this.hideLoading(); this.showEmptyState('Enter at least 2 characters to search.'); } } /** * Navigate to parent term */ navigateToParent() { const currentParent = this.activeStore.filters.parent || 0; // Find parent of current parent (could enhance this with breadcrumb tracking) this.activeStore.setFilter('parent', 0); this.activeStore.setFilter('page', 1); window.removeChildren(this.ui.termsList); this.showLoading(); this.fetchCurrentTerms(); // Update breadcrumbs this.ui.breadcrumbs.back.hidden = true; } /** * Navigate to child term */ navigateToChild(termId, termName) { this.activeStore.setFilter('parent', termId); this.activeStore.setFilter('page', 1); window.removeChildren(this.ui.termsList); this.showLoading(); this.fetchCurrentTerms(); // Update breadcrumbs this.updateBreadcrumbs(termId, termName); this.ui.breadcrumbs.back.hidden = false; } /** * Navigate to specific path level */ navigateToPath(pathLevel) { const parentId = parseInt(pathLevel.dataset.id) || 0; this.activeStore.setFilter('parent', parentId); this.activeStore.setFilter('page', 1); window.removeChildren(this.ui.termsList); this.showLoading(); this.fetchCurrentTerms(); // Update breadcrumbs to this level // You'd need to track the full path to properly implement this this.ui.breadcrumbs.back.hidden = parentId === 0; } /** * Fetch terms using current store filters */ fetchCurrentTerms() { if (!this.activeStore) return; this.showLoading(); this.activeStore.fetch(); } /** * Load more terms (pagination) */ loadMoreTerms() { if (!this.activeStore) return; const currentPage = this.activeStore.filters.page || 1; this.activeStore.setFilter('page', currentPage + 1); // fetch() will be called automatically by setFilter } /** * 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; } // Update breadcrumbs if needed const currentParent = this.activeStore.filters.parent || 0; this.ui.breadcrumbs.back.hidden = currentParent === 0; terms.forEach(term => { // Check if we have a cached DOM element const cachedElement = this.activeStore.getDOMElement(term.id, 'list-item'); if (cachedElement) { // Update checkbox state if needed const checkbox = cachedElement.querySelector('input[type="checkbox"]'); if (checkbox) { checkbox.checked = this.selectedTerms.has(term.id); checkbox.disabled = !checkbox.checked && this.disabled; } this.ui.termsList.appendChild(cachedElement); } else { // Create new element and cache it const element = this.createTermElement({ id: parseInt(term.id), name: term.name, hasChildren: term.hasChildren, path: term.path || null, show: showPath }); if (element) { this.activeStore.storeDOMElement(term.id, 'list-item', 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.activeStore?.filters?.search || ''; const currentParent = this.activeStore?.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.') { const emptyElement = window.getTemplate('noResults').cloneNode(true); if (message && emptyElement.querySelector('span')) { emptyElement.querySelector('span').textContent = message; } this.ui.termsList.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(); // Unsubscribe from all stores this.storeSubscriptions.forEach(unsubscribe => unsubscribe()); // Destroy all stores this.stores.forEach(store => store.destroy()); // Clear all maps this.fields.clear(); this.stores.clear(); this.storeSubscriptions.clear(); this.selectedTerms.clear(); } } /** * Initialize singleton */ document.addEventListener('DOMContentLoaded', function() { if (!window.jvbSelector) { window.jvbSelector = new TaxonomySelector(); } });