From 5fc52aa969292a8b8f636b532ac5ff27b082b222 Mon Sep 17 00:00:00 2001 From: rahim Date: Thu, 5 Mar 2026 15:46:54 -0800 Subject: [PATCH] feat(html): add alert dialog element (#741) --- .../html/src/define/ui/alert-dialog-close.ts | 10 + .../src/define/ui/alert-dialog-description.ts | 10 + .../html/src/define/ui/alert-dialog-title.ts | 10 + packages/html/src/define/ui/alert-dialog.ts | 20 ++ packages/html/src/index.ts | 5 + .../alert-dialog-close-element.ts | 44 +++++ .../alert-dialog-description-element.ts | 18 ++ .../ui/alert-dialog/alert-dialog-element.ts | 101 ++++++++++ .../alert-dialog-title-element.ts | 18 ++ packages/html/src/ui/alert-dialog/context.ts | 12 ++ .../tests/alert-dialog-element.test.ts | 186 ++++++++++++++++++ .../ui/alert-dialog/alert-dialog-close.tsx | 9 +- 12 files changed, 440 insertions(+), 3 deletions(-) create mode 100644 packages/html/src/define/ui/alert-dialog-close.ts create mode 100644 packages/html/src/define/ui/alert-dialog-description.ts create mode 100644 packages/html/src/define/ui/alert-dialog-title.ts create mode 100644 packages/html/src/define/ui/alert-dialog.ts create mode 100644 packages/html/src/ui/alert-dialog/alert-dialog-close-element.ts create mode 100644 packages/html/src/ui/alert-dialog/alert-dialog-description-element.ts create mode 100644 packages/html/src/ui/alert-dialog/alert-dialog-element.ts create mode 100644 packages/html/src/ui/alert-dialog/alert-dialog-title-element.ts create mode 100644 packages/html/src/ui/alert-dialog/context.ts create mode 100644 packages/html/src/ui/alert-dialog/tests/alert-dialog-element.test.ts diff --git a/packages/html/src/define/ui/alert-dialog-close.ts b/packages/html/src/define/ui/alert-dialog-close.ts new file mode 100644 index 00000000..88b96dcc --- /dev/null +++ b/packages/html/src/define/ui/alert-dialog-close.ts @@ -0,0 +1,10 @@ +import { AlertDialogCloseElement } from '../../ui/alert-dialog/alert-dialog-close-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(AlertDialogCloseElement); + +declare global { + interface HTMLElementTagNameMap { + [AlertDialogCloseElement.tagName]: AlertDialogCloseElement; + } +} diff --git a/packages/html/src/define/ui/alert-dialog-description.ts b/packages/html/src/define/ui/alert-dialog-description.ts new file mode 100644 index 00000000..3f68d38a --- /dev/null +++ b/packages/html/src/define/ui/alert-dialog-description.ts @@ -0,0 +1,10 @@ +import { AlertDialogDescriptionElement } from '../../ui/alert-dialog/alert-dialog-description-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(AlertDialogDescriptionElement); + +declare global { + interface HTMLElementTagNameMap { + [AlertDialogDescriptionElement.tagName]: AlertDialogDescriptionElement; + } +} diff --git a/packages/html/src/define/ui/alert-dialog-title.ts b/packages/html/src/define/ui/alert-dialog-title.ts new file mode 100644 index 00000000..1dd55b86 --- /dev/null +++ b/packages/html/src/define/ui/alert-dialog-title.ts @@ -0,0 +1,10 @@ +import { AlertDialogTitleElement } from '../../ui/alert-dialog/alert-dialog-title-element'; +import { safeDefine } from '../safe-define'; + +safeDefine(AlertDialogTitleElement); + +declare global { + interface HTMLElementTagNameMap { + [AlertDialogTitleElement.tagName]: AlertDialogTitleElement; + } +} diff --git a/packages/html/src/define/ui/alert-dialog.ts b/packages/html/src/define/ui/alert-dialog.ts new file mode 100644 index 00000000..11a0dcfe --- /dev/null +++ b/packages/html/src/define/ui/alert-dialog.ts @@ -0,0 +1,20 @@ +import { AlertDialogCloseElement } from '../../ui/alert-dialog/alert-dialog-close-element'; +import { AlertDialogDescriptionElement } from '../../ui/alert-dialog/alert-dialog-description-element'; +import { AlertDialogElement } from '../../ui/alert-dialog/alert-dialog-element'; +import { AlertDialogTitleElement } from '../../ui/alert-dialog/alert-dialog-title-element'; +import { safeDefine } from '../safe-define'; + +// Parent first — child elements consume its context. +safeDefine(AlertDialogElement); +safeDefine(AlertDialogCloseElement); +safeDefine(AlertDialogDescriptionElement); +safeDefine(AlertDialogTitleElement); + +declare global { + interface HTMLElementTagNameMap { + [AlertDialogElement.tagName]: AlertDialogElement; + [AlertDialogCloseElement.tagName]: AlertDialogCloseElement; + [AlertDialogDescriptionElement.tagName]: AlertDialogDescriptionElement; + [AlertDialogTitleElement.tagName]: AlertDialogTitleElement; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 471fddea..cd2dec5d 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -14,6 +14,11 @@ export * from './store/container-mixin'; export * from './store/provider-mixin'; export * from './store/types'; // UI Components +export { AlertDialogCloseElement } from './ui/alert-dialog/alert-dialog-close-element'; +export { AlertDialogDescriptionElement } from './ui/alert-dialog/alert-dialog-description-element'; +export { AlertDialogElement } from './ui/alert-dialog/alert-dialog-element'; +export { AlertDialogTitleElement } from './ui/alert-dialog/alert-dialog-title-element'; +export { type AlertDialogContextValue, alertDialogContext } from './ui/alert-dialog/context'; export { BufferingIndicatorElement } from './ui/buffering-indicator/buffering-indicator-element'; export { CaptionsButtonElement } from './ui/captions-button/captions-button-element'; export { ControlsElement } from './ui/controls/controls-element'; diff --git a/packages/html/src/ui/alert-dialog/alert-dialog-close-element.ts b/packages/html/src/ui/alert-dialog/alert-dialog-close-element.ts new file mode 100644 index 00000000..6a98f8a2 --- /dev/null +++ b/packages/html/src/ui/alert-dialog/alert-dialog-close-element.ts @@ -0,0 +1,44 @@ +import { applyElementProps, applyStateDataAttrs, createButton } from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; + +import { MediaElement } from '../media-element'; +import { alertDialogContext } from './context'; + +export class AlertDialogCloseElement extends MediaElement { + static readonly tagName = 'media-alert-dialog-close'; + + static override properties = { + disabled: { type: Boolean }, + } satisfies PropertyDeclarationMap<'disabled'>; + + disabled = false; + + readonly #ctx = new ContextConsumer(this, { context: alertDialogContext, subscribe: true }); + + #disconnect: AbortController | null = null; + + override connectedCallback(): void { + super.connectedCallback(); + this.#disconnect = new AbortController(); + + const buttonProps = createButton({ + onActivate: () => this.#ctx.value?.close(), + isDisabled: () => this.disabled, + }); + + applyElementProps(this, buttonProps, { signal: this.#disconnect.signal }); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#disconnect?.abort(); + this.#disconnect = null; + } + + protected override update(_changed: PropertyValues): void { + super.update(_changed); + const ctx = this.#ctx.value; + if (ctx) applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap); + } +} diff --git a/packages/html/src/ui/alert-dialog/alert-dialog-description-element.ts b/packages/html/src/ui/alert-dialog/alert-dialog-description-element.ts new file mode 100644 index 00000000..7c4e7e40 --- /dev/null +++ b/packages/html/src/ui/alert-dialog/alert-dialog-description-element.ts @@ -0,0 +1,18 @@ +import type { AlertDialogState } from '@videojs/core'; +import type { PropertyValues } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; + +import { ContextPartElement } from '../context-part-element'; +import { alertDialogContext } from './context'; + +export class AlertDialogDescriptionElement extends ContextPartElement { + static readonly tagName = 'media-alert-dialog-description'; + + protected readonly consumer = new ContextConsumer(this, { context: alertDialogContext, subscribe: true }); + + protected override update(changed: PropertyValues): void { + super.update(changed); + const descriptionId = this.consumer.value?.state.descriptionId; + if (descriptionId) this.id = descriptionId; + } +} diff --git a/packages/html/src/ui/alert-dialog/alert-dialog-element.ts b/packages/html/src/ui/alert-dialog/alert-dialog-element.ts new file mode 100644 index 00000000..3688be73 --- /dev/null +++ b/packages/html/src/ui/alert-dialog/alert-dialog-element.ts @@ -0,0 +1,101 @@ +import { AlertDialogCore, AlertDialogDataAttrs, type AlertDialogInput } from '@videojs/core'; +import { + type AlertDialogApi, + applyElementProps, + applyStateDataAttrs, + createAlertDialog, + createTransition, +} from '@videojs/core/dom'; +import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; +import { ContextProvider } from '@videojs/element/context'; +import { SnapshotController } from '@videojs/store/html'; + +import { MediaElement } from '../media-element'; +import { alertDialogContext } from './context'; + +let idCounter = 0; + +export class AlertDialogElement extends MediaElement { + static readonly tagName = 'media-alert-dialog'; + + static override properties = { + open: { type: Boolean }, + } satisfies PropertyDeclarationMap<'open'>; + + open = false; + + readonly #core = new AlertDialogCore(); + readonly #provider = new ContextProvider(this, { context: alertDialogContext }); + readonly #titleId = `vjs-alert-dialog-title-${idCounter++}`; + readonly #descriptionId = `vjs-alert-dialog-desc-${idCounter++}`; + + #dialog: AlertDialogApi | null = null; + #snapshot: SnapshotController | null = null; + + constructor() { + super(); + this.#core.setTitleId(this.#titleId); + this.#core.setDescriptionId(this.#descriptionId); + } + + override connectedCallback(): void { + super.connectedCallback(); + + this.#dialog = createAlertDialog({ + transition: createTransition(), + onOpenChange: (nextOpen: boolean) => { + this.open = nextOpen; + this.dispatchEvent(new CustomEvent('open-change', { detail: { open: nextOpen } })); + }, + }); + + // Register self as the dialog element. + this.#dialog.setElement(this); + + if (this.#snapshot) { + this.#snapshot.track(this.#dialog.input); + } else { + this.#snapshot = new SnapshotController(this, this.#dialog.input); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#dialog?.destroy(); + this.#dialog = null; + } + + protected override willUpdate(changed: PropertyValues): void { + super.willUpdate(changed); + + // Sync controlled open state. + if (this.#dialog && changed.has('open')) { + const { active: inputOpen } = this.#dialog.input.current; + if (this.open !== inputOpen) { + if (this.open) { + this.#dialog.open(); + } else { + this.#dialog.close(); + } + } + } + } + + protected override update(_changed: PropertyValues): void { + super.update(_changed); + if (!this.#dialog) return; + + const input = this.#dialog.input.current; + this.#core.setInput(input); + const state = this.#core.getState(); + + applyElementProps(this, this.#core.getAttrs(state)); + applyStateDataAttrs(this, state, AlertDialogDataAttrs); + + this.#provider.setValue({ + state, + stateAttrMap: AlertDialogDataAttrs, + close: () => this.#dialog?.close(), + }); + } +} diff --git a/packages/html/src/ui/alert-dialog/alert-dialog-title-element.ts b/packages/html/src/ui/alert-dialog/alert-dialog-title-element.ts new file mode 100644 index 00000000..ebc72fdc --- /dev/null +++ b/packages/html/src/ui/alert-dialog/alert-dialog-title-element.ts @@ -0,0 +1,18 @@ +import type { AlertDialogState } from '@videojs/core'; +import type { PropertyValues } from '@videojs/element'; +import { ContextConsumer } from '@videojs/element/context'; + +import { ContextPartElement } from '../context-part-element'; +import { alertDialogContext } from './context'; + +export class AlertDialogTitleElement extends ContextPartElement { + static readonly tagName = 'media-alert-dialog-title'; + + protected readonly consumer = new ContextConsumer(this, { context: alertDialogContext, subscribe: true }); + + protected override update(changed: PropertyValues): void { + super.update(changed); + const titleId = this.consumer.value?.state.titleId; + if (titleId) this.id = titleId; + } +} diff --git a/packages/html/src/ui/alert-dialog/context.ts b/packages/html/src/ui/alert-dialog/context.ts new file mode 100644 index 00000000..6db0eac6 --- /dev/null +++ b/packages/html/src/ui/alert-dialog/context.ts @@ -0,0 +1,12 @@ +import type { AlertDialogState, StateAttrMap } from '@videojs/core'; +import { createContext } from '@videojs/element/context'; + +export interface AlertDialogContextValue { + state: AlertDialogState; + stateAttrMap: StateAttrMap; + close: () => void; +} + +const ALERT_DIALOG_CONTEXT_KEY = Symbol('@videojs/alert-dialog'); + +export const alertDialogContext = createContext(ALERT_DIALOG_CONTEXT_KEY); diff --git a/packages/html/src/ui/alert-dialog/tests/alert-dialog-element.test.ts b/packages/html/src/ui/alert-dialog/tests/alert-dialog-element.test.ts new file mode 100644 index 00000000..89089436 --- /dev/null +++ b/packages/html/src/ui/alert-dialog/tests/alert-dialog-element.test.ts @@ -0,0 +1,186 @@ +import { flush } from '@videojs/store'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AlertDialogElement } from '../alert-dialog-element'; + +let tagCounter = 0; + +function uniqueTag(base: string): string { + return `${base}-${tagCounter++}`; +} + +function createElement(Base: abstract new () => Element): Element { + const tag = uniqueTag('test-el'); + customElements.define(tag, class extends (Base as unknown as typeof HTMLElement) {}); + return document.createElement(tag) as Element; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('AlertDialogElement', () => { + it('has the correct tag name', () => { + expect(AlertDialogElement.tagName).toBe('media-alert-dialog'); + }); + + it('initializes with open set to false', () => { + const el = createElement(AlertDialogElement); + expect(el.open).toBe(false); + }); + + it('sets data-open attribute when open is true', async () => { + const el = createElement(AlertDialogElement); + el.open = true; + + document.body.appendChild(el); + await el.updateComplete; + + expect(el.hasAttribute('data-open')).toBe(true); + }); + + it('does not set data-open attribute when open is false', async () => { + const el = createElement(AlertDialogElement); + + document.body.appendChild(el); + await el.updateComplete; + + expect(el.hasAttribute('data-open')).toBe(false); + }); + + it('removes data-open attribute after close transition completes', async () => { + const el = createElement(AlertDialogElement); + el.open = true; + + document.body.appendChild(el); + await el.updateComplete; + expect(el.hasAttribute('data-open')).toBe(true); + + el.open = false; + await el.updateComplete; + + // data-open stays true during the ending transition (active: true, status: 'ending'). + // Wait for the close transition to fully complete (double RAF + animation wait). + await vi.waitFor(() => { + expect(el.hasAttribute('data-open')).toBe(false); + }); + }); + + it('applies alertdialog role and aria-modal', async () => { + const el = createElement(AlertDialogElement); + el.open = true; + + document.body.appendChild(el); + await el.updateComplete; + + expect(el.getAttribute('role')).toBe('alertdialog'); + expect(el.getAttribute('aria-modal')).toBe('true'); + }); + + it('dispatches open-change event on close', async () => { + const el = createElement(AlertDialogElement); + el.open = true; + + document.body.appendChild(el); + await el.updateComplete; + flush(); + + const spy = vi.fn(); + el.addEventListener('open-change', spy); + + // Escape triggers dismiss layer → onOpenChange(false) → open-change event. + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(el.open).toBe(false); + expect(spy).toHaveBeenCalledOnce(); + expect((spy.mock.calls[0]![0] as CustomEvent).detail).toEqual({ open: false }); + }); + + it('closes on Escape key press', async () => { + const el = createElement(AlertDialogElement); + el.open = true; + + document.body.appendChild(el); + await el.updateComplete; + flush(); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(el.open).toBe(false); + }); + + it('does not close on Escape when already closed', async () => { + const el = createElement(AlertDialogElement); + + document.body.appendChild(el); + await el.updateComplete; + + const spy = vi.fn(); + el.addEventListener('open-change', spy); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(el.open).toBe(false); + expect(spy).not.toHaveBeenCalled(); + }); + + it('ignores non-Escape key presses', async () => { + const el = createElement(AlertDialogElement); + el.open = true; + + document.body.appendChild(el); + await el.updateComplete; + flush(); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + expect(el.open).toBe(true); + }); + + it('closes on button click within the dialog', async () => { + const el = createElement(AlertDialogElement); + el.open = true; + + const button = document.createElement('button'); + el.appendChild(button); + + document.body.appendChild(el); + await el.updateComplete; + flush(); + + button.click(); + + expect(el.open).toBe(false); + }); + + it('does not close on non-button element click', async () => { + const el = createElement(AlertDialogElement); + el.open = true; + + const span = document.createElement('span'); + el.appendChild(span); + + document.body.appendChild(el); + await el.updateComplete; + flush(); + + span.click(); + + expect(el.open).toBe(true); + }); + + it('cleans up on disconnect', async () => { + const el = createElement(AlertDialogElement); + el.open = true; + + document.body.appendChild(el); + await el.updateComplete; + flush(); + + document.body.removeChild(el); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + // Dialog was destroyed on disconnect, so open should still be true. + expect(el.open).toBe(true); + }); +}); diff --git a/packages/react/src/ui/alert-dialog/alert-dialog-close.tsx b/packages/react/src/ui/alert-dialog/alert-dialog-close.tsx index a0783b12..a666a84c 100644 --- a/packages/react/src/ui/alert-dialog/alert-dialog-close.tsx +++ b/packages/react/src/ui/alert-dialog/alert-dialog-close.tsx @@ -10,12 +10,15 @@ import { useAlertDialogContext } from './context'; export interface AlertDialogCloseProps extends UIComponentProps<'button', AlertDialogCore.State> {} export const AlertDialogClose = forwardRef(function AlertDialogClose( - { render, className, style, ...elementProps }, + { render, className, style, disabled, ...elementProps }, forwardedRef ) { const { dialog, state } = useAlertDialogContext(); - const handleClick = useCallback(() => dialog.close(), [dialog]); + const handleClick = useCallback(() => { + if (disabled) return; + dialog.close(); + }, [dialog, disabled]); return renderElement( 'button', @@ -23,7 +26,7 @@ export const AlertDialogClose = forwardRef