@esri/calcite-components
Version:
Web Components for Esri's Calcite Design System.
772 lines (771 loc) • 24.9 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, Host } from "@stencil/core";
import { connectConditionalSlotComponent, disconnectConditionalSlotComponent } from "../../utils/conditionalSlot";
import { ensureId, focusFirstTabbable, getSlotted, slotChangeHasAssignedElement } from "../../utils/dom";
import { activateFocusTrap, connectFocusTrap, deactivateFocusTrap, updateFocusTrapElements } from "../../utils/focusTrapComponent";
import { componentLoaded, setComponentLoaded, setUpLoadableComponent } from "../../utils/loadable";
import { createObserver } from "../../utils/observers";
import { onToggleOpenCloseComponent } from "../../utils/openCloseComponent";
import { CSS, ICONS, SLOTS } from "./resources";
import { connectLocalized, disconnectLocalized } from "../../utils/locale";
import { connectMessages, disconnectMessages, setUpMessages, updateMessages } from "../../utils/t9n";
/**
* @slot header - A slot for adding header text.
* @slot content - A slot for adding the component's content.
* @slot content-top - A slot for adding content to the component's sticky header, where content remains at the top of the component when scrolling up and down.
* @slot content-bottom - A slot for adding content to the component's sticky footer, where content remains at the bottom of the component when scrolling up and down.
* @slot primary - A slot for adding a primary button.
* @slot secondary - A slot for adding a secondary button.
* @slot back - A slot for adding a back button.
*/
export class Modal {
constructor() {
this.mutationObserver = createObserver("mutation", () => this.handleMutationObserver());
this.cssVarObserver = createObserver("mutation", () => {
this.updateSizeCssVars();
});
this.openTransitionProp = "opacity";
//--------------------------------------------------------------------------
//
// Private Methods
//
//--------------------------------------------------------------------------
this.setTransitionEl = (el) => {
this.transitionEl = el;
};
this.openEnd = () => {
this.setFocus();
this.el.removeEventListener("calciteModalOpen", this.openEnd);
};
this.handleOutsideClose = () => {
if (this.outsideCloseDisabled) {
return;
}
this.close();
};
/** Close the modal, first running the `beforeClose` method */
this.close = () => {
return this.beforeClose(this.el).then(() => {
this.open = false;
this.isOpen = false;
this.removeOverflowHiddenClass();
});
};
this.handleMutationObserver = () => {
this.updateFooterVisibility();
this.updateFocusTrapElements();
};
this.updateFooterVisibility = () => {
this.hasFooter = !!getSlotted(this.el, [SLOTS.back, SLOTS.primary, SLOTS.secondary]);
};
this.updateSizeCssVars = () => {
this.cssWidth = getComputedStyle(this.el).getPropertyValue("--calcite-modal-width");
this.cssHeight = getComputedStyle(this.el).getPropertyValue("--calcite-modal-height");
};
this.contentTopSlotChangeHandler = (event) => {
this.hasContentTop = slotChangeHasAssignedElement(event);
};
this.contentBottomSlotChangeHandler = (event) => {
this.hasContentBottom = slotChangeHasAssignedElement(event);
};
this.open = false;
this.beforeClose = () => Promise.resolve();
this.closeButtonDisabled = false;
this.focusTrapDisabled = false;
this.outsideCloseDisabled = false;
this.docked = undefined;
this.escapeDisabled = false;
this.scale = "m";
this.width = "m";
this.fullscreen = undefined;
this.kind = undefined;
this.messages = undefined;
this.messageOverrides = undefined;
this.slottedInShell = undefined;
this.cssWidth = undefined;
this.cssHeight = undefined;
this.hasFooter = true;
this.hasContentTop = false;
this.hasContentBottom = false;
this.isOpen = false;
this.effectiveLocale = undefined;
this.defaultMessages = undefined;
}
handleFocusTrapDisabled(focusTrapDisabled) {
if (!this.open) {
return;
}
focusTrapDisabled ? deactivateFocusTrap(this) : activateFocusTrap(this);
}
onMessagesChange() {
/* wired up by t9n util */
}
//--------------------------------------------------------------------------
//
// Lifecycle
//
//--------------------------------------------------------------------------
async componentWillLoad() {
await setUpMessages(this);
setUpLoadableComponent(this);
// when modal initially renders, if active was set we need to open as watcher doesn't fire
if (this.open) {
onToggleOpenCloseComponent(this);
requestAnimationFrame(() => this.openModal());
}
}
componentDidLoad() {
setComponentLoaded(this);
}
connectedCallback() {
this.mutationObserver?.observe(this.el, { childList: true, subtree: true });
this.cssVarObserver?.observe(this.el, { attributeFilter: ["style"] });
this.updateSizeCssVars();
this.updateFooterVisibility();
connectConditionalSlotComponent(this);
connectLocalized(this);
connectMessages(this);
connectFocusTrap(this);
}
disconnectedCallback() {
this.removeOverflowHiddenClass();
this.mutationObserver?.disconnect();
this.cssVarObserver?.disconnect();
disconnectConditionalSlotComponent(this);
deactivateFocusTrap(this);
disconnectLocalized(this);
disconnectMessages(this);
this.slottedInShell = false;
}
render() {
return (h(Host, { "aria-describedby": this.contentId, "aria-labelledby": this.titleId, "aria-modal": "true", role: "dialog" }, h("div", { class: {
[CSS.container]: true,
[CSS.containerOpen]: this.isOpen,
[CSS.slottedInShell]: this.slottedInShell
} }, h("calcite-scrim", { class: CSS.scrim, onClick: this.handleOutsideClose }), this.renderStyle(), h("div", { class: {
[CSS.modal]: true
},
// eslint-disable-next-line react/jsx-sort-props
ref: this.setTransitionEl }, h("div", { class: CSS.header }, this.renderCloseButton(), h("header", { class: CSS.title }, h("slot", { name: CSS.header }))), this.renderContentTop(), h("div", { class: {
[CSS.content]: true,
[CSS.contentNoFooter]: !this.hasFooter
},
// eslint-disable-next-line react/jsx-sort-props
ref: (el) => (this.modalContent = el) }, h("slot", { name: SLOTS.content })), this.renderContentBottom(), this.renderFooter()))));
}
renderFooter() {
return this.hasFooter ? (h("div", { class: CSS.footer, key: "footer" }, h("span", { class: CSS.back }, h("slot", { name: SLOTS.back })), h("span", { class: CSS.secondary }, h("slot", { name: SLOTS.secondary })), h("span", { class: CSS.primary }, h("slot", { name: SLOTS.primary })))) : null;
}
renderContentTop() {
return (h("div", { class: CSS.contentTop, hidden: !this.hasContentTop }, h("slot", { name: SLOTS.contentTop, onSlotchange: this.contentTopSlotChangeHandler })));
}
renderContentBottom() {
return (h("div", { class: CSS.contentBottom, hidden: !this.hasContentBottom }, h("slot", { name: SLOTS.contentBottom, onSlotchange: this.contentBottomSlotChangeHandler })));
}
renderCloseButton() {
return !this.closeButtonDisabled ? (h("button", { "aria-label": this.messages.close, class: CSS.close, key: "button", onClick: this.close, title: this.messages.close,
// eslint-disable-next-line react/jsx-sort-props
ref: (el) => (this.closeButtonEl = el) }, h("calcite-icon", { icon: ICONS.close, scale: this.scale === "s" ? "s" : this.scale === "m" ? "m" : this.scale === "l" ? "l" : null }))) : null;
}
renderStyle() {
if (!this.fullscreen && (this.cssWidth || this.cssHeight)) {
return (h("style", null, `.${CSS.container} {
${this.docked && this.cssWidth ? `align-items: center !important;` : ""}
}
.${CSS.modal} {
block-size: ${this.cssHeight ? this.cssHeight : "auto"} !important;
${this.cssWidth ? `inline-size: ${this.cssWidth} !important;` : ""}
${this.cssWidth ? `max-inline-size: ${this.cssWidth} !important;` : ""}
${this.docked ? `border-radius: var(--calcite-border-radius) !important;` : ""}
}
screen and (max-width: ${this.cssWidth}) {
.${CSS.container} {
${this.docked ? `align-items: flex-end !important;` : ""}
}
.${CSS.modal} {
max-block-size: 100% !important;
inline-size: 100% !important;
max-inline-size: 100% !important;
min-inline-size: 100% !important;
margin: 0 !important;
${!this.docked ? `block-size: 100% !important;` : ""}
${!this.docked ? `border-radius: 0 !important;` : ""}
${this.docked
? `border-radius: var(--calcite-border-radius) var(--calcite-border-radius) 0 0 !important;`
: ""}
}
}
`));
}
}
effectiveLocaleChange() {
updateMessages(this, this.effectiveLocale);
}
//--------------------------------------------------------------------------
//
// Event Listeners
//
//--------------------------------------------------------------------------
handleEscape(event) {
if (this.open && !this.escapeDisabled && event.key === "Escape" && !event.defaultPrevented) {
this.close();
event.preventDefault();
}
}
//--------------------------------------------------------------------------
//
// Public Methods
//
//--------------------------------------------------------------------------
/**
* Sets focus on the component's "close" button (the first focusable item).
*
*/
async setFocus() {
await componentLoaded(this);
focusFirstTabbable(this.el);
}
/**
* Updates the element(s) that are used within the focus-trap of the component.
*/
async updateFocusTrapElements() {
updateFocusTrapElements(this);
}
/**
* Sets the scroll top of the component's content.
*
* @param top
* @param left
*/
async scrollContent(top = 0, left = 0) {
if (this.modalContent) {
if (this.modalContent.scrollTo) {
this.modalContent.scrollTo({ top, left, behavior: "smooth" });
}
else {
this.modalContent.scrollTop = top;
this.modalContent.scrollLeft = left;
}
}
}
onBeforeOpen() {
this.transitionEl.classList.add(CSS.openingActive);
this.calciteModalBeforeOpen.emit();
}
onOpen() {
this.transitionEl.classList.remove(CSS.openingIdle, CSS.openingActive);
this.calciteModalOpen.emit();
activateFocusTrap(this);
}
onBeforeClose() {
this.transitionEl.classList.add(CSS.closingActive);
this.calciteModalBeforeClose.emit();
}
onClose() {
this.transitionEl.classList.remove(CSS.closingIdle, CSS.closingActive);
this.calciteModalClose.emit();
deactivateFocusTrap(this);
}
async toggleModal(value) {
onToggleOpenCloseComponent(this);
if (value) {
this.transitionEl?.classList.add(CSS.openingIdle);
this.openModal();
}
else {
this.transitionEl?.classList.add(CSS.closingIdle);
this.close();
}
}
/** Open the modal */
openModal() {
this.el.addEventListener("calciteModalOpen", this.openEnd);
this.open = true;
this.isOpen = true;
const titleEl = getSlotted(this.el, SLOTS.header);
const contentEl = getSlotted(this.el, SLOTS.content);
this.titleId = ensureId(titleEl);
this.contentId = ensureId(contentEl);
if (!this.slottedInShell) {
document.documentElement.classList.add(CSS.overflowHidden);
}
}
removeOverflowHiddenClass() {
document.documentElement.classList.remove(CSS.overflowHidden);
}
static get is() { return "calcite-modal"; }
static get encapsulation() { return "shadow"; }
static get originalStyleUrls() {
return {
"$": ["modal.scss"]
};
}
static get styleUrls() {
return {
"$": ["modal.css"]
};
}
static get assetsDirs() { return ["assets"]; }
static get properties() {
return {
"open": {
"type": "boolean",
"mutable": true,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "When `true`, displays and positions the component."
},
"attribute": "open",
"reflect": true,
"defaultValue": "false"
},
"beforeClose": {
"type": "unknown",
"mutable": false,
"complexType": {
"original": "(el: HTMLElement) => Promise<void>",
"resolved": "(el: HTMLElement) => Promise<void>",
"references": {
"HTMLElement": {
"location": "global"
},
"Promise": {
"location": "global"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Passes a function to run before the component closes."
},
"defaultValue": "() => Promise.resolve()"
},
"closeButtonDisabled": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "When `true`, disables the component's close button."
},
"attribute": "close-button-disabled",
"reflect": true,
"defaultValue": "false"
},
"focusTrapDisabled": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "When `true`, prevents focus trapping."
},
"attribute": "focus-trap-disabled",
"reflect": true,
"defaultValue": "false"
},
"outsideCloseDisabled": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "When `true`, disables the closing of the component when clicked outside."
},
"attribute": "outside-close-disabled",
"reflect": true,
"defaultValue": "false"
},
"docked": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "When `true`, prevents the component from expanding to the entire screen on mobile devices."
},
"attribute": "docked",
"reflect": true
},
"escapeDisabled": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "When `true`, disables the default close on escape behavior."
},
"attribute": "escape-disabled",
"reflect": true,
"defaultValue": "false"
},
"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."
},
"attribute": "scale",
"reflect": true,
"defaultValue": "\"m\""
},
"width": {
"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 width of the component."
},
"attribute": "width",
"reflect": true,
"defaultValue": "\"m\""
},
"fullscreen": {
"type": "boolean",
"mutable": false,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Sets the component to always be fullscreen (overrides `width` and `--calcite-modal-width` / `--calcite-modal-height`)."
},
"attribute": "fullscreen",
"reflect": true
},
"kind": {
"type": "string",
"mutable": false,
"complexType": {
"original": "Extract<\"brand\" | \"danger\" | \"info\" | \"success\" | \"warning\", Kind>",
"resolved": "\"brand\" | \"danger\" | \"info\" | \"success\" | \"warning\"",
"references": {
"Extract": {
"location": "global"
},
"Kind": {
"location": "import",
"path": "../interfaces"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Specifies the kind of the component (will apply to top border)."
},
"attribute": "kind",
"reflect": true
},
"messages": {
"type": "unknown",
"mutable": true,
"complexType": {
"original": "ModalMessages",
"resolved": "{ close: string; }",
"references": {
"ModalMessages": {
"location": "import",
"path": "./assets/modal/t9n"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [{
"name": "internal",
"text": undefined
}],
"text": "Made into a prop for testing purposes only"
}
},
"messageOverrides": {
"type": "unknown",
"mutable": true,
"complexType": {
"original": "Partial<ModalMessages>",
"resolved": "{ close?: string; }",
"references": {
"Partial": {
"location": "global"
},
"ModalMessages": {
"location": "import",
"path": "./assets/modal/t9n"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [],
"text": "Use this property to override individual strings used by the component."
}
},
"slottedInShell": {
"type": "boolean",
"mutable": true,
"complexType": {
"original": "boolean",
"resolved": "boolean",
"references": {}
},
"required": false,
"optional": false,
"docs": {
"tags": [{
"name": "internal",
"text": undefined
}],
"text": "This internal property, managed by a containing calcite-shell, is used\nto inform the component if special configuration or styles are needed"
},
"attribute": "slotted-in-shell",
"reflect": false
}
};
}
static get states() {
return {
"cssWidth": {},
"cssHeight": {},
"hasFooter": {},
"hasContentTop": {},
"hasContentBottom": {},
"isOpen": {},
"effectiveLocale": {},
"defaultMessages": {}
};
}
static get events() {
return [{
"method": "calciteModalBeforeClose",
"name": "calciteModalBeforeClose",
"bubbles": true,
"cancelable": false,
"composed": true,
"docs": {
"tags": [],
"text": "Fires when the component is requested to be closed and before the closing transition begins."
},
"complexType": {
"original": "void",
"resolved": "void",
"references": {}
}
}, {
"method": "calciteModalClose",
"name": "calciteModalClose",
"bubbles": true,
"cancelable": false,
"composed": true,
"docs": {
"tags": [],
"text": "Fires when the component is closed and animation is complete."
},
"complexType": {
"original": "void",
"resolved": "void",
"references": {}
}
}, {
"method": "calciteModalBeforeOpen",
"name": "calciteModalBeforeOpen",
"bubbles": true,
"cancelable": false,
"composed": true,
"docs": {
"tags": [],
"text": "Fires when the component is added to the DOM but not rendered, and before the opening transition begins."
},
"complexType": {
"original": "void",
"resolved": "void",
"references": {}
}
}, {
"method": "calciteModalOpen",
"name": "calciteModalOpen",
"bubbles": true,
"cancelable": false,
"composed": true,
"docs": {
"tags": [],
"text": "Fires when the component is open and animation is complete."
},
"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's \"close\" button (the first focusable item).",
"tags": []
}
},
"updateFocusTrapElements": {
"complexType": {
"signature": "() => Promise<void>",
"parameters": [],
"references": {
"Promise": {
"location": "global"
}
},
"return": "Promise<void>"
},
"docs": {
"text": "Updates the element(s) that are used within the focus-trap of the component.",
"tags": []
}
},
"scrollContent": {
"complexType": {
"signature": "(top?: number, left?: number) => Promise<void>",
"parameters": [{
"tags": [{
"name": "param",
"text": "top"
}],
"text": ""
}, {
"tags": [{
"name": "param",
"text": "left"
}],
"text": ""
}],
"references": {
"Promise": {
"location": "global"
}
},
"return": "Promise<void>"
},
"docs": {
"text": "Sets the scroll top of the component's content.",
"tags": [{
"name": "param",
"text": "top"
}, {
"name": "param",
"text": "left"
}]
}
}
};
}
static get elementRef() { return "el"; }
static get watchers() {
return [{
"propName": "focusTrapDisabled",
"methodName": "handleFocusTrapDisabled"
}, {
"propName": "messageOverrides",
"methodName": "onMessagesChange"
}, {
"propName": "effectiveLocale",
"methodName": "effectiveLocaleChange"
}, {
"propName": "open",
"methodName": "toggleModal"
}];
}
static get listeners() {
return [{
"name": "keydown",
"method": "handleEscape",
"target": "window",
"capture": false,
"passive": false
}];
}
}