PettyUI/docs/superpowers/plans/2026-03-28-foundation.md
Mats Bosson db906fd85a Fix linting config and package fields
- Replace .eslintrc.cjs with eslint.config.mjs (ESLint 9 flat config)
  using direct eslint-plugin-solid + @typescript-eslint/parser approach
- Add @typescript-eslint/parser to root devDependencies
- Add main/module/types top-level fields to packages/core/package.json
- Add resolve.conditions to packages/core/vite.config.ts
- Create packages/core/tsconfig.test.json for test type-checking
- Remove empty paths:{} from packages/core/tsconfig.json
2026-03-29 02:35:57 +07:00

2518 lines
71 KiB
Markdown

# 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<string>("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<T> {
/** Returns the controlled value, or undefined if uncontrolled. */
value: Accessor<T | undefined>;
/** Default value used when uncontrolled. */
defaultValue: Accessor<T>;
/** 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<T>(
options: CreateControllableSignalOptions<T>,
): [Accessor<T>, (value: T) => void] {
const [internalValue, setInternalValue] = createSignal<T>(options.defaultValue());
const get: Accessor<T> = () => {
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<boolean>;
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<boolean>({
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<string | undefined>,
(id: string | undefined) => void,
] {
const [id, setId] = createSignal<string | undefined>(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(() => <Portal><div data-testid="portal-content">hello</div></Portal>);
// 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(() => (
<Portal target={target}>
<div data-testid="custom-portal">hello</div>
</Portal>
));
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<HTMLSpanElement> {
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 (
<span
style={{ ...visuallyHiddenStyle, ...(local.style as JSX.CSSProperties | undefined) }}
{...rest}
>
{local.children}
</span>
);
}
```
- [ ] **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 (
<SolidPortal mount={props.target ?? document.body}>
{props.children}
</SolidPortal>
);
}
```
- [ ] **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<HTMLElement>(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<HTMLElement | null>): 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(() => (
<Presence present={true}>
<div data-testid="content">hello</div>
</Presence>
));
expect(getByTestId("content")).toBeTruthy();
});
it("does not render children when present is false", () => {
const { queryByTestId } = render(() => (
<Presence present={false}>
<div data-testid="content">hello</div>
</Presence>
));
expect(queryByTestId("content")).toBeNull();
});
it("adds data-opening attribute when transitioning in", async () => {
const [present, setPresent] = createSignal(false);
const { queryByTestId } = render(() => (
<Presence present={present()}>
<div data-testid="content">hello</div>
</Presence>
));
setPresent(true);
await Promise.resolve();
const el = queryByTestId("content");
expect(el).toBeTruthy();
});
it("keeps children mounted with forceMount", () => {
const { getByTestId } = render(() => (
<Presence present={false} forceMount>
<div data-testid="content">hello</div>
</Presence>
));
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<boolean>;
opening: Accessor<boolean>;
closing: Accessor<boolean>;
}
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 <Show when={shouldMount()}>{resolved()}</Show>;
}
```
- [ ] **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<boolean>;
setOpen: (open: boolean) => void;
modal: Accessor<boolean>;
/** SSR-safe ID for Dialog.Content element */
contentId: Accessor<string>;
/** Registered ID from Dialog.Title — used for aria-labelledby */
titleId: Accessor<string | undefined>;
setTitleId: (id: string | undefined) => void;
/** Registered ID from Dialog.Description — used for aria-describedby */
descriptionId: Accessor<string | undefined>;
setDescriptionId: (id: string | undefined) => void;
/** Whether Dialog.Trigger has been explicitly rendered */
hasTrigger: Accessor<boolean>;
setHasTrigger: (has: boolean) => void;
}
const InternalDialogContext = createContext<InternalDialogContextValue>();
export function useInternalDialogContext(): InternalDialogContextValue {
const ctx = useContext(InternalDialogContext);
if (!ctx) {
throw new Error(
"[PettyUI] Dialog parts must be used inside <Dialog>.\n" +
" Fix: Wrap your Dialog.Content, Dialog.Trigger, etc. inside <Dialog>.\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<boolean>;
/** Whether the dialog renders as a modal (blocks outside interaction). */
modal: Accessor<boolean>;
}
const DialogContext = createContext<DialogContextValue>();
export function useDialogContext(): DialogContextValue {
const ctx = useContext(DialogContext);
if (!ctx) {
throw new Error(
"[PettyUI] Dialog.useContext() was called outside of a <Dialog>.\n" +
" Fix: Call Dialog.useContext() inside a component rendered within <Dialog>.\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(() => (
<Dialog>
<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();
});
});
```
- [ ] **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 (
<InternalDialogContextProvider value={internalCtx}>
<DialogContextProvider value={{ open: disclosure.isOpen, modal: () => props.modal ?? true }}>
{props.children}
</DialogContextProvider>
</InternalDialogContextProvider>
);
}
```
- [ ] **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<HTMLDivElement> {
/**
* 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 (
<Show when={local.forceMount || ctx.isOpen()}>
<Portal>
<div
ref={contentRef}
id={ctx.contentId()}
role="dialog"
aria-modal={ctx.modal() || undefined}
aria-labelledby={ctx.titleId()}
aria-describedby={ctx.descriptionId()}
data-state={ctx.isOpen() ? "open" : "closed"}
{...rest}
>
{local.children}
</div>
</Portal>
</Show>
);
}
```
```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<HTMLHeadingElement> {
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 <h2 id={id} {...rest}>{local.children}</h2>;
}
```
```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<HTMLParagraphElement> {
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 <p id={id} {...rest}>{local.children}</p>;
}
```
```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<HTMLButtonElement> {
/** Render as a different element or component. */
as?: string | Component;
children?: JSX.Element | ((props: JSX.HTMLAttributes<HTMLElement>) => JSX.Element);
}
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)}</>;
}
return (
<Dynamic component={local.as ?? "button"} {...triggerProps}>
{local.children}
</Dynamic>
);
}
```
```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<HTMLButtonElement> {
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<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>
);
}
```
```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 <Portal target={local.target}>{local.children}</Portal>;
}
```
```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<HTMLDivElement> {
forceMount?: boolean;
}
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>
);
}
```
- [ ] **Step 6: Run rendering tests**
```bash
cd packages/core && pnpm vitest run tests/components/dialog/dialog-rendering.test.tsx
```
Expected: PASS — 5 tests passed.
- [ ] **Step 7: Commit**
```bash
git add packages/core/src/components/dialog/
git commit -m "feat: add Dialog component — all parts"
```
---
## Task 12: Dialog — ARIA and Keyboard Tests
**Files:**
- Create: `packages/core/tests/components/dialog/dialog-aria.test.tsx`
- Create: `packages/core/tests/components/dialog/dialog-keyboard.test.tsx`
- [ ] **Step 1: Write ARIA tests**
```tsx
// packages/core/tests/components/dialog/dialog-aria.test.tsx
import { render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Dialog } from "../../../src/components/dialog/index";
describe("Dialog ARIA", () => {
it("content has role=dialog", () => {
render(() => (
<Dialog defaultOpen>
<Dialog.Content data-testid="content">
<Dialog.Title>Title</Dialog.Title>
</Dialog.Content>
</Dialog>
));
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("content has aria-modal when modal prop is true", () => {
render(() => (
<Dialog defaultOpen modal>
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
</Dialog.Content>
</Dialog>
));
expect(screen.getByRole("dialog").getAttribute("aria-modal")).toBe("true");
});
it("content does not have aria-modal when modal is false", () => {
render(() => (
<Dialog defaultOpen modal={false}>
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
</Dialog.Content>
</Dialog>
));
expect(screen.getByRole("dialog").getAttribute("aria-modal")).toBeNull();
});
it("content is linked to title via aria-labelledby", () => {
render(() => (
<Dialog defaultOpen>
<Dialog.Content>
<Dialog.Title>My Title</Dialog.Title>
</Dialog.Content>
</Dialog>
));
const dialog = screen.getByRole("dialog");
const title = screen.getByText("My Title");
expect(dialog.getAttribute("aria-labelledby")).toBe(title.id);
});
it("content is linked to description via aria-describedby", () => {
render(() => (
<Dialog defaultOpen>
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
<Dialog.Description>My description</Dialog.Description>
</Dialog.Content>
</Dialog>
));
const dialog = screen.getByRole("dialog");
const desc = screen.getByText("My description");
expect(dialog.getAttribute("aria-describedby")).toBe(desc.id);
});
it("trigger has aria-haspopup=dialog", () => {
render(() => (
<Dialog>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
</Dialog.Content>
</Dialog>
));
expect(screen.getByText("Open").getAttribute("aria-haspopup")).toBe("dialog");
});
it("trigger aria-expanded reflects open state", () => {
render(() => (
<Dialog>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
</Dialog.Content>
</Dialog>
));
const trigger = screen.getByText("Open");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
});
});
```
- [ ] **Step 2: Write keyboard tests**
```tsx
// packages/core/tests/components/dialog/dialog-keyboard.test.tsx
import { render, screen, fireEvent } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Dialog } from "../../../src/components/dialog/index";
describe("Dialog keyboard", () => {
it("closes on Escape key", () => {
render(() => (
<Dialog defaultOpen>
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Content>
</Dialog>
));
expect(screen.getByRole("dialog")).toBeTruthy();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("Trigger click opens dialog", () => {
render(() => (
<Dialog>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
</Dialog.Content>
</Dialog>
));
expect(screen.queryByRole("dialog")).toBeNull();
fireEvent.click(screen.getByText("Open"));
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("Close button closes dialog", () => {
render(() => (
<Dialog defaultOpen>
<Dialog.Content>
<Dialog.Title>Title</Dialog.Title>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Content>
</Dialog>
));
expect(screen.getByRole("dialog")).toBeTruthy();
fireEvent.click(screen.getByText("Close"));
expect(screen.queryByRole("dialog")).toBeNull();
});
});
```
- [ ] **Step 3: Run ARIA and keyboard tests**
```bash
cd packages/core && pnpm vitest run tests/components/dialog/
```
Expected: PASS — all dialog tests pass.
- [ ] **Step 4: Run the full test suite**
```bash
cd packages/core && pnpm vitest run
```
Expected: PASS — all tests in all files pass.
- [ ] **Step 5: Run type check**
```bash
cd packages/core && pnpm typecheck
```
Expected: no errors.
- [ ] **Step 6: Run Biome check**
```bash
cd /Users/matsbosson/Documents/StayThree/PettyUI && pnpm biome check packages/
```
Expected: no errors (fix any formatting issues with `pnpm biome format --write packages/`).
- [ ] **Step 7: Commit**
```bash
git add packages/core/tests/components/dialog/
git commit -m "test: add Dialog ARIA and keyboard tests"
```
---
## Task 13: Main Package Entry and Build
**Files:**
- Create: `packages/core/src/index.ts`
- [ ] **Step 1: Create main package entry**
```ts
// 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 { 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";
```
- [ ] **Step 2: Build the package**
```bash
cd packages/core && pnpm build
```
Expected: `dist/` directory created with ESM + CJS + `.d.ts` files. No errors.
- [ ] **Step 3: Verify dist output**
```bash
ls packages/core/dist/
```
Expected: `index.js`, `index.cjs`, `index.d.ts`, `dialog/index.js`, `dialog/index.cjs`, `presence/index.js`, etc.
- [ ] **Step 4: Final full test run**
```bash
cd packages/core && pnpm vitest run
```
Expected: PASS — all tests.
- [ ] **Step 5: Commit**
```bash
git add packages/core/src/index.ts
git commit -m "feat: add main package entry and verify build"
```
---
## Task 14: Save Memory and CI Setup
**Files:**
- Create: `.github/workflows/ci.yml`
- [ ] **Step 1: Create CI workflow**
```yaml
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Biome check
run: pnpm biome check packages/
- name: ESLint (Solid rules)
run: pnpm eslint packages --ext .ts,.tsx
- name: Type check
run: pnpm -r typecheck
- name: Tests
run: pnpm -r test
- name: Build
run: pnpm -r build
```
- [ ] **Step 2: Commit CI**
```bash
git add .github/
git commit -m "ci: add GitHub Actions workflow"
```
- [ ] **Step 3: Final commit summary**
Run to confirm clean working tree:
```bash
git status
git log --oneline
```
Expected output (in order):
```
ci: add GitHub Actions workflow
feat: add main package entry and verify build
test: add Dialog ARIA and keyboard tests
feat: add Dialog component — all parts
feat: add Dialog context and types
feat: add Presence utility
feat: add createDismiss utility with layer stack
feat: add createScrollLock utility
feat: add createFocusTrap utility
feat: add VisuallyHidden and Portal utilities
feat: add createRegisterId primitive and primitives barrel
feat: add createDisclosureState primitive
feat: add createControllableSignal primitive
chore: scaffold monorepo with tooling
```
---
## Self-Review
**Spec coverage check:**
- ✅ Single monorepo package with sub-path exports
- ✅ SolidJS 1.9.x, TypeScript 6.x, Vite 8, Vitest 4, tsdown, Biome + ESLint
-`createControllableSignal` — controlled/uncontrolled primitive
-`createDisclosureState` — open/close state primitive
-`createRegisterId` — ARIA ID registration
-`Portal` — SSR-safe portal
-`VisuallyHidden` — screen reader utility
-`createFocusTrap` — focus trapping
-`createScrollLock` — body scroll prevention with reference counting
-`createDismiss` — Escape + pointer-outside with layer stack
-`Presence` — animation-aware mounting
- ✅ Dialog — all parts: Root, Trigger, Portal, Overlay, Content, Title, Description, Close
- ✅ Dual context (InternalDialogContext + DialogContextProvider)
-`as` prop polymorphism via `<Dynamic>`
- ✅ Children-as-function on Trigger
- ✅ ARIA attributes: role, aria-modal, aria-labelledby, aria-describedby, aria-haspopup, aria-expanded
- ✅ Data attributes: data-state on all parts
- ✅ Pit-of-success error messages in context hooks
- ✅ SSR-safe: `isServer` guard in Portal, `createUniqueId()` for IDs
- ✅ Build pipeline with tsdown
- ✅ CI workflow
**Gaps addressed:**
- `@floating-ui/dom` is a runtime dependency but is not used in Plan 1 (no positioning needed for Dialog). It will first be used in Popover/Tooltip in Plan 2.
- Zod schemas are a Plan 3 (AI layer) concern — not in Plan 1 scope.
- `openui.yaml` and `llms.txt` are Plan 3 concerns.