From 4cf1c2ffd3c6b7921fc5380b6e28e836fefd800c Mon Sep 17 00:00:00 2001 From: Simon Martens Date: Tue, 3 Jun 2025 23:37:43 +0200 Subject: [PATCH] divmenu --- views/transform/div-menu.js | 145 +++++++++++++++++++++++++----------- 1 file changed, 103 insertions(+), 42 deletions(-) diff --git a/views/transform/div-menu.js b/views/transform/div-menu.js index 57f341d..4192197 100644 --- a/views/transform/div-menu.js +++ b/views/transform/div-menu.js @@ -1,5 +1,4 @@ -const TS_MENU_COMPONENT = "ts-menu"; -const TS_TOGGLE_BUTTON = "ts-menu-toggle-button"; +const USER_PROVIDED_MENU_BUTTON_CLASS = "div-menu-button"; const TS_POPUP = "ts-menu-popup"; const TS_LIST = "ts-menu-list"; const TS_PLACEHOLDER_MESSAGE = "ts-menu-placeholder-message"; @@ -8,6 +7,16 @@ const TS_ITEM_ACTION = "ts-menu-item-action"; const TS_ITEM_IS_SELECTED = "ts-item-is-selected"; const TS_CONTENT_CLOSE_BUTTON = "ts-content-close-button"; const TAILWIND_HIDDEN_CLASS = "hidden"; +const TS_MENU_BUTTON_STATE_DISABLED = "ts-menu-button--disabled"; +// New constants for menu label states +const TS_MENU_LABEL_IN_MENU_STATE = "ts-menu-label--in-menu"; +const TS_MENU_LABEL_DISPLAYED_STATE = "ts-menu-label--displayed"; +// +// Prereq: child divs must have prop data-value, and a label element +// the label element must have class "menu-label" +// The child divs will be moved to the target element when selected +// The target element must be specified by the attribute target-id on the custom element +// The menu button must have the class div-menu-button export class DivMenu extends HTMLElement { constructor() { @@ -17,6 +26,7 @@ export class DivMenu extends HTMLElement { this._originalChildDivs = []; this._observer = null; this._menuPlaceholderMessageElement = null; + this._menuButton = null; this._toggleMenu = this._toggleMenu.bind(this); this._handleMenuItemClick = this._handleMenuItemClick.bind(this); @@ -25,29 +35,33 @@ export class DivMenu extends HTMLElement { this._handleMutations = this._handleMutations.bind(this); this._checkMenuPlaceholderAndButton = this._checkMenuPlaceholderAndButton.bind(this); this._clearFormElements = this._clearFormElements.bind(this); + this._refreshTargetOrder = this._refreshTargetOrder.bind(this); // Bind new method } connectedCallback() { - this._originalChildDivs = Array.from(this.children).filter((node) => node.nodeType === Node.ELEMENT_NODE && node.tagName === "DIV"); + this._menuButton = this.querySelector(`.${USER_PROVIDED_MENU_BUTTON_CLASS}`); + if (!this._menuButton) { + console.error(`TabSelectorMenu: User-provided menu button with class '${USER_PROVIDED_MENU_BUTTON_CLASS}' not found.`); + } + + this._originalChildDivs = Array.from(this.children).filter((node) => node.nodeType === Node.ELEMENT_NODE && node.tagName === "DIV" && !node.classList.contains(USER_PROVIDED_MENU_BUTTON_CLASS)); this._originalChildDivs.forEach((div) => div.remove()); - const componentHTML = ` - + const componentPopupHTML = `
`; - this.innerHTML = componentHTML; + this.insertAdjacentHTML("beforeend", componentPopupHTML); - this._menuButton = this.querySelector(`.${TS_TOGGLE_BUTTON}`); this._menuPopupElement = this.querySelector(`.${TS_POPUP}`); this._menuListElement = this.querySelector(`.${TS_LIST}`); this._menuPlaceholderMessageElement = this.querySelector(`.${TS_PLACEHOLDER_MESSAGE}`); - if (!this._menuButton || !this._menuPopupElement || !this._menuListElement || !this._menuPlaceholderMessageElement) { - console.error("CRITICAL: Essential parts missing after creating component from string."); + if (!this._menuPopupElement || !this._menuListElement || !this._menuPlaceholderMessageElement) { + console.error("CRITICAL: Popup structure parts missing."); return; } @@ -62,7 +76,6 @@ export class DivMenu extends HTMLElement { this._observer = new MutationObserver(this._handleMutations); this._originalChildDivs.forEach((sourceDiv) => { - // Use a specific class for the menu label to distinguish from form labels const menuLabelElement = sourceDiv.querySelector("label.menu-label"); const itemValue = sourceDiv.dataset.value; @@ -97,6 +110,7 @@ export class DivMenu extends HTMLElement { sourceDiv, menuItemButton: addedMenuItemButton, menuListItem: addedMenuItemButton.parentElement, + menuLabelElement: menuLabelElement, selected: isInitiallySelected, }); @@ -104,13 +118,14 @@ export class DivMenu extends HTMLElement { }); this._menuItemsMap.forEach((itemData) => { - if (itemData.selected) { - this._updateItemState(itemData, false, true); - } + this._updateItemState(itemData, false, true); }); + // _refreshTargetOrder() is called within _updateItemState, so target should be correct. this._checkMenuPlaceholderAndButton(); - this._menuButton.addEventListener("click", this._toggleMenu); + if (this._menuButton) { + this._menuButton.addEventListener("click", this._toggleMenu); + } this._menuListElement.addEventListener("click", this._handleMenuItemClick); document.addEventListener("click", this._handleClickOutside); } @@ -126,11 +141,13 @@ export class DivMenu extends HTMLElement { closeButton.removeEventListener("click", this._handleContentClose); } }); + if (this._menuButton) { + this._menuButton.removeEventListener("click", this._toggleMenu); + } } _clearFormElements(parentElement) { if (!parentElement) return; - const inputs = parentElement.querySelectorAll("input, textarea, select"); inputs.forEach((el) => { switch (el.type) { @@ -141,10 +158,6 @@ export class DivMenu extends HTMLElement { break; case "checkbox": case "radio": - // For radios, only uncheck if it's the one that was checked. - // For checkboxes, this is fine. - // A more robust radio reset might involve finding the default or unchecking all in group. - // For simplicity here, just unchecking. el.checked = false; break; case "file": @@ -153,11 +166,45 @@ export class DivMenu extends HTMLElement { default: el.value = ""; } - if (el.tagName === "SELECT") { - el.selectedIndex = 0; // Reset to the first option (often a placeholder) - // or -1 to clear completely if no placeholder. + if (el.tagName === "SELECT") el.selectedIndex = 0; + }); + } + + _refreshTargetOrder() { + if (!this._targetElement) return; + + // Detach all currently managed divs from target + const managedDivsInTarget = []; + this._menuItemsMap.forEach((item) => { + if (item.sourceDiv.parentElement === this._targetElement) { + managedDivsInTarget.push(item.sourceDiv); } }); + managedDivsInTarget.forEach((div) => this._targetElement.removeChild(div)); + + // Remove existing component-managed placeholder if any + const existingPlaceholder = this._targetElement.querySelector(`.${TS_TARGET_PLACEHOLDER_CLASS}`); + if (existingPlaceholder) { + this._targetElement.removeChild(existingPlaceholder); + } + + // Append selected divs in their original order + let hasSelectedItems = false; + this._menuItemsMap.forEach((item) => { + if (item.selected) { + item.sourceDiv.classList.remove(TAILWIND_HIDDEN_CLASS); // Ensure it's visible + this._targetElement.appendChild(item.sourceDiv); + hasSelectedItems = true; + } + }); + + // Add placeholder if target is empty and no items were selected to be appended + if (!hasSelectedItems) { + const placeholder = document.createElement("p"); + placeholder.className = TS_TARGET_PLACEHOLDER_CLASS; + placeholder.innerHTML = "Selected content will appear here."; + this._targetElement.appendChild(placeholder); + } } _checkMenuPlaceholderAndButton() { @@ -170,7 +217,12 @@ export class DivMenu extends HTMLElement { }); const allItemsSelected = visibleItemCount === 0; this._menuPlaceholderMessageElement.style.display = allItemsSelected ? "block" : "none"; - this._menuButton.disabled = allItemsSelected; + + if (allItemsSelected) { + this._menuButton.classList.add(TS_MENU_BUTTON_STATE_DISABLED); + } else { + this._menuButton.classList.remove(TS_MENU_BUTTON_STATE_DISABLED); + } } _handleMutations(mutationsList) { @@ -179,10 +231,8 @@ export class DivMenu extends HTMLElement { const targetDiv = mutation.target; const itemValue = targetDiv.dataset.value; const itemData = this._menuItemsMap.get(itemValue); - if (itemData) { const isNowHiddenByClass = targetDiv.classList.contains(TAILWIND_HIDDEN_CLASS); - if (isNowHiddenByClass && itemData.selected) { itemData.selected = false; this._updateItemState(itemData, false, true); @@ -205,6 +255,9 @@ export class DivMenu extends HTMLElement { } _toggleMenu(event) { + if (this._menuButton && this._menuButton.classList.contains(TS_MENU_BUTTON_STATE_DISABLED)) { + return; + } event.stopPropagation(); const isHidden = this._menuPopupElement.style.display === "none" || this._menuPopupElement.style.display === ""; if (isHidden) { @@ -219,6 +272,9 @@ export class DivMenu extends HTMLElement { _handleClickOutside(event) { if (!this.contains(event.target) && this._menuPopupElement.style.display !== "none") { + if (this._menuButton && this._menuButton.contains(event.target)) { + return; + } this._closeMenu(); } } @@ -228,7 +284,6 @@ export class DivMenu extends HTMLElement { if (clickedButton && clickedButton.dataset.value) { const itemValue = clickedButton.dataset.value; const itemData = this._menuItemsMap.get(itemValue); - if (itemData) { if (!itemData.selected) { itemData.selected = true; @@ -239,40 +294,46 @@ export class DivMenu extends HTMLElement { } _updateItemState(itemData, closeMenuAfterUpdate, manageHiddenClass) { - if (!itemData || !itemData.sourceDiv || !itemData.menuItemButton || !itemData.menuListItem) { + if (!itemData || !itemData.sourceDiv || !itemData.menuItemButton || !itemData.menuListItem || !itemData.menuLabelElement) { console.warn("TabSelectorMenu: Incomplete itemData for update:", itemData); return; } + const menuLabel = itemData.menuLabelElement; + if (itemData.selected) { - if (manageHiddenClass) { - itemData.sourceDiv.classList.remove(TAILWIND_HIDDEN_CLASS); - } + // No direct DOM manipulation of sourceDiv here regarding targetElement + // _refreshTargetOrder will handle placement. + if (manageHiddenClass) itemData.sourceDiv.classList.remove(TAILWIND_HIDDEN_CLASS); itemData.menuItemButton.classList.add(TS_ITEM_IS_SELECTED); itemData.menuListItem.style.display = "none"; - if (this._targetElement) { - this._targetElement.appendChild(itemData.sourceDiv); - } else { - console.warn(`TabSelectorMenu: Cannot mount div. Target element not found.`); - } + menuLabel.classList.add(TS_MENU_LABEL_DISPLAYED_STATE); + menuLabel.classList.remove(TS_MENU_LABEL_IN_MENU_STATE); } else { - if (manageHiddenClass) { - itemData.sourceDiv.classList.add(TAILWIND_HIDDEN_CLASS); - } + if (manageHiddenClass) itemData.sourceDiv.classList.add(TAILWIND_HIDDEN_CLASS); this._clearFormElements(itemData.sourceDiv); itemData.menuItemButton.classList.remove(TS_ITEM_IS_SELECTED); itemData.menuListItem.style.display = ""; - if (itemData.sourceDiv.parentElement !== this) { + menuLabel.classList.add(TS_MENU_LABEL_IN_MENU_STATE); + menuLabel.classList.remove(TS_MENU_LABEL_DISPLAYED_STATE); + + // If the div is being hidden, ensure it's moved back to the component + // if it was in the target. _refreshTargetOrder will handle its removal from target. + if (itemData.sourceDiv.parentElement !== this && itemData.sourceDiv.parentElement !== this._targetElement) { this.appendChild(itemData.sourceDiv); + } else if (itemData.sourceDiv.parentElement === this._targetElement) { + // It will be removed by _refreshTargetOrder, then re-appended to 'this' if needed + // For now, just ensure it's not orphaned if _refreshTargetOrder doesn't run immediately after this. + // Actually, better to let _refreshTargetOrder handle removal from target. + // And if it's not selected, ensure it's a child of 'this' for mutation observer. } } + this._refreshTargetOrder(); // Centralize target DOM updates this._checkMenuPlaceholderAndButton(); - if (closeMenuAfterUpdate) { - this._closeMenu(); - } + if (closeMenuAfterUpdate) this._closeMenu(); } }