- 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
2518 lines
71 KiB
Markdown
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.
|