class TaxonomySelector { constructor() { this.container = document.querySelector('dialog#jvb-selector'); if (!this.container) return; this.a11y = window.jvbA11y; this.error = window.jvbError; this.subscribers = new Set(); this.fields = new Map(); this.selectedTerms = new Map(); // a map of fieldId => Set of selected term Ids this.batchFetch = new Set(); this.activeField = null; this.isInitializing = true; this.lazyInit = false; this.messageText = {} this.init(); } init() { this.initStore(); this.initElements(); this.defineTemplates(); this.initModal(); this.scanExistingFields(); this.initListeners(); if (this.needsCreator() && window.jvbTaxCreator) { this.creator = new window.jvbTaxCreator(this); } this.isInitializing = false this.batchFetchTaxonomies().then(()=> {}); } initStore() { 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'}, {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; this.store.subscribe(this.handleStoreEvent.bind(this)); } defineTemplates() { const T = window.jvbTemplates; const terms = this; T.define('emptyState'); T.define('selectedTerm', { refs: { name: '.item-name', btn: 'button', }, setup({el, refs, manyRefs, data}) { el.dataset.id = data.id; el.dataset.taxonomy = data.taxonomy; if (refs.name) refs.name.textContent = data.path; if (refs.button) refs.button.title = `Remove ${data.name}`; } }); T.define('termListItem', { refs: { checkbox: 'input', label: 'label', name: 'span, .term-name' }, setup({el, refs, manyRefs, data}) { el.dataset.id = data.id; let field = terms.currentField(); let isSelected = terms.selectedTerms.get(terms.activeField).has(data.id); let limitReached = field.limit > 0 && terms.selectedTerms.get(terms.activeField).size >= field.limit; if (refs.checkbox) { refs.checkbox.dataset.id = data.id; refs.checkbox.id = `${field.id}-${data.id}`; refs.checkbox.name = `${field.id}-${field.taxonomy}-select`; refs.checkbox.value = data.id; refs.checkbox.disabled = !isSelected && limitReached; refs.checkbox.checked = isSelected; } if (refs.label) { refs.label.htmlFor = `${field.id}-${data.id}`; refs.label.title = data.path??data.name; refs.label.dataset.path = data.path; } if (refs.name) { refs.name.textContent = data.show ? data.path : data.name; } if (data.hasChildren) { let temp = { plural: field.plural, name: data.name }; const toggle = window.jvbTemplates.create('termChildrenToggle', temp); el.append(toggle); } } }); T.define('termChildrenToggle', { setup({el, refs, manyRefs, data}) { el.ariaLabel = `View ${data.plural} nested under ${data.name}`; } }); T.define('termBreadcrumb', { setup({el, refs, manyRefs, data}) { el.dataset.id = data.id; el.textContent = data.name; el.title = data.name; } }); T.define('autocompleteItem', { setup({el, refs, manyRefs, data}) { el.dataset.id = data.id; el.textContent = data.path||data.name; el.title = `Select ${data.name}`; } }); } /****************************************************************** ELEMENTS ******************************************************************/ initElements() { this.selectors = { search: { input: '[type="search"]', clear: '.clear-search', container: '.search-wrapper', results: '.search-results', }, create: { button: 'button.submit-term', span: '.submit-term span', }, terms: { list: '.items-container', wrap: '.items-wrap', sentinel: '.scroll-sentinel', }, nav: { nav: 'nav.term-navigation', back: '.back-to-parent', child: '.toggle-children', pathLevel: '.path-level', }, message: { message: 'p.message', text: 'p.message span', }, selected: '.selected-items', modal: { title: '#modal-title', content: '.modal-content', count: '.selection-count' }, favourites: '.favourite-terms', field: { toggle: 'button.selector-toggle, [data-filter="taxonomy"]', value: 'input[type="hidden"]', selected: '.selected-items', dropdown: { list: '.search-results', wrapper: '.auto-wrapper', }, create: { button: '.auto-wrapper .submit-term', span: '.auto-wrapper button span', }, search: 'input[data-autocomplete]', message: { message: 'p.message', text: 'p.message span', }, } } this.ui = window.uiFromSelectors(this.selectors, this.container); } initListeners() { this.observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { this.nextPage(); } }); }, { root: this.ui.terms.sentinel, threshold: 0.5 }); this.clickHandler = this.handleClick.bind(this); this.changeHandler = this.handleChange.bind(this); this.inputHandler = this.handleInput.bind(this); this.focusHandler = this.handleFocus.bind(this); this.blurHandler = this.handleBlur.bind(this); document.addEventListener('click', this.clickHandler); document.addEventListener('change', this.changeHandler); document.addEventListener('input', this.inputHandler); document.addEventListener('focus', this.focusHandler, true); document.addEventListener('blur', this.blurHandler, true); } handleClick(e) { if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) { return; } const fieldId = this.getFieldId(e.target) || this.activeField; const field = this.fields.get(fieldId); if (!fieldId || !field) return; if (this.creator) { let button = window.targetCheck(e, this.selectors.create.button); if (button) { this.maybeCreateTerm(e).then(()=>{}); } } const removeButton = window.targetCheck(e, '.remove-term'); if (removeButton) { const termId = removeButton.closest('[data-id]').dataset.id??false; if (fieldId && termId) { this.removeSelected(parseInt(termId), fieldId); } return; } const autocomplete = window.targetCheck(e, '.item.autocomplete'); if (autocomplete) { let termId = parseInt(autocomplete.dataset.id); this.addSelected(termId, fieldId); this.scheduleHideDropdown(fieldId, 6000); if (field.ui.search) { field.ui.search.value = ''; } return; } const toggleButton = window.targetCheck(e, this.selectors.field.toggle); if (toggleButton) { e.preventDefault(); this.openModal(fieldId); return; } if (e.target.matches('.modal-close')) { this.updateFieldValue(fieldId); this.modal?.handleClose(); return; } const backToParent = window.targetCheck(e, this.selectors.nav.back); if (backToParent) { this.navigateToParent(); return; } const toChild = window.targetCheck(e, this.selectors.nav.child); if (toChild) { const termItem = e.target.closest('li'); const termId = parseInt(termItem.dataset.id); if (termId) { this.navigateTo(termId); } return; } const pathLevel = window.targetCheck(e, this.selectors.nav.pathLevel); if (pathLevel) { const termId = parseInt(pathLevel.dataset.id)??0; this.navigateTo(termId); return; } const dropdown = window.targetCheck(e, this.selectors.field.dropdown); if (dropdown) { // reset the timer for hiding the dropdown this.scheduleHideDropdown(fieldId); return; } const clearSearch = window.targetCheck(e, this.selectors.search.clear); if (clearSearch) { const field = this.currentField(); if (field && field.ui.search) { field.ui.search.value = ''; this.store.setFilters({ search: '', page: 1, parent: this.store.filters.parent || 0 }); } if (this.ui.search.input) { this.ui.search.input.value = ''; } } } handleChange(e) { if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) { return; } if (!['checkbox', 'button'].includes(e.target.type)) return; e.preventDefault(); e.stopPropagation(); const termId = parseInt(e.target.dataset.id); let fieldId = this.getFieldId(e.target); if (e.target.checked) { this.addSelected(termId, fieldId); } else { this.removeSelected(termId, fieldId); } } //For search in modal or field autocomplete handleInput(e) { if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) { return; } let fieldId = this.getFieldId(e.target)??this.activeField; if (!fieldId) return; const field = this.fields.get(fieldId); if (!field) return; if (['checkbox', 'button'].includes(e.target.type)) return; e.preventDefault(); e.stopPropagation(); //If it's the autocomplete field, we need to set the active field if (!this.container.open) { this.setField(fieldId); } let query = e.target.value.trim(); this.setMessage(field,true, `Searching for "${query}" in ${field.plural??'items'}`); window.debouncer.schedule( `${fieldId}-search`, async () => { if (this.container.open) { window.removeChildren(this.ui.terms.list); } await this.store.setFilters({ taxonomy: field.taxonomy, search: query, page: 1, parent: query ? 0 : (this.store.filters.parent || 0) }); }, 100 ); } setField(fieldId) { const field = this.fields.get(fieldId); if (!field) { console.error('No field found...'); return; } this.activeField = fieldId; this.setMessage(field,true, `Loading ${field.plural}...`); this.resetFilters({taxonomy: field.taxonomy}); } resetFilters(filters) { if (!Object.hasOwn(filters, 'taxonomy')) { return; } filters = { page: 1, search: '', parent: 0, ... filters }; this.store.setFilters(filters); } handleFocus(e) { if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) { return; } const fieldId = this.getFieldId(e.target); if (!fieldId) return; const field = this.fields.get(fieldId); if (!field) return; if (!field.hasAutocomplete && !field.hasSearch) return; window.debouncer.cancel(`${fieldId}-search-results`); if (!this.container.open){ this.setField(fieldId); } } //Hide autocomplete dropdown on blur handleBlur(e) { if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) { return; } const fieldId = this.getFieldId(e.target); if (!fieldId) return; const field = this.fields.get(fieldId); if (!field) return; if (!field.hasAutocomplete || this.container.open) return; if (e.target.closest('.remove-item')) return; if (e.relatedTarget && field.ui.dropdown.wrapper?.contains(e.relatedTarget)) return; this.scheduleHideDropdown(fieldId); } scheduleHideDropdown(fieldId, delay = 1500){ const field = this.fields.get(fieldId); if (!field) return; window.debouncer.schedule( `${fieldId}-search-results`, () => { if (!this.container.open) { this.activeField = null; } if (field.ui.dropdown.wrapper) { field.ui.dropdown.wrapper.hidden = true; } }, delay ); } /****************************************************************** MODAL ******************************************************************/ initModal() { this.modalID = 'dialog#jvb-selector'; this.container = document.querySelector(this.modalID); this.modal = new window.jvbModal( this.container, { handleForm: false, open: null } ); this.modal.subscribe((event, data) => { switch (event) { case 'modal-close': this.closeModal() break; } }); } toggleModal(fieldId, open = true) { const field = this.fields.get(fieldId); if (!field) return; if (open) { this.openModal(fieldId); } else { this.closeModal(); } } openModal(fieldId) { const field = this.fields.get(fieldId); if (!field) return; this.setField(fieldId); this.ui.modal.title.textContent = (field.isFilter) ?`Filter by ${field.singular}` : `Select ${field.plural}`; if (this.ui.search.container) { this.ui.search.container.hidden = !field.canSearch; } if (this.creator) { this.creator.handleOpen(field); } let message = `Opened ${field.singular} selection. Choose from checkboxes, or search to filter results.`; window.removeChildren(this.ui.selected); window.removeChildren(this.ui.terms.list); this.modal.handleOpen(); this.a11y.announce(message); } openEmpty(taxonomy, singular, plural, onComplete) { // Store the callback for when modal closes this.emptyCallback = onComplete; // Create a temporary "field" for bulk operations const bulkFieldId = `empty-${taxonomy}-${Date.now()}`; if (!this.fields.has(bulkFieldId)) { this.fields.set(bulkFieldId, { id: bulkFieldId, taxonomy: taxonomy, singular: singular, plural: plural, canSearch: true, canCreate: false, hasAutocomplete: false, isFilter: false, isEmpty: true, limit: 0, ui: {}, element: null, value: null, toggle: null, checked: true }); this.selectedTerms.set(bulkFieldId, new Set()); } this.setField(bulkFieldId); this.ui.modal.title.textContent = `Add to ${plural}`; if (this.ui.search?.container) { this.ui.search.container.hidden = false; } window.removeChildren(this.ui.selected); window.removeChildren(this.ui.terms.list); this.modal.handleOpen(); } closeModal() { const field = this.fields.get(this.activeField); if (!field) return; this.updateFieldValue(this.activeField); this.observer.unobserve(this.ui.terms.sentinel); window.removeChildren(this.ui.terms.list); if (field.isEmpty && this.emptyCallback) { const selectedTermIds = Array.from(this.selectedTerms.get(this.activeField) || []); const selectedTerms = selectedTermIds.map(id => this.store.get(id)).filter(Boolean); this.emptyCallback({ taxonomy: field.taxonomy, termIds: selectedTermIds, terms: selectedTerms }); // Cleanup temporary bulk field this.fields.delete(this.activeField); this.selectedTerms.delete(this.activeField); this.emptyCallback = null; this.bulkAssignmentTaxonomy = null; } else { this.notify('selected-terms', { terms: this.selectedTerms.get(this.activeField), taxonomy: field.taxonomy }); } this.activeField = null; let message = `Closed ${field.singular} selector.`; this.a11y.announce(message); } navigateToParent() { const current = this.store.filters.parent; if (current === 0) return; let term = this.store.get(parseInt(current)); if (!term) { this.navigateTo(0); return; } let parent = term.parent; this.navigateTo(parseInt(parent)); } navigateTo(termId = 0) { termId = parseInt(termId)??0; this.store.setFilters({parent: termId, page: 1}); window.removeChildren(this.ui.terms.list); this.updateBreadcrumbs(termId); } nextPage() { let current = this.store.filters.page; let page = Math.min(current++, this.store.lastResponse.total); this.store.setFilters({page:page}); } prevPage() { let current = this.store.filters.page; let page = Math.max(current - 1, 1); this.store.setFilters({page:page}); } addTermToModal(termId) { const term = this.store.get(termId); if (!term) return; const field = this.currentField(); if (!field) return; if (this.ui.selected.querySelector(`[data-id="${termId}"]`)) return; this.ui.selected.append(this.getSelectedTermUI(term)); } getSelectedTermUI(term, showPath = true) { return window.jvbTemplates.create('selectedTerm', term); } /****************************************************************** FIELDS ******************************************************************/ scanExistingFields(container = document.body) { container.querySelectorAll('[data-type="selector"], [data-field-type="selector"]').forEach( selector => { try { if (selector.dataset.lazy) { this.lazyInit = true; } else { // Register field if not already registered // registerField will check if already registered and return early if so this.registerField(selector); } } catch (error) { this.error.log(error, { component: 'TaxonomySelector', action: 'scanExistingFields', container: selector.dataset.name }); } } ); if (this.lazyInit) { this.initObserver(container); } } unregisterFields(container) { container.querySelectorAll('[data-type="selector"],[data-field-type="selector"]').forEach( selector=> { this.fields.delete(selector.dataset.fieldId); } ); } initObserver(container){ this.lazyObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting && entry.target.dataset.lazy) { delete entry.target.dataset.lazy; this.registerField(entry.target); this.lazyObserver.unobserve(entry.target); } }); }, {rootMargin: '50px'}); container.querySelectorAll('[data-type="selector"][data-lazy], [data-field-type="selector"][data-lazy]').forEach(field => { this.lazyObserver.observe(field); }); } registerField(element, options = {}) { if (element.dataset.fieldId && this.fields.has(element.dataset.fieldId)) { return element.dataset.fieldId; // Already registered } let input = element.querySelector('input[type="hidden"]'); if (!input && !Object.hasOwn(element.dataset, 'filter')) { return; } if (!('fieldId' in element.dataset)) { element.dataset.fieldId = window.generateID('selector'); } const fieldId = element.dataset.fieldId; let selectors = this.selectors.field; const isFilter = Object.hasOwn(element.dataset,'filter') && element.dataset.filter === 'taxonomy'; let button = (isFilter) ? element : element.querySelector('button.selector-toggle'); if (Object.keys(options).length === 0){ if (!button) return; options = { taxonomy: button.dataset.taxonomy, single: button.dataset.single, plural: button.dataset.plural, search: Object.hasOwn(button.dataset, 'search'), autocomplete: Object.hasOwn(button.dataset, 'autocomplete'), creatable: Object.hasOwn(button.dataset, 'creatable') }; } else if (Object.hasOwn(options, 'toggle')) { button = document.querySelector(options.toggle); selectors.toggle = options.toggle; } const config = { id: fieldId, value: input, element: element, taxonomy: options.taxonomy??false, singular: options.single??'', plural: options.plural??'', name: element.dataset.field, canSearch: options.search??false, limit: options.limit??0, hasAutocomplete: options.autocomplete??false, canCreate: options.creatable??false, isRequired: options.required??false, isFilter: isFilter, toggle: button, create: { button: null, span: null }, selectors: selectors, ui: window.uiFromSelectors(selectors, element), checked: false, }; if (isFilter && !config.ui.toggle) { config.ui.toggle = element; } if (!config.taxonomy) { console.error('TaxonomySelector: Field missing taxonomy', element); return; } if (!config.singular || !config.plural) { console.warn('TaxonomySelector: Field missing singular/plural labels', element); config.singular = config.taxonomy.replace('jvb_', ''); config.plural = config.singular + 's'; } this.fields.set(fieldId, config); //Check for stored selected terms in hidden input this.setSelectedFromValue(fieldId, input); if (this.isInitializing) { this.batchFetch.add(config.taxonomy); } if (element.offsetParent !== null) { this.updateFieldUI(fieldId); } else { // Defer until visible requestIdleCallback(() => { if (element.offsetParent !== null) { this.updateFieldUI(fieldId); } }, {timeout: 2000}); } return fieldId; } setSelectedFromValue(fieldId, input) { if (!fieldId) return; let field = this.fields.get(fieldId); if (!field) return; if (!input && !field.isFilter) return; let selected = new Set(); if (input) { input.value.trim() .split(',') .map(id => parseInt(id.trim())) .filter(id => !isNaN(id)) .forEach(id => selected.add(id)); } this.selectedTerms.set(fieldId, selected); } addSelected(termId, fieldId = null) { if (!fieldId) fieldId = this.activeField; const field = this.fields.get(fieldId); const term = this.store.get(termId); if (!field || !term) return; const selected = this.selectedTerms.get(fieldId); if (field.limit !== 0 && selected.size >= field.limit) return; selected.add(parseInt(termId)); if (!this.container.open && !field.isFilter) { this.updateFieldValue(fieldId); } this.addTermToDisplay(termId, fieldId); this.checkLimits(fieldId); } removeSelected(termId, fieldId = null) { if (!fieldId) fieldId = this.activeField; const field = this.fields.get(fieldId); const term = this.store.get(termId); if (!field || !term) return; this.selectedTerms.get(fieldId).delete(parseInt(termId)); const selectedItem = (field.ui.selected) ? field.ui.selected.querySelector(`[data-id="${termId}"]`) : false; if (selectedItem) selectedItem.remove(); if (this.container.open) { let item = (this.ui.selected) ? this.ui.selected.querySelector(`[data-id="${termId}"]`) : false; if (item) item.remove(); let checkbox = this.ui.terms.list.querySelector(`[type=checkbox][data-id="${termId}"]`); if (checkbox) { checkbox.checked = false; } } if (!this.container.open && !field.isFilter) { this.updateFieldValue(fieldId); } this.checkLimits(fieldId); } updateFieldValue(fieldId) { const field = this.fields.get(fieldId); if (!field) return; let selected = Array.from(this.selectedTerms.get(fieldId)); if (field.ui.value) { field.ui.value.value = selected.join(',')??''; field.ui.value.dispatchEvent(new Event('change', { bubbles: true })); } } checkLimits(fieldId) { if (!this.container.open) return; const field = this.fields.get(fieldId); if (!field || !field.isFilter || field.limit === 0) return; const disabled = this.selectedTerms.get(fieldId).size >= field.limit; this.setCheckboxes(disabled); } updateFieldFromInput(input) { const fieldId = this.getFieldId(input); if (!fieldId) return; const field = this.fields.get(fieldId); if(!field) return; this.setSelectedFromValue(fieldId, input); this.updateFieldUI(fieldId); } updateFieldUI(fieldId) { const field = this.fields.get(fieldId); let selected = this.selectedTerms.get(fieldId)??new Set(); if (!field || field.isFilter || selected.size === 0) return; Array.from(selected).forEach(termId => { this.addTermToDisplay(termId, fieldId); }); } updateFieldsForTaxonomy(taxonomy) { let fields = Array.from(this.fields.values()) .filter(field => field.taxonomy === taxonomy); const hasItems = Array.from(this.store.data.values()) .some(term => term && term.taxonomy === taxonomy); fields.forEach(field => { if (!field.toggle) return; field.toggle.disabled = !hasItems && !field.canCreate; field.toggle.title = !hasItems ? `No ${field.singular} available` : `Select ${field.plural}`; field.checked = true; }); } showModalTerms(showPath = false) { const field = this.currentField(); const terms = this.store.getFiltered(); if (terms.length === 0) { if (this.store.filters.page??1 === 1) { window.removeChildren(this.ui.terms.list); } this.setMessage(field,true, this.store.filters.search === '' ? `No matching ${field.plural}.` : `No ${field.plural} found.`, false); if (this.ui.terms.sentinel) { this.observer.unobserve(this.ui.terms.sentinel); } return; } this.setCreateButton(field,true); if (this.ui.terms.sentinel) { if (this.store.lastResponse?.has_more) { this.observer.observe(this.ui.terms.sentinel); } else { this.observer.unobserve(this.ui.terms.sentinel); } } const currentParent = this.store.filters.parent??0; this.ui.nav.back.hidden = currentParent === 0; window.chunkIt( terms, (term) => this.createTermElement({show:showPath, ... term}), (fragment) => this.ui.terms.list.append(fragment), 10 ).then(()=>{}); if (terms.length > 0) { this.setMessage(field,false); } } createTermElement(term) { if (!term || !term.name) return null; return window.jvbTemplates.create('termListItem', term); } showAutocompleteTerms() { const field = this.currentField(); if (!field || !field.hasAutocomplete || !field.ui.dropdown?.list) return; const dropdown = field.ui.dropdown.list; const terms = this.currentTerms(); window.removeChildren(dropdown); if (terms.length === 0) { this.setMessage(field,true, `No ${field.plural} found.`, false); } else { window.chunkIt( terms, (term) => this.createAutocompleteTerm(term), (fragment) => dropdown.append(fragment) ).then(()=>{}); this.setMessage(field,false); } this.setCreateButton(field,true); if (field.ui.dropdown.wrapper) { field.ui.dropdown.wrapper.hidden = false; } } createAutocompleteTerm(term) { return window.jvbTemplates.create('autocompleteItem', term); } /****************************************************************** UI ******************************************************************/ addTermToDisplay(termId, fieldId) { const term = this.store.get(termId); const field = this.fields.get(fieldId); if (!term || !field) return; //if the term already exists in the selected items, bail early if (field.ui.selected && field.ui.selected.querySelector(`[data-id="${termId}"]`)) return; let item = this.getSelectedTermUI(term); if (field.ui.selected) { field.ui.selected.append(item); } if (this.container.open) { this.addTermToModal(termId); const checkbox = this.ui.terms.list.querySelector(`input[value="${termId}"]`); if (checkbox) checkbox.checked = true; } } updateBreadcrumbs(termId) { const nav = this.ui.nav.nav; if (!nav) return; const existingCrumb = Array.from(nav.children) .find(crumb => parseInt(crumb.dataset.id) === termId); if (existingCrumb) { // Remove all siblings after this crumb let nextSibling = existingCrumb.nextElementSibling; while (nextSibling) { const toRemove = nextSibling; nextSibling = nextSibling.nextElementSibling; toRemove.remove(); } } else { // Add new breadcrumb const term = this.store.get(termId); if (!term) return; const crumb = window.jvbTemplates.create('termBreadcrumb', term); nav.append(crumb); } } updateSelectionCount() { if (!this.container.open) return; const field = this.fields.get(this.activeField); if (!field) return; if (this.ui.modal.count) { const total = this.selectedTerms.get(this.activeField).size; this.ui.modal.count.textContent = field.limit > 0 ? `${total} of ${field.limit} ${field.plural} selected` : `${total} ${field.plural} selected`; } } /****************************************************************** UTILITY ******************************************************************/ checkRendered(collection, term) { if (!collection) return; if (!Object.hasOwn(collection, term.taxonomy)) { collection[term.taxonomy] = new Map(); } return collection[term.taxonomy].has(term.id); } currentField() { return this.fields.get(this.activeField)??false; } currentTerms() { return this.store.getFiltered(); } needsCreator() { return Array.from(this.fields.values()).some(field => field.canCreate || field.hasAutocomplete ); } getFieldId(element) { if (element.dataset.fieldId) return element.dataset.fieldId; const fieldContainer = element.closest('[data-field-id]'); return fieldContainer?.dataset.fieldId || null; } /** * Sets all checkbox disabled (or not) * @param {Boolean} disabled */ setCheckboxes(disabled) { this.ui.terms.list.querySelectorAll('input[type=checkbox]').forEach(checkbox => { if (!checkbox.checked) { checkbox.disabled = disabled; } }); } /****************************************************************** DATASTORE HELPERS ******************************************************************/ handleStoreEvent(event, data) { const handlers = { 'data-loaded': () => this.handleDataLoaded(), 'filters-changed': () => this.handleFiltersChanged(data), 'fetch-error': () => this.handleFetchError() }; try { handlers[event]?.(data); } catch (error) { console.error(`Error handling store event "${event}":`, error); } } handleDataLoaded() { const taxonomy = this.store.filters.taxonomy; if (taxonomy) { const taxonomies = taxonomy.split(',').map(t => t.trim()); taxonomies.forEach(tax => this.updateFieldsForTaxonomy(tax)); } if (this.container.open) { this.showResults(); return; } if (this.activeField) { this.showResults(true); return; } } showResults(isAutoComplete = false) { const terms = this.store.getFiltered(); const filters = this.store.filters; const isSearch = filters.search && filters.search.length > 0; this.notify('terms-loaded', { terms, filters }); if (!this.activeField && isAutoComplete) { return; } this.setMessage(this.currentField(), false); if (isAutoComplete) { this.showAutocompleteTerms(); } else { this.showModalTerms(isSearch); } this.a11y.announce(terms.length); } handleFiltersChanged(data) { //maybe do something? } handleFetchError(error) { const field = this.currentField(); this.setMessage(field, true, 'Something went wrong.', false); const conf = this.container.open || field?.isFilter ? this.ui : field?.ui; const p = conf?.message?.message; if (p && !p.querySelector('.clear-cache-btn')) { const btn = document.createElement('button'); btn.className = 'clear-cache-btn'; btn.type = 'button'; btn.textContent = 'Clear cache and try again'; btn.addEventListener('click', async () => { btn.remove(); this.store.clearCache(); if (this.activeField && field) { await this.store.setFilters({ taxonomy: field.taxonomy, page: 1, search: '', parent: 0 }); } }); p.appendChild(btn); } console.error('Store fetch error:', error); } async batchFetchTaxonomies() { if (this.batchFetch.size === 0) return; const taxonomies = Array.from(this.batchFetch); this.batchFetch.clear(); try { await this.store.setFilters({ taxonomy: taxonomies.join(','), page: 1, search: '', parent: 0 }); } catch (error) { console.error('Failed to batch fetch taxonomies:', error); } } preloadTaxonomy(taxonomy) { this.store.setFilters( { taxonomy: taxonomy, page: 1, search: '', parent: 0 }); } /************************************************** LOADING **************************************************/ setCreateButton(field, show = true) { if (!field.canCreate || !this.creator) return; const conf = (this.container.open) ? this.ui : field.ui; if (!conf.create?.button || !conf.create?.span) return; const createButton = conf.create.button; createButton.hidden = !show; const buttonSpan = conf.create.span; const input = (this.container.open) ? conf.search.input : conf.search; if (!input) return; let results = this.currentTerms()??[]; let matches = results.map(t => t.name); let query = input.value; const willShow = show && query.length >= 2 && !matches.includes(query); createButton.hidden = !willShow; if (willShow) { buttonSpan.textContent = input.value??''; } } async maybeCreateTerm(e) { const field = this.currentField(); if (!field) return; window.debouncer.cancel(`${field.id}-search-results`); let data = { taxonomy: field.taxonomy, parent: this.store.filters.parent??0 } if (!this.container.open || this.ui.search.input.value !== '') { data.name = (this.container.open) ? this.ui.search.input.value : field.ui.search.value; } else { data.parent = this.creator.ui.parent.value??data.parent; data.name = this.creator.ui.name.value??false; } if (data.parent !== undefined && data.name) { this.setMessage(field,true, `Creating "${data.name}"...`); this.setCreateButton(field,false); if (this.container.open) { window.removeChildren(this.ui.terms.list); } else { field.ui.search.disabled = true; if (field.ui.dropdown.wrapper) { field.ui.dropdown.wrapper.hidden = false; } } let term = await this.creator.handleTermCreation(data); if (term) { // Stop any typeLoop animation and show success message WITHOUT typeLoop this.setMessage(field,true, `"${term.name}" created!`, false); this.addSelected(term.id, field.id); this.updateFieldValue(field.id); // For autocomplete, show the newly created term in dropdown if (!this.container.open && field.ui.dropdown.list) { window.removeChildren(field.ui.dropdown.list); const termElement = this.createAutocompleteTerm(term); if (termElement) { termElement.classList.add('newly-created'); field.ui.dropdown.list.append(termElement); } } this.scheduleHideDropdown(field.id, 300); this.setMessage(field,false); } else { // Creation failed - hide immediately this.setMessage(field,false); if (!this.container.open && field.ui.dropdown.wrapper) { field.ui.dropdown.wrapper.hidden = true; } } if (!this.container.open) { field.ui.search.disabled = false; field.ui.search.value = ''; } } } setMessage(field, show = true, message = '', type = true) { const conf = this.container.open||field.isFilter ? this.ui : (field.isFilter ? null : field.ui); if (!conf?.message?.message) return; message = (message === '') ? `No ${field.plural??'items'} found.` : message; const p = conf.message.message; const pText = conf.message.text; p.hidden = !show; if (show) { if (message && pText) { if (type && window.typeLoop && pText) { if (this.messageText[field.id]) { this.messageText[field.id](); delete this.messageText[field.id]; } this.messageText[field.id] = window.typeLoop(pText, message); } else { pText.textContent = message; } } } else { if (this.messageText[field.id]) { this.messageText[field.id](); delete this.messageText[field.id]; } } } /************************************************** SUBSCRIBERS **************************************************/ 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); } }); } /****************************************************** CLEANUP ******************************************************/ destroy() { // Cancel all debounced operations for this instance this.fields.forEach((field, fieldId) => { window.debouncer.cancel(`${fieldId}-search`); window.debouncer.cancel(`${fieldId}-search-results`); }); // Stop any typeLoop animations Object.keys(this.messageText).forEach(key => { if (this.messageText[key]) { this.messageText[key](); } }); this.messageText = {}; // Disconnect observer if (this.ui.terms?.sentinel) { this.observer?.unobserve(this.ui.terms.sentinel); } this.observer?.disconnect(); this.lazyObserver?.disconnect(); // Remove event listeners document.removeEventListener('click', this.clickHandler); document.removeEventListener('change', this.changeHandler); document.removeEventListener('input', this.inputHandler); document.removeEventListener('focus', this.focusHandler, true); document.removeEventListener('blur', this.blurHandler, true); // Clear data structures this.subscribers.clear(); this.fields.clear(); this.selectedTerms.clear(); this.batchFetch.clear(); // Cleanup creator if exists if (this.creator) { this.creator.destroy(); this.creator = null; } // Unsubscribe from store if (this.store) { this.store = null; } } } document.addEventListener('DOMContentLoaded', function() { window.auth.subscribe((event) => { if (event === 'auth-loaded') { window.jvbSelector = new TaxonomySelector(); } }); });