Last active
February 12, 2025 22:32
-
-
Save ashtonmeuser/0613e3aeff5a4692d8c148d7fcd02f34 to your computer and use it in GitHub Desktop.
A simple reusable modal
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@keyframes spin { 0% { transform:rotate(0deg) } 100% { transform:rotate(360deg) } } | |
@keyframes pulse { 0% { border-top:8px solid black } 100% { border-top:4px solid black } } | |
dialog { | |
display: flex; | |
flex-direction: column; | |
outline: none; | |
border: none; | |
border-radius: 1em; | |
pointer-events: all !important; | |
padding: 0; | |
font-family: Arial,sans-serif; | |
line-height: 1.6; | |
background-color: #f9fafb; | |
color: #262626; | |
max-width: min(calc(100vw - 4em), 50em); | |
* { | |
box-sizing: border-box; | |
} | |
> div:first-child > div > .spinner { | |
margin: 0 auto; | |
width: 40px; | |
height: 40px; | |
border: 4px solid #ccc; | |
border-radius: 50%; | |
animation: spin 1s linear infinite, pulse 0.33s ease-in-out infinite alternate; | |
} | |
> div:last-child { | |
font-size: 0.8em; | |
color: #aaa; | |
padding: 0 2rem; | |
> a { | |
color: inherit; | |
text-decoration: underline; | |
} | |
} | |
&::backdrop { | |
-webkit-backdrop-filter: blur(6px) grayscale(75%) brightness(60%); | |
backdrop-filter: blur(6px) grayscale(75%) brightness(60%); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import css from './modal.css'; | |
export class DuplicateModalError extends Error {} | |
export type ModalOptions = { content?: string | Node, loading?: string | Node | boolean, padding?: string, footer?: string | Node, style?: string, onClose?: () => void }; | |
const node = (v: string | Node): Node => typeof v === 'string' ? document.createRange().createContextualFragment(v) : v; | |
export default class Modal { | |
dialogElement: HTMLDialogElement; | |
containerElement: HTMLDivElement; | |
footerElement: HTMLDivElement; | |
styleElement: HTMLStyleElement; | |
private _onClose: Array<() => void> = []; | |
// Simple Modal with ModalOptions | |
// ID can be supplied to prevent duplicate modals | |
constructor(id: string = 'bookmarklet-modal', options?: ModalOptions) { | |
if (document.getElementById(id)) throw new DuplicateModalError('modal already exists'); // Throw if modal already exists | |
// Define (shadow) DOM | |
const host = node(`<div id="${id}" style="all:initial;position:fixed;width:0;height:0;opacity:0;"></div>`).firstChild as HTMLDivElement; | |
const root = host.attachShadow({ mode: 'open' }); | |
this.dialogElement = node('<dialog><div><div style="margin:2em;"></div></div><div></div></dialog>').firstChild as HTMLDialogElement; | |
this.containerElement = this.dialogElement.firstChild!.firstChild as HTMLDivElement; | |
this.footerElement = this.dialogElement.lastChild as HTMLDivElement; | |
this.styleElement = Object.assign(document.createElement('style')); | |
// Dialog events | |
this.dialogElement.addEventListener('click', (event) => { if (event.target instanceof HTMLDialogElement) event.target.close(); }); | |
this.dialogElement.addEventListener('close', () => this._onClose.forEach(callback => callback())); | |
this.onClose(() => host.remove()); | |
// Apply default options | |
this.update(options); | |
// Assemble DOM | |
root.appendChild(Object.assign(document.createElement('style'), { textContent: css })); | |
root.appendChild(this.styleElement); | |
root.appendChild(this.dialogElement); | |
document.body.appendChild(host); | |
this.dialogElement.showModal(); | |
} | |
set loading(v: string | Node | boolean | undefined) { | |
if (v === undefined) return; | |
if (v === false) this.containerElement.replaceChildren(); | |
else { | |
const spinner = Object.assign(document.createElement('div'), { className: 'spinner' }); | |
if (v === true) this.containerElement.replaceChildren(spinner); | |
else this.containerElement.replaceChildren(spinner, node(v)); | |
} | |
} | |
set content(v: string | Node | undefined) { | |
if (v === undefined) return; | |
else this.containerElement.replaceChildren(node(v)); | |
} | |
set padding(v: string | undefined) { | |
if (v === undefined) return; | |
this.containerElement.style.margin = v; | |
this.dialogElement.style.borderRadius = v === '0' ? v : `calc(${v} / 2)`; | |
} | |
set footer(v: string | Node | undefined) { | |
if (v === undefined) return; | |
this.footerElement.replaceChildren(node(v)); | |
} | |
set style(v: string | undefined) { | |
if (v === undefined) return; | |
this.styleElement.textContent = v; | |
} | |
// Factory constructor returning Modal with ModalOptions or null | |
static factory(id?: string, options?: ModalOptions): Modal | null { | |
try { return new Modal(id, options); } | |
catch { return null; } | |
} | |
// Update all properties with ModalOptions | |
update(options?: ModalOptions) { | |
this.loading = options?.loading; | |
this.content = options?.content; | |
this.padding = options?.padding; | |
this.footer = options?.footer; | |
this.style = options?.style; | |
this.onClose(options?.onClose); | |
} | |
// Add callback fired when Modal is closed | |
onClose(callback?: () => void) { | |
if (callback) this._onClose.unshift(callback); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment