feat(web): useMediaQuery hook + Base UI tooltip wrapper (#44)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||||
|
<Tooltip content="Objects">
|
||||||
|
<button type="button">Objects nav</button>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<Tooltip content="Help text">
|
||||||
|
<button type="button">Focusable</button>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<Tooltip content="Tip">
|
||||||
|
<a href="/objects">Go</a>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = screen.getByRole("link", { name: "Go" });
|
||||||
|
expect(link).toHaveAttribute("href", "/objects");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("composes from the raw parts", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TooltipProvider delay={0}>
|
||||||
|
<TooltipRoot>
|
||||||
|
<TooltipTrigger render={<button type="button">Raw</button>} />
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPositioner side="bottom">
|
||||||
|
<TooltipPopup>Raw tip</TooltipPopup>
|
||||||
|
</TooltipPositioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
</TooltipRoot>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.hover(screen.getByRole("button", { name: "Raw" }));
|
||||||
|
|
||||||
|
const body = within(document.body);
|
||||||
|
expect(await body.findByText("Raw tip")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<TooltipPrimitive.Positioner.Props["side"]>;
|
||||||
|
|
||||||
|
function TooltipProvider({ ...props }: TooltipPrimitive.Provider.Props) {
|
||||||
|
return <TooltipPrimitive.Provider {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipRoot({ ...props }: TooltipPrimitive.Root.Props) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipPopup({ className, ...props }: TooltipPrimitive.Popup.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Popup
|
||||||
|
data-slot="tooltip-popup"
|
||||||
|
className={cn(
|
||||||
|
"rounded border bg-white px-2 py-1 text-sm shadow-md",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipPositioner({ className, ...props }: TooltipPrimitive.Positioner.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Positioner
|
||||||
|
data-slot="tooltip-positioner"
|
||||||
|
className={cn("z-50", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipRoot>
|
||||||
|
<TooltipTrigger render={children} />
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPositioner side={side} sideOffset={sideOffset}>
|
||||||
|
<TooltipPopup>{content}</TooltipPopup>
|
||||||
|
</TooltipPositioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
</TooltipRoot>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipRoot,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipPositioner,
|
||||||
|
TooltipPopup,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user