|
class TaxonomySelector {
|
constructor() {
|
this.container = document.querySelector('dialog#jvb-selector');
|
if (!this.container) return;
|
|
this.a11y = window.jvbA11y;
|
this.error = window.jvbError;
|
|
this.subscribers = new Set();
|
this.fields = new Map();
|
this.selectedTerms = new Map(); // a map of fieldId => Set of selected term Ids
|
this.batchFetch = new Set();
|
|
this.activeField = null;
|
this.isInitializing = true;
|
this.messageText = {}
|
this.init();
|
}
|
|
init() {
|
this.initStore();
|
this.initElements();
|
this.initModal();
|
this.scanExistingFields();
|
this.initListeners();
|
|
if (this.needsCreator() && window.jvbTaxCreator) {
|
this.creator = new window.jvbTaxCreator(this);
|
}
|
this.isInitializing = false
|
this.batchFetchTaxonomies().then(()=> {});
|
}
|
|
initStore() {
|
const store = window.jvbStore.register(
|
'taxonomies',
|
{
|
storeName: 'terms',
|
keyPath: 'id',
|
showLoading: false,
|
indexes: [
|
{name: 'taxonomy', keyPath: 'taxonomy'},
|
{name: 'parent', keyPath: 'parent'},
|
{name: 'slug', keyPath: 'slug', unique: true},
|
{name: 'count', keyPath: 'count'},
|
],
|
endpoint: 'terms',
|
TTL: 2 * 60 * 1000,
|
filters: {
|
taxonomy: '',
|
page: 1,
|
search: '',
|
parent: 0
|
},
|
required: 'taxonomy',
|
delayFetch: true,
|
}
|
);
|
this.store = store.terms;
|
|
this.store.subscribe(this.handleStoreEvent.bind(this));
|
}
|
|
/******************************************************************
|
ELEMENTS
|
******************************************************************/
|
initElements() {
|
this.selectors = {
|
search: {
|
input: '[type="search"]',
|
clear: '.clear-search',
|
container: '.search-wrapper',
|
results: '.search-results',
|
},
|
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.taxonomy-toggle, [data-filter="taxonomy"]',
|
value: 'input[type="hidden"]',
|
selected: '.selected-items',
|
dropdown: {
|
list: '.search-results',
|
wrapper: '.auto-wrapper',
|
},
|
create: {
|
button: '.auto-wrapper .submit-term',
|
span: '.auto-wrapper button span',
|
},
|
search: 'input[data-autocomplete]',
|
message: {
|
message: 'p.message',
|
text: 'p.message span',
|
},
|
}
|
}
|
|
this.ui = window.uiFromSelectors(this.selectors, this.container);
|
}
|
|
initListeners() {
|
this.observer = new IntersectionObserver((entries) => {
|
entries.forEach(entry => {
|
if (entry.isIntersecting) {
|
this.nextPage();
|
}
|
});
|
}, {
|
root: this.ui.terms.sentinel,
|
threshold: 0.5
|
});
|
|
this.clickHandler = this.handleClick.bind(this);
|
this.changeHandler = this.handleChange.bind(this);
|
this.inputHandler = this.handleInput.bind(this);
|
this.focusHandler = this.handleFocus.bind(this);
|
this.blurHandler = this.handleBlur.bind(this);
|
|
document.addEventListener('click', this.clickHandler);
|
document.addEventListener('change', this.changeHandler);
|
document.addEventListener('input', this.inputHandler);
|
document.addEventListener('focus', this.focusHandler, true);
|
document.addEventListener('blur', this.blurHandler, true);
|
}
|
|
handleClick(e) {
|
const fieldId = this.getFieldId(e.target) || this.activeField;
|
const field = this.fields.get(fieldId);
|
if (!fieldId || !field) return;
|
|
const autoComplete = window.targetCheck(e, '.item.autocomplete');
|
|
if (autoComplete) {
|
let termId = parseInt(autoComplete.dataset.id);
|
this.addSelected(termId, fieldId);
|
this.scheduleHideDropdown(fieldId);
|
if (field.ui.search) {
|
field.ui.search.value = '';
|
}
|
}
|
|
const toggleButton = window.targetCheck(e, this.selectors.field.toggle);
|
|
if (toggleButton) {
|
e.preventDefault();
|
this.openModal(fieldId);
|
return;
|
}
|
|
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;
|
}
|
|
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);
|
}
|
|
const dropdown = window.targetCheck(e, this.selectors.field.dropdown);
|
if (dropdown) {
|
// reset the timer for hiding the dropdown
|
this.scheduleHideDropdown(fieldId);
|
}
|
|
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 = '';
|
}
|
}
|
|
if (this.creator) {
|
let button = window.targetCheck(e, this.selectors.create.button);
|
if (button) {
|
this.maybeCreateTerm(e).then(()=>{});
|
}
|
}
|
|
}
|
handleChange(e) {
|
if (!this.container.contains(e.target)) {
|
return;
|
}
|
if (!['checkbox', 'button'].includes(e.target.type)) return;
|
e.preventDefault();
|
e.stopPropagation();
|
|
const termId = parseInt(e.target.dataset.id);
|
let fieldId = this.getFieldId(e.target);
|
if (e.target.checked) {
|
this.addSelected(termId, fieldId);
|
} else {
|
this.removeSelected(termId, fieldId);
|
}
|
}
|
//For search in modal or field autocomplete
|
handleInput(e) {
|
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(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(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) {
|
const fieldId = this.getFieldId(e.target);
|
const field = this.fields.get(fieldId);
|
if (!fieldId || !field) return;
|
if (!field.hasAutocomplete && !field.hasSearch) return;
|
|
window.debouncer.cancel(`${fieldId}-search-results`);
|
|
if (!this.container.open){
|
this.setField(fieldId);
|
}
|
}
|
|
//Hide autocomplete dropdown on blur
|
handleBlur(e) {
|
const fieldId = this.getFieldId(e.target);
|
const field = this.fields.get(fieldId);
|
if (!fieldId || ! field) return;
|
if (!field.hasAutocomplete || this.container.open) return;
|
if (e.relatedTarget && field.ui.dropdown.wrapper?.contains(e.relatedTarget)) return;
|
|
this.scheduleHideDropdown(fieldId);
|
}
|
|
scheduleHideDropdown(fieldId){
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
window.debouncer.schedule(
|
`${fieldId}-search-results`,
|
() => {
|
if (!this.container.open) {
|
this.activeField = null;
|
}
|
if (field.ui.dropdown.wrapper) {
|
field.ui.dropdown.wrapper.hidden = true;
|
}
|
},
|
1500
|
);
|
}
|
|
/******************************************************************
|
MODAL
|
******************************************************************/
|
initModal() {
|
this.modalID = 'dialog#jvb-selector';
|
this.container = document.querySelector(this.modalID);
|
|
this.modal = new window.jvbModal(
|
this.container,
|
{
|
handleForm: false,
|
open: null
|
}
|
);
|
this.modal.subscribe((event, data) => {
|
switch (event) {
|
case 'modal-close':
|
this.closeModal()
|
break;
|
}
|
});
|
}
|
|
toggleModal(fieldId, open = true) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
if (open) {
|
this.openModal(fieldId);
|
} else {
|
this.closeModal();
|
}
|
}
|
|
openModal(fieldId) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
|
this.setField(fieldId);
|
this.ui.modal.title.textContent = (field.isFilter) ?`Filter by ${field.singular}` : `Select ${field.plural}`;
|
if (this.ui.search.container) {
|
this.ui.search.container.hidden = !field.canSearch;
|
}
|
if (this.creator) {
|
this.creator.handleOpen(field);
|
}
|
let message = `Opened ${field.singular} selection. Choose from checkboxes, or search to filter results.`;
|
|
window.removeChildren(this.ui.selected);
|
window.removeChildren(this.ui.terms.list);
|
this.modal.handleOpen();
|
|
this.a11y.announce(message);
|
}
|
|
closeModal() {
|
const field = this.fields.get(this.activeField);
|
if (!field) return;
|
this.observer.unobserve(this.ui.terms.sentinel);
|
window.removeChildren(this.ui.terms.list);
|
|
this.notify('selected-terms', {
|
terms: this.selectedTerms.get(this.activeField),
|
taxonomy: field.taxonomy
|
});
|
|
this.activeField = null;
|
|
let message = `Closed ${field.singular} selector.`;
|
this.a11y.announce(message);
|
}
|
|
navigateToParent() {
|
const current = this.store.filters.parent;
|
if (current === 0) return;
|
let term = this.store.get(parseInt(current));
|
if (!term) {
|
this.navigateTo(0);
|
return;
|
}
|
let parent = term.parent;
|
this.navigateTo(parseInt(parent));
|
}
|
navigateTo(termId = 0) {
|
termId = parseInt(termId)??0;
|
this.store.setFilters({parent: termId, page: 1});
|
window.removeChildren(this.ui.terms.list);
|
this.updateBreadcrumbs(termId);
|
}
|
|
nextPage() {
|
let current = this.store.filters.page;
|
let page = Math.min(current++, this.store.lastResponse.total);
|
this.store.setFilters({page:page});
|
}
|
prevPage() {
|
let current = this.store.filters.page;
|
let page = Math.max(current - 1, 1);
|
this.store.setFilters({page:page});
|
}
|
|
addTermToModal(termId) {
|
const term = this.store.get(termId);
|
if (!term) return;
|
const field = this.currentField();
|
if (!field) return;
|
if (this.ui.selected.querySelector(`[data-id="${termId}"]`)) return;
|
|
const item = window.getTemplate('selectedTerm');
|
if (!item) return;
|
|
item.dataset.id = termId;
|
item.dataset.taxonomy = field.taxonomy;
|
item.querySelector('.item-name').textContent = term.path;
|
item.querySelector('button').title = `Remove ${term.name}`;
|
|
this.ui.selected.append(item);
|
}
|
/******************************************************************
|
FIELDS
|
******************************************************************/
|
scanExistingFields(container = document.body) {
|
container.querySelectorAll('[data-type="selector"]').forEach(
|
selector => {
|
try {
|
this.registerField(selector);
|
} catch (error) {
|
this.error.log(error, {
|
component: 'TaxonomySelector',
|
action: 'scanExistingFields',
|
container: selector.dataset.name
|
});
|
}
|
}
|
);
|
}
|
|
registerField(element, options = {}) {
|
let input = element.querySelector('input[type="hidden"]');
|
if (!input && !Object.hasOwn(element.dataset, 'filter')) {
|
console.warn('TaxonomySelector: No hidden input found for field', element);
|
return;
|
}
|
|
if (!('fieldId' in element.dataset)) {
|
element.dataset.fieldId = window.generateID('selector');
|
}
|
const fieldId = element.dataset.fieldId;
|
|
|
let selectors = this.selectors.field;
|
const isFilter = Object.hasOwn(element.dataset,'filter') && element.dataset.filter === 'taxonomy';
|
let button = (isFilter) ? element : element.querySelector('button.taxonomy-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')
|
};
|
if (Object.keys(options).length === 0) return;
|
} else if (Object.hasOwn(options, 'toggle')) {
|
button = document.querySelector(options.toggle);
|
selectors.toggle = options.toggle;
|
}
|
|
const config = {
|
id: fieldId,
|
value: input,
|
element: element,
|
taxonomy: options.taxonomy??false,
|
singular: options.single??'',
|
plural: options.plural??'',
|
name: element.dataset.field,
|
canSearch: options.search??false,
|
limit: options.limit??0,
|
hasAutocomplete: options.autocomplete??false,
|
canCreate: options.creatable??false,
|
isRequired: options.required??false,
|
isFilter: isFilter,
|
toggle: button,
|
create: {
|
button: null,
|
span: null
|
},
|
selectors: selectors,
|
ui: window.uiFromSelectors(selectors, element),
|
checked: false,
|
};
|
|
if (isFilter && !config.ui.toggle) {
|
config.ui.toggle = element;
|
}
|
if (!config.taxonomy) {
|
console.error('TaxonomySelector: Field missing taxonomy', element);
|
return;
|
}
|
if (!config.singular || !config.plural) {
|
console.warn('TaxonomySelector: Field missing singular/plural labels', element);
|
config.singular = config.taxonomy.replace('jvb_', '');
|
config.plural = config.singular + 's';
|
}
|
this.fields.set(fieldId, config);
|
|
//Check for stored selected terms in hidden input
|
this.setSelectedFromValue(fieldId, input);
|
|
|
if (this.isInitializing) {
|
this.batchFetch.add(config.taxonomy);
|
}
|
this.updateFieldUI(fieldId);
|
|
return fieldId;
|
}
|
|
setSelectedFromValue(fieldId, input) {
|
if (!fieldId) return;
|
let field = this.fields.get(fieldId);
|
if (!field) return;
|
if (!input && !field.isFilter) return;
|
|
let selected = new Set();
|
if (input) {
|
input.value.trim()
|
.split(',')
|
.map(id => parseInt(id.trim()))
|
.filter(id => !isNaN(id))
|
.forEach(id => selected.add(id));
|
}
|
this.selectedTerms.set(fieldId, selected);
|
}
|
|
addSelected(termId, fieldId = null) {
|
if (!fieldId) fieldId = this.activeField;
|
|
const field = this.fields.get(fieldId);
|
const term = this.store.get(termId);
|
if (!field || !term) return;
|
|
const selected = this.selectedTerms.get(fieldId);
|
if (field.limit !== 0 && selected.size >= field.limit) return;
|
|
selected.add(parseInt(termId));
|
if (!this.container.open && !field.isFilter) {
|
this.updateFieldValue(fieldId);
|
}
|
this.addTermToDisplay(termId, fieldId);
|
this.checkLimits(fieldId);
|
}
|
removeSelected(termId, fieldId = null) {
|
if (!fieldId) fieldId = this.activeField;
|
const field = this.fields.get(fieldId);
|
const term = this.store.get(termId);
|
if (!field || !term) return;
|
this.selectedTerms.get(fieldId).delete(parseInt(termId));
|
|
const selectedItem = (field.ui.selected) ? field.ui.selected.querySelector(`[data-id="${termId}"]`) : false;
|
if (selectedItem) selectedItem.remove();
|
if (this.container.open) {
|
let item = (this.ui.selected) ? this.ui.selected.querySelector(`[data-id="${termId}"]`) : false;
|
if (item) item.remove();
|
let checkbox = this.ui.terms.list.querySelector(`[type=checkbox][data-id="${termId}"]`);
|
if (checkbox) {
|
checkbox.checked = false;
|
}
|
}
|
if (!this.container.open && !field.isFilter) {
|
this.updateFieldValue(fieldId);
|
}
|
|
this.checkLimits(fieldId);
|
}
|
updateFieldValue(fieldId) {
|
const field = this.fields.get(fieldId);
|
if (!field) return;
|
let selected = Array.from(this.selectedTerms.get(fieldId));
|
field.ui.value.value = selected.join(',');
|
}
|
|
checkLimits(fieldId) {
|
if (!this.container.open) return;
|
const field = this.fields.get(fieldId);
|
if (!field || !field.isFilter || field.limit === 0) return;
|
const disabled = this.selectedTerms.get(fieldId).size >= field.limit;
|
this.setCheckboxes(disabled);
|
}
|
|
updateFieldFromInput(input) {
|
const fieldId = this.getFieldId(input);
|
if (!fieldId) return;
|
const field = this.fields.get(fieldId);
|
if(!field) return;
|
|
this.setSelectedFromValue(fieldId, input);
|
this.updateFieldUI(fieldId);
|
}
|
|
updateFieldUI(fieldId) {
|
const field = this.fields.get(fieldId);
|
let selected = this.selectedTerms.get(fieldId)??new Set();
|
if (!field || field.isFilter || selected.size === 0) return;
|
|
Array.from(selected).forEach(termId => {
|
this.addTermToDisplay(termId, fieldId);
|
});
|
}
|
|
updateFieldsForTaxonomy(taxonomy) {
|
let fields = Array.from(this.fields.values())
|
.filter(field => !field.checked && field.taxonomy === taxonomy);
|
const hasItems = Array.from(this.store.data.values())
|
.some(term=>term.taxonomy === taxonomy);
|
|
fields.forEach(field => {
|
field.toggle.disabled = !hasItems && !field.canCreate;
|
field.toggle.title = !hasItems
|
? `No ${field.singular} available`
|
: `Select ${field.plural}`;
|
|
field.checked = true;
|
});
|
}
|
|
showModalTerms(showPath = false) {
|
const field = this.currentField();
|
const terms = this.store.getFiltered();
|
if (terms.length === 0) {
|
if (this.store.filters.page??1 === 1) {
|
window.removeChildren(this.ui.terms.list);
|
}
|
this.setMessage(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);
|
}
|
}
|
|
this.setCreateButton(true);
|
|
if (this.ui.terms.sentinel) {
|
if (this.store.lastResponse?.has_more) {
|
this.observer.observe(this.ui.terms.sentinel);
|
} else {
|
this.observer.unobserve(this.ui.terms.sentinel);
|
}
|
}
|
|
const currentParent = this.store.filters.parent??0;
|
this.ui.nav.back.hidden = currentParent === 0;
|
|
const fragment = document.createDocumentFragment();
|
terms.forEach(term => {
|
const element = this.createTermElement({
|
show: showPath,
|
... term
|
});
|
if (element) {
|
fragment.append(element);
|
}
|
});
|
|
if (terms.length > 0) {
|
this.setMessage(false);
|
}
|
|
this.ui.terms.list.append(fragment);
|
}
|
createTermElement(term) {
|
if (!term || !term.name) return null;
|
|
const item = window.getTemplate('termListItem');
|
item.dataset.id = term.id;
|
|
const isSelected = this.selectedTerms.get(this.activeField).has(term.id);
|
let [
|
checkbox,
|
label,
|
nameSpan
|
] = [
|
item.querySelector('input'),
|
item.querySelector('label'),
|
item.querySelector('span, .term-name')
|
];
|
|
let field = this.currentField();
|
let limitReached = field.limit > 0 && this.selectedTerms.get(this.activeField).size >= field.limit;
|
if (checkbox && label && nameSpan) {
|
[
|
checkbox.dataset.id,
|
checkbox.id,
|
checkbox.name,
|
checkbox.value,
|
checkbox.disabled,
|
checkbox.checked,
|
label.htmlFor,
|
label.title,
|
label.dataset.path,
|
nameSpan.textContent
|
] = [
|
term.id,
|
`${field.element.id}-${term.id}`,
|
`${field.element.id}-${field.taxonomy}-select`,
|
term.id,
|
!isSelected && limitReached,
|
isSelected,
|
`${field.element.id}-${term.id}`,
|
term.path??term.name,
|
term.path,
|
term.show ? term.path : term.name
|
];
|
if (term.hasChildren) {
|
const toggle = window.getTemplate('termChildrenToggle');
|
if (toggle) {
|
toggle.ariaLabel = `View ${field.plural} nested under ${term.name}`;
|
item.append(toggle);
|
}
|
}
|
}
|
|
return item;
|
}
|
|
showAutocompleteTerms() {
|
const field = this.currentField();
|
const terms = this.currentTerms();
|
if (!field) return;
|
|
const dropdown = field.ui.dropdown.list;
|
if (!dropdown) return;
|
|
window.removeChildren(dropdown);
|
if (terms.length === 0) {
|
this.setMessage(true, `No ${field.plural} found.`, false);
|
} else {
|
terms.forEach(term => {
|
const item = this.createAutocompleteTerm(term);
|
if (item) {
|
dropdown.append(item);
|
}
|
});
|
this.setMessage(false);
|
}
|
this.setCreateButton(true);
|
|
if (field.ui.dropdown.wrapper) {
|
field.ui.dropdown.wrapper.hidden = false;
|
}
|
}
|
createAutocompleteTerm(term) {
|
const item = window.getTemplate('autocompleteItem');
|
if (!item) return;
|
|
item.dataset.id = term.id;
|
item.textContent = term.path || term.name;
|
|
return item;
|
}
|
/******************************************************************
|
UI
|
******************************************************************/
|
addTermToDisplay(termId, fieldId) {
|
const term = this.store.get(termId);
|
const field = this.fields.get(fieldId);
|
if (!term || !field) return;
|
//if the term already exists in the selected items, bail early
|
if (field.ui.selected && field.ui.selected.querySelector(`[data-id="${termId}"]`)) return;
|
|
const item = window.getTemplate('selectedTerm');
|
if (!item) return;
|
|
item.dataset.id = termId;
|
item.dataset.taxonomy = field.taxonomy;
|
item.querySelector('.item-name').textContent = term.path;
|
item.querySelector('button').title = `Remove ${term.name}`;
|
|
if (field.ui.selected) {
|
field.ui.selected.append(item);
|
}
|
|
if (this.container.open) {
|
this.addTermToModal(termId);
|
const checkbox = this.ui.terms.list.querySelector(`input[value="${termId}"]`);
|
if (checkbox) checkbox.checked = true;
|
}
|
}
|
|
updateBreadcrumbs(termId) {
|
const nav = this.ui.nav.nav;
|
if (!nav) return;
|
const existingCrumb = Array.from(nav.children)
|
.find(crumb => parseInt(crumb.dataset.id) === termId);
|
|
if (existingCrumb) {
|
// Remove all siblings after this crumb
|
let nextSibling = existingCrumb.nextElementSibling;
|
while (nextSibling) {
|
const toRemove = nextSibling;
|
nextSibling = nextSibling.nextElementSibling;
|
toRemove.remove();
|
}
|
} else {
|
// Add new breadcrumb
|
const term = this.store.get(termId);
|
if (!term) return;
|
|
const crumb = window.getTemplate('termBreadcrumb');
|
if (!crumb) return;
|
|
crumb.dataset.id = termId;
|
crumb.textContent = term.name;
|
crumb.title = term.name;
|
|
nav.append(crumb);
|
}
|
}
|
|
updateSelectionCount() {
|
if (!this.container.open) return;
|
const field = this.fields.get(this.activeField);
|
if (!field) return;
|
|
if (this.ui.modal.count) {
|
const total = this.selectedTerms.get(this.activeField).size;
|
|
this.ui.modal.count.textContent = field.limit > 0
|
? `${total} of ${field.limit} ${field.plural} selected`
|
: `${total} ${field.plural} selected`;
|
}
|
|
}
|
/******************************************************************
|
UTILITY
|
******************************************************************/
|
currentField() {
|
return this.fields.get(this.activeField)??false;
|
}
|
currentTerms() {
|
return this.store.getFiltered();
|
}
|
needsCreator() {
|
return Array.from(this.fields.values()).some(field =>
|
field.canCreate || field.hasAutocomplete
|
);
|
}
|
|
getFieldId(element) {
|
if (element.dataset.fieldId) return element.dataset.fieldId;
|
|
const fieldContainer = element.closest('[data-field-id]');
|
return fieldContainer?.dataset.fieldId || null;
|
}
|
|
/**
|
* Sets all checkbox disabled (or not)
|
* @param {Boolean} disabled
|
*/
|
setCheckboxes(disabled) {
|
this.ui.terms.list.querySelectorAll('input[type=checkbox]').forEach(checkbox => {
|
if (!checkbox.checked) {
|
checkbox.disabled = disabled;
|
}
|
});
|
}
|
|
/******************************************************************
|
DATASTORE HELPERS
|
******************************************************************/
|
handleStoreEvent(event, data) {
|
const handlers = {
|
'data-loaded': () => this.handleDataLoaded(),
|
'filters-changed': () => this.handleFiltersChanged(data),
|
'fetch-error': () => this.handleFetchError()
|
};
|
|
try {
|
handlers[event]?.(data);
|
} catch (error) {
|
console.error(`Error handling store event "${event}":`, error);
|
this.setMessage(true, 'An error occurred loading data', false);
|
}
|
}
|
handleDataLoaded() {
|
const taxonomy = this.store.filters.taxonomy;
|
|
// Always update fields for loaded taxonomies (handles both single and batch)
|
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;
|
}
|
this.setMessage(false);
|
}
|
|
showResults(isAutoComplete = false) {
|
this.setMessage(false);
|
const terms = this.store.getFiltered();
|
const filters = this.store.filters;
|
const isSearch = filters.search && filters.search.length > 0;
|
|
this.notify('terms-loaded', {
|
terms,
|
filters
|
});
|
|
|
if (isAutoComplete) {
|
this.showAutocompleteTerms();
|
} else {
|
this.showModalTerms(isSearch);
|
}
|
|
|
this.a11y.announce(terms.length);
|
}
|
handleFiltersChanged(data) {
|
//maybe do something?
|
}
|
|
handleFetchError(error) {
|
const field = this.currentField();
|
const message = field
|
? `Failed to load ${field.plural}`
|
: 'Failed to load data';
|
|
this.setMessage(true, message, false);
|
console.error('Store fetch error:', error);
|
}
|
async batchFetchTaxonomies() {
|
if (this.batchFetch.size === 0) return;
|
|
const taxonomies = Array.from(this.batchFetch);
|
this.batchFetch.clear();
|
|
try {
|
await this.store.setFilters({
|
taxonomy: taxonomies.join(','),
|
page: 1,
|
search: '',
|
parent: 0
|
});
|
} catch (error) {
|
console.error('Failed to batch fetch taxonomies:', error);
|
}
|
}
|
|
preloadTaxonomy(taxonomy) {
|
this.store.setFilters( {
|
taxonomy: taxonomy,
|
page: 1,
|
search: '',
|
parent: 0
|
});
|
}
|
|
/**************************************************
|
LOADING
|
**************************************************/
|
setCreateButton(show = true) {
|
const field = this.currentField();
|
if (!field || !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;
|
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 it's autocomplete or the selector's search input, we just need the name
|
if (!this.container.open || this.ui.search.input.value !== '') {
|
data.name = (this.container.open) ? this.ui.search.input.value : field.ui.search.value;
|
} else {
|
//Otherwise, we've created it from the details element
|
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(true, `Creating "${data.name}"...`);
|
this.setCreateButton(false);
|
if (this.container.open) {
|
window.removeChildren(this.ui.terms.list);
|
} else {
|
field.ui.search.disabled = true;
|
window.removeChildren(field.ui.dropdown.list);
|
if (field.ui.dropdown.wrapper) {
|
field.ui.dropdown.wrapper.hidden = false;
|
}
|
}
|
let term = await this.creator.handleTermCreation(data);
|
if (term) {
|
this.addSelected(term.id, field.id);
|
}
|
if (!this.container.open) {
|
field.ui.search.disabled = false;
|
field.ui.search.value = '';
|
}
|
this.scheduleHideDropdown(field.id);
|
this.setMessage(false);
|
}
|
}
|
setMessage(show = true, message = '', type = true) {
|
const field = this.currentField();
|
if (!field) return;
|
|
const conf = this.container.open||field.isFilter ? this.ui : (field.isFilter ? null : field.ui);
|
if (!conf?.message?.message) return;
|
|
message = (message === '') ? `No ${field.plural??'items'} found.` : message;
|
|
const p = conf.message.message;
|
const pText = conf.message.text;
|
|
p.hidden = !show;
|
if (show) {
|
if (message && pText) {
|
if (type && window.typeLoop && pText) {
|
if (this.messageText[field.id]) {
|
this.messageText[field.id]();
|
delete this.messageText[field.id];
|
}
|
this.messageText[field.id] = window.typeLoop(pText, message);
|
} else {
|
pText.textContent = message;
|
}
|
|
}
|
} else {
|
if (this.messageText[field.id]) {
|
this.messageText[field.id]();
|
delete this.messageText[field.id];
|
}
|
}
|
}
|
/**************************************************
|
SUBSCRIBERS
|
**************************************************/
|
subscribe(callback) {
|
this.subscribers.add(callback);
|
return () => this.subscribers.delete(callback);
|
}
|
notify(event, data={}) {
|
this.subscribers.forEach(callback => {
|
try {
|
callback(event, data);
|
} catch (error) {
|
console.error('Subscriber error:', error);
|
}
|
});
|
}
|
/******************************************************
|
CLEANUP
|
******************************************************/
|
destroy() {
|
// Cancel all debounced operations for this instance
|
this.fields.forEach((field, fieldId) => {
|
window.debouncer.cancel(`${fieldId}-search`);
|
window.debouncer.cancel(`${fieldId}-search-results`);
|
});
|
|
// Stop any typeLoop animations
|
Object.keys(this.messageText).forEach(key => {
|
if (this.messageText[key]) {
|
this.messageText[key]();
|
}
|
});
|
this.messageText = {};
|
|
// Disconnect observer
|
if (this.ui.terms?.sentinel) {
|
this.observer?.unobserve(this.ui.terms.sentinel);
|
}
|
this.observer?.disconnect();
|
|
// Remove event listeners
|
document.removeEventListener('click', this.clickHandler);
|
document.removeEventListener('change', this.changeHandler);
|
document.removeEventListener('input', this.inputHandler);
|
document.removeEventListener('focus', this.focusHandler, true);
|
document.removeEventListener('blur', this.blurHandler, true);
|
|
// Clear data structures
|
this.subscribers.clear();
|
this.fields.clear();
|
this.selectedTerms.clear();
|
this.batchFetch.clear();
|
|
// Cleanup creator if exists
|
if (this.creator) {
|
this.creator.destroy();
|
this.creator = null;
|
}
|
|
// Unsubscribe from store
|
if (this.store) {
|
this.store = null;
|
}
|
}
|
}
|
|
document.addEventListener('DOMContentLoaded', function() {
|
window.auth.subscribe((event) => {
|
if (event === 'auth-loaded') {
|
window.jvbSelector = new TaxonomySelector();
|
}
|
});
|
});
|