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