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
JavaScript
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