# 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(options)` → `[Accessor, setter]` - `create-disclosure-state.ts` — `createDisclosureState(options)` → `{ isOpen, open, close, toggle }` - `create-register-id.ts` — `createRegisterId()` → `[Accessor, 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 .\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 | undefined; render(() => { const [anchor, setAnchor] = createSignal(null); const [floating, setFloating] = createSignal(null); state = createFloating({ anchor, floating, placement: () => "bottom", }); return (
Float
); }); 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 | undefined; render(() => { const [anchor, setAnchor] = createSignal(null); const [floating, setFloating] = createSignal(null); state = createFloating({ anchor, floating, placement: () => "bottom", open: () => false, }); return (
Float
); }); // 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 | undefined; render(() => { const [anchor, setAnchor] = createSignal(null); const [floating, setFloating] = createSignal(null); state = createFloating({ anchor, floating, strategy: () => "fixed", }); return (
Float
); }); 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; /** The floating element to position. */ floating: Accessor; /** Desired placement. @default "bottom" */ placement?: Accessor; /** Floating UI middleware (flip, shift, offset, arrow, etc.). */ middleware?: Accessor; /** CSS positioning strategy. @default "absolute" */ strategy?: Accessor; /** Only compute position when true. @default () => true */ open?: Accessor; } /** Reactive floating position state. */ export interface FloatingState { /** Computed x position in px. */ x: Accessor; /** Computed y position in px. */ y: Accessor; /** Actual placement after middleware (may differ from requested after flip). */ placement: Accessor; /** Ready-to-spread CSS: { position, top, left }. */ style: Accessor; } /** * 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( 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 = () => ({ 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 (
{props.items.map((item) => (
{item}
))}
); } describe("createListNavigation", () => { it("container has role=listbox in selection mode", () => { render(() => ); expect(screen.getByTestId("container").getAttribute("role")).toBe("listbox"); }); it("container has role=menu in activation mode", () => { render(() => ); expect(screen.getByTestId("container").getAttribute("role")).toBe("menu"); }); it("ArrowDown highlights next item", () => { render(() => ); 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(() => ); 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(() => ); 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(() => ( )); 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(() => ( )); 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(() => ( )); fireEvent.click(screen.getByTestId("item-b")); expect(onValueChange).toHaveBeenCalledWith("b"); }); it("aria-activedescendant tracks highlighted item", () => { render(() => ); 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(() => ( 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(() => ); fireEvent.pointerEnter(screen.getByTestId("item-b")); expect(screen.getByTestId("item-b").getAttribute("data-highlighted")).toBe(""); }); it("pointer leave clears highlight", () => { render(() => ); 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(() => ); 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(() => ); expect(screen.getByTestId("item-b").getAttribute("aria-selected")).toBe("true"); }); it("items have correct roles", () => { render(() => ); expect(screen.getByTestId("item-a").getAttribute("role")).toBe("option"); // Cleanup and render activation mode const { unmount } = render(() => ); 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; /** "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; /** 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; /** Currently selected value (selection mode). */ selectedValue: Accessor; /** Props to spread on the list container. */ containerProps: { role: string; "aria-orientation": string; "aria-activedescendant": Accessor; 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(undefined); const [selectedValue, setSelectedValue] = createControllableSignal({ 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 | 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 (
); } describe("createRovingFocus", () => { it("ArrowRight moves focus to next item (horizontal)", () => { render(() => ); 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(() => ); 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(() => ); 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(() => ); 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(() => ); 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("[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(() => ( Hover me Help text )); expect(screen.getByRole("tooltip")).toBeTruthy(); }); it("trigger has aria-describedby pointing to content", () => { render(() => ( Hover me Help text )); 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(() => ( Hover me Help text )); expect(screen.queryByRole("tooltip")).toBeNull(); }); it("opens on trigger focus", () => { render(() => ( Hover me Help text )); fireEvent.focus(screen.getByText("Hover me")); expect(screen.getByRole("tooltip")).toBeTruthy(); }); it("closes on Escape", () => { render(() => ( Hover me Help text )); fireEvent.keyDown(document, { key: "Escape" }); expect(screen.queryByRole("tooltip")).toBeNull(); }); it("controlled mode works", () => { const onOpenChange = vi.fn(); render(() => ( Hover me Help text )); expect(screen.getByRole("tooltip")).toBeTruthy(); fireEvent.keyDown(document, { key: "Escape" }); expect(onOpenChange).toHaveBeenCalledWith(false); }); it("content has data-state attribute", () => { render(() => ( Hover me Help text )); 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; setOpen: (open: boolean) => void; contentId: Accessor; triggerRef: Accessor; setTriggerRef: (el: HTMLElement | null) => void; } const InternalTooltipContext = createContext(); /** * Returns the internal Tooltip context. Throws if used outside . */ export function useInternalTooltipContext(): InternalTooltipContextValue { const ctx = useContext(InternalTooltipContext); if (!ctx) { throw new Error( "[PettyUI] Tooltip parts must be used inside .\n" + " Fix: Wrap Tooltip.Trigger and Tooltip.Content inside .", ); } return ctx; } export const InternalTooltipContextProvider = InternalTooltipContext.Provider; /** Public context exposed via Tooltip.useContext(). */ export interface TooltipContextValue { /** Whether the tooltip is currently open. */ open: Accessor; } const TooltipContext = createContext(); /** * Returns the public Tooltip context. Throws if used outside . */ export function useTooltipContext(): TooltipContextValue { const ctx = useContext(TooltipContext); if (!ctx) { throw new Error("[PettyUI] Tooltip.useContext() called outside of ."); } 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(null); let openTimeout: ReturnType | undefined; let closeTimeout: ReturnType | 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 ( {local.children} ); } ``` **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 { 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 ( ); } ``` **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 { /** 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(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 ( ); } ``` **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 { /** 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 (