feat(html): add alert dialog element (#741)

This commit is contained in:
rahim
2026-03-05 15:46:54 -08:00
committed by GitHub
parent 536c86d9d1
commit 5fc52aa969
12 changed files with 440 additions and 3 deletions
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
+5
View File
@@ -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';
@@ -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);
}
}
@@ -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<AlertDialogState> {
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;
}
}
@@ -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<AlertDialogInput> | 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(),
});
}
}
@@ -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<AlertDialogState> {
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;
}
}
@@ -0,0 +1,12 @@
import type { AlertDialogState, StateAttrMap } from '@videojs/core';
import { createContext } from '@videojs/element/context';
export interface AlertDialogContextValue {
state: AlertDialogState;
stateAttrMap: StateAttrMap<AlertDialogState>;
close: () => void;
}
const ALERT_DIALOG_CONTEXT_KEY = Symbol('@videojs/alert-dialog');
export const alertDialogContext = createContext<AlertDialogContextValue>(ALERT_DIALOG_CONTEXT_KEY);
@@ -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<Element extends HTMLElement>(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);
});
});
@@ -10,12 +10,15 @@ import { useAlertDialogContext } from './context';
export interface AlertDialogCloseProps extends UIComponentProps<'button', AlertDialogCore.State> {}
export const AlertDialogClose = forwardRef<HTMLButtonElement, AlertDialogCloseProps>(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<HTMLButtonElement, AlertDialogClosePr
{
state,
ref: [forwardedRef],
props: [{ type: 'button' as const, onClick: handleClick }, elementProps],
props: [{ type: 'button' as const, disabled, onClick: handleClick }, elementProps],
}
);
});