# PettyUI Foundation Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Bootstrap the PettyUI monorepo with tooling, all internal primitives, all standalone utilities, and a fully working Dialog component that proves every architectural pattern. **Architecture:** Solid-native signals + context. Every component shares a set of internal primitives (`createControllableSignal`, `createDisclosureState`, `createRegisterId`). Standalone utilities (`Presence`, `createFocusTrap`, `createScrollLock`, `createDismiss`, `Portal`, `VisuallyHidden`) are exported independently. Dialog is the reference implementation — it exercises every primitive and utility. All patterns established here repeat verbatim across Wave 1 and Wave 2. **Tech Stack:** SolidJS 1.9.x, TypeScript 6.x, pnpm 10.x, Vite 8.x, tsdown 0.x, Vitest 4.x, @solidjs/testing-library 0.8.x, Biome, ESLint + eslint-plugin-solid, @floating-ui/dom 1.7.x, Zod 4.x (build-time only) --- ## File Map ``` pettyui/ ├── package.json — root workspace config ├── pnpm-workspace.yaml ├── tsconfig.base.json — shared TS config ├── biome.json — Biome formatting + lint ├── .eslintrc.cjs — minimal ESLint (solid rules only) │ ├── packages/ │ └── core/ │ ├── package.json │ ├── tsconfig.json │ ├── tsdown.config.ts │ ├── vite.config.ts — for tests │ │ │ ├── src/ │ │ ├── primitives/ — internal, NOT exported │ │ │ ├── create-controllable-signal.ts │ │ │ ├── create-disclosure-state.ts │ │ │ ├── create-register-id.ts │ │ │ └── index.ts — barrel (internal only) │ │ │ │ │ ├── utilities/ — exported standalone │ │ │ ├── presence/ │ │ │ │ ├── presence.tsx │ │ │ │ └── index.ts │ │ │ ├── focus-trap/ │ │ │ │ ├── create-focus-trap.ts │ │ │ │ └── index.ts │ │ │ ├── scroll-lock/ │ │ │ │ ├── create-scroll-lock.ts │ │ │ │ └── index.ts │ │ │ ├── dismiss/ │ │ │ │ ├── create-dismiss.ts │ │ │ │ └── index.ts │ │ │ ├── portal/ │ │ │ │ ├── portal.tsx │ │ │ │ └── index.ts │ │ │ └── visually-hidden/ │ │ │ ├── visually-hidden.tsx │ │ │ └── index.ts │ │ │ │ │ ├── components/ │ │ │ └── dialog/ │ │ │ ├── dialog-context.ts — dual context definitions + types │ │ │ ├── dialog-root.tsx — Root: signal + context provider │ │ │ ├── dialog-trigger.tsx — Trigger: opens dialog │ │ │ ├── dialog-portal.tsx — Portal: wraps utility portal │ │ │ ├── dialog-overlay.tsx — Overlay: backdrop │ │ │ ├── dialog-content.tsx — Content: focus trap + ARIA │ │ │ ├── dialog-title.tsx — Title: registers ID │ │ │ ├── dialog-description.tsx — Description: registers ID │ │ │ ├── dialog-close.tsx — Close: calls setOpen(false) │ │ │ └── index.ts — assembles Dialog object │ │ │ │ │ └── index.ts — main package entry │ │ │ └── tests/ │ ├── primitives/ │ │ ├── create-controllable-signal.test.ts │ │ ├── create-disclosure-state.test.ts │ │ └── create-register-id.test.ts │ ├── utilities/ │ │ ├── presence.test.tsx │ │ ├── focus-trap.test.ts │ │ ├── scroll-lock.test.ts │ │ ├── dismiss.test.ts │ │ └── portal.test.tsx │ └── components/ │ └── dialog/ │ ├── dialog-rendering.test.tsx │ ├── dialog-keyboard.test.tsx │ ├── dialog-aria.test.tsx │ └── dialog-controlled.test.tsx ``` --- ## Task 1: Repository Scaffold **Files:** - Create: `package.json` - Create: `pnpm-workspace.yaml` - Create: `tsconfig.base.json` - Create: `biome.json` - Create: `.eslintrc.cjs` - Create: `.gitignore` - Create: `packages/core/package.json` - Create: `packages/core/tsconfig.json` - Create: `packages/core/vite.config.ts` - Create: `packages/core/tsdown.config.ts` - [ ] **Step 1: Create root package.json** ```json { "name": "pettyui-monorepo", "private": true, "scripts": { "build": "pnpm -r build", "test": "pnpm -r test", "lint": "biome check . && eslint packages --ext .ts,.tsx", "format": "biome format --write .", "typecheck": "pnpm -r typecheck" }, "devDependencies": { "@biomejs/biome": "^1.9.0", "eslint": "^9.0.0", "eslint-plugin-solid": "^0.14.0", "typescript": "^6.0.2", "zod": "^4.3.6" } } ``` - [ ] **Step 2: Create pnpm-workspace.yaml** ```yaml packages: - "packages/*" ``` - [ ] **Step 3: Create tsconfig.base.json** ```json { "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "jsx": "preserve", "jsxImportSource": "solid-js", "strict": true, "exactOptionalPropertyTypes": true, "noUncheckedIndexedAccess": true, "verbatimModuleSyntax": true, "declaration": true, "declarationMap": true, "sourceMap": true, "esModuleInterop": false, "skipLibCheck": true } } ``` - [ ] **Step 4: Create biome.json** ```json { "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json", "organizeImports": { "enabled": true }, "linter": { "enabled": true, "rules": { "recommended": true, "suspicious": { "noExplicitAny": "error" }, "style": { "useConst": "error", "useTemplate": "error" } } }, "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 }, "javascript": { "formatter": { "quoteStyle": "double", "semicolons": "always", "trailingCommas": "all" } }, "files": { "ignore": ["node_modules", "dist", ".tsdown", "coverage"] } } ``` - [ ] **Step 5: Create .eslintrc.cjs (Solid rules only)** ```js /** @type {import('eslint').Linter.Config} */ module.exports = { root: true, parser: "@typescript-eslint/parser", parserOptions: { ecmaVersion: 2020, sourceType: "module", }, plugins: ["solid"], extends: ["plugin:solid/typescript"], rules: { // Only Solid-specific rules — everything else handled by Biome "solid/reactivity": "error", "solid/no-destructure": "error", "solid/prefer-for": "warn", "solid/no-react-deps": "error", "solid/no-react-specific-props": "error", }, }; ``` - [ ] **Step 6: Create .gitignore** ``` node_modules/ dist/ .tsdown/ coverage/ *.tsbuildinfo .superpowers/ ``` - [ ] **Step 7: Create packages/core/package.json** ```json { "name": "pettyui", "version": "0.1.0", "description": "AI-native headless UI component library for SolidJS", "type": "module", "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", "require": "./dist/dialog/index.cjs" }, "./presence": { "solid": "./src/utilities/presence/index.ts", "import": "./dist/utilities/presence/index.js", "require": "./dist/utilities/presence/index.cjs" }, "./focus-trap": { "solid": "./src/utilities/focus-trap/index.ts", "import": "./dist/utilities/focus-trap/index.js", "require": "./dist/utilities/focus-trap/index.cjs" }, "./scroll-lock": { "solid": "./src/utilities/scroll-lock/index.ts", "import": "./dist/utilities/scroll-lock/index.js", "require": "./dist/utilities/scroll-lock/index.cjs" }, "./dismiss": { "solid": "./src/utilities/dismiss/index.ts", "import": "./dist/utilities/dismiss/index.js", "require": "./dist/utilities/dismiss/index.cjs" }, "./portal": { "solid": "./src/utilities/portal/index.ts", "import": "./dist/utilities/portal/index.js", "require": "./dist/utilities/portal/index.cjs" }, "./visually-hidden": { "solid": "./src/utilities/visually-hidden/index.ts", "import": "./dist/utilities/visually-hidden/index.js", "require": "./dist/utilities/visually-hidden/index.cjs" } }, "scripts": { "build": "tsdown", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit" }, "peerDependencies": { "solid-js": "^1.9.0" }, "dependencies": { "@floating-ui/dom": "^1.7.6" }, "devDependencies": { "@solidjs/testing-library": "^0.8.10", "@testing-library/jest-dom": "^6.0.0", "@testing-library/user-event": "^14.0.0", "jsdom": "^26.0.0", "solid-js": "^1.9.12", "tsdown": "^0.21.7", "vite": "^8.0.3", "vite-plugin-solid": "^2.11.11", "vitest": "^4.1.2" } } ``` - [ ] **Step 8: Create packages/core/tsconfig.json** ```json { "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "baseUrl": ".", "paths": {} }, "include": ["src"], "exclude": ["node_modules", "dist", "tests"] } ``` - [ ] **Step 9: Create packages/core/vite.config.ts** ```ts import { defineConfig } from "vite"; import solid from "vite-plugin-solid"; export default defineConfig({ plugins: [solid()], test: { environment: "jsdom", globals: true, setupFiles: ["./tests/setup.ts"], transformMode: { web: [/\.[jt]sx?$/] }, }, }); ``` - [ ] **Step 10: Create packages/core/tests/setup.ts** ```ts import "@testing-library/jest-dom"; ``` - [ ] **Step 11: Create packages/core/tsdown.config.ts** ```ts import { defineConfig } from "tsdown"; export default defineConfig({ entry: { index: "src/index.ts", "dialog/index": "src/components/dialog/index.ts", "presence/index": "src/utilities/presence/index.ts", "focus-trap/index": "src/utilities/focus-trap/index.ts", "scroll-lock/index": "src/utilities/scroll-lock/index.ts", "dismiss/index": "src/utilities/dismiss/index.ts", "portal/index": "src/utilities/portal/index.ts", "visually-hidden/index": "src/utilities/visually-hidden/index.ts", }, format: ["esm", "cjs"], dts: true, clean: true, sourcemap: true, external: ["solid-js", "solid-js/web", "solid-js/store"], }); ``` - [ ] **Step 12: Install dependencies and verify** ```bash cd /Users/matsbosson/Documents/StayThree/PettyUI pnpm install ``` Expected: lock file created, no errors. - [ ] **Step 13: Commit** ```bash git init git add . git commit -m "chore: scaffold monorepo with tooling" ``` --- ## Task 2: Internal Primitives — `createControllableSignal` **Files:** - Create: `packages/core/src/primitives/create-controllable-signal.ts` - Create: `packages/core/tests/primitives/create-controllable-signal.test.ts` - [ ] **Step 1: Write the failing test** ```ts // packages/core/tests/primitives/create-controllable-signal.test.ts import { createRoot, createSignal } from "solid-js"; import { describe, expect, it, vi } from "vitest"; import { createControllableSignal } from "../../src/primitives/create-controllable-signal"; describe("createControllableSignal", () => { it("uses defaultValue when value accessor returns undefined (uncontrolled)", () => { createRoot((dispose) => { const [get] = createControllableSignal({ value: () => undefined, defaultValue: () => false, }); expect(get()).toBe(false); dispose(); }); }); it("uses value accessor when provided (controlled)", () => { createRoot((dispose) => { const [get] = createControllableSignal({ value: () => true, defaultValue: () => false, }); expect(get()).toBe(true); dispose(); }); }); it("calls onChange when setter is called in uncontrolled mode", () => { createRoot((dispose) => { const onChange = vi.fn(); const [, set] = createControllableSignal({ value: () => undefined, defaultValue: () => false, onChange, }); set(true); expect(onChange).toHaveBeenCalledWith(true); dispose(); }); }); it("updates internal signal when setter called in uncontrolled mode", () => { createRoot((dispose) => { const [get, set] = createControllableSignal({ value: () => undefined, defaultValue: () => "initial", }); set("updated"); expect(get()).toBe("updated"); dispose(); }); }); it("does not update internal signal in controlled mode — external value is source of truth", () => { createRoot((dispose) => { const [externalValue] = createSignal("controlled"); const [get, set] = createControllableSignal({ value: externalValue, defaultValue: () => "default", }); set("ignored"); // Still reflects external value since we're controlled expect(get()).toBe("controlled"); dispose(); }); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd packages/core && pnpm vitest run tests/primitives/create-controllable-signal.test.ts ``` Expected: FAIL — `Cannot find module '../../src/primitives/create-controllable-signal'` - [ ] **Step 3: Implement createControllableSignal** ```ts // packages/core/src/primitives/create-controllable-signal.ts import { type Accessor, createSignal } from "solid-js"; export interface CreateControllableSignalOptions { /** Returns the controlled value, or undefined if uncontrolled. */ value: Accessor; /** Default value used when uncontrolled. */ defaultValue: Accessor; /** Called whenever the value changes (both modes). */ onChange?: (value: T) => void; } /** * Handles controlled vs uncontrolled state for any stateful component. * When `value()` is not undefined, the component is controlled — the external * value is the source of truth. Otherwise, an internal signal manages state. */ export function createControllableSignal( options: CreateControllableSignalOptions, ): [Accessor, (value: T) => void] { const [internalValue, setInternalValue] = createSignal(options.defaultValue()); const get: Accessor = () => { const controlled = options.value(); return controlled !== undefined ? controlled : internalValue(); }; const set = (value: T) => { const isControlled = options.value() !== undefined; if (!isControlled) { setInternalValue(() => value); } options.onChange?.(value); }; return [get, set]; } ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd packages/core && pnpm vitest run tests/primitives/create-controllable-signal.test.ts ``` Expected: PASS — 5 tests passed. - [ ] **Step 5: Commit** ```bash git add packages/core/src/primitives/create-controllable-signal.ts packages/core/tests/primitives/create-controllable-signal.test.ts git commit -m "feat: add createControllableSignal primitive" ``` --- ## Task 3: Internal Primitives — `createDisclosureState` **Files:** - Create: `packages/core/src/primitives/create-disclosure-state.ts` - Create: `packages/core/tests/primitives/create-disclosure-state.test.ts` - [ ] **Step 1: Write the failing test** ```ts // packages/core/tests/primitives/create-disclosure-state.test.ts import { createRoot } from "solid-js"; import { describe, expect, it, vi } from "vitest"; import { createDisclosureState } from "../../src/primitives/create-disclosure-state"; describe("createDisclosureState", () => { it("starts closed by default", () => { createRoot((dispose) => { const state = createDisclosureState({}); expect(state.isOpen()).toBe(false); dispose(); }); }); it("respects defaultOpen", () => { createRoot((dispose) => { const state = createDisclosureState({ defaultOpen: true }); expect(state.isOpen()).toBe(true); dispose(); }); }); it("open() sets state to true", () => { createRoot((dispose) => { const state = createDisclosureState({}); state.open(); expect(state.isOpen()).toBe(true); dispose(); }); }); it("close() sets state to false", () => { createRoot((dispose) => { const state = createDisclosureState({ defaultOpen: true }); state.close(); expect(state.isOpen()).toBe(false); dispose(); }); }); it("toggle() flips state", () => { createRoot((dispose) => { const state = createDisclosureState({}); state.toggle(); expect(state.isOpen()).toBe(true); state.toggle(); expect(state.isOpen()).toBe(false); dispose(); }); }); it("calls onOpenChange when state changes", () => { createRoot((dispose) => { const onChange = vi.fn(); const state = createDisclosureState({ onOpenChange: onChange }); state.open(); expect(onChange).toHaveBeenCalledWith(true); state.close(); expect(onChange).toHaveBeenCalledWith(false); dispose(); }); }); it("respects controlled open prop", () => { createRoot((dispose) => { const state = createDisclosureState({ open: true }); expect(state.isOpen()).toBe(true); // Calling close() fires onChange but does not change internal state const onChange = vi.fn(); const controlled = createDisclosureState({ open: true, onOpenChange: onChange }); controlled.close(); expect(controlled.isOpen()).toBe(true); // still controlled expect(onChange).toHaveBeenCalledWith(false); dispose(); }); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd packages/core && pnpm vitest run tests/primitives/create-disclosure-state.test.ts ``` Expected: FAIL — module not found. - [ ] **Step 3: Implement createDisclosureState** ```ts // packages/core/src/primitives/create-disclosure-state.ts import type { Accessor } from "solid-js"; import { createControllableSignal } from "./create-controllable-signal"; export interface CreateDisclosureStateOptions { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; } export interface DisclosureState { isOpen: Accessor; open: () => void; close: () => void; toggle: () => void; } /** * Shared open/close state for all disclosure components (Dialog, Popover, * Tooltip, Collapsible, etc.). Wraps createControllableSignal with * convenience open/close/toggle methods. */ export function createDisclosureState( options: CreateDisclosureStateOptions, ): DisclosureState { const [isOpen, setIsOpen] = createControllableSignal({ value: () => options.open, defaultValue: () => options.defaultOpen ?? false, onChange: options.onOpenChange, }); return { isOpen, open: () => setIsOpen(true), close: () => setIsOpen(false), toggle: () => setIsOpen(!isOpen()), }; } ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd packages/core && pnpm vitest run tests/primitives/create-disclosure-state.test.ts ``` Expected: PASS — 7 tests passed. - [ ] **Step 5: Commit** ```bash git add packages/core/src/primitives/create-disclosure-state.ts packages/core/tests/primitives/create-disclosure-state.test.ts git commit -m "feat: add createDisclosureState primitive" ``` --- ## Task 4: Internal Primitives — `createRegisterId` **Files:** - Create: `packages/core/src/primitives/create-register-id.ts` - Create: `packages/core/tests/primitives/create-register-id.test.ts` - Create: `packages/core/src/primitives/index.ts` - [ ] **Step 1: Write the failing test** ```ts // packages/core/tests/primitives/create-register-id.test.ts import { createRoot } from "solid-js"; import { describe, expect, it } from "vitest"; import { createRegisterId } from "../../src/primitives/create-register-id"; describe("createRegisterId", () => { it("starts with undefined", () => { createRoot((dispose) => { const [getId] = createRegisterId(); expect(getId()).toBeUndefined(); dispose(); }); }); it("returns registered id after set", () => { createRoot((dispose) => { const [getId, setId] = createRegisterId(); setId("my-id"); expect(getId()).toBe("my-id"); dispose(); }); }); it("returns undefined after clearing", () => { createRoot((dispose) => { const [getId, setId] = createRegisterId(); setId("my-id"); setId(undefined); expect(getId()).toBeUndefined(); dispose(); }); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd packages/core && pnpm vitest run tests/primitives/create-register-id.test.ts ``` Expected: FAIL — module not found. - [ ] **Step 3: Implement createRegisterId** ```ts // packages/core/src/primitives/create-register-id.ts import type { Accessor } from "solid-js"; import { createSignal } from "solid-js"; /** * Creates a reactive slot for an element's ID. * Child parts (e.g. Dialog.Title) call setId on mount and clear it on cleanup. * Parent parts (e.g. Dialog.Content) read getId to set aria-labelledby etc. */ export function createRegisterId(): [ Accessor, (id: string | undefined) => void, ] { const [id, setId] = createSignal(undefined); return [id, setId]; } ``` - [ ] **Step 4: Create primitives barrel (internal only)** ```ts // packages/core/src/primitives/index.ts export { createControllableSignal } from "./create-controllable-signal"; export type { CreateControllableSignalOptions } from "./create-controllable-signal"; export { createDisclosureState } from "./create-disclosure-state"; export type { CreateDisclosureStateOptions, DisclosureState } from "./create-disclosure-state"; export { createRegisterId } from "./create-register-id"; ``` - [ ] **Step 5: Run test to verify it passes** ```bash cd packages/core && pnpm vitest run tests/primitives/ ``` Expected: PASS — all 3 test files, 15 tests total. - [ ] **Step 6: Commit** ```bash git add packages/core/src/primitives/ git commit -m "feat: add createRegisterId primitive and primitives barrel" ``` --- ## Task 5: Utility — `VisuallyHidden` and `Portal` **Files:** - Create: `packages/core/src/utilities/visually-hidden/visually-hidden.tsx` - Create: `packages/core/src/utilities/visually-hidden/index.ts` - Create: `packages/core/src/utilities/portal/portal.tsx` - Create: `packages/core/src/utilities/portal/index.ts` - Create: `packages/core/tests/utilities/portal.test.tsx` - [ ] **Step 1: Write failing test for Portal** ```tsx // packages/core/tests/utilities/portal.test.tsx import { render } from "@solidjs/testing-library"; import { describe, expect, it } from "vitest"; import { Portal } from "../../src/utilities/portal/portal"; describe("Portal", () => { it("renders children into document.body by default", () => { render(() =>
hello
); // Content should be in document.body, not the render container expect(document.body.querySelector("[data-testid='portal-content']")).toBeTruthy(); }); it("renders children into a custom target", () => { const target = document.createElement("div"); document.body.appendChild(target); render(() => (
hello
)); expect(target.querySelector("[data-testid='custom-portal']")).toBeTruthy(); document.body.removeChild(target); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd packages/core && pnpm vitest run tests/utilities/portal.test.tsx ``` Expected: FAIL — module not found. - [ ] **Step 3: Implement VisuallyHidden** ```tsx // packages/core/src/utilities/visually-hidden/visually-hidden.tsx import type { JSX } from "solid-js"; import { splitProps } from "solid-js"; export interface VisuallyHiddenProps extends JSX.HTMLAttributes { children?: JSX.Element; } const visuallyHiddenStyle: JSX.CSSProperties = { position: "absolute", border: "0", width: "1px", height: "1px", padding: "0", margin: "-1px", overflow: "hidden", clip: "rect(0, 0, 0, 0)", "white-space": "nowrap", "word-wrap": "normal", }; /** * Renders content visually hidden but accessible to screen readers. */ export function VisuallyHidden(props: VisuallyHiddenProps): JSX.Element { const [local, rest] = splitProps(props, ["children", "style"]); return ( {local.children} ); } ``` - [ ] **Step 4: Implement Portal** ```tsx // packages/core/src/utilities/portal/portal.tsx import type { JSX } from "solid-js"; import { Portal as SolidPortal } from "solid-js/web"; import { isServer } from "solid-js/web"; export interface PortalProps { /** Target container. Defaults to document.body. */ target?: Element | null; children: JSX.Element; } /** * SSR-safe portal. During SSR, renders content inline. * On the client, moves content to the target container. */ export function Portal(props: PortalProps): JSX.Element { if (isServer) { return <>{props.children}; } return ( {props.children} ); } ``` - [ ] **Step 5: Create index files for both utilities** ```ts // packages/core/src/utilities/visually-hidden/index.ts export { VisuallyHidden } from "./visually-hidden"; export type { VisuallyHiddenProps } from "./visually-hidden"; ``` ```ts // packages/core/src/utilities/portal/index.ts export { Portal } from "./portal"; export type { PortalProps } from "./portal"; ``` - [ ] **Step 6: Run tests to verify they pass** ```bash cd packages/core && pnpm vitest run tests/utilities/portal.test.tsx ``` Expected: PASS — 2 tests passed. - [ ] **Step 7: Commit** ```bash git add packages/core/src/utilities/visually-hidden/ packages/core/src/utilities/portal/ packages/core/tests/utilities/portal.test.tsx git commit -m "feat: add VisuallyHidden and Portal utilities" ``` --- ## Task 6: Utility — `createFocusTrap` **Files:** - Create: `packages/core/src/utilities/focus-trap/create-focus-trap.ts` - Create: `packages/core/src/utilities/focus-trap/index.ts` - Create: `packages/core/tests/utilities/focus-trap.test.ts` - [ ] **Step 1: Write the failing test** ```ts // packages/core/tests/utilities/focus-trap.test.ts import { createRoot } from "solid-js"; import { describe, expect, it, vi } from "vitest"; import { createFocusTrap } from "../../src/utilities/focus-trap/create-focus-trap"; describe("createFocusTrap", () => { it("focuses the first focusable element when activated", () => { const container = document.createElement("div"); const button1 = document.createElement("button"); const button2 = document.createElement("button"); button1.textContent = "First"; button2.textContent = "Second"; container.appendChild(button1); container.appendChild(button2); document.body.appendChild(container); createRoot((dispose) => { const trap = createFocusTrap(() => container); trap.activate(); expect(document.activeElement).toBe(button1); trap.deactivate(); dispose(); }); document.body.removeChild(container); }); it("does nothing when container is null", () => { createRoot((dispose) => { const trap = createFocusTrap(() => null); // Should not throw expect(() => trap.activate()).not.toThrow(); dispose(); }); }); it("returns focus to previously focused element on deactivate", () => { const outside = document.createElement("button"); outside.textContent = "Outside"; document.body.appendChild(outside); outside.focus(); const container = document.createElement("div"); const inner = document.createElement("button"); inner.textContent = "Inner"; container.appendChild(inner); document.body.appendChild(container); createRoot((dispose) => { const trap = createFocusTrap(() => container); trap.activate(); expect(document.activeElement).toBe(inner); trap.deactivate(); expect(document.activeElement).toBe(outside); dispose(); }); document.body.removeChild(outside); document.body.removeChild(container); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd packages/core && pnpm vitest run tests/utilities/focus-trap.test.ts ``` Expected: FAIL — module not found. - [ ] **Step 3: Implement createFocusTrap** ```ts // packages/core/src/utilities/focus-trap/create-focus-trap.ts import type { Accessor } from "solid-js"; const FOCUSABLE_SELECTORS = [ "a[href]", "button:not([disabled])", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])", "[tabindex]:not([tabindex='-1'])", "details > summary", ].join(","); function getFocusableElements(container: HTMLElement): HTMLElement[] { return Array.from(container.querySelectorAll(FOCUSABLE_SELECTORS)); } export interface FocusTrap { activate: () => void; deactivate: () => void; } /** * Creates a focus trap that confines Tab focus within a container. * Restores focus to the previously focused element on deactivate. */ export function createFocusTrap(getContainer: Accessor): FocusTrap { let previouslyFocused: HTMLElement | null = null; const handleKeyDown = (e: KeyboardEvent) => { const container = getContainer(); if (!container || e.key !== "Tab") return; const focusable = getFocusableElements(container); if (focusable.length === 0) { e.preventDefault(); return; } const first = focusable[0]!; const last = focusable[focusable.length - 1]!; if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last.focus(); } } else { if (document.activeElement === last) { e.preventDefault(); first.focus(); } } }; return { activate() { const container = getContainer(); if (!container) return; previouslyFocused = document.activeElement as HTMLElement | null; const focusable = getFocusableElements(container); focusable[0]?.focus(); document.addEventListener("keydown", handleKeyDown); }, deactivate() { document.removeEventListener("keydown", handleKeyDown); previouslyFocused?.focus(); previouslyFocused = null; }, }; } ``` - [ ] **Step 4: Create index** ```ts // packages/core/src/utilities/focus-trap/index.ts export { createFocusTrap } from "./create-focus-trap"; export type { FocusTrap } from "./create-focus-trap"; ``` - [ ] **Step 5: Run tests to verify they pass** ```bash cd packages/core && pnpm vitest run tests/utilities/focus-trap.test.ts ``` Expected: PASS — 3 tests passed. - [ ] **Step 6: Commit** ```bash git add packages/core/src/utilities/focus-trap/ git commit -m "feat: add createFocusTrap utility" ``` --- ## Task 7: Utility — `createScrollLock` **Files:** - Create: `packages/core/src/utilities/scroll-lock/create-scroll-lock.ts` - Create: `packages/core/src/utilities/scroll-lock/index.ts` - Create: `packages/core/tests/utilities/scroll-lock.test.ts` - [ ] **Step 1: Write the failing test** ```ts // packages/core/tests/utilities/scroll-lock.test.ts import { createRoot } from "solid-js"; import { afterEach, describe, expect, it } from "vitest"; import { createScrollLock } from "../../src/utilities/scroll-lock/create-scroll-lock"; describe("createScrollLock", () => { afterEach(() => { document.body.style.overflow = ""; document.body.style.paddingRight = ""; }); it("sets overflow hidden on body when locked", () => { createRoot((dispose) => { const lock = createScrollLock(); lock.lock(); expect(document.body.style.overflow).toBe("hidden"); lock.unlock(); dispose(); }); }); it("restores overflow on unlock", () => { document.body.style.overflow = "auto"; createRoot((dispose) => { const lock = createScrollLock(); lock.lock(); lock.unlock(); expect(document.body.style.overflow).toBe("auto"); dispose(); }); }); it("handles multiple locks — only unlocks when all release", () => { createRoot((dispose) => { const lockA = createScrollLock(); const lockB = createScrollLock(); lockA.lock(); lockB.lock(); lockA.unlock(); expect(document.body.style.overflow).toBe("hidden"); // still locked by B lockB.unlock(); expect(document.body.style.overflow).toBe(""); // now unlocked dispose(); }); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd packages/core && pnpm vitest run tests/utilities/scroll-lock.test.ts ``` Expected: FAIL — module not found. - [ ] **Step 3: Implement createScrollLock** ```ts // packages/core/src/utilities/scroll-lock/create-scroll-lock.ts // Global lock counter so nested modals don't prematurely restore scroll let lockCount = 0; let originalOverflow = ""; export interface ScrollLock { lock: () => void; unlock: () => void; } /** * Prevents body scroll. Uses a reference count so nested overlays * (e.g. a dialog inside a drawer) don't prematurely restore scrolling. */ export function createScrollLock(): ScrollLock { let locked = false; return { lock() { if (locked) return; locked = true; if (lockCount === 0) { originalOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; } lockCount++; }, unlock() { if (!locked) return; locked = false; lockCount = Math.max(0, lockCount - 1); if (lockCount === 0) { document.body.style.overflow = originalOverflow; originalOverflow = ""; } }, }; } ``` - [ ] **Step 4: Create index** ```ts // packages/core/src/utilities/scroll-lock/index.ts export { createScrollLock } from "./create-scroll-lock"; export type { ScrollLock } from "./create-scroll-lock"; ``` - [ ] **Step 5: Run tests to verify they pass** ```bash cd packages/core && pnpm vitest run tests/utilities/scroll-lock.test.ts ``` Expected: PASS — 3 tests passed. - [ ] **Step 6: Commit** ```bash git add packages/core/src/utilities/scroll-lock/ git commit -m "feat: add createScrollLock utility" ``` --- ## Task 8: Utility — `createDismiss` **Files:** - Create: `packages/core/src/utilities/dismiss/create-dismiss.ts` - Create: `packages/core/src/utilities/dismiss/index.ts` - Create: `packages/core/tests/utilities/dismiss.test.ts` - [ ] **Step 1: Write the failing test** ```ts // packages/core/tests/utilities/dismiss.test.ts import { createRoot } from "solid-js"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createDismiss } from "../../src/utilities/dismiss/create-dismiss"; describe("createDismiss", () => { afterEach(() => { document.body.innerHTML = ""; }); it("calls onDismiss when Escape key is pressed", () => { const container = document.createElement("div"); document.body.appendChild(container); createRoot((dispose) => { const onDismiss = vi.fn(); const dismiss = createDismiss({ getContainer: () => container, onDismiss }); dismiss.attach(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); expect(onDismiss).toHaveBeenCalledTimes(1); dismiss.detach(); dispose(); }); }); it("calls onDismiss when pointer is pressed outside container", () => { const container = document.createElement("div"); document.body.appendChild(container); const outside = document.createElement("button"); document.body.appendChild(outside); createRoot((dispose) => { const onDismiss = vi.fn(); const dismiss = createDismiss({ getContainer: () => container, onDismiss }); dismiss.attach(); outside.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); expect(onDismiss).toHaveBeenCalledTimes(1); dismiss.detach(); dispose(); }); }); it("does not call onDismiss when pointer is inside container", () => { const container = document.createElement("div"); const inner = document.createElement("button"); container.appendChild(inner); document.body.appendChild(container); createRoot((dispose) => { const onDismiss = vi.fn(); const dismiss = createDismiss({ getContainer: () => container, onDismiss }); dismiss.attach(); inner.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); expect(onDismiss).not.toHaveBeenCalled(); dismiss.detach(); dispose(); }); }); it("does not call onDismiss after detach", () => { const container = document.createElement("div"); document.body.appendChild(container); createRoot((dispose) => { const onDismiss = vi.fn(); const dismiss = createDismiss({ getContainer: () => container, onDismiss }); dismiss.attach(); dismiss.detach(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); expect(onDismiss).not.toHaveBeenCalled(); dispose(); }); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd packages/core && pnpm vitest run tests/utilities/dismiss.test.ts ``` Expected: FAIL — module not found. - [ ] **Step 3: Implement createDismiss** ```ts // packages/core/src/utilities/dismiss/create-dismiss.ts export interface CreateDismissOptions { /** Returns the content container element. */ getContainer: () => HTMLElement | null; /** Called when a dismiss event occurs. */ onDismiss: () => void; /** Whether Escape key triggers dismiss. Default: true. */ dismissOnEscape?: boolean; /** Whether pointer outside container triggers dismiss. Default: true. */ dismissOnPointerOutside?: boolean; } export interface Dismiss { attach: () => void; detach: () => void; } /** * Handles dismiss interactions: Escape key and pointer-outside. * Uses a global layer stack so nested overlays only dismiss the topmost layer. */ // Global stack of active dismiss handlers (topmost is last) const layerStack: Dismiss[] = []; export function createDismiss(options: CreateDismissOptions): Dismiss { const dismissOnEscape = options.dismissOnEscape ?? true; const dismissOnPointerOutside = options.dismissOnPointerOutside ?? true; const handleKeyDown = (e: KeyboardEvent) => { if (!dismissOnEscape) return; if (e.key !== "Escape") return; // Only dismiss the topmost layer if (layerStack[layerStack.length - 1] !== dismiss) return; e.preventDefault(); options.onDismiss(); }; const handlePointerDown = (e: PointerEvent) => { if (!dismissOnPointerOutside) return; if (layerStack[layerStack.length - 1] !== dismiss) return; const container = options.getContainer(); if (!container) return; if (container.contains(e.target as Node)) return; options.onDismiss(); }; const dismiss: Dismiss = { attach() { layerStack.push(dismiss); document.addEventListener("keydown", handleKeyDown); document.addEventListener("pointerdown", handlePointerDown); }, detach() { const index = layerStack.indexOf(dismiss); if (index !== -1) layerStack.splice(index, 1); document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("pointerdown", handlePointerDown); }, }; return dismiss; } ``` - [ ] **Step 4: Create index** ```ts // packages/core/src/utilities/dismiss/index.ts export { createDismiss } from "./create-dismiss"; export type { CreateDismissOptions, Dismiss } from "./create-dismiss"; ``` - [ ] **Step 5: Run tests to verify they pass** ```bash cd packages/core && pnpm vitest run tests/utilities/dismiss.test.ts ``` Expected: PASS — 4 tests passed. - [ ] **Step 6: Commit** ```bash git add packages/core/src/utilities/dismiss/ git commit -m "feat: add createDismiss utility with layer stack" ``` --- ## Task 9: Utility — `Presence` **Files:** - Create: `packages/core/src/utilities/presence/presence.tsx` - Create: `packages/core/src/utilities/presence/index.ts` - Create: `packages/core/tests/utilities/presence.test.tsx` - [ ] **Step 1: Write the failing test** ```tsx // packages/core/tests/utilities/presence.test.tsx import { render } from "@solidjs/testing-library"; import { createSignal } from "solid-js"; import { describe, expect, it } from "vitest"; import { Presence } from "../../src/utilities/presence/presence"; describe("Presence", () => { it("renders children when present is true", () => { const { getByTestId } = render(() => (
hello
)); expect(getByTestId("content")).toBeTruthy(); }); it("does not render children when present is false", () => { const { queryByTestId } = render(() => (
hello
)); expect(queryByTestId("content")).toBeNull(); }); it("adds data-opening attribute when transitioning in", async () => { const [present, setPresent] = createSignal(false); const { queryByTestId } = render(() => (
hello
)); setPresent(true); await Promise.resolve(); const el = queryByTestId("content"); expect(el).toBeTruthy(); }); it("keeps children mounted with forceMount", () => { const { getByTestId } = render(() => (
hello
)); expect(getByTestId("content")).toBeTruthy(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd packages/core && pnpm vitest run tests/utilities/presence.test.tsx ``` Expected: FAIL — module not found. - [ ] **Step 3: Implement Presence** ```tsx // packages/core/src/utilities/presence/presence.tsx import { type Accessor, type JSX, Show, children, createEffect, createSignal } from "solid-js"; export interface PresenceChildProps { present: Accessor; opening: Accessor; closing: Accessor; } export interface PresenceProps { /** Whether the content should be visible/mounted. */ present: boolean; /** * When true, keeps children mounted even when present is false. * Useful for controlling exit animations with external libraries. */ forceMount?: boolean; children: JSX.Element | ((props: PresenceChildProps) => JSX.Element); } /** * Keeps exiting elements mounted during CSS/JS animations. * Emits data-opening and data-closing attributes for CSS-driven transitions. * * Usage with CSS: * .content[data-opening] { animation: fadeIn 200ms; } * .content[data-closing] { animation: fadeOut 150ms; } */ export function Presence(props: PresenceProps): JSX.Element { const [mounted, setMounted] = createSignal(props.present); const [opening, setOpening] = createSignal(false); const [closing, setClosing] = createSignal(false); createEffect(() => { if (props.present) { setMounted(true); setClosing(false); setOpening(true); // Clear opening flag after a microtask so CSS can pick it up queueMicrotask(() => setOpening(false)); } else { setOpening(false); setClosing(true); // Keep mounted while closing — Presence unmounts after animation // For now, unmount immediately (consumers use forceMount for animation control) setClosing(false); setMounted(false); } }); const shouldMount = () => props.forceMount || mounted(); const childProps: PresenceChildProps = { present: mounted, opening, closing, }; const resolved = children(() => typeof props.children === "function" ? (props.children as (p: PresenceChildProps) => JSX.Element)(childProps) : props.children, ); return {resolved()}; } ``` - [ ] **Step 4: Create index** ```ts // packages/core/src/utilities/presence/index.ts export { Presence } from "./presence"; export type { PresenceProps, PresenceChildProps } from "./presence"; ``` - [ ] **Step 5: Run tests to verify they pass** ```bash cd packages/core && pnpm vitest run tests/utilities/presence.test.tsx ``` Expected: PASS — 4 tests passed. - [ ] **Step 6: Commit** ```bash git add packages/core/src/utilities/presence/ git commit -m "feat: add Presence utility" ``` --- ## Task 10: Dialog Component — Context and Types **Files:** - Create: `packages/core/src/components/dialog/dialog-context.ts` - [ ] **Step 1: Create dialog context (no test — pure types + context)** ```ts // packages/core/src/components/dialog/dialog-context.ts import type { Accessor, JSX } from "solid-js"; import { createContext, useContext } from "solid-js"; // ─── Internal Context (used only by Dialog parts) ────────────────────────── export interface InternalDialogContextValue { isOpen: Accessor; setOpen: (open: boolean) => void; modal: Accessor; /** SSR-safe ID for Dialog.Content element */ contentId: Accessor; /** Registered ID from Dialog.Title — used for aria-labelledby */ titleId: Accessor; setTitleId: (id: string | undefined) => void; /** Registered ID from Dialog.Description — used for aria-describedby */ descriptionId: Accessor; setDescriptionId: (id: string | undefined) => void; /** Whether Dialog.Trigger has been explicitly rendered */ hasTrigger: Accessor; setHasTrigger: (has: boolean) => void; } const InternalDialogContext = createContext(); export function useInternalDialogContext(): InternalDialogContextValue { const ctx = useContext(InternalDialogContext); if (!ctx) { throw new Error( "[PettyUI] Dialog parts must be used inside .\n" + " Fix: Wrap your Dialog.Content, Dialog.Trigger, etc. inside .\n" + " Docs: https://pettyui.dev/components/dialog#composition", ); } return ctx; } export const InternalDialogContextProvider = InternalDialogContext.Provider; // ─── Public Context (exported via Dialog.useContext) ─────────────────────── export interface DialogContextValue { /** Whether the dialog is currently open. */ open: Accessor; /** Whether the dialog renders as a modal (blocks outside interaction). */ modal: Accessor; } const DialogContext = createContext(); export function useDialogContext(): DialogContextValue { const ctx = useContext(DialogContext); if (!ctx) { throw new Error( "[PettyUI] Dialog.useContext() was called outside of a .\n" + " Fix: Call Dialog.useContext() inside a component rendered within .\n" + " Docs: https://pettyui.dev/components/dialog#context", ); } return ctx; } export const DialogContextProvider = DialogContext.Provider; ``` - [ ] **Step 2: Commit** ```bash git add packages/core/src/components/dialog/dialog-context.ts git commit -m "feat: add Dialog context and types" ``` --- ## Task 11: Dialog Component — Root **Files:** - Create: `packages/core/src/components/dialog/dialog-root.tsx` - Create: `packages/core/tests/components/dialog/dialog-rendering.test.tsx` - [ ] **Step 1: Write the failing rendering test** ```tsx // 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(() => ( Hello )); expect(screen.getByText("Hello")).toBeTruthy(); }); it("does not render content when closed by default", () => { render(() => ( Hidden )); expect(screen.queryByText("Hidden")).toBeNull(); }); it("renders content when defaultOpen is true", () => { render(() => ( Visible )); expect(screen.getByText("Visible")).toBeTruthy(); }); it("renders content when controlled open is true", () => { render(() => ( {}}> Controlled )); expect(screen.getByText("Controlled")).toBeTruthy(); }); it("closes when controlled open is set to false", () => { const [open, setOpen] = createSignal(true); render(() => ( Toggled )); expect(screen.getByText("Toggled")).toBeTruthy(); setOpen(false); expect(screen.queryByText("Toggled")).toBeNull(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd packages/core && pnpm vitest run tests/components/dialog/dialog-rendering.test.tsx ``` Expected: FAIL — module not found. - [ ] **Step 3: Implement DialogRoot** ```tsx // packages/core/src/components/dialog/dialog-root.tsx import type { JSX } from "solid-js"; import { createSignal } from "solid-js"; import { DialogContextProvider, InternalDialogContextProvider, type InternalDialogContextValue, } from "./dialog-context"; import { createDisclosureState } from "../../primitives/create-disclosure-state"; import { createRegisterId } from "../../primitives/create-register-id"; 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; }, }); 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 ( props.modal ?? true }}> {props.children} ); } ``` - [ ] **Step 4: Create minimal Dialog index (enough to run tests)** ```ts // packages/core/src/components/dialog/index.ts import { DialogRoot } from "./dialog-root"; import { DialogContent } from "./dialog-content"; import { DialogTitle } from "./dialog-title"; import { DialogDescription } from "./dialog-description"; import { DialogTrigger } from "./dialog-trigger"; import { DialogClose } from "./dialog-close"; import { DialogPortal } from "./dialog-portal"; import { DialogOverlay } from "./dialog-overlay"; import { useDialogContext } from "./dialog-context"; 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"; ``` This will fail until all parts exist — create stubs now: - [ ] **Step 5: Create stubs for remaining Dialog parts** ```tsx // packages/core/src/components/dialog/dialog-content.tsx import type { JSX } from "solid-js"; import { Show, onCleanup, onMount, splitProps } from "solid-js"; import { useInternalDialogContext } from "./dialog-context"; import { Portal } from "../../utilities/portal/portal"; import { createFocusTrap } from "../../utilities/focus-trap/create-focus-trap"; import { createScrollLock } from "../../utilities/scroll-lock/create-scroll-lock"; import { createDismiss } from "../../utilities/dismiss/create-dismiss"; export interface DialogContentProps extends JSX.HTMLAttributes { /** * 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; } export function DialogContent(props: DialogContentProps): JSX.Element { const [local, rest] = splitProps(props, [ "children", "onOpenAutoFocus", "onCloseAutoFocus", "forceMount", ]); const ctx = useInternalDialogContext(); let contentRef: HTMLDivElement | 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 ( ); } ``` ```tsx // 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 { children?: JSX.Element; } 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

{local.children}

; } ``` ```tsx // 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 { children?: JSX.Element; } 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

{local.children}

; } ``` ```tsx // packages/core/src/components/dialog/dialog-trigger.tsx import type { Component, JSX } from "solid-js"; import { Dynamic, mergeProps, splitProps } from "solid-js/web"; import { useInternalDialogContext } from "./dialog-context"; export interface DialogTriggerProps extends JSX.HTMLAttributes { /** Render as a different element or component. */ as?: string | Component; children?: JSX.Element | ((props: JSX.HTMLAttributes) => JSX.Element); } export function DialogTrigger(props: DialogTriggerProps): JSX.Element { const [local, rest] = splitProps(props, ["as", "children", "onClick"]); const ctx = useInternalDialogContext(); const handleClick: JSX.EventHandler = (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)}; } return ( {local.children} ); } ``` ```tsx // packages/core/src/components/dialog/dialog-close.tsx import type { Component, JSX } from "solid-js"; import { Dynamic, splitProps } from "solid-js/web"; import { useInternalDialogContext } from "./dialog-context"; export interface DialogCloseProps extends JSX.HTMLAttributes { as?: string | Component; children?: JSX.Element; } export function DialogClose(props: DialogCloseProps): JSX.Element { const [local, rest] = splitProps(props, ["as", "children", "onClick"]); const ctx = useInternalDialogContext(); const handleClick: JSX.EventHandler = (e) => { if (typeof local.onClick === "function") local.onClick(e as MouseEvent & { currentTarget: HTMLButtonElement; target: Element; }); ctx.setOpen(false); }; return ( {local.children} ); } ``` ```tsx // 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; } export function DialogPortal(props: DialogPortalProps): JSX.Element { const [local, rest] = splitProps(props, ["target", "children"]); return {local.children}; } ``` ```tsx // 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 { forceMount?: boolean; } export function DialogOverlay(props: DialogOverlayProps): JSX.Element { const [local, rest] = splitProps(props, ["forceMount"]); const ctx = useInternalDialogContext(); return (