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