Switch to sub-path exports

Consumers use sub-path imports (e.g. "pettyui/slider") for tree-shaking.
Adds Drawer component + 15 sub-path entries to package.json exports.
This commit is contained in:
Mats Bosson 2026-03-29 09:15:53 +07:00
parent 824d12ba9a
commit 295dd1388c
13 changed files with 502 additions and 40 deletions

View File

@ -3,15 +3,7 @@
"version": "0.1.0",
"description": "AI-native headless UI component library for SolidJS",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"solid": "./src/index.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./dialog": {
"solid": "./src/components/dialog/index.ts",
"import": "./dist/dialog/index.js",
@ -46,7 +38,22 @@
"solid": "./src/utilities/visually-hidden/index.ts",
"import": "./dist/utilities/visually-hidden/index.js",
"require": "./dist/utilities/visually-hidden/index.cjs"
}
},
"./separator": { "solid": "./src/components/separator/index.ts", "import": "./dist/components/separator/index.js", "require": "./dist/components/separator/index.cjs" },
"./toggle": { "solid": "./src/components/toggle/index.ts", "import": "./dist/components/toggle/index.js", "require": "./dist/components/toggle/index.cjs" },
"./switch": { "solid": "./src/components/switch/index.ts", "import": "./dist/components/switch/index.js", "require": "./dist/components/switch/index.cjs" },
"./checkbox": { "solid": "./src/components/checkbox/index.ts", "import": "./dist/components/checkbox/index.js", "require": "./dist/components/checkbox/index.cjs" },
"./progress": { "solid": "./src/components/progress/index.ts", "import": "./dist/components/progress/index.js", "require": "./dist/components/progress/index.cjs" },
"./text-field": { "solid": "./src/components/text-field/index.ts", "import": "./dist/components/text-field/index.js", "require": "./dist/components/text-field/index.cjs" },
"./radio-group": { "solid": "./src/components/radio-group/index.ts", "import": "./dist/components/radio-group/index.js", "require": "./dist/components/radio-group/index.cjs" },
"./toggle-group": { "solid": "./src/components/toggle-group/index.ts", "import": "./dist/components/toggle-group/index.js", "require": "./dist/components/toggle-group/index.cjs" },
"./collapsible": { "solid": "./src/components/collapsible/index.ts", "import": "./dist/components/collapsible/index.js", "require": "./dist/components/collapsible/index.cjs" },
"./accordion": { "solid": "./src/components/accordion/index.ts", "import": "./dist/components/accordion/index.js", "require": "./dist/components/accordion/index.cjs" },
"./alert-dialog": { "solid": "./src/components/alert-dialog/index.ts", "import": "./dist/components/alert-dialog/index.js", "require": "./dist/components/alert-dialog/index.cjs" },
"./tabs": { "solid": "./src/components/tabs/index.ts", "import": "./dist/components/tabs/index.js", "require": "./dist/components/tabs/index.cjs" },
"./slider": { "solid": "./src/components/slider/index.ts", "import": "./dist/components/slider/index.js", "require": "./dist/components/slider/index.cjs" },
"./pagination": { "solid": "./src/components/pagination/index.ts", "import": "./dist/components/pagination/index.js", "require": "./dist/components/pagination/index.cjs" },
"./drawer": { "solid": "./src/components/drawer/index.ts", "import": "./dist/components/drawer/index.js", "require": "./dist/components/drawer/index.cjs" }
},
"scripts": {
"build": "tsdown",

View File

@ -0,0 +1,27 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useInternalDrawerContext } from "./drawer-context";
/** Props for Drawer.Close. */
export interface DrawerCloseProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
children?: JSX.Element;
}
/**
* Closes the Drawer when clicked.
*/
export function DrawerClose(props: DrawerCloseProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "onClick"]);
const ctx = useInternalDrawerContext();
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (e) => {
if (typeof local.onClick === "function") local.onClick(e);
ctx.setOpen(false);
};
return (
<button type="button" onClick={handleClick} {...rest}>
{local.children}
</button>
);
}

