From 235ce5716edc2f7cbe80fdccf26eac7269587839 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Mon, 08 Jun 2026 04:38:18 +0000
Subject: [PATCH] =FavouritesManager.php and FavouritesRoutes.php fixes. Moving all logic to FavouritesManager.php. Still some left to do
---
assets/js/concise/Tabs.js | 480 +++++++++++++++++++++++++++++++++---------------------------
1 files changed, 264 insertions(+), 216 deletions(-)
diff --git a/assets/js/concise/Tabs.js b/assets/js/concise/Tabs.js
index 4dfce20..b93c083 100644
--- a/assets/js/concise/Tabs.js
+++ b/assets/js/concise/Tabs.js
@@ -2,227 +2,275 @@
* Handles the tabs functionality, given the html requirements are met
*/
class TabsContainer{
- constructor(container, callbacks = {}, parent = null){
- this.tabs = container.querySelector('.tabs');
- this.a11y = window.jvbA11y;
+ constructor() {
+ this.a11y = window.jvbA11y;
+ this.error = window.jvbError;
- this.updateURL = true;
+ this.subscribers = new Set();
+ this.tabs = new Map();
- this.parent = parent;
- this.childTabs = new Map();
- if ('updateURL' in callbacks && callbacks.updateURL === false) {
- this.updateURL = false;
- }
- this.callbacks = callbacks;
-
-
- this.activeTab = (this.updateURL) ? this.getInitialTabFromHash() :
- container.querySelector('button.tab.active')?.dataset.tab;
-
- this.container = container;
-
- if (this.tabs) {
- this.tabs.addEventListener('click', e => {
- const tab = e.target.closest('[data-tab]');
- if (tab) {
- let update = ('updateURL' in this.callbacks) ? this.callbacks.updateURL : true;
- this.switchTab(tab.dataset.tab, update);
- }
- });
- }
-
- // Check for and initialize child tab containers
- this.initializeChildTabs();
-
- // Find and set up select dropdown listeners
- this.selectDropdown = document.querySelector('select.tab-list');
- if (this.selectDropdown) {
- this.selectDropdown.addEventListener('change', e => {
-
- let update = ('updateURL' in this.callbacks) ? this.callbacks.updateURL : true;
- this.switchTab(e.target.value, update);
- });
- }
-
- let update = ('updateURL' in this.callbacks) ? this.callbacks.updateURL : true;
-
- if (!this.activeTab){
- this.activeTab = document.querySelector('button.tab')?.dataset.tab;
- }
-
- this.switchTab(this.activeTab, update);
- }
-
- /**
- * Auto-detect and initialize child tab containers
- */
- initializeChildTabs() {
-
- this.tabs.querySelectorAll('button').forEach(tab => {
- let hasChildren = this.container.querySelector(`.tab-content[data-tab="${tab.dataset['tab']}"]`);
-
- if(hasChildren && hasChildren.querySelector('.tabs')){
- let container = this.container.querySelector(`.tab-content[data-tab="${tab.dataset['tab']}"]`);
- let tabs = new window.jvbTabs(container, {updateURL: false}, this);
- this.childTabs.set(tab.dataset.tab, tabs);
- }
- });
- // Find all tab content panels in this container
- // const tabPanels = this.container.querySelectorAll('.tab-content');
- // tabPanels.forEach(panel => {
- // const tabId = panel.dataset.tab;
- // if (!tabId) return;
- //
- // // Look for nested tab containers within this panel
- // const nestedTabContainers = panel.querySelectorAll('.tabs, [data-tabs]');
- //
- // nestedTabContainers.forEach(nestedContainer => {
- // // Create a new TabsContainer for this nested container
- // const childContainer = new TabsContainer(nestedContainer, {}, this);
- //
- // // Store the child container
- // this.childTabs.set(tabId, childContainer);
- // });
- // });
- }
-
- /**
- * Get initial tab from URL hash
- * @returns {string|false} Tab identifier from hash or null if not present
- */
- getInitialTabFromHash() {
- if (!window.location.hash) return false;
-
- const hash = window.location.hash.substring(1); // Remove the # character
- const pathParts = hash.split('/');
-
- // If this is a root container, check first path part
- if (!this.parent) {
- const rootTabId = pathParts[0];
- // Check if this hash corresponds to a valid tab
- const matchingTab = this.tabs.querySelector(`[data-tab="${rootTabId}"]`);
- if (matchingTab) {
- return rootTabId;
- }
- }
- // If this is a child container, check if our parent's active tab matches the path
- else if (this.parent && pathParts.length > 1) {
- const parentIdx = this.getParentDepth();
- if (parentIdx < pathParts.length) {
- const childTabId = pathParts[parentIdx];
- const matchingTab = this.tabs.querySelector(`[data-tab="${childTabId}"]`);
- if (matchingTab) {
- return childTabId;
- }
- }
- }
-
- return null;
- }
-
- /**
- * Calculate the depth of this tabs container in the hierarchy
- * @returns {number} The depth (0 for root, 1 for first level child, etc.)
- */
- getParentDepth() {
- let depth = 0;
- let parent = this.parent;
-
- while (parent) {
- depth++;
- parent = parent.parent;
- }
-
- return depth;
- }
-
- /**
- * Build the full path for this tab including all parent tabs
- * @param {string} tabId - The current tab ID
- * @returns {string} The full path including parent tabs
- */
- getFullTabPath(tabId) {
- if (!this.parent) {
- return tabId;
- }
-
- // Get parent's active tab path and append this tab
- const parentPath = this.parent.getFullTabPath(this.parent.activeTab);
- return `${parentPath}/${tabId}`;
- }
-
- /**
- * Switch between tabs
- * @param {string} tab - Tab to switch to ('items' or 'lists')
- * @param {boolean} updateHistory - Whether to push the state to the url
- */
- switchTab(tab, updateHistory = false) {
- document.activeElement?.blur();
-
- // Update tab buttons
- this.tabs.querySelectorAll('[data-tab]').forEach(tabBtn => {
- tabBtn.classList.toggle('active', tabBtn.dataset.tab === tab);
- tabBtn.setAttribute('aria-selected', tabBtn.dataset.tab === tab);
- });
-
- // Update tab panels
- this.container.querySelectorAll('.tab-content').forEach(content => {
- content.classList.toggle('active', content.dataset.tab === tab);
- content.setAttribute('aria-hidden', content.dataset.tab !== tab);
- content.hidden = content.dataset.tab !== tab;
- });
-
- // Update state
- this.activeTab = tab;
- if (this.callbacks[tab]) {
- this.callbacks[tab]();
- }
-
- // Activate first child tab if this tab has children
- const childContainer = this.childTabs.get(tab);
- if (childContainer) {
- const firstTab = childContainer.container.querySelector('button.tab')?.dataset.tab;
- if (firstTab) {
- childContainer.switchTab(firstTab, false);
- }
- }
-
- // Update URL hash with full path (only from root container)
- if (updateHistory) {
- if (!this.parent) {
- window.history.pushState({ tab: tab }, '', `#${tab}`);
- } else {
- // This is a child container, notify parent to update URL
- this.parent.updateUrlFromChild();
- }
- }
-
- // Update select dropdown if it exists
- if (this.selectDropdown && this.selectDropdown.querySelector(`option[value="${tab}"]`)) {
- this.selectDropdown.value = tab;
- }
-
- // Announce to screen readers
- this.a11y.announce(`Switched to ${tab} tab`);
+ this.hasHash = false;
+ this.init();
+ }
+ init() {
+ this.initElements();
+ this.initListeners();
}
- /**
- * Update URL when a child tab changes
- */
- updateUrlFromChild() {
- console.log('Updating URL');
- if (('updateURL' in this.callbacks) ? this.callbacks.updateURL : true) {
- if (!this.parent) {
- // Only the root container should update the URL
- const fullPath = this.getFullTabPath(this.activeTab);
- window.history.pushState({ tab: fullPath }, '', `#${fullPath}`);
- } else {
- // Propagate up to the root
- this.parent.updateUrlFromChild();
+ initElements() {
+ this.selectors = {
+ nav: '.tabs',
+ tab: '[data-tab]',
+ active: 'button.tab.active',
+ section: '.tab-content',
+ button: 'button.tab',
+ select: 'select.tab-list',
+ };
+ }
+ initListeners() {
+ this.clickHandler = this.handleClick.bind(this);
+ this.changeHandler = this.handleChange.bind(this);
+ }
+ handleClick(e) {
+ let config = this.getConfig(e.target);
+ if (!config) return;
+
+ const tab = e.target.closest(this.selectors.tab);
+ if (tab) {
+ this.switchTab(tab.dataset.tab, config);
}
}
- }
+
+ handleChange(e) {
+ let config = this.getConfig(e.target);
+ if (!config) return;
+ if (!config) return;
+ this.switchTab(e.target.value, config);
+ }
+
+ /**
+ *
+ * @param {HTMLElement} container
+ * @param {object} options
+ */
+ registerTab(container, options = {}) {
+ if (!container) return false;
+
+ let ui = window.uiFromSelectors(this.selectors, container);
+ if (!ui.nav || !ui.section) {
+ console.error('No tab navigation or section found');
+ return false;
+ }
+ let tabsId = window.generateID('tab');
+
+ container.dataset.tabsId = tabsId;
+ ui.buttons = Array.from(container.querySelectorAll(this.selectors.button));
+ ui.sections = Array.from(container.querySelectorAll(this.selectors.section));
+ ui.sections.forEach(section => {
+ if (section.querySelector('.tabs')) {
+ options.hasChildren = true;
+ this.registerTab(section, {parent: tabsId});
+ }
+ });
+
+ if (ui.select) {
+ ui.options = Array.from(ui.select.querySelectorAll('option'));
+ }
+
+ let config = {
+ id: tabsId,
+ ui: ui,
+ updateURL: options.updateURL ?? true
+ };
+
+ //Add listeners
+ ui.nav.addEventListener('click', this.clickHandler);
+ ui.select?.addEventListener('change', this.changeHandler);
+ this.tabs.set(tabsId, config);
+ this.determineActiveTab(config);
+
+ return config;
+ }
+
+ determineActiveTab(config) {
+ if (this.getInitialTabFromHash()) {
+ let updated = this.tabs.get(config.id);
+ if (updated.activeTab && config.activeTab && updated.activeTab === config.activeTab) return;
+ }
+ let tab = config.ui.buttons[0].dataset.tab??false;
+ if (tab) {
+ this.switchTab(tab, config);
+ }
+ }
+ getInitialTabFromHash() {
+ if (this.hasHash || !window.location.search) return false;
+ const params = new URLSearchParams(window.location.search);
+ const hash = params.get('tab');
+ if (!hash) return false;
+
+ const parts = hash.split('|');
+
+ parts.forEach(part => {
+ // Find the config that has a button matching this part
+ const conf = Array.from(this.tabs.values()).find(tab =>
+ tab.ui.buttons.some(btn => btn.dataset.tab === part)
+ );
+
+ if (conf) {
+ this.switchTab(part, conf);
+ }
+ });
+
+ this.hasHash = true;
+ return true;
+ }
+
+ /**
+ *
+ * @param {HTMLElement} container
+ */
+ removeTab(container) {
+ if (!container || !container.dataset.tabsId) return;
+ let config = this.tabs.get(container.dataset.tabsId);
+ if (!config) return;
+ config.ui.nav.removeEventListener('click', this.clickHandler);
+ config.ui.select?.removeEventListener('change', this.changeHandler);
+ this.tabs.delete(container.dataset.tabsId);
+ }
+
+ /**
+ *
+ * @param {string} tab
+ * @param {string|Object} config Either the key of the tabs instance, or a tab config object
+ */
+ switchTab(tab, config) {
+ config = (typeof config === 'string') ? this.tabs.get(config) : config;
+ if (!config) return;
+ let activeTab = config.ui.sections.filter(section => section.classList.contains('active'));
+
+ if (Object.hasOwn(config, 'preCheck') && !config.preCheck(activeTab[0], config)) return;
+ if (document.activeElement && this.isInTabs(document.activeElement, config)) document.activeElement.blur();
+
+ config.ui.buttons.forEach((btn, index) => {
+ btn.classList.remove('active', 'previous', 'next');
+ let isActive = btn.dataset.tab === tab;
+ btn.setAttribute('aria-selected', isActive);
+ if (isActive) {
+ btn.classList.add('active');
+ let prv = Math.max(index - 1, 0);
+ let next = Math.min(index+1, config.ui.buttons.length -1);
+ if (prv !== index) {
+ config.ui.buttons[prv]?.classList.add('previous');
+ }
+ if (next !== index) {
+ config.ui.buttons[next]?.classList.add('next');
+ }
+ }
+ });
+
+ config.ui.sections.forEach((section, index) => {
+ let isActive = section.dataset.tab === tab;
+ section.classList.toggle('active', isActive);
+ section.setAttribute('aria-hidden', !isActive);
+ section.hidden = !isActive;
+ });
+
+ this.notify('tab-switched', {
+ previous: config.activeTab,
+ current: tab,
+ config: config
+ });
+ config.activeTab = tab;
+
+ this.tabs.set(config.id, config);
+ if (config?.hasChildren) this.updateChildTabs(config.id);
+ if (config?.updateURL) this.updateURL(config);
+ if (config.ui.select) this.maybeUpdateSelect(tab, config);
+
+ this.a11y.announce(`Switched to ${tab} tab`);
+ }
+ updateChildTabs(id) {
+ Array.from(this.tabs.values()).filter(conf => conf.parent === id).forEach(inst => {
+ let firstBtn = inst.ui.buttons[0].dataset.tab??false;
+ if (firstBtn) {
+ this.switchTab(firstBtn, inst);
+ }
+ });
+ }
+
+ updateURL(config) {
+ if (!config.updateURL) return;
+ let hash = this.checkAncestorsHash(config);
+ if (hash) {
+ window.history.pushState({tab:config.activeTab},'',`?tab=${hash}`);
+ }
+ }
+ checkAncestorsHash(conf) {
+ const parts = [];
+ let current = conf;
+
+ while (current) {
+ parts.unshift(current.activeTab);
+ current = current.parent ? this.tabs.get(current.parent) : null;
+ }
+
+ return parts.join('|');
+ }
+ maybeUpdateSelect(tab, config) {
+ if (!config.ui.select || !Object.hasOwn(config, 'options')) return;
+ config.options.forEach(option => {
+ if (option.value === tab) {
+ config.ui.select.value = tab;
+ return;
+ }
+ });
+
+ }
+
+
+ /**
+ * Event system
+ */
+ subscribe(callback) {
+ this.subscribers.add(callback);
+ return () => this.subscribers.delete(callback);
+ }
+
+ notify(event, data) {
+ this.subscribers.forEach(cb => cb(event, data));
+ }
+
+ /**************************************************************
+ UTILITY
+ **************************************************************/
+ /**
+ *
+ * @param {HTMLElement} target
+ * @returns {object|boolean}
+ */
+ getConfig(target) {
+ const instance = target.closest('[data-tabs-id]');
+ if (!instance) return false;
+ const config = this.tabs.get(instance.dataset.tabsId);
+ if (!config) return false;
+ return config;
+ }
+ isInTabs(target, config) {
+ return config.ui.sections.some(section => section.contains(target));
+ }
+ /**************************************************************
+ CLEANUP
+ **************************************************************/
+ destroy() {
+ this.subscribers.clear();
+
+ Array.from(this.tabs.values()).forEach(tab => {
+ tab.ui.nav.removeEventListener('click', this.clickHandler);
+ tab.ui.select?.removeEventListener('change', this.changeHandler);
+ });
+ }
}
-window.jvbTabs = TabsContainer;
-
+document.addEventListener('DOMContentLoaded', function() {
+ window.jvbTabs = new TabsContainer();
+});
--
Gitblit v1.10.0