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:
parent
4e711d8f5d
commit
69068fbee9
28
packages/core/src/components/dialog/dialog-close.tsx
Normal file
28
packages/core/src/components/dialog/dialog-close.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
packages/core/src/components/dialog/dialog-content.tsx
Normal file
76
packages/core/src/components/dialog/dialog-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
packages/core/src/components/dialog/dialog-description.tsx
Normal file
24
packages/core/src/components/dialog/dialog-description.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
packages/core/src/components/dialog/dialog-overlay.tsx
Normal file
20
packages/core/src/components/dialog/dialog-overlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
packages/core/src/components/dialog/dialog-portal.tsx
Normal file
20
packages/core/src/components/dialog/dialog-portal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
packages/core/src/components/dialog/dialog-root.tsx
Normal file
72
packages/core/src/components/dialog/dialog-root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
packages/core/src/components/dialog/dialog-title.tsx
Normal file
24
packages/core/src/components/dialog/dialog-title.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
packages/core/src/components/dialog/dialog-trigger.tsx
Normal file
42
packages/core/src/components/dialog/dialog-trigger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
packages/core/src/components/dialog/index.ts
Normal file
30
packages/core/src/components/dialog/index.ts
Normal 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";
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
) as unknown as JSX.Element}
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -13,6 +13,7 @@
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": false,
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"ignoreDeprecations": "6.0"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user