UNPKG

@esri/calcite-components

Version:

Web Components for Esri's Calcite Design System.

631 lines (630 loc) • 20.2 kB
/*! * 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 } from "@stencil/core"; import { debounce } from "lodash-es"; import { toAriaBoolean } from "../../utils/dom"; import { connectInteractive, disconnectInteractive, updateHostInteraction } from "../../utils/interactive"; import { createObserver } from "../../utils/observers"; import { MAX_COLUMNS } from "../list-item/resources"; import { getListItemChildren, updateListItemChildren } from "../list-item/utils"; import { CSS, debounceTimeout } from "./resources"; const listItemSelector = "calcite-list-item"; const parentSelector = "calcite-list-item-group, calcite-list-item"; import { componentLoaded, setComponentLoaded, setUpLoadableComponent } from "../../utils/loadable"; /** * A general purpose list that enables users to construct list items that conform to Calcite styling. * * @slot - A slot for adding `calcite-list-item` elements. */ export class List { constructor() { this.listItems = []; this.enabledListItems = []; this.mutationObserver = createObserver("mutation", () => this.updateListItems()); // -------------------------------------------------------------------------- // // Private Methods // // -------------------------------------------------------------------------- this.handleDefaultSlotChange = (event) => { updateListItemChildren(getListItemChildren(event)); }; this.setActiveListItem = () => { const { enabledListItems } = this; if (!enabledListItems.some((item) => item.active)) { if (enabledListItems[0]) { enabledListItems[0].active = true; } } }; this.updateSelectedItems = (emit = false) => { this.selectedItems = this.enabledListItems.filter((item) => item.selected); if (emit) { this.calciteListChange.emit(); } }; this.updateFilteredItems = (emit = false) => { const { listItems, filteredData, filterText } = this; const values = filteredData.map((item) => item.value); const lastDescendantItems = listItems?.filter((listItem) => listItems.every((li) => li === listItem || !listItem.contains(li))); const filteredItems = listItems.filter((item) => !filterText || values.includes(item.value)) || []; const visibleParents = new WeakSet(); lastDescendantItems.forEach((listItem) => this.filterElements({ el: listItem, filteredItems, visibleParents })); this.filteredItems = filteredItems; if (emit) { this.calciteListFilter.emit(); } }; this.handleFilter = (event) => { event.stopPropagation(); const { filteredItems, value } = event.currentTarget; this.filteredData = filteredItems; this.filterText = value; this.updateListItems(true); }; this.getItemData = () => { return this.listItems.map((item) => ({ label: item.label, description: item.description, metadata: item.metadata, value: item.value })); }; this.updateListItems = debounce((emit = false) => { const { selectionAppearance, selectionMode } = this; const items = this.queryListItems(); items.forEach((item) => { item.selectionAppearance = selectionAppearance; item.selectionMode = selectionMode; }); this.listItems = items; if (this.filterEnabled) { this.dataForFilter = this.getItemData(); if (this.filterEl) { this.filterEl.items = this.dataForFilter; } } this.updateFilteredItems(emit); this.enabledListItems = items.filter((item) => !item.disabled && !item.closed); this.setActiveListItem(); this.updateSelectedItems(emit); }, debounceTimeout); this.queryListItems = () => { return Array.from(this.el.querySelectorAll(listItemSelector)); }; this.focusRow = (focusEl) => { const { enabledListItems } = this; if (!focusEl) { return; } enabledListItems.forEach((listItem) => (listItem.active = listItem === focusEl)); focusEl.setFocus(); }; this.isNavigable = (listItem) => { const parentListItemEl = listItem.parentElement?.closest(listItemSelector); if (!parentListItemEl) { return true; } return parentListItemEl.open && this.isNavigable(parentListItemEl); }; this.handleListKeydown = (event) => { if (event.defaultPrevented) { return; } const { key } = event; const filteredItems = this.enabledListItems.filter((listItem) => this.isNavigable(listItem)); const currentIndex = filteredItems.findIndex((listItem) => listItem.active); if (key === "ArrowDown") { event.preventDefault(); const nextIndex = currentIndex + 1; if (filteredItems[nextIndex]) { this.focusRow(filteredItems[nextIndex]); } } else if (key === "ArrowUp") { event.preventDefault(); const prevIndex = currentIndex - 1; if (filteredItems[prevIndex]) { this.focusRow(filteredItems[prevIndex]); } } else if (key === "Home") { event.preventDefault(); const homeItem = filteredItems[0]; if (homeItem) { this.focusRow(homeItem); } } else if (key === "End") { event.preventDefault(); const endItem = filteredItems[filteredItems.length - 1]; if (endItem) { this.focusRow(endItem); } } }; this.disabled = false; this.filterEnabled = false; this.filteredItems = []; this.filteredData = []; this.filterPlaceholder = undefined; this.filterText = undefined; this.label = undefined; this.loading = false; this.openable = false; this.selectedItems = []; this.selectionMode = "none"; this.selectionAppearance = "icon"; this.dataForFilter = []; } handleFilterEnabledChange() { this.updateListItems(); } handleSelectionAppearanceChange() { this.updateListItems(); } handleCalciteInternalFocusPreviousItem(event) { event.stopPropagation(); const { enabledListItems } = this; const currentIndex = enabledListItems.findIndex((listItem) => listItem.active); const prevIndex = currentIndex - 1; if (enabledListItems[prevIndex]) { this.focusRow(enabledListItems[prevIndex]); } } handleCalciteInternalListItemActive(event) { const target = event.target; const { listItems } = this; listItems.forEach((listItem) => { listItem.active = listItem === target; }); } handleCalciteListItemSelect() { this.updateSelectedItems(true); } handleCalciteInternalListItemSelect(event) { const target = event.target; const { listItems, selectionMode } = this; if (target.selected && (selectionMode === "single" || selectionMode === "single-persist")) { listItems.forEach((listItem) => (listItem.selected = listItem === target)); } this.updateSelectedItems(); } handleCalciteListItemClose() { this.updateListItems(true); } //-------------------------------------------------------------------------- // // Lifecycle // //-------------------------------------------------------------------------- connectedCallback() { this.mutationObserver?.observe(this.el, { childList: true, subtree: true }); this.updateListItems(); connectInteractive(this); } disconnectedCallback() { this.mutationObserver?.disconnect(); disconnectInteractive(this); } componentWillLoad() { setUpLoadableComponent(this); } componentDidRender() { updateHostInteraction(this); } componentDidLoad() { setComponentLoaded(this); const { filterEl } = this; const filteredItems = filterEl?.filteredItems; if (filteredItems) { this.filteredData = filteredItems; } this.updateListItems(); } // -------------------------------------------------------------------------- // // Public Methods // // -------------------------------------------------------------------------- /** Sets focus on the component's first focusable element. */ async setFocus() { await componentLoaded(this); this.enabledListItems.find((listItem) => listItem.active)?.setFocus(); } // -------------------------------------------------------------------------- // // Render Methods // // -------------------------------------------------------------------------- render() { const { loading, label, disabled, dataForFilter, filterEnabled, filterPlaceholder, filterText } = this; return (h("div", { class: CSS.container }, loading ? h("calcite-scrim", { class: CSS.scrim, loading: loading }) : null, h("table", { "aria-busy": toAriaBoolean(loading), "aria-label": label || "", class: CSS.table, onKeyDown: this.handleListKeydown, role: "treegrid" }, filterEnabled ? (h("thead", null, h("tr", { class: { [CSS.sticky]: true } }, h("th", { colSpan: MAX_COLUMNS }, h("calcite-filter", { "aria-label": filterPlaceholder, disabled: loading || disabled, items: dataForFilter, onCalciteFilterChange: this.handleFilter, placeholder: filterPlaceholder, value: filterText, // eslint-disable-next-line react/jsx-sort-props ref: (el) => (this.filterEl = el) }))))) : null, h("tbody", { class: CSS.tableContainer }, h("slot", { onSlotchange: this.handleDefaultSlotChange }))))); } filterElements({ el, filteredItems, visibleParents }) { const hidden = !visibleParents.has(el) && !filteredItems.includes(el); el.hidden = hidden; const closestParent = el.parentElement.closest(parentSelector); if (!closestParent) { return; } if (!hidden) { visibleParents.add(closestParent); } this.filterElements({ el: closestParent, filteredItems, visibleParents }); } static get is() { return "calcite-list"; } static get encapsulation() { return "shadow"; } static get originalStyleUrls() { return { "$": ["list.scss"] }; } static get styleUrls() { return { "$": ["list.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" }, "filterEnabled": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "When `true`, an input appears at the top of the component that can be used by end users to filter `calcite-list-item`s." }, "attribute": "filter-enabled", "reflect": true, "defaultValue": "false" }, "filteredItems": { "type": "unknown", "mutable": true, "complexType": { "original": "HTMLCalciteListItemElement[]", "resolved": "HTMLCalciteListItemElement[]", "references": { "HTMLCalciteListItemElement": { "location": "global" } } }, "required": false, "optional": false, "docs": { "tags": [{ "name": "readonly", "text": undefined }], "text": "The currently filtered `calcite-list-item`s." }, "defaultValue": "[]" }, "filteredData": { "type": "unknown", "mutable": true, "complexType": { "original": "ItemData", "resolved": "{ label: string; description: string; metadata: Record<string, unknown>; value: string; }[]", "references": { "ItemData": { "location": "import", "path": "../list-item/interfaces" } } }, "required": false, "optional": false, "docs": { "tags": [{ "name": "readonly", "text": undefined }], "text": "The currently filtered `calcite-list-item` data." }, "defaultValue": "[]" }, "filterPlaceholder": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Placeholder text for the component's filter input field." }, "attribute": "filter-placeholder", "reflect": true }, "filterText": { "type": "string", "mutable": true, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Text for the component's filter input field." }, "attribute": "filter-text", "reflect": true }, "label": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Specifies an accessible name for the component." }, "attribute": "label", "reflect": false }, "loading": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "When `true`, a busy indicator is displayed." }, "attribute": "loading", "reflect": true, "defaultValue": "false" }, "openable": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "internal", "text": undefined }], "text": "One of the items within the list can be opened." }, "attribute": "openable", "reflect": false, "defaultValue": "false" }, "selectedItems": { "type": "unknown", "mutable": true, "complexType": { "original": "HTMLCalciteListItemElement[]", "resolved": "HTMLCalciteListItemElement[]", "references": { "HTMLCalciteListItemElement": { "location": "global" } } }, "required": false, "optional": false, "docs": { "tags": [{ "name": "readonly", "text": undefined }], "text": "The currently selected items." }, "defaultValue": "[]" }, "selectionMode": { "type": "string", "mutable": false, "complexType": { "original": "Extract<\n \"none\" | \"multiple\" | \"single\" | \"single-persist\",\n SelectionMode\n >", "resolved": "\"multiple\" | \"none\" | \"single\" | \"single-persist\"", "references": { "Extract": { "location": "global" }, "SelectionMode": { "location": "import", "path": "../interfaces" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Specifies the selection mode - `\"multiple\"` (allow any number of selected items), `\"single\"` (allow one selected item), `\"single-persist\"` (allow one selected item and prevent de-selection), or `\"none\"` (no selected items)." }, "attribute": "selection-mode", "reflect": true, "defaultValue": "\"none\"" }, "selectionAppearance": { "type": "string", "mutable": false, "complexType": { "original": "SelectionAppearance", "resolved": "\"border\" | \"icon\"", "references": { "SelectionAppearance": { "location": "import", "path": "./resources" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Specifies the selection appearance - `\"icon\"` (displays a checkmark or dot) or `\"border\"` (displays a border)." }, "attribute": "selection-appearance", "reflect": true, "defaultValue": "\"icon\"" } }; } static get states() { return { "dataForFilter": {} }; } static get events() { return [{ "method": "calciteListChange", "name": "calciteListChange", "bubbles": true, "cancelable": false, "composed": true, "docs": { "tags": [], "text": "Emits when any of the list item selections have changed." }, "complexType": { "original": "void", "resolved": "void", "references": {} } }, { "method": "calciteListFilter", "name": "calciteListFilter", "bubbles": true, "cancelable": false, "composed": true, "docs": { "tags": [], "text": "Emits when the component's filter has changed." }, "complexType": { "original": "void", "resolved": "void", "references": {} } }]; } static get methods() { return { "setFocus": { "complexType": { "signature": "() => Promise<void>", "parameters": [], "references": { "Promise": { "location": "global" } }, "return": "Promise<void>" }, "docs": { "text": "Sets focus on the component's first focusable element.", "tags": [] } } }; } static get elementRef() { return "el"; } static get watchers() { return [{ "propName": "filterEnabled", "methodName": "handleFilterEnabledChange" }, { "propName": "selectionMode", "methodName": "handleSelectionAppearanceChange" }, { "propName": "selectionAppearance", "methodName": "handleSelectionAppearanceChange" }]; } static get listeners() { return [{ "name": "calciteInternalFocusPreviousItem", "method": "handleCalciteInternalFocusPreviousItem", "target": undefined, "capture": false, "passive": false }, { "name": "calciteInternalListItemActive", "method": "handleCalciteInternalListItemActive", "target": undefined, "capture": false, "passive": false }, { "name": "calciteListItemSelect", "method": "handleCalciteListItemSelect", "target": undefined, "capture": false, "passive": false }, { "name": "calciteInternalListItemSelect", "method": "handleCalciteInternalListItemSelect", "target": undefined, "capture": false, "passive": false }, { "name": "calciteListItemClose", "method": "handleCalciteListItemClose", "target": undefined, "capture": false, "passive": false }]; } }