modal.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647
  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, Element, Event, h, Host, Listen, Method, Prop, State, Watch } from "@stencil/core";
  7. import { ensureId, focusElement, getSlotted, isCalciteFocusable } from "../../utils/dom";
  8. import { queryShadowRoot } from "@a11y/focus-trap/shadow";
  9. import { isFocusable, isHidden } from "@a11y/focus-trap/focusable";
  10. import { TEXT, SLOTS, CSS, ICONS } from "./resources";
  11. import { createObserver } from "../../utils/observers";
  12. import { connectConditionalSlotComponent, disconnectConditionalSlotComponent } from "../../utils/conditionalSlot";
  13. const isFocusableExtended = (el) => {
  14. return isCalciteFocusable(el) || isFocusable(el);
  15. };
  16. const getFocusableElements = (el) => {
  17. return queryShadowRoot(el, isHidden, isFocusableExtended);
  18. };
  19. /**
  20. * @slot header - a slot for adding a modal header
  21. * @slot content - a slot for adding modal content
  22. * @slot primary - a slot for adding a primary button
  23. * @slot secondary - a slot for adding a secondary button
  24. * @slot back - a slot for adding a back button
  25. */
  26. export class Modal {
  27. constructor() {
  28. //--------------------------------------------------------------------------
  29. //
  30. // Properties
  31. //
  32. //--------------------------------------------------------------------------
  33. /** Add the active attribute to open the modal */
  34. this.active = false;
  35. /** Optionally pass a function to run before close */
  36. this.beforeClose = () => Promise.resolve();
  37. /** Disables the display a close button within the Modal */
  38. this.disableCloseButton = false;
  39. /** Disables the closing of the Modal when clicked outside. */
  40. this.disableOutsideClose = false;
  41. /** Aria label for the close button */
  42. this.intlClose = TEXT.close;
  43. /** Flag to disable the default close on escape behavior */
  44. this.disableEscape = false;
  45. /** specify the scale of modal, defaults to m */
  46. this.scale = "m";
  47. /** Set the width of the modal. Can use stock sizes or pass a number (in pixels) */
  48. this.width = "m";
  49. /** Background color of modal content */
  50. this.backgroundColor = "white";
  51. /** Turn off spacing around the content area slot */
  52. this.noPadding = false;
  53. //--------------------------------------------------------------------------
  54. //
  55. // Variables
  56. //
  57. //--------------------------------------------------------------------------
  58. this.hasFooter = true;
  59. this.mutationObserver = createObserver("mutation", () => this.updateFooterVisibility());
  60. this.activeTransitionProp = "opacity";
  61. //--------------------------------------------------------------------------
  62. //
  63. // Private Methods
  64. //
  65. //--------------------------------------------------------------------------
  66. this.transitionEnd = (event) => {
  67. if (event.propertyName === this.activeTransitionProp) {
  68. this.active ? this.calciteModalOpen.emit() : this.calciteModalClose.emit();
  69. }
  70. };
  71. this.openEnd = () => {
  72. this.setFocus();
  73. this.el.removeEventListener("calciteModalOpen", this.openEnd);
  74. };
  75. this.handleOutsideClose = () => {
  76. if (this.disableOutsideClose) {
  77. return;
  78. }
  79. this.close();
  80. };
  81. /** Close the modal, first running the `beforeClose` method */
  82. this.close = () => {
  83. return this.beforeClose(this.el).then(() => {
  84. this.active = false;
  85. focusElement(this.previousActiveElement);
  86. this.removeOverflowHiddenClass();
  87. });
  88. };
  89. this.focusFirstElement = () => {
  90. focusElement(this.disableCloseButton ? getFocusableElements(this.el)[0] : this.closeButtonEl);
  91. };
  92. this.focusLastElement = () => {
  93. const focusableElements = getFocusableElements(this.el).filter((el) => !el.getAttribute("data-focus-fence"));
  94. if (focusableElements.length > 0) {
  95. focusElement(focusableElements[focusableElements.length - 1]);
  96. }
  97. else {
  98. focusElement(this.closeButtonEl);
  99. }
  100. };
  101. this.updateFooterVisibility = () => {
  102. this.hasFooter = !!getSlotted(this.el, [SLOTS.back, SLOTS.primary, SLOTS.secondary]);
  103. };
  104. }
  105. //--------------------------------------------------------------------------
  106. //
  107. // Lifecycle
  108. //
  109. //--------------------------------------------------------------------------
  110. componentWillLoad() {
  111. // when modal initially renders, if active was set we need to open as watcher doesn't fire
  112. if (this.active) {
  113. this.open();
  114. }
  115. }
  116. connectedCallback() {
  117. var _a;
  118. (_a = this.mutationObserver) === null || _a === void 0 ? void 0 : _a.observe(this.el, { childList: true, subtree: true });
  119. this.updateFooterVisibility();
  120. connectConditionalSlotComponent(this);
  121. }
  122. disconnectedCallback() {
  123. var _a;
  124. this.removeOverflowHiddenClass();
  125. (_a = this.mutationObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
  126. disconnectConditionalSlotComponent(this);
  127. }
  128. render() {
  129. return (h(Host, { "aria-describedby": this.contentId, "aria-labelledby": this.titleId, "aria-modal": "true", role: "dialog" },
  130. h("calcite-scrim", { class: CSS.scrim, onClick: this.handleOutsideClose }),
  131. this.renderStyle(),
  132. h("div", { class: "modal", onTransitionEnd: this.transitionEnd },
  133. h("div", { "data-focus-fence": true, onFocus: this.focusLastElement, tabindex: "0" }),
  134. h("div", { class: CSS.header },
  135. this.renderCloseButton(),
  136. h("header", { class: CSS.title },
  137. h("slot", { name: CSS.header }))),
  138. h("div", { class: {
  139. content: true,
  140. "content--spaced": !this.noPadding,
  141. "content--no-footer": !this.hasFooter
  142. }, ref: (el) => (this.modalContent = el) },
  143. h("slot", { name: SLOTS.content })),
  144. this.renderFooter(),
  145. h("div", { "data-focus-fence": true, onFocus: this.focusFirstElement, tabindex: "0" }))));
  146. }
  147. renderFooter() {
  148. return this.hasFooter ? (h("div", { class: CSS.footer, key: "footer" },
  149. h("span", { class: CSS.back },
  150. h("slot", { name: SLOTS.back })),
  151. h("span", { class: CSS.secondary },
  152. h("slot", { name: SLOTS.secondary })),
  153. h("span", { class: CSS.primary },
  154. h("slot", { name: SLOTS.primary })))) : null;
  155. }
  156. renderCloseButton() {
  157. return !this.disableCloseButton ? (h("button", { "aria-label": this.intlClose, class: CSS.close, key: "button", onClick: this.close, ref: (el) => (this.closeButtonEl = el), title: this.intlClose },
  158. h("calcite-icon", { icon: ICONS.close, scale: this.scale === "s" ? "s" : this.scale === "m" ? "m" : this.scale === "l" ? "l" : null }))) : null;
  159. }
  160. renderStyle() {
  161. const hasCustomWidth = !isNaN(parseInt(`${this.width}`));
  162. return hasCustomWidth ? (h("style", null, `
  163. .modal {
  164. max-width: ${this.width}px !important;
  165. }
  166. @media screen and (max-width: ${this.width}px) {
  167. .modal {
  168. height: 100% !important;
  169. max-height: 100% !important;
  170. width: 100% !important;
  171. max-width: 100% !important;
  172. margin: 0 !important;
  173. border-radius: 0 !important;
  174. }
  175. .content {
  176. flex: 1 1 auto !important;
  177. max-height: unset !important;
  178. }
  179. }
  180. `)) : null;
  181. }
  182. //--------------------------------------------------------------------------
  183. //
  184. // Event Listeners
  185. //
  186. //--------------------------------------------------------------------------
  187. handleEscape(e) {
  188. if (this.active && !this.disableEscape && e.key === "Escape") {
  189. this.close();
  190. }
  191. }
  192. //--------------------------------------------------------------------------
  193. //
  194. // Public Methods
  195. //
  196. //--------------------------------------------------------------------------
  197. /**
  198. * Focus first interactive element
  199. * @deprecated use `setFocus` instead.
  200. */
  201. async focusElement(el) {
  202. if (el) {
  203. el.focus();
  204. }
  205. return this.setFocus();
  206. }
  207. /**
  208. * Sets focus on the component.
  209. *
  210. * By default, will try to focus on any focusable content. If there is none, it will focus on the close button.
  211. * If you want to focus on the close button, you can use the `close-button` focus ID.
  212. */
  213. async setFocus(focusId) {
  214. const closeButton = this.closeButtonEl;
  215. return focusElement(focusId === "close-button" ? closeButton : getFocusableElements(this.el)[0] || closeButton);
  216. }
  217. /** Set the scroll top of the modal content */
  218. async scrollContent(top = 0, left = 0) {
  219. if (this.modalContent) {
  220. if (this.modalContent.scrollTo) {
  221. this.modalContent.scrollTo({ top, left, behavior: "smooth" });
  222. }
  223. else {
  224. this.modalContent.scrollTop = top;
  225. this.modalContent.scrollLeft = left;
  226. }
  227. }
  228. }
  229. async toggleModal(value, oldValue) {
  230. if (value !== oldValue) {
  231. if (value) {
  232. this.open();
  233. }
  234. else if (!value) {
  235. this.close();
  236. }
  237. }
  238. }
  239. /** Open the modal */
  240. open() {
  241. this.previousActiveElement = document.activeElement;
  242. this.el.addEventListener("calciteModalOpen", this.openEnd);
  243. this.active = true;
  244. const titleEl = getSlotted(this.el, SLOTS.header);
  245. const contentEl = getSlotted(this.el, SLOTS.content);
  246. this.titleId = ensureId(titleEl);
  247. this.contentId = ensureId(contentEl);
  248. document.documentElement.classList.add(CSS.overflowHidden);
  249. }
  250. removeOverflowHiddenClass() {
  251. document.documentElement.classList.remove(CSS.overflowHidden);
  252. }
  253. static get is() { return "calcite-modal"; }
  254. static get encapsulation() { return "shadow"; }
  255. static get originalStyleUrls() { return {
  256. "$": ["modal.scss"]
  257. }; }
  258. static get styleUrls() { return {
  259. "$": ["modal.css"]
  260. }; }
  261. static get properties() { return {
  262. "active": {
  263. "type": "boolean",
  264. "mutable": true,
  265. "complexType": {
  266. "original": "boolean",
  267. "resolved": "boolean",
  268. "references": {}
  269. },
  270. "required": false,
  271. "optional": false,
  272. "docs": {
  273. "tags": [],
  274. "text": "Add the active attribute to open the modal"
  275. },
  276. "attribute": "active",
  277. "reflect": true,
  278. "defaultValue": "false"
  279. },
  280. "beforeClose": {
  281. "type": "unknown",
  282. "mutable": false,
  283. "complexType": {
  284. "original": "(el: HTMLElement) => Promise<void>",
  285. "resolved": "(el: HTMLElement) => Promise<void>",
  286. "references": {
  287. "HTMLElement": {
  288. "location": "global"
  289. },
  290. "Promise": {
  291. "location": "global"
  292. }
  293. }
  294. },
  295. "required": false,
  296. "optional": false,
  297. "docs": {
  298. "tags": [],
  299. "text": "Optionally pass a function to run before close"
  300. },
  301. "defaultValue": "() => Promise.resolve()"
  302. },
  303. "disableCloseButton": {
  304. "type": "boolean",
  305. "mutable": false,
  306. "complexType": {
  307. "original": "boolean",
  308. "resolved": "boolean",
  309. "references": {}
  310. },
  311. "required": false,
  312. "optional": false,
  313. "docs": {
  314. "tags": [],
  315. "text": "Disables the display a close button within the Modal"
  316. },
  317. "attribute": "disable-close-button",
  318. "reflect": false,
  319. "defaultValue": "false"
  320. },
  321. "disableOutsideClose": {
  322. "type": "boolean",
  323. "mutable": false,
  324. "complexType": {
  325. "original": "boolean",
  326. "resolved": "boolean",
  327. "references": {}
  328. },
  329. "required": false,
  330. "optional": false,
  331. "docs": {
  332. "tags": [],
  333. "text": "Disables the closing of the Modal when clicked outside."
  334. },
  335. "attribute": "disable-outside-close",
  336. "reflect": false,
  337. "defaultValue": "false"
  338. },
  339. "intlClose": {
  340. "type": "string",
  341. "mutable": false,
  342. "complexType": {
  343. "original": "string",
  344. "resolved": "string",
  345. "references": {}
  346. },
  347. "required": false,
  348. "optional": false,
  349. "docs": {
  350. "tags": [],
  351. "text": "Aria label for the close button"
  352. },
  353. "attribute": "intl-close",
  354. "reflect": false,
  355. "defaultValue": "TEXT.close"
  356. },
  357. "docked": {
  358. "type": "boolean",
  359. "mutable": false,
  360. "complexType": {
  361. "original": "boolean",
  362. "resolved": "boolean",
  363. "references": {}
  364. },
  365. "required": false,
  366. "optional": false,
  367. "docs": {
  368. "tags": [],
  369. "text": "Prevent the modal from taking up the entire screen on mobile"
  370. },
  371. "attribute": "docked",
  372. "reflect": true
  373. },
  374. "firstFocus": {
  375. "type": "unknown",
  376. "mutable": false,
  377. "complexType": {
  378. "original": "HTMLElement",
  379. "resolved": "HTMLElement",
  380. "references": {
  381. "HTMLElement": {
  382. "location": "global"
  383. }
  384. }
  385. },
  386. "required": false,
  387. "optional": true,
  388. "docs": {
  389. "tags": [],
  390. "text": "Specify an element to focus when the modal is first opened"
  391. }
  392. },
  393. "disableEscape": {
  394. "type": "boolean",
  395. "mutable": false,
  396. "complexType": {
  397. "original": "boolean",
  398. "resolved": "boolean",
  399. "references": {}
  400. },
  401. "required": false,
  402. "optional": false,
  403. "docs": {
  404. "tags": [],
  405. "text": "Flag to disable the default close on escape behavior"
  406. },
  407. "attribute": "disable-escape",
  408. "reflect": false,
  409. "defaultValue": "false"
  410. },
  411. "scale": {
  412. "type": "string",
  413. "mutable": false,
  414. "complexType": {
  415. "original": "Scale",
  416. "resolved": "\"l\" | \"m\" | \"s\"",
  417. "references": {
  418. "Scale": {
  419. "location": "import",
  420. "path": "../interfaces"
  421. }
  422. }
  423. },
  424. "required": false,
  425. "optional": false,
  426. "docs": {
  427. "tags": [],
  428. "text": "specify the scale of modal, defaults to m"
  429. },
  430. "attribute": "scale",
  431. "reflect": true,
  432. "defaultValue": "\"m\""
  433. },
  434. "width": {
  435. "type": "any",
  436. "mutable": false,
  437. "complexType": {
  438. "original": "Scale | number",
  439. "resolved": "\"l\" | \"m\" | \"s\" | number",
  440. "references": {
  441. "Scale": {
  442. "location": "import",
  443. "path": "../interfaces"
  444. }
  445. }
  446. },
  447. "required": false,
  448. "optional": false,
  449. "docs": {
  450. "tags": [],
  451. "text": "Set the width of the modal. Can use stock sizes or pass a number (in pixels)"
  452. },
  453. "attribute": "width",
  454. "reflect": true,
  455. "defaultValue": "\"m\""
  456. },
  457. "fullscreen": {
  458. "type": "boolean",
  459. "mutable": false,
  460. "complexType": {
  461. "original": "boolean",
  462. "resolved": "boolean",
  463. "references": {}
  464. },
  465. "required": false,
  466. "optional": false,
  467. "docs": {
  468. "tags": [],
  469. "text": "Set the modal to always be fullscreen (overrides width)"
  470. },
  471. "attribute": "fullscreen",
  472. "reflect": true
  473. },
  474. "color": {
  475. "type": "string",
  476. "mutable": false,
  477. "complexType": {
  478. "original": "\"red\" | \"blue\"",
  479. "resolved": "\"blue\" | \"red\"",
  480. "references": {}
  481. },
  482. "required": false,
  483. "optional": true,
  484. "docs": {
  485. "tags": [],
  486. "text": "Adds a color bar at the top for visual impact,\nUse color to add importance to destructive/workflow dialogs."
  487. },
  488. "attribute": "color",
  489. "reflect": true
  490. },
  491. "backgroundColor": {
  492. "type": "string",
  493. "mutable": false,
  494. "complexType": {
  495. "original": "ModalBackgroundColor",
  496. "resolved": "\"grey\" | \"white\"",
  497. "references": {
  498. "ModalBackgroundColor": {
  499. "location": "import",
  500. "path": "./interfaces"
  501. }
  502. }
  503. },
  504. "required": false,
  505. "optional": false,
  506. "docs": {
  507. "tags": [],
  508. "text": "Background color of modal content"
  509. },
  510. "attribute": "background-color",
  511. "reflect": true,
  512. "defaultValue": "\"white\""
  513. },
  514. "noPadding": {
  515. "type": "boolean",
  516. "mutable": false,
  517. "complexType": {
  518. "original": "boolean",
  519. "resolved": "boolean",
  520. "references": {}
  521. },
  522. "required": false,
  523. "optional": false,
  524. "docs": {
  525. "tags": [],
  526. "text": "Turn off spacing around the content area slot"
  527. },
  528. "attribute": "no-padding",
  529. "reflect": false,
  530. "defaultValue": "false"
  531. }
  532. }; }
  533. static get states() { return {
  534. "hasFooter": {}
  535. }; }
  536. static get events() { return [{
  537. "method": "calciteModalOpen",
  538. "name": "calciteModalOpen",
  539. "bubbles": true,
  540. "cancelable": true,
  541. "composed": true,
  542. "docs": {
  543. "tags": [],
  544. "text": "Fired when the modal finishes the open animation"
  545. },
  546. "complexType": {
  547. "original": "any",
  548. "resolved": "any",
  549. "references": {}
  550. }
  551. }, {
  552. "method": "calciteModalClose",
  553. "name": "calciteModalClose",
  554. "bubbles": true,
  555. "cancelable": true,
  556. "composed": true,
  557. "docs": {
  558. "tags": [],
  559. "text": "Fired when the modal finishes the close animation"
  560. },
  561. "complexType": {
  562. "original": "any",
  563. "resolved": "any",
  564. "references": {}
  565. }
  566. }]; }
  567. static get methods() { return {
  568. "focusElement": {
  569. "complexType": {
  570. "signature": "(el?: HTMLElement) => Promise<void>",
  571. "parameters": [{
  572. "tags": [],
  573. "text": ""
  574. }],
  575. "references": {
  576. "Promise": {
  577. "location": "global"
  578. },
  579. "HTMLElement": {
  580. "location": "global"
  581. }
  582. },
  583. "return": "Promise<void>"
  584. },
  585. "docs": {
  586. "text": "Focus first interactive element",
  587. "tags": [{
  588. "name": "deprecated",
  589. "text": "use `setFocus` instead."
  590. }]
  591. }
  592. },
  593. "setFocus": {
  594. "complexType": {
  595. "signature": "(focusId?: \"close-button\") => Promise<void>",
  596. "parameters": [{
  597. "tags": [],
  598. "text": ""
  599. }],
  600. "references": {
  601. "Promise": {
  602. "location": "global"
  603. }
  604. },
  605. "return": "Promise<void>"
  606. },
  607. "docs": {
  608. "text": "Sets focus on the component.\n\nBy default, will try to focus on any focusable content. If there is none, it will focus on the close button.\nIf you want to focus on the close button, you can use the `close-button` focus ID.",
  609. "tags": []
  610. }
  611. },
  612. "scrollContent": {
  613. "complexType": {
  614. "signature": "(top?: number, left?: number) => Promise<void>",
  615. "parameters": [{
  616. "tags": [],
  617. "text": ""
  618. }, {
  619. "tags": [],
  620. "text": ""
  621. }],
  622. "references": {
  623. "Promise": {
  624. "location": "global"
  625. }
  626. },
  627. "return": "Promise<void>"
  628. },
  629. "docs": {
  630. "text": "Set the scroll top of the modal content",
  631. "tags": []
  632. }
  633. }
  634. }; }
  635. static get elementRef() { return "el"; }
  636. static get watchers() { return [{
  637. "propName": "active",
  638. "methodName": "toggleModal"
  639. }]; }
  640. static get listeners() { return [{
  641. "name": "keyup",
  642. "method": "handleEscape",
  643. "target": "window",
  644. "capture": false,
  645. "passive": false
  646. }]; }
  647. }