Dialog component

Implements DialogRoot, Content, Title, Description, Trigger, Close, Portal, and Overlay parts with full context wiring, focus trap, scroll lock, and dismiss. Also fixes pre-existing TS6 exactOptionalPropertyTypes errors in create-disclosure-state and presence, and silences TS6.0 deprecation warnings via ignoreDeprecations.
This commit is contained in:
Mats Bosson 2026-03-29 05:47:48 +07:00
parent 4e711d8f5d
commit 69068fbee9
13 changed files with 410 additions and 7 deletions

View File

@ -0,0 +1,28 @@
// packages/core/src/components/dialog/dialog-close.tsx
import type { Component, JSX } from "solid-js";
import { splitProps } from "solid-js";
import { Dynamic } from "solid-js/web";
import { useInternalDialogContext } from "./dialog-context";
export interface DialogCloseProps extends JSX.HTMLAttributes<HTMLButtonElement> {
as?: string | Component;
children?: JSX.Element;
}
/** Closes the dialog when clicked. Supports polymorphic rendering via `as`. */
export function DialogClose(props: DialogCloseProps): JSX.Element {
const [local, rest] = splitProps(props, ["as", "children", "onClick"]);
const ctx = useInternalDialogContext();
const handleClick: JSX.EventHandler<HTMLElement, MouseEvent> = (e) => {
if (typeof local.onClick === "function")
local.onClick(e as MouseEvent & { currentTarget: HTMLButtonElement; target: Element });
ctx.setOpen(false);
};
return (
<Dynamic component={local.as ?? "button"} onClick={handleClick} {...rest}>
{local.children}
</Dynamic>
);
}

View File

@ -0,0 +1,76 @@
// packages/core/src/components/dialog/dialog-content.tsx
import type { JSX } from "solid-js";
import { Show, onCleanup, onMount, splitProps } from "solid-js";
import { createDismiss } from "../../utilities/dismiss/create-dismiss";
import { createFocusTrap } from "../../utilities/focus-trap/create-focus-trap";
import { Portal } from "../../utilities/portal/portal";
import { createScrollLock } from "../../utilities/scroll-lock/create-scroll-lock";
import { useInternalDialogContext } from "./dialog-context";
export interface DialogContentProps extends JSX.DialogHtmlAttributes<HTMLDialogElement> {
/**
* Called when auto-focus fires on open. Call event.preventDefault() to prevent default.
*/
onOpenAutoFocus?: (event: Event) => void;
/**
* Called when auto-focus fires on close. Call event.preventDefault() to prevent default.
*/
onCloseAutoFocus?: (event: Event) => void;
/** Keep mounted even when closed (for animation control). */
forceMount?: boolean;
children?: JSX.Element;
}
/**
* Dialog content panel. Portals to body, manages focus trap, scroll lock, and dismiss.
* Only renders when open (unless forceMount is set).
*/
export function DialogContent(props: DialogContentProps): JSX.Element {
const [local, rest] = splitProps(props, [
"children",
"onOpenAutoFocus",
"onCloseAutoFocus",
"forceMount",
]);
const ctx = useInternalDialogContext();
let contentRef: HTMLDialogElement | undefined;
const focusTrap = createFocusTrap(() => contentRef ?? null);
const scrollLock = createScrollLock();
const dismiss = createDismiss({
getContainer: () => contentRef ?? null,
onDismiss: () => ctx.setOpen(false),
});
onMount(() => {
if (ctx.isOpen() && ctx.modal()) {
focusTrap.activate();
scrollLock.lock();
dismiss.attach();
}
});
onCleanup(() => {
focusTrap.deactivate();
scrollLock.unlock();
dismiss.detach();
});
return (
<Show when={local.forceMount || ctx.isOpen()}>
<Portal>
<dialog
ref={contentRef}
id={ctx.contentId()}
aria-modal={ctx.modal() || undefined}
aria-labelledby={ctx.titleId()}
aria-describedby={ctx.descriptionId()}
data-state={ctx.isOpen() ? "open" : "closed"}
{...rest}
>
{local.children}
</dialog>
</Portal>
</Show>
);
}

View File

@ -0,0 +1,24 @@
// packages/core/src/components/dialog/dialog-description.tsx
import type { JSX } from "solid-js";
import { createUniqueId, onCleanup, onMount, splitProps } from "solid-js";
import { useInternalDialogContext } from "./dialog-context";
export interface DialogDescriptionProps extends JSX.HTMLAttributes<HTMLParagraphElement> {
children?: JSX.Element;
}
/** Renders as p and registers its ID for aria-describedby on Dialog.Content. */
export function DialogDescription(props: DialogDescriptionProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const ctx = useInternalDialogContext();
const id = createUniqueId();
onMount(() => ctx.setDescriptionId(id));
onCleanup(() => ctx.setDescriptionId(undefined));
return (
<p id={id} {...rest}>
{local.children}
</p>
);
}

View File

