import { FluentComponent } from '../core/fluent-component' import { createUniversalComponent } from '../core/component-factory' class FluentModalInternal extends FluentComponent { static tag = 'fluent-modal' static override get observedAttributes() { return ['open', 'size', 'backdrop', 'position', 'header', 'footer'] } private previousFocus: HTMLElement | null = null private isOpen = false override connectedCallback() { super.connectedCallback() this.setup() } show() { if (this.isOpen) return this.isOpen = true this.setAttribute('open', '') this.previousFocus = document.activeElement as HTMLElement this.focus() this.dispatchEvent(new CustomEvent('modal-open', { bubbles: true })) } hide() { if (!this.isOpen) return this.isOpen = false this.removeAttribute('open') if (this.previousFocus) { this.previousFocus.focus() this.previousFocus = null } this.dispatchEvent(new CustomEvent('modal-close', { bubbles: true })) } render() { const backdrop = this.getAttribute('backdrop') || 'dim' const showHeader = this.hasAttribute('header') const showFooter = this.hasAttribute('footer') let headerHtml = '' if (showHeader) { headerHtml = ` ` } let footerHtml = '' if (showFooter) { footerHtml = ` ` } this.shadowRootRef.innerHTML = ` ` } constructor() { super() this.recipe = { base: { display: 'none' }, selectors: { ':host': { opacity: '0', 'pointer-events': 'none', transform: 'scale(0.9) translateY(20px)', transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)' }, ':host([open])': { display: 'block', position: 'fixed', inset: '0', 'z-index': '1000', opacity: '1', 'pointer-events': 'auto', transform: 'scale(1) translateY(0)' }, ':host([open]).position-center': { 'place-items': 'center' }, ':host([open]).position-left': { 'place-items': 'center start' }, ':host([open]).position-right': { 'place-items': 'center end' }, ':host([open]).position-top': { 'place-items': 'start center' }, ':host([open]).position-bottom': { 'place-items': 'end center' }, '.modal-backdrop': { display: 'flex', 'align-items': 'center', 'justify-content': 'center', padding: '1rem', 'backdrop-filter': 'blur(12px)', '-webkit-backdrop-filter': 'blur(12px)', background: 'radial-gradient(circle at center, oklch(0% 0 0 / 0.4) 0%, oklch(0% 0 0 / 0.6) 50%, oklch(0% 0 0 / 0.8) 100%)', transition: 'backdrop-filter 0.3s ease, background 0.3s ease' }, '.modal-backdrop.dim': { background: 'radial-gradient(circle at center, oklch(0% 0 0 / 0.5) 0%, oklch(0% 0 0 / 0.7) 50%, oklch(0% 0 0 / 0.9) 100%)', 'backdrop-filter': 'blur(16px)', '-webkit-backdrop-filter': 'blur(16px)' }, '.modal-backdrop.blur': { background: 'radial-gradient(circle at center, oklch(0% 0 0 / 0.2) 0%, oklch(0% 0 0 / 0.4) 50%, oklch(0% 0 0 / 0.6) 100%)', 'backdrop-filter': 'blur(24px)', '-webkit-backdrop-filter': 'blur(24px)' }, '.modal-content': { position: 'relative', background: 'light-dark(rgba(255, 255, 255, 0.95), rgba(30, 30, 30, 0.95))', 'backdrop-filter': 'blur(20px)', '-webkit-backdrop-filter': 'blur(20px)', color: 'light-dark(var(--fluent-color-neutral-900), var(--fluent-color-neutral-50))', 'border-radius': '1.5rem', 'border': '1px solid light-dark(rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.1))', 'box-shadow': '0 32px 100px oklch(0% 0 0 / 0.3), 0 0 0 1px oklch(0% 0 0 / 0.05), inset 0 1px 0 light-dark(rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))', width: '640px', 'max-width': '90vw', 'max-height': '90vh', overflow: 'auto', transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)', display: 'flex', 'flex-direction': 'column' }, '.modal-header': { display: 'flex', 'justify-content': 'space-between', 'align-items': 'center', padding: '1rem 1.25rem 0', 'border-bottom': '1px solid light-dark(oklch(0.9 0 0), oklch(0.2 0 0))' }, '.modal-title': { font: '1.25rem bold system-ui, sans-serif', margin: '0' }, '.modal-close-btn': { background: 'none', border: 'none', 'font-size': '1.5rem', cursor: 'pointer', color: 'light-dark(var(--fluent-color-neutral-600), var(--fluent-color-neutral-400))', padding: '0.25rem', 'border-radius': '0.25rem', transition: 'background 0.2s ease' }, '.modal-close-btn:hover': { background: 'light-dark(oklch(0.95 0 0), oklch(0.15 0 0))' }, '.modal-body': { padding: '1rem 1.25rem', flex: '1', overflow: 'auto' }, '.modal-footer': { padding: '0 1.25rem 1rem', 'border-top': '1px solid light-dark(oklch(0.9 0 0), oklch(0.2 0 0))', display: 'flex', 'justify-content': 'flex-end', gap: '0.5rem' } } } } override attributeChangedCallback(name: string, oldValue: string, newValue: string): void { super.attributeChangedCallback(name, oldValue, newValue) // Update position class on host const position = this.getAttribute('position') || 'center' this.classList.remove('position-center', 'position-left', 'position-right', 'position-top', 'position-bottom') this.classList.add(`position-${position}`) } private setup() { this.addEventListener('click', (e) => { const target = e.target as HTMLElement if (this.isOpen && (target.classList.contains('modal-backdrop') || target.classList.contains('modal-close-btn'))) { this.hide() } }) this.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.isOpen) { this.hide() } }) // Trap focus within modal when open this.addEventListener('focusin', (e) => { if (!this.isOpen) return const modalContent = this.shadowRootRef.querySelector('.modal-content') as HTMLElement if (modalContent && !modalContent.contains(e.target as Node)) { modalContent.focus() } }) } } export const FluentModal = createUniversalComponent({ tag: 'fluent-modal', class: FluentModalInternal, events: ['modal-open', 'modal-close'] }) if (typeof window !== 'undefined') { if (!customElements.get(FluentModalInternal.tag)) { customElements.define(FluentModalInternal.tag, FluentModalInternal) } }