View File

@ -0,0 +1,65 @@
import type { JSX } from "solid-js";
import { Show, createEffect, onCleanup, splitProps } from "solid-js";
import { createDismiss } from "../../utilities/dismiss/create-dismiss";
import { createFocusTrap } from "../../utilities/focus-trap/create-focus-trap";
import { createScrollLock } from "../../utilities/scroll-lock/create-scroll-lock";
import { useInternalDrawerContext } from "./drawer-context";
/** Props for Drawer.Content. */
export interface DrawerContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Keep mounted even when closed (for animation control). */
forceMount?: boolean;
children?: JSX.Element;
}
/**
* Drawer content panel. Activates focus trap, scroll lock, and Escape/outside dismiss when open.
* Sets data-side to indicate which edge the drawer slides from.
*/
export function DrawerContent(props: DrawerContentProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "forceMount"]);
const ctx = useInternalDrawerContext();
let contentRef: HTMLDivElement | undefined;
const focusTrap = createFocusTrap(() => contentRef ?? null);
const scrollLock = createScrollLock();
const dismiss = createDismiss({
getContainer: () => contentRef ?? null,
onDismiss: () => ctx.setOpen(false),
});
createEffect(() => {
if (ctx.isOpen()) {
focusTrap.activate();
scrollLock.lock();
dismiss.attach();
} else {
focusTrap.deactivate();
scrollLock.unlock();
dismiss.detach();
}
onCleanup(() => {
focusTrap.deactivate();
scrollLock.unlock();
dismiss.detach();
});
});
return (
<Show when={local.forceMount || ctx.isOpen()}>
<div
ref={contentRef}
id={ctx.contentId()}
role="dialog"
aria-modal="true"
aria-labelledby={ctx.titleId() || undefined}
aria-describedby={ctx.descriptionId() || undefined}
data-state={ctx.isOpen() ? "open" : "closed"}
data-side={ctx.side()}
{...rest}
>
{local.children}
</div>
</Show>
);
}

View File

@ -0,0 +1,58 @@
import type { Accessor } from "solid-js";
import { createContext, useContext } from "solid-js";
/** Which edge the Drawer slides from. */
export type DrawerSide = "top" | "bottom" | "left" | "right";
/** Internal context shared between all Drawer parts. */
export interface InternalDrawerContextValue {
isOpen: Accessor<boolean>;
setOpen: (open: boolean) => void;
side: Accessor<DrawerSide>;
contentId: Accessor<string>;
titleId: Accessor<string | undefined>;
setTitleId: (id: string | undefined) => void;
descriptionId: Accessor<string | undefined>;
setDescriptionId: (id: string | undefined) => void;
}
const InternalDrawerContext = createContext<InternalDrawerContextValue>();
/**
* Returns the internal Drawer context. Throws if used outside <Drawer>.
*/
export function useInternalDrawerContext(): InternalDrawerContextValue {
const ctx = useContext(InternalDrawerContext);
if (!ctx) {
throw new Error(
"[PettyUI] Drawer parts must be used inside <Drawer>.\n" +
" Fix: Wrap Drawer.Content, Drawer.Trigger, etc. inside <Drawer>.",
);
}
return ctx;
}
export const InternalDrawerContextProvider = InternalDrawerContext.Provider;
/** Public context exposed to consumers via Drawer.useContext(). */
export interface DrawerContextValue {
/** Whether the drawer is currently open. */
open: Accessor<boolean>;
/** Which edge the drawer slides from. */
side: Accessor<DrawerSide>;
}
const DrawerContext = createContext<DrawerContextValue>();
/**
* Returns the public Drawer context. Throws if used outside <Drawer>.
*/
export function useDrawerContext(): DrawerContextValue {
const ctx = useContext(DrawerContext);
if (!ctx) {
throw new Error("[PettyUI] Drawer.useContext() called outside of <Drawer>.");
}
return ctx;
}
export const DrawerContextProvider = DrawerContext.Provider;