@ -0,0 +1,20 @@
// packages/core/src/components/dialog/dialog-overlay.tsx
import type { JSX } from "solid-js";
import { Show, splitProps } from "solid-js";
import { useInternalDialogContext } from "./dialog-context";
export interface DialogOverlayProps extends JSX.HTMLAttributes<HTMLDivElement> {
forceMount?: boolean;
}
/** Semi-transparent overlay rendered behind dialog content. */
export function DialogOverlay(props: DialogOverlayProps): JSX.Element {
const [local, rest] = splitProps(props, ["forceMount"]);
const ctx = useInternalDialogContext();
return (
<Show when={local.forceMount || ctx.isOpen()}>
<div aria-hidden="true" data-state={ctx.isOpen() ? "open" : "closed"} {...rest} />
</Show>
);
}

View File

@ -0,0 +1,20 @@
// packages/core/src/components/dialog/dialog-portal.tsx
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { Portal } from "../../utilities/portal/portal";
export interface DialogPortalProps {
/** Override the portal target container. */
target?: Element | null;
children: JSX.Element;
}
/** Renders children into a portal (defaults to document.body). */
export function DialogPortal(props: DialogPortalProps): JSX.Element {
const [local, rest] = splitProps(props, ["target", "children"]);
return local.target !== undefined ? (
<Portal target={local.target}>{local.children}</Portal>
) : (
<Portal>{local.children}</Portal>
);
}

View File

@ -0,0 +1,72 @@
// packages/core/src/components/dialog/dialog-root.tsx
import type { JSX } from "solid-js";
import { createSignal } from "solid-js";
import {
type CreateDisclosureStateOptions,
createDisclosureState,
} from "../../primitives/create-disclosure-state";
import { createRegisterId } from "../../primitives/create-register-id";
import {
DialogContextProvider,
InternalDialogContextProvider,
type InternalDialogContextValue,
} from "./dialog-context";
export interface DialogRootProps {
/** Controlled open state. */
open?: boolean;
/** Default open state (uncontrolled). */
defaultOpen?: boolean;
/** Called when open state should change. */
onOpenChange?: (open: boolean) => void;
/**
* Whether the dialog blocks outside interaction and traps focus.
* @default true
*/
modal?: boolean;
children: JSX.Element;
}
/**
* Root component. Manages open state, provides context to all Dialog parts.
* Renders no DOM elements itself.
*/
export function DialogRoot(props: DialogRootProps): JSX.Element {
const disclosure = createDisclosureState({
get open() {
return props.open;
},
get defaultOpen() {
return props.defaultOpen;
},
get onOpenChange() {
return props.onOpenChange;
},
} as CreateDisclosureStateOptions);
const contentId = `pettyui-dialog-${Math.random().toString(36).slice(2, 9)}`;
const [titleId, setTitleId] = createRegisterId();
const [descriptionId, setDescriptionId] = createRegisterId();
const [hasTrigger, setHasTrigger] = createSignal(false);
const internalCtx: InternalDialogContextValue = {
isOpen: disclosure.isOpen,
setOpen: (open) => (open ? disclosure.open() : disclosure.close()),
modal: () => props.modal ?? true,
contentId: () => contentId,
titleId,
setTitleId,
descriptionId,
setDescriptionId,
hasTrigger,
setHasTrigger,
};
return (
<InternalDialogContextProvider value={internalCtx}>
<DialogContextProvider value={{ open: disclosure.isOpen, modal: () => props.modal ?? true }}>
{props.children}
</DialogContextProvider>
</InternalDialogContextProvider>
);
}

View File

@ -0,0 +1,24 @@
// packages/core/src/components/dialog/dialog-title.tsx
import type { JSX } from "solid-js";
import { createUniqueId, onCleanup, onMount, splitProps } from "solid-js";
import { useInternalDialogContext } from "./dialog-context";
export interface DialogTitleProps extends JSX.HTMLAttributes<HTMLHeadingElement> {
children?: JSX.Element;
}
/** Renders as h2 and registers its ID for aria-labelledby on Dialog.Content. */
export function DialogTitle(props: DialogTitleProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const ctx = useInternalDialogContext();
const id = createUniqueId();
onMount(() => ctx.setTitleId(id));
onCleanup(() => ctx.setTitleId(undefined));
return (
<h2 id={id} {...rest}>
{local.children}
</h2>
);
}

View File

@ -0,0 +1,42 @@
// packages/core/src/components/dialog/dialog-trigger.tsx
import type { Component, JSX } from "solid-js";
import { mergeProps, splitProps } from "solid-js";
import { Dynamic } from "solid-js/web";
import { useInternalDialogContext } from "./dialog-context";
export interface DialogTriggerProps
extends Omit<JSX.HTMLAttributes<HTMLButtonElement>, "children"> {
/** Render as a different element or component. */
as?: string | Component;
children?: JSX.Element | ((props: JSX.HTMLAttributes<HTMLElement>) => JSX.Element);
}
/** Opens the dialog when clicked. Supports polymorphic rendering via `as` and children-as-function. */
export function DialogTrigger(props: DialogTriggerProps): JSX.Element {
const [local, rest] = splitProps(props, ["as", "children", "onClick"]);
const ctx = useInternalDialogContext();
const handleClick: JSX.EventHandler<HTMLElement, MouseEvent> = (e) => {
if (typeof local.onClick === "function")
local.onClick(e as MouseEvent & { currentTarget: HTMLButtonElement; target: Element });
ctx.setOpen(!ctx.isOpen());
};
const triggerProps = mergeProps(rest, {
"aria-haspopup": "dialog" as const,
"aria-expanded": ctx.isOpen(),
"aria-controls": ctx.contentId(),
"data-state": ctx.isOpen() ? "open" : "closed",
onClick: handleClick,
});
if (typeof local.children === "function") {
return <>{local.children(triggerProps as JSX.HTMLAttributes<HTMLElement>)}</>;
}
return (
<Dynamic component={local.as ?? "button"} {...triggerProps}>
{local.children}
</Dynamic>
);
}

