diff --git a/web/src/components/ui/tooltip.test.tsx b/web/src/components/ui/tooltip.test.tsx new file mode 100644 index 0000000..b2e6e80 --- /dev/null +++ b/web/src/components/ui/tooltip.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; + +import { + Tooltip, + TooltipProvider, + TooltipRoot, + TooltipTrigger, + TooltipPositioner, + TooltipPopup, +} from "./tooltip"; + +describe("Tooltip", () => { + it("shows its content in a portal on hover", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Objects nav" }); + const body = within(document.body); + + expect(body.queryByText("Objects")).toBeNull(); + + await user.hover(trigger); + + expect(await body.findByText("Objects")).toBeVisible(); + }); + + it("shows its content on keyboard focus", async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + const body = within(document.body); + + await user.tab(); + + expect(await body.findByText("Help text")).toBeVisible(); + }); + + it("delegates the trigger element via render (keeps the host tag)", () => { + render( + + Go + , + ); + + const link = screen.getByRole("link", { name: "Go" }); + expect(link).toHaveAttribute("href", "/objects"); + }); + + it("composes from the raw parts", async () => { + const user = userEvent.setup(); + + render( + + + Raw} /> + + + Raw tip + + + + , + ); + + await user.hover(screen.getByRole("button", { name: "Raw" })); + + const body = within(document.body); + expect(await body.findByText("Raw tip")).toBeVisible(); + }); +}); diff --git a/web/src/components/ui/tooltip.tsx b/web/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..53b4512 --- /dev/null +++ b/web/src/components/ui/tooltip.tsx @@ -0,0 +1,82 @@ +import type { ReactElement, ReactNode } from "react"; +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; + +import { cn } from "@/lib/utils"; + +type Side = NonNullable; + +function TooltipProvider({ ...props }: TooltipPrimitive.Provider.Props) { + return ; +} + +function TooltipRoot({ ...props }: TooltipPrimitive.Root.Props) { + return ; +} + +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { + return ; +} + +function TooltipPopup({ className, ...props }: TooltipPrimitive.Popup.Props) { + return ( + + ); +} + +function TooltipPositioner({ className, ...props }: TooltipPrimitive.Positioner.Props) { + return ( + + ); +} + +export type TooltipProps = { + /** Text shown in the tooltip popup. */ + content: ReactNode; + /** The element the tooltip is attached to. Rendered as the trigger. */ + children: ReactElement; + /** Which side of the trigger to place the popup. Defaults to `"right"`. */ + side?: Side; + /** Pixel gap between the trigger and the popup. */ + sideOffset?: number; +}; + +/** + * Standalone tooltip: wraps its own `Provider`, so it can be dropped in + * anywhere without an ancestor provider. `children` is delegated to the + * Base UI trigger via `render`, so the underlying element keeps its own + * tag/handlers (e.g. a `NavLink` for the collapsed sidebar). + */ +function Tooltip({ content, children, side = "right", sideOffset = 6 }: TooltipProps) { + return ( + + + + + + {content} + + + + + ); +} + +export { + Tooltip, + TooltipProvider, + TooltipRoot, + TooltipTrigger, + TooltipPositioner, + TooltipPopup, +}; diff --git a/web/src/lib/use-media-query.ts b/web/src/lib/use-media-query.ts new file mode 100644 index 0000000..c918347 --- /dev/null +++ b/web/src/lib/use-media-query.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +/** SSR-safe `matchMedia` subscription; `true` while `query` matches. */ +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => + typeof window !== "undefined" ? window.matchMedia(query).matches : false, + ); + + useEffect(() => { + const mql = window.matchMedia(query); + const onChange = () => setMatches(mql.matches); + + onChange(); + mql.addEventListener("change", onChange); + + return () => mql.removeEventListener("change", onChange); + }, [query]); + + return matches; +}