mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(html): add alert dialog element (#741)
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import { AlertDialogCloseElement } from '../../ui/alert-dialog/alert-dialog-close-element';
|
||||
import { safeDefine } from '../safe-define';
|
||||
|
||||
safeDefine(AlertDialogCloseElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AlertDialogCloseElement.tagName]: AlertDialogCloseElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AlertDialogDescriptionElement } from '../../ui/alert-dialog/alert-dialog-description-element';
|
||||
import { safeDefine } from '../safe-define';
|
||||
|
||||
safeDefine(AlertDialogDescriptionElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AlertDialogDescriptionElement.tagName]: AlertDialogDescriptionElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AlertDialogTitleElement } from '../../ui/alert-dialog/alert-dialog-title-element';
|
||||
import { safeDefine } from '../safe-define';
|
||||
|
||||
safeDefine(AlertDialogTitleElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AlertDialogTitleElement.tagName]: AlertDialogTitleElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { AlertDialogCloseElement } from '../../ui/alert-dialog/alert-dialog-close-element';
|
||||
import { AlertDialogDescriptionElement } from '../../ui/alert-dialog/alert-dialog-description-element';
|
||||
import { AlertDialogElement } from '../../ui/alert-dialog/alert-dialog-element';
|
||||
import { AlertDialogTitleElement } from '../../ui/alert-dialog/alert-dialog-title-element';
|
||||
import { safeDefine } from '../safe-define';
|
||||
|
||||
// Parent first — child elements consume its context.
|
||||
safeDefine(AlertDialogElement);
|
||||
safeDefine(AlertDialogCloseElement);
|
||||
safeDefine(AlertDialogDescriptionElement);
|
||||
safeDefine(AlertDialogTitleElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AlertDialogElement.tagName]: AlertDialogElement;
|
||||
[AlertDialogCloseElement.tagName]: AlertDialogCloseElement;
|
||||
[AlertDialogDescriptionElement.tagName]: AlertDialogDescriptionElement;
|
||||
[AlertDialogTitleElement.tagName]: AlertDialogTitleElement;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,11 @@ export * from './store/container-mixin';
|
||||
export * from './store/provider-mixin';
|
||||
export * from './store/types';
|
||||
// UI Components
|
||||
export { AlertDialogCloseElement } from './ui/alert-dialog/alert-dialog-close-element';
|
||||
export { AlertDialogDescriptionElement } from './ui/alert-dialog/alert-dialog-description-element';
|
||||
export { AlertDialogElement } from './ui/alert-dialog/alert-dialog-element';
|
||||
export { AlertDialogTitleElement } from './ui/alert-dialog/alert-dialog-title-element';
|
||||
export { type AlertDialogContextValue, alertDialogContext } from './ui/alert-dialog/context';
|
||||
export { BufferingIndicatorElement } from './ui/buffering-indicator/buffering-indicator-element';
|
||||
export { CaptionsButtonElement } from './ui/captions-button/captions-button-element';
|
||||
export { ControlsElement } from './ui/controls/controls-element';
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { applyElementProps, applyStateDataAttrs, createButton } from '@videojs/core/dom';
|
||||
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
import { ContextConsumer } from '@videojs/element/context';
|
||||
|
||||
import { MediaElement } from '../media-element';
|
||||
import { alertDialogContext } from './context';
|
||||
|
||||
export class AlertDialogCloseElement extends MediaElement {
|
||||
static readonly tagName = 'media-alert-dialog-close';
|
||||
|
||||
static override properties = {
|
||||
disabled: { type: Boolean },
|
||||
} satisfies PropertyDeclarationMap<'disabled'>;
|
||||
|
||||
disabled = false;
|
||||
|
||||
readonly #ctx = new ContextConsumer(this, { context: alertDialogContext, subscribe: true });
|
||||
|
||||
#disconnect: AbortController | null = null;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.#disconnect = new AbortController();
|
||||
|
||||
const buttonProps = createButton({
|
||||
onActivate: () => this.#ctx.value?.close(),
|
||||
isDisabled: () => this.disabled,
|
||||
});
|
||||
|
||||
applyElementProps(this, buttonProps, { signal: this.#disconnect.signal });
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.#disconnect?.abort();
|
||||
this.#disconnect = null;
|
||||
}
|
||||
|
||||
protected override update(_changed: PropertyValues): void {
|
||||
super.update(_changed);
|
||||
const ctx = this.#ctx.value;
|
||||
if (ctx) applyStateDataAttrs(this, ctx.state, ctx.stateAttrMap);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AlertDialogState } from '@videojs/core';
|
||||
import type { PropertyValues } from '@videojs/element';
|
||||
import { ContextConsumer } from '@videojs/element/context';
|
||||
|
||||
import { ContextPartElement } from '../context-part-element';
|
||||
import { alertDialogContext } from './context';
|
||||
|
||||
export class AlertDialogDescriptionElement extends ContextPartElement<AlertDialogState> {
|
||||
static readonly tagName = 'media-alert-dialog-description';
|
||||
|
||||
protected readonly consumer = new ContextConsumer(this, { context: alertDialogContext, subscribe: true });
|
||||
|
||||
protected override update(changed: PropertyValues): void {
|
||||
super.update(changed);
|
||||
const descriptionId = this.consumer.value?.state.descriptionId;
|
||||
if (descriptionId) this.id = descriptionId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { AlertDialogCore, AlertDialogDataAttrs, type AlertDialogInput } from '@videojs/core';
|
||||
import {
|
||||
type AlertDialogApi,
|
||||
applyElementProps,
|
||||
applyStateDataAttrs,
|
||||
createAlertDialog,
|
||||
createTransition,
|
||||
} from '@videojs/core/dom';
|
||||
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
import { SnapshotController } from '@videojs/store/html';
|
||||
|
||||
import { MediaElement } from '../media-element';
|
||||
import { alertDialogContext } from './context';
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
export class AlertDialogElement extends MediaElement {
|
||||
static readonly tagName = 'media-alert-dialog';
|
||||
|
||||
static override properties = {
|
||||
open: { type: Boolean },
|
||||
} satisfies PropertyDeclarationMap<'open'>;
|
||||
|
||||
open = false;
|
||||
|
||||
readonly #core = new AlertDialogCore();
|
||||
readonly #provider = new ContextProvider(this, { context: alertDialogContext });
|
||||
readonly #titleId = `vjs-alert-dialog-title-${idCounter++}`;
|
||||
readonly #descriptionId = `vjs-alert-dialog-desc-${idCounter++}`;
|
||||
|
||||
#dialog: AlertDialogApi | null = null;
|
||||
#snapshot: SnapshotController<AlertDialogInput> | 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) => {
|
||||
this.open = nextOpen;
|
||||
this.dispatchEvent(new CustomEvent('open-change', { detail: { open: nextOpen } }));
|
||||
},
|
||||
});
|
||||
|
||||
// Register self as the dialog element.
|
||||
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);
|
||||
|
||||
// Sync controlled open state.
|
||||
if (this.#dialog && changed.has('open')) {
|
||||
const { active: inputOpen } = this.#dialog.input.current;
|
||||
if (this.open !== inputOpen) {
|
||||
if (this.open) {
|
||||
this.#dialog.open();
|
||||
} else {
|
||||
this.#dialog.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override update(_changed: PropertyValues): void {
|
||||
super.update(_changed);
|
||||
if (!this.#dialog) return;
|
||||
|
||||
const input = this.#dialog.input.current;
|
||||
this.#core.setInput(input);
|
||||
const state = this.#core.getState();
|
||||
|
||||
applyElementProps(this, this.#core.getAttrs(state));
|
||||
applyStateDataAttrs(this, state, AlertDialogDataAttrs);
|
||||
|
||||
this.#provider.setValue({
|
||||
state,
|
||||
stateAttrMap: AlertDialogDataAttrs,
|
||||
close: () => this.#dialog?.close(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AlertDialogState } from '@videojs/core';
|
||||
import type { PropertyValues } from '@videojs/element';
|
||||
import { ContextConsumer } from '@videojs/element/context';
|
||||
|
||||
import { ContextPartElement } from '../context-part-element';
|
||||
import { alertDialogContext } from './context';
|
||||
|
||||
export class AlertDialogTitleElement extends ContextPartElement<AlertDialogState> {
|
||||
static readonly tagName = 'media-alert-dialog-title';
|
||||
|
||||
protected readonly consumer = new ContextConsumer(this, { context: alertDialogContext, subscribe: true });
|
||||
|
||||
protected override update(changed: PropertyValues): void {
|
||||
super.update(changed);
|
||||
const titleId = this.consumer.value?.state.titleId;
|
||||
if (titleId) this.id = titleId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AlertDialogState, StateAttrMap } from '@videojs/core';
|
||||
import { createContext } from '@videojs/element/context';
|
||||
|
||||
export interface AlertDialogContextValue {
|
||||
state: AlertDialogState;
|
||||
stateAttrMap: StateAttrMap<AlertDialogState>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const ALERT_DIALOG_CONTEXT_KEY = Symbol('@videojs/alert-dialog');
|
||||
|
||||
export const alertDialogContext = createContext<AlertDialogContextValue>(ALERT_DIALOG_CONTEXT_KEY);
|
||||
@@ -0,0 +1,186 @@
|
||||
import { flush } from '@videojs/store';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AlertDialogElement } from '../alert-dialog-element';
|
||||
|
||||
let tagCounter = 0;
|
||||
|
||||
function uniqueTag(base: string): string {
|
||||
return `${base}-${tagCounter++}`;
|
||||
}
|
||||
|
||||
function createElement<Element extends HTMLElement>(Base: abstract new () => Element): Element {
|
||||
const tag = uniqueTag('test-el');
|
||||
customElements.define(tag, class extends (Base as unknown as typeof HTMLElement) {});
|
||||
return document.createElement(tag) as Element;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('AlertDialogElement', () => {
|
||||
it('has the correct tag name', () => {
|
||||
expect(AlertDialogElement.tagName).toBe('media-alert-dialog');
|
||||
});
|
||||
|
||||
it('initializes with open set to false', () => {
|
||||
const el = createElement(AlertDialogElement);
|
||||
expect(el.open).toBe(false);
|
||||
});
|
||||
|
||||
it('sets data-open attribute when open is true', async () => {
|
||||
const el = createElement(AlertDialogElement);
|
||||
el.open = true;
|
||||
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.hasAttribute('data-open')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not set data-open attribute when open is false', async () => {
|
||||
const el = createElement(AlertDialogElement);
|
||||
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.hasAttribute('data-open')).toBe(false);
|
||||
});
|
||||
|
||||
it('removes data-open attribute after close transition completes', async () => {
|
||||
const el = createElement(AlertDialogElement);
|
||||
el.open = true;
|
||||
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
expect(el.hasAttribute('data-open')).toBe(true);
|
||||
|
||||
el.open = false;
|
||||
await el.updateComplete;
|
||||
|
||||
// data-open stays true during the ending transition (active: true, status: 'ending').
|
||||
// Wait for the close transition to fully complete (double RAF + animation wait).
|
||||
await vi.waitFor(() => {
|
||||
expect(el.hasAttribute('data-open')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('applies alertdialog role and aria-modal', async () => {
|
||||
const el = createElement(AlertDialogElement);
|
||||
el.open = true;
|
||||
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(el.getAttribute('role')).toBe('alertdialog');
|
||||
expect(el.getAttribute('aria-modal')).toBe('true');
|
||||
});
|
||||
|
||||
it('dispatches open-change event on close', async () => {
|
||||
const el = createElement(AlertDialogElement);
|
||||
el.open = true;
|
||||
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
flush();
|
||||
|
||||
const spy = vi.fn();
|
||||
el.addEventListener('open-change', spy);
|
||||
|
||||
// Escape triggers dismiss layer → onOpenChange(false) → open-change event.
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
|
||||
expect(el.open).toBe(false);
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
expect((spy.mock.calls[0]![0] as CustomEvent).detail).toEqual({ open: false });
|
||||
});
|
||||
|
||||
it('closes on Escape key press', async () => {
|
||||
const el = createElement(AlertDialogElement);
|
||||
el.open = true;
|
||||
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
flush();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
|
||||
expect(el.open).toBe(false);
|
||||
});
|
||||
|
||||
it('does not close on Escape when already closed', async () => {
|
||||
const el = createElement(AlertDialogElement);
|
||||
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
|
||||
const spy = vi.fn();
|
||||
el.addEventListener('open-change', spy);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
|
||||
expect(el.open).toBe(false);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores non-Escape key presses', async () => {
|
||||
const el = createElement(AlertDialogElement);
|
||||
el.open = true;
|
||||
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
flush();
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
|
||||
expect(el.open).toBe(true);
|
||||
});
|
||||
|
||||
it('closes on button click within the dialog', async () => {
|
||||
const el = createElement(AlertDialogElement);
|
||||
el.open = true;
|
||||
|
||||
const button = document.createElement('button');
|
||||
el.appendChild(button);
|
||||
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
flush();
|
||||
|
||||
button.click();
|
||||
|
||||
expect(el.open).toBe(false);
|
||||
});
|
||||
|
||||
it('does not close on non-button element click', async () => {
|
||||
const el = createElement(AlertDialogElement);
|
||||
el.open = true;
|
||||
|
||||
const span = document.createElement('span');
|
||||
el.appendChild(span);
|
||||
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
flush();
|
||||
|
||||
span.click();
|
||||
|
||||
expect(el.open).toBe(true);
|
||||
});
|
||||
|
||||
it('cleans up on disconnect', async () => {
|
||||
const el = createElement(AlertDialogElement);
|
||||
el.open = true;
|
||||
|
||||
document.body.appendChild(el);
|
||||
await el.updateComplete;
|
||||
flush();
|
||||
|
||||
document.body.removeChild(el);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
|
||||
// Dialog was destroyed on disconnect, so open should still be true.
|
||||
expect(el.open).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -10,12 +10,15 @@ import { useAlertDialogContext } from './context';
|
||||
export interface AlertDialogCloseProps extends UIComponentProps<'button', AlertDialogCore.State> {}
|
||||
|
||||
export const AlertDialogClose = forwardRef<HTMLButtonElement, AlertDialogCloseProps>(function AlertDialogClose(
|
||||
{ render, className, style, ...elementProps },
|
||||
{ render, className, style, disabled, ...elementProps },
|
||||
forwardedRef
|
||||
) {
|
||||
const { dialog, state } = useAlertDialogContext();
|
||||
|
||||
const handleClick = useCallback(() => dialog.close(), [dialog]);
|
||||
const handleClick = useCallback(() => {
|
||||
if (disabled) return;
|
||||
dialog.close();
|
||||
}, [dialog, disabled]);
|
||||
|
||||
return renderElement(
|
||||
'button',
|
||||
@@ -23,7 +26,7 @@ export const AlertDialogClose = forwardRef<HTMLButtonElement, AlertDialogClosePr
|
||||
{
|
||||
state,
|
||||
ref: [forwardedRef],
|
||||
props: [{ type: 'button' as const, onClick: handleClick }, elementProps],
|
||||
props: [{ type: 'button' as const, disabled, onClick: handleClick }, elementProps],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user