58 KiB
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:
- Never destructure SolidJS props — always use
splitProps - Every exported function/component/interface needs JSDoc
/** ... */ - Use
createUniqueId()for all IDs (SSR-safe) aria-*boolean attributes as explicit strings ("true"/"false"), not booleanshidden={expr || undefined}— never emithidden="false"- Compound export:
Object.assign(Root, { Trigger, Content, ... }) - Dual context: Internal (for parts) + Public (for consumers)
- 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
// 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
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
// 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
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
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
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
// 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
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
// 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
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
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
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
// 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
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
// 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
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:
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";
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
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
// 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
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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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
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
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:
"./tooltip": { "solid": "./src/components/tooltip/index.ts", "import": "./dist/components/tooltip/index.js", "require": "./dist/components/tooltip/index.cjs" }
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
// 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
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
createDismissfor Escape + outside click (like Drawer) - Optional modal mode adds focus trap + scroll lock
- Has
popover-close.tsx(same asdrawer-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
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
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:
"./popover": { "solid": "./src/components/popover/index.ts", "import": "./dist/components/popover/index.js", "require": "./dist/components/popover/index.cjs" }
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
// 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
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
roleattribute) - Content stays open while pointer is inside it (add
onPointerEnter/onPointerLeaveon 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
roleattribute - Has its own
onPointerEnter(cancels close timer) andonPointerLeave(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
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
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:
"./hover-card": { "solid": "./src/components/hover-card/index.ts", "import": "./dist/components/hover-card/index.js", "require": "./dist/components/hover-card/index.cjs" }
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
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm vitest run
Expected: All tests pass (~200+ tests across 29+ files).
- Step 2: Typecheck
cd /Users/matsbosson/Documents/StayThree/PettyUI/packages/core && pnpm typecheck
Expected: No errors.
- Step 3: Biome on all new code
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
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