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.loadedTaxonomies = new Set(); // a set of taxonomies, to know whether we should preload a newly registered field this.batchFetch = new Set(); this.activeField = null; this.isInitializing = true; this.init(); } init() { this.initStore(); this.initElements(); 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', 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; this.store.subscribe(this.handleStoreEvent.bind(this)); } /****************************************************************** ELEMENTS ******************************************************************/ initElements() { this.selectors = { search: { input: '[type=search]', clear: '.clear-search', container: '.search-wrapper', results: '.search-results' }, 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', }, loading: { loading: '.loading', text: '.loading span', }, selected: '.selected-items', modal: { title: '#modal-title', content: '.modal-content', count: '.selection-count' }, favourites: '.favourite-terms', field: { toggle: 'button.taxonomy-toggle', value: 'input[type="hidden"]', selected: '.selected-items', dropdown: '.search-results', search: '[data-autocomplete]', } } this.ui = window.uiFromSelectors(this.selectors); } 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) { const fieldId = this.getFieldId(e.target); const field = this.fields.get(fieldId); if (!fieldId || !field) return; const autoComplete = window.targetCheck(e, '[data-autocomplete-select]'); if (autoComplete) { let termId = parseInt(autoComplete.dataset.id); this.addSelected(termId, fieldId); if (field.ui.dropdown) { field.ui.dropdown.hidden = true; } if (field.ui.search) { field.ui.search.value = ''; } } const toggleButton = window.targetCheck(e, field.ui.toggle); if (toggleButton) { e.preventDefault(); this.openModal(fieldId); return; } const removeButton = window.targetCheck(e, 'button.remove-item'); if (removeButton) { const fieldId = this.getFieldId(removeButton); const termId = removeButton.closest('.selected-item').dataset.id??false; if (fieldId && termId) { this.removeSelected(termId, fieldId); } return; } if (e.target.matches('.modal-close')) { 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); } const dropdown = window.targetCheck(e, field.selectors.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)) { return; } if (e.target.type !== 'checkbox') 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) { let fieldId = this.getFieldId(e.target)??this.activeField; if (!fieldId) return; const field = this.fields.get(fieldId); if (!field) return; if (!this.container.open) { this.activeField = fieldId; } const query = e.target.value.trim(); window.debouncer.schedule( `${fieldId}-search`, async () => { await this.store.setFilters({ taxonomy: field.taxonomy, search: query, page: 1, parent: query ? 0 : (this.store.filters.parent || 0) }); if (this.container.open) { window.removeChildren(this.ui.terms.list); } }, 100 ); } handleFocus(e) { const fieldId = this.getFieldId(e.target); const field = this.fields.get(fieldId); if (!fieldId || !field) return; if (!field.hasAutocomplete && !field.hasSearch) return; window.debouncer.cancel(`${fieldId}-search-results`); if (!this.container.open){ this.activeField = fieldId; this.preloadTaxonomy(field.taxonomy); } } //Hide autocomplete dropdown on blur handleBlur(e) { const fieldId = this.getFieldId(e.target); const field = this.fields.get(fieldId); if (!fieldId || ! field) return; if (!field.hasAutocomplete) return; this.scheduleHideDropdown(fieldId); } scheduleHideDropdown(fieldId){ const field = this.fields.get(fieldId); if (!field) return; window.debouncer.schedule( `${fieldId}-search-results`, () => { this.activeField = null; field.ui.dropdown.hidden = true; }, 1500 ); } /****************************************************************** MODAL ******************************************************************/ initModal() { this.modalID = 'dialog#jvb-selector'; this.container = document.querySelector(this.modalID); this.modal = new window.jvbModal( this.container, { handleForm: false, save: null, open: null } ); this.modal.subscribe((event, data) => { switch (event) { } }); } 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.activeField = fieldId; this.ui.modal.title.textContent = `Select ${field.plural}`; if (this.ui.search.container) { this.ui.search.container.hidden = !field.canSearch; } if (this.ui.create.details) { this.ui.create.details.hidden = !field.canCreate; if (this.ui.create.summary) { this.ui.create.summary.textContent = `Add new ${field.singular}`; } if (this.ui.create.label.name) { this.ui.create.label.name.textContent = `Name this ${field.singular}`; } if (this.ui.create.label.parent) { this.ui.create.label.parent.textContent = `Nest it under`; } } let message = `Opened ${field.singular} selection. Choose from checkboxes, or search to filter results.`; window.removeChildren(this.ui.terms.list); this.modal.handleOpen(); this.setLoading(); this.store.setFilters({ taxonomy: field.taxonomy, page: 1, search: '', parent: 0, }); this.a11y.announce(message); } closeModal() { this.modal.handleClose(); const field = this.fields.get(this.activeField); if (!field) return; this.observer.unobserve(this.ui.terms.sentinel); window.removeChildren(this.ui.terms.list); 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) 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 item = window.getTemplate('selectedTerm'); item.dataset.id = termId; item.querySelector('span').textContent = term.path; item.querySelector('button').title = `Remove ${name}`; this.ui.selected.append(item); } /****************************************************************** FIELDS ******************************************************************/ scanExistingFields(container = document.body) { container.querySelectorAll('[data-type="selector"]').forEach( selector => { try { this.registerField(selector); } catch (error) { this.error.log(error, { component: 'TaxonomySelector', action: 'scanExistingFields', container: selector.dataset.name }); } } ); } registerField(element, options = {}) { let input = element.querySelector('input[type="hidden"]'); if (!input) { console.warn('TaxonomySelector: No hidden input found for field', element); return; } if (!('fieldId' in element.dataset)) { element.dataset.fieldId = window.generateID('selector'); } const fieldId = element.dataset.fieldId; let selectors = this.selectors.field; let button = element.querySelector('button.taxonomy-toggle'); if (options.size === 0){ if (!button) return; options = button.dataset; if (options.size === 0) return; } 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: Object.hasOwn(options, 'search'), limit: options.limit??0, hasAutocomplete: Object.hasOwn(options, 'autocomplete'), canCreate: Object.hasOwn(options, 'creatable'), isRequired: Object.hasOwn(options, 'required'), toggle: button, selectors: selectors, ui: window.uiFromSelectors(selectors, element), checked: false, }; if (!config.taxonomy) return; this.fields.set(fieldId, config); //Check for stored selected terms in hidden input this.setSelectedFromValue(input); if (this.isInitializing) { this.batchFetch.add(config.taxonomy); } this.updateFieldUI(fieldId); return fieldId; } setSelectedFromValue(fieldId, input) { let selected = new Set(); input.value.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)); this.addTermToDisplay(termId, fieldId); this.updateFieldValue(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.querySelector(`[data-i"${termId}"]`); if (selectedItem) selectedItem.remove(); if (this.container.open) { let item = this.ui.selected.querySelector(`[data-id="${termId}"]`); if (item) item.remove(); } 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)); field.ui.value = selected.join(','); } checkLimits(fieldId) { if (!this.container.open) return; const field = this.fields.get(fieldId); if (!field || field.limit === 0) return; const disabled = this.selectedTerms.get(fieldId).size >= field.limit; this.setCheckboxes(disabled); } updateFieldFromInput(input) { const fieldId = this.getFieldId(input); const field = this.fields.get(fieldId); if (!fieldId || !field) return; this.setSelectedFromValue(fieldId, input); this.updateFieldUI(fieldId); } updateFieldUI(fieldId) { const field = this.fields.get(fieldId); let selected = this.selectedTerms.get(fieldId); if (!field || 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.checked && field.taxonomy === taxonomy); const hasItems = Array.from(this.store.data.values()) .some(term=>term.taxonomy === taxonomy); fields.forEach(field => { field.ui.toggle.disabled = !hasItems && !field.canCreate; field.ui.toggle.title = !hasItems ? `No ${field.singular} available` : `Select ${field.plural}`; field.checked = true; }); } showModalTerms(append = true, showPath = false) { const terms = this.store.getFiltered(); if (terms.size === 0) return; if (!append) { window.removeChildren(this.ui.terms.list); } const currentParent = this.store.filters.parent??0; this.ui.nav.back.hidden = currentParent === 0; const fragment = document.createDocumentFragment(); terms.forEach(term => { const element = this.createTermElement({ show: showPath, ... term }); if (element) { fragment.appendChild(element); } }); this.ui.terms.list.append(fragment); } createTermElement(term) { if (!term || !term.name) return null; const item = window.getTemplate('termListItem'); item.dataset.id = term.id; const isSelected = this.selectedTerms.get(this.activeField).has(term.id); let [ checkbox, label, nameSpan ] = [ item.querySelector('input'), item.querySelector('label'), item.querySelector('span, .term-name') ]; let field = this.currentField(); let limitReached = field.limit > 0 && this.selectedTerms.get(this.activeField).size >= field.limit; if (checkbox && label && nameSpan) { [ checkbox.id, checkbox.name, checkbox.value, checkbox.disabled, checkbox.checked, label.htmlFor, label.title, label.dataset.path, nameSpan.textContent ] = [ `${field.element.id}-${term.id}`, `${field.container.id}-${field.taxonomy}-select`, term.id, !isSelected && limitReached, isSelected, `${field.element.id}-${term.id}`, term.path??term.name, term.path, term.show ? term.path : term.name ]; if (term.hasChildren) { const toggle = window.getTemplate('termChildrenToggle'); if (toggle) { toggle.ariaLabel = `View ${field.plural} nested under ${term.name}`; item.append(toggle); } } } return item; } showAutocompleteTerms() { const field = this.currentField(); const terms = this.currentTerms(); if (!field || terms.size ===0) return; const dropdown = field.ui.dropdown; window.removeChildren(dropdown); if (terms.length === 0) { this.showEmptyState(`No ${field.plural} found.`, dropdown); } else { terms.forEach(term => { const item = this.createAutocompleteTerm(term); if (item) { dropdown.append(item); } }) } const query = field.ui.search?.value; if (field.canCreate && query.length >= 2 && this.creator) { const createButton = this.createTermButton(query); if (createButton) { dropdown.append(createButton); } } dropdown.hidden = false; } createAutocompleteTerm(term) { const item = window.getTemplate('autocompleteItem'); if (!item) return; item.dataset.id = term.id; item.textContent = term.path || term.name; return item; } /****************************************************************** 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.querySelector(`[data-id="${termId}"]`)) return; const item = window.getTemplate('selectedTerm'); if (!item) return; item.dataset.id = termId; item.dataset.taxonomy = field.taxonomy; item.querySelector('.item-name').textContent = term.path; item.querySelector('button').title = `Remove ${term.name}`; 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; } } createTermButton(query) { const button = window.getTemplate('autocompleteButton'); if(!button) return; let queryEl = button.querySelector('span'); queryEl.textContent = `"${query}"`; return button; } 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.getTemplate('termBreadcrumb'); if (!crumb) return; crumb.dataset.id = termId; crumb.textContent = term.name; crumb.title = term.name; 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 ******************************************************************/ 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(), 'fetch-error': () => this.handleFetchError() }; handlers[event]?.(); } handleDataLoaded() { const taxonomy = this.store.filters.taxonomy; if (taxonomy?.includes(',')) { 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); } } showResults(isAutoComplete = false) { this.setLoading(false); const terms = this.store.getFiltered(); const filters = this.store.filters; const response = this.store.lastResponse?.page || {}; const isSearch = filters.search && filters.search.length > 0; const append = filters.page > 1; const field = this.currentField(); this.notify('terms-loaded', { terms, filters }); if (terms.length === 0) { if (!append) { this.showEmptyState(isSearch ? `No matching ${field.plural}.` : `No ${field.plural} available.`); } this.observer.unobserve(this.ui.terms.sentinel); } else { if (!isAutoComplete) { this.showModalTerms(append, isSearch); if (response.has_more) { this.observer.observe(this.ui.terms.sentinel); } else { this.observer.unobserve(this.ui.terms.sentinel); } } else { this.showAutocompleteTerms() } } this.a11y.announce(terms.length, append); } handleFiltersChanged() { // if (this.modal?.open) { // this.setLoading(); // } } handleFetchError(error) { this.setLoading(false); } async batchFetchTaxonomies() { if (this.batchFetch.size === 0) return; const taxonomies = Array.from(this.batchFetch); taxonomies.forEach(tax => this.loadedTaxonomies.add(tax)); this.batchFetch.clear(); try { taxonomies.forEach(tax => this.loadedTaxonomies.add(tax)); await this.store.setFilters({ taxonomy: taxonomies.join(','), page: 1, search: '', parent: 0 }); } catch (error) { console.error('Failed to batch fetch taxonomies:', error); } } preloadTaxonomy(taxonomy) { if (this.loadedTaxonomies.has(taxonomy)) return; this.store.setFilters( { taxonomy: taxonomy, page: 1, search: '', parent: 0 }); this.loadedTaxonomies.add(taxonomy); } /************************************************** LOADING **************************************************/ setLoading(on = true) { this.ui.loading.loading.hidden = on; this.modal.classList.toggle('loading', on); if (on) { let searchQuery = this.store.filters.search || ''; searchQuery = searchQuery === '' ? false : searchQuery; 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.ui.loading.text) { this.stopTyping = window.typeLoop(this.ui.loading.text, message); } else { this.ui.loading.text.textContenet = message; } } else { if (this.stopTyping) { this.stopTyping(); this.stopTyping = null; } } } showEmptyState(message = 'No items found.', container = null) { if (!container) container = this.ui.terms.list; const emptyElement = window.getTemplate('noTermResults'); const span = emptyElement.querySelector('span'); if (message && span) { span.textContent = message; } container.append(emptyElement); } /************************************************** 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() { document.removeEventListener('click', this.clickHandler); document.removeEventListener('change', this.changeHandler); document.removeEventListener('input', this.inputHandler); document.removeEventListener('focus', this.focusHandler); document.removeEventListener('blur', this.blurHandler); this.observer?.disconnect(); this.subscribers.clear(); this.fields.clear(); this.selectedTerms.clear(); } } document.addEventListener('DOMContentLoaded', function() { window.auth.subscribe((event) => { if (event === 'auth-loaded') { window.jvbSelector = new TaxonomySelector(); } }); });