PettyUI/docs/superpowers/plans/2026-03-29-plan3-primitives-floating.md
2026-03-29 10:21:30 +07:00

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.tscreateControllableSignal<T>(options)[Accessor<T>, setter]
  • create-disclosure-state.tscreateDisclosureState(options){ isOpen, open, close, toggle }
  • create-register-id.tscreateRegisterId()[Accessor<string|undefined>, setter]

Utilities at packages/core/src/utilities/:

  • dismiss/create-dismiss.tscreateDismiss({ getContainer, onDismiss, dismissOnEscape?, dismissOnPointerOutside? }){ attach, detach }
  • focus-trap/create-focus-trap.tscreateFocusTrap(getContainer){ activate, deactivate }
  • scroll-lock/create-scroll-lock.tscreateScrollLock(){ lock, unlock }

Rules every component must follow:

  1. Never destructure SolidJS props — always use splitProps
  2. Every exported function/component/interface needs JSDoc /** ... */
  3. Use createUniqueId() for all IDs (SSR-safe)
  4. aria-* boolean attributes as explicit strings ("true" / "false"), not booleans
  5. hidden={expr || undefined} — never emit hidden="false"
  6. Compound export: Object.assign(Root, { Trigger, Content, ... })
  7. Dual context: Internal (for parts) + Public (for consumers)
  8. Error messages: [PettyUI] Component.Part used outside <Component>.\n Fix: ...

Working directory: /Users/matsbosson/Documents/StayThree/PettyUI

Test commands:

  • Single test: cd packages/core && pnpm vitest run tests/path/to/test.tsx
  • Full suite: cd packages/core && pnpm vitest run
  • Typecheck: cd packages/core && pnpm typecheck
  • Biome: pnpm biome check packages/core/src/path/

Task 1: createFloating primitive

Files:

  • Create: packages/core/src/primitives/create-floating.ts

  • Test: packages/core/tests/primitives/create-floating.test.tsx

  • Step 1: Write the failing test

// 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 createDismiss for Escape + outside click (like Drawer)
  • Optional modal mode adds focus trap + scroll lock
  • Has popover-close.tsx (same as drawer-close.tsx)
  • Trigger is click-based toggle (same as Drawer trigger)

popover-context.ts — Same dual-context pattern as Drawer. Internal context has: isOpen, setOpen, modal, contentId, titleId/setTitleId, descriptionId/setDescriptionId, triggerRef/setTriggerRef. Public context has: open.

popover-root.tsx — Same as Drawer root but adds modal prop (default false) and triggerRef signal. Uses createDisclosureState with getter properties.

popover-trigger.tsx — Click toggles open. Sets aria-haspopup="dialog", aria-expanded="true"/"false", aria-controls. Registers its element via ctx.setTriggerRef.

popover-content.tsx — Uses createFloating for positioning (default "bottom", flip+shift+offset(8)). Uses createDismiss for Escape + outside click. When modal is true: also uses createFocusTrap + createScrollLock. Sets role="dialog", aria-modal (only when modal), aria-labelledby, aria-describedby.

popover-close.tsx — Same pattern as drawer-close.tsx. Button that calls ctx.setOpen(false).

popover-arrow.tsx — Same as tooltip-arrow.tsx.

popover-portal.tsx — Same as drawer-portal.tsx.

index.tsObject.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 role attribute)
  • Content stays open while pointer is inside it (add onPointerEnter/onPointerLeave on content that cancels close)
  • Default placement is "bottom" (not "top" like Tooltip)
  • No focus/blur triggers — pointer only

hover-card-context.ts — Same as tooltip-context.ts. Internal: isOpen, setOpen, contentId, triggerRef/setTriggerRef. Public: open.

hover-card-root.tsx — Same as tooltip-root.tsx with delay timers. Same "group" instant-open behavior is NOT needed (each HoverCard is independent).

hover-card-trigger.tsx — Only onPointerEnter/onPointerLeave (no focus/blur). Renders a <a> or generic element — use <span> wrapper with passthrough. Sets data-state but no ARIA roles.

hover-card-content.tsx — Like tooltip-content.tsx but:

  • No role attribute
  • Has its own onPointerEnter (cancels close timer) and onPointerLeave (starts close timer)
  • Default placement "bottom"
  • Escape key closes

hover-card-arrow.tsx — Same as tooltip-arrow.tsx.

index.tsObject.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