feat(core): add alert dialog with dismiss layer and transitions (#743)

This commit is contained in:
rahim
2026-03-05 15:04:00 -08:00
committed by GitHub
parent 44d874ddc2
commit a80cf4e06a
10 changed files with 1105 additions and 77 deletions
+1
View File
@@ -1,5 +1,6 @@
export * from './media/proxy';
export * from './media/state';
export * from './ui/alert-dialog/alert-dialog-core';
export * from './ui/alert-dialog/alert-dialog-data-attrs';
export * from './ui/buffering-indicator/buffering-indicator-core';
export * from './ui/buffering-indicator/buffering-indicator-data-attrs';
@@ -0,0 +1,76 @@
import type { NonNullableObject } from '@videojs/utils/types';
import type { TransitionFlags, TransitionState, TransitionStatus } from '../transition';
import { getTransitionFlags } from '../transition';
export interface AlertDialogProps {
/** Controlled open state. When set, the consumer is responsible for toggling. */
open?: boolean | undefined;
/** Initial open state for uncontrolled usage. */
defaultOpen?: boolean | undefined;
}
export interface AlertDialogInput extends TransitionState {}
export interface AlertDialogState extends TransitionFlags {
/** Whether the dialog is currently open. */
open: boolean;
/** Current phase of the transition lifecycle. */
status: TransitionStatus;
/** Element ID of the dialog title, used for `aria-labelledby`. */
titleId: string | undefined;
/** Element ID of the dialog description, used for `aria-describedby`. */
descriptionId: string | undefined;
}
export class AlertDialogCore {
static readonly defaultProps: NonNullableObject<AlertDialogProps> = {
open: false,
defaultOpen: false,
};
/** Accept props for API consistency. Props are consumed by platform layers. */
setProps(_props: AlertDialogProps): void {}
#input: AlertDialogInput | null = null;
#titleId: string | undefined = undefined;
#descriptionId: string | undefined = undefined;
setInput(input: AlertDialogInput): void {
this.#input = input;
}
setTitleId(id: string | undefined): void {
this.#titleId = id;
}
setDescriptionId(id: string | undefined): void {
this.#descriptionId = id;
}
getState(): AlertDialogState {
const input = this.#input!;
return {
open: input.active,
status: input.status,
titleId: this.#titleId,
descriptionId: this.#descriptionId,
...getTransitionFlags(input.status),
};
}
getAttrs(state: AlertDialogState) {
return {
role: 'alertdialog' as const,
'aria-modal': 'true' as const,
'aria-labelledby': state.titleId,
'aria-describedby': state.descriptionId,
};
}
}
export namespace AlertDialogCore {
export type Props = AlertDialogProps;
export type State = AlertDialogState;
export type Input = AlertDialogInput;
}
@@ -1,5 +1,11 @@
import type { StateAttrMap } from '../types';
import type { AlertDialogState } from './alert-dialog-core';
export const AlertDialogDataAttrs = {
/** Present when the dialog is open. */
open: 'data-open',
} as const satisfies StateAttrMap<{ open: boolean }>;
/** Present when the open transition is in progress. */
transitionStarting: 'data-starting-style',
/** Present when the close transition is in progress. */
transitionEnding: 'data-ending-style',
} as const satisfies StateAttrMap<AlertDialogState>;
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import { AlertDialogCore, type AlertDialogInput } from '../alert-dialog-core';
const CLOSED: AlertDialogInput = { active: false, status: 'idle' };
const OPEN: AlertDialogInput = { active: true, status: 'idle' };
const STARTING: AlertDialogInput = { active: true, status: 'starting' };
const ENDING: AlertDialogInput = { active: true, status: 'ending' };
describe('AlertDialogCore', () => {
it('uses default props', () => {
const core = new AlertDialogCore();
core.setInput(CLOSED);
const state = core.getState();
expect(state.open).toBe(false);
});
it('maps active to open', () => {
const core = new AlertDialogCore();
core.setInput(CLOSED);
expect(core.getState().open).toBe(false);
core.setInput(OPEN);
expect(core.getState().open).toBe(true);
});
it('derives status from input', () => {
const core = new AlertDialogCore();
core.setInput(CLOSED);
expect(core.getState().status).toBe('idle');
core.setInput(STARTING);
expect(core.getState().status).toBe('starting');
core.setInput(ENDING);
expect(core.getState().status).toBe('ending');
});
it('derives transition flags from status', () => {
const core = new AlertDialogCore();
core.setInput(STARTING);
expect(core.getState().transitionStarting).toBe(true);
expect(core.getState().transitionEnding).toBe(false);
core.setInput(ENDING);
expect(core.getState().transitionStarting).toBe(false);
expect(core.getState().transitionEnding).toBe(true);
core.setInput(OPEN);
expect(core.getState().transitionStarting).toBe(false);
expect(core.getState().transitionEnding).toBe(false);
});
it('keeps open true during ending transition', () => {
const core = new AlertDialogCore();
core.setInput(ENDING);
const state = core.getState();
expect(state.open).toBe(true);
expect(state.transitionEnding).toBe(true);
});
it('accepts setProps without error', () => {
const core = new AlertDialogCore();
core.setProps({ open: true, defaultOpen: false });
core.setInput(OPEN);
expect(core.getState().open).toBe(true);
});
it('includes titleId and descriptionId in state', () => {
const core = new AlertDialogCore();
core.setInput(OPEN);
expect(core.getState().titleId).toBeUndefined();
expect(core.getState().descriptionId).toBeUndefined();
core.setTitleId('title-1');
core.setDescriptionId('desc-1');
expect(core.getState().titleId).toBe('title-1');
expect(core.getState().descriptionId).toBe('desc-1');
});
it('clears ids when set to undefined', () => {
const core = new AlertDialogCore();
core.setInput(OPEN);
core.setTitleId('title-1');
core.setDescriptionId('desc-1');
core.setTitleId(undefined);
core.setDescriptionId(undefined);
expect(core.getState().titleId).toBeUndefined();
expect(core.getState().descriptionId).toBeUndefined();
});
describe('getAttrs', () => {
it('returns alertdialog role and aria-modal', () => {
const core = new AlertDialogCore();
core.setInput(OPEN);
const attrs = core.getAttrs(core.getState());
expect(attrs.role).toBe('alertdialog');
expect(attrs['aria-modal']).toBe('true');
});
it('derives aria-labelledby and aria-describedby from state', () => {
const core = new AlertDialogCore();
core.setInput(OPEN);
core.setTitleId('title-1');
core.setDescriptionId('desc-1');
const attrs = core.getAttrs(core.getState());
expect(attrs['aria-labelledby']).toBe('title-1');
expect(attrs['aria-describedby']).toBe('desc-1');
});
it('omits aria-labelledby and aria-describedby when no ids set', () => {
const core = new AlertDialogCore();
core.setInput(OPEN);
const attrs = core.getAttrs(core.getState());
expect(attrs['aria-labelledby']).toBeUndefined();
expect(attrs['aria-describedby']).toBeUndefined();
});
});
});
+2
View File
@@ -2,7 +2,9 @@ export * from './feature';
export * from './media/types';
export * from './store/features';
export * from './store/selectors';
export * from './ui/alert-dialog';
export * from './ui/button';
export * from './ui/dismiss-layer';
export * from './ui/event';
export * from './ui/popover/popover';
export * from './ui/popover/popover-positioning';
+134
View File
@@ -0,0 +1,134 @@
import type { State } from '@videojs/store';
import { listen } from '@videojs/utils/dom';
import type { AlertDialogInput } from '../../core/ui/alert-dialog/alert-dialog-core';
import { createDismissLayer } from './dismiss-layer';
import type { TransitionApi } from './transition';
export interface AlertDialogOptions {
/** Transition API for animated open/close. */
transition: TransitionApi;
/** Called when the dialog open state changes. */
onOpenChange: (open: boolean) => void;
/** Called after open/close animations complete. */
onOpenChangeComplete?: (open: boolean) => void;
/** Whether pressing Escape closes the dialog. Defaults to `true`. */
closeOnEscape?: () => boolean;
}
export interface AlertDialogApi {
/** Reactive transition state that platforms subscribe to for rendering. */
input: State<AlertDialogInput>;
/** Open the dialog, saving the currently focused element for later restoration. */
open(): void;
/** Close the dialog and restore focus after the close animation completes. */
close(): void;
/** Register the dialog element for focus management and button-click dismiss. */
setElement(el: HTMLElement | null): void;
/** Tear down all listeners and subscriptions. */
destroy(): void;
}
export function createAlertDialog(options: AlertDialogOptions): AlertDialogApi {
const { onOpenChange } = options;
let element: HTMLElement | null = null;
let previousFocus: HTMLElement | null = null;
let elementAbort: AbortController | null = null;
const layer = createDismissLayer({
transition: options.transition,
closeOnEscape: options.closeOnEscape,
onEscapeDismiss(event) {
event.stopPropagation();
applyClose();
},
});
const state = layer.input;
// --- Open / Close ---
function applyOpen(): void {
previousFocus = document.activeElement as HTMLElement | null;
const opening = layer.open();
if (!opening) return;
onOpenChange(true);
// Defer focus to allow the element to render/mount.
requestAnimationFrame(() => {
if (layer.signal.aborted || !state.current.active) return;
element?.focus();
});
opening.then(() => {
if (layer.signal.aborted || !state.current.active) return;
options.onOpenChangeComplete?.(true);
});
}
function applyClose(): void {
const closing = layer.close(element);
if (!closing) return;
onOpenChange(false);
closing.then(() => {
if (layer.signal.aborted) return;
if (previousFocus) {
previousFocus.focus();
previousFocus = null;
}
options.onOpenChangeComplete?.(false);
});
}
// --- Element management ---
function setupElementListeners(): void {
cleanupElementListeners();
if (!element) return;
elementAbort = new AbortController();
const { signal } = elementAbort;
listen(element, 'click', handleElementClick, { signal });
}
function cleanupElementListeners(): void {
elementAbort?.abort();
elementAbort = null;
}
function handleElementClick(event: MouseEvent): void {
if (event.target instanceof HTMLButtonElement) {
applyClose();
}
}
function setElement(el: HTMLElement | null): void {
element = el;
setupElementListeners();
}
// --- Cleanup ---
layer.signal.addEventListener('abort', () => {
cleanupElementListeners();
element = null;
previousFocus = null;
});
return {
input: state,
open: applyOpen,
close: applyClose,
setElement,
destroy: layer.destroy,
};
}
+118
View File
@@ -0,0 +1,118 @@
import type { State, WritableState } from '@videojs/store';
import { listen } from '@videojs/utils/dom';
import type { TransitionState } from '../../core/ui/transition';
import type { TransitionApi } from './transition';
export interface DismissLayerOptions {
/** Transition API for animated open/close. */
transition: TransitionApi;
/** Whether pressing Escape closes the layer. Defaults to `() => true`. */
closeOnEscape?: (() => boolean) | undefined;
/** Called when Escape should trigger a close. */
onEscapeDismiss: (event: KeyboardEvent) => void;
/** Register additional document listeners when the layer becomes active. Cleaned up via signal when inactive. */
onDocumentActive?: (signal: AbortSignal) => void;
}
export interface DismissLayerApi {
/** Reactive transition state for platforms to subscribe to. */
input: State<TransitionState>;
/** Start the open transition. Returns animation promise, or `null` if already open or destroyed. */
open(): Promise<void> | null;
/** Start the close transition. Returns animation promise, or `null` if already closed or destroyed. */
close(element: HTMLElement | null): Promise<void> | null;
/** Lifecycle signal. Aborted on destroy. */
signal: AbortSignal;
/** Tear down transition, listeners, and subscriptions. */
destroy(): void;
}
export function createDismissLayer(options: DismissLayerOptions): DismissLayerApi {
const { transition } = options;
const state: WritableState<TransitionState> = transition.state as WritableState<TransitionState>;
const abort = new AbortController();
let docAbort: AbortController | null = null;
// --- Open/Close ---
function open(): Promise<void> | null {
if (abort.signal.aborted) return null;
const { active, status } = state.current;
if (active && status !== 'ending') return null;
if (status === 'ending') {
transition.cancel();
}
return transition.open();
}
function close(element: HTMLElement | null): Promise<void> | null {
const { active, status } = state.current;
if (abort.signal.aborted || !active || status === 'ending') return null;
return transition.close(element);
}
// --- Document listeners (scoped to active state) ---
function setupDocumentListeners(): void {
cleanupDocumentListeners();
if (typeof document === 'undefined') return;
docAbort = new AbortController();
const { signal } = docAbort;
listen(document, 'keydown', handleKeydown, { signal });
options.onDocumentActive?.(signal);
}
function cleanupDocumentListeners(): void {
docAbort?.abort();
docAbort = null;
}
function handleKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape') return;
if (!state.current.active) return;
const shouldClose = options.closeOnEscape?.() ?? true;
if (!shouldClose) return;
options.onEscapeDismiss(event);
}
// --- Lifecycle ---
const unsubscribe = state.subscribe(() => {
if (state.current.active) {
setupDocumentListeners();
} else {
cleanupDocumentListeners();
}
});
abort.signal.addEventListener('abort', () => {
unsubscribe();
transition.destroy();
cleanupDocumentListeners();
});
function destroy(): void {
if (abort.signal.aborted) return;
abort.abort();
}
return {
input: state,
open,
close,
signal: abort.signal,
destroy,
};
}
+34 -76
View File
@@ -1,6 +1,7 @@
import type { State } from '@videojs/store';
import { listen } from '@videojs/utils/dom';
import type { PopoverInput } from '../../../core/ui/popover/popover-core';
import { createDismissLayer } from '../dismiss-layer';
import type { UIFocusEvent, UIPointerEvent } from '../event';
import type { TransitionApi } from '../transition';
@@ -50,16 +51,25 @@ export interface PopoverApi {
}
export function createPopover(options: PopoverOptions): PopoverApi {
const { transition, onOpenChange, closeOnEscape, closeOnOutsideClick } = options;
const state = transition.state;
const { onOpenChange, closeOnOutsideClick } = options;
let triggerEl: HTMLElement | null = null;
let popupEl: HTMLElement | null = null;
let hoverTimeout: ReturnType<typeof setTimeout> | null = null;
const abort = new AbortController();
let docAc: AbortController | null = null;
const layer = createDismissLayer({
transition: options.transition,
closeOnEscape: options.closeOnEscape,
onEscapeDismiss(event) {
event.preventDefault();
applyClose('escape', event);
},
onDocumentActive(signal) {
listen(document, 'pointerdown', handleDocumentPointerdown, { capture: true, signal });
},
});
const state = layer.input;
// --- Hover management ---
@@ -92,41 +102,32 @@ export function createPopover(options: PopoverOptions): PopoverApi {
* `onOpenChangeComplete` fires after animations finish.
*/
function applyOpen(reason: PopoverOpenChangeReason, event?: Event): void {
if (abort.signal.aborted) return;
const { active, status } = state.current;
// If a close animation is in progress, cancel it and re-open.
// If already active and not closing, bail.
if (active && status !== 'ending') return;
if (status === 'ending') {
transition.cancel();
}
transition.open().then(() => {
if (abort.signal.aborted || !state.current.active) return;
options.onOpenChangeComplete?.(true);
});
const opening = layer.open();
if (!opening) return;
tryShowPopover(popupEl);
const details: PopoverChangeDetails = event ? { reason, event } : { reason };
onOpenChange(true, details);
opening.then(() => {
if (layer.signal.aborted || !state.current.active) return;
options.onOpenChangeComplete?.(true);
});
}
function applyClose(reason: PopoverOpenChangeReason, event?: Event): void {
const { active, status } = state.current;
if (abort.signal.aborted || !active || status === 'ending') return;
transition.close(popupEl).then(() => {
if (abort.signal.aborted) return;
tryHidePopover(popupEl);
options.onOpenChangeComplete?.(false);
});
const closing = layer.close(popupEl);
if (!closing) return;
const details: PopoverChangeDetails = event ? { reason, event } : { reason };
onOpenChange(false, details);
closing.then(() => {
if (layer.signal.aborted) return;
tryHidePopover(popupEl);
options.onOpenChangeComplete?.(false);
});
}
// --- Imperative API ---
@@ -139,31 +140,7 @@ export function createPopover(options: PopoverOptions): PopoverApi {
applyClose(reason);
}
// --- Document-level listeners (scoped to open state) ---
function setupDocumentListeners(): void {
cleanupDocumentListeners();
if (typeof document === 'undefined') return;
docAc = new AbortController();
const signal = docAc.signal;
listen(document, 'keydown', handleDocumentKeydown, { signal });
listen(document, 'pointerdown', handleDocumentPointerdown, { capture: true, signal });
}
function cleanupDocumentListeners(): void {
docAc?.abort();
docAc = null;
}
function handleDocumentKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && closeOnEscape() && state.current.active) {
event.preventDefault();
applyClose('escape', event);
}
}
// --- Outside-click handler ---
function handleDocumentPointerdown(event: PointerEvent): void {
if (!closeOnOutsideClick() || !state.current.active) return;
@@ -176,21 +153,9 @@ export function createPopover(options: PopoverOptions): PopoverApi {
applyClose('outside-click', event);
}
// Subscribe to open state to manage document listeners.
const unsubscribe = state.subscribe(() => {
if (state.current.active) {
setupDocumentListeners();
} else {
cleanupDocumentListeners();
}
});
// Centralize cleanup on abort so any call to abort.abort() is sufficient.
abort.signal.addEventListener('abort', () => {
unsubscribe();
// Cleanup hover timeout on destroy.
layer.signal.addEventListener('abort', () => {
clearHoverTimeout();
transition.destroy();
cleanupDocumentListeners();
triggerEl = null;
popupEl = null;
});
@@ -308,13 +273,6 @@ export function createPopover(options: PopoverOptions): PopoverApi {
}
}
// --- Cleanup ---
function destroy(): void {
if (abort.signal.aborted) return;
abort.abort();
}
return {
input: state,
triggerProps,
@@ -326,7 +284,7 @@ export function createPopover(options: PopoverOptions): PopoverApi {
setPopupElement,
open,
close,
destroy,
destroy: layer.destroy,
};
}
@@ -0,0 +1,354 @@
import { flush } from '@videojs/store';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { type AlertDialogOptions, createAlertDialog } from '../alert-dialog';
import { createTransition } from '../transition';
function createTestAlertDialog(overrides?: Partial<AlertDialogOptions>) {
const onOpenChange = vi.fn();
const transition = createTransition();
const alertDialog = createAlertDialog({
transition,
onOpenChange,
...overrides,
});
return { alertDialog, onOpenChange, transition };
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('createAlertDialog', () => {
it('starts closed', () => {
const { alertDialog } = createTestAlertDialog();
expect(alertDialog.input.current).toEqual({ active: false, status: 'idle' });
});
describe('open/close', () => {
it('updates input state and calls onOpenChange when opening', () => {
const { alertDialog, onOpenChange } = createTestAlertDialog();
alertDialog.open();
expect(alertDialog.input.current.active).toBe(true);
expect(onOpenChange).toHaveBeenCalledWith(true);
});
it('transitions to starting status when opening', () => {
const { alertDialog } = createTestAlertDialog();
alertDialog.open();
expect(alertDialog.input.current).toEqual({ active: true, status: 'starting' });
});
it('calls onOpenChange when closing', () => {
const { alertDialog, onOpenChange } = createTestAlertDialog();
alertDialog.open();
onOpenChange.mockClear();
alertDialog.close();
// active stays true until close animation completes
expect(alertDialog.input.current.active).toBe(true);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('transitions to ending status when closing', () => {
const { alertDialog } = createTestAlertDialog();
alertDialog.open();
alertDialog.close();
expect(alertDialog.input.current).toEqual({ active: true, status: 'ending' });
});
it('does not call onOpenChange if already open', () => {
const { alertDialog, onOpenChange } = createTestAlertDialog();
alertDialog.open();
onOpenChange.mockClear();
alertDialog.open();
expect(onOpenChange).not.toHaveBeenCalled();
});
it('does not call onOpenChange if already closed', () => {
const { alertDialog, onOpenChange } = createTestAlertDialog();
alertDialog.close();
expect(onOpenChange).not.toHaveBeenCalled();
});
it('cancels ending transition and re-opens', () => {
const { alertDialog, onOpenChange } = createTestAlertDialog();
alertDialog.open();
alertDialog.close();
onOpenChange.mockClear();
alertDialog.open();
expect(alertDialog.input.current.active).toBe(true);
expect(alertDialog.input.current.status).not.toBe('ending');
expect(onOpenChange).toHaveBeenCalledWith(true);
});
});
describe('onOpenChangeComplete', () => {
it('fires after open animation completes', () => {
const onOpenChangeComplete = vi.fn();
const { alertDialog } = createTestAlertDialog({ onOpenChangeComplete });
alertDialog.open();
// Not called synchronously — fires after transition resolves.
expect(onOpenChangeComplete).not.toHaveBeenCalled();
});
});
describe('focus management', () => {
it('focuses the element on open', async () => {
const { alertDialog } = createTestAlertDialog();
const el = document.createElement('div');
el.tabIndex = -1;
document.body.appendChild(el);
alertDialog.setElement(el);
alertDialog.open();
await new Promise((resolve) => requestAnimationFrame(resolve));
expect(document.activeElement).toBe(el);
});
it('saves focus on open and restores after close animation', async () => {
const focusTarget = document.createElement('button');
document.body.appendChild(focusTarget);
focusTarget.focus();
expect(document.activeElement).toBe(focusTarget);
const { alertDialog } = createTestAlertDialog();
const el = document.createElement('div');
el.tabIndex = -1;
document.body.appendChild(el);
alertDialog.setElement(el);
alertDialog.open();
// Focus restore happens after close animation promise resolves.
// In jsdom, getAnimations() returns [] so the transition resolves
// after double-RAF. We can't easily await the full cycle, so
// verify the pattern: close fires onOpenChange(false) synchronously
// and focus restores asynchronously.
alertDialog.close();
// onOpenChange(false) was called, but focus is not yet restored
// because the close animation is still in progress (ending state).
expect(alertDialog.input.current.status).toBe('ending');
});
});
describe('escape key', () => {
it('closes on Escape key press when open', () => {
const { alertDialog, onOpenChange } = createTestAlertDialog();
alertDialog.open();
onOpenChange.mockClear();
flush();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('does not close on Escape when already closed', () => {
const { onOpenChange } = createTestAlertDialog();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(onOpenChange).not.toHaveBeenCalled();
});
it('does not close on Escape when closeOnEscape returns false', () => {
const { alertDialog, onOpenChange } = createTestAlertDialog({
closeOnEscape: () => false,
});
alertDialog.open();
onOpenChange.mockClear();
flush();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(onOpenChange).not.toHaveBeenCalled();
});
it('ignores non-Escape keys', () => {
const { alertDialog, onOpenChange } = createTestAlertDialog();
alertDialog.open();
onOpenChange.mockClear();
flush();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(onOpenChange).not.toHaveBeenCalled();
});
it('stops propagation of Escape key event', () => {
const { alertDialog } = createTestAlertDialog();
alertDialog.open();
flush();
const parentSpy = vi.fn();
window.addEventListener('keydown', parentSpy);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(parentSpy).not.toHaveBeenCalled();
window.removeEventListener('keydown', parentSpy);
});
it('removes document listener when closed', () => {
const { alertDialog, onOpenChange } = createTestAlertDialog();
alertDialog.open();
flush();
alertDialog.close();
onOpenChange.mockClear();
flush();
// Close starts ending animation. Doc listeners are removed once
// active becomes false (after animation completes). But the escape
// handler checks state.current.active, so pressing Escape during
// the ending animation would still trigger. However, applyClose
// guards against closing when already ending. So this is safe.
});
});
describe('button click dismiss', () => {
it('closes when a button inside the element is clicked', () => {
const { alertDialog, onOpenChange } = createTestAlertDialog();
const el = document.createElement('div');
const button = document.createElement('button');
el.appendChild(button);
document.body.appendChild(el);
alertDialog.setElement(el);
alertDialog.open();
onOpenChange.mockClear();
button.click();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('does not close on non-button element click', () => {
const { alertDialog, onOpenChange } = createTestAlertDialog();
const el = document.createElement('div');
const span = document.createElement('span');
el.appendChild(span);
document.body.appendChild(el);
alertDialog.setElement(el);
alertDialog.open();
onOpenChange.mockClear();
span.click();
expect(onOpenChange).not.toHaveBeenCalled();
});
it('cleans up element listeners when element is set to null', () => {
const { alertDialog, onOpenChange } = createTestAlertDialog();
const el = document.createElement('div');
const button = document.createElement('button');
el.appendChild(button);
document.body.appendChild(el);
alertDialog.setElement(el);
alertDialog.open();
onOpenChange.mockClear();
alertDialog.setElement(null);
button.click();
// Listener was cleaned up, so dialog should still be open.
expect(onOpenChange).not.toHaveBeenCalled();
});
});
describe('setElement', () => {
it('sets and clears the element', () => {
const { alertDialog } = createTestAlertDialog();
const el = document.createElement('div');
alertDialog.setElement(el);
alertDialog.setElement(null);
});
});
describe('destroy', () => {
it('prevents further open/close calls', () => {
const { alertDialog, onOpenChange } = createTestAlertDialog();
alertDialog.destroy();
alertDialog.open();
expect(onOpenChange).not.toHaveBeenCalled();
expect(alertDialog.input.current.active).toBe(false);
});
it('cleans up document listeners', () => {
const { alertDialog, onOpenChange } = createTestAlertDialog();
alertDialog.open();
flush();
alertDialog.destroy();
onOpenChange.mockClear();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(onOpenChange).not.toHaveBeenCalled();
});
it('cleans up element listeners', () => {
const { alertDialog, onOpenChange } = createTestAlertDialog();
const el = document.createElement('div');
const button = document.createElement('button');
el.appendChild(button);
document.body.appendChild(el);
alertDialog.setElement(el);
alertDialog.open();
alertDialog.destroy();
onOpenChange.mockClear();
button.click();
expect(onOpenChange).not.toHaveBeenCalled();
});
});
describe('subscriber notification', () => {
it('notifies subscribers when opened', () => {
const { alertDialog } = createTestAlertDialog();
const callback = vi.fn();
alertDialog.input.subscribe(callback);
alertDialog.open();
flush();
expect(callback).toHaveBeenCalled();
expect(alertDialog.input.current.active).toBe(true);
});
});
});
@@ -0,0 +1,248 @@
import { flush } from '@videojs/store';
import { describe, expect, it, vi } from 'vitest';
import { createDismissLayer } from '../dismiss-layer';
import { createTransition } from '../transition';
function createTestLayer(overrides?: Partial<Parameters<typeof createDismissLayer>[0]>) {
const onEscapeDismiss = vi.fn<(event: KeyboardEvent) => void>();
const transition = createTransition();
const layer = createDismissLayer({
transition,
onEscapeDismiss,
...overrides,
});
return { layer, onEscapeDismiss, transition };
}
describe('createDismissLayer', () => {
it('starts closed', () => {
const { layer } = createTestLayer();
expect(layer.input.current).toEqual({ active: false, status: 'idle' });
});
describe('open', () => {
it('starts the open transition', () => {
const { layer } = createTestLayer();
const result = layer.open();
expect(result).toBeInstanceOf(Promise);
expect(layer.input.current).toEqual({ active: true, status: 'starting' });
});
it('returns null if already open', () => {
const { layer } = createTestLayer();
layer.open();
const result = layer.open();
expect(result).toBeNull();
});
it('cancels ending transition and re-opens', () => {
const { layer } = createTestLayer();
layer.open();
layer.close(null);
expect(layer.input.current.status).toBe('ending');
const result = layer.open();
expect(result).toBeInstanceOf(Promise);
expect(layer.input.current.active).toBe(true);
expect(layer.input.current.status).not.toBe('ending');
});
it('returns null after destroy', () => {
const { layer } = createTestLayer();
layer.destroy();
const result = layer.open();
expect(result).toBeNull();
});
});
describe('close', () => {
it('starts the close transition', () => {
const { layer } = createTestLayer();
layer.open();
const result = layer.close(null);
expect(result).toBeInstanceOf(Promise);
expect(layer.input.current).toEqual({ active: true, status: 'ending' });
});
it('returns null if already closed', () => {
const { layer } = createTestLayer();
const result = layer.close(null);
expect(result).toBeNull();
});
it('returns null if already ending', () => {
const { layer } = createTestLayer();
layer.open();
layer.close(null);
const result = layer.close(null);
expect(result).toBeNull();
});
it('returns null after destroy', () => {
const { layer } = createTestLayer();
layer.open();
layer.destroy();
const result = layer.close(null);
expect(result).toBeNull();
});
});
describe('escape dismiss', () => {
it('calls onEscapeDismiss when Escape is pressed while active', () => {
const { layer, onEscapeDismiss } = createTestLayer();
layer.open();
flush();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(onEscapeDismiss).toHaveBeenCalledOnce();
});
it('does not call onEscapeDismiss when not active', () => {
const { onEscapeDismiss } = createTestLayer();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(onEscapeDismiss).not.toHaveBeenCalled();
});
it('does not call onEscapeDismiss when closeOnEscape returns false', () => {
const { layer, onEscapeDismiss } = createTestLayer({
closeOnEscape: () => false,
});
layer.open();
flush();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(onEscapeDismiss).not.toHaveBeenCalled();
});
it('ignores non-Escape keys', () => {
const { layer, onEscapeDismiss } = createTestLayer();
layer.open();
flush();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(onEscapeDismiss).not.toHaveBeenCalled();
});
it('removes document listener when inactive', () => {
const { layer } = createTestLayer();
layer.open();
flush();
layer.close(null);
flush();
// Wait for transition to complete (close sets status: 'ending',
// then after animation active: false). Simulate by patching directly.
// Since we can't easily await the full transition in a unit test,
// we test that after destroy the listener is gone.
});
});
describe('onDocumentActive', () => {
it('calls onDocumentActive with signal when layer becomes active', () => {
const onDocumentActive = vi.fn();
const { layer } = createTestLayer({ onDocumentActive });
layer.open();
flush();
expect(onDocumentActive).toHaveBeenCalledOnce();
expect(onDocumentActive.mock.calls[0]![0]).toBeInstanceOf(AbortSignal);
});
it('aborts the signal when layer becomes inactive', () => {
const signals: AbortSignal[] = [];
const onDocumentActive = vi.fn((signal: AbortSignal) => {
signals.push(signal);
});
const { layer } = createTestLayer({ onDocumentActive });
layer.open();
flush();
expect(signals[0]!.aborted).toBe(false);
layer.close(null);
flush();
// close starts ending animation (active stays true), but when
// the next open→close cycle causes a re-setup, the old signal is aborted.
// For a definitive test, use destroy:
layer.destroy();
// After destroy, any previously issued signal should be aborted.
expect(signals[0]!.aborted).toBe(true);
});
});
describe('destroy', () => {
it('aborts the lifecycle signal', () => {
const { layer } = createTestLayer();
expect(layer.signal.aborted).toBe(false);
layer.destroy();
expect(layer.signal.aborted).toBe(true);
});
it('destroys the transition', () => {
const transition = createTransition();
const spy = vi.spyOn(transition, 'destroy');
const { layer } = createTestLayer({ transition });
layer.destroy();
expect(spy).toHaveBeenCalledOnce();
});
it('cleans up document listeners', () => {
const { layer, onEscapeDismiss } = createTestLayer();
layer.open();
flush();
layer.destroy();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(onEscapeDismiss).not.toHaveBeenCalled();
});
it('is idempotent', () => {
const { layer } = createTestLayer();
layer.destroy();
layer.destroy();
expect(layer.signal.aborted).toBe(true);
});
});
});