From 06eba6d5510be54e9dbd822a868f24dd18c07afd Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 10:21:30 +0700 Subject: [PATCH] Plan for floating components --- .../2026-03-29-plan3-primitives-floating.md | 1767 +++++++++++++++++ 1 file changed, 1767 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-29-plan3-primitives-floating.md diff --git a/docs/superpowers/plans/2026-03-29-plan3-primitives-floating.md b/docs/superpowers/plans/2026-03-29-plan3-primitives-floating.md new file mode 100644 index 0000000..949b94b --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-plan3-primitives-floating.md @@ -0,0 +1,1767 @@ +# 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 ( +