@esri/calcite-components
Version:
Web Components for Esri's Calcite Design System.
631 lines (630 loc) • 20.2 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 } 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
}];
}
}