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 = `
${headerHtml}
${footerHtml}
`
}
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)
}
}