@scania/tegel
Version:
Tegel Design System
602 lines (601 loc) • 24 kB
JavaScript
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
}];
}
}