action-menu.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. /*!
  2. * All material copyright ESRI, All Rights Reserved, unless otherwise specified.
  3. * See https://github.com/Esri/calcite-components/blob/master/LICENSE.md for details.
  4. * v1.0.0-beta.82
  5. */
  6. import { Component, h, Element, Event, Listen, Prop, Watch, Method, State } from "@stencil/core";
  7. import { CSS, SLOTS, ICONS } from "./resources";
  8. import { focusElement, getSlotted, toAriaBoolean } from "../../utils/dom";
  9. import { Fragment } from "@stencil/core/internal";
  10. import { getRoundRobinIndex } from "../../utils/array";
  11. import { guid } from "../../utils/guid";
  12. import { createObserver } from "../../utils/observers";
  13. import { connectConditionalSlotComponent, disconnectConditionalSlotComponent } from "../../utils/conditionalSlot";
  14. const SUPPORTED_BUTTON_NAV_KEYS = ["ArrowUp", "ArrowDown"];
  15. const SUPPORTED_MENU_NAV_KEYS = ["ArrowUp", "ArrowDown", "End", "Home"];
  16. /**
  17. * @slot - A slot for adding `calcite-action`s.
  18. * @slot trigger - A slot for adding a `calcite-action` to trigger opening the menu.
  19. * @slot tooltip - A slot for adding an tooltip for the menu.
  20. */
  21. export class ActionMenu {
  22. constructor() {
  23. // --------------------------------------------------------------------------
  24. //
  25. // Properties
  26. //
  27. // --------------------------------------------------------------------------
  28. /**
  29. * Indicates whether widget is expanded.
  30. */
  31. this.expanded = false;
  32. /**
  33. * Opens the action menu.
  34. */
  35. this.open = false;
  36. /** Describes the type of positioning to use for the overlaid content. If your element is in a fixed container, use the 'fixed' value. */
  37. this.overlayPositioning = "absolute";
  38. /**
  39. * Determines where the component will be positioned relative to the referenceElement.
  40. * @see [PopperPlacement](https://github.com/Esri/calcite-components/blob/master/src/utils/popper.ts#L25)
  41. */
  42. this.placement = "auto";
  43. this.actionElements = [];
  44. this.mutationObserver = createObserver("mutation", () => this.getActions());
  45. this.guid = `calcite-action-menu-${guid()}`;
  46. this.menuId = `${this.guid}-menu`;
  47. this.menuButtonId = `${this.guid}-menu-button`;
  48. this.activeMenuItemIndex = -1;
  49. // --------------------------------------------------------------------------
  50. //
  51. // Component Methods
  52. //
  53. // --------------------------------------------------------------------------
  54. this.connectMenuButtonEl = () => {
  55. const { el, menuButtonId, menuId, open, label } = this;
  56. const menuButtonEl = getSlotted(el, SLOTS.trigger) || this.defaultMenuButtonEl;
  57. if (this.menuButtonEl === menuButtonEl) {
  58. return;
  59. }
  60. this.disconnectMenuButtonEl();
  61. this.menuButtonEl = menuButtonEl;
  62. this.setTooltipReferenceElement();
  63. if (!menuButtonEl) {
  64. return;
  65. }
  66. menuButtonEl.active = open;
  67. menuButtonEl.setAttribute("aria-controls", menuId);
  68. menuButtonEl.setAttribute("aria-expanded", toAriaBoolean(open));
  69. menuButtonEl.setAttribute("aria-haspopup", "true");
  70. if (!menuButtonEl.id) {
  71. menuButtonEl.id = menuButtonId;
  72. }
  73. if (!menuButtonEl.label) {
  74. menuButtonEl.label = label;
  75. }
  76. if (!menuButtonEl.text) {
  77. menuButtonEl.text = label;
  78. }
  79. menuButtonEl.addEventListener("click", this.menuButtonClick);
  80. menuButtonEl.addEventListener("keydown", this.menuButtonKeyDown);
  81. menuButtonEl.addEventListener("keyup", this.menuButtonKeyUp);
  82. };
  83. this.disconnectMenuButtonEl = () => {
  84. const { menuButtonEl } = this;
  85. if (!menuButtonEl) {
  86. return;
  87. }
  88. menuButtonEl.removeEventListener("click", this.menuButtonClick);
  89. menuButtonEl.removeEventListener("keydown", this.menuButtonKeyDown);
  90. menuButtonEl.removeEventListener("keyup", this.menuButtonKeyUp);
  91. };
  92. this.setDefaultMenuButtonEl = (el) => {
  93. this.defaultMenuButtonEl = el;
  94. this.connectMenuButtonEl();
  95. };
  96. // --------------------------------------------------------------------------
  97. //
  98. // Private Methods
  99. //
  100. // --------------------------------------------------------------------------
  101. this.handleCalciteActionClick = () => {
  102. this.open = false;
  103. this.setFocus();
  104. };
  105. this.menuButtonClick = () => {
  106. this.toggleOpen();
  107. };
  108. this.updateTooltip = (event) => {
  109. const tooltips = event.target
  110. .assignedElements({
  111. flatten: true
  112. })
  113. .filter((el) => el === null || el === void 0 ? void 0 : el.matches("calcite-tooltip"));
  114. this.tooltipEl = tooltips[0];
  115. this.setTooltipReferenceElement();
  116. };
  117. this.setTooltipReferenceElement = () => {
  118. const { tooltipEl, expanded, menuButtonEl } = this;
  119. if (tooltipEl) {
  120. tooltipEl.referenceElement = !expanded ? menuButtonEl : null;
  121. }
  122. };
  123. this.updateAction = (action, index) => {
  124. const { guid, activeMenuItemIndex } = this;
  125. const id = `${guid}-action-${index}`;
  126. action.tabIndex = -1;
  127. action.setAttribute("role", "menuitem");
  128. if (!action.id) {
  129. action.id = id;
  130. }
  131. action.active = index === activeMenuItemIndex;
  132. };
  133. this.updateActions = (actions) => {
  134. actions === null || actions === void 0 ? void 0 : actions.forEach(this.updateAction);
  135. };
  136. this.getActions = () => {
  137. const { el } = this;
  138. const actionElements = getSlotted(el, { all: true, matches: "calcite-action" });
  139. this.updateActions(actionElements);
  140. this.actionElements = actionElements;
  141. this.connectMenuButtonEl();
  142. };
  143. this.menuButtonKeyUp = (event) => {
  144. const { key } = event;
  145. const { actionElements } = this;
  146. if (!this.isValidKey(key, SUPPORTED_BUTTON_NAV_KEYS)) {
  147. return;
  148. }
  149. event.preventDefault();
  150. if (!actionElements.length) {
  151. return;
  152. }
  153. this.toggleOpen(true);
  154. this.handleActionNavigation(key, actionElements);
  155. };
  156. this.menuButtonKeyDown = (event) => {
  157. const { key } = event;
  158. if (!this.isValidKey(key, SUPPORTED_BUTTON_NAV_KEYS)) {
  159. return;
  160. }
  161. event.preventDefault();
  162. };
  163. this.menuActionsContainerKeyDown = (event) => {
  164. const { key } = event;
  165. const { actionElements, activeMenuItemIndex } = this;
  166. if (key === "Tab") {
  167. this.open = false;
  168. return;
  169. }
  170. if (key === " " || key === "Enter") {
  171. event.preventDefault();
  172. const action = actionElements[activeMenuItemIndex];
  173. action ? action.click() : this.toggleOpen(false);
  174. return;
  175. }
  176. if (this.isValidKey(key, SUPPORTED_MENU_NAV_KEYS)) {
  177. event.preventDefault();
  178. }
  179. };
  180. this.menuActionsContainerKeyUp = (event) => {
  181. const { key } = event;
  182. const { actionElements } = this;
  183. if (key === "Escape") {
  184. this.toggleOpen(false);
  185. return;
  186. }
  187. if (!this.isValidKey(key, SUPPORTED_MENU_NAV_KEYS)) {
  188. return;
  189. }
  190. event.preventDefault();
  191. if (!actionElements.length) {
  192. return;
  193. }
  194. this.handleActionNavigation(key, actionElements);
  195. };
  196. this.handleActionNavigation = (key, actions) => {
  197. const currentIndex = this.activeMenuItemIndex;
  198. if (key === "Home") {
  199. this.activeMenuItemIndex = 0;
  200. }
  201. if (key === "End") {
  202. this.activeMenuItemIndex = actions.length - 1;
  203. }
  204. if (key === "ArrowUp") {
  205. this.activeMenuItemIndex = getRoundRobinIndex(Math.max(currentIndex - 1, -1), actions.length);
  206. }
  207. if (key === "ArrowDown") {
  208. this.activeMenuItemIndex = getRoundRobinIndex(currentIndex + 1, actions.length);
  209. }
  210. };
  211. this.toggleOpenEnd = () => {
  212. this.setFocus();
  213. this.el.removeEventListener("calcitePopoverOpen", this.toggleOpenEnd);
  214. };
  215. this.toggleOpen = (value = !this.open) => {
  216. this.el.addEventListener("calcitePopoverOpen", this.toggleOpenEnd);
  217. this.open = value;
  218. };
  219. }
  220. // --------------------------------------------------------------------------
  221. //
  222. // Lifecycle
  223. //
  224. // --------------------------------------------------------------------------
  225. connectedCallback() {
  226. var _a;
  227. (_a = this.mutationObserver) === null || _a === void 0 ? void 0 : _a.observe(this.el, { childList: true, subtree: true });
  228. this.getActions();
  229. connectConditionalSlotComponent(this);
  230. }
  231. disconnectedCallback() {
  232. var _a;
  233. (_a = this.mutationObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
  234. this.disconnectMenuButtonEl();
  235. disconnectConditionalSlotComponent(this);
  236. }
  237. expandedHandler() {
  238. this.open = false;
  239. this.setTooltipReferenceElement();
  240. }
  241. openHandler(open) {
  242. this.activeMenuItemIndex = this.open ? 0 : -1;
  243. if (this.menuButtonEl) {
  244. this.menuButtonEl.active = open;
  245. }
  246. this.calciteActionMenuOpenChange.emit(open);
  247. }
  248. closeCalciteActionMenuOnClick(event) {
  249. const composedPath = event.composedPath();
  250. if (composedPath.includes(this.el)) {
  251. return;
  252. }
  253. this.open = false;
  254. }
  255. activeMenuItemIndexHandler() {
  256. this.updateActions(this.actionElements);
  257. }
  258. // --------------------------------------------------------------------------
  259. //
  260. // Methods
  261. //
  262. // --------------------------------------------------------------------------
  263. /** Sets focus on the component. */
  264. async setFocus() {
  265. focusElement(this.open ? this.menuEl : this.menuButtonEl);
  266. }
  267. renderMenuButton() {
  268. const { label, scale } = this;
  269. const menuButtonSlot = (h("slot", { name: SLOTS.trigger },
  270. h("calcite-action", { class: CSS.defaultTrigger, icon: ICONS.menu, ref: this.setDefaultMenuButtonEl, scale: scale, text: label })));
  271. return menuButtonSlot;
  272. }
  273. renderMenuItems() {
  274. const { actionElements, activeMenuItemIndex, open, menuId, menuButtonEl, label, placement, overlayPositioning, flipPlacements } = this;
  275. const activeAction = actionElements[activeMenuItemIndex];
  276. const activeDescendantId = (activeAction === null || activeAction === void 0 ? void 0 : activeAction.id) || null;
  277. return (h("calcite-popover", { disablePointer: true, flipPlacements: flipPlacements, label: label, offsetDistance: 0, open: open, overlayPositioning: overlayPositioning, placement: placement, referenceElement: menuButtonEl },
  278. 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 },
  279. h("slot", null))));
  280. }
  281. render() {
  282. return (h(Fragment, null,
  283. this.renderMenuButton(),
  284. this.renderMenuItems(),
  285. h("slot", { name: SLOTS.tooltip, onSlotchange: this.updateTooltip })));
  286. }
  287. isValidKey(key, supportedKeys) {
  288. return !!supportedKeys.find((k) => k === key);
  289. }
  290. static get is() { return "calcite-action-menu"; }
  291. static get encapsulation() { return "shadow"; }
  292. static get originalStyleUrls() { return {
  293. "$": ["action-menu.scss"]
  294. }; }
  295. static get styleUrls() { return {
  296. "$": ["action-menu.css"]
  297. }; }
  298. static get properties() { return {
  299. "expanded": {
  300. "type": "boolean",
  301. "mutable": false,
  302. "complexType": {
  303. "original": "boolean",
  304. "resolved": "boolean",
  305. "references": {}
  306. },
  307. "required": false,
  308. "optional": false,
  309. "docs": {
  310. "tags": [],
  311. "text": "Indicates whether widget is expanded."
  312. },
  313. "attribute": "expanded",
  314. "reflect": true,
  315. "defaultValue": "false"
  316. },
  317. "flipPlacements": {
  318. "type": "unknown",
  319. "mutable": false,
  320. "complexType": {
  321. "original": "ComputedPlacement[]",
  322. "resolved": "ComputedPlacement[]",
  323. "references": {
  324. "ComputedPlacement": {
  325. "location": "import",
  326. "path": "../../utils/popper"
  327. }
  328. }
  329. },
  330. "required": false,
  331. "optional": true,
  332. "docs": {
  333. "tags": [],
  334. "text": "Defines the available placements that can be used when a flip occurs."
  335. }
  336. },
  337. "label": {
  338. "type": "string",
  339. "mutable": false,
  340. "complexType": {
  341. "original": "string",
  342. "resolved": "string",
  343. "references": {}
  344. },
  345. "required": true,
  346. "optional": false,
  347. "docs": {
  348. "tags": [],
  349. "text": "Text string for the actions menu."
  350. },
  351. "attribute": "label",
  352. "reflect": false
  353. },
  354. "open": {
  355. "type": "boolean",
  356. "mutable": true,
  357. "complexType": {
  358. "original": "boolean",
  359. "resolved": "boolean",
  360. "references": {}
  361. },
  362. "required": false,
  363. "optional": false,
  364. "docs": {
  365. "tags": [],
  366. "text": "Opens the action menu."
  367. },
  368. "attribute": "open",
  369. "reflect": true,
  370. "defaultValue": "false"
  371. },
  372. "overlayPositioning": {
  373. "type": "string",
  374. "mutable": false,
  375. "complexType": {
  376. "original": "OverlayPositioning",
  377. "resolved": "\"absolute\" | \"fixed\"",
  378. "references": {
  379. "OverlayPositioning": {
  380. "location": "import",
  381. "path": "../../utils/popper"
  382. }
  383. }
  384. },
  385. "required": false,
  386. "optional": false,
  387. "docs": {
  388. "tags": [],
  389. "text": "Describes the type of positioning to use for the overlaid content. If your element is in a fixed container, use the 'fixed' value."
  390. },
  391. "attribute": "overlay-positioning",
  392. "reflect": false,
  393. "defaultValue": "\"absolute\""
  394. },
  395. "placement": {
  396. "type": "string",
  397. "mutable": false,
  398. "complexType": {
  399. "original": "PopperPlacement",
  400. "resolved": "Placement | PlacementRtl | VariationRtl",
  401. "references": {
  402. "PopperPlacement": {
  403. "location": "import",
  404. "path": "../../utils/popper"
  405. }
  406. }
  407. },
  408. "required": false,
  409. "optional": false,
  410. "docs": {
  411. "tags": [{
  412. "name": "see",
  413. "text": "[PopperPlacement](https://github.com/Esri/calcite-components/blob/master/src/utils/popper.ts#L25)"
  414. }],
  415. "text": "Determines where the component will be positioned relative to the referenceElement."
  416. },
  417. "attribute": "placement",
  418. "reflect": true,
  419. "defaultValue": "\"auto\""
  420. },
  421. "scale": {
  422. "type": "string",
  423. "mutable": false,
  424. "complexType": {
  425. "original": "Scale",
  426. "resolved": "\"l\" | \"m\" | \"s\"",
  427. "references": {
  428. "Scale": {
  429. "location": "import",
  430. "path": "../interfaces"
  431. }
  432. }
  433. },
  434. "required": false,
  435. "optional": false,
  436. "docs": {
  437. "tags": [],
  438. "text": "Specifies the size of the menu trigger action."
  439. },
  440. "attribute": "scale",
  441. "reflect": true
  442. }
  443. }; }
  444. static get states() { return {
  445. "activeMenuItemIndex": {}
  446. }; }
  447. static get events() { return [{
  448. "method": "calciteActionMenuOpenChange",
  449. "name": "calciteActionMenuOpenChange",
  450. "bubbles": true,
  451. "cancelable": true,
  452. "composed": true,
  453. "docs": {
  454. "tags": [],
  455. "text": "Emitted when the open property has changed."
  456. },
  457. "complexType": {
  458. "original": "any",
  459. "resolved": "any",
  460. "references": {}
  461. }
  462. }]; }
  463. static get methods() { return {
  464. "setFocus": {
  465. "complexType": {
  466. "signature": "() => Promise<void>",
  467. "parameters": [],
  468. "references": {
  469. "Promise": {
  470. "location": "global"
  471. }
  472. },
  473. "return": "Promise<void>"
  474. },
  475. "docs": {
  476. "text": "Sets focus on the component.",
  477. "tags": []
  478. }
  479. }
  480. }; }
  481. static get elementRef() { return "el"; }
  482. static get watchers() { return [{
  483. "propName": "expanded",
  484. "methodName": "expandedHandler"
  485. }, {
  486. "propName": "open",
  487. "methodName": "openHandler"
  488. }, {
  489. "propName": "activeMenuItemIndex",
  490. "methodName": "activeMenuItemIndexHandler"
  491. }]; }
  492. static get listeners() { return [{
  493. "name": "click",
  494. "method": "closeCalciteActionMenuOnClick",
  495. "target": "window",
  496. "capture": false,
  497. "passive": false
  498. }]; }
  499. }