UNPKG

@esri/calcite-components

Version:

Web Components for Esri's Calcite Design System.

496 lines (495 loc) • 16.1 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 { Fragment } from "@stencil/core/internal"; import { getRoundRobinIndex } from "../../utils/array"; import { focusElement, isPrimaryPointerButton, toAriaBoolean } from "../../utils/dom"; import { guid } from "../../utils/guid"; import { isActivationKey } from "../../utils/key"; import { componentLoaded, setComponentLoaded, setUpLoadableComponent } from "../../utils/loadable"; import { CSS, ICONS, SLOTS } from "./resources"; const SUPPORTED_MENU_NAV_KEYS = ["ArrowUp", "ArrowDown", "End", "Home"]; /** * @slot - A slot for adding `calcite-action`s. * @slot trigger - A slot for adding a `calcite-action` to trigger opening the menu. * @slot tooltip - A slot for adding an tooltip for the menu. */ export class ActionMenu { constructor() { this.actionElements = []; this.guid = `calcite-action-menu-${guid()}`; this.menuId = `${this.guid}-menu`; this.menuButtonId = `${this.guid}-menu-button`; // -------------------------------------------------------------------------- // // Component Methods // // -------------------------------------------------------------------------- this.connectMenuButtonEl = () => { const { menuButtonId, menuId, open, label } = this; const menuButtonEl = this.slottedMenuButtonEl || this.defaultMenuButtonEl; if (this.menuButtonEl === menuButtonEl) { return; } this.disconnectMenuButtonEl(); this.menuButtonEl = menuButtonEl; this.setTooltipReferenceElement(); if (!menuButtonEl) { return; } menuButtonEl.active = open; menuButtonEl.setAttribute("aria-controls", menuId); menuButtonEl.setAttribute("aria-expanded", toAriaBoolean(open)); menuButtonEl.setAttribute("aria-haspopup", "true"); if (!menuButtonEl.id) { menuButtonEl.id = menuButtonId; } if (!menuButtonEl.label) { menuButtonEl.label = label; } if (!menuButtonEl.text) { menuButtonEl.text = label; } menuButtonEl.addEventListener("pointerdown", this.menuButtonClick); menuButtonEl.addEventListener("keydown", this.menuButtonKeyDown); }; this.disconnectMenuButtonEl = () => { const { menuButtonEl } = this; if (!menuButtonEl) { return; } menuButtonEl.removeEventListener("pointerdown", this.menuButtonClick); menuButtonEl.removeEventListener("keydown", this.menuButtonKeyDown); }; this.setMenuButtonEl = (event) => { const actions = event.target .assignedElements({ flatten: true }) .filter((el) => el?.matches("calcite-action")); this.slottedMenuButtonEl = actions[0]; this.connectMenuButtonEl(); }; this.setDefaultMenuButtonEl = (el) => { this.defaultMenuButtonEl = el; this.connectMenuButtonEl(); }; // -------------------------------------------------------------------------- // // Private Methods // // -------------------------------------------------------------------------- this.handleCalciteActionClick = () => { this.open = false; this.setFocus(); }; this.menuButtonClick = (event) => { if (!isPrimaryPointerButton(event)) { return; } this.toggleOpen(); }; this.updateTooltip = (event) => { const tooltips = event.target .assignedElements({ flatten: true }) .filter((el) => el?.matches("calcite-tooltip")); this.tooltipEl = tooltips[0]; this.setTooltipReferenceElement(); }; this.setTooltipReferenceElement = () => { const { tooltipEl, expanded, menuButtonEl, open } = this; if (tooltipEl) { tooltipEl.referenceElement = !expanded && !open ? menuButtonEl : null; } }; 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?.forEach(this.updateAction); }; this.handleDefaultSlotChange = (event) => { const actions = event.target .assignedElements({ flatten: true }) .filter((el) => el?.matches("calcite-action")); this.actionElements = actions; }; this.menuButtonKeyDown = (event) => { const { key } = event; const { actionElements, activeMenuItemIndex, open } = this; if (!actionElements.length) { return; } if (isActivationKey(key)) { event.preventDefault(); if (!open) { this.toggleOpen(); return; } const action = actionElements[activeMenuItemIndex]; action ? action.click() : this.toggleOpen(false); } if (key === "Tab") { this.open = false; return; } if (key === "Escape") { this.toggleOpen(false); event.preventDefault(); return; } this.handleActionNavigation(event, key, actionElements); }; this.handleActionNavigation = (event, key, actions) => { if (!this.isValidKey(key, SUPPORTED_MENU_NAV_KEYS)) { return; } event.preventDefault(); if (!this.open) { this.toggleOpen(); if (key === "Home" || key === "ArrowDown") { this.activeMenuItemIndex = 0; } if (key === "End" || key === "ArrowUp") { this.activeMenuItemIndex = actions.length - 1; } return; } if (key === "Home") { this.activeMenuItemIndex = 0; } if (key === "End") { this.activeMenuItemIndex = actions.length - 1; } const currentIndex = this.activeMenuItemIndex; if (key === "ArrowUp") { this.activeMenuItemIndex = getRoundRobinIndex(Math.max(currentIndex - 1, -1), actions.length); } if (key === "ArrowDown") { this.activeMenuItemIndex = getRoundRobinIndex(currentIndex + 1, actions.length); } }; this.toggleOpenEnd = () => { this.setFocus(); this.el.removeEventListener("calcitePopoverOpen", this.toggleOpenEnd); }; this.toggleOpen = (value = !this.open) => { this.el.addEventListener("calcitePopoverOpen", this.toggleOpenEnd); this.open = value; }; this.expanded = false; this.flipPlacements = undefined; this.label = undefined; this.open = false; this.overlayPositioning = "absolute"; this.placement = "auto"; this.scale = undefined; this.menuButtonEl = undefined; this.activeMenuItemIndex = -1; } // -------------------------------------------------------------------------- // // Lifecycle // // -------------------------------------------------------------------------- componentWillLoad() { setUpLoadableComponent(this); } componentDidLoad() { setComponentLoaded(this); } disconnectedCallback() { this.disconnectMenuButtonEl(); } expandedHandler() { this.open = false; this.setTooltipReferenceElement(); } openHandler(open) { this.activeMenuItemIndex = this.open ? 0 : -1; if (this.menuButtonEl) { this.menuButtonEl.active = open; } this.calciteActionMenuOpen.emit(); this.setTooltipReferenceElement(); } closeCalciteActionMenuOnClick(event) { if (!isPrimaryPointerButton(event)) { return; } const composedPath = event.composedPath(); if (composedPath.includes(this.el)) { return; } this.open = false; } activeMenuItemIndexHandler() { this.updateActions(this.actionElements); } // -------------------------------------------------------------------------- // // Methods // // -------------------------------------------------------------------------- /** Sets focus on the component. */ async setFocus() { await componentLoaded(this); focusElement(this.menuButtonEl); } renderMenuButton() { const { label, scale, expanded } = this; const menuButtonSlot = (h("slot", { name: SLOTS.trigger, onSlotchange: this.setMenuButtonEl }, h("calcite-action", { class: CSS.defaultTrigger, icon: ICONS.menu, scale: scale, text: label, textEnabled: expanded, // eslint-disable-next-line react/jsx-sort-props ref: this.setDefaultMenuButtonEl }))); return menuButtonSlot; } renderMenuItems() { const { actionElements, activeMenuItemIndex, open, menuId, menuButtonEl, label, placement, overlayPositioning, flipPlacements } = this; const activeAction = actionElements[activeMenuItemIndex]; const activeDescendantId = activeAction?.id || null; return (h("calcite-popover", { flipPlacements: flipPlacements, focusTrapDisabled: true, label: label, offsetDistance: 0, open: open, overlayPositioning: overlayPositioning, placement: placement, pointerDisabled: true, referenceElement: menuButtonEl }, h("div", { "aria-activedescendant": activeDescendantId, "aria-labelledby": menuButtonEl?.id, class: CSS.menu, id: menuId, onClick: this.handleCalciteActionClick, role: "menu", tabIndex: -1 }, h("slot", { onSlotchange: this.handleDefaultSlotChange })))); } render() { return (h(Fragment, null, this.renderMenuButton(), this.renderMenuItems(), h("slot", { name: SLOTS.tooltip, onSlotchange: this.updateTooltip }))); } 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 { "$": ["action-menu.scss"] }; } static get styleUrls() { return { "$": ["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": "When `true`, the component is expanded." }, "attribute": "expanded", "reflect": true, "defaultValue": "false" }, "flipPlacements": { "type": "unknown", "mutable": false, "complexType": { "original": "EffectivePlacement[]", "resolved": "Placement[]", "references": { "EffectivePlacement": { "location": "import", "path": "../../utils/floating-ui" } } }, "required": false, "optional": false, "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": "Specifies the text string for the component." }, "attribute": "label", "reflect": false }, "open": { "type": "boolean", "mutable": true, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "When `true`, the component is open." }, "attribute": "open", "reflect": true, "defaultValue": "false" }, "overlayPositioning": { "type": "string", "mutable": false, "complexType": { "original": "OverlayPositioning", "resolved": "\"absolute\" | \"fixed\"", "references": { "OverlayPositioning": { "location": "import", "path": "../../utils/floating-ui" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Determines the type of positioning to use for the overlaid content.\n\nUsing `\"absolute\"` will work for most cases. The component will be positioned inside of overflowing parent containers and will affect the container's layout.\n`\"fixed\"` should be used to escape an overflowing parent container, or when the reference element's `position` CSS property is `\"fixed\"`." }, "attribute": "overlay-positioning", "reflect": true, "defaultValue": "\"absolute\"" }, "placement": { "type": "string", "mutable": false, "complexType": { "original": "LogicalPlacement", "resolved": "\"auto\" | \"top\" | \"right\" | \"bottom\" | \"left\" | \"top-start\" | \"top-end\" | \"right-start\" | \"right-end\" | \"bottom-start\" | \"bottom-end\" | \"left-start\" | \"left-end\" | \"auto-start\" | \"auto-end\" | \"leading-start\" | \"leading\" | \"leading-end\" | \"trailing-end\" | \"trailing\" | \"trailing-start\"", "references": { "LogicalPlacement": { "location": "import", "path": "../../utils/floating-ui" } } }, "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 component's trigger `calcite-action`." }, "attribute": "scale", "reflect": true } }; } static get states() { return { "menuButtonEl": {}, "activeMenuItemIndex": {} }; } static get events() { return [{ "method": "calciteActionMenuOpen", "name": "calciteActionMenuOpen", "bubbles": true, "cancelable": false, "composed": true, "docs": { "tags": [], "text": "Emits when the `open` property is toggled." }, "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": "expanded", "methodName": "expandedHandler" }, { "propName": "open", "methodName": "openHandler" }, { "propName": "activeMenuItemIndex", "methodName": "activeMenuItemIndexHandler" }]; } static get listeners() { return [{ "name": "pointerdown", "method": "closeCalciteActionMenuOnClick", "target": "window", "capture": false, "passive": true }]; } }