From 3388dbd37190d3b30c08f94e87444c31be9f0fd9 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 08:19:02 +0700 Subject: [PATCH] AlertDialog component Implements AlertDialog with compound component pattern (Root, Content, Title, Description, Trigger, Cancel, Action, Portal, Overlay). Content uses role=alertdialog, aria-modal, aria-labelledby/describedby, focus trap, and scroll lock. Does not dismiss on Escape key. 8 tests passing. --- .../alert-dialog/alert-dialog-action.tsx | 25 ++++ .../alert-dialog/alert-dialog-cancel.tsx | 25 ++++ .../alert-dialog/alert-dialog-content.tsx | 55 +++++++++ .../alert-dialog/alert-dialog-context.ts | 52 +++++++++ .../alert-dialog/alert-dialog-description.tsx | 22 ++++ .../alert-dialog/alert-dialog-overlay.tsx | 19 +++ .../alert-dialog/alert-dialog-portal.tsx | 17 +++ .../alert-dialog/alert-dialog-root.tsx | 59 ++++++++++ .../alert-dialog/alert-dialog-title.tsx | 22 ++++ .../alert-dialog/alert-dialog-trigger.tsx | 25 ++++ .../core/src/components/alert-dialog/index.ts | 33 ++++++ .../alert-dialog/alert-dialog.test.tsx | 108 ++++++++++++++++++ 12 files changed, 462 insertions(+) create mode 100644 packages/core/src/components/alert-dialog/alert-dialog-action.tsx create mode 100644 packages/core/src/components/alert-dialog/alert-dialog-cancel.tsx create mode 100644 packages/core/src/components/alert-dialog/alert-dialog-content.tsx create mode 100644 packages/core/src/components/alert-dialog/alert-dialog-context.ts create mode 100644 packages/core/src/components/alert-dialog/alert-dialog-description.tsx create mode 100644 packages/core/src/components/alert-dialog/alert-dialog-overlay.tsx create mode 100644 packages/core/src/components/alert-dialog/alert-dialog-portal.tsx create mode 100644 packages/core/src/components/alert-dialog/alert-dialog-root.tsx create mode 100644 packages/core/src/components/alert-dialog/alert-dialog-title.tsx create mode 100644 packages/core/src/components/alert-dialog/alert-dialog-trigger.tsx create mode 100644 packages/core/src/components/alert-dialog/index.ts create mode 100644 packages/core/tests/components/alert-dialog/alert-dialog.test.tsx diff --git a/packages/core/src/components/alert-dialog/alert-dialog-action.tsx b/packages/core/src/components/alert-dialog/alert-dialog-action.tsx new file mode 100644 index 0000000..9553b41 --- /dev/null +++ b/packages/core/src/components/alert-dialog/alert-dialog-action.tsx @@ -0,0 +1,25 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { useInternalAlertDialogContext } from "./alert-dialog-context"; + +/** Props for AlertDialog.Action. */ +export interface AlertDialogActionProps extends JSX.ButtonHTMLAttributes { + children?: JSX.Element; +} + +/** Confirms the action and closes the AlertDialog. */ +export function AlertDialogAction(props: AlertDialogActionProps): JSX.Element { + const [local, rest] = splitProps(props, ["children", "onClick"]); + const ctx = useInternalAlertDialogContext(); + + const handleClick: JSX.EventHandler = (e) => { + if (typeof local.onClick === "function") local.onClick(e); + ctx.setOpen(false); + }; + + return ( + + ); +} diff --git a/packages/core/src/components/alert-dialog/alert-dialog-cancel.tsx b/packages/core/src/components/alert-dialog/alert-dialog-cancel.tsx new file mode 100644 index 0000000..db50c08 --- /dev/null +++ b/packages/core/src/components/alert-dialog/alert-dialog-cancel.tsx @@ -0,0 +1,25 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { useInternalAlertDialogContext } from "./alert-dialog-context"; + +/** Props for AlertDialog.Cancel. */ +export interface AlertDialogCancelProps extends JSX.ButtonHTMLAttributes { + children?: JSX.Element; +} + +/** Cancels the action and closes the AlertDialog. */ +export function AlertDialogCancel(props: AlertDialogCancelProps): JSX.Element { + const [local, rest] = splitProps(props, ["children", "onClick"]); + const ctx = useInternalAlertDialogContext(); + + const handleClick: JSX.EventHandler = (e) => { + if (typeof local.onClick === "function") local.onClick(e); + ctx.setOpen(false); + }; + + return ( + + ); +} diff --git a/packages/core/src/components/alert-dialog/alert-dialog-content.tsx b/packages/core/src/components/alert-dialog/alert-dialog-content.tsx new file mode 100644 index 0000000..a63aafa --- /dev/null +++ b/packages/core/src/components/alert-dialog/alert-dialog-content.tsx @@ -0,0 +1,55 @@ +import type { JSX } from "solid-js"; +import { Show, createEffect, onCleanup, splitProps } from "solid-js"; +import { createFocusTrap } from "../../utilities/focus-trap/create-focus-trap"; +import { createScrollLock } from "../../utilities/scroll-lock/create-scroll-lock"; +import { useInternalAlertDialogContext } from "./alert-dialog-context"; + +/** Props for AlertDialog.Content. */ +export interface AlertDialogContentProps extends JSX.HTMLAttributes { + forceMount?: boolean; + children?: JSX.Element; +} + +/** + * AlertDialog content panel. Uses focus trap and scroll lock. + * Does NOT dismiss on Escape or outside pointer click (unlike Dialog). + */ +export function AlertDialogContent(props: AlertDialogContentProps): JSX.Element { + const [local, rest] = splitProps(props, ["children", "forceMount"]); + const ctx = useInternalAlertDialogContext(); + let contentRef: HTMLDivElement | undefined; + + const focusTrap = createFocusTrap(() => contentRef ?? null); + const scrollLock = createScrollLock(); + + createEffect(() => { + if (ctx.isOpen()) { + focusTrap.activate(); + scrollLock.lock(); + } else { + focusTrap.deactivate(); + scrollLock.unlock(); + } + onCleanup(() => { + focusTrap.deactivate(); + scrollLock.unlock(); + }); + }); + + return ( + +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/src/components/alert-dialog/alert-dialog-context.ts b/packages/core/src/components/alert-dialog/alert-dialog-context.ts new file mode 100644 index 0000000..c57e0bf --- /dev/null +++ b/packages/core/src/components/alert-dialog/alert-dialog-context.ts @@ -0,0 +1,52 @@ +import type { Accessor } from "solid-js"; +import { createContext, useContext } from "solid-js"; + +/** Internal context shared between all AlertDialog parts. */ +export interface InternalAlertDialogContextValue { + isOpen: Accessor; + setOpen: (open: boolean) => void; + contentId: Accessor; + titleId: Accessor; + setTitleId: (id: string | undefined) => void; + descriptionId: Accessor; + setDescriptionId: (id: string | undefined) => void; +} + +const InternalAlertDialogContext = createContext(); + +/** + * Returns the internal AlertDialog context. Throws if used outside . + */ +export function useInternalAlertDialogContext(): InternalAlertDialogContextValue { + const ctx = useContext(InternalAlertDialogContext); + if (!ctx) { + throw new Error( + "[PettyUI] AlertDialog parts must be used inside .\n" + + " Fix: Wrap AlertDialog.Content, AlertDialog.Trigger, etc. inside .", + ); + } + return ctx; +} + +export const InternalAlertDialogContextProvider = InternalAlertDialogContext.Provider; + +/** Public context exposed to consumers via AlertDialog.useContext(). */ +export interface AlertDialogContextValue { + /** Whether the alert dialog is currently open. */ + open: Accessor; +} + +const AlertDialogContext = createContext(); + +/** + * Returns the public AlertDialog context. Throws if used outside . + */ +export function useAlertDialogContext(): AlertDialogContextValue { + const ctx = useContext(AlertDialogContext); + if (!ctx) { + throw new Error("[PettyUI] AlertDialog.useContext() called outside of ."); + } + return ctx; +} + +export const AlertDialogContextProvider = AlertDialogContext.Provider; diff --git a/packages/core/src/components/alert-dialog/alert-dialog-description.tsx b/packages/core/src/components/alert-dialog/alert-dialog-description.tsx new file mode 100644 index 0000000..2a9b571 --- /dev/null +++ b/packages/core/src/components/alert-dialog/alert-dialog-description.tsx @@ -0,0 +1,22 @@ +import type { JSX } from "solid-js"; +import { createUniqueId, onCleanup, onMount, splitProps } from "solid-js"; +import { useInternalAlertDialogContext } from "./alert-dialog-context"; + +/** Props for AlertDialog.Description. */ +export interface AlertDialogDescriptionProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +/** Description for the AlertDialog. Registers its ID for aria-describedby. */ +export function AlertDialogDescription(props: AlertDialogDescriptionProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + const ctx = useInternalAlertDialogContext(); + const id = createUniqueId(); + onMount(() => ctx.setDescriptionId(id)); + onCleanup(() => ctx.setDescriptionId(undefined)); + return ( +

+ {local.children} +

+ ); +} diff --git a/packages/core/src/components/alert-dialog/alert-dialog-overlay.tsx b/packages/core/src/components/alert-dialog/alert-dialog-overlay.tsx new file mode 100644 index 0000000..a23c163 --- /dev/null +++ b/packages/core/src/components/alert-dialog/alert-dialog-overlay.tsx @@ -0,0 +1,19 @@ +import type { JSX } from "solid-js"; +import { Show, splitProps } from "solid-js"; +import { useInternalAlertDialogContext } from "./alert-dialog-context"; + +/** Props for AlertDialog.Overlay. */ +export interface AlertDialogOverlayProps extends JSX.HTMLAttributes { + forceMount?: boolean; +} + +/** Backdrop overlay behind AlertDialog content. */ +export function AlertDialogOverlay(props: AlertDialogOverlayProps): JSX.Element { + const [local, rest] = splitProps(props, ["forceMount"]); + const ctx = useInternalAlertDialogContext(); + return ( + +