diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index d0dc03ba..4339caeb 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -9,6 +9,8 @@ export * from './ui/captions-button/captions-button-core'; export * from './ui/captions-button/captions-button-data-attrs'; export * from './ui/controls/controls-core'; export * from './ui/controls/controls-data-attrs'; +export * from './ui/error-dialog/error-dialog-core'; +export * from './ui/error-dialog/error-dialog-data-attrs'; export * from './ui/fullscreen-button/fullscreen-button-core'; export * from './ui/fullscreen-button/fullscreen-button-data-attrs'; export * from './ui/mute-button/mute-button-core'; diff --git a/packages/core/src/core/ui/error-dialog/error-dialog-core.ts b/packages/core/src/core/ui/error-dialog/error-dialog-core.ts new file mode 100644 index 00000000..531b40b1 --- /dev/null +++ b/packages/core/src/core/ui/error-dialog/error-dialog-core.ts @@ -0,0 +1,12 @@ +import { AlertDialogCore, type AlertDialogState } from '../alert-dialog/alert-dialog-core'; + +export interface ErrorDialogState extends AlertDialogState {} + +/** Error-dialog core: an alert dialog whose open state is driven by media error state. */ +export class ErrorDialogCore extends AlertDialogCore { + override setProps(): void {} +} + +export namespace ErrorDialogCore { + export type State = ErrorDialogState; +} diff --git a/packages/core/src/core/ui/error-dialog/error-dialog-data-attrs.ts b/packages/core/src/core/ui/error-dialog/error-dialog-data-attrs.ts new file mode 100644 index 00000000..46217826 --- /dev/null +++ b/packages/core/src/core/ui/error-dialog/error-dialog-data-attrs.ts @@ -0,0 +1 @@ +export { AlertDialogDataAttrs as ErrorDialogDataAttrs } from '../alert-dialog/alert-dialog-data-attrs'; diff --git a/packages/html/src/define/audio/minimal-skin.tailwind.ts b/packages/html/src/define/audio/minimal-skin.tailwind.ts index c5b263a1..42ba24da 100644 --- a/packages/html/src/define/audio/minimal-skin.tailwind.ts +++ b/packages/html/src/define/audio/minimal-skin.tailwind.ts @@ -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() { + +
+
+ Something went wrong. + +
+
+ OK +
+
+
+
diff --git a/packages/html/src/define/audio/minimal-skin.ts b/packages/html/src/define/audio/minimal-skin.ts index 0b2b6286..1cef15b3 100644 --- a/packages/html/src/define/audio/minimal-skin.ts +++ b/packages/html/src/define/audio/minimal-skin.ts @@ -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() { + +
+
+ Something went wrong. + +
+
+ OK +
+
+
+
diff --git a/packages/html/src/define/audio/skin.tailwind.ts b/packages/html/src/define/audio/skin.tailwind.ts index 331a29db..4e90fe52 100644 --- a/packages/html/src/define/audio/skin.tailwind.ts +++ b/packages/html/src/define/audio/skin.tailwind.ts @@ -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() { + +
+
+ Something went wrong. + +
+
+ OK +
+
+
+
diff --git a/packages/html/src/define/audio/skin.ts b/packages/html/src/define/audio/skin.ts index 26fea0c1..7af08ed7 100644 --- a/packages/html/src/define/audio/skin.ts +++ b/packages/html/src/define/audio/skin.ts @@ -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() { + +
+
+ Something went wrong. + +
+
+ OK +
+
+
+
diff --git a/packages/html/src/define/ui/error-dialog.ts b/packages/html/src/define/ui/error-dialog.ts new file mode 100644 index 00000000..01da6f7c --- /dev/null +++ b/packages/html/src/define/ui/error-dialog.ts @@ -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; + } +} diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts index 0e9cd9ca..01e388f9 100644 --- a/packages/html/src/define/video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -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')} + +
+
+ Something went wrong. + +
+
+ OK +
+
+
+
diff --git a/packages/html/src/define/video/minimal-skin.ts b/packages/html/src/define/video/minimal-skin.ts index 1b5e7d7e..bd0739a1 100644 --- a/packages/html/src/define/video/minimal-skin.ts +++ b/packages/html/src/define/video/minimal-skin.ts @@ -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' })} + +
+
+ Something went wrong. + +
+
+ OK +
+
+
+
diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index 356c23f2..34691eff 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -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() {
+ +
+
+ Something went wrong. + +
+
+ OK +
+
+
+
diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts index 84bdf890..d1b17146 100644 --- a/packages/html/src/define/video/skin.ts +++ b/packages/html/src/define/video/skin.ts @@ -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() {
+ +
+
+ Something went wrong. + +
+
+ OK +
+
+
+
diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index d993b635..7c120465 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -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 diff --git a/packages/html/src/ui/error-dialog/error-dialog-element.ts b/packages/html/src/ui/error-dialog/error-dialog-element.ts new file mode 100644 index 00000000..f0ea9a3c --- /dev/null +++ b/packages/html/src/ui/error-dialog/error-dialog-element.ts @@ -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 | 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(), + }); + } +} diff --git a/packages/html/src/ui/error-dialog/tests/error-dialog-element.test.ts b/packages/html/src/ui/error-dialog/tests/error-dialog-element.test.ts new file mode 100644 index 00000000..66cc0b31 --- /dev/null +++ b/packages/html/src/ui/error-dialog/tests/error-dialog-element.test.ts @@ -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(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); + }); +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 93f29c53..f1613a67 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -37,6 +37,7 @@ export { CaptionsButton, type CaptionsButtonProps } from './ui/captions-button/c export { Controls } from './ui/controls'; export type { ControlsGroupProps } from './ui/controls/controls-group'; export type { ControlsRootProps } from './ui/controls/controls-root'; +export { ErrorDialog, type ErrorDialogContextValue, useErrorDialogContext } from './ui/error-dialog'; export { FullscreenButton, type FullscreenButtonProps } from './ui/fullscreen-button/fullscreen-button'; export { useButton } from './ui/hooks/use-button'; export { useSlider } from './ui/hooks/use-slider'; diff --git a/packages/react/src/presets/audio/minimal-skin.tailwind.tsx b/packages/react/src/presets/audio/minimal-skin.tailwind.tsx index 0f29d189..56134e59 100644 --- a/packages/react/src/presets/audio/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/audio/minimal-skin.tailwind.tsx @@ -26,6 +26,7 @@ import { import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { Container, usePlayer } from '@/player/context'; +import { ErrorDialog } from '@/ui/error-dialog'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; import { PlaybackRateButton } from '@/ui/playback-rate-button'; @@ -35,21 +36,10 @@ import { Time } from '@/ui/time'; import { TimeSlider } from '@/ui/time-slider'; import { Tooltip } from '@/ui/tooltip'; import { VolumeSlider } from '@/ui/volume-slider'; -import { ErrorDialog } from '../error-dialog'; import type { MinimalAudioSkinProps } from './minimal-skin'; const SEEK_TIME = 10; -const ERROR_CLASSNAMES = { - root: error.root, - dialog: error.dialog, - content: error.content, - title: error.title, - description: error.description, - actions: error.actions, - close: cn(button.base, button.subtle), -}; - /* --------------------------------------- Components ---------------------------------------- */ const Button = forwardRef>(function Button({ className, ...props }, ref) { @@ -143,7 +133,19 @@ export function MinimalAudioSkinTailwind(props: MinimalAudioSkinProps): ReactNod {children} - + + +
+
+ Something went wrong. + +
+
+ OK +
+
+
+
diff --git a/packages/react/src/presets/audio/minimal-skin.tsx b/packages/react/src/presets/audio/minimal-skin.tsx index 26146a25..cbbfe079 100644 --- a/packages/react/src/presets/audio/minimal-skin.tsx +++ b/packages/react/src/presets/audio/minimal-skin.tsx @@ -10,6 +10,7 @@ import { import { cn } from '@videojs/utils/style'; import { type ComponentProps, forwardRef, type ReactNode } from 'react'; import { Container, usePlayer } from '@/player/context'; +import { ErrorDialog } from '@/ui/error-dialog'; import { MuteButton } from '@/ui/mute-button'; import { PlayButton } from '@/ui/play-button'; import { PlaybackRateButton } from '@/ui/playback-rate-button'; @@ -19,23 +20,12 @@ import { Time } from '@/ui/time'; import { TimeSlider } from '@/ui/time-slider'; import { Tooltip } from '@/ui/tooltip'; import { VolumeSlider } from '@/ui/volume-slider'; -import { ErrorDialog } from '../error-dialog'; import type { BaseSkinProps } from '../types'; export type MinimalAudioSkinProps = BaseSkinProps; const SEEK_TIME = 10; -const ERROR_CLASSNAMES = { - root: 'media-error', - dialog: 'media-error__dialog', - content: 'media-error__content', - title: 'media-error__title', - description: 'media-error__description', - actions: 'media-error__actions', - close: 'media-button media-button--subtle', -}; - const Button = forwardRef>(function Button({ className, ...props }, ref) { return (