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