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