import { globalThis } from './utils/server-safe-globals.js'; import { containsComposedNode, getActiveElement, getBooleanAttr, namedNodeMapToObject, setBooleanAttr, getOrInsertCSSRule, } from './utils/element-utils.js'; import { InvokeEvent } from './utils/events.js'; /** * Get the template HTML for the dialog with the given attributes. * * This is a static method that can be called on the class and can be * overridden by subclasses to customize the template. * * Another static method, `getSlotTemplateHTML`, is called by this method * which can be separately overridden to customize the slot template. */ function getTemplateHTML(_attrs: Record) { return /*html*/ ` ${this.getSlotTemplateHTML(_attrs)} `; } /** * Get the slot template HTML for the dialog with the given attributes. * * This is a static method that can be called on the class and can be * overridden by subclasses to customize the slot template. */ function getSlotTemplateHTML(_attrs: Record) { return /*html*/ ` `; } export const Attributes = { OPEN: 'open', ANCHOR: 'anchor', }; /** * @extends {HTMLElement} * * @slot - Default slotted elements. * * @attr {boolean} open - The open state of the dialog. * * @cssproperty --media-primary-color - Default color of text / icon. * @cssproperty --media-secondary-color - Default color of background. * @cssproperty --media-text-color - `color` of text. * * @cssproperty --media-dialog-display - `display` of dialog. * * @cssproperty --media-font - `font` shorthand property. * @cssproperty --media-font-weight - `font-weight` property. * @cssproperty --media-font-family - `font-family` property. * @cssproperty --media-font-size - `font-size` property. * @cssproperty --media-text-content-height - `line-height` of text. * * @event {Event} open - Dispatched when the dialog is opened. * @event {Event} close - Dispatched when the dialog is closed. * @event {Event} focus - Dispatched when the dialog is focused. * @event {Event} focusin - Dispatched when the dialog is focused in. */ class MediaChromeDialog extends globalThis.HTMLElement { static shadowRootOptions = { mode: 'open' as ShadowRootMode }; static getTemplateHTML = getTemplateHTML; static getSlotTemplateHTML = getSlotTemplateHTML; static get observedAttributes() { return [Attributes.OPEN, Attributes.ANCHOR]; } #isInit = false; #previouslyFocused: HTMLElement | null = null; #invokerElement: HTMLElement | null = null; constructor() { super(); } get open() { return getBooleanAttr(this, Attributes.OPEN); } set open(value) { setBooleanAttr(this, Attributes.OPEN, value); } #init() { if (this.#isInit) return; this.#isInit = true; if (!this.shadowRoot) { // Set up the Shadow DOM if not using Declarative Shadow DOM. this.attachShadow((this.constructor as typeof MediaChromeDialog).shadowRootOptions); const attrs = namedNodeMapToObject(this.attributes); this.shadowRoot.innerHTML = (this.constructor as typeof MediaChromeDialog).getTemplateHTML(attrs); // Delay setting the transition to prevent seeing the transition from default start styles. queueMicrotask(() => { const { style } = getOrInsertCSSRule(this.shadowRoot, ':host'); style.setProperty( 'transition', `display .15s, visibility .15s, opacity .15s ease-in, transform .15s ease-in` ); }); } } handleEvent(event: Event) { switch (event.type) { case 'invoke': this.#handleInvoke(event as InvokeEvent); break; case 'focusout': this.#handleFocusOut(event as FocusEvent); break; case 'keydown': this.#handleKeyDown(event as KeyboardEvent); break; } } connectedCallback(): void { this.#init(); if (!this.role) { this.role = 'dialog'; } this.addEventListener('invoke', this); this.addEventListener('focusout', this); this.addEventListener('keydown', this); } disconnectedCallback(): void { this.removeEventListener('invoke', this); this.removeEventListener('focusout', this); this.removeEventListener('keydown', this); } attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string | null) { this.#init(); if (attrName === Attributes.OPEN && newValue !== oldValue) { if (this.open) { this.#handleOpen(); } else { this.#handleClosed(); } } } #handleOpen() { this.#invokerElement?.setAttribute('aria-expanded', 'true'); this.dispatchEvent(new Event('open', { composed: true, bubbles: true })); // Focus when the transition ends. this.addEventListener('transitionend', () => this.focus(), { once: true }); } #handleClosed() { this.#invokerElement?.setAttribute('aria-expanded', 'false'); this.dispatchEvent(new Event('close', { composed: true, bubbles: true })); } focus() { this.#previouslyFocused = getActiveElement(); // https://w3c.github.io/uievents/#event-type-focus const focusCancelled = !this.dispatchEvent(new Event('focus', { composed: true, cancelable: true })); // https://w3c.github.io/uievents/#event-type-focusin const focusInCancelled = !this.dispatchEvent(new Event('focusin', { composed: true, bubbles: true, cancelable: true })); // If `event.preventDefault()` was called in a listener prevent focusing. if (focusCancelled || focusInCancelled) return; const focusable: HTMLElement | null = this.querySelector( '[autofocus], [tabindex]:not([tabindex="-1"]), [role="menu"]' ); focusable?.focus(); } #handleInvoke(event: InvokeEvent) { this.#invokerElement = event.relatedTarget as HTMLElement; if (!containsComposedNode(this, event.relatedTarget as Node)) { this.open = !this.open; } } #handleFocusOut(event: FocusEvent) { if (!containsComposedNode(this, event.relatedTarget as Node)) { this.#previouslyFocused?.focus(); // If the dialog was opened by a click, close it when selecting an item. if (this.#invokerElement && this.#invokerElement !== event.relatedTarget && this.open) { this.open = false; } } } get keysUsed() { return ['Escape', 'Tab']; } #handleKeyDown(event: KeyboardEvent) { const { key, ctrlKey, altKey, metaKey } = event; if (ctrlKey || altKey || metaKey) { return; } if (!this.keysUsed.includes(key)) { return; } event.preventDefault(); event.stopPropagation(); if (key === 'Tab') { // Move focus to the previous focusable element. if (event.shiftKey) { (this.previousElementSibling as HTMLElement)?.focus?.(); } else { // Move focus to the next focusable element. (this.nextElementSibling as HTMLElement)?.focus?.(); } // Go back to the previous focused element. this.blur(); } else if (key === 'Escape') { // Go back to the previous menu or close the menu. this.#previouslyFocused?.focus(); this.open = false; } } } if (!globalThis.customElements.get('media-chrome-dialog')) { globalThis.customElements.define('media-chrome-dialog', MediaChromeDialog); } export { MediaChromeDialog }; export default MediaChromeDialog;