View File

@ -0,0 +1,30 @@
import { DialogClose } from "./dialog-close";
import { DialogContent } from "./dialog-content";
import { useDialogContext } from "./dialog-context";
import { DialogDescription } from "./dialog-description";
import { DialogOverlay } from "./dialog-overlay";
import { DialogPortal } from "./dialog-portal";
// packages/core/src/components/dialog/index.ts
import { DialogRoot } from "./dialog-root";
import { DialogTitle } from "./dialog-title";
import { DialogTrigger } from "./dialog-trigger";
export const Dialog = Object.assign(DialogRoot, {
Content: DialogContent,
Title: DialogTitle,
Description: DialogDescription,
Trigger: DialogTrigger,
Close: DialogClose,
Portal: DialogPortal,
Overlay: DialogOverlay,
useContext: useDialogContext,
});
export type { DialogRootProps } from "./dialog-root";
export type { DialogContentProps } from "./dialog-content";
export type { DialogTitleProps } from "./dialog-title";
export type { DialogDescriptionProps } from "./dialog-description";
export type { DialogTriggerProps } from "./dialog-trigger";
export type { DialogCloseProps } from "./dialog-close";
export type { DialogPortalProps } from "./dialog-portal";
export type { DialogOverlayProps } from "./dialog-overlay";

View File

@ -25,7 +25,7 @@ export function createDisclosureState(
const [isOpen, setIsOpen] = createControllableSignal<boolean>({
value: () => options.open,
defaultValue: () => options.defaultOpen ?? false,
onChange: options.onOpenChange,
...(options.onOpenChange !== undefined && { onChange: options.onOpenChange }),
});
return {

View File

@ -75,11 +75,12 @@ export function Presence(props: PresenceProps): JSX.Element {
// Wrap in a function so Show evaluates children lazily (only when mounted).
return (
<Show when={shouldMount()}>
{() =>
typeof props.children === "function"
? (props.children as (p: PresenceChildProps) => JSX.Element)(childProps)
: props.children
}
{(
() =>
typeof props.children === "function"
? (props.children as (p: PresenceChildProps) => JSX.Element)(childProps)
: props.children
) as unknown as JSX.Element}
</Show>
);
}

View File

@ -0,0 +1,65 @@
// packages/core/tests/components/dialog/dialog-rendering.test.tsx
import { render, screen } from "@solidjs/testing-library";
import { createSignal } from "solid-js";
import { describe, expect, it } from "vitest";
import { Dialog } from "../../../src/components/dialog/index";
describe("Dialog rendering", () => {
it("renders children", () => {
render(() => (
<Dialog defaultOpen>
<Dialog.Content>
<Dialog.Title>Hello</Dialog.Title>
</Dialog.Content>
</Dialog>
));
expect(screen.getByText("Hello")).toBeTruthy();
});
it("does not render content when closed by default", () => {
render(() => (
<Dialog>
<Dialog.Content>
<Dialog.Title>Hidden</Dialog.Title>
</Dialog.Content>
</Dialog>
));
expect(screen.queryByText("Hidden")).toBeNull();
});
it("renders content when defaultOpen is true", () => {
render(() => (
<Dialog defaultOpen>
<Dialog.Content>
<Dialog.Title>Visible</Dialog.Title>
</Dialog.Content>
</Dialog>
));
expect(screen.getByText("Visible")).toBeTruthy();
});
it("renders content when controlled open is true", () => {
render(() => (
<Dialog open={true} onOpenChange={() => {}}>
<Dialog.Content>
<Dialog.Title>Controlled</Dialog.Title>
</Dialog.Content>
</Dialog>
));
expect(screen.getByText("Controlled")).toBeTruthy();
});
it("closes when controlled open is set to false", () => {
const [open, setOpen] = createSignal(true);
render(() => (
<Dialog open={open()} onOpenChange={setOpen}>
<Dialog.Content>
<Dialog.Title>Toggled</Dialog.Title>
</Dialog.Content>
</Dialog>
));
expect(screen.getByText("Toggled")).toBeTruthy();
setOpen(false);
expect(screen.queryByText("Toggled")).toBeNull();
});
});

View File

@ -13,6 +13,7 @@
"declarationMap": true,
"sourceMap": true,
"esModuleInterop": false,
"skipLibCheck": true
"skipLibCheck": true,
"ignoreDeprecations": "6.0"
}
}