import { booleanAttribute, ChangeDetectionStrategy, Component, contentChild, DestroyRef, effect, ElementRef, inject, input, model, signal, ViewEncapsulation, } from "@angular/core"; import { SdDropdownPopup } from "./sd-dropdown-popup"; import "@simplysm/core-browser"; @Component({ selector: "sd-dropdown", changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, standalone: true, imports: [], host: { "[attr.tabindex]": "disabled() ? undefined : '0'", "(click)": "onHostClick()", "(keydown)": "onHostKeydown($event)", }, template: ` `, }) export class SdDropdown { private readonly _elRef = inject(ElementRef); open = model(false); disabled = input(false, { transform: booleanAttribute }); popupElRef = contentChild.required>(SdDropdownPopup, { read: ElementRef, }); private _popupEl?: HTMLElement; private _mouseoverEl?: HTMLElement; private _backdropEl?: HTMLElement; private readonly _isMobile; private readonly _mql: MediaQueryList; constructor() { const destroyRef = inject(DestroyRef); // $breakpoint-mobile (SCSS 변수 참조 불가, 값 동기화 필요) this._mql = window.matchMedia("(max-width: 520px)"); this._isMobile = signal(this._mql.matches); const onMqlChange = (e: MediaQueryListEvent) => { this._isMobile.set(e.matches); }; this._mql.addEventListener("change", onMqlChange); destroyRef.onDestroy(() => { this._mql.removeEventListener("change", onMqlChange); }); effect((onCleanup) => { if (this.open()) { const popupEl = this.popupElRef().nativeElement; this._popupEl = popupEl; document.body.appendChild(popupEl); if (this._isMobile()) { popupEl.setAttribute("data-sd-mobile", ""); const backdrop = document.createElement("div"); backdrop.setAttribute("data-sd-dropdown-backdrop", ""); backdrop.style.cssText = "position:fixed;inset:0;background:var(--busy-overlay-bg);z-index:var(--z-index-dropdown)"; backdrop.addEventListener("click", () => { this._closePopup(); }); document.body.insertBefore(backdrop, popupEl); this._backdropEl = backdrop; } else { this._updatePopupPosition(); } const onScroll = (event: Event) => { this._onDocumentScrollCapture(event); }; const onMouseover = (event: Event) => { this._mouseoverEl = (event as MouseEvent).target as HTMLElement; }; const onBlur = (event: Event) => { this._onDocumentBlurCapture(event as FocusEvent); }; document.addEventListener("scroll", onScroll, { capture: true, passive: true }); document.addEventListener("mouseover", onMouseover); document.addEventListener("blur", onBlur, { capture: true }); onCleanup(() => { document.removeEventListener("scroll", onScroll, { capture: true }); document.removeEventListener("mouseover", onMouseover); document.removeEventListener("blur", onBlur, { capture: true }); this._removePopup(); }); } else { this._removePopup(); } }); } onHostClick(): void { if (this.open()) { this._closePopup(); } else { this._openPopup(); } } onHostKeydown(event: KeyboardEvent): void { if (event.ctrlKey || event.altKey) return; if (event.key === "ArrowDown") { if (!this.open()) { event.preventDefault(); event.stopPropagation(); this._openPopup(); } else { const popupEl = this.popupElRef().nativeElement; const tabbable = popupEl.findFirstTabbableChild(); if (tabbable) { event.preventDefault(); event.stopPropagation(); tabbable.focus(); } } } if (event.key === "ArrowUp") { if (this.open()) { event.preventDefault(); event.stopPropagation(); this._closePopup(); } } if (event.key === " ") { event.preventDefault(); event.stopPropagation(); if (this.open()) { this._closePopup(); } else { this._openPopup(); } } if (event.key === "Escape") { if (this.open()) { event.preventDefault(); event.stopPropagation(); this._closePopup(); } } } onPopupKeydown(event: KeyboardEvent): void { if (event.ctrlKey || event.altKey) return; if (event.key === "Escape") { if (this.open()) { event.preventDefault(); event.stopPropagation(); this._closePopup(); } } } private _openPopup(): void { if (this.open()) return; if (this.disabled()) return; this.open.set(true); } private _closePopup(): void { if (!this.open()) return; this.open.set(false); } private _updatePopupPosition(): void { const contentEl = this._elRef.nativeElement; const popupEl = this._popupEl; if (popupEl == null) return; contentEl.repaint(); const rect = contentEl.getBoundingClientRect(); const shouldPlaceAbove = window.innerHeight < rect.top * 2; const shouldPlaceLeft = window.innerWidth < rect.left * 2; const gap = 2; const topPos = shouldPlaceAbove ? undefined : rect.bottom + gap; const bottomPos = shouldPlaceAbove ? window.innerHeight - rect.top : undefined; const leftPos = shouldPlaceLeft ? undefined : rect.left; const rightPos = shouldPlaceLeft ? window.innerWidth - rect.right : undefined; Object.assign(popupEl.style, { top: topPos != null ? topPos + "px" : "", bottom: bottomPos != null ? bottomPos + "px" : "", left: leftPos != null ? leftPos + "px" : "", right: rightPos != null ? rightPos + "px" : "", minWidth: contentEl.offsetWidth + "px", opacity: "1", pointerEvents: "auto", transform: "none", }); } private _onDocumentScrollCapture(event: Event): void { if (this._isMobile()) return; const contentEl = this._elRef.nativeElement; if ((event.target as Element).contains(contentEl)) { this._updatePopupPosition(); } } private _onDocumentBlurCapture(event: FocusEvent): void { const contentEl = this._elRef.nativeElement; const popupEl = this._popupEl; if (popupEl == null) return; const relatedTarget = event.relatedTarget; if (relatedTarget instanceof HTMLElement) { if ( relatedTarget === contentEl || relatedTarget === popupEl || contentEl.contains(relatedTarget) === true || popupEl.contains(relatedTarget) === true ) { return; } } const mouseoverEl = this._mouseoverEl; if ( relatedTarget == null && mouseoverEl instanceof HTMLElement && (contentEl.contains(mouseoverEl) === true || popupEl.contains(mouseoverEl) === true) ) { const tabbable = popupEl.findFirstTabbableChild(); if (tabbable != null) { tabbable.focus(); } else { contentEl.focus(); } return; } if (this.open()) { this._closePopup(); } } private _removePopup(): void { if (this._backdropEl != null) { this._backdropEl.remove(); this._backdropEl = undefined; } const popupEl = this._popupEl; if (popupEl != null) { const contentEl = this._elRef.nativeElement; if (popupEl.matches(":focus-within")) { contentEl.focus(); } popupEl.removeAttribute("data-sd-mobile"); Object.assign(popupEl.style, { top: "", bottom: "", left: "", right: "", minWidth: "", opacity: "", pointerEvents: "", transform: "", }); popupEl.remove(); this._popupEl = undefined; } } }