UNPKG

@esri/calcite-components

Version:

Web Components for Esri's Calcite Design System.

819 lines (818 loc) • 27.5 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, Host } from "@stencil/core"; import { getElementDir, slotChangeHasAssignedElement, toAriaBoolean } from "../../utils/dom"; import { connectInteractive, disconnectInteractive, updateHostInteraction } from "../../utils/interactive"; import { CSS, ICONS, SLOTS } from "./resources"; import { getDepth, getListItemChildren, updateListItemChildren } from "./utils"; import { connectLocalized, disconnectLocalized } from "../../utils/locale"; import { connectMessages, disconnectMessages, setUpMessages, updateMessages } from "../../utils/t9n"; const focusMap = new Map(); const listSelector = "calcite-list"; import { componentLoaded, setComponentLoaded, setUpLoadableComponent } from "../../utils/loadable"; /** * @slot - A slot for adding `calcite-list-item` and `calcite-list-item-group` elements. * @slot actions-start - A slot for adding actionable `calcite-action` elements before the content of the component. * @slot content-start - A slot for adding non-actionable elements before the label and description of the component. * @slot content - A slot for adding non-actionable, centered content in place of the `label` and `description` of the component. * @slot content-end - A slot for adding non-actionable elements after the label and description of the component. * @slot actions-end - A slot for adding actionable `calcite-action` elements after the content of the component. */ export class ListItem { constructor() { // -------------------------------------------------------------------------- // // Private Methods // // -------------------------------------------------------------------------- this.closeClickHandler = () => { this.closed = true; this.calciteListItemClose.emit(); }; this.handleContentSlotChange = (event) => { this.hasCustomContent = slotChangeHasAssignedElement(event); }; this.handleActionsStartSlotChange = (event) => { this.hasActionsStart = slotChangeHasAssignedElement(event); }; this.handleActionsEndSlotChange = (event) => { this.hasActionsEnd = slotChangeHasAssignedElement(event); }; this.handleContentStartSlotChange = (event) => { this.hasContentStart = slotChangeHasAssignedElement(event); }; this.handleContentEndSlotChange = (event) => { this.hasContentEnd = slotChangeHasAssignedElement(event); }; this.handleDefaultSlotChange = (event) => { const { parentListEl } = this; const listItemChildren = getListItemChildren(event); updateListItemChildren(listItemChildren); const openable = !!listItemChildren.length; if (openable && parentListEl && !parentListEl.openable) { parentListEl.openable = true; } this.openable = openable; if (!openable) { this.open = false; } }; this.toggleOpen = () => { this.open = !this.open; }; this.itemClicked = (event) => { if (event.defaultPrevented) { return; } this.toggleSelected(); this.calciteInternalListItemActive.emit(); }; this.toggleSelected = () => { const { selectionMode, selected } = this; if (this.disabled) { return; } if (selectionMode === "multiple" || selectionMode === "single") { this.selected = !selected; } else if (selectionMode === "single-persist") { this.selected = true; } this.calciteListItemSelect.emit(); }; this.handleItemKeyDown = (event) => { if (event.defaultPrevented) { return; } const { key } = event; const composedPath = event.composedPath(); const { containerEl, contentEl, actionsStartEl, actionsEndEl, open, openable } = this; const cells = [actionsStartEl, contentEl, actionsEndEl].filter(Boolean); const currentIndex = cells.findIndex((cell) => composedPath.includes(cell)); if (key === "Enter") { event.preventDefault(); this.toggleSelected(); } else if (key === "ArrowRight") { event.preventDefault(); const nextIndex = currentIndex + 1; if (currentIndex === -1) { if (!open && openable) { this.open = true; this.focusCell(null); } else if (cells[0]) { this.focusCell(cells[0]); } } else if (cells[currentIndex] && cells[nextIndex]) { this.focusCell(cells[nextIndex]); } } else if (key === "ArrowLeft") { event.preventDefault(); const prevIndex = currentIndex - 1; if (currentIndex === -1) { this.focusCell(null); if (open && openable) { this.open = false; } else { this.calciteInternalFocusPreviousItem.emit(); } } else if (currentIndex === 0) { this.focusCell(null); containerEl.focus(); } else if (cells[currentIndex] && cells[prevIndex]) { this.focusCell(cells[prevIndex]); } } }; this.focusCellNull = () => { this.focusCell(null); }; this.focusCell = (focusEl, saveFocusIndex = true) => { const { contentEl, actionsStartEl, actionsEndEl, parentListEl } = this; if (saveFocusIndex) { focusMap.set(parentListEl, null); } [actionsStartEl, contentEl, actionsEndEl].filter(Boolean).forEach((tableCell, cellIndex) => { const tabIndexAttr = "tabindex"; if (tableCell === focusEl) { tableCell.setAttribute(tabIndexAttr, "0"); saveFocusIndex && focusMap.set(parentListEl, cellIndex); } else { tableCell.removeAttribute(tabIndexAttr); } }); focusEl?.focus(); }; this.active = false; this.closable = false; this.closed = false; this.description = undefined; this.disabled = false; this.label = undefined; this.metadata = undefined; this.open = false; this.setSize = null; this.setPosition = null; this.selected = false; this.value = undefined; this.selectionMode = null; this.selectionAppearance = null; this.messageOverrides = undefined; this.messages = undefined; this.effectiveLocale = ""; this.defaultMessages = undefined; this.level = null; this.visualLevel = null; this.parentListEl = undefined; this.openable = false; this.hasActionsStart = false; this.hasActionsEnd = false; this.hasCustomContent = false; this.hasContentStart = false; this.hasContentEnd = false; } activeHandler(active) { if (!active) { this.focusCell(null, false); } } handleSelectedChange() { this.calciteInternalListItemSelect.emit(); } onMessagesChange() { /* wired up by t9n util */ } effectiveLocaleChange() { updateMessages(this, this.effectiveLocale); } connectedCallback() { connectInteractive(this); connectLocalized(this); connectMessages(this); const { el } = this; this.parentListEl = el.closest(listSelector); this.level = getDepth(el) + 1; this.visualLevel = getDepth(el, true); this.setSelectionDefaults(); } async componentWillLoad() { setUpLoadableComponent(this); await setUpMessages(this); } componentDidLoad() { setComponentLoaded(this); } componentDidRender() { updateHostInteraction(this, "managed"); } disconnectedCallback() { disconnectInteractive(this); disconnectLocalized(this); disconnectMessages(this); } // -------------------------------------------------------------------------- // // Public Methods // // -------------------------------------------------------------------------- /** Sets focus on the component. */ async setFocus() { await componentLoaded(this); const { containerEl, contentEl, actionsStartEl, actionsEndEl, parentListEl } = this; const focusIndex = focusMap.get(parentListEl); if (typeof focusIndex === "number") { const cells = [actionsStartEl, contentEl, actionsEndEl].filter(Boolean); if (cells[focusIndex]) { this.focusCell(cells[focusIndex]); } else { containerEl?.focus(); } return; } containerEl?.focus(); } // -------------------------------------------------------------------------- // // Render Methods // // -------------------------------------------------------------------------- renderSelected() { const { selected, selectionMode, selectionAppearance } = this; if (selectionMode === "none" || selectionAppearance === "border") { return null; } return (h("td", { class: CSS.selectionContainer, key: "selection-container", onClick: this.itemClicked }, h("calcite-icon", { icon: selected ? selectionMode === "multiple" ? ICONS.selectedMultiple : ICONS.selectedSingle : ICONS.unselected, scale: "s" }))); } renderOpen() { const { el, open, openable, parentListEl } = this; const dir = getElementDir(el); return openable ? (h("td", { class: CSS.openContainer, key: "open-container", onClick: this.toggleOpen }, h("calcite-icon", { icon: open ? ICONS.open : dir === "rtl" ? ICONS.closedRTL : ICONS.closedLTR, scale: "s" }))) : parentListEl?.openable ? (h("td", { class: CSS.openContainer, key: "open-container", onClick: this.itemClicked }, h("calcite-icon", { icon: ICONS.blank, scale: "s" }))) : null; } renderActionsStart() { const { label, hasActionsStart } = this; return (h("td", { "aria-label": label, class: CSS.actionsStart, hidden: !hasActionsStart, key: "actions-start-container", role: "gridcell", // eslint-disable-next-line react/jsx-sort-props ref: (el) => (this.actionsStartEl = el) }, h("slot", { name: SLOTS.actionsStart, onSlotchange: this.handleActionsStartSlotChange }))); } renderActionsEnd() { const { label, hasActionsEnd, closable, messages } = this; return (h("td", { "aria-label": label, class: CSS.actionsEnd, hidden: !(hasActionsEnd || closable), key: "actions-end-container", role: "gridcell", // eslint-disable-next-line react/jsx-sort-props ref: (el) => (this.actionsEndEl = el) }, h("slot", { name: SLOTS.actionsEnd, onSlotchange: this.handleActionsEndSlotChange }), closable ? (h("calcite-action", { appearance: "transparent", icon: ICONS.close, key: "close-action", label: messages.close, onClick: this.closeClickHandler, text: messages.close })) : null)); } renderContentStart() { const { hasContentStart } = this; return (h("div", { class: CSS.contentStart, hidden: !hasContentStart }, h("slot", { name: SLOTS.contentStart, onSlotchange: this.handleContentStartSlotChange }))); } renderCustomContent() { const { hasCustomContent } = this; return (h("div", { class: CSS.customContent, hidden: !hasCustomContent }, h("slot", { name: SLOTS.content, onSlotchange: this.handleContentSlotChange }))); } renderContentEnd() { const { hasContentEnd } = this; return (h("div", { class: CSS.contentEnd, hidden: !hasContentEnd }, h("slot", { name: SLOTS.contentEnd, onSlotchange: this.handleContentEndSlotChange }))); } renderContentProperties() { const { label, description, hasCustomContent } = this; return !hasCustomContent && (!!label || !!description) ? (h("div", { class: CSS.content, key: "content" }, label ? (h("div", { class: CSS.label, key: "label" }, label)) : null, description ? (h("div", { class: CSS.description, key: "description" }, description)) : null)) : null; } renderContentContainer() { const { description, label, selectionMode, hasCustomContent } = this; const hasCenterContent = hasCustomContent || !!label || !!description; const content = [ this.renderContentStart(), this.renderCustomContent(), this.renderContentProperties(), this.renderContentEnd() ]; return (h("td", { "aria-label": label, class: { [CSS.contentContainer]: true, [CSS.contentContainerSelectable]: selectionMode !== "none", [CSS.contentContainerHasCenterContent]: hasCenterContent }, key: "content-container", onClick: this.itemClicked, role: "gridcell", // eslint-disable-next-line react/jsx-sort-props ref: (el) => (this.contentEl = el) }, content)); } render() { const { openable, open, level, setPosition, setSize, active, label, selected, selectionAppearance, selectionMode, closed } = this; const showBorder = selectionMode !== "none" && selectionAppearance === "border"; const borderSelected = showBorder && selected; const borderUnselected = showBorder && !selected; return (h(Host, null, h("tr", { "aria-expanded": openable ? toAriaBoolean(open) : null, "aria-label": label, "aria-level": level, "aria-posinset": setPosition, "aria-selected": toAriaBoolean(selected), "aria-setsize": setSize, class: { [CSS.container]: true, [CSS.containerBorderSelected]: borderSelected, [CSS.containerBorderUnselected]: borderUnselected }, hidden: closed, onFocus: this.focusCellNull, onKeyDown: this.handleItemKeyDown, role: "row", style: { "--calcite-list-item-spacing-indent-multiplier": `${this.visualLevel}` }, tabIndex: active ? 0 : -1, // eslint-disable-next-line react/jsx-sort-props ref: (el) => (this.containerEl = el) }, this.renderSelected(), this.renderOpen(), this.renderActionsStart(), this.renderContentContainer(), this.renderActionsEnd()), h("div", { class: { [CSS.nestedContainer]: true, [CSS.nestedContainerHidden]: openable && !open } }, h("slot", { onSlotchange: this.handleDefaultSlotChange })))); } setSelectionDefaults() { const { parentListEl, selectionMode, selectionAppearance } = this; if (!parentListEl) { return; } if (!selectionMode) { this.selectionMode = parentListEl.selectionMode; } if (!selectionAppearance) { this.selectionAppearance = parentListEl.selectionAppearance; } } static get is() { return "calcite-list-item"; } static get encapsulation() { return "shadow"; } static get originalStyleUrls() { return { "$": ["list-item.scss"] }; } static get styleUrls() { return { "$": ["list-item.css"] }; } static get assetsDirs() { return ["assets"]; } static get properties() { return { "active": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "internal", "text": undefined }], "text": "Sets the item as focusable. Only one item should be focusable within a list." }, "attribute": "active", "reflect": false, "defaultValue": "false" }, "closable": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "When `true`, a close button is added to the component." }, "attribute": "closable", "reflect": true, "defaultValue": "false" }, "closed": { "type": "boolean", "mutable": true, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "When `true`, hides the component." }, "attribute": "closed", "reflect": true, "defaultValue": "false" }, "description": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "A description for the component. Displays below the label text." }, "attribute": "description", "reflect": false }, "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" }, "label": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "The label text of the component. Displays above the description text." }, "attribute": "label", "reflect": false }, "metadata": { "type": "unknown", "mutable": false, "complexType": { "original": "Record<string, unknown>", "resolved": "{ [x: string]: unknown; }", "references": { "Record": { "location": "global" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Provides additional metadata to the component. Primary use is for a filter on the parent `calcite-list`." } }, "open": { "type": "boolean", "mutable": true, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "When `true`, the item is open to show child components." }, "attribute": "open", "reflect": true, "defaultValue": "false" }, "setSize": { "type": "number", "mutable": false, "complexType": { "original": "number", "resolved": "number", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "internal", "text": undefined }], "text": "Used to specify the aria-setsize attribute to define the number of items in the current set of list for accessibility." }, "attribute": "set-size", "reflect": false, "defaultValue": "null" }, "setPosition": { "type": "number", "mutable": false, "complexType": { "original": "number", "resolved": "number", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "internal", "text": undefined }], "text": "Used to specify the aria-posinset attribute to define the number or position in the current set of list items for accessibility." }, "attribute": "set-position", "reflect": false, "defaultValue": "null" }, "selected": { "type": "boolean", "mutable": true, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "When `true` and the parent `calcite-list`'s `selectionMode` is `\"single\"`, `\"single-persist\"', or `\"multiple\"`, the component is selected." }, "attribute": "selected", "reflect": true, "defaultValue": "false" }, "value": { "type": "any", "mutable": false, "complexType": { "original": "any", "resolved": "any", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "The component's value." }, "attribute": "value", "reflect": false }, "selectionMode": { "type": "string", "mutable": true, "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": [{ "name": "internal", "text": undefined }], "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": false, "defaultValue": "null" }, "selectionAppearance": { "type": "string", "mutable": true, "complexType": { "original": "SelectionAppearance", "resolved": "\"border\" | \"icon\"", "references": { "SelectionAppearance": { "location": "import", "path": "../list/resources" } } }, "required": false, "optional": false, "docs": { "tags": [{ "name": "internal", "text": undefined }], "text": "Specifies the selection appearance - `\"icon\"` (displays a checkmark or dot) or `\"border\"` (displays a border)." }, "attribute": "selection-appearance", "reflect": false, "defaultValue": "null" }, "messageOverrides": { "type": "unknown", "mutable": true, "complexType": { "original": "Partial<ListItemMessages>", "resolved": "{ close?: string; }", "references": { "Partial": { "location": "global" }, "ListItemMessages": { "location": "import", "path": "./assets/list-item/t9n" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Use this property to override individual strings used by the component." } }, "messages": { "type": "unknown", "mutable": true, "complexType": { "original": "ListItemMessages", "resolved": "{ close: string; }", "references": { "ListItemMessages": { "location": "import", "path": "./assets/list-item/t9n" } } }, "required": false, "optional": false, "docs": { "tags": [{ "name": "internal", "text": undefined }], "text": "Made into a prop for testing purposes only" } } }; } static get states() { return { "effectiveLocale": {}, "defaultMessages": {}, "level": {}, "visualLevel": {}, "parentListEl": {}, "openable": {}, "hasActionsStart": {}, "hasActionsEnd": {}, "hasCustomContent": {}, "hasContentStart": {}, "hasContentEnd": {} }; } static get events() { return [{ "method": "calciteListItemSelect", "name": "calciteListItemSelect", "bubbles": true, "cancelable": false, "composed": true, "docs": { "tags": [], "text": "Emits when the item's content is selected." }, "complexType": { "original": "void", "resolved": "void", "references": {} } }, { "method": "calciteListItemClose", "name": "calciteListItemClose", "bubbles": true, "cancelable": false, "composed": true, "docs": { "tags": [], "text": "Fires when the close button is clicked." }, "complexType": { "original": "void", "resolved": "void", "references": {} } }, { "method": "calciteInternalListItemSelect", "name": "calciteInternalListItemSelect", "bubbles": true, "cancelable": false, "composed": true, "docs": { "tags": [{ "name": "internal", "text": undefined }], "text": "" }, "complexType": { "original": "void", "resolved": "void", "references": {} } }, { "method": "calciteInternalListItemActive", "name": "calciteInternalListItemActive", "bubbles": true, "cancelable": false, "composed": true, "docs": { "tags": [{ "name": "internal", "text": undefined }], "text": "" }, "complexType": { "original": "void", "resolved": "void", "references": {} } }, { "method": "calciteInternalFocusPreviousItem", "name": "calciteInternalFocusPreviousItem", "bubbles": true, "cancelable": false, "composed": true, "docs": { "tags": [{ "name": "internal", "text": undefined }], "text": "" }, "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.", "tags": [] } } }; } static get elementRef() { return "el"; } static get watchers() { return [{ "propName": "active", "methodName": "activeHandler" }, { "propName": "selected", "methodName": "handleSelectedChange" }, { "propName": "messageOverrides", "methodName": "onMessagesChange" }, { "propName": "effectiveLocale", "methodName": "effectiveLocaleChange" }]; } }