import { customScroll } from "@supersoniks/concorde/core/components/ui/_css/scroll"; import Subscriber from "@supersoniks/concorde/core/mixins/Subscriber"; import { css, html, LitElement, nothing, PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { styleMap } from "lit/directives/style-map.js"; import "@supersoniks/concorde/core/components/ui/modal/modal-actions"; import "@supersoniks/concorde/core/components/ui/modal/modal-close"; import "@supersoniks/concorde/core/components/ui/modal/modal-content"; import "@supersoniks/concorde/core/components/ui/modal/modal-subtitle"; import "@supersoniks/concorde/core/components/ui/modal/modal-title"; import { PublisherManager } from "@supersoniks/concorde/utils"; import { ConcordeWindow } from "@supersoniks/concorde/core/_types/types"; import { Theme } from "@supersoniks/concorde/core/components/ui/theme/theme"; import { PrimitiveType } from "@supersoniks/concorde/core/_types/types"; import { DirectiveResult } from "lit/directive.js"; import LocationHandler from "@supersoniks/concorde/core/utils/LocationHandler"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { ButtonType } from "../button/button"; declare const window: ConcordeWindow; declare type ModalCreateOptions = { title?: string | DirectiveResult; subtitle?: string | DirectiveResult; content?: string | DirectiveResult; actions?: string | DirectiveResult; zIndex?: string; paddingX?: string; paddingY?: string; width?: string; maxWidth?: string; height?: string; maxHeight?: string; removeOnHide?: boolean; forceAction?: boolean; removeHashOnHide?: boolean; fullScreen?: boolean; effect?: effectType; closeOnLocationChange?: boolean; noCloseButton?: boolean; closeButtonType?: ButtonType; }; type effectType = "fade" | "slide" | "none"; const tagName = "sonic-modal"; @customElement(tagName) export class Modal extends Subscriber(LitElement) { static styles = [ customScroll, css` :host { --sc-modal-py: 2.5rem; --sc-modal-px: 1.5rem; --sc_backdrop-bg: var( --sc_overlay-bg, var(--sc-base-200, rgba(0, 0, 0, 0.12)) ); --sc_backdrop-opacity: 0; --sc_rounded: var(--sc-rounded-lg); --sc_z-index: 990; } * { box-sizing: border-box; } #modal { background: var(--sc-base, #fff); color: var(--sc-base-content, #000); width: 100%; box-shadow: var(--sc-shadow-lg); border-radius: var(--sc_rounded) var(--sc_rounded); transform: translateZ(0); border: none; outline: none; height: fit-content; padding: 0; pointer-events: none; transition: none !important; opacity: 0; position: fixed; inset: 0; z-index: var(--sc_z-index); } #backdrop { opacity: var(--sc_backdrop-opacity, 0); background: var(--sc_backdrop-bg); transition: opacity 0.15s linear; inset: 0; position: fixed; z-index: var(--sc_z-index); } #modal-content { display: flex; flex-direction: column; min-height: 10rem; line-height: 1.25; padding: var(--sc-modal-py) var(--sc-modal-px); } @media (max-width: 767.5px) { #modal { max-width: none !important; margin: auto 0 0 !important; width: 100% !important; border-radius: var(--sc_rounded) var(--sc_rounded) 0 0 !important; } } :host([fullScreen]) { --sc_rounded: 0; } :host([fullScreen]) #modal { width: 100% !important; height: 100% !important; max-width: none !important; max-height: none !important; transform: none !important; } /* Layout des éléments slottés */ ::slotted(sonic-modal-title), sonic-modal-title { margin-bottom: 1.2rem; } ::slotted(sonic-modal-subtitle), sonic-modal-subtitle { margin-bottom: 1.2rem; margin-top: -1rem; } :host([align="left"]) ::slotted(sonic-modal-title), :host([align="left"]) sonic-modal-title { padding-right: 1em; } :host([align="left"]) #modal-content { text-align: left; align-items: flex-start; } :host([align="center"]) #modal-content { text-align: center; align-items: center; } :host([align="right"]) #modal-content { text-align: right; align-items: flex-end; } /* Close button */ ::slotted(sonic-modal-close), sonic-modal-close { --sc_top: 0.25rem; --sc_x: 0.25rem; position: sticky; display: block; top: var(--sc_top); margin-left: auto; margin-bottom: -1rem; margin-top: calc(-1 * var(--sc-modal-py) - 0.25rem); margin-right: calc(-1 * var(--sc-modal-px) + 0.25rem); z-index: 20; opacity: 0; transform: scale(0); transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.1s linear; transform-origin: center center; display: flex; align-items: center; justify-content: center; } :host([align="right"]) ::slotted(sonic-modal-close), :host([align="right"]) sonic-modal-close { right: auto; margin-right: auto; margin-left: calc(-1 * var(--sc-modal-px) + 0.25rem); } /* Border radius */ :host([rounded="none"]) #modal { --sc-img-radius: 0 !important; } sonic-modal-close.animate-in { transform: scale(1); opacity: 1 !important; transition-delay: 0.2s; } sonic-modal-close.animate-out { opacity: 0 !important; transition-delay: 0s !important; transform: scale(0) !important; transition: 0.2s linear !important; } `, ]; static modals: Array = []; @property({ type: Boolean }) forceAction = false; @property({ type: Boolean }) noCloseButton = false; @property({ type: Boolean }) removeOnHide = false; @property({ type: Boolean }) removeHashOnHide = false; @property({ type: String, reflect: true }) align: | "center" | "right" | "left" = "left"; // Settings de base @property({ type: String }) paddingX?: string; @property({ type: String }) paddingY?: string; @property({ type: String }) maxWidth = "min(100vw, 40rem)"; @property({ type: String }) maxHeight = "90vh"; @property({ type: String }) zIndex?: string; @property({ type: String }) width = "100%"; @property({ type: String }) height = "fit-content"; @property({ type: String }) effect: effectType = "slide"; @property({ type: Object }) options?: ModalCreateOptions; @property({ type: Boolean, reflect: true }) fullScreen = false; @property({ type: Boolean, reflect: true }) visible = false; @property({ type: String }) closeButtonType?: ButtonType; @query("#modal") _modalElement!: HTMLDialogElement; @property({ type: Boolean }) closeOnLocationChange = false; @state() location = ""; @state() private _animationState: "visible" | "in" | "out" | "hidden" = "hidden"; static create(options: ModalCreateOptions): Modal { const modal = document.createElement(tagName) as Modal; // modal styles modal.options = options; if (options.removeHashOnHide === true) modal.setAttribute("removeHashOnHide", "true"); if (options.removeOnHide === true) modal.setAttribute("removeOnHide", "true"); if (options.closeOnLocationChange === true) modal.setAttribute("closeOnLocationChange", "true"); if (options.maxWidth) modal.maxWidth = options?.maxWidth; if (options.width) modal.width = options?.width; if (options.maxHeight) modal.maxHeight = options?.maxHeight; if (options.height) modal.height = options?.height; if (options.forceAction) modal.forceAction = true; if (options.fullScreen) modal.fullScreen = options?.fullScreen; if (options.effect) modal.effect = options?.effect; if (options.noCloseButton) modal.noCloseButton = true; if (options.closeButtonType) modal.closeButtonType = options?.closeButtonType; if (options.paddingX) modal.paddingX = options?.paddingX; if (options.paddingY) modal.paddingY = options?.paddingY; if (options.zIndex) modal.zIndex = options?.zIndex; const container = Theme.getPopContainer(); container.appendChild(modal); modal.updateComplete.then(() => { modal.show(); }); return modal; } connectedCallback(): void { Modal.modals.push(this); LocationHandler.onChange(this); super.connectedCallback(); } disconnectedCallback(): void { LocationHandler.offChange(this); Modal.modals.splice(Modal.modals.indexOf(this), 1); this.removeEventListener("keydown", this.handleEscape); super.disconnectedCallback(); } firstUpdated(): void { this.addEventListener("keydown", this.handleEscape); } willUpdate(_changedProperties: PropertyValues): void { // CLOSE modal on location change if (this.closeOnLocationChange && _changedProperties.has("location")) { const previousLocation = _changedProperties.get("location"); if ( previousLocation && this.location && this.location !== previousLocation ) { setTimeout(() => { this.hide(); }, 50); } } if (_changedProperties.has("zIndex")) { this.style.setProperty("--sc_z-index", this.zIndex || "990"); } if (_changedProperties.has("paddingX")) { this.style.setProperty("--sc-modal-px", this.paddingX || ""); } if (_changedProperties.has("paddingY")) { this.style.setProperty("--sc-modal-py", this.paddingY || ""); } super.willUpdate(_changedProperties); } protected updated(_changedProperties: PropertyValues): void { const changedToVisible = !_changedProperties.get("visible") && this.visible; const changedToHidden = _changedProperties.get("visible") && !this.visible; if (changedToVisible && this._animationState === "hidden") { this.show(); } else if (changedToHidden && this._animationState === "visible") { this.hide(); } } // SI c'est en modal // handleModalClick(e: MouseEvent): void { // const isBackdropClick = e.target === e.currentTarget; // if ( // isBackdropClick && // !this.forceAction && // this._animationState === "visible" // ) { // this.hide(); // } // } handleOverlayClick(_e: MouseEvent) { if (!this.forceAction && this._animationState === "visible") { this.hide(); } } render() { const modalStyles = { maxWidth: this.maxWidth, maxHeight: this.maxHeight, width: this.width, height: this.height, pointerEvents: this._animationState !== "hidden" ? "auto" : "none", }; const overlayStyles = { display: this.fullScreen ? "none" : "block", pointerEvents: this._animationState === "visible" ? "auto" : "none", }; return html`
${this._animationState !== "hidden" ? html`` : nothing} `; } modalFragment(optionKey: keyof ModalCreateOptions) { const optionValue: Object | PrimitiveType = this.options?.[optionKey]; if (!optionValue) return nothing; let output; // si object c'est une template Result //@todo, faire mieux... if (optionValue instanceof Object) { output = optionValue; } else { output = unsafeHTML(optionValue as string); } switch (optionKey) { case "title": return html`${output}`; case "subtitle": return html`${output}`; case "content": return html`${output}`; case "actions": return html`${output}`; default: return nothing; } } // Show the modal async show(): Promise { this._modalElement.show(); this._animationState = "in"; await this.animation("in"); this._animationState = "visible"; this.visible = true; this.dispatchEvent(new CustomEvent("show")); this._modalElement.focus(); } // Hide the modal async hide(): Promise { this._animationState = "out"; this.dispatchEvent(new CustomEvent("hide")); await this.animation("out"); this._modalElement.close(); this._animationState = "hidden"; this.visible = false; if (this.hasAttribute("resetDataProviderOnHide")) { PublisherManager.get(this.getAttribute("resetDataProviderOnHide")).set( {} ); } if (this.removeHashOnHide) { window.history.replaceState({}, "", window.location.pathname); } if (this.removeOnHide) { this.remove(); } this.dispatchEvent(new CustomEvent("hidden")); } // Hide and remove the modal async dispose() { await this.hide(); this.remove(); } // Hide and remove all modals static disposeAll() { Modal.modals.forEach((modal) => { modal.dispose(); }); } // Hide the last visible modal handleEscape(e: KeyboardEvent): void { if (e.key === "Escape") { e.preventDefault(); const visibleModals = Modal.modals.filter( (modal) => modal._animationState !== "hidden" && !modal.forceAction ); if (visibleModals.length > 0) { const lastVisibleModal = visibleModals[visibleModals.length - 1]; lastVisibleModal.hide(); } } } private _animationConfig = { quartOut: "cubic-bezier(0.165, 0.84, 0.44, 1)", quadOut: "cubic-bezier(0.25, 0.46, 0.45, 0.94)", linear: "linear", translateY: "translateY(2.5rem)", durationIn: 300, durationOut: 300, } as const; // ------------------------ // ANIMATIONS // ------------------------ private animation(direction: "in" | "out"): Promise { return new Promise((resolve) => { const { quartOut, linear, translateY, durationIn, durationOut, quadOut } = this._animationConfig; const element = this._modalElement; if (!element) return resolve(); const isEntering = direction === "in"; const duration = isEntering ? durationIn : durationOut; const delay = !this.fullScreen && isEntering ? 100 : 0; // Backdrop if (!this.fullScreen) { if (isEntering) { this.style.setProperty("--sc_backdrop-opacity", "0.8"); } else { setTimeout(() => { this.style.setProperty("--sc_backdrop-opacity", "0"); }, 150); } } // Animations const animations = []; if (this.effect === "slide") { animations.push( element.animate( [ { transform: isEntering ? translateY : "translateY(0)" }, { transform: isEntering ? "translateY(0)" : translateY }, ], { duration: duration, easing: isEntering ? quartOut : quadOut, fill: "both", delay: delay, } ) ); animations.push( element.animate( [{ opacity: isEntering ? 0 : 1 }, { opacity: isEntering ? 1 : 0 }], { duration: duration, easing: linear, fill: "both", delay: delay, } ) ); } Promise.all(animations.map((anim) => anim.finished)).then(() => resolve() ); }); } } if (typeof window !== "undefined") { window.SonicModal = Modal; }