feat(packages): error dialog component (#1077)

This commit is contained in:
Sam Potts
2026-03-31 17:00:45 -07:00
committed by GitHub
parent 29465609c5
commit 3430fe1a49
42 changed files with 612 additions and 153 deletions
@@ -5,6 +5,7 @@ import {
button,
buttonGroup,
controls,
error,
icon,
iconContainer,
iconFlipped,
@@ -22,6 +23,7 @@ import { SkinMixin } from '../skin-mixin';
// Side-effect imports: register all custom elements used in the template.
import '../media/container';
import '../ui/error-dialog';
import '../ui/mute-button';
import '../ui/play-button';
import '../ui/playback-rate-button';
@@ -42,6 +44,18 @@ function getTemplateHTML() {
<slot name="media"></slot>
<slot></slot>
<media-error-dialog class="${error.root}">
<div class="${error.dialog}">
<div class="${error.content}">
<media-alert-dialog-title class="${error.title}">Something went wrong.</media-alert-dialog-title>
<media-alert-dialog-description class="${error.description}"></media-alert-dialog-description>
</div>
<div class="${error.actions}">
<media-alert-dialog-close class="${cn(button.base, button.subtle)}">OK</media-alert-dialog-close>
</div>
</div>
</media-error-dialog>
<div class="${controls}">
<media-tooltip-group>
<div class="${buttonGroup}">
@@ -6,6 +6,7 @@ import styles from './minimal-skin.css?inline';
// Side-effect imports: register all custom elements used in the template.
import '../media/container';
import '../ui/error-dialog';
import '../ui/mute-button';
import '../ui/play-button';
import '../ui/playback-rate-button';
@@ -26,6 +27,18 @@ function getTemplateHTML() {
<slot name="media"></slot>
<slot></slot>
<media-error-dialog class="media-error">
<div class="media-error__dialog">
<div class="media-error__content">
<media-alert-dialog-title class="media-error__title">Something went wrong.</media-alert-dialog-title>
<media-alert-dialog-description class="media-error__description"></media-alert-dialog-description>
</div>
<div class="media-error__actions">
<media-alert-dialog-close class="media-button media-button--subtle">OK</media-alert-dialog-close>
</div>
</div>
</media-error-dialog>
<div class="media-controls">
<media-tooltip-group>
<div class="media-button-group">
@@ -4,6 +4,7 @@ import {
button,
buttonGroup,
controls,
error,
icon,
iconContainer,
iconFlipped,
@@ -22,6 +23,7 @@ import { SkinMixin } from '../skin-mixin';
// Side-effect imports: register all custom elements used in the template.
import '../media/container';
import '../ui/error-dialog';
import '../ui/mute-button';
import '../ui/play-button';
import '../ui/playback-rate-button';
@@ -42,6 +44,18 @@ function getTemplateHTML() {
<slot name="media"></slot>
<slot></slot>
<media-error-dialog class="${error.root}">
<div class="${error.dialog}">
<div class="${error.content}">
<media-alert-dialog-title class="${error.title}">Something went wrong.</media-alert-dialog-title>
<media-alert-dialog-description class="${error.description}"></media-alert-dialog-description>
</div>
<div class="${error.actions}">
<media-alert-dialog-close class="${cn(button.base, button.subtle)}">OK</media-alert-dialog-close>
</div>
</div>
</media-error-dialog>
<div class="${controls}">
<media-tooltip-group>
<div class="${buttonGroup}">
+13
View File
@@ -6,6 +6,7 @@ import styles from './skin.css?inline';
// Side-effect imports: register all custom elements used in the template.
import '../media/container';
import '../ui/error-dialog';
import '../ui/mute-button';
import '../ui/play-button';
import '../ui/playback-rate-button';
@@ -26,6 +27,18 @@ function getTemplateHTML() {
<slot name="media"></slot>
<slot></slot>
<media-error-dialog class="media-error">
<div class="media-error__dialog">
<div class="media-error__content">
<media-alert-dialog-title class="media-error__title">Something went wrong.</media-alert-dialog-title>
<media-alert-dialog-description class="media-error__description"></media-alert-dialog-description>
</div>
<div class="media-error__actions">
<media-alert-dialog-close class="media-button media-button--subtle">OK</media-alert-dialog-close>
</div>
</div>
</media-error-dialog>
<div class="media-surface media-controls">
<media-tooltip-group>
<div class="media-button-group">
@@ -0,0 +1,17 @@
import { AlertDialogCloseElement } from '../../ui/alert-dialog/alert-dialog-close-element';
import { AlertDialogDescriptionElement } from '../../ui/alert-dialog/alert-dialog-description-element';
import { AlertDialogTitleElement } from '../../ui/alert-dialog/alert-dialog-title-element';
import { ErrorDialogElement } from '../../ui/error-dialog/error-dialog-element';
import { safeDefine } from '../safe-define';
// Parent first — child elements consume its context.
safeDefine(ErrorDialogElement);
safeDefine(AlertDialogCloseElement);
safeDefine(AlertDialogDescriptionElement);
safeDefine(AlertDialogTitleElement);
declare global {
interface HTMLElementTagNameMap {
[ErrorDialogElement.tagName]: ErrorDialogElement;
}
}
@@ -6,6 +6,7 @@ import {
buttonGroupEnd,
buttonGroupStart,
controls,
error,
icon,
iconContainer,
iconFlipped,
@@ -30,6 +31,7 @@ import '../media/container';
import '../ui/buffering-indicator';
import '../ui/captions-button';
import '../ui/controls';
import '../ui/error-dialog';
import '../ui/fullscreen-button';
import '../ui/mute-button';
import '../ui/pip-button';
@@ -61,6 +63,18 @@ function getTemplateHTML() {
${renderIcon('spinner')}
</media-buffering-indicator>
<media-error-dialog class="${error.root}">
<div class="${error.dialog}">
<div class="${error.content}">
<media-alert-dialog-title class="${error.title}">Something went wrong.</media-alert-dialog-title>
<media-alert-dialog-description class="${error.description}"></media-alert-dialog-description>
</div>
<div class="${error.actions}">
<media-alert-dialog-close class="${cn(button.base, button.primary)}">OK</media-alert-dialog-close>
</div>
</div>
</media-error-dialog>
<media-controls data-controls="" class="${controls}">
<media-tooltip-group>
<div class="${buttonGroupStart}">
@@ -8,6 +8,7 @@ import styles from './minimal-skin.css?inline';
import '../media/container';
import '../ui/buffering-indicator';
import '../ui/controls';
import '../ui/error-dialog';
import '../ui/fullscreen-button';
import '../ui/mute-button';
import '../ui/pip-button';
@@ -39,6 +40,18 @@ function getTemplateHTML() {
${renderIcon('spinner', { class: 'media-icon' })}
</media-buffering-indicator>
<media-error-dialog class="media-error">
<div class="media-error__dialog">
<div class="media-error__content">
<media-alert-dialog-title class="media-error__title">Something went wrong.</media-alert-dialog-title>
<media-alert-dialog-description class="media-error__description"></media-alert-dialog-description>
</div>
<div class="media-error__actions">
<media-alert-dialog-close class="media-button media-button--primary">OK</media-alert-dialog-close>
</div>
</div>
</media-error-dialog>
<media-controls class="media-controls">
<media-tooltip-group>
<div class="media-button-group">
@@ -6,6 +6,7 @@ import {
buttonGroupEnd,
buttonGroupStart,
controls,
error,
icon,
iconContainer,
iconFlipped,
@@ -29,6 +30,7 @@ import { SkinMixin } from '../skin-mixin';
import '../media/container';
import '../ui/buffering-indicator';
import '../ui/captions-button';
import '../ui/error-dialog';
import '../ui/controls';
import '../ui/fullscreen-button';
import '../ui/mute-button';
@@ -63,6 +65,18 @@ function getTemplateHTML() {
</div>
</media-buffering-indicator>
<media-error-dialog class="${error.root}">
<div class="${error.dialog}">
<div class="${error.content}">
<media-alert-dialog-title class="${error.title}">Something went wrong.</media-alert-dialog-title>
<media-alert-dialog-description class="${error.description}"></media-alert-dialog-description>
</div>
<div class="${error.actions}">
<media-alert-dialog-close class="${cn(button.base, button.primary)}">OK</media-alert-dialog-close>
</div>
</div>
</media-error-dialog>
<media-controls data-controls="" class="${controls}">
<media-tooltip-group>
<div class="${buttonGroupStart}">
+13
View File
@@ -8,6 +8,7 @@ import styles from './skin.css?inline';
import '../media/container';
import '../ui/buffering-indicator';
import '../ui/captions-button';
import '../ui/error-dialog';
import '../ui/controls';
import '../ui/fullscreen-button';
import '../ui/mute-button';
@@ -42,6 +43,18 @@ function getTemplateHTML() {
</div>
</media-buffering-indicator>
<media-error-dialog class="media-error">
<div class="media-error__dialog media-surface">
<div class="media-error__content">
<media-alert-dialog-title class="media-error__title">Something went wrong.</media-alert-dialog-title>
<media-alert-dialog-description class="media-error__description"></media-alert-dialog-description>
</div>
<div class="media-error__actions">
<media-alert-dialog-close class="media-button media-button--primary">OK</media-alert-dialog-close>
</div>
</div>
</media-error-dialog>
<media-controls class="media-surface media-controls">
<media-tooltip-group>
<div class="media-button-group">
+1
View File
@@ -25,6 +25,7 @@ export { BufferingIndicatorElement } from './ui/buffering-indicator/buffering-in
export { CaptionsButtonElement } from './ui/captions-button/captions-button-element';
export { ControlsElement } from './ui/controls/controls-element';
export { ControlsGroupElement } from './ui/controls/controls-group-element';
export { ErrorDialogElement } from './ui/error-dialog/error-dialog-element';
export { FullscreenButtonElement } from './ui/fullscreen-button/fullscreen-button-element';
export { MediaButtonElement } from './ui/media-button-element';
// Primitives
@@ -0,0 +1,112 @@
import { AlertDialogDataAttrs, type AlertDialogInput, ErrorDialogCore } from '@videojs/core';
import {
type AlertDialogApi,
applyElementProps,
applyStateDataAttrs,
createAlertDialog,
createTransition,
selectError,
} from '@videojs/core/dom';
import type { PropertyValues } from '@videojs/element';
import { ContextProvider } from '@videojs/element/context';
import { SnapshotController } from '@videojs/store/html';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { alertDialogContext } from '../alert-dialog/context';
import { MediaElement } from '../media-element';
const FALLBACK_MESSAGE = 'An error occurred. Please try again.';
let idCounter = 0;
export class ErrorDialogElement extends MediaElement {
static readonly tagName = 'media-error-dialog';
readonly #core = new ErrorDialogCore();
readonly #provider = new ContextProvider(this, { context: alertDialogContext });
readonly #titleId = `vjs-error-dialog-title-${idCounter++}`;
readonly #descriptionId = `vjs-error-dialog-desc-${idCounter++}`;
readonly #errorState = new PlayerController(this, playerContext, selectError);
#dialog: AlertDialogApi | null = null;
#snapshot: SnapshotController<AlertDialogInput> | null = null;
#lastErrorMessage: string | 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) => {
if (!nextOpen) {
this.#errorState.value?.dismissError();
}
},
});
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);
if (!this.#dialog) return;
const errorState = this.#errorState.value;
const hasError = Boolean(errorState?.error);
const { active: isOpen } = this.#dialog.input.current;
if (errorState?.error) {
const message = errorState.error.message?.trim();
this.#lastErrorMessage = message || null;
}
// Set description text before opening so content is ready for the transition.
const desc = this.querySelector('media-alert-dialog-description');
if (desc) {
desc.textContent = this.#lastErrorMessage ?? FALLBACK_MESSAGE;
}
if (hasError && !isOpen) {
this.#dialog.open();
} else if (!hasError && isOpen) {
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,75 @@
import { afterEach, describe, expect, it } from 'vitest';
import { AlertDialogCloseElement } from '../../alert-dialog/alert-dialog-close-element';
import { AlertDialogDescriptionElement } from '../../alert-dialog/alert-dialog-description-element';
import { AlertDialogTitleElement } from '../../alert-dialog/alert-dialog-title-element';
import { ErrorDialogElement } from '../error-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;
}
function ensureDefined(tagName: string, Base: CustomElementConstructor): void {
if (!customElements.get(tagName)) {
customElements.define(tagName, Base);
}
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('ErrorDialogElement', () => {
it('has the correct tag name', () => {
expect(ErrorDialogElement.tagName).toBe('media-error-dialog');
});
it('provides alertDialogContext for child parts', async () => {
ensureDefined(AlertDialogTitleElement.tagName, AlertDialogTitleElement);
ensureDefined(AlertDialogDescriptionElement.tagName, AlertDialogDescriptionElement);
ensureDefined(AlertDialogCloseElement.tagName, AlertDialogCloseElement);
const el = createElement(ErrorDialogElement);
const title = document.createElement(AlertDialogTitleElement.tagName) as AlertDialogTitleElement;
const desc = document.createElement(AlertDialogDescriptionElement.tagName) as AlertDialogDescriptionElement;
const close = document.createElement(AlertDialogCloseElement.tagName) as AlertDialogCloseElement;
el.appendChild(title);
el.appendChild(desc);
el.appendChild(close);
document.body.appendChild(el);
await el.updateComplete;
expect(title.isConnected).toBe(true);
expect(desc.isConnected).toBe(true);
expect(close.isConnected).toBe(true);
});
it('handles missing child elements gracefully', async () => {
const el = createElement(ErrorDialogElement);
document.body.appendChild(el);
await el.updateComplete;
expect(el.isConnected).toBe(true);
});
it('cleans up on disconnect', async () => {
const el = createElement(ErrorDialogElement);
document.body.appendChild(el);
await el.updateComplete;
document.body.removeChild(el);
expect(el.isConnected).toBe(false);
});
});