View File

@ -0,0 +1,24 @@
import type { JSX } from "solid-js";
import { createUniqueId, onCleanup, onMount, splitProps } from "solid-js";
import { useInternalDrawerContext } from "./drawer-context";
/** Props for Drawer.Description. */
export interface DrawerDescriptionProps extends JSX.HTMLAttributes<HTMLParagraphElement> {
children?: JSX.Element;
}
/**
* Description for the Drawer. Registers its ID for aria-describedby.
*/
export function DrawerDescription(props: DrawerDescriptionProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const ctx = useInternalDrawerContext();
const id = createUniqueId();
onMount(() => ctx.setDescriptionId(id));
onCleanup(() => ctx.setDescriptionId(undefined));
return (
<p id={id} {...rest}>
{local.children}
</p>
);
}

View File

@ -0,0 +1,22 @@
import type { JSX } from "solid-js";
import { Show, splitProps } from "solid-js";
import { useInternalDrawerContext } from "./drawer-context";
/** Props for Drawer.Overlay. */
export interface DrawerOverlayProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Keep mounted even when closed (for animation control). */
forceMount?: boolean;
}
/**
* Semi-transparent overlay behind Drawer content.
*/
export function DrawerOverlay(props: DrawerOverlayProps): JSX.Element {
const [local, rest] = splitProps(props, ["forceMount"]);
const ctx = useInternalDrawerContext();
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 @@
import type { JSX } from "solid-js";
import { Portal } from "../../utilities/portal/portal";
/** Props for Drawer.Portal. */
export interface DrawerPortalProps {
/** Optional target element; defaults to document.body. */
target?: Element | null;
children: JSX.Element;
}
/**
* Renders children into a portal (defaults to document.body).
*/
export function DrawerPortal(props: DrawerPortalProps): JSX.Element {
return props.target !== undefined ? (
<Portal target={props.target}>{props.children}</Portal>
) : (
<Portal>{props.children}</Portal>
);
}

View File

@ -0,0 +1,68 @@
import type { JSX } from "solid-js";
import { createUniqueId, splitProps } from "solid-js";
import {
type CreateDisclosureStateOptions,
createDisclosureState,
} from "../../primitives/create-disclosure-state";
import { createRegisterId } from "../../primitives/create-register-id";
import {
DrawerContextProvider,
type DrawerSide,
InternalDrawerContextProvider,
type InternalDrawerContextValue,
} from "./drawer-context";
/** Props for the Drawer root component. */
export interface DrawerRootProps {
/** Controls open state externally. */
open?: boolean;
/** Initial open state when uncontrolled. */
defaultOpen?: boolean;
/** Called when open state changes. */
onOpenChange?: (open: boolean) => void;
/** Which edge the drawer slides from. @default "right" */
side?: DrawerSide;
children: JSX.Element;
}
/**
* Root component for Drawer. Manages open state and provides context.
*/
export function DrawerRoot(props: DrawerRootProps): JSX.Element {
const [local] = splitProps(props, ["open", "defaultOpen", "onOpenChange", "side", "children"]);
const disclosure = createDisclosureState({
get open() {
return local.open;
},
get defaultOpen() {
return local.defaultOpen;
},
get onOpenChange() {
return local.onOpenChange;
},
} as CreateDisclosureStateOptions);
const contentId = createUniqueId();
const [titleId, setTitleId] = createRegisterId();
const [descriptionId, setDescriptionId] = createRegisterId();
const internalCtx: InternalDrawerContextValue = {
isOpen: disclosure.isOpen,
setOpen: (open) => (open ? disclosure.open() : disclosure.close()),
side: () => local.side ?? "right",
contentId: () => contentId,
titleId,
setTitleId,
descriptionId,
setDescriptionId,
};
return (
<InternalDrawerContextProvider value={internalCtx}>
<DrawerContextProvider value={{ open: disclosure.isOpen, side: () => local.side ?? "right" }}>
{local.children}
</DrawerContextProvider>
</InternalDrawerContextProvider>
);
}

View File

@ -0,0 +1,24 @@
import type { JSX } from "solid-js";
import { createUniqueId, onCleanup, onMount, splitProps } from "solid-js";
import { useInternalDrawerContext } from "./drawer-context";
/** Props for Drawer.Title. */
export interface DrawerTitleProps extends JSX.HTMLAttributes<HTMLHeadingElement> {
children?: JSX.Element;
}
/**
* Title for the Drawer. Registers its ID for aria-labelledby.
*/
export function DrawerTitle(props: DrawerTitleProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const ctx = useInternalDrawerContext();
const id = createUniqueId();
onMount(() => ctx.setTitleId(id));
onCleanup(() => ctx.setTitleId(undefined));
return (
<h2 id={id} {...rest}>
{local.children}
</h2>
);
}

View File

@ -0,0 +1,35 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useInternalDrawerContext } from "./drawer-context";
/** Props for Drawer.Trigger. */
export interface DrawerTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
children?: JSX.Element;
}
/**
* Opens the Drawer when clicked.
*/
export function DrawerTrigger(props: DrawerTriggerProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "onClick"]);
const ctx = useInternalDrawerContext();
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (e) => {
if (typeof local.onClick === "function") local.onClick(e);
ctx.setOpen(!ctx.isOpen());
};
return (
<button
type="button"
aria-haspopup="dialog"
aria-expanded={ctx.isOpen() ? "true" : "false"}
aria-controls={ctx.contentId()}
data-state={ctx.isOpen() ? "open" : "closed"}
onClick={handleClick}
{...rest}
>
{local.children}
</button>
);
}

