UNPKG

wj-elements

Version:

WebJET Elements is a modern set of user interface tools harnessing the power of web components designed to simplify web application development.

562 lines (561 loc) 21.7 kB
var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); import WJElement from "./wje-element.js"; const styles = "/*\n[ WJ Tree ]\n*/\n\n:host {\n position: relative;\n width: 100%;\n font-size: 0;\n}\n"; class Tree extends WJElement { /** * Creates an instance of Tree. */ constructor() { super(); /** * The class name for the component. * @type {string} */ __publicField(this, "className", "Tree"); /** * Handles the click event triggered by the user interaction. * Identifies the closest tree item element to the event target and sets it * as the selected item. Ensures that only one item is selected at a time, resetting * the selection state for all other items. * @param {Event} e The click event object. */ __publicField(this, "handleClick", (e) => { var _a; if (e == null ? void 0 : e.preventDefault) e.preventDefault(); if (this.reorder && this.isReorderHandleEvent(e)) return; const selectedItem = this.getTreeItemFromEvent(e); if (!selectedItem) return; const isClickButton = (((_a = e.composedPath) == null ? void 0 : _a.call(e)) || []).some((el) => { var _a2; return (_a2 = el == null ? void 0 : el.classList) == null ? void 0 : _a2.contains("toggle"); }); if (isClickButton) return; if (this.selection === "single") { for (let item of this.getAllItems()) { item.selected = item === selectedItem; } } else if (this.selection === "multiple") { selectedItem.selected = !selectedItem.selected; this.updateCheckboxState(selectedItem); } }); /** * Starts dragging a tree item when the drag begins from the configured start slot handle. * @param {DragEvent} e The drag event. * @returns {void} */ __publicField(this, "handleDragStart", (e) => { var _a; if (!this.reorder || !this.isReorderHandleEvent(e)) return; const item = this.getTreeItemFromEvent(e); if (!item) return; this.draggedItem = item; item.classList.add("wje-tree-dragging"); if (e.dataTransfer) { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", ((_a = item.textContent) == null ? void 0 : _a.trim()) || ""); } }); /** * Updates the current drop target and exposes a visual drop position. * @param {DragEvent} e The drag event. * @returns {void} */ __publicField(this, "handleDragOver", (e) => { if (!this.reorder || !this.draggedItem) return; const target = this.getTreeItemFromEvent(e); if (!this.canDrop(this.draggedItem, target)) { this.clearDropState(); return; } e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; const position = this.getDropPosition(e, target); this.setDropState(target, position); }); /** * Completes a tree item move. * @param {DragEvent} e The drop event. * @returns {void} */ __publicField(this, "handleDrop", (e) => { if (!this.reorder || !this.draggedItem) return; e.preventDefault(); const target = this.dropTarget || this.getTreeItemFromEvent(e); const position = this.dropPosition || this.getDropPosition(e, target); const detail = this.moveItem(this.draggedItem, target, position); if (detail) { this.dispatchEvent(new CustomEvent("wje-tree:move", { detail, bubbles: true, composed: true })); } this.clearDragState(); }); /** * Clears drag state after the browser drag operation ends. * @returns {void} */ __publicField(this, "handleDragEnd", () => { this.clearDragState(); }); this.draggedItem = null; this.dropTarget = null; this.dropPosition = null; } /** * Sets the selection attribute for the element. * @param {string} value The value to set as the selection attribute. */ set selection(value) { this.setAttribute("selection", value); } /** * Gets the current selection mode for the element. * If no selection is explicitly set, it defaults to 'single'. * @returns {string} The current selection mode, either set by the element's attribute or the default value 'single'. */ get selection() { return this.getAttribute("selection") || "single"; } /** * Enables reordering tree items by dragging an element assigned to the start slot. * @param {boolean} value Whether reordering is enabled. */ set reorder(value) { if (value) this.setAttribute("reorder", ""); else this.removeAttribute("reorder"); } /** * Indicates whether tree items can be reordered with the start slot handle. * @returns {boolean} */ get reorder() { return this.hasAttribute("reorder"); } /** * Returns the CSS stylesheet for the component. * @static * @returns {CSSStyleSheet} The CSS stylesheet */ static get cssStyleSheet() { return styles; } /** * Setup attributes for the Button element. */ setupAttributes() { this.isShadowRoot = "open"; this.syncAria(); } /** * A method called before the drawing or rendering process of tree items. * It iterates through all `wje-tree-item` elements, updating their selection state * and managing their expand/collapse icons accordingly. * @returns {void} This method does not return a value. */ beforeDraw() { const template = this.querySelector("template"); const items = this.querySelectorAll("wje-tree-item"); items == null ? void 0 : items.forEach((item) => { var _a; item.selection = this.selection; (_a = item.syncNestingState) == null ? void 0 : _a.call(item); this.appendTemplateSlot(item, template, "expand"); this.appendTemplateSlot(item, template, "collapse"); this.appendTemplateSlot(item, template, "start"); this.appendTemplateSlot(item, template, "end"); if (this.reorder) this.setupReorderHandle(item); }); } /** * Draw method for the tree. * @returns {object} Document fragment */ draw() { let fragment = document.createDocumentFragment(); let native = document.createElement("div"); native.setAttribute("part", "native"); native.classList.add("native-tree"); let slot = document.createElement("slot"); native.appendChild(slot); fragment.appendChild(native); return fragment; } /** * Called after the draw process of the component is completed. * Typically used to add event listeners or perform operations * that are dependent on the component's drawn state. * @returns {void} This method does not return a value. */ afterDraw() { this.addEventListener("click", this.handleClick); this.addEventListener("dragstart", this.handleDragStart); this.addEventListener("dragover", this.handleDragOver); this.addEventListener("drop", this.handleDrop); this.addEventListener("dragend", this.handleDragEnd); this.syncAria(); } /** * Syncs ARIA attributes on the host element. */ syncAria() { this.setAriaState({ role: "tree", multiselectable: this.selection === "multiple" ? "true" : void 0 }); } beforeDisconnect() { this.removeEventListener("click", this.handleClick); this.removeEventListener("dragstart", this.handleDragStart); this.removeEventListener("dragover", this.handleDragOver); this.removeEventListener("drop", this.handleDrop); this.removeEventListener("dragend", this.handleDragEnd); } /** * Retrieves all items that match the selector 'wje-tree-item' within the current context. * @returns {Array<Element>} An array of all matching DOM elements. */ getAllItems() { return [...this.querySelectorAll("wje-tree-item")]; } /** * Retrieves and appends a template slot to a tree item. * @param {HTMLElement} item The DOM element to which the slot content will be appended. * @param {HTMLTemplateElement|null} template Template that may provide reusable slot content. * @param {string} slotName Name of the projected slot content to clone. * @returns {void} */ appendTemplateSlot(item, template, slotName) { const slot = template == null ? void 0 : template.content.querySelector(`[slot="${slotName}"]`); if (!slot) return; const templateSlotClass = this.getTemplateSlotClass(slotName); const existing = item.querySelector(`:scope > [slot="${slotName}"].${templateSlotClass}`); if (existing) return; const slotClone = slot.cloneNode(true); slotClone.classList.add("wje-tree-template-slot", templateSlotClass); item.append(slotClone); } /** * Returns the internal class used to identify cloned template slot content. * @param {string} slotName Name of the projected slot. * @returns {string} */ getTemplateSlotClass(slotName) { return `wje-tree-template-slot-${slotName}`; } /** * Marks direct start slot content as the drag handle for reorderable trees. * @param {HTMLElement} item Host item that receives draggable start content. * @returns {void} */ setupReorderHandle(item) { const handles = item.querySelectorAll(':scope > [slot="start"]'); handles.forEach((handle) => { handle.setAttribute("draggable", "true"); handle.classList.add("wje-tree-drag-handle"); if (!handle.hasAttribute("aria-label")) { handle.setAttribute("aria-label", "Move tree item"); } }); } /** * Finds a tree item from a composed event path. * @param {Event} e User interaction that may originate inside shadow DOM. * @returns {HTMLElement|null} */ getTreeItemFromEvent(e) { var _a, _b, _c; const path = ((_a = e.composedPath) == null ? void 0 : _a.call(e)) || []; const pathItem = path.find((el) => this.isTreeItem(el) && this.contains(el)); if (pathItem) return pathItem; const targetItem = (_c = (_b = e.target) == null ? void 0 : _b.closest) == null ? void 0 : _c.call(_b, "wje-tree-item"); return targetItem && this.contains(targetItem) ? targetItem : null; } /** * Checks whether the event started from a reorder handle. * @param {Event} e User interaction to inspect. * @returns {boolean} */ isReorderHandleEvent(e) { var _a; return (((_a = e.composedPath) == null ? void 0 : _a.call(e)) || []).some((el) => { var _a2; return (_a2 = el == null ? void 0 : el.classList) == null ? void 0 : _a2.contains("wje-tree-drag-handle"); }); } /** * Checks whether a node is a tree item element. * @param {object} node The node to inspect. * @returns {boolean} */ isTreeItem(node) { return node instanceof Element && node.localName === "wje-tree-item"; } /** * Returns direct tree item children for a tree or tree item parent. * @param {HTMLElement} parent The parent element. * @returns {HTMLElement[]} */ getDirectTreeItems(parent) { return Array.from((parent == null ? void 0 : parent.children) || []).filter((child) => this.isTreeItem(child)); } /** * Gets the index of a tree item among direct tree item siblings. * @param {HTMLElement} item Element whose sibling position should be resolved. * @returns {number} */ getTreeItemIndex(item) { return this.getDirectTreeItems(item.parentElement).indexOf(item); } /** * Determines whether the dragged item can be dropped on the target. * @param {HTMLElement} draggedItem Item currently being moved. * @param {HTMLElement} targetItem Candidate item under the pointer. * @returns {boolean} */ canDrop(draggedItem, targetItem) { return !!draggedItem && !!targetItem && draggedItem !== targetItem && !draggedItem.contains(targetItem); } /** * Resolves the drop position against the visible tree item row. * @param {DragEvent} e Browser drag event carrying pointer coordinates. * @param {HTMLElement|null} target Item used to calculate row boundaries. * @returns {'before'|'inside'|'after'} */ getDropPosition(e, target) { var _a, _b, _c; const row = (_a = target == null ? void 0 : target.shadowRoot) == null ? void 0 : _a.querySelector(".item"); const rect = ((_b = row == null ? void 0 : row.getBoundingClientRect) == null ? void 0 : _b.call(row)) || ((_c = target == null ? void 0 : target.getBoundingClientRect) == null ? void 0 : _c.call(target)); if (!rect) return "after"; const topBoundary = rect.top + rect.height * 0.25; const bottomBoundary = rect.bottom - rect.height * 0.25; if (e.clientY < topBoundary) return "before"; if (e.clientY > bottomBoundary) return "after"; return "inside"; } /** * Applies the visual drop position to the current target item. * @param {HTMLElement} target Item currently showing insertion feedback. * @param {string} position Resolved insertion area for the target row. * @returns {void} */ setDropState(target, position) { if (this.dropTarget !== target || this.dropPosition !== position) { this.clearDropState(); this.dropTarget = target; this.dropPosition = position; target.classList.add(this.getDropPositionClass(position)); } } /** * Clears visual drop target state. * @returns {void} */ clearDropState() { var _a; (_a = this.dropTarget) == null ? void 0 : _a.classList.remove( this.getDropPositionClass("before"), this.getDropPositionClass("inside"), this.getDropPositionClass("after") ); this.dropTarget = null; this.dropPosition = null; } /** * Clears the active drag state. * @returns {void} */ clearDragState() { var _a; (_a = this.draggedItem) == null ? void 0 : _a.classList.remove("wje-tree-dragging"); this.clearDropState(); this.draggedItem = null; } /** * Returns the internal class used to render a drop marker. * @param {'before'|'inside'|'after'} position Drop marker position. * @returns {string} */ getDropPositionClass(position) { return `wje-tree-drop-${position}`; } /** * Moves a tree item relative to another tree item and syncs nesting metadata. * @param {HTMLElement} draggedItem The item being moved. * @param {HTMLElement} targetItem Item that receives or anchors the moved item. * @param {'before'|'inside'|'after'} position The target position. * @returns {object|null} Move detail, or null when the move is invalid. */ moveItem(draggedItem, targetItem, position = "after") { var _a, _b, _c; if (!this.canDrop(draggedItem, targetItem)) return null; const fromParent = draggedItem.parentElement; const fromParentItem = (_a = fromParent == null ? void 0 : fromParent.closest) == null ? void 0 : _a.call(fromParent, "wje-tree-item"); const fromIndex = this.getTreeItemIndex(draggedItem); if (position === "inside") { targetItem.appendChild(draggedItem); (_b = targetItem.setChildrenExpanded) == null ? void 0 : _b.call(targetItem, true); } else { const parent = targetItem.parentElement; const reference = position === "before" ? targetItem : targetItem.nextSibling; parent.insertBefore(draggedItem, reference); } this.syncTreeItems(); const toParent = draggedItem.parentElement; const toParentItem = (_c = toParent == null ? void 0 : toParent.closest) == null ? void 0 : _c.call(toParent, "wje-tree-item"); const toIndex = this.getTreeItemIndex(draggedItem); const orderElements = this.getOrderElements(toParent); const order = orderElements.map((item) => item.innerText.trim()); this.refreshMovedItems([fromParentItem, toParentItem, targetItem]); return { item: draggedItem, target: targetItem, position, from: fromIndex, to: toIndex, order, orderElements, orderIds: this.getOrderIds(toParent), itemId: this.getItemId(draggedItem), targetId: this.getItemId(targetItem), fromParentItem, toParentItem, fromParentId: this.getItemId(fromParentItem), toParentId: this.getItemId(toParentItem), fromParent, toParent, fromIndex, toIndex }; } /** * Returns cloned direct tree item children for an event detail. * @param {HTMLElement} parent Parent whose direct tree item children should be serialized. * @returns {HTMLElement[]} */ getOrderElements(parent) { return this.getDirectTreeItems(parent).map((item) => { const clone = item.cloneNode(true); clone.querySelectorAll(".wje-tree-template-slot").forEach((slot) => slot.remove()); return clone; }); } /** * Returns stable ids for direct tree item children when they are available. * @param {HTMLElement} parent Parent whose direct tree item children should be serialized. * @returns {Array<string|null>} */ getOrderIds(parent) { return this.getDirectTreeItems(parent).map((item) => this.getItemId(item)); } /** * Gets an application-facing id from a tree item. * @param {HTMLElement|null} item Tree item to read. * @returns {string|null} */ getItemId(item) { if (!this.isTreeItem(item)) return null; return item.getAttribute("value") || item.id || null; } /** * Syncs item selection mode and nesting metadata after a DOM move. * @returns {void} */ syncTreeItems() { this.getAllItems().forEach((item) => { var _a, _b; item.selection = this.selection; (_a = item.syncNestingState) == null ? void 0 : _a.call(item); (_b = item.syncAria) == null ? void 0 : _b.call(item); }); } /** * Redraws affected parent items so expand/collapse affordances match current children. * @param {HTMLElement[]} items Items that may need refresh. * @returns {void} */ refreshMovedItems(items) { [...new Set(items)].forEach((item) => { var _a; if (this.isTreeItem(item)) (_a = item.refresh) == null ? void 0 : _a.call(item); }); } /** * Updates the state of a checkbox, syncing the state both upwards to parent elements * and downwards to child elements as necessary. * @param {object} changedItem The specific item whose checkbox state has changed. * @param {boolean} [isInitialSync] Indicates whether the state update is part of the initial synchronization process. * @returns {void} This method does not return a value. */ updateCheckboxState(changedItem, isInitialSync = false) { this.isInitialSync = isInitialSync; this.propagateStateDownwards(changedItem); this.propagateStateUpwards(changedItem); } /** * Updates the state of the parent item based on the state of its child items. * Recursively propagates changes up to all parent items to reflect the selection * or indeterminate state accurately. * @param {object} item The current tree item whose parent state needs to be updated. * It is expected to have properties `selected`, `indeterminate`, * and a method `getChildrenItems({ includeDisabled: boolean })`. * @returns {void} This method does not return a value. */ updateParentState(item) { var _a; const children = item.getChildrenItems({ includeDisabled: false }); if (children.length) { const areAllChildrenChecked = children.every((child) => child.selected); const areSomeChildrenChecked = children.some((child) => child.selected); const areSomeChildrenIndeterminate = children.some((child) => child.indeterminate); item.selected = areAllChildrenChecked; item.indeterminate = areSomeChildrenIndeterminate || areSomeChildrenChecked && !areAllChildrenChecked; } else { item.indeterminate = false; } const parent = (_a = item.parentElement) == null ? void 0 : _a.closest("wje-tree-item"); if (parent) { this.updateParentState(parent); } } /** * Propagates the state changes of an item upwards through its ancestors in the hierarchy. * Calls the `updateParentState` method for each parent element until no parent exists. * @param {HTMLElement} item The current item whose state to propagate to its parent. * @returns {void} This method does not return a value. */ propagateStateUpwards(item) { var _a; const parent = (_a = item.parentElement) == null ? void 0 : _a.closest("wje-tree-item"); if (parent) { this.updateParentState(parent); this.propagateStateUpwards(parent); } } /** * Propagates the selected state of an item to its children recursively. Depending on the `isInitialSync` flag, * it also determines how the state should be applied to the child items and updates the parent state if needed. * @param {object} item The item whose state is being propagated to its child items. The item must have properties * such as `selected` and methods like `getChildrenItems` to retrieve its child elements. * @returns {void} This method does not return a value. */ propagateStateDownwards(item) { const isChecked = item.selected; item.getChildrenItems().forEach((child) => { child.selected = this.isInitialSync ? isChecked || child.selected : !child.disabled && isChecked; this.propagateStateDownwards(child); }); if (this.isInitialSync) { this.updateParentState(item); } } } Tree.define("wje-tree", Tree); export { Tree as default }; //# sourceMappingURL=wje-tree.js.map