UNPKG

@scania/tegel

Version:
602 lines (601 loc) 24 kB
import { h, Host, } from "@stencil/core"; import hasSlot from "../../utils/hasSlot"; import generateUniqueId from "../../utils/generateUniqueId"; /** * @slot header - Slot for header text * @slot body - Slot for main content of modal * @slot actions - Slot for extra buttons * */ export class TdsModal { constructor() { /** Disables closing Modal on clicking on overlay area. */ this.prevent = false; /** Size of Modal */ this.size = 'md'; /** Changes the position behaviour of the actions slot. */ this.actionsPosition = 'static'; /** Shows or hides the close [X] button. */ this.closable = true; /** Role of the modal component. Can be either 'alertdialog' for important messages that require immediate attention, or 'dialog' for regular messages. */ this.tdsAlertDialog = 'dialog'; // State that keeps track of show/closed state for the Modal. this.isShown = false; // Focus state index in focusable Elements this.activeElementIndex = 0; this.handleClose = (event) => { const closeEvent = this.tdsClose.emit(event); if (closeEvent.defaultPrevented) return; this.isShown = false; this.returnFocusOnClose(); }; this.handleShow = () => { const showEvent = this.tdsOpen.emit(); if (showEvent.defaultPrevented) return; this.isShown = true; this.onOpen(); }; /** Checks if click on Modal is on overlay, if so it closes the Modal if prevent is not true. */ this.handleOverlayClick = (event) => { const targetList = event.composedPath(); const target = targetList[0]; if (target.classList[0] === 'tds-modal-close' || (target.classList[0] === 'tds-modal-backdrop' && this.prevent === false)) { this.handleClose(event); } }; this.handleReferenceElementClick = (event) => { if (this.isShown) { this.handleClose(event); } else { this.handleShow(); } }; /** Check if there is a referenceElement or selector and adds event listener to them if so. */ this.setShowButton = () => { var _a; if (this.selector || this.referenceEl) { const referenceEl = (_a = this.referenceEl) !== null && _a !== void 0 ? _a : (this.selector ? document.querySelector(this.selector) : null); if (referenceEl) { this.initializeReferenceElement(referenceEl); } } }; /** Adds an event listener to the reference element that shows/closes the Modal. */ this.initializeReferenceElement = (referenceEl) => { if (referenceEl) { referenceEl.addEventListener('click', this.handleReferenceElementClick); } }; } /** Shows the Modal. */ async showModal() { this.isShown = true; this.onOpen(); } /** Closes the Modal. */ async closeModal() { this.isShown = false; this.returnFocusOnClose(); } /** Returns the current open state of the Modal. */ async isOpen() { return this.isShown; } /** Runs whenever the show prop changes. */ handleShowPropChange(newValue, oldValue) { if (newValue === oldValue || newValue === undefined) return; this.isShown = newValue; if (newValue) { this.onOpen(); } else { this.returnFocusOnClose(); } } connectedCallback() { if (this.closable === undefined) { this.closable = true; } if (this.show !== undefined) { this.isShown = this.show; } this.initializeModal(); if (this.header && hasSlot('header', this.host)) { console.warn("Tegel Modal component: Using both header prop and header slot might break modal's design. Please use just one of them. "); } if (!this.selector && !this.referenceEl) { console.warn('Tegel Modal: Missing focus origin. Please provide either a "referenceEl" or a "selector" to ensure focus returns to the element that opened the modal. If the modal is opened programmatically, this message can be ignored.'); } } componentWillLoad() { this.initializeModal(); } disconnectedCallback() { this.cleanupModal(); } /** Initializes or re-initializes the modal, setting up event listeners. */ async initializeModal() { this.setDismissButtons(); this.setShowButton(); } /** Cleans up event listeners and other resources. */ async cleanupModal() { var _a; if (this.selector || this.referenceEl) { const referenceEl = (_a = this.referenceEl) !== null && _a !== void 0 ? _a : (this.selector ? document.querySelector(this.selector) : null); if (referenceEl) { referenceEl.removeEventListener('click', this.handleReferenceElementClick); } } this.host.querySelectorAll('[data-dismiss-modal]').forEach((dismissButton) => { dismissButton.removeEventListener('click', this.handleClose); }); } returnFocusOnClose() { var _a; const referenceElement = (_a = this.referenceEl) !== null && _a !== void 0 ? _a : (this.selector ? document.querySelector(this.selector) : null); if (!referenceElement) return; const potentialReferenceElements = ['BUTTON', 'A', 'INPUT']; const isNativeFocusable = potentialReferenceElements.includes(referenceElement.tagName); const interactiveElement = isNativeFocusable ? referenceElement : referenceElement.querySelector(potentialReferenceElements.join(',')); if (!interactiveElement) return; interactiveElement.classList.remove('active'); interactiveElement.focus(); } getFocusableElements() { var _a, _b; const focusableSelectors = [ 'a[href]', 'button:not([disabled])', 'textarea:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(','); const focusableInShadowRoot = Array.from((_b = (_a = this.host.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelectorAll(focusableSelectors)) !== null && _b !== void 0 ? _b : []); const focusableInSlots = Array.from(this.host.querySelectorAll(focusableSelectors)); /** Focusable elements */ return [...focusableInShadowRoot, ...focusableInSlots]; } /** Resets the scroll position to the top. */ resetScrollPosition() { var _a; const root = this.host.shadowRoot; const scroller = (_a = root === null || root === void 0 ? void 0 : root.querySelector('.tds-modal__actions-sticky .body')) !== null && _a !== void 0 ? _a : root === null || root === void 0 ? void 0 : root.querySelector('.tds-modal'); scroller === null || scroller === void 0 ? void 0 : scroller.scrollTo(0, 0); } focusFirstElement() { var _a, _b; const focusableSelectors = [ 'a[href]', 'button:not([disabled])', 'textarea:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(','); // Prioritize focusable elements in slotted content (actions/body) over shadow DOM elements (like close button) const focusableInSlots = Array.from(this.host.querySelectorAll(focusableSelectors)); if (focusableInSlots.length > 0) { focusableInSlots[0].focus(); this.activeElementIndex = this.getFocusableElements().indexOf(focusableInSlots[0]); } else { // Fallback to shadow DOM elements if no slotted content is focusable const focusableInShadowRoot = Array.from((_b = (_a = this.host.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelectorAll(focusableSelectors)) !== null && _b !== void 0 ? _b : []); if (focusableInShadowRoot.length > 0) { focusableInShadowRoot[0].focus(); this.activeElementIndex = 0; } } } /** Runs whenever the modal is opened and updates it. */ onOpen() { // Focus immediately to preserve interaction modality for :focus-visible this.focusFirstElement(); // Defer scroll reset to next frame requestAnimationFrame(() => { this.resetScrollPosition(); }); } handleFocusTrap(event) { if (event.key === 'Escape' && this.isShown && !this.prevent) { this.handleClose(event); return; } // Only trap focus if the modal is open if (!this.isShown) return; // We care only about the Tab key if (event.key !== 'Tab') return; const focusableElements = this.getFocusableElements(); // If there are no focusable elements if (focusableElements.length === 0) return; event.preventDefault(); // Going backwards (Shift + Tab) on the first element => move to last if (event.shiftKey) { this.activeElementIndex -= 1; if (this.activeElementIndex === -1) { this.activeElementIndex = focusableElements.length - 1; } } // // Going forwards (Tab) on the last element => move to first if (!event.shiftKey) { this.activeElementIndex += 1; if (this.activeElementIndex === focusableElements.length) { this.activeElementIndex = 0; } } const nextElement = focusableElements[this.activeElementIndex]; nextElement.focus(); } /** Adds an event listener to the dismiss buttons that closes the Modal. */ setDismissButtons() { this.host.querySelectorAll('[data-dismiss-modal]').forEach((dismissButton) => { dismissButton.addEventListener('click', this.handleClose); }); } render() { const usesHeaderSlot = hasSlot('header', this.host); const usesActionsSlot = hasSlot('actions', this.host); const headerId = this.header ? `tds-modal-header-${generateUniqueId()}` : undefined; const bodyId = `tds-modal-body-${generateUniqueId()}`; return (h(Host, { key: '627470cc4fd39a05084149c125b34e8c41fca76b', role: this.tdsAlertDialog, "aria-modal": "true", "aria-describedby": bodyId, "aria-labelledby": headerId, class: { show: this.isShown, hide: !this.isShown, }, onClick: (event) => this.handleOverlayClick(event) }, h("div", { key: '64cbcfcc22244a706d448dab7834a83173943d85', class: "tds-modal-backdrop" }), h("div", { key: 'ab7af811011361c8b5fb311dff6fd12143ad9fa4', class: `tds-modal tds-modal__actions-${this.actionsPosition} tds-modal-${this.size}`, tabindex: "-1" }, h("div", { key: '4aa8706b05c2b2dfd286368b0f1dd7e4eaaec10f', id: headerId, class: "header" }, this.header && h("div", { key: 'a24a3998f441a80a5ba39082e1b8c6a4568424a1', class: "header-text" }, this.header), usesHeaderSlot && h("slot", { key: '5aba912426f4743d5c136230cfbccbc3730b2adf', name: "header" }), this.closable && (h("button", { key: '14da162e80c29bdaad47990af70b533a1a2e0fc2', class: "tds-modal-close", "aria-label": "close", onClick: (event) => this.handleClose(event) }, h("tds-icon", { key: 'f28f4aedb9e432849803a5961db568db4c157003', name: "cross", size: "20px" })))), h("div", { key: '928a57c57f5bbbcd6411da9378d2001c9befb855', id: bodyId, class: "body" }, h("slot", { key: '800c2deda52545ce5ca457c3274734df18a1bd57', name: "body" })), usesActionsSlot && h("slot", { key: 'd453d7e1fe3062aa238ca19883345aa09c0b87c5', name: "actions" })))); } static get is() { return "tds-modal"; } static get encapsulation() { return "shadow"; } static get originalStyleUrls() { return { "$": ["modal.scss"] }; } static get styleUrls() { return { "$": ["modal.css"] }; } static get properties() { return { "header": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string | undefined", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "Sets the header of the Modal." }, "getter": false, "setter": false, "reflect": false, "attribute": "header" }, "prevent": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Disables closing Modal on clicking on overlay area." }, "getter": false, "setter": false, "reflect": false, "attribute": "prevent", "defaultValue": "false" }, "size": { "type": "string", "mutable": false, "complexType": { "original": "'xs' | 'sm' | 'md' | 'lg'", "resolved": "\"lg\" | \"md\" | \"sm\" | \"xs\"", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Size of Modal" }, "getter": false, "setter": false, "reflect": false, "attribute": "size", "defaultValue": "'md'" }, "actionsPosition": { "type": "string", "mutable": false, "complexType": { "original": "'sticky' | 'static'", "resolved": "\"static\" | \"sticky\"", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Changes the position behaviour of the actions slot." }, "getter": false, "setter": false, "reflect": false, "attribute": "actions-position", "defaultValue": "'static'" }, "selector": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string | undefined", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "CSS selector for the element that will show the Modal." }, "getter": false, "setter": false, "reflect": false, "attribute": "selector" }, "referenceEl": { "type": "unknown", "mutable": false, "complexType": { "original": "HTMLElement | null", "resolved": "HTMLElement | null | undefined", "references": { "HTMLElement": { "location": "global", "id": "global::HTMLElement" } } }, "required": false, "optional": true, "docs": { "tags": [], "text": "Element that will show the Modal (takes priority over selector)" }, "getter": false, "setter": false }, "show": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean | undefined", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "Controls whether the Modal is shown or not. If this is set hiding and showing\nwill be decided by this prop and will need to be controlled from the outside." }, "getter": false, "setter": false, "reflect": false, "attribute": "show" }, "closable": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Shows or hides the close [X] button." }, "getter": false, "setter": false, "reflect": false, "attribute": "closable", "defaultValue": "true" }, "tdsAlertDialog": { "type": "string", "mutable": false, "complexType": { "original": "'alertdialog' | 'dialog'", "resolved": "\"alertdialog\" | \"dialog\"", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Role of the modal component. Can be either 'alertdialog' for important messages that require immediate attention, or 'dialog' for regular messages." }, "getter": false, "setter": false, "reflect": false, "attribute": "tds-alert-dialog", "defaultValue": "'dialog'" } }; } static get states() { return { "isShown": {}, "activeElementIndex": {} }; } static get events() { return [{ "method": "tdsClose", "name": "tdsClose", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emits when the Modal is closed." }, "complexType": { "original": "object", "resolved": "object", "references": {} } }, { "method": "tdsOpen", "name": "tdsOpen", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emits just before Modal is opened." }, "complexType": { "original": "void", "resolved": "void", "references": {} } }]; } static get methods() { return { "showModal": { "complexType": { "signature": "() => Promise<void>", "parameters": [], "references": { "Promise": { "location": "global", "id": "global::Promise" } }, "return": "Promise<void>" }, "docs": { "text": "Shows the Modal.", "tags": [] } }, "closeModal": { "complexType": { "signature": "() => Promise<void>", "parameters": [], "references": { "Promise": { "location": "global", "id": "global::Promise" } }, "return": "Promise<void>" }, "docs": { "text": "Closes the Modal.", "tags": [] } }, "isOpen": { "complexType": { "signature": "() => Promise<boolean>", "parameters": [], "references": { "Promise": { "location": "global", "id": "global::Promise" } }, "return": "Promise<boolean>" }, "docs": { "text": "Returns the current open state of the Modal.", "tags": [] } }, "initializeModal": { "complexType": { "signature": "() => Promise<void>", "parameters": [], "references": { "Promise": { "location": "global", "id": "global::Promise" } }, "return": "Promise<void>" }, "docs": { "text": "Initializes or re-initializes the modal, setting up event listeners.", "tags": [] } }, "cleanupModal": { "complexType": { "signature": "() => Promise<void>", "parameters": [], "references": { "Promise": { "location": "global", "id": "global::Promise" }, "HTMLElement": { "location": "global", "id": "global::HTMLElement" } }, "return": "Promise<void>" }, "docs": { "text": "Cleans up event listeners and other resources.", "tags": [] } } }; } static get elementRef() { return "host"; } static get watchers() { return [{ "propName": "show", "methodName": "handleShowPropChange" }]; } static get listeners() { return [{ "name": "keydown", "method": "handleFocusTrap", "target": "window", "capture": true, "passive": false }]; } }