mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
feat(core): add alert dialog with dismiss layer and transitions (#743)
This commit is contained in:
@@ -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,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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user