From 536c86d9d1b648f4e1b94b7a55b17735cc0ea03c Mon Sep 17 00:00:00 2001 From: rahim Date: Thu, 5 Mar 2026 15:25:53 -0800 Subject: [PATCH] feat(react): add alert dialog component (#739) --- packages/react/src/index.ts | 1 + .../ui/alert-dialog/alert-dialog-close.tsx | 34 +++ .../alert-dialog/alert-dialog-description.tsx | 21 ++ .../ui/alert-dialog/alert-dialog-popup.tsx | 45 ++++ .../src/ui/alert-dialog/alert-dialog-root.tsx | 89 +++++++ .../ui/alert-dialog/alert-dialog-title.tsx | 21 ++ .../react/src/ui/alert-dialog/context.tsx | 22 ++ .../react/src/ui/alert-dialog/index.parts.ts | 8 + packages/react/src/ui/alert-dialog/index.ts | 2 + .../alert-dialog/tests/alert-dialog.test.tsx | 219 ++++++++++++++++++ packages/react/src/ui/create-context-part.tsx | 7 +- 11 files changed, 467 insertions(+), 2 deletions(-) create mode 100644 packages/react/src/ui/alert-dialog/alert-dialog-close.tsx create mode 100644 packages/react/src/ui/alert-dialog/alert-dialog-description.tsx create mode 100644 packages/react/src/ui/alert-dialog/alert-dialog-popup.tsx create mode 100644 packages/react/src/ui/alert-dialog/alert-dialog-root.tsx create mode 100644 packages/react/src/ui/alert-dialog/alert-dialog-title.tsx create mode 100644 packages/react/src/ui/alert-dialog/context.tsx create mode 100644 packages/react/src/ui/alert-dialog/index.parts.ts create mode 100644 packages/react/src/ui/alert-dialog/index.ts create mode 100644 packages/react/src/ui/alert-dialog/tests/alert-dialog.test.tsx diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9c2835f7..d863290d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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'; diff --git a/packages/react/src/ui/alert-dialog/alert-dialog-close.tsx b/packages/react/src/ui/alert-dialog/alert-dialog-close.tsx new file mode 100644 index 00000000..a0783b12 --- /dev/null +++ b/packages/react/src/ui/alert-dialog/alert-dialog-close.tsx @@ -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(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; +} diff --git a/packages/react/src/ui/alert-dialog/alert-dialog-description.tsx b/packages/react/src/ui/alert-dialog/alert-dialog-description.tsx new file mode 100644 index 00000000..1c014a08 --- /dev/null +++ b/packages/react/src/ui/alert-dialog/alert-dialog-description.tsx @@ -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({ + displayName: 'AlertDialogDescription', + tag: 'p', + useContext: useAlertDialogContext, + getProps: (state) => ({ id: state.descriptionId }), +}); + +export namespace AlertDialogDescription { + export type Props = AlertDialogDescriptionProps; + export type State = AlertDialogCore.State; +} diff --git a/packages/react/src/ui/alert-dialog/alert-dialog-popup.tsx b/packages/react/src/ui/alert-dialog/alert-dialog-popup.tsx new file mode 100644 index 00000000..592ccbbc --- /dev/null +++ b/packages/react/src/ui/alert-dialog/alert-dialog-popup.tsx @@ -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(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; +} diff --git a/packages/react/src/ui/alert-dialog/alert-dialog-root.tsx b/packages/react/src/ui/alert-dialog/alert-dialog-root.tsx new file mode 100644 index 00000000..da82f35e --- /dev/null +++ b/packages/react/src/ui/alert-dialog/alert-dialog-root.tsx @@ -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 ( + + {children} + + ); +} + +export namespace AlertDialogRoot { + export type Props = AlertDialogRootProps; +} diff --git a/packages/react/src/ui/alert-dialog/alert-dialog-title.tsx b/packages/react/src/ui/alert-dialog/alert-dialog-title.tsx new file mode 100644 index 00000000..6f039844 --- /dev/null +++ b/packages/react/src/ui/alert-dialog/alert-dialog-title.tsx @@ -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({ + displayName: 'AlertDialogTitle', + tag: 'h2', + useContext: useAlertDialogContext, + getProps: (state) => ({ id: state.titleId }), +}); + +export namespace AlertDialogTitle { + export type Props = AlertDialogTitleProps; + export type State = AlertDialogCore.State; +} diff --git a/packages/react/src/ui/alert-dialog/context.tsx b/packages/react/src/ui/alert-dialog/context.tsx new file mode 100644 index 00000000..b97664b0 --- /dev/null +++ b/packages/react/src/ui/alert-dialog/context.tsx @@ -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; +} + +const AlertDialogContext = createContext(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; +} diff --git a/packages/react/src/ui/alert-dialog/index.parts.ts b/packages/react/src/ui/alert-dialog/index.parts.ts new file mode 100644 index 00000000..97095d52 --- /dev/null +++ b/packages/react/src/ui/alert-dialog/index.parts.ts @@ -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'; diff --git a/packages/react/src/ui/alert-dialog/index.ts b/packages/react/src/ui/alert-dialog/index.ts new file mode 100644 index 00000000..b0db4fcf --- /dev/null +++ b/packages/react/src/ui/alert-dialog/index.ts @@ -0,0 +1,2 @@ +export { type AlertDialogContextValue, useAlertDialogContext } from './context'; +export * as AlertDialog from './index.parts'; diff --git a/packages/react/src/ui/alert-dialog/tests/alert-dialog.test.tsx b/packages/react/src/ui/alert-dialog/tests/alert-dialog.test.tsx new file mode 100644 index 00000000..4977dd75 --- /dev/null +++ b/packages/react/src/ui/alert-dialog/tests/alert-dialog.test.tsx @@ -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( + + content + + ); + + expect(container.querySelector('[role="alertdialog"]')).not.toBeNull(); + }); + + it('supports uncontrolled mode with defaultOpen', () => { + const { container } = render( + + content + + ); + + expect(container.querySelector('[role="alertdialog"]')).not.toBeNull(); + }); + + it('renders null when closed', () => { + const { container } = render( + + content + + ); + + expect(container.querySelector('[role="alertdialog"]')).toBeNull(); + }); +}); + +describe('AlertDialogPopup', () => { + it('throws when used outside Root', () => { + expect(() => render()).toThrow( + 'AlertDialog compound components must be used within an AlertDialog.Root' + ); + }); + + it('renders with alertdialog role and aria-modal', () => { + const { container } = render( + + content + + ); + + 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( + + content + + ); + + const popup = container.querySelector('[role="alertdialog"]')!; + expect(popup.hasAttribute('data-open')).toBe(true); + }); + + it('wires aria-labelledby to Title id', () => { + const { container } = render( + + + Title + + + ); + + 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( + + + Desc + + + ); + + 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(); + + render( + + content + + ); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); +}); + +describe('AlertDialogTitle', () => { + it('renders an h2 with the context-provided id', () => { + const { container } = render( + + + My Title + + + ); + + const title = container.querySelector('h2')!; + expect(title.textContent).toBe('My Title'); + expect(title.id).toMatch(/^alert-dialog-title-/); + }); + + it('forwards ref', () => { + const ref = createRef(); + + render( + + + Title + + + ); + + expect(ref.current).toBeInstanceOf(HTMLHeadingElement); + }); +}); + +describe('AlertDialogDescription', () => { + it('renders a p with the context-provided id', () => { + const { container } = render( + + + My Description + + + ); + + const desc = container.querySelector('p')!; + expect(desc.textContent).toBe('My Description'); + expect(desc.id).toMatch(/^alert-dialog-desc-/); + }); + + it('forwards ref', () => { + const ref = createRef(); + + render( + + + Desc + + + ); + + expect(ref.current).toBeInstanceOf(HTMLParagraphElement); + }); +}); + +describe('AlertDialogClose', () => { + it('renders a button', () => { + const { container } = render( + + + OK + + + ); + + 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( + + + OK + + + ); + + container.querySelector('button')!.click(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('forwards ref', () => { + const ref = createRef(); + + render( + + + OK + + + ); + + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + }); +}); diff --git a/packages/react/src/ui/create-context-part.tsx b/packages/react/src/ui/create-context-part.tsx index f4bfbfff..851820d0 100644 --- a/packages/react/src/ui/create-context-part.tsx +++ b/packages/react/src/ui/create-context-part.tsx @@ -12,22 +12,25 @@ interface ContextPartConfig { tag: keyof React.JSX.IntrinsicElements; useContext: () => { state: State; stateAttrMap: StateAttrMap }; staticProps?: Partial; + /** Derive props from state on each render (e.g. `id` from a state field). */ + getProps?: (state: State) => Record; } export function createContextPart, State extends object>( config: ContextPartConfig ): ForwardRefExoticComponent { - const { displayName, tag, useContext, staticProps } = config; + const { displayName, tag, useContext, staticProps, getProps } = config; const Component = forwardRef(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: context.state, stateAttrMap: context.stateAttrMap, ref: forwardedRef, - props: staticProps ? [staticProps, elementProps] : [elementProps], + props: [staticProps, dynamicProps, elementProps].filter(Boolean), }); });