action-menu.js 17 KB

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