View File

@ -0,0 +1,31 @@
import { DrawerClose } from "./drawer-close";
import { DrawerContent } from "./drawer-content";
import { useDrawerContext } from "./drawer-context";
import { DrawerDescription } from "./drawer-description";
import { DrawerOverlay } from "./drawer-overlay";
import { DrawerPortal } from "./drawer-portal";
import { DrawerRoot } from "./drawer-root";
import { DrawerTitle } from "./drawer-title";
import { DrawerTrigger } from "./drawer-trigger";
/** Compound Drawer component with sub-components attached as properties. */
export const Drawer = Object.assign(DrawerRoot, {
Content: DrawerContent,
Title: DrawerTitle,
Description: DrawerDescription,
Trigger: DrawerTrigger,
Close: DrawerClose,
Portal: DrawerPortal,
Overlay: DrawerOverlay,
useContext: useDrawerContext,
});
export type { DrawerRootProps } from "./drawer-root";
export type { DrawerContentProps } from "./drawer-content";
export type { DrawerTitleProps } from "./drawer-title";
export type { DrawerDescriptionProps } from "./drawer-description";
export type { DrawerTriggerProps } from "./drawer-trigger";
export type { DrawerCloseProps } from "./drawer-close";
export type { DrawerPortalProps } from "./drawer-portal";
export type { DrawerOverlayProps } from "./drawer-overlay";
export type { DrawerContextValue, DrawerSide } from "./drawer-context";

View File

