@esri/calcite-components
Version:
Web Components for Esri's Calcite Design System.
609 lines (608 loc) • 20 kB
JavaScript
/*!
* All material copyright ESRI, All Rights Reserved, unless otherwise specified.
* See https://github.com/Esri/calcite-components/blob/master/LICENSE.md for details.
* v1.5.0-next.4
*/
import { h, Host } from "@stencil/core";
import { slotChangeHasAssignedElement, filterDirectChildren, getElementDir, getSlotted, nodeListToArray, toAriaBoolean } from "../../utils/dom";
import { connectConditionalSlotComponent, disconnectConditionalSlotComponent } from "../../utils/conditionalSlot";
import { connectInteractive, disconnectInteractive, updateHostInteraction } from "../../utils/interactive";
import { onToggleOpenCloseComponent } from "../../utils/openCloseComponent";
import { CSS_UTILITY } from "../../utils/resources";
import { CSS, ICONS, SLOTS } from "./resources";
/**
* @slot - A slot for adding text.
* @slot children - A slot for adding nested `calcite-tree` elements.
* @slot actions-end - A slot for adding actions to the end of the component. It is recommended to use two or fewer actions.
*/
export class TreeItem {
constructor() {
this.openTransitionProp = "opacity";
this.transitionProp = "expanded";
this.iconClickHandler = (event) => {
event.stopPropagation();
this.expanded = !this.expanded;
};
this.childrenClickHandler = (event) => event.stopPropagation();
this.updateParentIsExpanded = (el, expanded) => {
const items = getSlotted(el, SLOTS.children, {
all: true,
selector: "calcite-tree-item"
});
items.forEach((item) => (item.parentExpanded = expanded));
};
this.actionsEndSlotChangeHandler = (event) => {
this.hasEndActions = slotChangeHasAssignedElement(event);
};
this.disabled = false;
this.expanded = false;
this.iconFlipRtl = undefined;
this.iconStart = undefined;
this.selected = false;
this.parentExpanded = false;
this.depth = -1;
this.hasChildren = null;
this.lines = undefined;
this.scale = undefined;
this.indeterminate = false;
this.selectionMode = undefined;
this.updateAfterInitialRender = false;
this.hasEndActions = false;
}
expandedHandler(newValue) {
this.updateParentIsExpanded(this.el, newValue);
onToggleOpenCloseComponent(this, true);
}
getselectionMode() {
this.isSelectionMultiLike =
this.selectionMode === "multiple" || this.selectionMode === "multichildren";
}
/**
* Defines method for `beforeOpen` event handler.
*/
onBeforeOpen() {
this.transitionEl.style.transform = "scaleY(1)";
}
/**
* Defines method for `open` event handler:
*/
onOpen() {
this.transitionEl.style.transform = "none";
}
/**
* Defines method for `beforeClose` event handler:
*/
onBeforeClose() {
// pattern needs to be defined on how we emit events for components without `open` prop.
}
/**
* Defines method for `close` event handler:
*/
onClose() {
this.transitionEl.style.transform = "scaleY(0)";
}
//--------------------------------------------------------------------------
//
// Lifecycle
//
//--------------------------------------------------------------------------
connectedCallback() {
this.parentTreeItem = this.el.parentElement?.closest("calcite-tree-item");
if (this.parentTreeItem) {
const { expanded } = this.parentTreeItem;
this.updateParentIsExpanded(this.parentTreeItem, expanded);
}
connectConditionalSlotComponent(this);
connectInteractive(this);
}
disconnectedCallback() {
disconnectConditionalSlotComponent(this);
disconnectInteractive(this);
}
componentWillRender() {
this.hasChildren = !!this.el.querySelector("calcite-tree");
this.depth = 0;
let parentTree = this.el.closest("calcite-tree");
if (!parentTree) {
return;
}
this.selectionMode = parentTree.selectionMode;
this.scale = parentTree.scale || "m";
this.lines = parentTree.lines;
let nextParentTree;
while (parentTree) {
nextParentTree = parentTree.parentElement?.closest("calcite-tree");
if (nextParentTree === parentTree) {
break;
}
else {
parentTree = nextParentTree;
this.depth = this.depth + 1;
}
}
}
componentWillLoad() {
if (this.expanded) {
onToggleOpenCloseComponent(this, true);
}
requestAnimationFrame(() => (this.updateAfterInitialRender = true));
}
componentDidLoad() {
this.updateAncestorTree();
}
componentDidRender() {
updateHostInteraction(this, () => this.parentExpanded || this.depth === 1);
}
render() {
const rtl = getElementDir(this.el) === "rtl";
const showBulletPoint = this.selectionMode === "single" || this.selectionMode === "children";
const showCheckmark = this.selectionMode === "multiple" || this.selectionMode === "multichildren";
const showBlank = this.selectionMode === "none" && !this.hasChildren;
const chevron = this.hasChildren ? (h("calcite-icon", { class: {
[CSS.chevron]: true,
[CSS_UTILITY.rtl]: rtl
}, "data-test-id": "icon", icon: ICONS.chevronRight, onClick: this.iconClickHandler, scale: this.scale === "l" ? "m" : "s" })) : null;
const defaultSlotNode = h("slot", { key: "default-slot" });
const checkbox = this.selectionMode === "ancestors" ? (h("label", { class: CSS.checkboxLabel, key: "checkbox-label" }, h("calcite-checkbox", { checked: this.selected, class: CSS.checkbox, "data-test-id": "checkbox", indeterminate: this.hasChildren && this.indeterminate, scale: this.scale, tabIndex: -1 }), defaultSlotNode)) : null;
const selectedIcon = showBulletPoint
? ICONS.bulletPoint
: showCheckmark
? ICONS.checkmark
: showBlank
? ICONS.blank
: null;
const itemIndicator = selectedIcon ? (h("calcite-icon", { class: {
[CSS.bulletPointIcon]: selectedIcon === ICONS.bulletPoint,
[CSS.checkmarkIcon]: selectedIcon === ICONS.checkmark,
[CSS_UTILITY.rtl]: rtl
}, icon: selectedIcon, scale: this.scale === "l" ? "m" : "s" })) : null;
const hidden = !(this.parentExpanded || this.depth === 1);
const isExpanded = this.updateAfterInitialRender && this.expanded;
const { hasEndActions } = this;
const slotNode = (h("slot", { key: "actionsEndSlot", name: SLOTS.actionsEnd, onSlotchange: this.actionsEndSlotChangeHandler }));
const iconStartEl = (h("calcite-icon", { class: CSS.iconStart, flipRtl: this.iconFlipRtl === "start" || this.iconFlipRtl === "both", icon: this.iconStart, scale: this.scale === "l" ? "m" : "s" }));
return (h(Host, { "aria-expanded": this.hasChildren ? toAriaBoolean(isExpanded) : undefined, "aria-hidden": toAriaBoolean(hidden), "aria-selected": this.selected ? "true" : showCheckmark ? "false" : undefined, "calcite-hydrated-hidden": hidden, role: "treeitem" }, h("div", { class: { [CSS.itemExpanded]: isExpanded } }, h("div", { class: CSS.nodeAndActionsContainer }, h("div", { class: {
[CSS.nodeContainer]: true,
[CSS_UTILITY.rtl]: rtl
}, "data-selection-mode": this.selectionMode,
// eslint-disable-next-line react/jsx-sort-props
ref: (el) => (this.defaultSlotWrapper = el) }, chevron, itemIndicator, this.iconStart ? iconStartEl : null, checkbox ? checkbox : defaultSlotNode), h("div", { class: CSS.actionsEnd, hidden: !hasEndActions, ref: (el) => (this.actionSlotWrapper = el) }, slotNode)), h("div", { class: {
[CSS.childrenContainer]: true,
[CSS_UTILITY.rtl]: rtl
}, "data-test-id": "calcite-tree-children", onClick: this.childrenClickHandler, role: this.hasChildren ? "group" : undefined,
// eslint-disable-next-line react/jsx-sort-props
ref: (el) => this.setTransitionEl(el) }, h("slot", { name: SLOTS.children })))));
}
setTransitionEl(el) {
this.transitionEl = el;
}
//--------------------------------------------------------------------------
//
// Event Listeners
//
//--------------------------------------------------------------------------
onClick(event) {
if (this.disabled || this.isActionEndEvent(event)) {
return;
}
// Solve for if the item is clicked somewhere outside the slotted anchor.
// Anchor is triggered anywhere you click
const [link] = filterDirectChildren(this.el, "a");
if (link && event.composedPath()[0].tagName.toLowerCase() !== "a") {
const target = link.target === "" ? "_self" : link.target;
window.open(link.href, target);
}
this.calciteInternalTreeItemSelect.emit({
modifyCurrentSelection: this.selectionMode === "ancestors" || this.isSelectionMultiLike,
forceToggle: false
});
}
keyDownHandler(event) {
let root;
if (this.isActionEndEvent(event)) {
return;
}
switch (event.key) {
case " ":
if (this.selectionMode === "none") {
return;
}
this.calciteInternalTreeItemSelect.emit({
modifyCurrentSelection: this.isSelectionMultiLike,
forceToggle: false
});
event.preventDefault();
break;
case "Enter":
if (this.selectionMode === "none") {
return;
}
// activates a node, i.e., performs its default action. For parent nodes, one possible default action is to open or close the node. In single-select trees where selection does not follow focus (see note below), the default action is typically to select the focused node.
const link = nodeListToArray(this.el.children).find((el) => el.matches("a"));
if (link) {
link.click();
this.selected = true;
}
else {
this.calciteInternalTreeItemSelect.emit({
modifyCurrentSelection: this.isSelectionMultiLike,
forceToggle: false
});
}
event.preventDefault();
break;
case "Home":
root = this.el.closest("calcite-tree:not([child])");
const firstNode = root.querySelector("calcite-tree-item");
firstNode?.focus();
break;
case "End":
root = this.el.closest("calcite-tree:not([child])");
let currentNode = root.children[root.children.length - 1]; // last child
let currentTree = nodeListToArray(currentNode.children).find((el) => el.matches("calcite-tree"));
while (currentTree) {
currentNode = currentTree.children[root.children.length - 1];
currentTree = nodeListToArray(currentNode.children).find((el) => el.matches("calcite-tree"));
}
currentNode?.focus();
break;
}
}
//--------------------------------------------------------------------------
//
// Private Methods
//
//--------------------------------------------------------------------------
isActionEndEvent(event) {
const composedPath = event.composedPath();
return composedPath.includes(this.actionSlotWrapper);
}
/**
* This is meant to be called in `componentDidLoad` in order to take advantage of the hierarchical component lifecycle
* and help check for item selection as items are initialized
*
* @private
*/
updateAncestorTree() {
const parentItem = this.parentTreeItem;
if (this.selectionMode !== "ancestors" || !parentItem) {
return;
}
if (this.selected) {
const parentTree = this.el.parentElement;
const siblings = Array.from(parentTree?.children);
const selectedSiblings = siblings.filter((child) => child.selected);
if (siblings.length === selectedSiblings.length) {
parentItem.selected = true;
parentItem.indeterminate = false;
}
else if (selectedSiblings.length > 0) {
parentItem.indeterminate = true;
}
}
else if (this.indeterminate) {
const parentItem = this.parentTreeItem;
parentItem.indeterminate = true;
}
}
static get is() { return "calcite-tree-item"; }
static get encapsulation() { return "shadow"; }
static get originalStyleUrls() {
return {
"$": ["tree-item.scss"]
};
}
static get styleUrls() {
return {
"$": ["tree-item.css"]
};
}
static get properties() {
return {
"disabled": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "When `true`, interaction is prevented and the component is displayed with lower opacity."
},
"attribute": "disabled",
"reflect": true,
"defaultValue": "false"
},
"expanded": {
"type": "boolean",
"mutable": true,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "When `true`, the component is expanded."
},
"attribute": "expanded",
"reflect": true,
"defaultValue": "false"
},
"iconFlipRtl": {
"type": "string",
"mutable": false,
"complexType": {
"original": "FlipContext",
"resolved": "\"both\" | \"end\" | \"start\"",
"references": {
"FlipContext": {
"location": "import",
"path": "../interfaces"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "When `true`, the icon will be flipped when the element direction is right-to-left (`\"rtl\"`)."
},
"attribute": "icon-flip-rtl",
"reflect": true
},
"iconStart": {
"type": "string",
"mutable": false,
"complexType": {
"original": "string",
"resolved": "string",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Specifies an icon to display at the start of the component."
},
"attribute": "icon-start",
"reflect": true
},
"selected": {
"type": "boolean",
"mutable": true,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "When `true`, the component is selected."
},
"attribute": "selected",
"reflect": true,
"defaultValue": "false"
},
"parentExpanded": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [{
"name": "internal",
"text": undefined
}],
"text": ""
},
"attribute": "parent-expanded",
"reflect": false,
"defaultValue": "false"
},
"depth": {
"type": "number",
"mutable": true,
"complexType": {
"original": "number",
"resolved": "number",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [{
"name": "internal",
"text": undefined
}],
"text": ""
},
"attribute": "depth",
"reflect": true,
"defaultValue": "-1"
},
"hasChildren": {
"type": "boolean",
"mutable": true,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [{
"name": "internal",
"text": undefined
}],
"text": ""
},
"attribute": "has-children",
"reflect": true,
"defaultValue": "null"
},
"lines": {
"type": "boolean",
"mutable": true,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [{
"name": "internal",
"text": undefined
}],
"text": ""
},
"attribute": "lines",
"reflect": true
},
"scale": {
"type": "string",
"mutable": true,
"complexType": {
"original": "Scale",
"resolved": "\"l\" | \"m\" | \"s\"",
"references": {
"Scale": {
"location": "import",
"path": "../interfaces"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [{
"name": "internal",
"text": undefined
}],
"text": ""
},
"attribute": "scale",
"reflect": true
},
"indeterminate": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [{
"name": "internal",
"text": undefined
}],
"text": "In ancestor selection mode, show as indeterminate when only some children are selected."
},
"attribute": "indeterminate",
"reflect": true,
"defaultValue": "false"
},
"selectionMode": {
"type": "string",
"mutable": true,
"complexType": {
"original": "SelectionMode",
"resolved": "\"ancestors\" | \"children\" | \"multichildren\" | \"multiple\" | \"none\" | \"single\" | \"single-persist\"",
"references": {
"SelectionMode": {
"location": "import",
"path": "../interfaces"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [{
"name": "internal",
"text": undefined
}],
"text": ""
},
"attribute": "selection-mode",
"reflect": true
}
};
}
static get states() {
return {
"updateAfterInitialRender": {},
"hasEndActions": {}
};
}
static get events() {
return [{
"method": "calciteInternalTreeItemSelect",
"name": "calciteInternalTreeItemSelect",
"bubbles": true,
"cancelable": false,
"composed": true,
"docs": {
"tags": [{
"name": "internal",
"text": undefined
}],
"text": ""
},
"complexType": {
"original": "TreeItemSelectDetail",
"resolved": "TreeItemSelectDetail",
"references": {
"TreeItemSelectDetail": {
"location": "import",
"path": "./interfaces"
}
}
}
}];
}
static get elementRef() { return "el"; }
static get watchers() {
return [{
"propName": "expanded",
"methodName": "expandedHandler"
}, {
"propName": "selectionMode",
"methodName": "getselectionMode"
}];
}
static get listeners() {
return [{
"name": "click",
"method": "onClick",
"target": undefined,
"capture": false,
"passive": false
}, {
"name": "keydown",
"method": "keyDownHandler",
"target": undefined,
"capture": false,
"passive": false
}];
}
}