/** * TaxonomySelector - Streamlined version * Manages taxonomy selection fields with DataStore integration */ class TaxonomySelector { constructor() { this.a11y = window.jvbA11y; this.error = window.jvbError; this.index = -1; this.isInitializing = true; this.taxonomiesToFetch = new Set(); this.subscribers = new Set(); // Register DataStore const store = window.jvbStore.register('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: 2 * 60 * 1000, filters: { taxonomy: '', page: 1, search: '', parent: 0 }, required: 'taxonomy', delayFetch: true, }); this.store = store.terms; // Field management this.fields = new Map(); this.selectedTerms = new Map(); // Current modal selection // Modal context this.activeField = null; this.currentConfig = null; this.disabled = false; // Search contexts this.searchContexts = new Map(); this.init(); } init() { this.initModal(); this.scanExistingFields(); this.initGlobalListeners(); // Initialize creator if needed if (this.needsCreator() && window.jvbTaxCreator) { this.creator = new window.jvbTaxCreator(this); } this.store.subscribe(this.handleStoreEvent.bind(this)); this.isInitializing = false; this.batchFetchTaxonomies(); } needsCreator() { return Array.from(this.fields.values()).some(field => field.canCreate || field.hasAutocomplete ); } /*********************************************************************** * DATASTORE EVENT HANDLING ***********************************************************************/ handleStoreEvent(event, data) { const handlers = { 'data-loaded': () => this.handleDataLoaded(data), 'filters-changed': () => this.handleFiltersChanged(data), 'fetch-error': () => this.handleFetchError(data.error), }; handlers[event]?.(); } handleDataLoaded(data) { const taxonomy = this.store.filters.taxonomy; // Update field states for affected taxonomies if (taxonomy) { const taxonomies = taxonomy.includes(',') ? taxonomy.split(',').map(t => t.trim()) : [taxonomy]; taxonomies.forEach(tax => this.updateFieldsForTaxonomy(tax)); } // Initialize displays on first load if (this.isInitializing) { this.fields.forEach((config, fieldId) => { if (config.selectedTerms.size > 0) { this.initFieldDisplay(fieldId); } }); } // Render based on context this.renderSearchResults(data); } renderSearchResults(data) { const context = this.getActiveSearchContext(); if (context === 'modal') { this.renderModalResults(data); } else if (context === 'autocomplete') { this.renderAutocompleteResults(data); } } getActiveSearchContext() { if (this.modal?.open) return 'modal'; if (this.activeField && this.searchContexts.has(this.activeField)) { return this.searchContexts.get(this.activeField); } return null; } renderModalResults(data) { this.hideLoading(); const terms = this.store.getFiltered(); const response = this.store.lastResponse?.page || {}; const isSearch = data.filters?.search?.length > 0; const append = response.page > 1; this.notify('terms-loaded', { terms, filters: data.filters }); 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); if (response.has_more) { this.observer.observe(this.ui.sentinel); } else { this.observer.unobserve(this.ui.sentinel); } } this.a11y?.announce(terms.length, append); } renderAutocompleteResults(data) { const field = this.fields.get(this.activeField); if (!field?.autocompleteDropdown) return; const terms = this.store.getFiltered(); const query = data.filters?.search || ''; this.showAutocompleteResults(field, terms, query); this.searchContexts.delete(this.activeField); } handleFiltersChanged(data) { if (this.modal?.open) { this.showLoading(); } } handleFetchError(error) { this.hideLoading(); const context = this.getActiveSearchContext(); if (context === 'autocomplete') { this.showAutocompleteError(this.activeField); this.searchContexts.delete(this.activeField); } else { this.handleError(error, 'fetch'); } } /*********************************************************************** * FIELD MANAGEMENT ***********************************************************************/ updateFieldsForTaxonomy(taxonomy) { this.getFieldsForTaxonomy(taxonomy).forEach(field => { this.updateFieldButtonState(field.id); }); } updateFieldButtonState(fieldId) { const field = this.fields.get(fieldId); if (!field) return; const hasTerms = Array.from(this.store.data.values()) .some(term => term.taxonomy === field.taxonomy); if (field.toggle) { field.toggle.disabled = !hasTerms && !field.canCreate; field.toggle.title = !hasTerms ? `No ${this.getLabel(field.taxonomy, 'single')} available` : `Select ${this.getLabel(field.taxonomy, 'plural')}`; } } getFieldsForTaxonomy(taxonomy) { return Array.from(this.fields.values()) .filter(field => field.taxonomy === taxonomy); } scanExistingFields(container = document.body) { container.querySelectorAll('.field.taxonomy, .field.post').forEach(selector => { try { this.registerField(selector); } catch (error) { this.handleError(error, 'scanExistingFields', selector.dataset.name); } }); } registerField(field) { const input = field.querySelector('input[type=hidden]'); if (!input) return false; const fieldId = this.createFieldId(field); field.dataset.fieldId = fieldId; const button = field.querySelector('button.taxonomy-toggle'); const 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') || null, canCreate: 'creatable' in button.dataset, isRequired: 'required' in button.dataset, selectedTerms: new Set(), toggle: button, selectedContainer: field.querySelector('.selected-items'), }; // Parse initial values const value = input.value.trim(); if (value) { value.split(',') .map(id => parseInt(id.trim())) .filter(id => !isNaN(id)) .forEach(id => config.selectedTerms.add(id)); } this.fields.set(fieldId, config); // Queue for batch fetch if (this.isInitializing) { this.taxonomiesToFetch.add(config.taxonomy); } // Initialize display if (config.selectedTerms.size > 0) { this.initFieldDisplay(fieldId); } return fieldId; } createFieldId(field) { this.index++; return 'selector-' + this.index; } async initFieldDisplay(fieldId) { const field = this.fields.get(fieldId); if (!field || field.selectedTerms.size === 0) return; Array.from(field.selectedTerms).forEach(termId => { const term = this.store.get(termId); if (term) { this.addTermDisplay(termId, term.name, term.path, 'field', fieldId); } }); } /*********************************************************************** * MODAL INITIALIZATION ***********************************************************************/ initModal() { this.modal = document.querySelector('dialog#jvb-selector'); if (!this.modal) { console.warn('Taxonomy selector modal not found'); return; } this.initModalElements(); this.modalInstance = new window.jvbModal(this.modal, { handleForm: false }); this.modalInstance.subscribe((event) => { if (event === 'modal-open') this.openModal(); if (event === 'modal-close') this.closeModal(); }); } initModalElements() { const selectors = { search: { input: '[type=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', }, create: { details: '.create-new-term', summary: '.create-new-term summary', label: { name: '[for=term_name]', parent: '[for=select_parent]' } } }; this.ui = window.uiFromSelectors(selectors); // Initialize infinite scroll observer this.observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { this.loadMoreTerms(); } }); }, { root: this.ui.termsWrap, threshold: 0.5 }); } /*********************************************************************** * GLOBAL EVENT LISTENERS ***********************************************************************/ initGlobalListeners() { document.addEventListener('click', this.handleClick.bind(this)); document.addEventListener('change', this.handleChange.bind(this)); document.addEventListener('input', this.handleInput.bind(this)); document.addEventListener('focus', this.handleFocus.bind(this), true); document.addEventListener('blur', this.handleBlur.bind(this), true); } handleClick(e) { // Toggle button if (window.targetCheck(e, '.taxonomy-toggle')) { e.preventDefault(); const fieldId = this.getFieldId(e.target); const field = this.fields.get(fieldId); if (field) this.setActiveField(fieldId, true); return; } // Remove selected term 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; } // Modal close if (e.target.matches('.modal-close')) { this.modalInstance?.handleClose(); return; } // Modal clicks if (this.modal?.contains(e.target)) { this.handleModalClick(e); } } handleChange(e) { // Hidden input changes 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; } // Modal checkboxes if (this.modal?.contains(e.target)) { this.handleModalChange(e); } } handleInput(e) { // Modal search if (this.modal?.contains(e.target) && e.target.type === 'search') { this.performSearch(e.target.value.trim(), 'modal'); return; } // Autocomplete if ('autocomplete' in e.target.dataset) { const fieldId = this.getFieldId(e.target); const field = this.fields.get(fieldId); if (field?.hasAutocomplete) { this.performSearch(e.target.value.trim(), 'autocomplete', fieldId); } } } handleFocus(e) { if (!('autocomplete' in e.target.dataset)) return; const fieldId = this.getFieldId(e.target); const field = this.fields.get(fieldId); if (field?.hasAutocomplete) { this.preloadTaxonomy(field.taxonomy); } } handleBlur(e) { if (!('autocomplete' in e.target.dataset)) return; setTimeout(() => { const fieldId = this.getFieldId(e.target); const field = this.fields.get(fieldId); if (field?.autocompleteDropdown) { field.autocompleteDropdown.hidden = true; } this.searchContexts.delete(fieldId); }, 200); } /*********************************************************************** * UNIFIED SEARCH ***********************************************************************/ performSearch(query, context = 'modal', fieldId = null) { const field = context === 'autocomplete' ? this.fields.get(fieldId) : this.currentConfig; if (!field) return; // Autocomplete validation if (context === 'autocomplete') { field.currentAutocompleteQuery = query; if (query.length < 2) { if (field.autocompleteDropdown) { field.autocompleteDropdown.hidden = true; } return; } this.searchContexts.set(fieldId, 'autocomplete'); this.activeField = fieldId; if (field.autocompleteDropdown) { field.autocompleteDropdown.hidden = false; } } // Debounced search window.debouncer.schedule( `taxonomy-search-${context}-${fieldId || 'modal'}`, async () => { await this.store.setFilters({ taxonomy: field.taxonomy, search: query, page: 1, parent: query ? 0 : (this.store.filters.parent || 0) }); if (context === 'modal') { window.removeChildren(this.ui.termsList); } }, 300 ); } /*********************************************************************** * MODAL OPERATIONS ***********************************************************************/ setActiveField(fieldId, openModal = false) { this.activeField = fieldId; this.currentConfig = this.fields.get(fieldId); if (openModal) { this.modalInstance.handleOpen(); } this.store.setFilter('taxonomy', this.currentConfig.taxonomy); // Reset modal selection state this.selectedTerms.clear(); // Copy field selections to modal this.currentConfig.selectedTerms.forEach(termId => { const term = this.store.get(termId); if (term) { this.selectedTerms.set(termId, { id: termId, name: term.name, path: term.path }); } }); } handleModalClick(e) { if (window.targetCheck(e, '.remove-item')) { const 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')) { const termItem = e.target.closest('li'); this.navigateToChild( parseInt(termItem.dataset.id), termItem.querySelector('.term-name').textContent ); } else if (window.targetCheck(e, '.path-level')) { const pathLevel = window.targetCheck(e, '.path-level'); this.navigateToPath(parseInt(pathLevel.dataset.id) || 0); } } handleModalChange(e) { if (e.target.type !== 'checkbox') return; 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); } } openModal() { if (!this.currentConfig) { console.error('No active field set'); return; } this.updateModalUI(); this.updateModalSelections(); window.removeChildren(this.ui.termsList); this.showLoading(); } closeModal() { this.observer.unobserve(this.ui.sentinel); window.removeChildren(this.ui.termsList); this.notify('selected-terms', { terms: this.selectedTerms, taxonomy: this.currentConfig.taxonomy }); if (this.activeField) { this.saveSelectionsToField(this.activeField); } this.activeField = null; this.currentConfig = null; } updateModalUI() { const singular = this.getLabel(this.currentConfig.taxonomy, 'single'); const plural = this.getLabel(this.currentConfig.taxonomy, 'plural'); this.ui.modal.title.textContent = `Select ${plural}`; 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 ${singular}`; } if (this.ui.create.label.name) { this.ui.create.label.name.textContent = `Name this ${singular}`; } if (this.ui.create.label.parent) { this.ui.create.label.parent.textContent = `Nest it under`; } } this.a11y?.announce(`Opened ${singular} selection. Choose from checkboxes or search to filter results.`); } updateModalSelections() { window.removeChildren(this.ui.selectedTerms); this.selectedTerms.forEach((termData, id) => { this.addTermDisplay(id, termData.name, termData.path, 'modal'); }); this.checkSelectionLimits(); } addSelectedTermToModal(id, name, path) { this.selectedTerms.set(id, { id, name, path }); this.addTermDisplay(id, name, path, 'modal'); this.checkSelectionLimits(); const checkbox = this.ui.termsList.querySelector(`input[value="${id}"]`); if (checkbox) checkbox.checked = true; } removeSelectedTermFromModal(id) { this.selectedTerms.delete(parseInt(id)); const selectedItem = this.ui.selectedTerms.querySelector(`[data-id="${id}"]`); if (selectedItem) selectedItem.remove(); const checkbox = this.ui.termsList.querySelector(`input[value="${id}"]`); if (checkbox) checkbox.checked = false; this.checkSelectionLimits(); } checkSelectionLimits() { if (!this.currentConfig || this.currentConfig.maxSelection === 0) { return; } this.disabled = this.selectedTerms.size >= this.currentConfig.maxSelection; this.ui.termsList.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { if (!checkbox.checked) { checkbox.disabled = this.disabled; } }); } saveSelectionsToField(fieldId) { const field = this.fields.get(fieldId); if (!field) return; field.selectedTerms.clear(); window.removeChildren(field.selectedContainer); this.selectedTerms.forEach((termData, id) => { field.selectedTerms.add(id); this.addTermDisplay(id, termData.name, termData.path, 'field', fieldId); }); field.input.value = Array.from(field.selectedTerms).join(','); field.input.dispatchEvent(new Event('change', { bubbles: true })); } /*********************************************************************** * TERM DISPLAY ***********************************************************************/ addTermDisplay(termId, termName, termPath, context = 'field', fieldId = null) { const config = context === 'field' ? this.fields.get(fieldId) : this.currentConfig; const container = context === 'field' ? config.selectedContainer : this.ui.selectedTerms; if (container.querySelector(`[data-id="${termId}"]`)) return; const item = window.getTemplate('selectedTerm'); item.dataset.id = termId; item.dataset.path = termPath; item.dataset.name = termName; item.dataset.taxonomy = config.taxonomy; item.querySelector('.item-name').textContent = termPath; item.querySelector('button').title = `Remove ${termName}`; container.appendChild(item); if (context === 'modal') { const checkbox = this.ui.termsList.querySelector(`input[value="${termId}"]`); if (checkbox) checkbox.checked = true; } } removeSelectedTerm(fieldId, termId) { const field = this.fields.get(fieldId); if (!field) return; field.selectedTerms.delete(parseInt(termId)); const selectedItem = field.selectedContainer.querySelector(`[data-id="${termId}"]`); if (selectedItem) selectedItem.remove(); field.input.value = Array.from(field.selectedTerms).join(','); field.input.dispatchEvent(new Event('change', { bubbles: true })); } 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) { value.split(',') .map(id => parseInt(id.trim())) .filter(id => !isNaN(id)) .forEach(id => field.selectedTerms.add(id)); this.initFieldDisplay(fieldId); } } /*********************************************************************** * NAVIGATION ***********************************************************************/ navigateToParent() { this.store.setFilters({ parent: 0, page: 1 }); window.removeChildren(this.ui.termsList); this.ui.breadcrumbs.back.hidden = true; } navigateToChild(termId, termName) { this.store.setFilters({ parent: termId, page: 1 }); window.removeChildren(this.ui.termsList); this.updateBreadcrumbs(termId, termName); this.ui.breadcrumbs.back.hidden = false; } navigateToPath(parentId) { this.store.setFilters({ parent: parentId, page: 1 }); window.removeChildren(this.ui.termsList); this.ui.breadcrumbs.back.hidden = parentId === 0; } loadMoreTerms() { const currentPage = this.store.filters.page || 1; this.store.setFilter('page', currentPage + 1); } updateBreadcrumbs(termId, termName) { const breadcrumb = window.getTemplate('termBreadcrumb'); breadcrumb.dataset.id = termId; breadcrumb.textContent = termName; breadcrumb.title = termName; const existingCrumb = this.ui.breadcrumbs.nav.querySelector(`[data-id="${termId}"]`); if (existingCrumb) { while (existingCrumb.nextElementSibling) { existingCrumb.nextElementSibling.remove(); } } else { this.ui.breadcrumbs.nav.appendChild(breadcrumb); } } /*********************************************************************** * RENDERING ***********************************************************************/ renderTerms(terms = null, append = false, showPath = false) { if (!terms) terms = this.store.getFiltered(); if (!append) window.removeChildren(this.ui.termsList); if (terms.length === 0) { if (!append) this.showEmptyState(); return; } const currentParent = this.store.filters.parent || 0; this.ui.breadcrumbs.back.hidden = currentParent === 0; const fragment = document.createDocumentFragment(); 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) fragment.appendChild(element); }); this.ui.termsList.appendChild(fragment); } createTermElement(termData) { if (!termData?.name) return null; const listItem = window.getTemplate('termListItem'); 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('.term-name'); 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('termChildrenToggle'); childrenToggle.ariaLabel = `View sub-terms of ${termData.name}`; listItem.appendChild(childrenToggle); } return listItem; } /*********************************************************************** * AUTOCOMPLETE ***********************************************************************/ showAutocompleteResults(field, terms, query) { if (!field?.autocompleteDropdown) return; const dropdown = field.autocompleteDropdown; window.removeChildren(dropdown); if (terms.length === 0) { this.showEmptyState('No items found.', dropdown); } else { const fragment = document.createDocumentFragment(); terms.forEach(term => { const item = this.createAutocompleteItem(field, term); if (item) fragment.appendChild(item); }); dropdown.appendChild(fragment); } // Create button if allowed and no exact match const currentQuery = field.currentAutocompleteQuery || query; if (field.canCreate && currentQuery) { const exactMatch = terms.find(term => term.name.toLowerCase() === currentQuery.toLowerCase() ); if (!exactMatch) { dropdown.appendChild(this.createAutocompleteCreateButton(currentQuery)); } } dropdown.hidden = false; } createAutocompleteItem(field, term) { const button = document.createElement('button'); button.type = 'button'; button.className = 'autocomplete-item'; button.dataset.id = term.id; button.dataset.name = term.name; button.dataset.path = term.path || term.name; button.textContent = term.path || term.name; button.addEventListener('click', () => { field.selectedTerms.add(parseInt(term.id)); this.addTermDisplay(term.id, term.name, term.path, 'field', field.id); field.input.value = Array.from(field.selectedTerms).join(','); field.input.dispatchEvent(new Event('change', { bubbles: true })); field.autocompleteDropdown.hidden = true; const input = field.container.querySelector('input[data-autocomplete]'); if (input) input.value = ''; }); return button; } createAutocompleteCreateButton(query) { const button = document.createElement('button'); button.type = 'button'; button.className = 'autocomplete-item create-term'; button.dataset.query = query; const strong = document.createElement('strong'); strong.textContent = 'Create: '; button.appendChild(strong); button.appendChild(document.createTextNode(`"${query}"`)); return button; } showAutocompleteError(fieldId) { const field = this.fields.get(fieldId); if (!field?.autocompleteDropdown) return; window.removeChildren(field.autocompleteDropdown); this.showEmptyState('Hmmm... something went wrong', field.autocompleteDropdown); } /*********************************************************************** * UI STATES ***********************************************************************/ 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; const 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; } } hideLoading() { this.ui.loading.loading.hidden = true; this.modal.classList.remove('loading'); if (this.stopTyping) { this.stopTyping(); } } showEmptyState(message = 'No items found.', container = null) { if (!container) container = this.ui.termsList; const emptyElement = window.getTemplate('noResults'); const messageSpan = emptyElement.querySelector('span'); if (message && messageSpan) { messageSpan.textContent = message; } container.appendChild(emptyElement); } /*********************************************************************** * UTILITIES ***********************************************************************/ getFieldId(element) { if (element.dataset.fieldId) return element.dataset.fieldId; const fieldContainer = element.closest('[data-field-id]'); return fieldContainer?.dataset.fieldId || null; } getLabel(taxonomy, type = 'single') { return jvbSettings.labels[taxonomy]?.[type] || taxonomy; } async batchFetchTaxonomies() { if (this.taxonomiesToFetch.size === 0) return; const taxonomies = Array.from(this.taxonomiesToFetch); this.taxonomiesToFetch.clear(); this.store.setFilters({ taxonomy: taxonomies.join(','), page: 1, search: '', parent: 0 }); } async preloadTaxonomy(taxonomy) { await this.store.setFilters({ taxonomy: taxonomy, page: 1, search: '', parent: 0 }); } handleError(error, context, detail = null) { console.error(`Taxonomy ${context} error:`, error, detail); if (this.error?.log) { this.error.log(error, { component: 'TaxonomySelector', action: context, detail: detail }); } if (this.modal?.open) { this.showEmptyState('Error loading. Please try again.'); } } subscribe(callback) { this.subscribers.add(callback); return () => this.subscribers.delete(callback); } notify(event, data = {}) { this.subscribers.forEach(callback => { try { callback(event, data); } catch (error) { console.error('Subscriber error:', error); } }); } destroy() { document.removeEventListener('click', this.handleClick); document.removeEventListener('change', this.handleChange); document.removeEventListener('input', this.handleInput); document.removeEventListener('focus', this.handleFocus); document.removeEventListener('blur', this.handleBlur); this.observer?.disconnect(); this.store.destroy(); this.subscribers.clear(); this.fields.clear(); this.selectedTerms.clear(); this.searchContexts.clear(); } } // Initialize on auth ready document.addEventListener('DOMContentLoaded', () => { window.auth.subscribe((event) => { if (event === 'auth-loaded') { window.jvbSelector = new TaxonomySelector(); } }); });