mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages): error dialog component (#1077)
This commit is contained in:
@@ -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}">
|
||||
|
||||
@@ -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}">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user