@esri/calcite-components
Version:
Web Components for Esri's Calcite Design System.
497 lines (496 loc) • 16.6 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.
*/
import { Component, h, Element, Event, Listen, Prop, Watch, Method, State } from "@stencil/core";
import { CSS, SLOTS, ICONS } from "./resources";
import { focusElement, getSlotted } from "../../utils/dom";
import { Fragment } from "@stencil/core/internal";
import { getRoundRobinIndex } from "../../utils/array";
import { guid } from "../../utils/guid";
import { createObserver } from "../../utils/observers";
import { connectConditionalSlotComponent, disconnectConditionalSlotComponent } from "../../utils/conditionalSlot";
const SUPPORTED_BUTTON_NAV_KEYS = ["ArrowUp", "ArrowDown"];
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() {
// --------------------------------------------------------------------------
//
// 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.
* @see [PopperPlacement](https://github.com/Esri/calcite-components/blob/master/src/utils/popper.ts#L25)
*/
this.placement = "auto";
this.actionElements = [];
this.mutationObserver = createObserver("mutation", () => this.getActions());
this.guid = `calcite-action-menu-${guid()}`;
this.menuId = `${this.guid}-menu`;
this.menuButtonId = `${this.guid}-menu-button`;
this.activeMenuItemIndex = -1;
// --------------------------------------------------------------------------
//
// Component Methods
//
// --------------------------------------------------------------------------
this.connectMenuButtonEl = () => {
const { el, menuButtonId, menuId, open, label } = this;
const menuButtonEl = getSlotted(el, SLOTS.trigger) || 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", open.toString());
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("click", this.menuButtonClick);
menuButtonEl.addEventListener("keydown", this.menuButtonKeyDown);
menuButtonEl.addEventListener("keyup", this.menuButtonKeyUp);
};
this.disconnectMenuButtonEl = () => {
const { menuButtonEl } = this;
if (!menuButtonEl) {
return;
}
menuButtonEl.removeEventListener("click", this.menuButtonClick);
menuButtonEl.removeEventListener("keydown", this.menuButtonKeyDown);
menuButtonEl.removeEventListener("keyup", this.menuButtonKeyUp);
};
this.setDefaultMenuButtonEl = (el) => {
this.defaultMenuButtonEl = el;
this.connectMenuButtonEl();
};
// --------------------------------------------------------------------------
//
// 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 : 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 === null || actions === void 0 ? void 0 : actions.forEach(this.updateAction);
};
this.getActions = () => {
const { el } = this;
const actionElements = getSlotted(el, { all: true, matches: "calcite-action" });
this.updateActions(actionElements);
this.actionElements = actionElements;
this.connectMenuButtonEl();
};
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.toggleOpenEnd = () => {
this.setFocus();
this.el.removeEventListener("calcitePopoverOpen", this.toggleOpenEnd);
};
this.toggleOpen = (value = !this.open) => {
this.el.addEventListener("calcitePopoverOpen", this.toggleOpenEnd);
this.open = value;
};
}
// --------------------------------------------------------------------------
//
// Lifecycle
//
// --------------------------------------------------------------------------
connectedCallback() {
var _a;
(_a = this.mutationObserver) === null || _a === void 0 ? void 0 : _a.observe(this.el, { childList: true, subtree: true });
this.getActions();
connectConditionalSlotComponent(this);
}
disconnectedCallback() {
var _a;
(_a = this.mutationObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
this.disconnectMenuButtonEl();
disconnectConditionalSlotComponent(this);
}
expandedHandler() {
this.open = false;
this.setTooltipReferenceElement();
}
openHandler(open) {
this.activeMenuItemIndex = this.open ? 0 : -1;
if (this.menuButtonEl) {
this.menuButtonEl.active = open;
}
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
//
// --------------------------------------------------------------------------
/** Sets focus on the component. */
async setFocus() {
focusElement(this.open ? this.menuEl : this.menuButtonEl);
}
renderMenuButton() {
const { el, label, scale } = this;
const menuButtonSlot = (h("slot", { name: SLOTS.trigger },
h("calcite-action", { class: CSS.defaultTrigger, icon: ICONS.menu, ref: this.setDefaultMenuButtonEl, scale: scale, text: label })));
return getSlotted(el, SLOTS.tooltip) ? (h("calcite-tooltip-manager", null, menuButtonSlot)) : (menuButtonSlot);
}
renderMenuItems() {
const { actionElements, activeMenuItemIndex, open, 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": menuButtonEl === null || menuButtonEl === void 0 ? void 0 : menuButtonEl.id, 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 {
"$": ["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": "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": [{
"name": "see",
"text": "[PopperPlacement](https://github.com/Esri/calcite-components/blob/master/src/utils/popper.ts#L25)"
}],
"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 menu trigger action."
},
"attribute": "scale",
"reflect": true
}
}; }
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": "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": "click",
"method": "closeCalciteActionMenuOnClick",
"target": "window",
"capture": false,
"passive": false
}]; }
}