| | |
| | | /** |
| | | * Centralized Taxonomy Selector with DataStore Integration |
| | | * Handles all taxonomy selection fields using DataStore for state management |
| | | */ |
| | | |
| | | class TaxonomySelector { |
| | | constructor() { |
| | | this.container = document.querySelector('dialog#jvb-selector'); |
| | | if (!this.container) return; |
| | | |
| | | this.a11y = window.jvbA11y; |
| | | this.error = window.jvbError; |
| | | this.index = -1; |
| | | |
| | | this.hasAutocomplete = false; |
| | | this.isInitializing = true; |
| | | this.taxonomiesToFetch = new Set(); |
| | | |
| | | this.triggers = new Set(['.taxonomy-toggle']); |
| | | |
| | | 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.store = window.jvbStore.register( |
| | | 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`, |
| | | storeName: 'terms', |
| | | keyPath: 'id', |
| | | showLoading: false, |
| | | indexes: [ |
| | | {name: 'taxonomy', keyPath: 'taxonomy'}, |
| | | {name: 'parent', keyPath: 'parent'}, |
| | | {name: 'slug', keyPath: 'slug', unique: true}, |
| | | {name: 'slug', keyPath: 'slug'}, |
| | | {name: 'count', keyPath: 'count'}, |
| | | ], |
| | | endpoint: 'terms', |
| | | TTL: 2 * 60 * 1000, //2 hours |
| | | TTL: 2 * 60 * 1000, |
| | | filters: { |
| | | taxonomy: '', |
| | | page: 1, |
| | |
| | | }, |
| | | required: 'taxonomy', |
| | | delayFetch: true, |
| | | }); |
| | | } |
| | | ); |
| | | this.store = store.terms; |
| | | |
| | | // 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; |
| | | |
| | | // Modal state |
| | | this.disabled = false; |
| | | |
| | | // Search debouncing |
| | | this.searchHandler = null; |
| | | this.autocompleteHandler = null; |
| | | this.isAutocompleteActive = false; |
| | | |
| | | this.init(); |
| | | } |
| | | |
| | | /** |
| | | * Initialize the selector |
| | | */ |
| | | init() { |
| | | this.initModal(); |
| | | this.scanExistingFields(); |
| | | this.initGlobalListeners(); |
| | | |
| | | if (this.hasAutocomplete && window.jvbTaxCreator) { |
| | | this.creator = new window.jvbTaxCreator(this); |
| | | } |
| | | this.store.subscribe(this.handleStoreEvent.bind(this)); |
| | | // Complete initialization |
| | | this.isInitializing = false; |
| | | this.batchFetchTaxonomies(); |
| | | } |
| | | |
| | | /** |
| | | * Handle DataStore events |
| | | */ |
| | | handleStoreEvent(event, data) { |
| | | switch (event) { |
| | | case 'data-loaded': |
| | | const taxonomy = this.store.filters.taxonomy; |
| | | // Handle batch taxonomy loading (comma-separated) |
| | | if (taxonomy?.includes(',')) { |
| | | this.handleBatchDataLoaded(taxonomy, data); |
| | | } |
| | | // Update button states for this taxonomy (or taxonomies) |
| | | if (taxonomy) { |
| | | // Handle comma-separated taxonomies from batch fetch |
| | | const taxonomies = taxonomy.includes(',') |
| | | ? taxonomy.split(',').map(t => t.trim()) |
| | | : [taxonomy]; |
| | | defineTemplates() { |
| | | const T = window.jvbTemplates; |
| | | const terms = this; |
| | | |
| | | taxonomies.forEach(tax => { |
| | | this.updateFieldsForTaxonomy(tax); |
| | | }); |
| | | } |
| | | |
| | | // Only render if modal is open OR autocomplete active |
| | | if (this.modal?.open) { |
| | | this.handleTermsLoaded(data); |
| | | } |
| | | |
| | | if (this.isAutocompleteActive && this.activeField) { |
| | | const field = this.fields.get(this.activeField); |
| | | const terms = data.data?.items || []; |
| | | const query = data.filters?.search || ''; |
| | | this.showAutocompleteResults(field, terms, query); |
| | | this.isAutocompleteActive = false; |
| | | } |
| | | break; |
| | | |
| | | case 'filters-changed': |
| | | if (this.modal?.open) { |
| | | this.showLoading(); |
| | | } |
| | | break; |
| | | |
| | | case 'fetch-error': |
| | | if (this.isAutocompleteActive && this.activeField) { |
| | | this.showAutocompleteError(this.activeField); |
| | | this.isAutocompleteActive = false; |
| | | } |
| | | this.handleFetchError(data.error); |
| | | break; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle loaded terms from DataStore |
| | | */ |
| | | handleTermsLoaded(data) { |
| | | this.hideLoading(); |
| | | const terms = this.store.getFiltered(); // Use getFiltered() instead of getFilteredItems() |
| | | const response = this.store.lastResponse?.page || {}; |
| | | const isSearch = data.filters?.search && 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.'); |
| | | 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}`; |
| | | } |
| | | this.observer.unobserve(this.ui.sentinel); |
| | | } else { |
| | | this.renderTerms(terms, append, isSearch); |
| | | }); |
| | | T.define('termListItem', { |
| | | refs: { |
| | | checkbox: 'input', |
| | | label: 'label', |
| | | name: 'span, .term-name' |
| | | }, |
| | | setup({el, refs, manyRefs, data}) { |
| | | el.dataset.id = data.id; |
| | | |
| | | // Handle pagination |
| | | if (response.has_more) { |
| | | this.observer.observe(this.ui.sentinel); |
| | | } else { |
| | | this.observer.unobserve(this.ui.sentinel); |
| | | 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', |
| | | }, |
| | | } |
| | | } |
| | | |
| | | // Announce to screen readers |
| | | this.a11y?.announce(terms.length, append); |
| | | this.ui = window.uiFromSelectors(this.selectors, this.container); |
| | | } |
| | | |
| | | /** |
| | | * Handle fetch errors |
| | | */ |
| | | handleFetchError(error) { |
| | | console.error('Taxonomy fetch error:', error); |
| | | this.hideLoading(); |
| | | initListeners() { |
| | | this.observer = new IntersectionObserver((entries) => { |
| | | entries.forEach(entry => { |
| | | if (entry.isIntersecting) { |
| | | this.nextPage(); |
| | | } |
| | | }); |
| | | }, { |
| | | root: this.ui.terms.sentinel, |
| | | threshold: 0.5 |
| | | }); |
| | | |
| | | if (this.error?.log) { |
| | | this.error.log(error, { |
| | | component: 'TaxonomySelector', |
| | | action: 'fetchTerms' |
| | | }, () => this.fetchCurrentTerms()); |
| | | 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.showEmptyState('Error loading terms. Please try again.'); |
| | | 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; |
| | | |
| | | /** |
| | | * Check if taxonomy has terms and update button states |
| | | */ |
| | | updateFieldButtonState(fieldId) { |
| | | 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; |
| | | |
| | | // Check store for items of this specific taxonomy |
| | | 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.getSingular(field.taxonomy)} available` |
| | | : `Select ${this.getPlural(field.taxonomy)}`; |
| | | } |
| | | } |
| | | /** |
| | | * Update fields when taxonomy items are updated |
| | | */ |
| | | updateFieldsForTaxonomy(taxonomy) { |
| | | this.getFieldsForTaxonomy(taxonomy).forEach(field => { |
| | | this.updateFieldButtonState(field.id); |
| | | }); |
| | | 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 |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Get fields for a specific taxonomy |
| | | */ |
| | | getFieldsForTaxonomy(taxonomy) { |
| | | return Array.from(this.fields.values()) |
| | | .filter(field => field.taxonomy === taxonomy); |
| | | } |
| | | /****************************************************************** |
| | | MODAL |
| | | ******************************************************************/ |
| | | initModal() { |
| | | this.modalID = 'dialog#jvb-selector'; |
| | | this.container = document.querySelector(this.modalID); |
| | | |
| | | |
| | | |
| | | /** |
| | | * Scan page for existing taxonomy fields and register them |
| | | */ |
| | | scanExistingFields(container = null) { |
| | | if (!container) { |
| | | container = document.body; |
| | | } |
| | | const selectors = container.querySelectorAll('.field.taxonomy, .field.post'); |
| | | |
| | | selectors.forEach(selector => { |
| | | try { |
| | | this.registerField(selector); |
| | | } catch (error) { |
| | | this.error.log(error, { |
| | | component: 'TaxonomySelector', |
| | | action: 'scanExistingFields', |
| | | container: selector.dataset.name |
| | | }); |
| | | this.modal = new window.jvbModal( |
| | | this.container, |
| | | { |
| | | handleForm: false, |
| | | open: null |
| | | } |
| | | ); |
| | | this.modal.subscribe((event, data) => { |
| | | switch (event) { |
| | | case 'modal-close': |
| | | this.closeModal() |
| | | break; |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Register a taxonomy field |
| | | */ |
| | | registerField(field, options = {}) { |
| | | let input = field.querySelector('input[type=hidden]'); |
| | | if (!input) { |
| | | return false; |
| | | } |
| | | if (!('fieldId' in field.dataset)) { |
| | | field.dataset.fieldId = this.createFieldId(field); |
| | | } |
| | | let fieldId = field.dataset.fieldId; |
| | | toggleModal(fieldId, open = true) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | let button = (Object.hasOwn(options, 'button')) ? options.button : field.querySelector('button.taxonomy-toggle'); |
| | | if (open) { |
| | | this.openModal(fieldId); |
| | | } else { |
| | | this.closeModal(); |
| | | } |
| | | } |
| | | |
| | | if (Object.hasOwn(options, 'buttonSelector')) { |
| | | this.triggers.add(options.buttonSelector); |
| | | 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()); |
| | | } |
| | | |
| | | let config = { |
| | | id: fieldId, |
| | | input: input, |
| | | container: field, |
| | | taxonomy: button.dataset.taxonomy, |
| | | name: field.dataset.field, |
| | | maxSelection: parseInt(button.dataset.max) || 0, |
| | | canSearch: 'search' in button.dataset, |
| | | hasAutocomplete: 'autocomplete' in button.dataset, |
| | | autocompleteDropdown: field.querySelector('.autocomplete-dropdown')??false, |
| | | canCreate: 'creatable' in button.dataset, |
| | | isRequired: 'required' in button.dataset, |
| | | selectedTerms: new Set(), |
| | | toggle: button, |
| | | selectedContainer: (Object.hasOwn(options, 'selected')) ? options.selected : field.querySelector('.selected-items'), |
| | | ...options |
| | | }; |
| | | this.setField(bulkFieldId); |
| | | this.ui.modal.title.textContent = `Add to ${plural}`; |
| | | |
| | | if (!this.hasAutocomplete && config.hasAutocomplete) { |
| | | this.hasAutocomplete = true; |
| | | this.initAutocomplete(); |
| | | if (this.ui.search?.container) { |
| | | this.ui.search.container.hidden = false; |
| | | } |
| | | |
| | | // 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)); |
| | | } |
| | | window.removeChildren(this.ui.selected); |
| | | window.removeChildren(this.ui.terms.list); |
| | | |
| | | if (Object.hasOwn(options, 'selectedItems')) { |
| | | options.selectedItems.forEach(id => { |
| | | config.selectedTerms.add(id); |
| | | 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.fields.set(fieldId, config); |
| | | this.activeField = null; |
| | | |
| | | // Ensure store exists for this taxonomy |
| | | if (this.isInitializing) { |
| | | this.taxonomiesToFetch.add(config.taxonomy); |
| | | } else { |
| | | // this.store.setFilter('taxonomy', config.taxonomy); |
| | | } |
| | | |
| | | // Initialize display for any pre-selected values |
| | | if (config.selectedTerms.size > 0) { |
| | | this.initFieldDisplay(fieldId); |
| | | } |
| | | |
| | | return fieldId; |
| | | let message = `Closed ${field.singular} selector.`; |
| | | this.a11y.announce(message); |
| | | } |
| | | |
| | | /** |
| | | * Register a filter button (simplified registration for feed blocks) |
| | | */ |
| | | registerFilterButton(button, options = {}) { |
| | | const fieldId = this.createFieldId(button); |
| | | button.dataset.fieldId = fieldId; |
| | | 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); |
| | | } |
| | | |
| | | if (options.buttonSelector) { |
| | | this.triggers.add(options.buttonSelector); |
| | | 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, |
| | | input: null, |
| | | container: options.container || button.closest('.filters') || button.parentElement, |
| | | taxonomy: button.dataset.taxonomy, |
| | | name: `filter_${button.dataset.taxonomy}`, |
| | | maxSelection: parseInt(button.dataset.max) || 0, |
| | | canSearch: 'search' in button.dataset, |
| | | hasAutocomplete: false, |
| | | canCreate: false, |
| | | isRequired: false, |
| | | selectedTerms: new Set(options.selectedItems || []), |
| | | 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, |
| | | selectedContainer: options.selected || null, |
| | | isFilterMode: true, |
| | | ...options |
| | | 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.taxonomiesToFetch.add(config.taxonomy); |
| | | this.batchFetch.add(config.taxonomy); |
| | | } |
| | | |
| | | if (element.offsetParent !== null) { |
| | | this.updateFieldUI(fieldId); |
| | | } else { |
| | | this.store.setFilter('taxonomy', config.taxonomy); |
| | | // Defer until visible |
| | | requestIdleCallback(() => { |
| | | if (element.offsetParent !== null) { |
| | | this.updateFieldUI(fieldId); |
| | | } |
| | | }, {timeout: 2000}); |
| | | |
| | | } |
| | | |
| | | return fieldId; |
| | | } |
| | | |
| | | /** |
| | | * Create unique field ID |
| | | */ |
| | | createFieldId(field) { |
| | | this.index++; |
| | | return 'selector-' + this.index; |
| | | 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); |
| | | } |
| | | |
| | | /** |
| | | * Initialize display for a field with existing values |
| | | */ |
| | | async initFieldDisplay(fieldId) { |
| | | addSelected(termId, fieldId = null) { |
| | | if (!fieldId) fieldId = this.activeField; |
| | | |
| | | const field = this.fields.get(fieldId); |
| | | if (!field || field.selectedTerms.size === 0) return; |
| | | const term = this.store.get(termId); |
| | | if (!field || !term) return; |
| | | |
| | | const selectedIds = Array.from(field.selectedTerms); |
| | | const selected = this.selectedTerms.get(fieldId); |
| | | if (field.limit !== 0 && selected.size >= field.limit) return; |
| | | |
| | | selectedIds.forEach(termId => { |
| | | const term = this.store.get(termId); // Changed from getItem |
| | | if (term) { |
| | | this.addTermToDisplay(fieldId, term.id, term.name, term.path); |
| | | 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); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Initialize modal elements |
| | | */ |
| | | initModal() { |
| | | this.modalID = 'dialog#jvb-selector'; |
| | | this.modal = document.querySelector(this.modalID); |
| | | 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); |
| | | |
| | | if (!this.modal) { |
| | | console.warn('Taxonomy selector modal not found'); |
| | | return; |
| | | } |
| | | fields.forEach(field => { |
| | | if (!field.toggle) return; |
| | | field.toggle.disabled = !hasItems && !field.canCreate; |
| | | field.toggle.title = !hasItems |
| | | ? `No ${field.singular} available` |
| | | : `Select ${field.plural}`; |
| | | |
| | | 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': |
| | | this.openModal(data); |
| | | break; |
| | | case 'modal-close': |
| | | this.closeModal(data); |
| | | break; |
| | | } |
| | | field.checked = true; |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * 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.loadMoreTerms(); |
| | | } |
| | | }); |
| | | }, { |
| | | root: this.ui.termsWrap, |
| | | threshold: 0.5 |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Set up global event delegation |
| | | */ |
| | | initGlobalListeners() { |
| | | document.addEventListener('click', this.handleClick.bind(this)); |
| | | document.addEventListener('change', this.handleChange.bind(this)); |
| | | if (this.hasAutocomplete) { |
| | | this.initAutocomplete(); |
| | | } |
| | | } |
| | | |
| | | initAutocomplete() |
| | | { |
| | | this.autocompleteHandler = window.debounce((e) => this.handleAutocomplete(e), 300); |
| | | document.addEventListener('input', this.autocompleteHandler); |
| | | document.addEventListener('blur', this.cleanupAutocomplete.bind(this)); |
| | | // Preload taxonomy data on focus |
| | | document.addEventListener('focus', (e) => { |
| | | if (!('autocomplete' in e.target.dataset)) { |
| | | return; |
| | | 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); |
| | | } |
| | | |
| | | const fieldId = this.getFieldId(e.target); |
| | | const field = this.fields.get(fieldId); |
| | | |
| | | if (!field) return; |
| | | |
| | | // Preload this taxonomy's data |
| | | this.preloadTaxonomy(field.taxonomy); |
| | | }, true); // Use capture phase |
| | | } |
| | | |
| | | /** |
| | | * Handle global click events |
| | | */ |
| | | handleClick(e) { |
| | | // Handle taxonomy toggle buttons |
| | | const toggleButton = window.targetCheck(e, Array.from(this.triggers)); |
| | | |
| | | 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(); |
| | | 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; |
| | | } |
| | | |
| | | // Handle clicks within the modal |
| | | if (this.modal && this.modal.contains(e.target)) { |
| | | this.handleModalClick(e); |
| | | } |
| | | } |
| | | this.setCreateButton(field,true); |
| | | |
| | | /** |
| | | * 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, true); |
| | | |
| | | } catch (error) { |
| | | console.error('Error handling toggle click:', error); |
| | | if (this.error?.log) { |
| | | this.error.log(error, { |
| | | component: 'TaxonomySelector', |
| | | action: 'handleToggleClick' |
| | | }); |
| | | 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); |
| | | } |
| | | |
| | | /** |
| | | * Set the active field for modal operations |
| | | */ |
| | | setActiveField(fieldId, openModal = false) { |
| | | this.activeField = fieldId; |
| | | this.currentConfig = this.fields.get(fieldId); |
| | | showAutocompleteTerms() { |
| | | const field = this.currentField(); |
| | | if (!field || !field.hasAutocomplete || !field.ui.dropdown?.list) return; |
| | | const dropdown = field.ui.dropdown.list; |
| | | const terms = this.currentTerms(); |
| | | |
| | | this.currentSingular = this.getSingular(this.currentConfig.taxonomy); |
| | | this.currentPlural = this.getPlural(this.currentConfig.taxonomy); |
| | | 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(()=>{}); |
| | | |
| | | if (openModal) { |
| | | this.modalInstance.handleOpen(); |
| | | 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); |
| | | } |
| | | |
| | | // Set taxonomy filter - store handles the rest |
| | | this.store.setFilter('taxonomy', this.currentConfig.taxonomy); |
| | | if (this.container.open) { |
| | | this.addTermToModal(termId); |
| | | const checkbox = this.ui.terms.list.querySelector(`input[value="${termId}"]`); |
| | | if (checkbox) checkbox.checked = true; |
| | | } |
| | | } |
| | | |
| | | // Clear modal selection state |
| | | this.selectedTerms.clear(); |
| | | updateBreadcrumbs(termId) { |
| | | const nav = this.ui.nav.nav; |
| | | if (!nav) return; |
| | | const existingCrumb = Array.from(nav.children) |
| | | .find(crumb => parseInt(crumb.dataset.id) === termId); |
| | | |
| | | // Copy field's current selections to modal state |
| | | this.currentConfig.selectedTerms.forEach(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) { |
| | | this.selectedTerms.set(termId, { |
| | | id: termId, |
| | | name: term.name, |
| | | path: term.path |
| | | }); |
| | | } |
| | | }); |
| | | } |
| | | if (!term) return; |
| | | const crumb = window.jvbTemplates.create('termBreadcrumb', term); |
| | | |
| | | |
| | | /** |
| | | * 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); |
| | | nav.append(crumb); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle changes within modal (checkboxes) |
| | | */ |
| | | handleModalChange(e) { |
| | | if (window.targetCheck(e, this.modalID) && e.target.type === 'checkbox') { |
| | | e.preventDefault(); |
| | | e.stopPropagation(); |
| | | |
| | | const termId = parseInt(e.target.closest('li').dataset.id); |
| | | const label = e.target.closest('li').querySelector('label'); |
| | | |
| | | if (e.target.checked) { |
| | | this.addSelectedTermToModal(termId, label.title, label.dataset.path); |
| | | } else { |
| | | this.removeSelectedTermFromModal(termId); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Open modal for filtering (without a field) |
| | | * @param {string} taxonomy - The taxonomy to filter by |
| | | * @param {Function} callback - Callback when terms are selected |
| | | * @param {Array} preselected - Array of term IDs already selected |
| | | */ |
| | | openForFilter(taxonomy, callback, preselected = []) { |
| | | // Create a temporary virtual field config |
| | | const virtualFieldId = `filter-${taxonomy}-${Date.now()}`; |
| | | |
| | | this.fields.set(virtualFieldId, { |
| | | id: virtualFieldId, |
| | | input: null, // No input for filter mode |
| | | container: null, |
| | | taxonomy: taxonomy, |
| | | name: `filter_${taxonomy}`, |
| | | maxSelection: 0, // No limit for filters |
| | | canSearch: true, |
| | | hasAutocomplete: false, |
| | | autocompleteDropdown: document.querySelector('.autocomplete-dropdown')??false, |
| | | canCreate: false, // Disable creation for filters |
| | | isRequired: false, |
| | | selectedTerms: new Set(preselected), |
| | | toggle: null, |
| | | selectedContainer: null, |
| | | isFilterMode: true, // Flag for filter mode |
| | | filterCallback: callback // Store the callback |
| | | }); |
| | | |
| | | this.setActiveField(virtualFieldId, true); |
| | | this.modalInstance.handleOpen(); |
| | | } |
| | | |
| | | /** |
| | | * Open modal and initialize |
| | | */ |
| | | openModal() { |
| | | if (!this.currentConfig) { |
| | | console.error('No active field set'); |
| | | return; |
| | | } |
| | | |
| | | // Initialize creator if available |
| | | if (!this.creator && this.currentConfig.canCreate && 'jvbTaxCreator' in window) { |
| | | this.creator = new window.jvbTaxCreator(this); |
| | | } |
| | | |
| | | // Update modal UI |
| | | this.updateModalForTaxonomy(); |
| | | |
| | | // Load selected terms display |
| | | this.updateModalSelections(); |
| | | this.updateSelectionCount(); |
| | | |
| | | // Clear terms list and show loading |
| | | window.removeChildren(this.ui.termsList); |
| | | this.showLoading(); |
| | | } |
| | | |
| | | /** |
| | | * Update selection count display in modal |
| | | */ |
| | | updateSelectionCount() { |
| | | if (!this.currentConfig) return; |
| | | if (!this.container.open) return; |
| | | const field = this.fields.get(this.activeField); |
| | | if (!field) return; |
| | | |
| | | const count = this.selectedTerms.size; |
| | | const max = this.currentConfig.maxSelection; |
| | | if (this.ui.modal.count) { |
| | | const total = this.selectedTerms.get(this.activeField).size; |
| | | |
| | | // Update any count display elements |
| | | const countElement = this.modal?.querySelector('.selection-count'); |
| | | if (countElement) { |
| | | if (max > 0) { |
| | | countElement.textContent = `${count} of ${max} selected`; |
| | | } else { |
| | | countElement.textContent = `${count} selected`; |
| | | } |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Get singular label for taxonomy |
| | | */ |
| | | getSingular(taxonomy) { |
| | | return jvbSettings.labels[taxonomy]?.single || taxonomy; |
| | | } |
| | | |
| | | /** |
| | | * Get plural label for taxonomy |
| | | */ |
| | | getPlural(taxonomy) { |
| | | return jvbSettings.labels[taxonomy]?.plural || taxonomy; |
| | | } |
| | | |
| | | /** |
| | | * Close modal and save selections |
| | | */ |
| | | 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.currentConfig?.isFilterMode) { |
| | | if (this.currentConfig.filterCallback) { |
| | | const selectedIds = Array.from(this.selectedTerms.keys()); |
| | | this.currentConfig.filterCallback(selectedIds, this.currentConfig.taxonomy); |
| | | } |
| | | // this.fields.delete(this.activeField); |
| | | } else if (this.activeField) { |
| | | this.saveSelectionsToField(this.activeField); |
| | | this.ui.modal.count.textContent = field.limit > 0 |
| | | ? `${total} of ${field.limit} ${field.plural} selected` |
| | | : `${total} ${field.plural} selected`; |
| | | } |
| | | |
| | | // Cleanup |
| | | if (this.currentConfig?.canSearch && this.searchHandler) { |
| | | this.ui.search.input.removeEventListener('input', this.searchHandler); |
| | | } |
| | | /****************************************************************** |
| | | 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 |
| | | ); |
| | | } |
| | | |
| | | if (!this.hasAutocomplete && this.creator) { |
| | | delete this.creator; |
| | | } |
| | | getFieldId(element) { |
| | | if (element.dataset.fieldId) return element.dataset.fieldId; |
| | | |
| | | // Remove: this.activeStore = null; |
| | | this.activeField = null; |
| | | this.currentConfig = null; |
| | | const fieldContainer = element.closest('[data-field-id]'); |
| | | return fieldContainer?.dataset.fieldId || 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 |
| | | * Sets all checkbox disabled (or not) |
| | | * @param {Boolean} disabled |
| | | */ |
| | | setCheckboxes(disabled) { |
| | | this.ui.termsList.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { |
| | | this.ui.terms.list.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; |
| | | /****************************************************************** |
| | | DATASTORE HELPERS |
| | | ******************************************************************/ |
| | | handleStoreEvent(event, data) { |
| | | const handlers = { |
| | | 'data-loaded': () => this.handleDataLoaded(), |
| | | 'filters-changed': () => this.handleFiltersChanged(data), |
| | | 'fetch-error': () => this.handleFetchError() |
| | | }; |
| | | |
| | | // Clear current field selections |
| | | field.selectedTerms.clear(); |
| | | window.removeChildren(field.selectedContainer); |
| | | try { |
| | | handlers[event]?.(data); |
| | | } catch (error) { |
| | | console.error(`Error handling store event "${event}":`, error); |
| | | } |
| | | } |
| | | handleDataLoaded() { |
| | | const taxonomy = this.store.filters.taxonomy; |
| | | |
| | | // Add modal selections to field |
| | | this.selectedTerms.forEach((termData, id) => { |
| | | field.selectedTerms.add(id); |
| | | this.addTermToDisplay(fieldId, id, termData.name, termData.path); |
| | | 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 |
| | | }); |
| | | |
| | | // Update hidden input |
| | | const selectedIds = Array.from(field.selectedTerms); |
| | | field.input.value = selectedIds.join(','); |
| | | field.input.dispatchEvent(new Event('change', { bubbles: true })); |
| | | } |
| | | |
| | | /** |
| | | * Remove selected term from field |
| | | */ |
| | | removeSelectedTerm(fieldId, termId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | const id = parseInt(termId); |
| | | field.selectedTerms.delete(id); |
| | | |
| | | // Remove from display |
| | | const selectedItem = field.selectedContainer.querySelector(`[data-id="${id}"]`); |
| | | if (selectedItem) { |
| | | selectedItem.remove(); |
| | | } |
| | | |
| | | // Update hidden input |
| | | const selectedIds = Array.from(field.selectedTerms); |
| | | field.input.value = selectedIds.join(','); |
| | | field.input.dispatchEvent(new Event('change', { bubbles: true })); |
| | | } |
| | | |
| | | /** |
| | | * Add term to field display |
| | | */ |
| | | addTermToDisplay(fieldId, id, name, path) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field || field.selectedContainer.querySelector(`[data-id="${id}"]`)) { |
| | | return; // Already displayed |
| | | } |
| | | |
| | | const item = window.getTemplate('selectedTerm').cloneNode(true); |
| | | item.dataset.id = id; |
| | | item.dataset.path = path; |
| | | item.dataset.name = name; |
| | | item.dataset.taxonomy = field.taxonomy; |
| | | item.querySelector('span').textContent = path; |
| | | item.querySelector('button').title = `Remove ${name}`; |
| | | |
| | | field.selectedContainer.appendChild(item); |
| | | } |
| | | |
| | | /** |
| | | * Update field from hidden input value |
| | | */ |
| | | updateFieldFromInput(fieldId) { |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) return; |
| | | |
| | | const value = field.input.value.trim(); |
| | | field.selectedTerms.clear(); |
| | | window.removeChildren(field.selectedContainer); |
| | | |
| | | if (value !== '') { |
| | | const selectedIds = value.split(',') |
| | | .map(id => parseInt(id.trim())) |
| | | .filter(id => !isNaN(id)); |
| | | |
| | | selectedIds.forEach(id => field.selectedTerms.add(id)); |
| | | this.initFieldDisplay(fieldId); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle search input |
| | | */ |
| | | handleSearch(e) { |
| | | const query = e.target.value.trim(); |
| | | |
| | | // Clear existing debounce |
| | | if (this.searchHandler) { |
| | | clearTimeout(this.searchHandler); |
| | | } |
| | | |
| | | this.searchHandler = setTimeout(() => { |
| | | // Single call - auto-fetches |
| | | this.store.setFilters({ |
| | | search: query, |
| | | page: 1, |
| | | parent: query ? 0 : (this.store.filters.parent || 0) |
| | | }); |
| | | |
| | | window.removeChildren(this.ui.termsList); |
| | | }, 300); |
| | | } |
| | | |
| | | async handleAutocomplete(e) { |
| | | if (!('autocomplete' in e.target.dataset)) { |
| | | if (!this.activeField && isAutoComplete) { |
| | | return; |
| | | } |
| | | |
| | | const fieldId = this.getFieldId(e.target); |
| | | const field = this.fields.get(fieldId); |
| | | |
| | | if (!field) return; |
| | | |
| | | // Store current value immediately (fixes fast typing issue) |
| | | const query = e.target.value.trim(); |
| | | field.currentAutocompleteQuery = query; |
| | | |
| | | if (query.length < 2) { |
| | | if (field.autocompleteDropdown) { |
| | | field.autocompleteDropdown.hidden = true; |
| | | } |
| | | this.isAutocompleteActive = false; |
| | | return; |
| | | } |
| | | |
| | | this.activeField = fieldId; |
| | | this.isAutocompleteActive = true; |
| | | |
| | | if (field.autocompleteDropdown) { |
| | | field.autocompleteDropdown.hidden = false; |
| | | } |
| | | |
| | | this.store.setFilters({ |
| | | taxonomy: field.taxonomy, |
| | | search: query, |
| | | page: 1 |
| | | }); |
| | | } |
| | | |
| | | cleanupAutocomplete(e) { |
| | | if (!('autocomplete' in e.target.dataset)) { |
| | | return; |
| | | } |
| | | |
| | | const fieldId = this.getFieldId(e.target); |
| | | const field = this.fields.get(fieldId); |
| | | |
| | | if (!field) return; |
| | | |
| | | if (this.creator) { |
| | | delete this.creator; |
| | | } |
| | | } |
| | | |
| | | showAutocompleteError(fieldId) { |
| | | |
| | | const field = this.fields.get(fieldId); |
| | | if (!field) { |
| | | return; |
| | | } |
| | | if (!field.config.autocompleteDropdown) { |
| | | field.config.autocompleteDropdown = field.element.querySelector('.autocomplete-dropdown'); |
| | | } |
| | | const dropdown = field.config.autocompleteDropdown; |
| | | if (dropdown) { |
| | | window.removeChildren(dropdown); |
| | | this.showEmptyState('Hmmm... something went wrong', dropdown); |
| | | } |
| | | } |
| | | |
| | | showAutocompleteResults(field, terms, query) { |
| | | if (!field || !field.autocompleteDropdown) { |
| | | return; |
| | | } |
| | | |
| | | const dropdown = field.autocompleteDropdown; |
| | | window.removeChildren(dropdown); |
| | | |
| | | if (terms.length === 0) { |
| | | this.showEmptyState('No items found.', dropdown); |
| | | this.setMessage(this.currentField(), false); |
| | | if (isAutoComplete) { |
| | | this.showAutocompleteTerms(); |
| | | } else { |
| | | terms.forEach(term => { |
| | | const element = this.createAutocompleteTermElement(field, term); |
| | | if (element) { |
| | | dropdown.appendChild(element); |
| | | } |
| | | }); |
| | | this.showModalTerms(isSearch); |
| | | } |
| | | |
| | | // Use stored current query instead of debounced one |
| | | const currentQuery = field.currentAutocompleteQuery || query; |
| | | if (field.canCreate && currentQuery && window.jvbTaxCreator) { |
| | | const createOption = this.createNewTermOption(currentQuery); |
| | | dropdown.appendChild(createOption); |
| | | } |
| | | |
| | | dropdown.hidden = false; |
| | | this.a11y.announce(terms.length); |
| | | } |
| | | handleFiltersChanged(data) { |
| | | //maybe do something? |
| | | } |
| | | |
| | | createNewTermOption(query) { |
| | | const button = document.createElement('button'); |
| | | button.type = 'button'; |
| | | button.className = 'autocomplete-item create-term'; |
| | | button.dataset.query = query; |
| | | button.innerHTML = `<strong>Create:</strong> "${query}"`; |
| | | handleFetchError(error) { |
| | | const field = this.currentField(); |
| | | const message = field |
| | | ? `Failed to load ${field.plural}` |
| | | : 'Failed to load data'; |
| | | |
| | | return button; |
| | | this.setMessage(field,true, message, false); |
| | | console.error('Store fetch error:', error); |
| | | } |
| | | |
| | | createAutocompleteTermElement(field, term) { |
| | | const item = document.createElement('button'); |
| | | item.type = 'button'; |
| | | item.className = 'autocomplete-item'; |
| | | item.dataset.id = term.id; |
| | | item.dataset.name = term.name; |
| | | item.dataset.path = term.path || term.name; |
| | | item.textContent = term.path || term.name; |
| | | |
| | | item.addEventListener('click', () => { |
| | | // Add term to field |
| | | field.selectedTerms.add(parseInt(term.id)); |
| | | this.addTermToDisplay(field.id, term.id, term.name, term.path); |
| | | |
| | | // Update input |
| | | field.input.value = Array.from(field.selectedTerms).join(','); |
| | | field.input.dispatchEvent(new Event('change', { bubbles: true })); |
| | | |
| | | // Clear and hide dropdown |
| | | field.autocompleteDropdown.hidden = true; |
| | | const input = field.container.querySelector('input[data-autocomplete]'); |
| | | if (input) input.value = ''; |
| | | }); |
| | | |
| | | return item; |
| | | } |
| | | |
| | | /** |
| | | * Navigate to parent term |
| | | */ |
| | | navigateToParent() { |
| | | // Store handles fetch automatically |
| | | this.store.setFilters({ |
| | | parent: 0, |
| | | page: 1 |
| | | }); |
| | | |
| | | window.removeChildren(this.ui.termsList); |
| | | this.ui.breadcrumbs.back.hidden = true; |
| | | } |
| | | |
| | | /** |
| | | * Navigate to child term |
| | | */ |
| | | navigateToChild(termId, termName) { |
| | | // Store handles fetch automatically |
| | | this.store.setFilters({ |
| | | parent: termId, |
| | | page: 1 |
| | | }); |
| | | |
| | | window.removeChildren(this.ui.termsList); |
| | | this.updateBreadcrumbs(termId, termName); |
| | | this.ui.breadcrumbs.back.hidden = false; |
| | | } |
| | | |
| | | /** |
| | | * Navigate to specific path level |
| | | */ |
| | | navigateToPath(pathLevel) { |
| | | const parentId = parseInt(pathLevel.dataset.id) || 0; |
| | | |
| | | // Store handles fetch automatically |
| | | this.store.setFilters({ |
| | | parent: parentId, |
| | | page: 1 |
| | | }); |
| | | |
| | | window.removeChildren(this.ui.termsList); |
| | | this.ui.breadcrumbs.back.hidden = parentId === 0; |
| | | } |
| | | |
| | | /** |
| | | * Load more terms (pagination) |
| | | */ |
| | | loadMoreTerms() { |
| | | const currentPage = this.store.filters.page || 1; |
| | | this.store.setFilter('page', currentPage + 1); |
| | | } |
| | | |
| | | /** |
| | | * Render terms list |
| | | */ |
| | | renderTerms(terms = null, append = false, showPath = false) { |
| | | // If no terms provided, get from store |
| | | 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); |
| | | } |
| | | |
| | | /** |
| | | * Create individual term element |
| | | */ |
| | | createTermElement(termData) { |
| | | if (!termData || !termData.name) return null; |
| | | |
| | | const listItem = window.getTemplate('termListItem').cloneNode(true); |
| | | listItem.dataset.id = termData.id; |
| | | |
| | | const isSelected = this.selectedTerms.has(termData.id); |
| | | const checkbox = listItem.querySelector('input'); |
| | | const label = listItem.querySelector('label'); |
| | | const nameSpan = listItem.querySelector('span, .term-name'); |
| | | |
| | | if (checkbox && label && nameSpan) { |
| | | checkbox.id = `${this.currentConfig.container.id}${termData.id}`; |
| | | checkbox.name = `${this.currentConfig.container.id}${this.currentConfig.taxonomy}-select`; |
| | | checkbox.value = termData.id; |
| | | checkbox.disabled = !isSelected && this.disabled; |
| | | checkbox.checked = isSelected; |
| | | |
| | | label.htmlFor = checkbox.id; |
| | | label.title = termData.path || termData.name; |
| | | label.dataset.path = termData.path; |
| | | |
| | | nameSpan.textContent = termData.show ? termData.path : termData.name; |
| | | } |
| | | |
| | | if (termData.hasChildren) { |
| | | const childrenToggle = window.getTemplate ? |
| | | window.getTemplate('termChildrenToggle') : |
| | | this.createChildrenToggle(); |
| | | |
| | | if (childrenToggle) { |
| | | childrenToggle.ariaLabel = `View sub-terms of ${termData.name}`; |
| | | listItem.appendChild(childrenToggle); |
| | | } |
| | | } |
| | | |
| | | return listItem; |
| | | } |
| | | |
| | | /** |
| | | * Create children toggle button |
| | | */ |
| | | createChildrenToggle() { |
| | | const button = document.createElement('button'); |
| | | button.type = 'button'; |
| | | button.className = 'toggle-children'; |
| | | button.innerHTML = '→'; |
| | | return button; |
| | | } |
| | | |
| | | /** |
| | | * Update breadcrumb navigation |
| | | */ |
| | | updateBreadcrumbs(termId, termName) { |
| | | // This is a simplified version - you'd want to maintain a proper breadcrumb trail |
| | | const breadcrumb = window.getTemplate('termBreadcrumb').cloneNode(true); |
| | | breadcrumb.dataset.id = termId; |
| | | breadcrumb.textContent = termName; |
| | | breadcrumb.title = termName; |
| | | |
| | | // Remove any existing breadcrumbs after this level |
| | | const existingCrumb = this.ui.breadcrumbs.nav.querySelector(`[data-id="${termId}"]`); |
| | | if (existingCrumb) { |
| | | // Remove all breadcrumbs after this one |
| | | while (existingCrumb.nextElementSibling) { |
| | | existingCrumb.nextElementSibling.remove(); |
| | | } |
| | | } else { |
| | | this.ui.breadcrumbs.nav.appendChild(breadcrumb); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Show loading state |
| | | */ |
| | | showLoading() { |
| | | this.ui.loading.loading.hidden = false; |
| | | this.modal.classList.add('loading'); |
| | | |
| | | const searchQuery = this.store?.filters?.search || ''; |
| | | const currentParent = this.store?.filters?.parent || 0; |
| | | |
| | | let message = searchQuery !== '' ? |
| | | `searching for "${searchQuery}" items` : |
| | | currentParent === 0 ? |
| | | 'loading items' : |
| | | `loading child items`; |
| | | |
| | | if (window.typeLoop) { |
| | | this.stopTyping = window.typeLoop(this.ui.loading.text, message); |
| | | } else { |
| | | this.ui.loading.text.textContent = message; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Hide loading state |
| | | */ |
| | | hideLoading() { |
| | | this.ui.loading.loading.hidden = true; |
| | | this.modal.classList.remove('loading'); |
| | | |
| | | if (this.stopTyping) { |
| | | this.stopTyping(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Show empty state message |
| | | */ |
| | | showEmptyState(message = 'No items found.', container = null) { |
| | | if (!container) { |
| | | container = this.ui.termsList; |
| | | } |
| | | const emptyElement = window.getTemplate('noResults').cloneNode(true); |
| | | |
| | | if (message && emptyElement.querySelector('span')) { |
| | | emptyElement.querySelector('span').textContent = message; |
| | | } |
| | | |
| | | container.appendChild(emptyElement); |
| | | } |
| | | |
| | | /** |
| | | * Get field ID from any element within the field |
| | | */ |
| | | getFieldId(element) { |
| | | if (element.dataset.fieldId) { |
| | | return element.dataset.fieldId; |
| | | } |
| | | |
| | | const fieldContainer = element.closest('[data-field-id]'); |
| | | if (fieldContainer) { |
| | | return fieldContainer.dataset.fieldId; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | /******************************************** |
| | | BATCH FETCH: fetches first page for all taxonomies in one call |
| | | ********************************************/ |
| | | async batchFetchTaxonomies() { |
| | | if (this.taxonomiesToFetch.size === 0) return; |
| | | |
| | | const taxonomies = Array.from(this.taxonomiesToFetch); |
| | | this.taxonomiesToFetch.clear(); |
| | | |
| | | // Single fetch - the data-loaded event will handle cache splitting |
| | | this.store.setFilters({ |
| | | taxonomy: taxonomies.join(','), |
| | | page: 1, |
| | | search: '', |
| | | parent: 0 |
| | | }); |
| | | } |
| | | handleBatchDataLoaded(taxonomyString, data) { |
| | | const taxonomies = taxonomyString.split(',').map(t => t.trim()); |
| | | const storeInstance = this.store.getStore(); // Access actual store instance |
| | | |
| | | taxonomies.forEach(taxonomy => { |
| | | const filters = { |
| | | taxonomy: taxonomy, |
| | | 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 |
| | | }; |
| | | |
| | | // Use the internal generateCacheKey method via store instance |
| | | const cacheKey = this.generateCacheKeyForFilters(filters); |
| | | |
| | | // Filter items for this specific taxonomy |
| | | const items = Array.from(this.store.data.values()) |
| | | .filter(item => item.taxonomy === taxonomy) |
| | | .map(item => item.id); |
| | | |
| | | const cacheEntry = { |
| | | key: cacheKey, |
| | | items: items, |
| | | timestamp: Date.now(), |
| | | endpoint: storeInstance.config.endpoint, |
| | | filters: filters |
| | | }; |
| | | |
| | | // Set in both memory and IndexedDB cache |
| | | storeInstance.cache.set(cacheKey, cacheEntry); |
| | | |
| | | // Persist to IndexedDB (if available) |
| | | if (storeInstance.db?.objectStoreNames.contains('cache')) { |
| | | const tx = storeInstance.db.transaction(['cache'], 'readwrite'); |
| | | const objectStore = tx.objectStore('cache'); |
| | | objectStore.put(cacheEntry); |
| | | } |
| | | |
| | | // Update button states for this taxonomy |
| | | this.updateFieldsForTaxonomy(taxonomy); |
| | | }); |
| | | |
| | | // Initialize field displays |
| | | this.fields.forEach((config, fieldId) => { |
| | | if (config.selectedTerms.size > 0) { |
| | | this.initFieldDisplay(fieldId); |
| | | } |
| | | }); |
| | | }); |
| | | } catch (error) { |
| | | console.error('Failed to batch fetch taxonomies:', error); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Generate cache key for given filters (matching DataStore's internal logic) |
| | | */ |
| | | generateCacheKeyForFilters(filters) { |
| | | const normalized = Object.keys(filters) |
| | | .sort() |
| | | .reduce((acc, key) => { |
| | | acc[key] = filters[key]; |
| | | return acc; |
| | | }, {}); |
| | | |
| | | return JSON.stringify(normalized); |
| | | } |
| | | |
| | | /** |
| | | * Preload taxonomy data on hover |
| | | */ |
| | | async preloadTaxonomy(taxonomy) { |
| | | // Trigger fetch for this taxonomy |
| | | this.store.setFilters({ |
| | | preloadTaxonomy(taxonomy) { |
| | | this.store.setFilters( { |
| | | taxonomy: taxonomy, |
| | | page: 1, |
| | | search: '', |
| | | parent: 0 |
| | | }); |
| | | } |
| | | /***************************************** |
| | | SUBSCRIBERS |
| | | *****************************************/ |
| | | |
| | | /************************************************** |
| | | 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 => { |
| | | notify(event, data={}) { |
| | | this.subscribers.forEach(callback => { |
| | | try { |
| | | callback(event, data); |
| | | } catch (error) { |
| | |
| | | } |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Clean up |
| | | */ |
| | | /****************************************************** |
| | | CLEANUP |
| | | ******************************************************/ |
| | | destroy() { |
| | | // Remove event listeners |
| | | document.removeEventListener('click', this.handleClick); |
| | | document.removeEventListener('change', this.handleChange); |
| | | // Cancel all debounced operations for this instance |
| | | this.fields.forEach((field, fieldId) => { |
| | | window.debouncer.cancel(`${fieldId}-search`); |
| | | window.debouncer.cancel(`${fieldId}-search-results`); |
| | | }); |
| | | |
| | | // Clear intervals and cleanup |
| | | // 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(); |
| | | |
| | | // Destroy all stores |
| | | this.store.destroy(); |
| | | // 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(); |
| | | // Clear all maps |
| | | 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; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Initialize singleton |
| | | */ |
| | | document.addEventListener('DOMContentLoaded', function() { |
| | | window.jvbSelector = new TaxonomySelector(); |
| | | window.auth.subscribe((event) => { |
| | | if (event === 'auth-loaded') { |
| | | window.jvbSelector = new TaxonomySelector(); |
| | | } |
| | | }); |
| | | }); |