1768 lines
58 KiB
Markdown
1768 lines
58 KiB
Markdown
# Plan 3: Core Primitives + Floating Components
|
|
|
|
> **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:** Build 3 new primitives (`createFloating`, `createListNavigation`, `createRovingFocus`) and 3 floating components (Tooltip, Popover, HoverCard) that use `createFloating` for positioning.
|
|
|
|
**Architecture:** `createFloating` wraps `@floating-ui/dom` with SolidJS reactivity. `createListNavigation` is a value-based keyboard navigation + selection/activation engine with typeahead (used by Plan 4's collection components). `createRovingFocus` extracts shared keyboard nav from existing Tabs/Accordion. Tooltip, Popover, and HoverCard are compound components following the established pattern (Root/Trigger/Content/Arrow).
|
|
|
|
**Tech Stack:** SolidJS 1.9.x, `@floating-ui/dom` 1.7.x, TypeScript, Vitest, `@solidjs/testing-library`, Biome, pnpm 10
|
|
|
|
---
|
|
|
|
## Key patterns (read before any task)
|
|
|
|
**Primitives at** `packages/core/src/primitives/`:
|
|
- `create-controllable-signal.ts` — `createControllableSignal<T>(options)` → `[Accessor<T>, setter]`
|
|
- `create-disclosure-state.ts` — `createDisclosureState(options)` → `{ isOpen, open, close, toggle }`
|
|
- `create-register-id.ts` — `createRegisterId()` → `[Accessor<string|undefined>, setter]`
|
|
|
|
**Utilities at** `packages/core/src/utilities/`:
|
|
- `dismiss/create-dismiss.ts` — `createDismiss({ getContainer, onDismiss, dismissOnEscape?, dismissOnPointerOutside? })` → `{ attach, detach }`
|
|
- `focus-trap/create-focus-trap.ts` — `createFocusTrap(getContainer)` → `{ activate, deactivate }`
|
|
- `scroll-lock/create-scroll-lock.ts` — `createScrollLock()` → `{ lock, unlock }`
|
|
|
|
**Rules every component must follow:**
|
|
1. Never destructure SolidJS props — always use `splitProps`
|
|
2. Every exported function/component/interface needs JSDoc `/** ... */`
|
|
3. Use `createUniqueId()` for all IDs (SSR-safe)
|
|
4. `aria-*` boolean attributes as explicit strings (`"true"` / `"false"`), not booleans
|
|
5. `hidden={expr || undefined}` — never emit `hidden="false"`
|
|
6. Compound export: `Object.assign(Root, { Trigger, Content, ... })`
|
|
7. Dual context: Internal (for parts) + Public (for consumers)
|
|
8. Error messages: `[PettyUI] Component.Part used outside <Component>.\n Fix: ...`
|
|
|
|
**Working directory:** `/Users/matsbosson/Documents/StayThree/PettyUI`
|
|
|
|
**Test commands:**
|
|
- Single test: `cd packages/core && pnpm vitest run tests/path/to/test.tsx`
|
|
- Full suite: `cd packages/core && pnpm vitest run`
|
|
- Typecheck: `cd packages/core && pnpm typecheck`
|
|
- Biome: `pnpm biome check packages/core/src/path/`
|
|
|
|
---
|
|
|
|
## Task 1: `createFloating` primitive
|
|
|
|
**Files:**
|
|
- Create: `packages/core/src/primitives/create-floating.ts`
|
|
- Test: `packages/core/tests/primitives/create-floating.test.tsx`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```tsx
|
|
// packages/core/tests/primitives/create-floating.test.tsx
|
|
import { createSignal } from "solid-js";
|
|
import { render } from "@solidjs/testing-library";
|
|
import { describe, expect, it } from "vitest";
|
|
import { createFloating } from "../../src/primitives/create-floating";
|
|
|
|
describe("createFloating", () => {
|
|
it("returns reactive x, y, placement, and style", () => {
|
|
let state: ReturnType<typeof createFloating> | undefined;
|
|
|
|
render(() => {
|
|
const [anchor, setAnchor] = createSignal<HTMLElement | null>(null);
|
|
const [floating, setFloating] = createSignal<HTMLElement | null>(null);
|
|
|
|
state = createFloating({
|
|
anchor,
|
|
floating,
|
|
placement: () => "bottom",
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<button ref={setAnchor}>Anchor</button>
|
|
<div ref={setFloating}>Float</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
expect(state).toBeDefined();
|
|
expect(typeof state!.x()).toBe("number");
|
|
expect(typeof state!.y()).toBe("number");
|
|
expect(state!.placement()).toBe("bottom");
|
|
expect(state!.style()).toHaveProperty("position");
|
|
});
|
|
|
|
it("does not compute when open is false", () => {
|
|
let state: ReturnType<typeof createFloating> | undefined;
|
|
|
|
render(() => {
|
|
const [anchor, setAnchor] = createSignal<HTMLElement | null>(null);
|
|
const [floating, setFloating] = createSignal<HTMLElement | null>(null);
|
|
|
|
state = createFloating({
|
|
anchor,
|
|
floating,
|
|
placement: () => "bottom",
|
|
open: () => false,
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<button ref={setAnchor}>Anchor</button>
|
|
<div ref={setFloating}>Float</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
// When not open, coordinates default to 0
|
|
expect(state!.x()).toBe(0);
|
|
expect(state!.y()).toBe(0);
|
|
});
|
|
|
|
it("style includes position strategy", () => {
|
|
let state: ReturnType<typeof createFloating> | undefined;
|
|
|
|
render(() => {
|
|
const [anchor, setAnchor] = createSignal<HTMLElement | null>(null);
|
|
const [floating, setFloating] = createSignal<HTMLElement | null>(null);
|
|
|
|
state = createFloating({
|
|
anchor,
|
|
floating,
|
|
strategy: () => "fixed",
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<button ref={setAnchor}>Anchor</button>
|
|
<div ref={setFloating}>Float</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
expect(state!.style().position).toBe("fixed");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run tests/primitives/create-floating.test.tsx
|
|
```
|
|
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 3: Implement `createFloating`**
|
|
|
|
```ts
|
|
// packages/core/src/primitives/create-floating.ts
|
|
import {
|
|
type Middleware,
|
|
type Placement,
|
|
type Strategy,
|
|
autoUpdate,
|
|
computePosition,
|
|
} from "@floating-ui/dom";
|
|
import { type Accessor, createEffect, createSignal, onCleanup } from "solid-js";
|
|
import type { JSX } from "solid-js";
|
|
|
|
/** Options for createFloating. */
|
|
export interface CreateFloatingOptions {
|
|
/** Reference/anchor element. */
|
|
anchor: Accessor<HTMLElement | null>;
|
|
/** The floating element to position. */
|
|
floating: Accessor<HTMLElement | null>;
|
|
/** Desired placement. @default "bottom" */
|
|
placement?: Accessor<Placement>;
|
|
/** Floating UI middleware (flip, shift, offset, arrow, etc.). */
|
|
middleware?: Accessor<Middleware[]>;
|
|
/** CSS positioning strategy. @default "absolute" */
|
|
strategy?: Accessor<Strategy>;
|
|
/** Only compute position when true. @default () => true */
|
|
open?: Accessor<boolean>;
|
|
}
|
|
|
|
/** Reactive floating position state. */
|
|
export interface FloatingState {
|
|
/** Computed x position in px. */
|
|
x: Accessor<number>;
|
|
/** Computed y position in px. */
|
|
y: Accessor<number>;
|
|
/** Actual placement after middleware (may differ from requested after flip). */
|
|
placement: Accessor<Placement>;
|
|
/** Ready-to-spread CSS: { position, top, left }. */
|
|
style: Accessor<JSX.CSSProperties>;
|
|
}
|
|
|
|
/**
|
|
* Thin reactive wrapper around @floating-ui/dom.
|
|
* Computes and auto-updates the position of a floating element relative to an anchor.
|
|
*/
|
|
export function createFloating(options: CreateFloatingOptions): FloatingState {
|
|
const [x, setX] = createSignal(0);
|
|
const [y, setY] = createSignal(0);
|
|
const [currentPlacement, setCurrentPlacement] = createSignal<Placement>(
|
|
options.placement?.() ?? "bottom",
|
|
);
|
|
|
|
const getStrategy = () => options.strategy?.() ?? "absolute";
|
|
const isOpen = () => options.open?.() ?? true;
|
|
|
|
const update = async () => {
|
|
const anchor = options.anchor();
|
|
const floating = options.floating();
|
|
if (!anchor || !floating) return;
|
|
|
|
const result = await computePosition(anchor, floating, {
|
|
placement: options.placement?.() ?? "bottom",
|
|
middleware: options.middleware?.(),
|
|
strategy: getStrategy(),
|
|
});
|
|
|
|
setX(result.x);
|
|
setY(result.y);
|
|
setCurrentPlacement(result.placement);
|
|
};
|
|
|
|
createEffect(() => {
|
|
const anchor = options.anchor();
|
|
const floating = options.floating();
|
|
|
|
if (!anchor || !floating || !isOpen()) return;
|
|
|
|
update();
|
|
|
|
const cleanup = autoUpdate(anchor, floating, update);
|
|
onCleanup(cleanup);
|
|
});
|
|
|
|
const style: Accessor<JSX.CSSProperties> = () => ({
|
|
position: getStrategy(),
|
|
top: `${y()}px`,
|
|
left: `${x()}px`,
|
|
});
|
|
|
|
return { x, y, placement: currentPlacement, style };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run tests/primitives/create-floating.test.tsx
|
|
```
|
|
|
|
Expected: PASS — 3 tests.
|
|
|
|
- [ ] **Step 5: Full suite + typecheck + biome**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm typecheck
|
|
pnpm biome check packages/core/src/primitives/create-floating.ts
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI && git add packages/core/src/primitives/create-floating.ts packages/core/tests/primitives/ && git commit -m "feat: add createFloating primitive"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: `createListNavigation` primitive
|
|
|
|
**Files:**
|
|
- Create: `packages/core/src/primitives/create-list-navigation.ts`
|
|
- Test: `packages/core/tests/primitives/create-list-navigation.test.tsx`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```tsx
|
|
// packages/core/tests/primitives/create-list-navigation.test.tsx
|
|
import { createSignal } from "solid-js";
|
|
import { fireEvent, render, screen } from "@solidjs/testing-library";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { createListNavigation } from "../../src/primitives/create-list-navigation";
|
|
|
|
function TestListbox(props: {
|
|
items: string[];
|
|
mode?: "selection" | "activation";
|
|
defaultValue?: string;
|
|
onValueChange?: (v: string) => void;
|
|
onActivate?: (v: string) => void;
|
|
getLabel?: (v: string) => string;
|
|
}) {
|
|
const nav = createListNavigation({
|
|
items: () => props.items,
|
|
mode: props.mode ?? "selection",
|
|
defaultValue: props.defaultValue,
|
|
onValueChange: props.onValueChange,
|
|
onActivate: props.onActivate,
|
|
getLabel: props.getLabel,
|
|
});
|
|
|
|
return (
|
|
<div data-testid="container" tabIndex={0} {...nav.containerProps}>
|
|
{props.items.map((item) => (
|
|
<div data-testid={`item-${item}`} {...nav.getItemProps(item)}>
|
|
{item}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe("createListNavigation", () => {
|
|
it("container has role=listbox in selection mode", () => {
|
|
render(() => <TestListbox items={["a", "b", "c"]} />);
|
|
expect(screen.getByTestId("container").getAttribute("role")).toBe("listbox");
|
|
});
|
|
|
|
it("container has role=menu in activation mode", () => {
|
|
render(() => <TestListbox items={["a", "b"]} mode="activation" />);
|
|
expect(screen.getByTestId("container").getAttribute("role")).toBe("menu");
|
|
});
|
|
|
|
it("ArrowDown highlights next item", () => {
|
|
render(() => <TestListbox items={["a", "b", "c"]} />);
|
|
const container = screen.getByTestId("container");
|
|
container.focus();
|
|
fireEvent.keyDown(container, { key: "ArrowDown" });
|
|
expect(screen.getByTestId("item-a").getAttribute("data-highlighted")).toBe("");
|
|
fireEvent.keyDown(container, { key: "ArrowDown" });
|
|
expect(screen.getByTestId("item-b").getAttribute("data-highlighted")).toBe("");
|
|
});
|
|
|
|
it("ArrowUp highlights previous item", () => {
|
|
render(() => <TestListbox items={["a", "b", "c"]} />);
|
|
const container = screen.getByTestId("container");
|
|
container.focus();
|
|
fireEvent.keyDown(container, { key: "ArrowDown" });
|
|
fireEvent.keyDown(container, { key: "ArrowDown" });
|
|
fireEvent.keyDown(container, { key: "ArrowUp" });
|
|
expect(screen.getByTestId("item-a").getAttribute("data-highlighted")).toBe("");
|
|
});
|
|
|
|
it("Home highlights first, End highlights last", () => {
|
|
render(() => <TestListbox items={["a", "b", "c"]} />);
|
|
const container = screen.getByTestId("container");
|
|
container.focus();
|
|
fireEvent.keyDown(container, { key: "End" });
|
|
expect(screen.getByTestId("item-c").getAttribute("data-highlighted")).toBe("");
|
|
fireEvent.keyDown(container, { key: "Home" });
|
|
expect(screen.getByTestId("item-a").getAttribute("data-highlighted")).toBe("");
|
|
});
|
|
|
|
it("Enter selects in selection mode", () => {
|
|
const onValueChange = vi.fn();
|
|
render(() => (
|
|
<TestListbox items={["a", "b"]} onValueChange={onValueChange} />
|
|
));
|
|
const container = screen.getByTestId("container");
|
|
container.focus();
|
|
fireEvent.keyDown(container, { key: "ArrowDown" });
|
|
fireEvent.keyDown(container, { key: "Enter" });
|
|
expect(onValueChange).toHaveBeenCalledWith("a");
|
|
});
|
|
|
|
it("Enter activates in activation mode", () => {
|
|
const onActivate = vi.fn();
|
|
render(() => (
|
|
<TestListbox items={["a", "b"]} mode="activation" onActivate={onActivate} />
|
|
));
|
|
const container = screen.getByTestId("container");
|
|
container.focus();
|
|
fireEvent.keyDown(container, { key: "ArrowDown" });
|
|
fireEvent.keyDown(container, { key: "Enter" });
|
|
expect(onActivate).toHaveBeenCalledWith("a");
|
|
});
|
|
|
|
it("click on item selects it", () => {
|
|
const onValueChange = vi.fn();
|
|
render(() => (
|
|
<TestListbox items={["a", "b"]} onValueChange={onValueChange} />
|
|
));
|
|
fireEvent.click(screen.getByTestId("item-b"));
|
|
expect(onValueChange).toHaveBeenCalledWith("b");
|
|
});
|
|
|
|
it("aria-activedescendant tracks highlighted item", () => {
|
|
render(() => <TestListbox items={["a", "b"]} />);
|
|
const container = screen.getByTestId("container");
|
|
container.focus();
|
|
expect(container.getAttribute("aria-activedescendant")).toBeFalsy();
|
|
fireEvent.keyDown(container, { key: "ArrowDown" });
|
|
const itemA = screen.getByTestId("item-a");
|
|
expect(container.getAttribute("aria-activedescendant")).toBe(itemA.id);
|
|
});
|
|
|
|
it("typeahead jumps to matching item", () => {
|
|
render(() => (
|
|
<TestListbox
|
|
items={["apple", "banana", "cherry"]}
|
|
getLabel={(v) => v}
|
|
/>
|
|
));
|
|
const container = screen.getByTestId("container");
|
|
container.focus();
|
|
fireEvent.keyDown(container, { key: "b" });
|
|
expect(screen.getByTestId("item-banana").getAttribute("data-highlighted")).toBe("");
|
|
});
|
|
|
|
it("pointer enter highlights item", () => {
|
|
render(() => <TestListbox items={["a", "b"]} />);
|
|
fireEvent.pointerEnter(screen.getByTestId("item-b"));
|
|
expect(screen.getByTestId("item-b").getAttribute("data-highlighted")).toBe("");
|
|
});
|
|
|
|
it("pointer leave clears highlight", () => {
|
|
render(() => <TestListbox items={["a", "b"]} />);
|
|
const container = screen.getByTestId("container");
|
|
fireEvent.pointerEnter(screen.getByTestId("item-a"));
|
|
fireEvent.pointerLeave(container);
|
|
expect(screen.getByTestId("item-a").getAttribute("data-highlighted")).toBeNull();
|
|
});
|
|
|
|
it("loop wraps from last to first", () => {
|
|
render(() => <TestListbox items={["a", "b"]} />);
|
|
const container = screen.getByTestId("container");
|
|
container.focus();
|
|
fireEvent.keyDown(container, { key: "ArrowDown" });
|
|
fireEvent.keyDown(container, { key: "ArrowDown" });
|
|
fireEvent.keyDown(container, { key: "ArrowDown" });
|
|
expect(screen.getByTestId("item-a").getAttribute("data-highlighted")).toBe("");
|
|
});
|
|
|
|
it("defaultValue sets initial selection", () => {
|
|
render(() => <TestListbox items={["a", "b"]} defaultValue="b" />);
|
|
expect(screen.getByTestId("item-b").getAttribute("aria-selected")).toBe("true");
|
|
});
|
|
|
|
it("items have correct roles", () => {
|
|
render(() => <TestListbox items={["a"]} />);
|
|
expect(screen.getByTestId("item-a").getAttribute("role")).toBe("option");
|
|
|
|
// Cleanup and render activation mode
|
|
const { unmount } = render(() => <TestListbox items={["a"]} mode="activation" />);
|
|
expect(screen.getAllByTestId("item-a")[1].getAttribute("role")).toBe("menuitem");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run tests/primitives/create-list-navigation.test.tsx
|
|
```
|
|
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 3: Implement `createListNavigation`**
|
|
|
|
```ts
|
|
// packages/core/src/primitives/create-list-navigation.ts
|
|
import { type Accessor, createSignal, createUniqueId } from "solid-js";
|
|
import { createControllableSignal } from "./create-controllable-signal";
|
|
|
|
/** Options for createListNavigation. */
|
|
export interface CreateListNavigationOptions {
|
|
/** Ordered list of valid item values. Reactive. */
|
|
items: Accessor<string[]>;
|
|
/** "selection" for Listbox/Select/Combobox. "activation" for Menu. */
|
|
mode: "selection" | "activation";
|
|
/** @default "vertical" */
|
|
orientation?: "vertical" | "horizontal";
|
|
/** Wrap at list boundaries. @default true */
|
|
loop?: boolean;
|
|
|
|
/** Controlled selected value (single selection mode). */
|
|
value?: Accessor<string | undefined>;
|
|
/** Initial uncontrolled value. */
|
|
defaultValue?: string;
|
|
/** Called when selection changes. */
|
|
onValueChange?: ((value: string) => void) | undefined;
|
|
|
|
/** Called when an item is activated (activation mode). */
|
|
onActivate?: (value: string) => void;
|
|
|
|
/** Enable typeahead. @default true */
|
|
typeahead?: boolean;
|
|
/** Return the display label for a value. Used for typeahead. Defaults to identity. */
|
|
getLabel?: (value: string) => string;
|
|
|
|
/** Base ID for generating item IDs. Auto-generated if not provided. */
|
|
baseId?: string;
|
|
}
|
|
|
|
/** Return type of createListNavigation. */
|
|
export interface ListNavigationState {
|
|
/** Currently highlighted item value (virtual focus). */
|
|
highlightedValue: Accessor<string | undefined>;
|
|
/** Currently selected value (selection mode). */
|
|
selectedValue: Accessor<string | undefined>;
|
|
|
|
/** Props to spread on the list container. */
|
|
containerProps: {
|
|
role: string;
|
|
"aria-orientation": string;
|
|
"aria-activedescendant": Accessor<string | undefined>;
|
|
onKeyDown: (e: KeyboardEvent) => void;
|
|
onPointerLeave: () => void;
|
|
};
|
|
|
|
/** Get props for a specific item by value. */
|
|
getItemProps: (value: string) => {
|
|
id: string;
|
|
role: string;
|
|
"aria-selected"?: string;
|
|
"data-highlighted": "" | undefined;
|
|
"data-state"?: string;
|
|
onPointerEnter: () => void;
|
|
onPointerMove: () => void;
|
|
onClick: () => void;
|
|
};
|
|
|
|
/** Imperatively set highlighted value. */
|
|
highlight: (value: string | undefined) => void;
|
|
/** Highlight the first item. */
|
|
highlightFirst: () => void;
|
|
/** Highlight the last item. */
|
|
highlightLast: () => void;
|
|
/** Clear highlight. */
|
|
clearHighlight: () => void;
|
|
}
|
|
|
|
/**
|
|
* Value-based keyboard navigation, selection/activation, and typeahead for list-like components.
|
|
* Uses aria-activedescendant (virtual focus) — the container retains DOM focus.
|
|
*/
|
|
export function createListNavigation(options: CreateListNavigationOptions): ListNavigationState {
|
|
const baseId = options.baseId ?? createUniqueId();
|
|
const orientation = options.orientation ?? "vertical";
|
|
const loop = options.loop ?? true;
|
|
const isSelection = options.mode === "selection";
|
|
const typeaheadEnabled = options.typeahead ?? true;
|
|
const getLabel = options.getLabel ?? ((v: string) => v);
|
|
|
|
const [highlightedValue, setHighlightedValue] = createSignal<string | undefined>(undefined);
|
|
|
|
const [selectedValue, setSelectedValue] = createControllableSignal<string | undefined>({
|
|
value: () => (isSelection ? options.value?.() : undefined),
|
|
defaultValue: () => (isSelection ? options.defaultValue : undefined),
|
|
onChange: (v) => {
|
|
if (v !== undefined && isSelection) options.onValueChange?.(v);
|
|
},
|
|
});
|
|
|
|
// --- Typeahead ---
|
|
let typeaheadBuffer = "";
|
|
let typeaheadTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
const handleTypeahead = (char: string) => {
|
|
if (!typeaheadEnabled) return;
|
|
clearTimeout(typeaheadTimeout);
|
|
typeaheadBuffer += char.toLowerCase();
|
|
typeaheadTimeout = setTimeout(() => {
|
|
typeaheadBuffer = "";
|
|
}, 500);
|
|
|
|
const items = options.items();
|
|
const match = items.find((v) => getLabel(v).toLowerCase().startsWith(typeaheadBuffer));
|
|
if (match) setHighlightedValue(match);
|
|
};
|
|
|
|
// --- Navigation helpers ---
|
|
const highlightNext = () => {
|
|
const items = options.items();
|
|
if (items.length === 0) return;
|
|
const current = highlightedValue();
|
|
if (current === undefined) {
|
|
setHighlightedValue(items[0]);
|
|
return;
|
|
}
|
|
const idx = items.indexOf(current);
|
|
const next = idx + 1;
|
|
if (next < items.length) {
|
|
setHighlightedValue(items[next]);
|
|
} else if (loop) {
|
|
setHighlightedValue(items[0]);
|
|
}
|
|
};
|
|
|
|
const highlightPrev = () => {
|
|
const items = options.items();
|
|
if (items.length === 0) return;
|
|
const current = highlightedValue();
|
|
if (current === undefined) {
|
|
setHighlightedValue(items[items.length - 1]);
|
|
return;
|
|
}
|
|
const idx = items.indexOf(current);
|
|
const prev = idx - 1;
|
|
if (prev >= 0) {
|
|
setHighlightedValue(items[prev]);
|
|
} else if (loop) {
|
|
setHighlightedValue(items[items.length - 1]);
|
|
}
|
|
};
|
|
|
|
const highlightFirst = () => {
|
|
const items = options.items();
|
|
if (items.length > 0) setHighlightedValue(items[0]);
|
|
};
|
|
|
|
const highlightLast = () => {
|
|
const items = options.items();
|
|
if (items.length > 0) setHighlightedValue(items[items.length - 1]);
|
|
};
|
|
|
|
const clearHighlight = () => setHighlightedValue(undefined);
|
|
|
|
const selectOrActivate = () => {
|
|
const value = highlightedValue();
|
|
if (value === undefined) return;
|
|
if (isSelection) {
|
|
setSelectedValue(value);
|
|
} else {
|
|
options.onActivate?.(value);
|
|
}
|
|
};
|
|
|
|
// --- Keyboard ---
|
|
const nextKey = orientation === "horizontal" ? "ArrowRight" : "ArrowDown";
|
|
const prevKey = orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
|
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
switch (e.key) {
|
|
case nextKey:
|
|
e.preventDefault();
|
|
highlightNext();
|
|
break;
|
|
case prevKey:
|
|
e.preventDefault();
|
|
highlightPrev();
|
|
break;
|
|
case "Home":
|
|
e.preventDefault();
|
|
highlightFirst();
|
|
break;
|
|
case "End":
|
|
e.preventDefault();
|
|
highlightLast();
|
|
break;
|
|
case "Enter":
|
|
case " ":
|
|
e.preventDefault();
|
|
selectOrActivate();
|
|
break;
|
|
case "Escape":
|
|
clearHighlight();
|
|
break;
|
|
default:
|
|
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
handleTypeahead(e.key);
|
|
}
|
|
}
|
|
};
|
|
|
|
// --- Item ID generation ---
|
|
const getItemId = (value: string) => `${baseId}-item-${value}`;
|
|
|
|
// --- Public API ---
|
|
const containerProps = {
|
|
role: isSelection ? "listbox" : "menu",
|
|
"aria-orientation": orientation,
|
|
get "aria-activedescendant"() {
|
|
const v = highlightedValue();
|
|
return v !== undefined ? getItemId(v) : undefined;
|
|
},
|
|
onKeyDown,
|
|
onPointerLeave: clearHighlight,
|
|
};
|
|
|
|
const getItemProps = (value: string) => ({
|
|
id: getItemId(value),
|
|
role: isSelection ? "option" : "menuitem",
|
|
...(isSelection && {
|
|
"aria-selected": selectedValue() === value ? "true" : "false",
|
|
}),
|
|
"data-highlighted": highlightedValue() === value ? ("" as const) : undefined,
|
|
...(isSelection && {
|
|
"data-state": selectedValue() === value ? "active" : "inactive",
|
|
}),
|
|
onPointerEnter: () => setHighlightedValue(value),
|
|
onPointerMove: () => {
|
|
if (highlightedValue() !== value) setHighlightedValue(value);
|
|
},
|
|
onClick: () => {
|
|
setHighlightedValue(value);
|
|
selectOrActivate();
|
|
},
|
|
});
|
|
|
|
return {
|
|
highlightedValue,
|
|
selectedValue,
|
|
containerProps,
|
|
getItemProps,
|
|
highlight: setHighlightedValue,
|
|
highlightFirst,
|
|
highlightLast,
|
|
clearHighlight,
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run tests/primitives/create-list-navigation.test.tsx
|
|
```
|
|
|
|
Expected: PASS — 14 tests.
|
|
|
|
- [ ] **Step 5: Full suite + typecheck + biome**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm typecheck
|
|
pnpm biome check packages/core/src/primitives/create-list-navigation.ts
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI && git add packages/core/src/primitives/create-list-navigation.ts packages/core/tests/primitives/create-list-navigation.test.tsx && git commit -m "feat: add createListNavigation primitive"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: `createRovingFocus` primitive
|
|
|
|
**Files:**
|
|
- Create: `packages/core/src/primitives/create-roving-focus.ts`
|
|
- Test: `packages/core/tests/primitives/create-roving-focus.test.tsx`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```tsx
|
|
// packages/core/tests/primitives/create-roving-focus.test.tsx
|
|
import { fireEvent, render, screen } from "@solidjs/testing-library";
|
|
import { describe, expect, it } from "vitest";
|
|
import { createRovingFocus } from "../../src/primitives/create-roving-focus";
|
|
|
|
function TestRoving(props: { orientation?: "horizontal" | "vertical"; loop?: boolean }) {
|
|
const roving = createRovingFocus({
|
|
orientation: props.orientation ?? "horizontal",
|
|
loop: props.loop,
|
|
});
|
|
|
|
return (
|
|
<div data-testid="container" {...roving.containerProps}>
|
|
<button data-roving-item data-testid="btn-a">A</button>
|
|
<button data-roving-item data-testid="btn-b">B</button>
|
|
<button data-roving-item data-testid="btn-c">C</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe("createRovingFocus", () => {
|
|
it("ArrowRight moves focus to next item (horizontal)", () => {
|
|
render(() => <TestRoving />);
|
|
const a = screen.getByTestId("btn-a");
|
|
a.focus();
|
|
fireEvent.keyDown(screen.getByTestId("container"), { key: "ArrowRight" });
|
|
expect(document.activeElement).toBe(screen.getByTestId("btn-b"));
|
|
});
|
|
|
|
it("ArrowLeft moves focus to previous item (horizontal)", () => {
|
|
render(() => <TestRoving />);
|
|
const b = screen.getByTestId("btn-b");
|
|
b.focus();
|
|
fireEvent.keyDown(screen.getByTestId("container"), { key: "ArrowLeft" });
|
|
expect(document.activeElement).toBe(screen.getByTestId("btn-a"));
|
|
});
|
|
|
|
it("ArrowDown moves focus in vertical mode", () => {
|
|
render(() => <TestRoving orientation="vertical" />);
|
|
const a = screen.getByTestId("btn-a");
|
|
a.focus();
|
|
fireEvent.keyDown(screen.getByTestId("container"), { key: "ArrowDown" });
|
|
expect(document.activeElement).toBe(screen.getByTestId("btn-b"));
|
|
});
|
|
|
|
it("Home moves to first, End moves to last", () => {
|
|
render(() => <TestRoving />);
|
|
const b = screen.getByTestId("btn-b");
|
|
b.focus();
|
|
fireEvent.keyDown(screen.getByTestId("container"), { key: "End" });
|
|
expect(document.activeElement).toBe(screen.getByTestId("btn-c"));
|
|
fireEvent.keyDown(screen.getByTestId("container"), { key: "Home" });
|
|
expect(document.activeElement).toBe(screen.getByTestId("btn-a"));
|
|
});
|
|
|
|
it("wraps when loop is true (default)", () => {
|
|
render(() => <TestRoving />);
|
|
const c = screen.getByTestId("btn-c");
|
|
c.focus();
|
|
fireEvent.keyDown(screen.getByTestId("container"), { key: "ArrowRight" });
|
|
expect(document.activeElement).toBe(screen.getByTestId("btn-a"));
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run tests/primitives/create-roving-focus.test.tsx
|
|
```
|
|
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 3: Implement `createRovingFocus`**
|
|
|
|
```ts
|
|
// packages/core/src/primitives/create-roving-focus.ts
|
|
|
|
/** Options for createRovingFocus. */
|
|
export interface CreateRovingFocusOptions {
|
|
/** @default "horizontal" */
|
|
orientation?: "horizontal" | "vertical" | "both";
|
|
/** Wrap at boundaries. @default true */
|
|
loop?: boolean;
|
|
}
|
|
|
|
/** Return type of createRovingFocus. */
|
|
export interface RovingFocusState {
|
|
/** Props to spread on the container element. */
|
|
containerProps: {
|
|
onKeyDown: (e: KeyboardEvent) => void;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Manages roving tabIndex focus within a container.
|
|
* Items must have the `data-roving-item` attribute.
|
|
* Arrow keys move real DOM focus between items.
|
|
*/
|
|
export function createRovingFocus(options?: CreateRovingFocusOptions): RovingFocusState {
|
|
const orientation = options?.orientation ?? "horizontal";
|
|
const loop = options?.loop ?? true;
|
|
|
|
const getItems = (container: HTMLElement): HTMLElement[] =>
|
|
Array.from(
|
|
container.querySelectorAll<HTMLElement>("[data-roving-item]:not([disabled])"),
|
|
);
|
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
const container = e.currentTarget as HTMLElement;
|
|
const items = getItems(container);
|
|
const focused = document.activeElement as HTMLElement;
|
|
const index = items.indexOf(focused);
|
|
if (index === -1) return;
|
|
|
|
const isNext =
|
|
(orientation !== "vertical" && e.key === "ArrowRight") ||
|
|
(orientation !== "horizontal" && e.key === "ArrowDown");
|
|
const isPrev =
|
|
(orientation !== "vertical" && e.key === "ArrowLeft") ||
|
|
(orientation !== "horizontal" && e.key === "ArrowUp");
|
|
|
|
let target: HTMLElement | undefined;
|
|
|
|
if (isNext) {
|
|
e.preventDefault();
|
|
const next = index + 1;
|
|
target = next < items.length ? items[next] : loop ? items[0] : undefined;
|
|
} else if (isPrev) {
|
|
e.preventDefault();
|
|
const prev = index - 1;
|
|
target = prev >= 0 ? items[prev] : loop ? items[items.length - 1] : undefined;
|
|
} else if (e.key === "Home") {
|
|
e.preventDefault();
|
|
target = items[0];
|
|
} else if (e.key === "End") {
|
|
e.preventDefault();
|
|
target = items[items.length - 1];
|
|
}
|
|
|
|
target?.focus();
|
|
};
|
|
|
|
return {
|
|
containerProps: { onKeyDown },
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run tests/primitives/create-roving-focus.test.tsx
|
|
```
|
|
|
|
Expected: PASS — 5 tests.
|
|
|
|
- [ ] **Step 5: Update primitives index + full suite + typecheck + biome**
|
|
|
|
Add exports to `packages/core/src/primitives/index.ts`:
|
|
|
|
```ts
|
|
export { createFloating } from "./create-floating";
|
|
export type { CreateFloatingOptions, FloatingState } from "./create-floating";
|
|
export { createListNavigation } from "./create-list-navigation";
|
|
export type { CreateListNavigationOptions, ListNavigationState } from "./create-list-navigation";
|
|
export { createRovingFocus } from "./create-roving-focus";
|
|
export type { CreateRovingFocusOptions, RovingFocusState } from "./create-roving-focus";
|
|
```
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm typecheck
|
|
pnpm biome check packages/core/src/primitives/
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI && git add packages/core/src/primitives/ packages/core/tests/primitives/ && git commit -m "feat: add createRovingFocus primitive and update primitives index"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Tooltip
|
|
|
|
**Files:**
|
|
- Create: `packages/core/src/components/tooltip/tooltip-context.ts`
|
|
- Create: `packages/core/src/components/tooltip/tooltip-root.tsx`
|
|
- Create: `packages/core/src/components/tooltip/tooltip-trigger.tsx`
|
|
- Create: `packages/core/src/components/tooltip/tooltip-content.tsx`
|
|
- Create: `packages/core/src/components/tooltip/tooltip-arrow.tsx`
|
|
- Create: `packages/core/src/components/tooltip/index.ts`
|
|
- Test: `packages/core/tests/components/tooltip/tooltip.test.tsx`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```tsx
|
|
// packages/core/tests/components/tooltip/tooltip.test.tsx
|
|
import { fireEvent, render, screen } from "@solidjs/testing-library";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { Tooltip } from "../../../src/components/tooltip/index";
|
|
|
|
describe("Tooltip", () => {
|
|
it("content has role=tooltip when open", () => {
|
|
render(() => (
|
|
<Tooltip defaultOpen>
|
|
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
<Tooltip.Content>Help text</Tooltip.Content>
|
|
</Tooltip>
|
|
));
|
|
expect(screen.getByRole("tooltip")).toBeTruthy();
|
|
});
|
|
|
|
it("trigger has aria-describedby pointing to content", () => {
|
|
render(() => (
|
|
<Tooltip defaultOpen>
|
|
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
<Tooltip.Content>Help text</Tooltip.Content>
|
|
</Tooltip>
|
|
));
|
|
const trigger = screen.getByText("Hover me");
|
|
const tooltip = screen.getByRole("tooltip");
|
|
expect(trigger.getAttribute("aria-describedby")).toBe(tooltip.id);
|
|
});
|
|
|
|
it("content not rendered when closed", () => {
|
|
render(() => (
|
|
<Tooltip>
|
|
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
<Tooltip.Content>Help text</Tooltip.Content>
|
|
</Tooltip>
|
|
));
|
|
expect(screen.queryByRole("tooltip")).toBeNull();
|
|
});
|
|
|
|
it("opens on trigger focus", () => {
|
|
render(() => (
|
|
<Tooltip openDelay={0}>
|
|
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
<Tooltip.Content>Help text</Tooltip.Content>
|
|
</Tooltip>
|
|
));
|
|
fireEvent.focus(screen.getByText("Hover me"));
|
|
expect(screen.getByRole("tooltip")).toBeTruthy();
|
|
});
|
|
|
|
it("closes on Escape", () => {
|
|
render(() => (
|
|
<Tooltip defaultOpen>
|
|
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
<Tooltip.Content>Help text</Tooltip.Content>
|
|
</Tooltip>
|
|
));
|
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
expect(screen.queryByRole("tooltip")).toBeNull();
|
|
});
|
|
|
|
it("controlled mode works", () => {
|
|
const onOpenChange = vi.fn();
|
|
render(() => (
|
|
<Tooltip open={true} onOpenChange={onOpenChange}>
|
|
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
<Tooltip.Content>Help text</Tooltip.Content>
|
|
</Tooltip>
|
|
));
|
|
expect(screen.getByRole("tooltip")).toBeTruthy();
|
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("content has data-state attribute", () => {
|
|
render(() => (
|
|
<Tooltip defaultOpen>
|
|
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
|
|
<Tooltip.Content data-testid="content">Help text</Tooltip.Content>
|
|
</Tooltip>
|
|
));
|
|
expect(screen.getByTestId("content").getAttribute("data-state")).toBe("open");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run tests/components/tooltip/tooltip.test.tsx
|
|
```
|
|
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 3: Implement Tooltip**
|
|
|
|
**tooltip-context.ts:**
|
|
```ts
|
|
// packages/core/src/components/tooltip/tooltip-context.ts
|
|
import type { Accessor } from "solid-js";
|
|
import { createContext, useContext } from "solid-js";
|
|
|
|
/** Internal context shared between all Tooltip parts. */
|
|
export interface InternalTooltipContextValue {
|
|
isOpen: Accessor<boolean>;
|
|
setOpen: (open: boolean) => void;
|
|
contentId: Accessor<string>;
|
|
triggerRef: Accessor<HTMLElement | null>;
|
|
setTriggerRef: (el: HTMLElement | null) => void;
|
|
}
|
|
|
|
const InternalTooltipContext = createContext<InternalTooltipContextValue>();
|
|
|
|
/**
|
|
* Returns the internal Tooltip context. Throws if used outside <Tooltip>.
|
|
*/
|
|
export function useInternalTooltipContext(): InternalTooltipContextValue {
|
|
const ctx = useContext(InternalTooltipContext);
|
|
if (!ctx) {
|
|
throw new Error(
|
|
"[PettyUI] Tooltip parts must be used inside <Tooltip>.\n" +
|
|
" Fix: Wrap Tooltip.Trigger and Tooltip.Content inside <Tooltip>.",
|
|
);
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
export const InternalTooltipContextProvider = InternalTooltipContext.Provider;
|
|
|
|
/** Public context exposed via Tooltip.useContext(). */
|
|
export interface TooltipContextValue {
|
|
/** Whether the tooltip is currently open. */
|
|
open: Accessor<boolean>;
|
|
}
|
|
|
|
const TooltipContext = createContext<TooltipContextValue>();
|
|
|
|
/**
|
|
* Returns the public Tooltip context. Throws if used outside <Tooltip>.
|
|
*/
|
|
export function useTooltipContext(): TooltipContextValue {
|
|
const ctx = useContext(TooltipContext);
|
|
if (!ctx) {
|
|
throw new Error("[PettyUI] Tooltip.useContext() called outside of <Tooltip>.");
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
export const TooltipContextProvider = TooltipContext.Provider;
|
|
```
|
|
|
|
**tooltip-root.tsx:**
|
|
```tsx
|
|
// packages/core/src/components/tooltip/tooltip-root.tsx
|
|
import type { JSX } from "solid-js";
|
|
import { createSignal, createUniqueId, splitProps } from "solid-js";
|
|
import {
|
|
type CreateDisclosureStateOptions,
|
|
createDisclosureState,
|
|
} from "../../primitives/create-disclosure-state";
|
|
import {
|
|
InternalTooltipContextProvider,
|
|
type InternalTooltipContextValue,
|
|
TooltipContextProvider,
|
|
} from "./tooltip-context";
|
|
|
|
/** Props for the Tooltip root component. */
|
|
export interface TooltipRootProps {
|
|
/** Controls open state externally. */
|
|
open?: boolean;
|
|
/** Initial open state when uncontrolled. */
|
|
defaultOpen?: boolean;
|
|
/** Called when open state changes. */
|
|
onOpenChange?: (open: boolean) => void;
|
|
/** Delay in ms before showing. @default 700 */
|
|
openDelay?: number;
|
|
/** Delay in ms before hiding. @default 300 */
|
|
closeDelay?: number;
|
|
children: JSX.Element;
|
|
}
|
|
|
|
// Module-level timestamp for "tooltip group" instant-open behavior
|
|
let lastTooltipCloseTime = 0;
|
|
const TOOLTIP_GROUP_TIMEOUT = 300;
|
|
|
|
/**
|
|
* Root component for Tooltip. Manages open state with configurable delays.
|
|
*/
|
|
export function TooltipRoot(props: TooltipRootProps): JSX.Element {
|
|
const [local] = splitProps(props, [
|
|
"open", "defaultOpen", "onOpenChange", "openDelay", "closeDelay", "children",
|
|
]);
|
|
|
|
const disclosure = createDisclosureState({
|
|
get open() { return local.open; },
|
|
get defaultOpen() { return local.defaultOpen; },
|
|
get onOpenChange() { return local.onOpenChange; },
|
|
} as CreateDisclosureStateOptions);
|
|
|
|
const contentId = createUniqueId();
|
|
const [triggerRef, setTriggerRef] = createSignal<HTMLElement | null>(null);
|
|
|
|
let openTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
let closeTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
const openWithDelay = () => {
|
|
clearTimeout(closeTimeout);
|
|
const now = Date.now();
|
|
const skipDelay = now - lastTooltipCloseTime < TOOLTIP_GROUP_TIMEOUT;
|
|
const delay = skipDelay ? 0 : (local.openDelay ?? 700);
|
|
if (delay === 0) {
|
|
disclosure.open();
|
|
} else {
|
|
openTimeout = setTimeout(() => disclosure.open(), delay);
|
|
}
|
|
};
|
|
|
|
const closeWithDelay = () => {
|
|
clearTimeout(openTimeout);
|
|
const delay = local.closeDelay ?? 300;
|
|
if (delay === 0) {
|
|
disclosure.close();
|
|
lastTooltipCloseTime = Date.now();
|
|
} else {
|
|
closeTimeout = setTimeout(() => {
|
|
disclosure.close();
|
|
lastTooltipCloseTime = Date.now();
|
|
}, delay);
|
|
}
|
|
};
|
|
|
|
const setOpen = (open: boolean) => {
|
|
if (open) {
|
|
openWithDelay();
|
|
} else {
|
|
closeWithDelay();
|
|
}
|
|
};
|
|
|
|
const internalCtx: InternalTooltipContextValue = {
|
|
isOpen: disclosure.isOpen,
|
|
setOpen,
|
|
contentId: () => contentId,
|
|
triggerRef,
|
|
setTriggerRef,
|
|
};
|
|
|
|
return (
|
|
<InternalTooltipContextProvider value={internalCtx}>
|
|
<TooltipContextProvider value={{ open: disclosure.isOpen }}>
|
|
{local.children}
|
|
</TooltipContextProvider>
|
|
</InternalTooltipContextProvider>
|
|
);
|
|
}
|
|
```
|
|
|
|
**tooltip-trigger.tsx:**
|
|
```tsx
|
|
// packages/core/src/components/tooltip/tooltip-trigger.tsx
|
|
import type { JSX } from "solid-js";
|
|
import { splitProps } from "solid-js";
|
|
import { useInternalTooltipContext } from "./tooltip-context";
|
|
|
|
/** Props for Tooltip.Trigger. */
|
|
export interface TooltipTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
children?: JSX.Element;
|
|
}
|
|
|
|
/** Element that opens the Tooltip on hover or focus. */
|
|
export function TooltipTrigger(props: TooltipTriggerProps): JSX.Element {
|
|
const [local, rest] = splitProps(props, ["children"]);
|
|
const ctx = useInternalTooltipContext();
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
ref={(el) => ctx.setTriggerRef(el)}
|
|
aria-describedby={ctx.isOpen() ? ctx.contentId() : undefined}
|
|
data-state={ctx.isOpen() ? "open" : "closed"}
|
|
{...rest}
|
|
onPointerEnter={(e) => {
|
|
if (typeof rest.onPointerEnter === "function") rest.onPointerEnter(e);
|
|
ctx.setOpen(true);
|
|
}}
|
|
onPointerLeave={(e) => {
|
|
if (typeof rest.onPointerLeave === "function") rest.onPointerLeave(e);
|
|
ctx.setOpen(false);
|
|
}}
|
|
onFocus={(e) => {
|
|
if (typeof rest.onFocus === "function") rest.onFocus(e);
|
|
ctx.setOpen(true);
|
|
}}
|
|
onBlur={(e) => {
|
|
if (typeof rest.onBlur === "function") rest.onBlur(e);
|
|
ctx.setOpen(false);
|
|
}}
|
|
>
|
|
{local.children}
|
|
</button>
|
|
);
|
|
}
|
|
```
|
|
|
|
**tooltip-content.tsx:**
|
|
```tsx
|
|
// packages/core/src/components/tooltip/tooltip-content.tsx
|
|
import { flip, offset, shift } from "@floating-ui/dom";
|
|
import type { Placement } from "@floating-ui/dom";
|
|
import type { JSX } from "solid-js";
|
|
import { Show, createEffect, createSignal, onCleanup, splitProps } from "solid-js";
|
|
import { createFloating } from "../../primitives/create-floating";
|
|
import { useInternalTooltipContext } from "./tooltip-context";
|
|
|
|
/** Props for Tooltip.Content. */
|
|
export interface TooltipContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
|
/** Floating placement. @default "top" */
|
|
placement?: Placement;
|
|
/** Offset from anchor in px. @default 8 */
|
|
offset?: number;
|
|
/** Auto-flip when clipped. @default true */
|
|
flip?: boolean;
|
|
/** Shift along axis to stay in viewport. @default true */
|
|
shift?: boolean;
|
|
/** Keep mounted when closed. */
|
|
forceMount?: boolean;
|
|
children?: JSX.Element;
|
|
}
|
|
|
|
/** Positioned floating panel with role=tooltip. */
|
|
export function TooltipContent(props: TooltipContentProps): JSX.Element {
|
|
const [local, rest] = splitProps(props, [
|
|
"children", "placement", "offset", "flip", "shift", "forceMount",
|
|
]);
|
|
const ctx = useInternalTooltipContext();
|
|
const [floatingRef, setFloatingRef] = createSignal<HTMLElement | null>(null);
|
|
|
|
const middleware = () => {
|
|
const mw = [];
|
|
if (local.offset !== 0) mw.push(offset(local.offset ?? 8));
|
|
if (local.flip !== false) mw.push(flip());
|
|
if (local.shift !== false) mw.push(shift());
|
|
return mw;
|
|
};
|
|
|
|
const floating = createFloating({
|
|
anchor: ctx.triggerRef,
|
|
floating: floatingRef,
|
|
placement: () => local.placement ?? "top",
|
|
middleware,
|
|
open: ctx.isOpen,
|
|
});
|
|
|
|
// Escape key closes tooltip
|
|
createEffect(() => {
|
|
if (!ctx.isOpen()) return;
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") ctx.setOpen(false);
|
|
};
|
|
document.addEventListener("keydown", handler);
|
|
onCleanup(() => document.removeEventListener("keydown", handler));
|
|
});
|
|
|
|
return (
|
|
<Show when={local.forceMount || ctx.isOpen()}>
|
|
<div
|
|
ref={setFloatingRef}
|
|
id={ctx.contentId()}
|
|
role="tooltip"
|
|
data-state={ctx.isOpen() ? "open" : "closed"}
|
|
style={floating.style()}
|
|
{...rest}
|
|
>
|
|
{local.children}
|
|
</div>
|
|
</Show>
|
|
);
|
|
}
|
|
```
|
|
|
|
**tooltip-arrow.tsx:**
|
|
```tsx
|
|
// packages/core/src/components/tooltip/tooltip-arrow.tsx
|
|
import type { JSX } from "solid-js";
|
|
import { splitProps } from "solid-js";
|
|
|
|
/** Props for Tooltip.Arrow. */
|
|
export interface TooltipArrowProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
|
/** Arrow width in px. @default 10 */
|
|
width?: number;
|
|
/** Arrow height in px. @default 5 */
|
|
height?: number;
|
|
}
|
|
|
|
/** Decorative arrow pointing to the trigger. Styled via CSS. */
|
|
export function TooltipArrow(props: TooltipArrowProps): JSX.Element {
|
|
const [local, rest] = splitProps(props, ["width", "height"]);
|
|
return (
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
width: `${local.width ?? 10}px`,
|
|
height: `${local.height ?? 5}px`,
|
|
}}
|
|
{...rest}
|
|
/>
|
|
);
|
|
}
|
|
```
|
|
|
|
**index.ts:**
|
|
```ts
|
|
// packages/core/src/components/tooltip/index.ts
|
|
import { useTooltipContext } from "./tooltip-context";
|
|
import { TooltipArrow } from "./tooltip-arrow";
|
|
import { TooltipContent } from "./tooltip-content";
|
|
import { TooltipRoot } from "./tooltip-root";
|
|
import { TooltipTrigger } from "./tooltip-trigger";
|
|
|
|
export const Tooltip = Object.assign(TooltipRoot, {
|
|
Trigger: TooltipTrigger,
|
|
Content: TooltipContent,
|
|
Arrow: TooltipArrow,
|
|
useContext: useTooltipContext,
|
|
});
|
|
|
|
export type { TooltipRootProps } from "./tooltip-root";
|
|
export type { TooltipTriggerProps } from "./tooltip-trigger";
|
|
export type { TooltipContentProps } from "./tooltip-content";
|
|
export type { TooltipArrowProps } from "./tooltip-arrow";
|
|
export type { TooltipContextValue } from "./tooltip-context";
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run tests/components/tooltip/tooltip.test.tsx
|
|
```
|
|
|
|
Expected: PASS — 7 tests.
|
|
|
|
- [ ] **Step 5: Full suite + typecheck + biome**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm typecheck
|
|
pnpm biome check packages/core/src/components/tooltip/
|
|
```
|
|
|
|
- [ ] **Step 6: Add package.json export + commit**
|
|
|
|
Add to `packages/core/package.json` exports:
|
|
```json
|
|
"./tooltip": { "solid": "./src/components/tooltip/index.ts", "import": "./dist/components/tooltip/index.js", "require": "./dist/components/tooltip/index.cjs" }
|
|
```
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI && git add packages/core/src/components/tooltip/ packages/core/tests/components/tooltip/ packages/core/package.json && git commit -m "feat: add Tooltip component"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Popover
|
|
|
|
**Files:**
|
|
- Create: `packages/core/src/components/popover/popover-context.ts`
|
|
- Create: `packages/core/src/components/popover/popover-root.tsx`
|
|
- Create: `packages/core/src/components/popover/popover-trigger.tsx`
|
|
- Create: `packages/core/src/components/popover/popover-content.tsx`
|
|
- Create: `packages/core/src/components/popover/popover-arrow.tsx`
|
|
- Create: `packages/core/src/components/popover/popover-close.tsx`
|
|
- Create: `packages/core/src/components/popover/popover-portal.tsx`
|
|
- Create: `packages/core/src/components/popover/index.ts`
|
|
- Test: `packages/core/tests/components/popover/popover.test.tsx`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```tsx
|
|
// packages/core/tests/components/popover/popover.test.tsx
|
|
import { fireEvent, render, screen } from "@solidjs/testing-library";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { Popover } from "../../../src/components/popover/index";
|
|
|
|
describe("Popover", () => {
|
|
it("content has role=dialog when open", () => {
|
|
render(() => (
|
|
<Popover defaultOpen>
|
|
<Popover.Trigger>Open</Popover.Trigger>
|
|
<Popover.Content>
|
|
<p>Popover body</p>
|
|
</Popover.Content>
|
|
</Popover>
|
|
));
|
|
expect(screen.getByRole("dialog")).toBeTruthy();
|
|
});
|
|
|
|
it("trigger has correct ARIA attributes", () => {
|
|
render(() => (
|
|
<Popover defaultOpen>
|
|
<Popover.Trigger>Open</Popover.Trigger>
|
|
<Popover.Content>Body</Popover.Content>
|
|
</Popover>
|
|
));
|
|
const trigger = screen.getByText("Open");
|
|
expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
|
|
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
});
|
|
|
|
it("click trigger opens popover", () => {
|
|
render(() => (
|
|
<Popover>
|
|
<Popover.Trigger>Open</Popover.Trigger>
|
|
<Popover.Content>Body</Popover.Content>
|
|
</Popover>
|
|
));
|
|
expect(screen.queryByRole("dialog")).toBeNull();
|
|
fireEvent.click(screen.getByText("Open"));
|
|
expect(screen.getByRole("dialog")).toBeTruthy();
|
|
});
|
|
|
|
it("Escape closes popover", () => {
|
|
render(() => (
|
|
<Popover defaultOpen>
|
|
<Popover.Trigger>Open</Popover.Trigger>
|
|
<Popover.Content>Body</Popover.Content>
|
|
</Popover>
|
|
));
|
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
expect(screen.queryByRole("dialog")).toBeNull();
|
|
});
|
|
|
|
it("Close button closes popover", () => {
|
|
render(() => (
|
|
<Popover defaultOpen>
|
|
<Popover.Trigger>Open</Popover.Trigger>
|
|
<Popover.Content>
|
|
<Popover.Close>Close</Popover.Close>
|
|
</Popover.Content>
|
|
</Popover>
|
|
));
|
|
fireEvent.click(screen.getByText("Close"));
|
|
expect(screen.queryByRole("dialog")).toBeNull();
|
|
});
|
|
|
|
it("controlled mode works", () => {
|
|
const onOpenChange = vi.fn();
|
|
render(() => (
|
|
<Popover open={true} onOpenChange={onOpenChange}>
|
|
<Popover.Trigger>Open</Popover.Trigger>
|
|
<Popover.Content>Body</Popover.Content>
|
|
</Popover>
|
|
));
|
|
expect(screen.getByRole("dialog")).toBeTruthy();
|
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("content has data-state attribute", () => {
|
|
render(() => (
|
|
<Popover defaultOpen>
|
|
<Popover.Trigger>Open</Popover.Trigger>
|
|
<Popover.Content data-testid="content">Body</Popover.Content>
|
|
</Popover>
|
|
));
|
|
expect(screen.getByTestId("content").getAttribute("data-state")).toBe("open");
|
|
});
|
|
|
|
it("content is positioned (has style)", () => {
|
|
render(() => (
|
|
<Popover defaultOpen>
|
|
<Popover.Trigger>Open</Popover.Trigger>
|
|
<Popover.Content data-testid="content">Body</Popover.Content>
|
|
</Popover>
|
|
));
|
|
const content = screen.getByTestId("content");
|
|
expect(content.style.position).toBeTruthy();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run tests/components/popover/popover.test.tsx
|
|
```
|
|
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 3: Implement Popover**
|
|
|
|
Popover follows the same pattern as Drawer/AlertDialog but uses `createFloating` for positioning instead of a full-screen overlay. Key differences:
|
|
- `role="dialog"` on content (same as Drawer)
|
|
- Positioned via `createFloating` (unlike Dialog which is centered)
|
|
- Uses `createDismiss` for Escape + outside click (like Drawer)
|
|
- Optional modal mode adds focus trap + scroll lock
|
|
- Has `popover-close.tsx` (same as `drawer-close.tsx`)
|
|
- Trigger is click-based toggle (same as Drawer trigger)
|
|
|
|
**popover-context.ts** — Same dual-context pattern as Drawer. Internal context has: `isOpen`, `setOpen`, `modal`, `contentId`, `titleId/setTitleId`, `descriptionId/setDescriptionId`, `triggerRef/setTriggerRef`. Public context has: `open`.
|
|
|
|
**popover-root.tsx** — Same as Drawer root but adds `modal` prop (default false) and `triggerRef` signal. Uses `createDisclosureState` with getter properties.
|
|
|
|
**popover-trigger.tsx** — Click toggles open. Sets `aria-haspopup="dialog"`, `aria-expanded="true"/"false"`, `aria-controls`. Registers its element via `ctx.setTriggerRef`.
|
|
|
|
**popover-content.tsx** — Uses `createFloating` for positioning (default "bottom", flip+shift+offset(8)). Uses `createDismiss` for Escape + outside click. When `modal` is true: also uses `createFocusTrap` + `createScrollLock`. Sets `role="dialog"`, `aria-modal` (only when modal), `aria-labelledby`, `aria-describedby`.
|
|
|
|
**popover-close.tsx** — Same pattern as `drawer-close.tsx`. Button that calls `ctx.setOpen(false)`.
|
|
|
|
**popover-arrow.tsx** — Same as `tooltip-arrow.tsx`.
|
|
|
|
**popover-portal.tsx** — Same as `drawer-portal.tsx`.
|
|
|
|
**index.ts** — `Object.assign(PopoverRoot, { Trigger, Content, Arrow, Close, Portal, useContext })`.
|
|
|
|
The engineer should implement these following the exact patterns in `drawer-context.ts`, `drawer-root.tsx`, `drawer-trigger.tsx`, `drawer-content.tsx`, `drawer-close.tsx`, `drawer-portal.tsx` but replacing Drawer-specific details with Popover-specific details, and adding `createFloating` positioning to the content.
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run tests/components/popover/popover.test.tsx
|
|
```
|
|
|
|
Expected: PASS — 8 tests.
|
|
|
|
- [ ] **Step 5: Full suite + typecheck + biome**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm typecheck
|
|
pnpm biome check packages/core/src/components/popover/
|
|
```
|
|
|
|
- [ ] **Step 6: Add package.json export + commit**
|
|
|
|
Add to `packages/core/package.json` exports:
|
|
```json
|
|
"./popover": { "solid": "./src/components/popover/index.ts", "import": "./dist/components/popover/index.js", "require": "./dist/components/popover/index.cjs" }
|
|
```
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI && git add packages/core/src/components/popover/ packages/core/tests/components/popover/ packages/core/package.json && git commit -m "feat: add Popover component"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: HoverCard
|
|
|
|
**Files:**
|
|
- Create: `packages/core/src/components/hover-card/hover-card-context.ts`
|
|
- Create: `packages/core/src/components/hover-card/hover-card-root.tsx`
|
|
- Create: `packages/core/src/components/hover-card/hover-card-trigger.tsx`
|
|
- Create: `packages/core/src/components/hover-card/hover-card-content.tsx`
|
|
- Create: `packages/core/src/components/hover-card/hover-card-arrow.tsx`
|
|
- Create: `packages/core/src/components/hover-card/index.ts`
|
|
- Test: `packages/core/tests/components/hover-card/hover-card.test.tsx`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```tsx
|
|
// packages/core/tests/components/hover-card/hover-card.test.tsx
|
|
import { fireEvent, render, screen } from "@solidjs/testing-library";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { HoverCard } from "../../../src/components/hover-card/index";
|
|
|
|
describe("HoverCard", () => {
|
|
it("content not rendered when closed", () => {
|
|
render(() => (
|
|
<HoverCard>
|
|
<HoverCard.Trigger>Hover me</HoverCard.Trigger>
|
|
<HoverCard.Content data-testid="content">Card</HoverCard.Content>
|
|
</HoverCard>
|
|
));
|
|
expect(screen.queryByTestId("content")).toBeNull();
|
|
});
|
|
|
|
it("opens with defaultOpen", () => {
|
|
render(() => (
|
|
<HoverCard defaultOpen>
|
|
<HoverCard.Trigger>Hover me</HoverCard.Trigger>
|
|
<HoverCard.Content data-testid="content">Card</HoverCard.Content>
|
|
</HoverCard>
|
|
));
|
|
expect(screen.getByTestId("content")).toBeTruthy();
|
|
});
|
|
|
|
it("closes on Escape", () => {
|
|
render(() => (
|
|
<HoverCard defaultOpen>
|
|
<HoverCard.Trigger>Hover me</HoverCard.Trigger>
|
|
<HoverCard.Content data-testid="content">Card</HoverCard.Content>
|
|
</HoverCard>
|
|
));
|
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
expect(screen.queryByTestId("content")).toBeNull();
|
|
});
|
|
|
|
it("content has data-state attribute", () => {
|
|
render(() => (
|
|
<HoverCard defaultOpen>
|
|
<HoverCard.Trigger>Hover me</HoverCard.Trigger>
|
|
<HoverCard.Content data-testid="content">Card</HoverCard.Content>
|
|
</HoverCard>
|
|
));
|
|
expect(screen.getByTestId("content").getAttribute("data-state")).toBe("open");
|
|
});
|
|
|
|
it("controlled mode works", () => {
|
|
const onOpenChange = vi.fn();
|
|
render(() => (
|
|
<HoverCard open={true} onOpenChange={onOpenChange}>
|
|
<HoverCard.Trigger>Hover me</HoverCard.Trigger>
|
|
<HoverCard.Content data-testid="content">Card</HoverCard.Content>
|
|
</HoverCard>
|
|
));
|
|
expect(screen.getByTestId("content")).toBeTruthy();
|
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("content is positioned", () => {
|
|
render(() => (
|
|
<HoverCard defaultOpen>
|
|
<HoverCard.Trigger>Hover me</HoverCard.Trigger>
|
|
<HoverCard.Content data-testid="content">Card</HoverCard.Content>
|
|
</HoverCard>
|
|
));
|
|
const content = screen.getByTestId("content");
|
|
expect(content.style.position).toBeTruthy();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run tests/components/hover-card/hover-card.test.tsx
|
|
```
|
|
|
|
Expected: FAIL — module not found.
|
|
|
|
- [ ] **Step 3: Implement HoverCard**
|
|
|
|
HoverCard is structurally identical to Tooltip with these differences:
|
|
- No ARIA role on content (supplementary, not announced — no `role` attribute)
|
|
- Content stays open while pointer is inside it (add `onPointerEnter`/`onPointerLeave` on content that cancels close)
|
|
- Default placement is "bottom" (not "top" like Tooltip)
|
|
- No focus/blur triggers — pointer only
|
|
|
|
**hover-card-context.ts** — Same as tooltip-context.ts. Internal: `isOpen`, `setOpen`, `contentId`, `triggerRef/setTriggerRef`. Public: `open`.
|
|
|
|
**hover-card-root.tsx** — Same as tooltip-root.tsx with delay timers. Same "group" instant-open behavior is NOT needed (each HoverCard is independent).
|
|
|
|
**hover-card-trigger.tsx** — Only `onPointerEnter`/`onPointerLeave` (no focus/blur). Renders a `<a>` or generic element — use `<span>` wrapper with passthrough. Sets `data-state` but no ARIA roles.
|
|
|
|
**hover-card-content.tsx** — Like tooltip-content.tsx but:
|
|
- No `role` attribute
|
|
- Has its own `onPointerEnter` (cancels close timer) and `onPointerLeave` (starts close timer)
|
|
- Default placement "bottom"
|
|
- Escape key closes
|
|
|
|
**hover-card-arrow.tsx** — Same as tooltip-arrow.tsx.
|
|
|
|
**index.ts** — `Object.assign(HoverCardRoot, { Trigger, Content, Arrow, useContext })`.
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run tests/components/hover-card/hover-card.test.tsx
|
|
```
|
|
|
|
Expected: PASS — 6 tests.
|
|
|
|
- [ ] **Step 5: Full suite + typecheck + biome**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm typecheck
|
|
pnpm biome check packages/core/src/components/hover-card/
|
|
```
|
|
|
|
- [ ] **Step 6: Add package.json export + commit**
|
|
|
|
Add to `packages/core/package.json` exports:
|
|
```json
|
|
"./hover-card": { "solid": "./src/components/hover-card/index.ts", "import": "./dist/components/hover-card/index.js", "require": "./dist/components/hover-card/index.cjs" }
|
|
```
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI && git add packages/core/src/components/hover-card/ packages/core/tests/components/hover-card/ packages/core/package.json && git commit -m "feat: add HoverCard component"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Final Verification
|
|
|
|
- [ ] **Step 1: Run full test suite**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run
|
|
```
|
|
|
|
Expected: All tests pass (~200+ tests across 29+ files).
|
|
|
|
- [ ] **Step 2: Typecheck**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm typecheck
|
|
```
|
|
|
|
Expected: No errors.
|
|
|
|
- [ ] **Step 3: Biome on all new code**
|
|
|
|
```bash
|
|
cd /Users/matsbosson/Documents/StayThree/PettyUI && pnpm biome check packages/core/src/primitives/ packages/core/src/components/tooltip/ packages/core/src/components/popover/ packages/core/src/components/hover-card/
|
|
```
|
|
|
|
Expected: No errors.
|
|
|
|
- [ ] **Step 4: Verify package.json exports**
|
|
|
|
```bash
|
|
node -e "const p=require('./packages/core/package.json'); const keys=Object.keys(p.exports); console.log(keys.filter(k=>['./tooltip','./popover','./hover-card'].includes(k)).join(', '))"
|
|
```
|
|
|
|
Expected: `./tooltip, ./popover, ./hover-card`
|