@ -1,31 +0,0 @@
// packages/core/src/index.ts
// Main entry — re-exports everything for convenience.
// Prefer sub-path imports (e.g. "pettyui/dialog") for tree-shaking.
export { Dialog } from "./components/dialog/index";
export type { DialogRootProps } from "./components/dialog/dialog-root";
export type { DialogContentProps } from "./components/dialog/dialog-content";
export type { DialogTitleProps } from "./components/dialog/dialog-title";
export type { DialogDescriptionProps } from "./components/dialog/dialog-description";
export type { DialogTriggerProps } from "./components/dialog/dialog-trigger";
export type { DialogCloseProps } from "./components/dialog/dialog-close";
export type { DialogPortalProps } from "./components/dialog/dialog-portal";
export type { DialogOverlayProps } from "./components/dialog/dialog-overlay";
export { Presence } from "./utilities/presence/index";
export type { PresenceProps, PresenceChildProps } from "./utilities/presence/index";
export { Portal } from "./utilities/portal/index";
export type { PortalProps } from "./utilities/portal/index";
export { VisuallyHidden } from "./utilities/visually-hidden/index";
export type { VisuallyHiddenProps } from "./utilities/visually-hidden/index";
export { createFocusTrap } from "./utilities/focus-trap/index";
export type { FocusTrap } from "./utilities/focus-trap/index";
export { createScrollLock } from "./utilities/scroll-lock/index";
export type { ScrollLock } from "./utilities/scroll-lock/index";
export { createDismiss } from "./utilities/dismiss/index";
export type { CreateDismissOptions, Dismiss } from "./utilities/dismiss/index";

View File

@ -0,0 +1,112 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Drawer } from "../../../src/components/drawer/index";
describe("Drawer", () => {
it("closed by default", () => {
render(() => (
<Drawer>
<Drawer.Content>
<Drawer.Title>Title</Drawer.Title>
</Drawer.Content>
</Drawer>
));
expect(screen.queryByRole("dialog")).toBeNull();
});
it("opens with defaultOpen=true", () => {
render(() => (
<Drawer defaultOpen>
<Drawer.Content>
<Drawer.Title>Title</Drawer.Title>
</Drawer.Content>
</Drawer>
));
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("trigger click opens drawer", () => {
render(() => (
<Drawer>
<Drawer.Trigger>Open</Drawer.Trigger>
<Drawer.Content>
<Drawer.Title>Title</Drawer.Title>
</Drawer.Content>
</Drawer>
));
fireEvent.click(screen.getByText("Open"));
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("Close button closes drawer", () => {
render(() => (
<Drawer defaultOpen>
<Drawer.Content>
<Drawer.Title>Title</Drawer.Title>
<Drawer.Close>Close</Drawer.Close>
</Drawer.Content>
</Drawer>
));
fireEvent.click(screen.getByText("Close"));
expect(screen.queryByRole("dialog")).toBeNull();
});
it("Escape key closes drawer", () => {
render(() => (
<Drawer defaultOpen>
<Drawer.Content>
<Drawer.Title>Title</Drawer.Title>
</Drawer.Content>
</Drawer>
));
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("content has role=dialog and aria-modal", () => {
render(() => (
<Drawer defaultOpen>
<Drawer.Content>
<Drawer.Title>Title</Drawer.Title>
</Drawer.Content>
</Drawer>
));
const dialog = screen.getByRole("dialog");
expect(dialog.getAttribute("aria-modal")).toBe("true");
});
it("data-side defaults to right", () => {
render(() => (
<Drawer defaultOpen>
<Drawer.Content data-testid="content">
<Drawer.Title>Title</Drawer.Title>
</Drawer.Content>
</Drawer>
));
expect(screen.getByTestId("content").getAttribute("data-side")).toBe("right");
});
it("data-side reflects side prop", () => {
render(() => (
<Drawer defaultOpen side="left">
<Drawer.Content data-testid="content">
<Drawer.Title>Title</Drawer.Title>
</Drawer.Content>
</Drawer>
));
expect(screen.getByTestId("content").getAttribute("data-side")).toBe("left");
});
it("title linked via aria-labelledby", () => {
render(() => (
<Drawer defaultOpen>
<Drawer.Content>
<Drawer.Title>My Drawer</Drawer.Title>
</Drawer.Content>
</Drawer>
));
const dialog = screen.getByRole("dialog");
const title = screen.getByText("My Drawer");
expect(dialog.getAttribute("aria-labelledby")).toBe(title.id);
});
});