UNPKG

@esri/calcite-components

Version:

Web Components for Esri's Calcite Design System.

449 lines (448 loc) • 14.6 kB
import { Component, h, Element, Event, Listen, Prop, Watch, Method, State } from "@stencil/core"; import { CSS, ICONS, SLOTS } from "./resources"; import { focusElement, getSlotted } from "../../utils/dom"; import { forceUpdate, Fragment } from "@stencil/core/internal"; import { getRoundRobinIndex } from "../../utils/array"; import { guid } from "../../utils/guid"; const SUPPORTED_BUTTON_NAV_KEYS = ["ArrowUp", "ArrowDown"]; const SUPPORTED_MENU_NAV_KEYS = ["ArrowUp", "ArrowDown", "End", "Home"]; const MENU_ANIMATION_DELAY_MS = 50; /** * @slot - A slot for adding `calcite-action`s. * @slot tooltip - a slot for adding an tooltip for the menu. */ export class CalciteActionMenu { constructor() { // -------------------------------------------------------------------------- // // Properties // // -------------------------------------------------------------------------- /** * Indicates whether widget is expanded. */ this.expanded = false; /** * Opens the action menu. */ this.open = false; /** Describes the type of positioning to use for the overlaid content. If your element is in a fixed container, use the 'fixed' value. */ this.overlayPositioning = "absolute"; /** * Determines where the component will be positioned relative to the referenceElement. */ this.placement = "auto"; /** * Specifies the size of the action. */ this.scale = "m"; this.actionElements = []; this.mutationObserver = new MutationObserver(() => this.getActions()); this.guid = `calcite-action-menu-${guid()}`; this.menuId = `${this.guid}-menu`; this.menuButtonId = `${this.guid}-menu-button`; this.activeMenuItemIndex = -1; // -------------------------------------------------------------------------- // // Private Methods // // -------------------------------------------------------------------------- this.handleCalciteActionClick = () => { this.open = false; this.setFocus(); }; this.menuButtonClick = () => { this.toggleOpen(); }; this.setTooltipReferenceElement = () => { const { el, expanded, menuButtonEl } = this; const slotted = getSlotted(el, SLOTS.tooltip); const tooltip = (slotted === null || slotted === void 0 ? void 0 : slotted.tagName) === "SLOT" ? slotted.assignedElements()[0] : slotted; if ((tooltip === null || tooltip === void 0 ? void 0 : tooltip.tagName) === "CALCITE-TOOLTIP") { tooltip.referenceElement = !expanded && menuButtonEl; } }; this.setMenuButtonRef = (node) => { this.menuButtonEl = node; this.setTooltipReferenceElement(); forceUpdate(this); }; this.updateAction = (action, index) => { const { guid, activeMenuItemIndex } = this; const id = `${guid}-action-${index}`; action.tabIndex = -1; action.setAttribute("role", "menuitem"); if (!action.id) { action.id = id; } action.active = index === activeMenuItemIndex; }; this.updateActions = (actions) => { actions === null || actions === void 0 ? void 0 : actions.forEach(this.updateAction); }; this.getActions = () => { const { el } = this; const assignedActions = this.getAssignedElements().filter((element) => element.tagName === "CALCITE-ACTION"); const actionElements = assignedActions.length ? assignedActions : Array.from(el.querySelectorAll("calcite-action")); this.updateActions(actionElements); this.actionElements = actionElements; }; this.menuButtonKeyUp = (event) => { const { key } = event; const { actionElements } = this; if (!this.isValidKey(key, SUPPORTED_BUTTON_NAV_KEYS)) { return; } event.preventDefault(); if (!actionElements.length) { return; } this.toggleOpen(true); this.handleActionNavigation(key, actionElements); }; this.menuButtonKeyDown = (event) => { const { key } = event; if (!this.isValidKey(key, SUPPORTED_BUTTON_NAV_KEYS)) { return; } event.preventDefault(); }; this.menuActionsContainerKeyDown = (event) => { const { key } = event; const { actionElements, activeMenuItemIndex } = this; if (key === "Tab") { this.open = false; return; } if (key === " " || key === "Enter") { event.preventDefault(); const action = actionElements[activeMenuItemIndex]; action ? action.click() : this.toggleOpen(false); return; } if (this.isValidKey(key, SUPPORTED_MENU_NAV_KEYS)) { event.preventDefault(); } }; this.menuActionsContainerKeyUp = (event) => { const { key } = event; const { actionElements } = this; if (key === "Escape") { this.toggleOpen(false); return; } if (!this.isValidKey(key, SUPPORTED_MENU_NAV_KEYS)) { return; } event.preventDefault(); if (!actionElements.length) { return; } this.handleActionNavigation(key, actionElements); }; this.handleActionNavigation = (key, actions) => { const currentIndex = this.activeMenuItemIndex; if (key === "Home") { this.activeMenuItemIndex = 0; } if (key === "End") { this.activeMenuItemIndex = actions.length - 1; } if (key === "ArrowUp") { this.activeMenuItemIndex = getRoundRobinIndex(Math.max(currentIndex - 1, -1), actions.length); } if (key === "ArrowDown") { this.activeMenuItemIndex = getRoundRobinIndex(currentIndex + 1, actions.length); } }; this.toggleOpen = (value = !this.open) => { this.open = value; clearTimeout(this.menuFocusTimeout); if (value) { this.menuFocusTimeout = window.setTimeout(() => this.setFocus(), MENU_ANIMATION_DELAY_MS); } else { this.setFocus(); } }; } // -------------------------------------------------------------------------- // // Lifecycle // // -------------------------------------------------------------------------- connectedCallback() { this.mutationObserver.observe(this.el, { childList: true, subtree: true }); this.getActions(); } disconnectedCallback() { this.mutationObserver.disconnect(); } expandedHandler() { this.open = false; this.setTooltipReferenceElement(); } openHandler(open) { this.activeMenuItemIndex = this.open ? 0 : -1; this.calciteActionMenuOpenChange.emit(open); } closeCalciteActionMenuOnClick(event) { const composedPath = event.composedPath(); if (composedPath.includes(this.el)) { return; } this.open = false; } activeMenuItemIndexHandler() { this.updateActions(this.actionElements); } // -------------------------------------------------------------------------- // // Methods // // -------------------------------------------------------------------------- async setFocus() { focusElement(this.open ? this.menuEl : this.menuButtonEl); } // -------------------------------------------------------------------------- // // Component Methods // // -------------------------------------------------------------------------- renderMenuButton() { const { el, menuButtonId, menuId, open, label, expanded, scale } = this; const actionNode = (h("calcite-action", { active: open, "aria-controls": menuId, "aria-expanded": open.toString(), "aria-haspopup": "true", class: CSS.menuButton, icon: ICONS.menu, id: menuButtonId, label: label, onClick: this.menuButtonClick, onKeyDown: this.menuButtonKeyDown, onKeyUp: this.menuButtonKeyUp, ref: this.setMenuButtonRef, scale: scale, text: label, textEnabled: expanded })); return getSlotted(el, SLOTS.tooltip) ? (h("calcite-tooltip-manager", null, actionNode)) : (actionNode); } renderMenuItems() { const { actionElements, activeMenuItemIndex, open, menuButtonId, menuId, menuButtonEl, label, placement, overlayPositioning } = this; const activeAction = actionElements[activeMenuItemIndex]; const activeDescendantId = (activeAction === null || activeAction === void 0 ? void 0 : activeAction.id) || null; return (h("calcite-popover", { disablePointer: true, label: label, offsetDistance: 0, open: open, overlayPositioning: overlayPositioning, placement: placement, referenceElement: menuButtonEl }, h("div", { "aria-activedescendant": activeDescendantId, "aria-labelledby": menuButtonId, class: CSS.menu, id: menuId, onClick: this.handleCalciteActionClick, onKeyDown: this.menuActionsContainerKeyDown, onKeyUp: this.menuActionsContainerKeyUp, ref: (el) => (this.menuEl = el), role: "menu", tabIndex: -1 }, h("slot", null)))); } render() { return (h(Fragment, null, this.renderMenuButton(), this.renderMenuItems(), h("slot", { name: SLOTS.tooltip }))); } getAssignedElements() { return Array.from(this.el.querySelectorAll("slot")) .map((slot) => slot.assignedElements({ flatten: true })) .reduce((ar, val) => ar.concat(val), []); } isValidKey(key, supportedKeys) { return !!supportedKeys.find((k) => k === key); } static get is() { return "calcite-action-menu"; } static get encapsulation() { return "shadow"; } static get originalStyleUrls() { return { "$": ["calcite-action-menu.scss"] }; } static get styleUrls() { return { "$": ["calcite-action-menu.css"] }; } static get properties() { return { "expanded": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Indicates whether widget is expanded." }, "attribute": "expanded", "reflect": true, "defaultValue": "false" }, "flipPlacements": { "type": "unknown", "mutable": false, "complexType": { "original": "Placement[]", "resolved": "Placement[]", "references": { "Placement": { "location": "import", "path": "@popperjs/core" } } }, "required": false, "optional": true, "docs": { "tags": [], "text": "Defines the available placements that can be used when a flip occurs." } }, "label": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": true, "optional": false, "docs": { "tags": [], "text": "Text string for the actions menu." }, "attribute": "label", "reflect": false }, "open": { "type": "boolean", "mutable": true, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Opens the action menu." }, "attribute": "open", "reflect": true, "defaultValue": "false" }, "overlayPositioning": { "type": "string", "mutable": false, "complexType": { "original": "OverlayPositioning", "resolved": "\"absolute\" | \"fixed\"", "references": { "OverlayPositioning": { "location": "import", "path": "../../utils/popper" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Describes the type of positioning to use for the overlaid content. If your element is in a fixed container, use the 'fixed' value." }, "attribute": "overlay-positioning", "reflect": false, "defaultValue": "\"absolute\"" }, "placement": { "type": "string", "mutable": false, "complexType": { "original": "PopperPlacement", "resolved": "Placement | PlacementRtl | VariationRtl", "references": { "PopperPlacement": { "location": "import", "path": "../../utils/popper" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Determines where the component will be positioned relative to the referenceElement." }, "attribute": "placement", "reflect": true, "defaultValue": "\"auto\"" }, "scale": { "type": "string", "mutable": false, "complexType": { "original": "Scale", "resolved": "\"l\" | \"m\" | \"s\"", "references": { "Scale": { "location": "import", "path": "../interfaces" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Specifies the size of the action." }, "attribute": "scale", "reflect": true, "defaultValue": "\"m\"" } }; } static get states() { return { "activeMenuItemIndex": {} }; } static get events() { return [{ "method": "calciteActionMenuOpenChange", "name": "calciteActionMenuOpenChange", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emitted when the open property has changed." }, "complexType": { "original": "any", "resolved": "any", "references": {} } }]; } static get methods() { return { "setFocus": { "complexType": { "signature": "() => Promise<void>", "parameters": [], "references": { "Promise": { "location": "global" } }, "return": "Promise<void>" }, "docs": { "text": "", "tags": [] } } }; } static get elementRef() { return "el"; } static get watchers() { return [{ "propName": "expanded", "methodName": "expandedHandler" }, { "propName": "open", "methodName": "openHandler" }, { "propName": "activeMenuItemIndex", "methodName": "activeMenuItemIndexHandler" }]; } static get listeners() { return [{ "name": "click", "method": "closeCalciteActionMenuOnClick", "target": "window", "capture": false, "passive": false }]; } }