From 56a9a1ccf764ff7a6af8f8a2292cb07443cb4aa7 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Thu, 28 May 2026 18:19:57 +0000
Subject: [PATCH] =New Gitbit setpu
---
assets/js/concise/TaxonomySelector.js | 2554 +++++++++++++++++++++++++++-------------------------------
1 files changed, 1,188 insertions(+), 1,366 deletions(-)
diff --git a/assets/js/concise/TaxonomySelector.js b/assets/js/concise/TaxonomySelector.js
index 032c749..90f6076 100644
--- a/assets/js/concise/TaxonomySelector.js
+++ b/assets/js/concise/TaxonomySelector.js
@@ -1,35 +1,54 @@
-/**
- * Centralized Taxonomy Selector with DataStore Integration
- * Handles all taxonomy selection fields using DataStore for state management
- */
+
class TaxonomySelector {
constructor() {
+ this.container = document.querySelector('dialog#jvb-selector');
+ if (!this.container) return;
+
this.a11y = window.jvbA11y;
this.error = window.jvbError;
- this.index = -1;
-
- this.hasAutocomplete = false;
- this.isInitializing = true;
- this.taxonomiesToFetch = new Set();
-
- this.triggers = new Set(['.taxonomy-toggle']);
this.subscribers = new Set();
+ this.fields = new Map();
+ this.selectedTerms = new Map(); // a map of fieldId => Set of selected term Ids
+ this.batchFetch = new Set();
+ this.activeField = null;
+ this.isInitializing = true;
+ this.lazyInit = false;
+ this.messageText = {}
+ this.init();
+ }
+
+ init() {
+ this.initStore();
+ this.initElements();
+ this.defineTemplates();
+ this.initModal();
+ this.scanExistingFields();
+ this.initListeners();
+
+ if (this.needsCreator() && window.jvbTaxCreator) {
+ this.creator = new window.jvbTaxCreator(this);
+ }
+ this.isInitializing = false
+ this.batchFetchTaxonomies().then(()=> {});
+ }
+
+ initStore() {
const store = window.jvbStore.register(
'taxonomies',
{
- storeName: `terms`,
+ storeName: 'terms',
keyPath: 'id',
showLoading: false,
indexes: [
{name: 'taxonomy', keyPath: 'taxonomy'},
{name: 'parent', keyPath: 'parent'},
- {name: 'slug', keyPath: 'slug', unique: true},
+ {name: 'slug', keyPath: 'slug'},
{name: 'count', keyPath: 'count'},
],
endpoint: 'terms',
- TTL: 2 * 60 * 1000, //2 hours
+ TTL: 2 * 60 * 1000,
filters: {
taxonomy: '',
page: 1,
@@ -38,1486 +57,1264 @@
},
required: 'taxonomy',
delayFetch: true,
- });
+ }
+ );
this.store = store.terms;
- // Central field management
- this.fields = new Map();
- this.selectedTerms = new Map(); // Current modal selection
-
- // Current modal context
- this.activeField = null;
- this.currentConfig = null;
- this.currentSingular = null;
- this.currentPlural = null;
-
- // Modal state
- this.disabled = false;
-
- // Search debouncing
- this.searchHandler = null;
- this.autocompleteHandler = null;
- this.isAutocompleteActive = false;
-
- this.init();
- }
-
- /**
- * Initialize the selector
- */
- init() {
- this.initModal();
- this.scanExistingFields();
- this.initGlobalListeners();
-
- if (this.hasAutocomplete && window.jvbTaxCreator) {
- this.creator = new window.jvbTaxCreator(this);
- }
this.store.subscribe(this.handleStoreEvent.bind(this));
- // Complete initialization
- this.isInitializing = false;
- this.batchFetchTaxonomies();
}
- /**
- * Handle DataStore events
- */
- handleStoreEvent(event, data) {
- switch (event) {
- case 'data-loaded':
- const taxonomy = this.store.filters.taxonomy;
- // Handle batch taxonomy loading (comma-separated)
- if (taxonomy?.includes(',')) {
- this.handleBatchDataLoaded(taxonomy, data);
- }
- // Update button states for this taxonomy (or taxonomies)
- if (taxonomy) {
- // Handle comma-separated taxonomies from batch fetch
- const taxonomies = taxonomy.includes(',')
- ? taxonomy.split(',').map(t => t.trim())
- : [taxonomy];
+ defineTemplates() {
+ const T = window.jvbTemplates;
+ const terms = this;
- taxonomies.forEach(tax => {
- this.updateFieldsForTaxonomy(tax);
- });
- }
-
- // Only render if modal is open OR autocomplete active
- if (this.modal?.open) {
- this.handleTermsLoaded(data);
- }
-
- if (this.isAutocompleteActive && this.activeField) {
- const field = this.fields.get(this.activeField);
- const terms = data.data?.items || [];
- const query = data.filters?.search || '';
- this.showAutocompleteResults(field, terms, query);
- this.isAutocompleteActive = false;
- }
- break;
-
- case 'filters-changed':
- if (this.modal?.open) {
- this.showLoading();
- }
- break;
-
- case 'fetch-error':
- if (this.isAutocompleteActive && this.activeField) {
- this.showAutocompleteError(this.activeField);
- this.isAutocompleteActive = false;
- }
- this.handleFetchError(data.error);
- break;
- }
- }
-
- /**
- * Handle loaded terms from DataStore
- */
- handleTermsLoaded(data) {
- this.hideLoading();
- const terms = this.store.getFiltered(); // Use getFiltered() instead of getFilteredItems()
- const response = this.store.lastResponse?.page || {};
- const isSearch = data.filters?.search && data.filters.search.length > 0;
- const append = response.page > 1;
-
- this.notify('terms-loaded', { terms, filters: data.filters });
-
- if (terms.length === 0) {
- if (!append) {
- this.showEmptyState(isSearch ? 'No results found.' : 'No items available.');
+ T.define('emptyState');
+ T.define('selectedTerm', {
+ refs: {
+ name: '.item-name',
+ btn: 'button',
+ },
+ setup({el, refs, manyRefs, data}) {
+ el.dataset.id = data.id;
+ el.dataset.taxonomy = data.taxonomy;
+ if (refs.name) refs.name.textContent = data.path;
+ if (refs.button) refs.button.title = `Remove ${data.name}`;
}
- this.observer.unobserve(this.ui.sentinel);
- } else {
- this.renderTerms(terms, append, isSearch);
+ });
+ T.define('termListItem', {
+ refs: {
+ checkbox: 'input',
+ label: 'label',
+ name: 'span, .term-name'
+ },
+ setup({el, refs, manyRefs, data}) {
+ el.dataset.id = data.id;
- // Handle pagination
- if (response.has_more) {
- this.observer.observe(this.ui.sentinel);
- } else {
- this.observer.unobserve(this.ui.sentinel);
+ let field = terms.currentField();
+ let isSelected = terms.selectedTerms.get(terms.activeField).has(data.id);
+ let limitReached = field.limit > 0 && terms.selectedTerms.get(terms.activeField).size >= field.limit;
+
+ if (refs.checkbox) {
+ refs.checkbox.dataset.id = data.id;
+ refs.checkbox.id = `${field.id}-${data.id}`;
+ refs.checkbox.name = `${field.id}-${field.taxonomy}-select`;
+ refs.checkbox.value = data.id;
+ refs.checkbox.disabled = !isSelected && limitReached;
+ refs.checkbox.checked = isSelected;
+ }
+ if (refs.label) {
+ refs.label.htmlFor = `${field.id}-${data.id}`;
+ refs.label.title = data.path??data.name;
+ refs.label.dataset.path = data.path;
+ }
+ if (refs.name) {
+ refs.name.textContent = data.show ? data.path : data.name;
+ }
+
+ if (data.hasChildren) {
+ let temp = {
+ plural: field.plural,
+ name: data.name
+ };
+ const toggle = window.jvbTemplates.create('termChildrenToggle', temp);
+ el.append(toggle);
+ }
+ }
+ });
+
+ T.define('termChildrenToggle', {
+ setup({el, refs, manyRefs, data}) {
+ el.ariaLabel = `View ${data.plural} nested under ${data.name}`;
+ }
+ });
+
+ T.define('termBreadcrumb', {
+ setup({el, refs, manyRefs, data}) {
+ el.dataset.id = data.id;
+ el.textContent = data.name;
+ el.title = data.name;
+ }
+ });
+
+ T.define('autocompleteItem', {
+ setup({el, refs, manyRefs, data}) {
+ el.dataset.id = data.id;
+ el.textContent = data.path||data.name;
+ el.title = `Select ${data.name}`;
+ }
+ });
+
+
+ }
+ /******************************************************************
+ ELEMENTS
+ ******************************************************************/
+ initElements() {
+ this.selectors = {
+ search: {
+ input: '[type="search"]',
+ clear: '.clear-search',
+ container: '.search-wrapper',
+ results: '.search-results',
+ },
+ create: {
+ button: 'button.submit-term',
+ span: '.submit-term span',
+ },
+ terms: {
+ list: '.items-container',
+ wrap: '.items-wrap',
+ sentinel: '.scroll-sentinel',
+ },
+ nav: {
+ nav: 'nav.term-navigation',
+ back: '.back-to-parent',
+ child: '.toggle-children',
+ pathLevel: '.path-level',
+ },
+ message: {
+ message: 'p.message',
+ text: 'p.message span',
+ },
+ selected: '.selected-items',
+ modal: {
+ title: '#modal-title',
+ content: '.modal-content',
+ count: '.selection-count'
+ },
+ favourites: '.favourite-terms',
+ field: {
+ toggle: 'button.selector-toggle, [data-filter="taxonomy"]',
+ value: 'input[type="hidden"]',
+ selected: '.selected-items',
+ dropdown: {
+ list: '.search-results',
+ wrapper: '.auto-wrapper',
+ },
+ create: {
+ button: '.auto-wrapper .submit-term',
+ span: '.auto-wrapper button span',
+ },
+ search: 'input[data-autocomplete]',
+ message: {
+ message: 'p.message',
+ text: 'p.message span',
+ },
}
}
- // Announce to screen readers
- this.a11y?.announce(terms.length, append);
+ this.ui = window.uiFromSelectors(this.selectors, this.container);
}
- /**
- * Handle fetch errors
- */
- handleFetchError(error) {
- console.error('Taxonomy fetch error:', error);
- this.hideLoading();
+ initListeners() {
+ this.observer = new IntersectionObserver((entries) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ this.nextPage();
+ }
+ });
+ }, {
+ root: this.ui.terms.sentinel,
+ threshold: 0.5
+ });
- if (this.error?.log) {
- this.error.log(error, {
- component: 'TaxonomySelector',
- action: 'fetchTerms'
- }, () => this.fetchCurrentTerms());
+ this.clickHandler = this.handleClick.bind(this);
+ this.changeHandler = this.handleChange.bind(this);
+ this.inputHandler = this.handleInput.bind(this);
+ this.focusHandler = this.handleFocus.bind(this);
+ this.blurHandler = this.handleBlur.bind(this);
+
+ document.addEventListener('click', this.clickHandler);
+ document.addEventListener('change', this.changeHandler);
+ document.addEventListener('input', this.inputHandler);
+ document.addEventListener('focus', this.focusHandler, true);
+ document.addEventListener('blur', this.blurHandler, true);
+ }
+
+ handleClick(e) {
+ if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) {
+ return;
+ }
+ const fieldId = this.getFieldId(e.target) || this.activeField;
+ const field = this.fields.get(fieldId);
+ if (!fieldId || !field) return;
+
+ if (this.creator) {
+ let button = window.targetCheck(e, this.selectors.create.button);
+ if (button) {
+ this.maybeCreateTerm(e).then(()=>{});
+ }
+ }
+
+ const removeButton = window.targetCheck(e, '.remove-term');
+ if (removeButton) {
+ const termId = removeButton.closest('[data-id]').dataset.id??false;
+ if (fieldId && termId) {
+ this.removeSelected(parseInt(termId), fieldId);
+ }
+ return;
+ }
+
+ const autocomplete = window.targetCheck(e, '.item.autocomplete');
+
+ if (autocomplete) {
+ let termId = parseInt(autocomplete.dataset.id);
+ this.addSelected(termId, fieldId);
+ this.scheduleHideDropdown(fieldId, 6000);
+ if (field.ui.search) {
+ field.ui.search.value = '';
+ }
+ return;
+ }
+
+ const toggleButton = window.targetCheck(e, this.selectors.field.toggle);
+
+ if (toggleButton) {
+ e.preventDefault();
+ this.openModal(fieldId);
+ return;
+ }
+
+
+ if (e.target.matches('.modal-close')) {
+ this.updateFieldValue(fieldId);
+ this.modal?.handleClose();
+ return;
+ }
+
+ const backToParent = window.targetCheck(e, this.selectors.nav.back);
+ if (backToParent) {
+ this.navigateToParent();
+ return;
+ }
+
+ const toChild = window.targetCheck(e, this.selectors.nav.child);
+ if (toChild) {
+ const termItem = e.target.closest('li');
+ const termId = parseInt(termItem.dataset.id);
+
+ if (termId) {
+ this.navigateTo(termId);
+ }
+ return;
+ }
+
+ const pathLevel = window.targetCheck(e, this.selectors.nav.pathLevel);
+ if (pathLevel) {
+ const termId = parseInt(pathLevel.dataset.id)??0;
+ this.navigateTo(termId);
+ return;
+ }
+
+ const dropdown = window.targetCheck(e, this.selectors.field.dropdown);
+ if (dropdown) {
+ // reset the timer for hiding the dropdown
+ this.scheduleHideDropdown(fieldId);
+ return;
+ }
+
+ const clearSearch = window.targetCheck(e, this.selectors.search.clear);
+ if (clearSearch) {
+ const field = this.currentField();
+ if (field && field.ui.search) {
+ field.ui.search.value = '';
+ this.store.setFilters({
+ search: '',
+ page: 1,
+ parent: this.store.filters.parent || 0
+ });
+ }
+ if (this.ui.search.input) {
+ this.ui.search.input.value = '';
+ }
+ }
+ }
+ handleChange(e) {
+ if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) {
+ return;
+ }
+ if (!['checkbox', 'button'].includes(e.target.type)) return;
+ e.preventDefault();
+ e.stopPropagation();
+
+ const termId = parseInt(e.target.dataset.id);
+ let fieldId = this.getFieldId(e.target);
+ if (e.target.checked) {
+ this.addSelected(termId, fieldId);
} else {
- this.showEmptyState('Error loading terms. Please try again.');
+ this.removeSelected(termId, fieldId);
+ }
+ }
+ //For search in modal or field autocomplete
+ handleInput(e) {
+ if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) {
+ return;
+ }
+ let fieldId = this.getFieldId(e.target)??this.activeField;
+ if (!fieldId) return;
+ const field = this.fields.get(fieldId);
+ if (!field) return;
+ if (['checkbox', 'button'].includes(e.target.type)) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ //If it's the autocomplete field, we need to set the active field
+ if (!this.container.open) {
+ this.setField(fieldId);
+ }
+
+ let query = e.target.value.trim();
+ this.setMessage(field,true, `Searching for "${query}" in ${field.plural??'items'}`);
+ window.debouncer.schedule(
+ `${fieldId}-search`,
+ async () => {
+ if (this.container.open) {
+ window.removeChildren(this.ui.terms.list);
+ }
+ await this.store.setFilters({
+ taxonomy: field.taxonomy,
+ search: query,
+ page: 1,
+ parent: query ? 0 : (this.store.filters.parent || 0)
+ });
+ },
+ 100
+ );
+ }
+
+ setField(fieldId) {
+ const field = this.fields.get(fieldId);
+ if (!field) {
+ console.error('No field found...');
+ return;
+ }
+ this.activeField = fieldId;
+ this.setMessage(field,true, `Loading ${field.plural}...`);
+ this.resetFilters({taxonomy: field.taxonomy});
+ }
+
+ resetFilters(filters) {
+ if (!Object.hasOwn(filters, 'taxonomy')) {
+ return;
+ }
+ filters = {
+ page: 1,
+ search: '',
+ parent: 0,
+ ... filters
+ };
+ this.store.setFilters(filters);
+ }
+
+ handleFocus(e) {
+ if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) {
+ return;
+ }
+ const fieldId = this.getFieldId(e.target);
+ if (!fieldId) return;
+ const field = this.fields.get(fieldId);
+ if (!field) return;
+ if (!field.hasAutocomplete && !field.hasSearch) return;
+
+ window.debouncer.cancel(`${fieldId}-search-results`);
+
+ if (!this.container.open){
+ this.setField(fieldId);
}
}
+ //Hide autocomplete dropdown on blur
+ handleBlur(e) {
+ if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) {
+ return;
+ }
+ const fieldId = this.getFieldId(e.target);
+ if (!fieldId) return;
+ const field = this.fields.get(fieldId);
+ if (!field) return;
+ if (!field.hasAutocomplete || this.container.open) return;
+ if (e.target.closest('.remove-item')) return;
- /**
- * Check if taxonomy has terms and update button states
- */
- updateFieldButtonState(fieldId) {
+ if (e.relatedTarget && field.ui.dropdown.wrapper?.contains(e.relatedTarget)) return;
+
+ this.scheduleHideDropdown(fieldId);
+ }
+
+ scheduleHideDropdown(fieldId, delay = 1500){
const field = this.fields.get(fieldId);
if (!field) return;
- // Check store for items of this specific taxonomy
- const hasTerms = Array.from(this.store.data.values())
- .some(term => term.taxonomy === field.taxonomy);
-
- if (field.toggle) {
- field.toggle.disabled = !hasTerms && !field.canCreate;
- field.toggle.title = !hasTerms
- ? `No ${this.getSingular(field.taxonomy)} available`
- : `Select ${this.getPlural(field.taxonomy)}`;
- }
- }
- /**
- * Update fields when taxonomy items are updated
- */
- updateFieldsForTaxonomy(taxonomy) {
- this.getFieldsForTaxonomy(taxonomy).forEach(field => {
- this.updateFieldButtonState(field.id);
- });
+ window.debouncer.schedule(
+ `${fieldId}-search-results`,
+ () => {
+ if (!this.container.open) {
+ this.activeField = null;
+ }
+ if (field.ui.dropdown.wrapper) {
+ field.ui.dropdown.wrapper.hidden = true;
+ }
+ },
+ delay
+ );
}
- /**
- * Get fields for a specific taxonomy
- */
- getFieldsForTaxonomy(taxonomy) {
- return Array.from(this.fields.values())
- .filter(field => field.taxonomy === taxonomy);
- }
+ /******************************************************************
+ MODAL
+ ******************************************************************/
+ initModal() {
+ this.modalID = 'dialog#jvb-selector';
+ this.container = document.querySelector(this.modalID);
-
-
- /**
- * Scan page for existing taxonomy fields and register them
- */
- scanExistingFields(container = null) {
- if (!container) {
- container = document.body;
- }
- const selectors = container.querySelectorAll('.field.taxonomy, .field.post');
-
- selectors.forEach(selector => {
- try {
- this.registerField(selector);
- } catch (error) {
- this.error.log(error, {
- component: 'TaxonomySelector',
- action: 'scanExistingFields',
- container: selector.dataset.name
- });
+ this.modal = new window.jvbModal(
+ this.container,
+ {
+ handleForm: false,
+ open: null
+ }
+ );
+ this.modal.subscribe((event, data) => {
+ switch (event) {
+ case 'modal-close':
+ this.closeModal()
+ break;
}
});
}
- /**
- * Register a taxonomy field
- */
- registerField(field, options = {}) {
- let input = field.querySelector('input[type=hidden]');
- if (!input) {
- return false;
- }
- if (!('fieldId' in field.dataset)) {
- field.dataset.fieldId = this.createFieldId(field);
- }
- let fieldId = field.dataset.fieldId;
+ toggleModal(fieldId, open = true) {
+ const field = this.fields.get(fieldId);
+ if (!field) return;
- let button = (Object.hasOwn(options, 'button')) ? options.button : field.querySelector('button.taxonomy-toggle');
+ if (open) {
+ this.openModal(fieldId);
+ } else {
+ this.closeModal();
+ }
+ }
- if (Object.hasOwn(options, 'buttonSelector')) {
- this.triggers.add(options.buttonSelector);
+ openModal(fieldId) {
+ const field = this.fields.get(fieldId);
+ if (!field) return;
+
+ this.setField(fieldId);
+ this.ui.modal.title.textContent = (field.isFilter) ?`Filter by ${field.singular}` : `Select ${field.plural}`;
+ if (this.ui.search.container) {
+ this.ui.search.container.hidden = !field.canSearch;
+ }
+ if (this.creator) {
+ this.creator.handleOpen(field);
+ }
+ let message = `Opened ${field.singular} selection. Choose from checkboxes, or search to filter results.`;
+
+ window.removeChildren(this.ui.selected);
+ window.removeChildren(this.ui.terms.list);
+ this.modal.handleOpen();
+
+ this.a11y.announce(message);
+ }
+
+ openEmpty(taxonomy, singular, plural, onComplete) {
+ // Store the callback for when modal closes
+ this.emptyCallback = onComplete;
+
+ // Create a temporary "field" for bulk operations
+ const bulkFieldId = `empty-${taxonomy}-${Date.now()}`;
+
+ if (!this.fields.has(bulkFieldId)) {
+ this.fields.set(bulkFieldId, {
+ id: bulkFieldId,
+ taxonomy: taxonomy,
+ singular: singular,
+ plural: plural,
+ canSearch: true,
+ canCreate: false,
+ hasAutocomplete: false,
+ isFilter: false,
+ isEmpty: true,
+ limit: 0,
+ ui: {},
+ element: null,
+ value: null,
+ toggle: null,
+ checked: true
+ });
+ this.selectedTerms.set(bulkFieldId, new Set());
}
- let config = {
- id: fieldId,
- input: input,
- container: field,
- taxonomy: button.dataset.taxonomy,
- name: field.dataset.field,
- maxSelection: parseInt(button.dataset.max) || 0,
- canSearch: 'search' in button.dataset,
- hasAutocomplete: 'autocomplete' in button.dataset,
- autocompleteDropdown: field.querySelector('.autocomplete-dropdown')??false,
- canCreate: 'creatable' in button.dataset,
- isRequired: 'required' in button.dataset,
- selectedTerms: new Set(),
- toggle: button,
- selectedContainer: (Object.hasOwn(options, 'selected')) ? options.selected : field.querySelector('.selected-items'),
- ...options
- };
+ this.setField(bulkFieldId);
+ this.ui.modal.title.textContent = `Add to ${plural}`;
- if (!this.hasAutocomplete && config.hasAutocomplete) {
- this.hasAutocomplete = true;
- this.initAutocomplete();
+ if (this.ui.search?.container) {
+ this.ui.search.container.hidden = false;
}
- // Parse initial selected values
- const value = input.value.trim();
- if (value !== '') {
- const selectedIds = value.split(',')
- .map(id => parseInt(id.trim()))
- .filter(id => !isNaN(id));
- selectedIds.forEach(id => config.selectedTerms.add(id));
- }
+ window.removeChildren(this.ui.selected);
+ window.removeChildren(this.ui.terms.list);
- if (Object.hasOwn(options, 'selectedItems')) {
- options.selectedItems.forEach(id => {
- config.selectedTerms.add(id);
+ this.modal.handleOpen();
+ }
+
+ closeModal() {
+ const field = this.fields.get(this.activeField);
+ if (!field) return;
+
+
+ this.updateFieldValue(this.activeField);
+
+ this.observer.unobserve(this.ui.terms.sentinel);
+ window.removeChildren(this.ui.terms.list);
+
+ if (field.isEmpty && this.emptyCallback) {
+ const selectedTermIds = Array.from(this.selectedTerms.get(this.activeField) || []);
+ const selectedTerms = selectedTermIds.map(id => this.store.get(id)).filter(Boolean);
+
+ this.emptyCallback({
+ taxonomy: field.taxonomy,
+ termIds: selectedTermIds,
+ terms: selectedTerms
+ });
+
+ // Cleanup temporary bulk field
+ this.fields.delete(this.activeField);
+ this.selectedTerms.delete(this.activeField);
+ this.emptyCallback = null;
+ this.bulkAssignmentTaxonomy = null;
+ } else {
+ this.notify('selected-terms', {
+ terms: this.selectedTerms.get(this.activeField),
+ taxonomy: field.taxonomy
});
}
- this.fields.set(fieldId, config);
+ this.activeField = null;
- // Ensure store exists for this taxonomy
- if (this.isInitializing) {
- this.taxonomiesToFetch.add(config.taxonomy);
- } else {
- // this.store.setFilter('taxonomy', config.taxonomy);
- }
-
- // Initialize display for any pre-selected values
- if (config.selectedTerms.size > 0) {
- this.initFieldDisplay(fieldId);
- }
-
- return fieldId;
+ let message = `Closed ${field.singular} selector.`;
+ this.a11y.announce(message);
}
- /**
- * Register a filter button (simplified registration for feed blocks)
- */
- registerFilterButton(button, options = {}) {
- const fieldId = this.createFieldId(button);
- button.dataset.fieldId = fieldId;
+ navigateToParent() {
+ const current = this.store.filters.parent;
+ if (current === 0) return;
+ let term = this.store.get(parseInt(current));
+ if (!term) {
+ this.navigateTo(0);
+ return;
+ }
+ let parent = term.parent;
+ this.navigateTo(parseInt(parent));
+ }
+ navigateTo(termId = 0) {
+ termId = parseInt(termId)??0;
+ this.store.setFilters({parent: termId, page: 1});
+ window.removeChildren(this.ui.terms.list);
+ this.updateBreadcrumbs(termId);
+ }
- if (options.buttonSelector) {
- this.triggers.add(options.buttonSelector);
+ nextPage() {
+ let current = this.store.filters.page;
+ let page = Math.min(current++, this.store.lastResponse.total);
+ this.store.setFilters({page:page});
+ }
+ prevPage() {
+ let current = this.store.filters.page;
+ let page = Math.max(current - 1, 1);
+ this.store.setFilters({page:page});
+ }
+
+ addTermToModal(termId) {
+ const term = this.store.get(termId);
+ if (!term) return;
+ const field = this.currentField();
+ if (!field) return;
+ if (this.ui.selected.querySelector(`[data-id="${termId}"]`)) return;
+
+ this.ui.selected.append(this.getSelectedTermUI(term));
+ }
+
+ getSelectedTermUI(term, showPath = true) {
+ return window.jvbTemplates.create('selectedTerm', term);
+ }
+ /******************************************************************
+ FIELDS
+ ******************************************************************/
+ scanExistingFields(container = document.body) {
+ container.querySelectorAll('[data-type="selector"], [data-field-type="selector"]').forEach(
+ selector => {
+ try {
+ if (selector.dataset.lazy) {
+ this.lazyInit = true;
+ } else {
+ // Register field if not already registered
+ // registerField will check if already registered and return early if so
+ this.registerField(selector);
+ }
+ } catch (error) {
+ this.error.log(error, {
+ component: 'TaxonomySelector',
+ action: 'scanExistingFields',
+ container: selector.dataset.name
+ });
+ }
+ }
+ );
+ if (this.lazyInit) {
+ this.initObserver(container);
+ }
+ }
+
+ unregisterFields(container) {
+ container.querySelectorAll('[data-type="selector"],[data-field-type="selector"]').forEach(
+ selector=> {
+ this.fields.delete(selector.dataset.fieldId);
+ }
+ );
+ }
+ initObserver(container){
+ this.lazyObserver = new IntersectionObserver((entries) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting && entry.target.dataset.lazy) {
+ delete entry.target.dataset.lazy;
+ this.registerField(entry.target);
+ this.lazyObserver.unobserve(entry.target);
+ }
+ });
+ }, {rootMargin: '50px'});
+
+ container.querySelectorAll('[data-type="selector"][data-lazy], [data-field-type="selector"][data-lazy]').forEach(field => {
+ this.lazyObserver.observe(field);
+ });
+ }
+
+ registerField(element, options = {}) {
+ if (element.dataset.fieldId && this.fields.has(element.dataset.fieldId)) {
+ return element.dataset.fieldId; // Already registered
+ }
+
+ let input = element.querySelector('input[type="hidden"]');
+ if (!input && !Object.hasOwn(element.dataset, 'filter')) {
+ return;
+ }
+
+ if (!('fieldId' in element.dataset)) {
+ element.dataset.fieldId = window.generateID('selector');
+ }
+ const fieldId = element.dataset.fieldId;
+
+
+ let selectors = this.selectors.field;
+ const isFilter = Object.hasOwn(element.dataset,'filter') && element.dataset.filter === 'taxonomy';
+ let button = (isFilter) ? element : element.querySelector('button.selector-toggle');
+
+ if (Object.keys(options).length === 0){
+ if (!button) return;
+ options = {
+ taxonomy: button.dataset.taxonomy,
+ single: button.dataset.single,
+ plural: button.dataset.plural,
+ search: Object.hasOwn(button.dataset, 'search'),
+ autocomplete: Object.hasOwn(button.dataset, 'autocomplete'),
+ creatable: Object.hasOwn(button.dataset, 'creatable')
+ };
+ } else if (Object.hasOwn(options, 'toggle')) {
+ button = document.querySelector(options.toggle);
+ selectors.toggle = options.toggle;
}
const config = {
id: fieldId,
- input: null,
- container: options.container || button.closest('.filters') || button.parentElement,
- taxonomy: button.dataset.taxonomy,
- name: `filter_${button.dataset.taxonomy}`,
- maxSelection: parseInt(button.dataset.max) || 0,
- canSearch: 'search' in button.dataset,
- hasAutocomplete: false,
- canCreate: false,
- isRequired: false,
- selectedTerms: new Set(options.selectedItems || []),
+ value: input,
+ element: element,
+ taxonomy: options.taxonomy??false,
+ singular: options.single??'',
+ plural: options.plural??'',
+ name: element.dataset.field,
+ canSearch: options.search??false,
+ limit: options.limit??0,
+ hasAutocomplete: options.autocomplete??false,
+ canCreate: options.creatable??false,
+ isRequired: options.required??false,
+ isFilter: isFilter,
toggle: button,
- selectedContainer: options.selected || null,
- isFilterMode: true,
- ...options
+ create: {
+ button: null,
+ span: null
+ },
+ selectors: selectors,
+ ui: window.uiFromSelectors(selectors, element),
+ checked: false,
};
+ if (isFilter && !config.ui.toggle) {
+ config.ui.toggle = element;
+ }
+ if (!config.taxonomy) {
+ console.error('TaxonomySelector: Field missing taxonomy', element);
+ return;
+ }
+ if (!config.singular || !config.plural) {
+ console.warn('TaxonomySelector: Field missing singular/plural labels', element);
+ config.singular = config.taxonomy.replace('jvb_', '');
+ config.plural = config.singular + 's';
+ }
this.fields.set(fieldId, config);
+ //Check for stored selected terms in hidden input
+ this.setSelectedFromValue(fieldId, input);
+
+
if (this.isInitializing) {
- this.taxonomiesToFetch.add(config.taxonomy);
+ this.batchFetch.add(config.taxonomy);
+ }
+
+ if (element.offsetParent !== null) {
+ this.updateFieldUI(fieldId);
} else {
- this.store.setFilter('taxonomy', config.taxonomy);
+ // Defer until visible
+ requestIdleCallback(() => {
+ if (element.offsetParent !== null) {
+ this.updateFieldUI(fieldId);
+ }
+ }, {timeout: 2000});
+
}
return fieldId;
}
- /**
- * Create unique field ID
- */
- createFieldId(field) {
- this.index++;
- return 'selector-' + this.index;
+ setSelectedFromValue(fieldId, input) {
+ if (!fieldId) return;
+ let field = this.fields.get(fieldId);
+ if (!field) return;
+ if (!input && !field.isFilter) return;
+
+ let selected = new Set();
+ if (input) {
+ input.value.trim()
+ .split(',')
+ .map(id => parseInt(id.trim()))
+ .filter(id => !isNaN(id))
+ .forEach(id => selected.add(id));
+ }
+ this.selectedTerms.set(fieldId, selected);
}
- /**
- * Initialize display for a field with existing values
- */
- async initFieldDisplay(fieldId) {
+ addSelected(termId, fieldId = null) {
+ if (!fieldId) fieldId = this.activeField;
+
const field = this.fields.get(fieldId);
- if (!field || field.selectedTerms.size === 0) return;
+ const term = this.store.get(termId);
+ if (!field || !term) return;
- const selectedIds = Array.from(field.selectedTerms);
+ const selected = this.selectedTerms.get(fieldId);
+ if (field.limit !== 0 && selected.size >= field.limit) return;
- selectedIds.forEach(termId => {
- const term = this.store.get(termId); // Changed from getItem
- if (term) {
- this.addTermToDisplay(fieldId, term.id, term.name, term.path);
+ selected.add(parseInt(termId));
+ if (!this.container.open && !field.isFilter) {
+ this.updateFieldValue(fieldId);
+ }
+ this.addTermToDisplay(termId, fieldId);
+ this.checkLimits(fieldId);
+ }
+ removeSelected(termId, fieldId = null) {
+ if (!fieldId) fieldId = this.activeField;
+ const field = this.fields.get(fieldId);
+ const term = this.store.get(termId);
+ if (!field || !term) return;
+ this.selectedTerms.get(fieldId).delete(parseInt(termId));
+
+ const selectedItem = (field.ui.selected) ? field.ui.selected.querySelector(`[data-id="${termId}"]`) : false;
+ if (selectedItem) selectedItem.remove();
+ if (this.container.open) {
+ let item = (this.ui.selected) ? this.ui.selected.querySelector(`[data-id="${termId}"]`) : false;
+ if (item) item.remove();
+ let checkbox = this.ui.terms.list.querySelector(`[type=checkbox][data-id="${termId}"]`);
+ if (checkbox) {
+ checkbox.checked = false;
}
+ }
+ if (!this.container.open && !field.isFilter) {
+ this.updateFieldValue(fieldId);
+ }
+
+ this.checkLimits(fieldId);
+ }
+ updateFieldValue(fieldId) {
+ const field = this.fields.get(fieldId);
+ if (!field) return;
+ let selected = Array.from(this.selectedTerms.get(fieldId));
+ if (field.ui.value) {
+ field.ui.value.value = selected.join(',')??'';
+ field.ui.value.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+ }
+
+ checkLimits(fieldId) {
+ if (!this.container.open) return;
+ const field = this.fields.get(fieldId);
+ if (!field || !field.isFilter || field.limit === 0) return;
+ const disabled = this.selectedTerms.get(fieldId).size >= field.limit;
+ this.setCheckboxes(disabled);
+ }
+
+ updateFieldFromInput(input) {
+ const fieldId = this.getFieldId(input);
+ if (!fieldId) return;
+ const field = this.fields.get(fieldId);
+ if(!field) return;
+
+ this.setSelectedFromValue(fieldId, input);
+ this.updateFieldUI(fieldId);
+ }
+
+ updateFieldUI(fieldId) {
+ const field = this.fields.get(fieldId);
+ let selected = this.selectedTerms.get(fieldId)??new Set();
+ if (!field || field.isFilter || selected.size === 0) return;
+
+ Array.from(selected).forEach(termId => {
+ this.addTermToDisplay(termId, fieldId);
});
}
- /**
- * Initialize modal elements
- */
- initModal() {
- this.modalID = 'dialog#jvb-selector';
- this.modal = document.querySelector(this.modalID);
+ updateFieldsForTaxonomy(taxonomy) {
+ let fields = Array.from(this.fields.values())
+ .filter(field => field.taxonomy === taxonomy);
+ const hasItems = Array.from(this.store.data.values())
+ .some(term => term && term.taxonomy === taxonomy);
- if (!this.modal) {
- console.warn('Taxonomy selector modal not found');
- return;
- }
+ fields.forEach(field => {
+ if (!field.toggle) return;
+ field.toggle.disabled = !hasItems && !field.canCreate;
+ field.toggle.title = !hasItems
+ ? `No ${field.singular} available`
+ : `Select ${field.plural}`;
- this.initModalElements();
-
- // Initialize modal instance
- this.modalInstance = new window.jvbModal(this.modal, {
- handleForm: false,
- save: null,
- open: null
- });
- this.modalInstance.subscribe((event, data) => {
- switch (event) {
- case 'modal-open':
- this.openModal(data);
- break;
- case 'modal-close':
- this.closeModal(data);
- break;
- }
+ field.checked = true;
});
}
- /**
- * Initialize modal element references
- */
- initModalElements() {
- this.selectors = {
- search: {
- input: '[type=search]',
- clear: '.clear-search',
- container: '.search-wrapper'
- },
- termsList: '.items-container',
- termsWrap: '.items-wrap',
- breadcrumbs: {
- nav: 'nav.term-navigation',
- back: '.back-to-parent',
- },
- loading: {
- loading: '.loading',
- text: '.loading span'
- },
- selectedTerms: '.selected-items',
- sentinel: '.scroll-sentinel',
- modal: {
- title: '#modal-title',
- content: '.modal-content'
- },
- create: {
- details: '.create-new-term',
- parent: '#select_parent',
- summary: '.create-new-term summary',
- name: '#term_name',
- button: '.submit-term',
- label: {
- name: '[for=term_name]',
- parent: '[for=select_parent]'
- }
- },
- favouriteTerms: '.favourite-terms'
- }
-
- this.ui = window.uiFromSelectors(this.selectors);
-
- // Initialize intersection observer for infinite scroll
- this.observer = new IntersectionObserver((entries) => {
- entries.forEach(entry => {
- if (entry.isIntersecting) {
- this.loadMoreTerms();
- }
- });
- }, {
- root: this.ui.termsWrap,
- threshold: 0.5
- });
- }
-
- /**
- * Set up global event delegation
- */
- initGlobalListeners() {
- document.addEventListener('click', this.handleClick.bind(this));
- document.addEventListener('change', this.handleChange.bind(this));
- if (this.hasAutocomplete) {
- this.initAutocomplete();
- }
- }
-
- initAutocomplete()
- {
- this.autocompleteHandler = (e) => {
- window.debouncer.schedule(
- 'taxonomy-autocomplete',
- () => this.handleAutocomplete(e),
- 300
- );
- };
- document.addEventListener('input', this.autocompleteHandler);
- document.addEventListener('blur', this.cleanupAutocomplete.bind(this));
- // Preload taxonomy data on focus
- document.addEventListener('focus', (e) => {
- if (!('autocomplete' in e.target.dataset)) {
- return;
+ showModalTerms(showPath = false) {
+ const field = this.currentField();
+ const terms = this.store.getFiltered();
+ if (terms.length === 0) {
+ if (this.store.filters.page??1 === 1) {
+ window.removeChildren(this.ui.terms.list);
}
-
- const fieldId = this.getFieldId(e.target);
- const field = this.fields.get(fieldId);
-
- if (!field) return;
-
- // Preload this taxonomy's data
- this.preloadTaxonomy(field.taxonomy);
- }, true); // Use capture phase
- }
-
- /**
- * Handle global click events
- */
- handleClick(e) {
- // Handle taxonomy toggle buttons
- const toggleButton = window.targetCheck(e, Array.from(this.triggers));
-
- if (toggleButton) {
- e.preventDefault();
- this.handleToggleClick(toggleButton);
- return;
- }
-
- // Handle remove selected term buttons
- const removeButton = window.targetCheck(e, 'button.remove-item');
- if (removeButton && e.target.closest('.jvb-selector')) {
- const fieldId = this.getFieldId(removeButton);
- const termId = removeButton.closest('.selected-item').dataset.id;
- this.removeSelectedTerm(fieldId, termId);
- return;
- }
-
- // Handle modal close button
- if (e.target.matches('.modal-close')) {
- if (this.modalInstance) {
- this.modalInstance.handleClose();
+ this.setMessage(field,true, this.store.filters.search === ''
+ ? `No matching ${field.plural}.`
+ : `No ${field.plural} found.`,
+ false);
+ if (this.ui.terms.sentinel) {
+ this.observer.unobserve(this.ui.terms.sentinel);
}
return;
}
- // Handle clicks within the modal
- if (this.modal && this.modal.contains(e.target)) {
- this.handleModalClick(e);
- }
- }
+ this.setCreateButton(field,true);
- /**
- * Handle global change events
- */
- handleChange(e) {
- // Handle hidden input changes for taxonomy fields
- const taxonomyField = window.targetCheck(e, '.taxonomy.field, .post.field');
- if (taxonomyField && e.target.type === 'hidden') {
- const fieldId = this.getFieldId(e.target);
- this.updateFieldFromInput(fieldId);
- return;
- }
-
- // Handle modal changes
- if (this.modal && this.modal.contains(e.target)) {
- this.handleModalChange(e);
- }
- }
-
- /**
- * Handle toggle button click
- */
- handleToggleClick(toggle) {
- try {
- const fieldId = this.getFieldId(toggle);
- const field = this.fields.get(fieldId);
-
- if (!field) {
- console.error('Field not found for toggle:', fieldId);
- return;
- }
-
-
- this.setActiveField(fieldId, true);
-
- } catch (error) {
- console.error('Error handling toggle click:', error);
- if (this.error?.log) {
- this.error.log(error, {
- component: 'TaxonomySelector',
- action: 'handleToggleClick'
- });
+ if (this.ui.terms.sentinel) {
+ if (this.store.lastResponse?.has_more) {
+ this.observer.observe(this.ui.terms.sentinel);
+ } else {
+ this.observer.unobserve(this.ui.terms.sentinel);
}
}
+
+ const currentParent = this.store.filters.parent??0;
+ this.ui.nav.back.hidden = currentParent === 0;
+
+ window.chunkIt(
+ terms,
+ (term) => this.createTermElement({show:showPath, ... term}),
+ (fragment) => this.ui.terms.list.append(fragment),
+ 10
+ ).then(()=>{});
+
+ if (terms.length > 0) {
+ this.setMessage(field,false);
+ }
+ }
+ createTermElement(term) {
+ if (!term || !term.name) return null;
+ return window.jvbTemplates.create('termListItem', term);
}
- /**
- * Set the active field for modal operations
- */
- setActiveField(fieldId, openModal = false) {
- this.activeField = fieldId;
- this.currentConfig = this.fields.get(fieldId);
+ showAutocompleteTerms() {
+ const field = this.currentField();
+ if (!field || !field.hasAutocomplete || !field.ui.dropdown?.list) return;
+ const dropdown = field.ui.dropdown.list;
+ const terms = this.currentTerms();
- this.currentSingular = this.getSingular(this.currentConfig.taxonomy);
- this.currentPlural = this.getPlural(this.currentConfig.taxonomy);
+ window.removeChildren(dropdown);
+ if (terms.length === 0) {
+ this.setMessage(field,true, `No ${field.plural} found.`, false);
+ } else {
+ window.chunkIt(
+ terms,
+ (term) => this.createAutocompleteTerm(term),
+ (fragment) => dropdown.append(fragment)
+ ).then(()=>{});
- if (openModal) {
- this.modalInstance.handleOpen();
+ this.setMessage(field,false);
+ }
+ this.setCreateButton(field,true);
+
+ if (field.ui.dropdown.wrapper) {
+ field.ui.dropdown.wrapper.hidden = false;
+ }
+ }
+
+ createAutocompleteTerm(term) {
+ return window.jvbTemplates.create('autocompleteItem', term);
+ }
+ /******************************************************************
+ UI
+ ******************************************************************/
+ addTermToDisplay(termId, fieldId) {
+ const term = this.store.get(termId);
+ const field = this.fields.get(fieldId);
+ if (!term || !field) return;
+
+ //if the term already exists in the selected items, bail early
+ if (field.ui.selected && field.ui.selected.querySelector(`[data-id="${termId}"]`)) return;
+
+
+ let item = this.getSelectedTermUI(term);
+
+ if (field.ui.selected) {
+ field.ui.selected.append(item);
}
- // Set taxonomy filter - store handles the rest
- this.store.setFilter('taxonomy', this.currentConfig.taxonomy);
+ if (this.container.open) {
+ this.addTermToModal(termId);
+ const checkbox = this.ui.terms.list.querySelector(`input[value="${termId}"]`);
+ if (checkbox) checkbox.checked = true;
+ }
+ }
- // Clear modal selection state
- this.selectedTerms.clear();
+ updateBreadcrumbs(termId) {
+ const nav = this.ui.nav.nav;
+ if (!nav) return;
+ const existingCrumb = Array.from(nav.children)
+ .find(crumb => parseInt(crumb.dataset.id) === termId);
- // Copy field's current selections to modal state
- this.currentConfig.selectedTerms.forEach(termId => {
+ if (existingCrumb) {
+ // Remove all siblings after this crumb
+ let nextSibling = existingCrumb.nextElementSibling;
+ while (nextSibling) {
+ const toRemove = nextSibling;
+ nextSibling = nextSibling.nextElementSibling;
+ toRemove.remove();
+ }
+ } else {
+ // Add new breadcrumb
const term = this.store.get(termId);
- if (term) {
- this.selectedTerms.set(termId, {
- id: termId,
- name: term.name,
- path: term.path
- });
- }
- });
- }
+ if (!term) return;
+ const crumb = window.jvbTemplates.create('termBreadcrumb', term);
-
- /**
- * Handle clicks within modal
- */
- handleModalClick(e) {
- if (window.targetCheck(e, '.remove-item')) {
- let selectedItem = window.targetCheck(e, '.selected-item');
- if (selectedItem) {
- this.removeSelectedTermFromModal(selectedItem.dataset.id);
- }
- } else if (window.targetCheck(e, '.back-to-parent')) {
- this.navigateToParent();
- } else if (window.targetCheck(e, '.toggle-children')) {
- let termItem = e.target.closest('li');
- this.navigateToChild(
- parseInt(termItem.dataset.id),
- termItem.querySelector('.term-name').textContent
- );
- } else if (window.targetCheck(e, '.path-level')) {
- let pathLevel = window.targetCheck(e, '.path-level');
- this.navigateToPath(pathLevel);
+ nav.append(crumb);
}
}
- /**
- * Handle changes within modal (checkboxes)
- */
- handleModalChange(e) {
- if (window.targetCheck(e, this.modalID) && e.target.type === 'checkbox') {
- e.preventDefault();
- e.stopPropagation();
-
- const termId = parseInt(e.target.closest('li').dataset.id);
- const label = e.target.closest('li').querySelector('label');
-
- if (e.target.checked) {
- this.addSelectedTermToModal(termId, label.title, label.dataset.path);
- } else {
- this.removeSelectedTermFromModal(termId);
- }
- }
- }
-
- /**
- * Open modal for filtering (without a field)
- * @param {string} taxonomy - The taxonomy to filter by
- * @param {Function} callback - Callback when terms are selected
- * @param {Array} preselected - Array of term IDs already selected
- */
- openForFilter(taxonomy, callback, preselected = []) {
- // Create a temporary virtual field config
- const virtualFieldId = `filter-${taxonomy}-${Date.now()}`;
-
- this.fields.set(virtualFieldId, {
- id: virtualFieldId,
- input: null, // No input for filter mode
- container: null,
- taxonomy: taxonomy,
- name: `filter_${taxonomy}`,
- maxSelection: 0, // No limit for filters
- canSearch: true,
- hasAutocomplete: false,
- autocompleteDropdown: document.querySelector('.autocomplete-dropdown')??false,
- canCreate: false, // Disable creation for filters
- isRequired: false,
- selectedTerms: new Set(preselected),
- toggle: null,
- selectedContainer: null,
- isFilterMode: true, // Flag for filter mode
- filterCallback: callback // Store the callback
- });
-
- this.setActiveField(virtualFieldId, true);
- this.modalInstance.handleOpen();
- }
-
- /**
- * Open modal and initialize
- */
- openModal() {
- if (!this.currentConfig) {
- console.error('No active field set');
- return;
- }
-
- // Initialize creator if available
- if (!this.creator && this.currentConfig.canCreate && 'jvbTaxCreator' in window) {
- this.creator = new window.jvbTaxCreator(this);
- }
-
- // Update modal UI
- this.updateModalForTaxonomy();
-
- // Load selected terms display
- this.updateModalSelections();
- this.updateSelectionCount();
-
- // Clear terms list and show loading
- window.removeChildren(this.ui.termsList);
- this.showLoading();
- }
-
- /**
- * Update selection count display in modal
- */
updateSelectionCount() {
- if (!this.currentConfig) return;
+ if (!this.container.open) return;
+ const field = this.fields.get(this.activeField);
+ if (!field) return;
- const count = this.selectedTerms.size;
- const max = this.currentConfig.maxSelection;
+ if (this.ui.modal.count) {
+ const total = this.selectedTerms.get(this.activeField).size;
- // Update any count display elements
- const countElement = this.modal?.querySelector('.selection-count');
- if (countElement) {
- if (max > 0) {
- countElement.textContent = `${count} of ${max} selected`;
- } else {
- countElement.textContent = `${count} selected`;
- }
- }
- }
-
-
-
- /**
- * Get singular label for taxonomy
- */
- getSingular(taxonomy) {
- return jvbSettings.labels[taxonomy]?.single || taxonomy;
- }
-
- /**
- * Get plural label for taxonomy
- */
- getPlural(taxonomy) {
- return jvbSettings.labels[taxonomy]?.plural || taxonomy;
- }
-
- /**
- * Close modal and save selections
- */
- closeModal() {
- this.observer.unobserve(this.ui.sentinel);
- window.removeChildren(this.ui.termsList);
-
- this.notify('selected-terms', {
- terms: this.selectedTerms,
- taxonomy: this.currentConfig.taxonomy
- });
-
- if (this.currentConfig?.isFilterMode) {
- if (this.currentConfig.filterCallback) {
- const selectedIds = Array.from(this.selectedTerms.keys());
- this.currentConfig.filterCallback(selectedIds, this.currentConfig.taxonomy);
- }
- // this.fields.delete(this.activeField);
- } else if (this.activeField) {
- this.saveSelectionsToField(this.activeField);
+ this.ui.modal.count.textContent = field.limit > 0
+ ? `${total} of ${field.limit} ${field.plural} selected`
+ : `${total} ${field.plural} selected`;
}
- // Cleanup
- if (this.currentConfig?.canSearch && this.searchHandler) {
- this.ui.search.input.removeEventListener('input', this.searchHandler);
+ }
+ /******************************************************************
+ UTILITY
+ ******************************************************************/
+ checkRendered(collection, term) {
+ if (!collection) return;
+ if (!Object.hasOwn(collection, term.taxonomy)) {
+ collection[term.taxonomy] = new Map();
}
+ return collection[term.taxonomy].has(term.id);
+ }
+ currentField() {
+ return this.fields.get(this.activeField)??false;
+ }
+ currentTerms() {
+ return this.store.getFiltered();
+ }
+ needsCreator() {
+ return Array.from(this.fields.values()).some(field =>
+ field.canCreate || field.hasAutocomplete
+ );
+ }
- if (!this.hasAutocomplete && this.creator) {
- delete this.creator;
- }
+ getFieldId(element) {
+ if (element.dataset.fieldId) return element.dataset.fieldId;
- // Remove: this.activeStore = null;
- this.activeField = null;
- this.currentConfig = null;
+ const fieldContainer = element.closest('[data-field-id]');
+ return fieldContainer?.dataset.fieldId || null;
}
/**
- * Reset modal state
- */
- resetModalState() {
- this.disabled = false;
-
- window.removeChildren(this.ui.termsList);
- window.removeChildren(this.ui.selectedTerms);
- this.ui.search.input.value = '';
-
- // Clear navigation breadcrumbs
- window.removeChildren(this.ui.breadcrumbs.nav);
- this.ui.breadcrumbs.nav.appendChild(this.ui.breadcrumbs.back);
- this.ui.breadcrumbs.back.hidden = true;
- }
-
- /**
- * Update modal content for current taxonomy
- */
- updateModalForTaxonomy() {
- if (!this.currentConfig) return;
-
- this.ui.modal.title.textContent = `Select ${this.currentPlural}`;
-
- if (this.ui.search.container) {
- this.ui.search.container.style.display = this.currentConfig.canSearch ? 'block' : 'none';
- }
-
- if (this.ui.create.details) {
- this.ui.create.details.style.display = this.currentConfig.canCreate ? 'block' : 'none';
- this.ui.create.details.hidden = !this.currentConfig.canCreate;
-
- if (this.ui.create.summary) {
- this.ui.create.summary.textContent = `Add new ${this.currentSingular}`;
- }
-
- if (this.ui.create.label.name) {
- this.ui.create.label.name.textContent = `Name this ${this.currentSingular}`;
- }
- if (this.ui.create.label.parent) {
- this.ui.create.label.parent.textContent = `Nest it under`;
- }
-
- if (this.ui.create.parent) {
-
- }
- }
-
- const openMessage = `Opened ${this.currentSingular} selection. Choose from checkboxes or search to filter results.`;
- this.a11y?.announce(openMessage);
- }
-
- /**
- * Update modal selections display
- */
- updateModalSelections() {
- window.removeChildren(this.ui.selectedTerms);
-
- this.selectedTerms.forEach((termData, id) => {
- this.addTermToModalDisplay(id, termData.name, termData.path);
- });
-
- this.checkSelectionLimits();
- }
-
- /**
- * Add selected term to modal
- */
- addSelectedTermToModal(id, name, path) {
- this.selectedTerms.set(id, {
- id: id,
- name: name,
- path: path
- });
-
- this.addTermToModalDisplay(id, name, path);
- this.checkSelectionLimits();
-
- // Check the corresponding checkbox
- const checkbox = this.ui.termsList.querySelector(`input[value="${id}"]`);
- if (checkbox) {
- checkbox.checked = true;
- }
- }
-
- /**
- * Remove selected term from modal
- */
- removeSelectedTermFromModal(id) {
- this.selectedTerms.delete(parseInt(id));
-
- // Remove from modal display
- const selectedItem = this.ui.selectedTerms.querySelector(`[data-id="${id}"]`);
- if (selectedItem) {
- selectedItem.remove();
- }
-
- // Uncheck the corresponding checkbox
- const checkbox = this.ui.termsList.querySelector(`input[value="${id}"]`);
- if (checkbox) {
- checkbox.checked = false;
- }
-
- this.checkSelectionLimits();
- }
-
- /**
- * Add term to modal display
- */
- addTermToModalDisplay(id, name, path) {
- const item = window.getTemplate('selectedTerm').cloneNode(true);
- item.dataset.id = id;
- item.dataset.path = path;
- item.dataset.name = name;
- item.dataset.taxonomy = this.currentConfig.taxonomy;
- item.querySelector('span').textContent = path;
- item.querySelector('button').title = `Remove ${name}`;
-
- this.ui.selectedTerms.appendChild(item);
- }
-
- /**
- * Check selection limits and disable/enable checkboxes
- */
- checkSelectionLimits() {
- if (!this.currentConfig || this.currentConfig.maxSelection === 0) {
- return;
- }
-
- this.disabled = this.selectedTerms.size >= this.currentConfig.maxSelection;
- this.setCheckboxes(this.disabled);
- }
-
- /**
- * Set checkbox disabled state
+ * Sets all checkbox disabled (or not)
+ * @param {Boolean} disabled
*/
setCheckboxes(disabled) {
- this.ui.termsList.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
+ this.ui.terms.list.querySelectorAll('input[type=checkbox]').forEach(checkbox => {
if (!checkbox.checked) {
checkbox.disabled = disabled;
}
});
}
- /**
- * Save modal selections to field
- */
- saveSelectionsToField(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field) return;
+ /******************************************************************
+ DATASTORE HELPERS
+ ******************************************************************/
+ handleStoreEvent(event, data) {
+ const handlers = {
+ 'data-loaded': () => this.handleDataLoaded(),
+ 'filters-changed': () => this.handleFiltersChanged(data),
+ 'fetch-error': () => this.handleFetchError()
+ };
- // Clear current field selections
- field.selectedTerms.clear();
- window.removeChildren(field.selectedContainer);
+ try {
+ handlers[event]?.(data);
+ } catch (error) {
+ console.error(`Error handling store event "${event}":`, error);
+ }
+ }
+ handleDataLoaded() {
+ const taxonomy = this.store.filters.taxonomy;
- // Add modal selections to field
- this.selectedTerms.forEach((termData, id) => {
- field.selectedTerms.add(id);
- this.addTermToDisplay(fieldId, id, termData.name, termData.path);
+ if (taxonomy) {
+ const taxonomies = taxonomy.split(',').map(t => t.trim());
+ taxonomies.forEach(tax => this.updateFieldsForTaxonomy(tax));
+ }
+
+ if (this.container.open) {
+ this.showResults();
+ return;
+ }
+ if (this.activeField) {
+ this.showResults(true);
+ return;
+ }
+ }
+
+ showResults(isAutoComplete = false) {
+ const terms = this.store.getFiltered();
+ const filters = this.store.filters;
+ const isSearch = filters.search && filters.search.length > 0;
+
+ this.notify('terms-loaded', {
+ terms,
+ filters
});
- // Update hidden input
- const selectedIds = Array.from(field.selectedTerms);
- field.input.value = selectedIds.join(',');
- field.input.dispatchEvent(new Event('change', { bubbles: true }));
- }
-
- /**
- * Remove selected term from field
- */
- removeSelectedTerm(fieldId, termId) {
- const field = this.fields.get(fieldId);
- if (!field) return;
-
- const id = parseInt(termId);
- field.selectedTerms.delete(id);
-
- // Remove from display
- const selectedItem = field.selectedContainer.querySelector(`[data-id="${id}"]`);
- if (selectedItem) {
- selectedItem.remove();
- }
-
- // Update hidden input
- const selectedIds = Array.from(field.selectedTerms);
- field.input.value = selectedIds.join(',');
- field.input.dispatchEvent(new Event('change', { bubbles: true }));
- }
-
- /**
- * Add term to field display
- */
- addTermToDisplay(fieldId, id, name, path) {
- const field = this.fields.get(fieldId);
- if (!field || field.selectedContainer.querySelector(`[data-id="${id}"]`)) {
- return; // Already displayed
- }
-
- const item = window.getTemplate('selectedTerm').cloneNode(true);
- item.dataset.id = id;
- item.dataset.path = path;
- item.dataset.name = name;
- item.dataset.taxonomy = field.taxonomy;
- item.querySelector('span').textContent = path;
- item.querySelector('button').title = `Remove ${name}`;
-
- field.selectedContainer.appendChild(item);
- }
-
- /**
- * Update field from hidden input value
- */
- updateFieldFromInput(fieldId) {
- const field = this.fields.get(fieldId);
- if (!field) return;
-
- const value = field.input.value.trim();
- field.selectedTerms.clear();
- window.removeChildren(field.selectedContainer);
-
- if (value !== '') {
- const selectedIds = value.split(',')
- .map(id => parseInt(id.trim()))
- .filter(id => !isNaN(id));
-
- selectedIds.forEach(id => field.selectedTerms.add(id));
- this.initFieldDisplay(fieldId);
- }
- }
-
- /**
- * Handle search input
- */
- handleSearch(e) {
- const query = e.target.value.trim();
-
- // Clear existing debounce
- if (this.searchHandler) {
- clearTimeout(this.searchHandler);
- }
-
- this.searchHandler = setTimeout(() => {
- // Single call - auto-fetches
- this.store.setFilters({
- search: query,
- page: 1,
- parent: query ? 0 : (this.store.filters.parent || 0)
- });
-
- window.removeChildren(this.ui.termsList);
- }, 300);
- }
-
- async handleAutocomplete(e) {
- if (!('autocomplete' in e.target.dataset)) {
+ if (!this.activeField && isAutoComplete) {
return;
}
-
- const fieldId = this.getFieldId(e.target);
- const field = this.fields.get(fieldId);
-
- if (!field) return;
-
- // Store current value immediately (fixes fast typing issue)
- const query = e.target.value.trim();
- field.currentAutocompleteQuery = query;
-
- if (query.length < 2) {
- if (field.autocompleteDropdown) {
- field.autocompleteDropdown.hidden = true;
- }
- this.isAutocompleteActive = false;
- return;
- }
-
- this.activeField = fieldId;
- this.isAutocompleteActive = true;
-
- if (field.autocompleteDropdown) {
- field.autocompleteDropdown.hidden = false;
- }
-
- this.store.setFilters({
- taxonomy: field.taxonomy,
- search: query,
- page: 1
- });
- }
-
- cleanupAutocomplete(e) {
- if (!('autocomplete' in e.target.dataset)) {
- return;
- }
-
- const fieldId = this.getFieldId(e.target);
- const field = this.fields.get(fieldId);
-
- if (!field) return;
-
- if (this.creator) {
- delete this.creator;
- }
- }
-
- showAutocompleteError(fieldId) {
-
- const field = this.fields.get(fieldId);
- if (!field) {
- return;
- }
- if (!field.config.autocompleteDropdown) {
- field.config.autocompleteDropdown = field.element.querySelector('.autocomplete-dropdown');
- }
- const dropdown = field.config.autocompleteDropdown;
- if (dropdown) {
- window.removeChildren(dropdown);
- this.showEmptyState('Hmmm... something went wrong', dropdown);
- }
- }
-
- showAutocompleteResults(field, terms, query) {
- if (!field || !field.autocompleteDropdown) {
- return;
- }
-
- const dropdown = field.autocompleteDropdown;
- window.removeChildren(dropdown);
-
- if (terms.length === 0) {
- this.showEmptyState('No items found.', dropdown);
+ this.setMessage(this.currentField(), false);
+ if (isAutoComplete) {
+ this.showAutocompleteTerms();
} else {
- terms.forEach(term => {
- const element = this.createAutocompleteTermElement(field, term);
- if (element) {
- dropdown.appendChild(element);
+ this.showModalTerms(isSearch);
+ }
+
+
+ this.a11y.announce(terms.length);
+ }
+ handleFiltersChanged(data) {
+ //maybe do something?
+ }
+
+ handleFetchError(error) {
+ const field = this.currentField();
+ this.setMessage(field, true, 'Something went wrong.', false);
+
+ const conf = this.container.open || field?.isFilter ? this.ui : field?.ui;
+ const p = conf?.message?.message;
+
+ if (p && !p.querySelector('.clear-cache-btn')) {
+ const btn = document.createElement('button');
+ btn.className = 'clear-cache-btn';
+ btn.type = 'button';
+ btn.textContent = 'Clear cache and try again';
+ btn.addEventListener('click', async () => {
+ btn.remove();
+ this.store.clearCache();
+ if (this.activeField && field) {
+ await this.store.setFilters({
+ taxonomy: field.taxonomy,
+ page: 1,
+ search: '',
+ parent: 0
+ });
}
});
+ p.appendChild(btn);
}
- // Use stored current query instead of debounced one
- const currentQuery = field.currentAutocompleteQuery || query;
- if (field.canCreate && currentQuery && window.jvbTaxCreator) {
- const createOption = this.createNewTermOption(currentQuery);
- dropdown.appendChild(createOption);
- }
-
- dropdown.hidden = false;
+ console.error('Store fetch error:', error);
}
-
- createNewTermOption(query) {
- const button = document.createElement('button');
- button.type = 'button';
- button.className = 'autocomplete-item create-term';
- button.dataset.query = query;
- button.innerHTML = `<strong>Create:</strong> "${query}"`;
-
- return button;
- }
-
- createAutocompleteTermElement(field, term) {
- const item = document.createElement('button');
- item.type = 'button';
- item.className = 'autocomplete-item';
- item.dataset.id = term.id;
- item.dataset.name = term.name;
- item.dataset.path = term.path || term.name;
- item.textContent = term.path || term.name;
-
- item.addEventListener('click', () => {
- // Add term to field
- field.selectedTerms.add(parseInt(term.id));
- this.addTermToDisplay(field.id, term.id, term.name, term.path);
-
- // Update input
- field.input.value = Array.from(field.selectedTerms).join(',');
- field.input.dispatchEvent(new Event('change', { bubbles: true }));
-
- // Clear and hide dropdown
- field.autocompleteDropdown.hidden = true;
- const input = field.container.querySelector('input[data-autocomplete]');
- if (input) input.value = '';
- });
-
- return item;
- }
-
- /**
- * Navigate to parent term
- */
- navigateToParent() {
- // Store handles fetch automatically
- this.store.setFilters({
- parent: 0,
- page: 1
- });
-
- window.removeChildren(this.ui.termsList);
- this.ui.breadcrumbs.back.hidden = true;
- }
-
- /**
- * Navigate to child term
- */
- navigateToChild(termId, termName) {
- // Store handles fetch automatically
- this.store.setFilters({
- parent: termId,
- page: 1
- });
-
- window.removeChildren(this.ui.termsList);
- this.updateBreadcrumbs(termId, termName);
- this.ui.breadcrumbs.back.hidden = false;
- }
-
- /**
- * Navigate to specific path level
- */
- navigateToPath(pathLevel) {
- const parentId = parseInt(pathLevel.dataset.id) || 0;
-
- // Store handles fetch automatically
- this.store.setFilters({
- parent: parentId,
- page: 1
- });
-
- window.removeChildren(this.ui.termsList);
- this.ui.breadcrumbs.back.hidden = parentId === 0;
- }
-
- /**
- * Load more terms (pagination)
- */
- loadMoreTerms() {
- const currentPage = this.store.filters.page || 1;
- this.store.setFilter('page', currentPage + 1);
- }
-
- /**
- * Render terms list
- */
- renderTerms(terms = null, append = false, showPath = false) {
- // If no terms provided, get from store
- if (!terms) {
- terms = this.store.getFiltered();
- }
-
- if (!append) {
- window.removeChildren(this.ui.termsList);
- }
-
- if (terms.length === 0) {
- if (!append) {
- this.showEmptyState();
- }
- return;
- }
-
- const currentParent = this.store.filters.parent || 0;
- this.ui.breadcrumbs.back.hidden = currentParent === 0;
-
- const fragment = document.createDocumentFragment();
- terms.forEach(term => {
- const element = this.createTermElement({
- id: parseInt(term.id),
- name: term.name,
- hasChildren: term.hasChildren,
- path: term.path || null,
- show: showPath
- });
-
- if (element) {
- fragment.appendChild(element);
- }
- });
-
- this.ui.termsList.appendChild(fragment);
- }
-
- /**
- * Create individual term element
- */
- createTermElement(termData) {
- if (!termData || !termData.name) return null;
-
- const listItem = window.getTemplate('termListItem').cloneNode(true);
- listItem.dataset.id = termData.id;
-
- const isSelected = this.selectedTerms.has(termData.id);
- const checkbox = listItem.querySelector('input');
- const label = listItem.querySelector('label');
- const nameSpan = listItem.querySelector('span, .term-name');
-
- if (checkbox && label && nameSpan) {
- checkbox.id = `${this.currentConfig.container.id}${termData.id}`;
- checkbox.name = `${this.currentConfig.container.id}${this.currentConfig.taxonomy}-select`;
- checkbox.value = termData.id;
- checkbox.disabled = !isSelected && this.disabled;
- checkbox.checked = isSelected;
-
- label.htmlFor = checkbox.id;
- label.title = termData.path || termData.name;
- label.dataset.path = termData.path;
-
- nameSpan.textContent = termData.show ? termData.path : termData.name;
- }
-
- if (termData.hasChildren) {
- const childrenToggle = window.getTemplate ?
- window.getTemplate('termChildrenToggle') :
- this.createChildrenToggle();
-
- if (childrenToggle) {
- childrenToggle.ariaLabel = `View sub-terms of ${termData.name}`;
- listItem.appendChild(childrenToggle);
- }
- }
-
- return listItem;
- }
-
- /**
- * Create children toggle button
- */
- createChildrenToggle() {
- const button = document.createElement('button');
- button.type = 'button';
- button.className = 'toggle-children';
- button.innerHTML = '→';
- return button;
- }
-
- /**
- * Update breadcrumb navigation
- */
- updateBreadcrumbs(termId, termName) {
- // This is a simplified version - you'd want to maintain a proper breadcrumb trail
- const breadcrumb = window.getTemplate('termBreadcrumb').cloneNode(true);
- breadcrumb.dataset.id = termId;
- breadcrumb.textContent = termName;
- breadcrumb.title = termName;
-
- // Remove any existing breadcrumbs after this level
- const existingCrumb = this.ui.breadcrumbs.nav.querySelector(`[data-id="${termId}"]`);
- if (existingCrumb) {
- // Remove all breadcrumbs after this one
- while (existingCrumb.nextElementSibling) {
- existingCrumb.nextElementSibling.remove();
- }
- } else {
- this.ui.breadcrumbs.nav.appendChild(breadcrumb);
- }
- }
-
- /**
- * Show loading state
- */
- showLoading() {
- this.ui.loading.loading.hidden = false;
- this.modal.classList.add('loading');
-
- const searchQuery = this.store?.filters?.search || '';
- const currentParent = this.store?.filters?.parent || 0;
-
- let message = searchQuery !== '' ?
- `searching for "${searchQuery}" items` :
- currentParent === 0 ?
- 'loading items' :
- `loading child items`;
-
- if (window.typeLoop) {
- this.stopTyping = window.typeLoop(this.ui.loading.text, message);
- } else {
- this.ui.loading.text.textContent = message;
- }
- }
-
- /**
- * Hide loading state
- */
- hideLoading() {
- this.ui.loading.loading.hidden = true;
- this.modal.classList.remove('loading');
-
- if (this.stopTyping) {
- this.stopTyping();
- }
- }
-
- /**
- * Show empty state message
- */
- showEmptyState(message = 'No items found.', container = null) {
- if (!container) {
- container = this.ui.termsList;
- }
- const emptyElement = window.getTemplate('noResults').cloneNode(true);
-
- if (message && emptyElement.querySelector('span')) {
- emptyElement.querySelector('span').textContent = message;
- }
-
- container.appendChild(emptyElement);
- }
-
- /**
- * Get field ID from any element within the field
- */
- getFieldId(element) {
- if (element.dataset.fieldId) {
- return element.dataset.fieldId;
- }
-
- const fieldContainer = element.closest('[data-field-id]');
- if (fieldContainer) {
- return fieldContainer.dataset.fieldId;
- }
-
- return null;
- }
- /********************************************
- BATCH FETCH: fetches first page for all taxonomies in one call
- ********************************************/
async batchFetchTaxonomies() {
- if (this.taxonomiesToFetch.size === 0) return;
-
- const taxonomies = Array.from(this.taxonomiesToFetch);
- this.taxonomiesToFetch.clear();
-
- // Single fetch - the data-loaded event will handle cache splitting
- this.store.setFilters({
- taxonomy: taxonomies.join(','),
- page: 1,
- search: '',
- parent: 0
- });
- }
- handleBatchDataLoaded(taxonomyString, data) {
- const taxonomies = taxonomyString.split(',').map(t => t.trim());
- const storeInstance = this.store.getStore(); // Access actual store instance
-
- taxonomies.forEach(taxonomy => {
- const filters = {
- taxonomy: taxonomy,
+ if (this.batchFetch.size === 0) return;
+ const taxonomies = Array.from(this.batchFetch);
+ this.batchFetch.clear();
+ try {
+ await this.store.setFilters({
+ taxonomy: taxonomies.join(','),
page: 1,
search: '',
parent: 0
- };
-
- // Use the internal generateCacheKey method via store instance
- const cacheKey = this.generateCacheKeyForFilters(filters);
-
- // Filter items for this specific taxonomy
- const items = Array.from(this.store.data.values())
- .filter(item => item.taxonomy === taxonomy)
- .map(item => item.id);
-
- const cacheEntry = {
- key: cacheKey,
- items: items,
- timestamp: Date.now(),
- endpoint: storeInstance.config.endpoint,
- filters: filters
- };
-
- // Set in both memory and IndexedDB cache
- storeInstance.cache.set(cacheKey, cacheEntry);
-
- // Persist to IndexedDB (if available)
- if (storeInstance.db?.objectStoreNames.contains('cache')) {
- const tx = storeInstance.db.transaction(['cache'], 'readwrite');
- const objectStore = tx.objectStore('cache');
- objectStore.put(cacheEntry);
- }
-
- // Update button states for this taxonomy
- this.updateFieldsForTaxonomy(taxonomy);
- });
-
- // Initialize field displays
- this.fields.forEach((config, fieldId) => {
- if (config.selectedTerms.size > 0) {
- this.initFieldDisplay(fieldId);
- }
- });
+ });
+ } catch (error) {
+ console.error('Failed to batch fetch taxonomies:', error);
+ }
}
- /**
- * Generate cache key for given filters (matching DataStore's internal logic)
- */
- generateCacheKeyForFilters(filters) {
- const normalized = Object.keys(filters)
- .sort()
- .reduce((acc, key) => {
- acc[key] = filters[key];
- return acc;
- }, {});
-
- return JSON.stringify(normalized);
- }
-
- /**
- * Preload taxonomy data on hover
- */
- async preloadTaxonomy(taxonomy) {
- // Trigger fetch for this taxonomy
- this.store.setFilters({
+ preloadTaxonomy(taxonomy) {
+ this.store.setFilters( {
taxonomy: taxonomy,
page: 1,
search: '',
parent: 0
});
}
- /*****************************************
- SUBSCRIBERS
- *****************************************/
+ /**************************************************
+ LOADING
+ **************************************************/
+ setCreateButton(field, show = true) {
+ if (!field.canCreate || !this.creator) return;
+
+ const conf = (this.container.open) ? this.ui : field.ui;
+ if (!conf.create?.button || !conf.create?.span) return;
+
+ const createButton = conf.create.button;
+ createButton.hidden = !show;
+ const buttonSpan = conf.create.span;
+ const input = (this.container.open) ? conf.search.input : conf.search;
+ if (!input) return;
+
+ let results = this.currentTerms()??[];
+ let matches = results.map(t => t.name);
+
+ let query = input.value;
+ const willShow = show && query.length >= 2 && !matches.includes(query);
+ createButton.hidden = !willShow;
+ if (willShow) {
+ buttonSpan.textContent = input.value??'';
+ }
+ }
+ async maybeCreateTerm(e) {
+ const field = this.currentField();
+ if (!field) return;
+
+ window.debouncer.cancel(`${field.id}-search-results`);
+
+ let data = {
+ taxonomy: field.taxonomy,
+ parent: this.store.filters.parent??0
+ }
+
+ if (!this.container.open || this.ui.search.input.value !== '') {
+ data.name = (this.container.open) ? this.ui.search.input.value : field.ui.search.value;
+ } else {
+ data.parent = this.creator.ui.parent.value??data.parent;
+ data.name = this.creator.ui.name.value??false;
+ }
+
+ if (data.parent !== undefined && data.name) {
+ this.setMessage(field,true, `Creating "${data.name}"...`);
+ this.setCreateButton(field,false);
+
+ if (this.container.open) {
+ window.removeChildren(this.ui.terms.list);
+ } else {
+ field.ui.search.disabled = true;
+ if (field.ui.dropdown.wrapper) {
+ field.ui.dropdown.wrapper.hidden = false;
+ }
+ }
+
+ let term = await this.creator.handleTermCreation(data);
+
+ if (term) {
+ // Stop any typeLoop animation and show success message WITHOUT typeLoop
+ this.setMessage(field,true, `"${term.name}" created!`, false);
+
+ this.addSelected(term.id, field.id);
+ this.updateFieldValue(field.id);
+ // For autocomplete, show the newly created term in dropdown
+ if (!this.container.open && field.ui.dropdown.list) {
+ window.removeChildren(field.ui.dropdown.list);
+ const termElement = this.createAutocompleteTerm(term);
+ if (termElement) {
+ termElement.classList.add('newly-created');
+ field.ui.dropdown.list.append(termElement);
+ }
+ }
+ this.scheduleHideDropdown(field.id, 300);
+ this.setMessage(field,false);
+ } else {
+ // Creation failed - hide immediately
+ this.setMessage(field,false);
+ if (!this.container.open && field.ui.dropdown.wrapper) {
+ field.ui.dropdown.wrapper.hidden = true;
+ }
+ }
+
+ if (!this.container.open) {
+ field.ui.search.disabled = false;
+ field.ui.search.value = '';
+ }
+ }
+ }
+ setMessage(field, show = true, message = '', type = true) {
+ const conf = this.container.open||field.isFilter ? this.ui : (field.isFilter ? null : field.ui);
+ if (!conf?.message?.message) return;
+
+ message = (message === '') ? `No ${field.plural??'items'} found.` : message;
+
+ const p = conf.message.message;
+ const pText = conf.message.text;
+
+ p.hidden = !show;
+ if (show) {
+ if (message && pText) {
+ if (type && window.typeLoop && pText) {
+ if (this.messageText[field.id]) {
+ this.messageText[field.id]();
+ delete this.messageText[field.id];
+ }
+ this.messageText[field.id] = window.typeLoop(pText, message);
+ } else {
+ pText.textContent = message;
+ }
+
+ }
+ } else {
+ if (this.messageText[field.id]) {
+ this.messageText[field.id]();
+ delete this.messageText[field.id];
+ }
+ }
+ }
+ /**************************************************
+ SUBSCRIBERS
+ **************************************************/
subscribe(callback) {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
-
- notify(event, data = {}) {
- this.subscribers.forEach( callback => {
+ notify(event, data={}) {
+ this.subscribers.forEach(callback => {
try {
callback(event, data);
} catch (error) {
@@ -1525,36 +1322,61 @@
}
});
}
-
- /**
- * Clean up
- */
+ /******************************************************
+ CLEANUP
+ ******************************************************/
destroy() {
- // Remove event listeners
- document.removeEventListener('click', this.handleClick);
- document.removeEventListener('change', this.handleChange);
+ // Cancel all debounced operations for this instance
+ this.fields.forEach((field, fieldId) => {
+ window.debouncer.cancel(`${fieldId}-search`);
+ window.debouncer.cancel(`${fieldId}-search-results`);
+ });
- // Clear intervals and cleanup
+ // Stop any typeLoop animations
+ Object.keys(this.messageText).forEach(key => {
+ if (this.messageText[key]) {
+ this.messageText[key]();
+ }
+ });
+ this.messageText = {};
+
+ // Disconnect observer
+ if (this.ui.terms?.sentinel) {
+ this.observer?.unobserve(this.ui.terms.sentinel);
+ }
this.observer?.disconnect();
+ this.lazyObserver?.disconnect();
- // Destroy all stores
- this.store.destroy();
+ // Remove event listeners
+ document.removeEventListener('click', this.clickHandler);
+ document.removeEventListener('change', this.changeHandler);
+ document.removeEventListener('input', this.inputHandler);
+ document.removeEventListener('focus', this.focusHandler, true);
+ document.removeEventListener('blur', this.blurHandler, true);
+ // Clear data structures
this.subscribers.clear();
- // Clear all maps
this.fields.clear();
this.selectedTerms.clear();
+ this.batchFetch.clear();
+
+ // Cleanup creator if exists
+ if (this.creator) {
+ this.creator.destroy();
+ this.creator = null;
+ }
+
+ // Unsubscribe from store
+ if (this.store) {
+ this.store = null;
+ }
}
}
-/**
- * Initialize singleton
- */
document.addEventListener('DOMContentLoaded', function() {
window.auth.subscribe((event) => {
if (event === 'auth-loaded') {
window.jvbSelector = new TaxonomySelector();
}
});
-
});
--
Gitblit v1.10.0