import { ChangeDetectionStrategy, Component, effect, ElementRef, ErrorHandler, inject, input, model, output, type TemplateRef, ViewEncapsulation, } from "@angular/core"; import { NgTemplateOutlet } from "@angular/common"; import { NgIcon } from "@ng-icons/core"; import { tablerX } from "@ng-icons/tabler-icons"; import { SdActivatedModalProvider } from "./sd-activated-modal.provider"; import { SdSystemConfigProvider } from "../config/sd-system-config.provider"; import { injectFocusTrap } from "./injectFocusTrap"; import { injectDragResize } from "./injectDragResize"; import { SdAnchor } from "../../controls/button/sd-anchor"; import { SdResizeDirective, type SdResizeEvent } from "../events/sd-resize"; import "@simplysm/core-browser"; @Component({ selector: "sd-modal", changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, standalone: true, imports: [NgTemplateOutlet, NgIcon, SdAnchor, SdResizeDirective], hostDirectives: [{ directive: SdResizeDirective, outputs: ["sdResize"] }], host: { "[attr.data-sd-open]": "open() || undefined", "[attr.data-sd-float]": "float() || undefined", "[attr.data-sd-fill]": "fill() || undefined", "[attr.data-sd-position]": "position() || undefined", "(sdResize)": "onHostResize($event)", "(window:resize)": "onWindowResize()", }, template: `
@if (!hideHeader()) {
{{ title() }}
@if (actionTplRef()) { } @if (!hideCloseButton()) { }
}
@if (resizable()) {
}
`, styles: [ /* language=SCSS */ ` @use "../../../scss/commons/mixins"; sd-modal { display: block; position: fixed; z-index: var(--z-index-modal); top: 0; left: 0; width: 100%; height: 100%; padding-top: calc(var(--topbar-height) + var(--gap-sm)); opacity: 0; transition: opacity var(--animation-duration) ease-in-out; pointer-events: none; > ._backdrop { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: var(--trans-default); } > ._dialog { position: relative; display: flex; flex-direction: column; margin: 0 auto; width: fit-content; min-width: 200px; background: var(--control-color); overflow: hidden; @include mixins.elevation(16); border-radius: var(--border-radius-default); transform: translateY(-25px); transition: transform var(--animation-duration) ease-in-out; &:focus { outline: none; } > ._header { display: flex; align-items: center; user-select: none; border-bottom: 1px solid var(--theme-gray-lightest); > ._title { flex: 1; padding: var(--gap-sm) var(--gap-default); } > ._close-btn { padding: var(--gap-sm) var(--gap-default); } } > ._content { flex: 1; overflow: auto; } > ._resize-handle { position: absolute; &._resize-left { top: 0; left: 0; width: var(--gap-sm); height: 100%; cursor: ew-resize; } &._resize-right { top: 0; right: 0; width: var(--gap-sm); height: 100%; cursor: ew-resize; } &._resize-top { top: 0; left: 0; width: 100%; height: var(--gap-sm); cursor: ns-resize; } &._resize-top-right { right: 0; top: 0; width: var(--gap-sm); height: var(--gap-sm); z-index: 1; cursor: nesw-resize; } &._resize-top-left { left: 0; top: 0; width: var(--gap-sm); height: var(--gap-sm); cursor: nwse-resize; z-index: 1; } &._resize-bottom { bottom: 0; left: 0; width: 100%; height: var(--gap-sm); cursor: ns-resize; } &._resize-bottom-right { right: 0; bottom: 0; width: var(--gap-sm); height: var(--gap-sm); z-index: 1; cursor: nwse-resize; } &._resize-bottom-left { left: 0; bottom: 0; width: var(--gap-sm); height: var(--gap-sm); cursor: nesw-resize; z-index: 1; } } } &[data-sd-open][data-sd-init] { opacity: 1; pointer-events: auto; > ._dialog { transform: none; } } &[data-sd-float] { pointer-events: none; > ._backdrop { display: none; } > ._dialog { pointer-events: auto; opacity: 0; @include mixins.elevation(4); border: 1px solid var(--theme-gray-lighter); &:focus { @include mixins.elevation(16); } } &[data-sd-open][data-sd-init] { pointer-events: none; > ._dialog { pointer-events: auto; opacity: 1; } } } &[data-sd-position="bottom-right"] { > ._dialog { position: absolute; right: calc(var(--gap-xxl) * 2); bottom: var(--gap-xxl); } } &[data-sd-position="top-right"] { > ._dialog { position: absolute; right: var(--gap-xxl); top: var(--gap-xxl); } } &[data-sd-fill] { padding-top: 0; > ._dialog { width: 100%; height: 100%; border: none; border-radius: 0; > ._header { background: transparent; color: var(--text-trans-lighter); } } } } `, ], }) export class SdModal { private readonly _elRef = inject(ElementRef); private readonly _sdActivatedModal = inject(SdActivatedModalProvider, { optional: true }); private readonly _sdSystemConfig = inject(SdSystemConfigProvider, { optional: true }); private readonly _errorHandler = inject(ErrorHandler); private readonly _focusTrap = injectFocusTrap(); protected readonly tablerX = tablerX; open = model(false); key = input(undefined); title = input(""); hideHeader = input(false); hideCloseButton = input(false); headerStyle = input(undefined); useCloseByBackdrop = input(true); useCloseByEscapeKey = input(true); float = input(false); fill = input(false); resizable = input(false); movable = input(false); position = input<"bottom-right" | "top-right" | undefined>(undefined); minHeightPx = input(undefined); minWidthPx = input(undefined); heightPx = input(undefined); widthPx = input(undefined); actionTplRef = input | undefined>(undefined); closeRequest = output(); private readonly _dragResize = injectDragResize({ getDialogEl: () => this._getDialogEl(), minWidthPx: this.minWidthPx, minHeightPx: this.minHeightPx, onEnd: () => void this._saveConfig().catch((err) => this._errorHandler.handleError(err)), }); constructor() { // data-sd-init: 첫 렌더 후 설정하여 CSS transition 트리거 허용 effect(() => { this._elRef.nativeElement.setAttribute("data-sd-init", ""); }); // widthPx/heightPx를 dialog 스타일에 적용 effect(() => { const dialogEl = this._getDialogEl(); if (dialogEl == null) return; const w = this.widthPx(); const h = this.heightPx(); if (w != null) { dialogEl.style.width = `${w}px`; } else { dialogEl.style.width = ""; } if (h != null) { dialogEl.style.height = `${h}px`; } else { dialogEl.style.height = ""; } }); // key 기반 설정 복원 effect(() => { const k = this.key(); if (k == null || this._sdSystemConfig == null) return; void this._restoreConfig(k).catch((err) => this._errorHandler.handleError(err)); }); } onResizeMouseDown(event: MouseEvent, dir: string): void { event.preventDefault(); this._dragResize.startResize(event, dir); } onHeaderMouseDown(event: MouseEvent): void { if (!this.movable()) return; if ((event.target as HTMLElement).closest("button, sd-anchor") != null) return; event.preventDefault(); this._dragResize.startDrag(event); } onBackdropClick(): void { if (!this.useCloseByBackdrop()) return; this._requestClose(); } onCloseButtonClick(): void { this._requestClose(); } onDialogKeydown(event: KeyboardEvent): void { if (event.key === "Escape") { if (!this.useCloseByEscapeKey()) return; this._requestClose(); } else if (event.key === "Tab") { this._focusTrap.handleTabTrap(event); } } onDialogFocus(): void { this._bringToFront(); } onHostResize(event: SdResizeEvent): void { if (event.heightChanged) this._calcHeight(); if (event.widthChanged) this._calcWidth(); } onDialogResize(event: SdResizeEvent): void { if (event.heightChanged) this._calcHeight(); if (event.widthChanged) this._calcWidth(); } onWindowResize(): void { const dialogEl = this._getDialogEl(); if (dialogEl == null) return; const hostEl = this._elRef.nativeElement; if (dialogEl.offsetLeft > hostEl.offsetWidth - 100) { dialogEl.style.left = hostEl.offsetWidth - 100 + "px"; } if (dialogEl.offsetTop > hostEl.offsetHeight - 100) { dialogEl.style.top = hostEl.offsetHeight - 100 + "px"; } } private _requestClose(): void { if (this._sdActivatedModal != null && !this._sdActivatedModal.canDeactivateFn()) { return; } void this._saveConfig().catch((err) => this._errorHandler.handleError(err)); this.closeRequest.emit(); } private _bringToFront(): void { const hostEl = this._elRef.nativeElement; const allModals = document.body.findAll("sd-modal"); let maxZ = 4000; for (const m of allModals) { if (m === hostEl) continue; const z = parseInt(m.style.zIndex || "0", 10); if (z > maxZ) { maxZ = z; } } const currentZ = parseInt(hostEl.style.zIndex !== "" ? hostEl.style.zIndex : "0", 10); if (currentZ >= maxZ) return; hostEl.style.zIndex = String(maxZ + 1); } private _calcHeight(): void { const dialogEl = this._getDialogEl(); if (dialogEl == null) return; const style = getComputedStyle(this._elRef.nativeElement); const paddingTop = style.paddingTop === "" ? 0 : parseInt(style.paddingTop, 10) || 0; if (dialogEl.offsetHeight > this._elRef.nativeElement.offsetHeight - paddingTop) { dialogEl.style.maxHeight = "100%"; dialogEl.style.height = "100%"; } } private _calcWidth(): void { const dialogEl = this._getDialogEl(); if (dialogEl == null) return; if (dialogEl.offsetWidth > this._elRef.nativeElement.offsetWidth) { dialogEl.style.maxWidth = "100%"; dialogEl.style.width = "100%"; } } private _getDialogEl(): HTMLElement | null { return this._elRef.nativeElement.querySelector("._dialog"); } private async _saveConfig(): Promise { const k = this.key(); if (k == null || this._sdSystemConfig == null) return; const dialogEl = this._getDialogEl(); if (dialogEl == null) return; const config: Record = {}; if (dialogEl.style.width !== "") config["width"] = dialogEl.style.width; if (dialogEl.style.height !== "") config["height"] = dialogEl.style.height; if (dialogEl.style.left !== "") config["left"] = dialogEl.style.left; if (dialogEl.style.top !== "") config["top"] = dialogEl.style.top; await this._sdSystemConfig.setAsync(`sd-modal.${k}` as any, config as any); } private async _restoreConfig(k: string): Promise { if (this._sdSystemConfig == null) return; const config = (await this._sdSystemConfig.getAsync(`sd-modal.${k}`)) as | Record | undefined; if (config == null) return; const dialogEl = this._getDialogEl(); if (dialogEl == null) return; if (config["width"] != null) dialogEl.style.width = config["width"]; if (config["height"] != null) dialogEl.style.height = config["height"]; if (config["left"] != null) dialogEl.style.left = config["left"]; if (config["top"] != null) dialogEl.style.top = config["top"]; } }