feat(react): add alert dialog component (#739)

This commit is contained in:
rahim
2026-03-05 15:25:53 -08:00
committed by GitHub
parent 5c116065a9
commit 536c86d9d1
11 changed files with 467 additions and 2 deletions
+1
View File
@@ -28,6 +28,7 @@ export {
} from './player/create-player';
// UI
export { AlertDialog, type AlertDialogContextValue, useAlertDialogContext } from './ui/alert-dialog';
export { BufferingIndicator, type BufferingIndicatorProps } from './ui/buffering-indicator/buffering-indicator';
export { Controls } from './ui/controls';
export type { ControlsGroupProps } from './ui/controls/controls-group';
@@ -0,0 +1,34 @@
'use client';
import type { AlertDialogCore } from '@videojs/core';
import { forwardRef, useCallback } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useAlertDialogContext } from './context';
export interface AlertDialogCloseProps extends UIComponentProps<'button', AlertDialogCore.State> {}
export const AlertDialogClose = forwardRef<HTMLButtonElement, AlertDialogCloseProps>(function AlertDialogClose(
{ render, className, style, ...elementProps },
forwardedRef
) {
const { dialog, state } = useAlertDialogContext();
const handleClick = useCallback(() => dialog.close(), [dialog]);
return renderElement(
'button',
{ render, className, style },
{
state,
ref: [forwardedRef],
props: [{ type: 'button' as const, onClick: handleClick }, elementProps],
}
);
});
export namespace AlertDialogClose {
export type Props = AlertDialogCloseProps;
export type State = AlertDialogCore.State;
}
@@ -0,0 +1,21 @@
'use client';
import type { AlertDialogCore } from '@videojs/core';
import type { UIComponentProps } from '../../utils/types';
import { createContextPart } from '../create-context-part';
import { useAlertDialogContext } from './context';
export interface AlertDialogDescriptionProps extends UIComponentProps<'p', AlertDialogCore.State> {}
export const AlertDialogDescription = createContextPart<AlertDialogDescriptionProps, AlertDialogCore.State>({
displayName: 'AlertDialogDescription',
tag: 'p',
useContext: useAlertDialogContext,
getProps: (state) => ({ id: state.descriptionId }),
});
export namespace AlertDialogDescription {
export type Props = AlertDialogDescriptionProps;
export type State = AlertDialogCore.State;
}
@@ -0,0 +1,45 @@
'use client';
import type { AlertDialogCore } from '@videojs/core';
import { forwardRef, useCallback } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { useComposedRefs } from '../../utils/use-composed-refs';
import { renderElement } from '../../utils/use-render';
import { useAlertDialogContext } from './context';
export interface AlertDialogPopupProps extends UIComponentProps<'div', AlertDialogCore.State> {}
export const AlertDialogPopup = forwardRef<HTMLDivElement, AlertDialogPopupProps>(function AlertDialogPopup(
{ render, className, style, ...elementProps },
forwardedRef
) {
const { core, dialog, state, stateAttrMap } = useAlertDialogContext();
const elementRef = useCallback(
(el: HTMLDivElement | null) => {
dialog.setElement(el);
},
[dialog]
);
const composedRef = useComposedRefs(forwardedRef, elementRef);
if (!state.open) return null;
return renderElement(
'div',
{ render, className, style },
{
state,
stateAttrMap,
ref: composedRef,
props: [{ tabIndex: -1, ...core.getAttrs(state) }, elementProps],
}
);
});
export namespace AlertDialogPopup {
export type Props = AlertDialogPopupProps;
export type State = AlertDialogCore.State;
}
@@ -0,0 +1,89 @@
'use client';
import { AlertDialogCore, AlertDialogDataAttrs, type AlertDialogProps } from '@videojs/core';
import { createAlertDialog, createTransition } from '@videojs/core/dom';
import { useSnapshot } from '@videojs/store/react';
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { useDestroy } from '../../utils/use-destroy';
import { useLatestRef } from '../../utils/use-latest-ref';
import { useSafeId } from '../../utils/use-safe-id';
import { AlertDialogContextProvider } from './context';
export interface AlertDialogRootProps extends AlertDialogProps {
/** Called when the open state changes (fires immediately, before animations). */
onOpenChange?: (open: boolean) => void;
/** Called after open/close animations complete. */
onOpenChangeComplete?: (open: boolean) => void;
children?: ReactNode;
}
export function AlertDialogRoot({
open: controlledOpen,
defaultOpen = AlertDialogCore.defaultProps.defaultOpen,
onOpenChange: onOpenChangeProp,
onOpenChangeComplete: onOpenChangeCompleteProp,
children,
}: AlertDialogRootProps): ReactNode {
const [core] = useState(() => new AlertDialogCore());
const isControlled = controlledOpen !== undefined;
const onOpenChangeRef = useLatestRef(onOpenChangeProp);
const onOpenChangeCompleteRef = useLatestRef(onOpenChangeCompleteProp);
const [dialog] = useState(() => {
const instance = createAlertDialog({
transition: createTransition(),
onOpenChange: (nextOpen: boolean) => {
onOpenChangeRef.current?.(nextOpen);
},
onOpenChangeComplete: (nextOpen: boolean) => {
onOpenChangeCompleteRef.current?.(nextOpen);
},
});
if (!isControlled && defaultOpen) {
instance.open();
}
return instance;
});
const titleId = useSafeId('alert-dialog-title-');
const descriptionId = useSafeId('alert-dialog-desc-');
core.setTitleId(titleId);
core.setDescriptionId(descriptionId);
// Sync controlled open prop -> internal input state.
useEffect(() => {
if (controlledOpen === undefined) return;
const { active: inputOpen } = dialog.input.current;
if (controlledOpen === inputOpen) return;
if (controlledOpen) {
dialog.open();
} else {
dialog.close();
}
}, [controlledOpen, dialog]);
useDestroy(dialog);
const input = useSnapshot(dialog.input);
core.setInput(input);
const state = core.getState();
return (
<AlertDialogContextProvider value={{ core, dialog, state, stateAttrMap: AlertDialogDataAttrs }}>
{children}
</AlertDialogContextProvider>
);
}
export namespace AlertDialogRoot {
export type Props = AlertDialogRootProps;
}
@@ -0,0 +1,21 @@
'use client';
import type { AlertDialogCore } from '@videojs/core';
import type { UIComponentProps } from '../../utils/types';
import { createContextPart } from '../create-context-part';
import { useAlertDialogContext } from './context';
export interface AlertDialogTitleProps extends UIComponentProps<'h2', AlertDialogCore.State> {}
export const AlertDialogTitle = createContextPart<AlertDialogTitleProps, AlertDialogCore.State>({
displayName: 'AlertDialogTitle',
tag: 'h2',
useContext: useAlertDialogContext,
getProps: (state) => ({ id: state.titleId }),
});
export namespace AlertDialogTitle {
export type Props = AlertDialogTitleProps;
export type State = AlertDialogCore.State;
}
@@ -0,0 +1,22 @@
'use client';
import type { AlertDialogCore, StateAttrMap } from '@videojs/core';
import type { AlertDialogApi } from '@videojs/core/dom';
import { createContext, useContext } from 'react';
export interface AlertDialogContextValue {
core: AlertDialogCore;
dialog: AlertDialogApi;
state: AlertDialogCore.State;
stateAttrMap: StateAttrMap<AlertDialogCore.State>;
}
const AlertDialogContext = createContext<AlertDialogContextValue | null>(null);
export const AlertDialogContextProvider = AlertDialogContext.Provider;
export function useAlertDialogContext(): AlertDialogContextValue {
const ctx = useContext(AlertDialogContext);
if (!ctx) throw new Error('AlertDialog compound components must be used within an AlertDialog.Root');
return ctx;
}
@@ -0,0 +1,8 @@
export { AlertDialogClose as Close, type AlertDialogCloseProps as CloseProps } from './alert-dialog-close';
export {
AlertDialogDescription as Description,
type AlertDialogDescriptionProps as DescriptionProps,
} from './alert-dialog-description';
export { AlertDialogPopup as Popup, type AlertDialogPopupProps as PopupProps } from './alert-dialog-popup';
export { AlertDialogRoot as Root, type AlertDialogRootProps as RootProps } from './alert-dialog-root';
export { AlertDialogTitle as Title, type AlertDialogTitleProps as TitleProps } from './alert-dialog-title';
@@ -0,0 +1,2 @@
export { type AlertDialogContextValue, useAlertDialogContext } from './context';
export * as AlertDialog from './index.parts';
@@ -0,0 +1,219 @@
import { cleanup, render } from '@testing-library/react';
import { createRef } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AlertDialogClose } from '../alert-dialog-close';
import { AlertDialogDescription } from '../alert-dialog-description';
import { AlertDialogPopup } from '../alert-dialog-popup';
import { AlertDialogRoot } from '../alert-dialog-root';
import { AlertDialogTitle } from '../alert-dialog-title';
afterEach(cleanup);
describe('AlertDialogRoot', () => {
it('provides context to children', () => {
const { container } = render(
<AlertDialogRoot open>
<AlertDialogPopup data-testid="popup">content</AlertDialogPopup>
</AlertDialogRoot>
);
expect(container.querySelector('[role="alertdialog"]')).not.toBeNull();
});
it('supports uncontrolled mode with defaultOpen', () => {
const { container } = render(
<AlertDialogRoot defaultOpen>
<AlertDialogPopup>content</AlertDialogPopup>
</AlertDialogRoot>
);
expect(container.querySelector('[role="alertdialog"]')).not.toBeNull();
});
it('renders null when closed', () => {
const { container } = render(
<AlertDialogRoot open={false}>
<AlertDialogPopup>content</AlertDialogPopup>
</AlertDialogRoot>
);
expect(container.querySelector('[role="alertdialog"]')).toBeNull();
});
});
describe('AlertDialogPopup', () => {
it('throws when used outside Root', () => {
expect(() => render(<AlertDialogPopup />)).toThrow(
'AlertDialog compound components must be used within an AlertDialog.Root'
);
});
it('renders with alertdialog role and aria-modal', () => {
const { container } = render(
<AlertDialogRoot open>
<AlertDialogPopup>content</AlertDialogPopup>
</AlertDialogRoot>
);
const popup = container.querySelector('[role="alertdialog"]')!;
expect(popup.getAttribute('aria-modal')).toBe('true');
expect(popup.getAttribute('tabindex')).toBe('-1');
});
it('sets data-open when open', () => {
const { container } = render(
<AlertDialogRoot open>
<AlertDialogPopup>content</AlertDialogPopup>
</AlertDialogRoot>
);
const popup = container.querySelector('[role="alertdialog"]')!;
expect(popup.hasAttribute('data-open')).toBe(true);
});
it('wires aria-labelledby to Title id', () => {
const { container } = render(
<AlertDialogRoot open>
<AlertDialogPopup>
<AlertDialogTitle>Title</AlertDialogTitle>
</AlertDialogPopup>
</AlertDialogRoot>
);
const popup = container.querySelector('[role="alertdialog"]')!;
const title = popup.querySelector('h2')!;
expect(popup.getAttribute('aria-labelledby')).toBe(title.id);
});
it('wires aria-describedby to Description id', () => {
const { container } = render(
<AlertDialogRoot open>
<AlertDialogPopup>
<AlertDialogDescription>Desc</AlertDialogDescription>
</AlertDialogPopup>
</AlertDialogRoot>
);
const popup = container.querySelector('[role="alertdialog"]')!;
const desc = popup.querySelector('p')!;
expect(popup.getAttribute('aria-describedby')).toBe(desc.id);
});
it('forwards ref', () => {
const ref = createRef<HTMLDivElement>();
render(
<AlertDialogRoot open>
<AlertDialogPopup ref={ref}>content</AlertDialogPopup>
</AlertDialogRoot>
);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
});
});
describe('AlertDialogTitle', () => {
it('renders an h2 with the context-provided id', () => {
const { container } = render(
<AlertDialogRoot open>
<AlertDialogPopup>
<AlertDialogTitle>My Title</AlertDialogTitle>
</AlertDialogPopup>
</AlertDialogRoot>
);
const title = container.querySelector('h2')!;
expect(title.textContent).toBe('My Title');
expect(title.id).toMatch(/^alert-dialog-title-/);
});
it('forwards ref', () => {
const ref = createRef<HTMLHeadingElement>();
render(
<AlertDialogRoot open>
<AlertDialogPopup>
<AlertDialogTitle ref={ref}>Title</AlertDialogTitle>
</AlertDialogPopup>
</AlertDialogRoot>
);
expect(ref.current).toBeInstanceOf(HTMLHeadingElement);
});
});
describe('AlertDialogDescription', () => {
it('renders a p with the context-provided id', () => {
const { container } = render(
<AlertDialogRoot open>
<AlertDialogPopup>
<AlertDialogDescription>My Description</AlertDialogDescription>
</AlertDialogPopup>
</AlertDialogRoot>
);
const desc = container.querySelector('p')!;
expect(desc.textContent).toBe('My Description');
expect(desc.id).toMatch(/^alert-dialog-desc-/);
});
it('forwards ref', () => {
const ref = createRef<HTMLParagraphElement>();
render(
<AlertDialogRoot open>
<AlertDialogPopup>
<AlertDialogDescription ref={ref}>Desc</AlertDialogDescription>
</AlertDialogPopup>
</AlertDialogRoot>
);
expect(ref.current).toBeInstanceOf(HTMLParagraphElement);
});
});
describe('AlertDialogClose', () => {
it('renders a button', () => {
const { container } = render(
<AlertDialogRoot open>
<AlertDialogPopup>
<AlertDialogClose>OK</AlertDialogClose>
</AlertDialogPopup>
</AlertDialogRoot>
);
const button = container.querySelector('button')!;
expect(button.textContent).toBe('OK');
expect(button.type).toBe('button');
});
it('calls onOpenChange(false) on click', () => {
const onOpenChange = vi.fn();
const { container } = render(
<AlertDialogRoot open onOpenChange={onOpenChange}>
<AlertDialogPopup>
<AlertDialogClose>OK</AlertDialogClose>
</AlertDialogPopup>
</AlertDialogRoot>
);
container.querySelector('button')!.click();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('forwards ref', () => {
const ref = createRef<HTMLButtonElement>();
render(
<AlertDialogRoot open>
<AlertDialogPopup>
<AlertDialogClose ref={ref}>OK</AlertDialogClose>
</AlertDialogPopup>
</AlertDialogRoot>
);
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
});
});
@@ -12,22 +12,25 @@ interface ContextPartConfig<Props extends object, State extends object> {
tag: keyof React.JSX.IntrinsicElements;
useContext: () => { state: State; stateAttrMap: StateAttrMap<State> };
staticProps?: Partial<Props>;
/** Derive props from state on each render (e.g. `id` from a state field). */
getProps?: (state: State) => Record<string, unknown>;
}
export function createContextPart<Props extends UIComponentProps<any, any>, State extends object>(
config: ContextPartConfig<Props, State>
): ForwardRefExoticComponent<Props> {
const { displayName, tag, useContext, staticProps } = config;
const { displayName, tag, useContext, staticProps, getProps } = config;
const Component = forwardRef<HTMLElement, Props>(function ContextPart(componentProps, forwardedRef) {
const { render, className, style, ...elementProps } = componentProps;
const context = useContext();
const dynamicProps = getProps?.(context.state);
return renderElement(tag, { render, className, style } as renderElementFn.ComponentProps<State>, {
state: context.state,
stateAttrMap: context.stateAttrMap,
ref: forwardedRef,
props: staticProps ? [staticProps, elementProps] : [elementProps],
props: [staticProps, dynamicProps, elementProps].filter(Boolean),
});
});