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