Initial Commit
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
# HoverCard
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - hover-card.variants — `hoverCardContentVariants` is…
|
||||
- [hover-card.variants](./hover-card.variants.md) — `hoverCardContentVariants` is the cva configuration powering the `<HoverCardContent>` floating panel. It is split from…
|
||||
- hover-card.variants — `hoverCardContentVariants` is…
|
||||
|
||||
- [hover-card.variants](./hover-card.variants.md) — `hoverCardContentVariants` is the cva configuration powering the `<HoverCardContent>` floating panel. It is split from…
|
||||
|
||||
- hover-card.variants — `hoverCardContentVariants` is…
|
||||
|
||||
- [hover-card.variants](./hover-card.variants.md) — `hoverCardContentVariants` is the cva configuration powering the `<HoverCardContent>` floating panel. It is split from…
|
||||
|
||||
- hover-card.variants — `hoverCardContentVariants` is…
|
||||
|
||||
- [hover-card.variants](./hover-card.variants.md) — `hoverCardContentVariants` is the cva configuration powering the `<HoverCardContent>` floating panel. It is split from…
|
||||
- [hover-card.variants](./hover-card.variants.md) — `hoverCardContentVariants` is the cva configuration powering the `<HoverCardContent>` floating panel. It is split from…
|
||||
|
||||
## Purpose
|
||||
|
||||
`<HoverCard>` and its sub-components (`<HoverCardTrigger>`, `<HoverCardPortal>`, `<HoverCardContent>`, `<HoverCardArrow>`) are the chassis hover-card primitives built on `@radix-ui/react-hover-card`. Use this for non-modal previews that open on hover or focus over the trigger and dismiss on hover-out or blur — link previews, user-mention bios, contextual mini-cards.
|
||||
|
||||
Distinct from `<Popover>`: HoverCard is hover-driven (mouse-only by spec; touch devices fall back to tap-to-open) with `openDelay` / `closeDelay` hysteresis. Distinct from `<Tooltip>`: HoverCard panels can hold rich content (multiple paragraphs, images, action links); Tooltip is a single short label.
|
||||
|
||||
## Features
|
||||
|
||||
- Opens on hover or focus over the trigger; closes on hover-out or blur.
|
||||
- `openDelay` / `closeDelay` numeric hysteresis (default 700/300 ms — Radix defaults).
|
||||
- Side / align positional API (`side`: top/right/bottom/left; `align`: start/center/end).
|
||||
- Self-portals via `<HoverCardPortal>`.
|
||||
- Optional `<HoverCardArrow>` decoration.
|
||||
- Theme-driven cva via `useThemedVariants("HoverCardContent")`.
|
||||
- All sub-components forward refs.
|
||||
- `motion-reduce:animate-none` collapses the open/close animations under `prefers-reduced-motion: reduce`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@suaveplan/react-ui";
|
||||
|
||||
export function MentionPreview() {
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>@suaveplan</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
Component library for the Suaveplan platform.
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardArrow,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@suaveplan/react-ui";
|
||||
|
||||
interface User {
|
||||
readonly handle: string;
|
||||
readonly displayName: string;
|
||||
readonly bio: string;
|
||||
readonly avatarUrl: string;
|
||||
}
|
||||
|
||||
export function UserMention({ user }: { user: User }) {
|
||||
return (
|
||||
<HoverCard openDelay={400} closeDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<a href={`/${user.handle}`}>@{user.handle}</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="top" align="start" sideOffset={8}>
|
||||
<HoverCardArrow />
|
||||
<div className="flex gap-3">
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt=""
|
||||
className="h-10 w-10 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="font-semibold">{user.displayName}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@{user.handle}
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{user.bio}</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `<HoverCard>`
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `open` | `boolean` | _uncontrolled_ | Controlled open state. |
|
||||
| `defaultOpen` | `boolean` | `false` | Initial open state. |
|
||||
| `onOpenChange` | `(open: boolean) => void` | _none_ | Fires on open/close transitions. |
|
||||
| `openDelay` | `number` | `700` | Delay (ms) before opening on hover. |
|
||||
| `closeDelay` | `number` | `300` | Delay (ms) before closing on hover-out. |
|
||||
|
||||
### `<HoverCardContent>`
|
||||
|
||||
Same positional props as `<PopoverContent>` (`side`, `align`, `sideOffset`, `alignOffset`, `avoidCollisions`, `collisionPadding`).
|
||||
|
||||
### `<HoverCardTrigger>`, `<HoverCardArrow>`, `<HoverCardPortal>`
|
||||
|
||||
Direct re-exports of the Radix sub-components.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The chassis NEVER assigns a numeric `z-index` — Radix portals handle stacking.
|
||||
- HoverCard is hover-driven and therefore inappropriate for touch-only UI; consider `<Popover>` (click-driven) for tap-to-open ergonomics.
|
||||
- `<HoverCardContent>` self-portals via `<HoverCardPortal>` so consumers do not need to compose the portal manually.
|
||||
- `align="center"` and `sideOffset={4}` are chassis defaults (matching shadcn upstream).
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { renderComponent } from "@suaveplan/testing-dom";
|
||||
import * as Stories from "./hover-card.stories.js";
|
||||
|
||||
const STORY_KEYS = [
|
||||
"Default",
|
||||
"Playground",
|
||||
"Variants",
|
||||
"States",
|
||||
"WithI18n",
|
||||
"WithRTL",
|
||||
"WithTheme",
|
||||
"A11y",
|
||||
"Composition",
|
||||
] as const;
|
||||
|
||||
type StoryKey = (typeof STORY_KEYS)[number];
|
||||
|
||||
interface StoryRecord {
|
||||
readonly args?: Record<string, unknown>;
|
||||
readonly render?: (args: Record<string, unknown>) => React.ReactNode;
|
||||
}
|
||||
|
||||
describe("hover-card.stories", () => {
|
||||
it("exports every Overlays story", { timeout: 5000 }, () => {
|
||||
for (const key of STORY_KEYS) {
|
||||
expect(
|
||||
(Stories as unknown as Record<string, unknown>)[key],
|
||||
`missing story export: ${key}`
|
||||
).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
for (const key of STORY_KEYS) {
|
||||
it(`renders the ${key} story without throwing`, {
|
||||
timeout: 10000,
|
||||
}, () => {
|
||||
const story = (Stories as unknown as Record<StoryKey, StoryRecord>)[
|
||||
key
|
||||
];
|
||||
expect(story).toBeDefined();
|
||||
const render = story.render;
|
||||
expect(typeof render).toBe("function");
|
||||
if (render === undefined) return;
|
||||
const tree = render(story.args ?? {});
|
||||
const ui = (
|
||||
<div data-testid="story-host">{tree as React.ReactNode}</div>
|
||||
);
|
||||
const { container } = renderComponent(ui);
|
||||
expect(
|
||||
container.querySelector("[data-testid='story-host']")
|
||||
).not.toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Storybook stories for `<HoverCard>`.
|
||||
*/
|
||||
|
||||
import {
|
||||
createI18n as createI18nNarrow,
|
||||
type I18nInstance,
|
||||
} from "@suaveplan/i18n/plugin";
|
||||
import { TranslationProvider } from "@suaveplan/react-i18n";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import type { SupportedLocale } from "../../locales/locales.types.js";
|
||||
import type { ThemePlugin } from "../../theme/theme.types.js";
|
||||
import { ThemeProvider } from "../../theme/theme-provider/theme-provider.js";
|
||||
import { darkTheme } from "../../theme/themes/dark.js";
|
||||
import { highContrastTheme } from "../../theme/themes/high-contrast.js";
|
||||
import { lightTheme } from "../../theme/themes/light.js";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardArrow,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "./hover-card.js";
|
||||
import type { HoverCardContentProps } from "./hover-card.types.js";
|
||||
|
||||
function createI18n(
|
||||
options: Parameters<typeof createI18nNarrow>[0]
|
||||
): I18nInstance {
|
||||
return createI18nNarrow(options) as unknown as I18nInstance;
|
||||
}
|
||||
|
||||
interface Meta<T> {
|
||||
readonly component: T;
|
||||
readonly title: string;
|
||||
readonly tags?: readonly string[];
|
||||
readonly parameters?: Record<string, unknown>;
|
||||
readonly argTypes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface StoryObj<TArgs> {
|
||||
readonly args?: Partial<TArgs>;
|
||||
readonly render?: (args: Partial<TArgs>) => ReactNode;
|
||||
readonly name?: string;
|
||||
readonly parameters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const meta: Meta<unknown> = {
|
||||
component: undefined as unknown as never,
|
||||
title: "react-ui/Overlays/HoverCard",
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<HoverCardContentProps>;
|
||||
|
||||
interface ProviderShellProps {
|
||||
readonly locale: SupportedLocale;
|
||||
readonly themes: ReadonlyArray<ThemePlugin>;
|
||||
readonly defaultTheme: string;
|
||||
readonly children: ReactNode;
|
||||
}
|
||||
|
||||
function ProviderShellSync(props: ProviderShellProps): ReactElement {
|
||||
const i18n = createI18n({
|
||||
defaultLocale: props.locale,
|
||||
fallbackLocale: "en-GB",
|
||||
});
|
||||
return (
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<ThemeProvider
|
||||
themes={props.themes}
|
||||
defaultTheme={props.defaultTheme}
|
||||
>
|
||||
{props.children}
|
||||
</ThemeProvider>
|
||||
</TranslationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: (_args) => (
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<HoverCard openDelay={0}>
|
||||
<HoverCardTrigger>@suaveplan</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
Component library for the Suaveplan platform.
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</ProviderShellSync>
|
||||
),
|
||||
};
|
||||
|
||||
export const Playground: Story = {
|
||||
render: (_args) => (
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<HoverCard defaultOpen openDelay={0} closeDelay={0}>
|
||||
<HoverCardTrigger>@suaveplan</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<HoverCardArrow />
|
||||
Component library for the Suaveplan platform.
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</ProviderShellSync>
|
||||
),
|
||||
};
|
||||
|
||||
export const Variants: Story = {
|
||||
render: () => (
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{(["top", "right", "bottom", "left"] as const).map((side) => (
|
||||
<HoverCard key={side} openDelay={0}>
|
||||
<HoverCardTrigger>{`open ${side}`}</HoverCardTrigger>
|
||||
<HoverCardContent side={side}>{side}</HoverCardContent>
|
||||
</HoverCard>
|
||||
))}
|
||||
</div>
|
||||
</ProviderShellSync>
|
||||
),
|
||||
};
|
||||
|
||||
export const States: Story = {
|
||||
render: () => (
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<HoverCard defaultOpen openDelay={0} closeDelay={0}>
|
||||
<HoverCardTrigger>Trigger</HoverCardTrigger>
|
||||
<HoverCardContent>Open by default.</HoverCardContent>
|
||||
</HoverCard>
|
||||
</ProviderShellSync>
|
||||
),
|
||||
};
|
||||
|
||||
const LOCALES: ReadonlyArray<SupportedLocale> = [
|
||||
"en-GB",
|
||||
"zh-CN",
|
||||
"ar-AE",
|
||||
"cy-GB",
|
||||
"ru-RU",
|
||||
];
|
||||
|
||||
export const WithI18n: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-3">
|
||||
{LOCALES.map((locale) => (
|
||||
<div
|
||||
key={locale}
|
||||
dir={locale === "ar-AE" ? "rtl" : "ltr"}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span style={{ width: 80 }}>{locale}</span>
|
||||
<ProviderShellSync
|
||||
locale={locale}
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<HoverCard openDelay={0}>
|
||||
<HoverCardTrigger>Hover</HoverCardTrigger>
|
||||
<HoverCardContent>{locale}</HoverCardContent>
|
||||
</HoverCard>
|
||||
</ProviderShellSync>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithRTL: Story = {
|
||||
render: () => (
|
||||
<div dir="rtl">
|
||||
<ProviderShellSync
|
||||
locale="ar-AE"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<HoverCard defaultOpen openDelay={0} closeDelay={0}>
|
||||
<HoverCardTrigger>تمرير</HoverCardTrigger>
|
||||
<HoverCardContent>محتوى</HoverCardContent>
|
||||
</HoverCard>
|
||||
</ProviderShellSync>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
const THEMES: ReadonlyArray<{
|
||||
readonly label: string;
|
||||
readonly theme: ThemePlugin;
|
||||
}> = [
|
||||
{ label: "light", theme: lightTheme },
|
||||
{ label: "dark", theme: darkTheme },
|
||||
{ label: "highContrast", theme: highContrastTheme },
|
||||
];
|
||||
|
||||
export const WithTheme: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-3">
|
||||
{THEMES.map(({ label, theme }) => (
|
||||
<div key={label} className="flex items-center gap-2 p-3 border">
|
||||
<span style={{ width: 120 }}>{label}</span>
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[theme]}
|
||||
defaultTheme={theme.name}
|
||||
>
|
||||
<HoverCard openDelay={0}>
|
||||
<HoverCardTrigger>Hover</HoverCardTrigger>
|
||||
<HoverCardContent>{label}</HoverCardContent>
|
||||
</HoverCard>
|
||||
</ProviderShellSync>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const A11y: Story = {
|
||||
parameters: {
|
||||
a11y: {
|
||||
element: "#storybook-root",
|
||||
config: {
|
||||
rules: [{ id: "color-contrast", enabled: true }],
|
||||
},
|
||||
},
|
||||
},
|
||||
render: () => (
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<HoverCard defaultOpen openDelay={0} closeDelay={0}>
|
||||
<HoverCardTrigger>User profile</HoverCardTrigger>
|
||||
<HoverCardContent>Brief profile summary.</HoverCardContent>
|
||||
</HoverCard>
|
||||
</ProviderShellSync>
|
||||
),
|
||||
};
|
||||
|
||||
export const Composition: Story = {
|
||||
name: "Composition (link preview)",
|
||||
render: () => (
|
||||
<ProviderShellSync
|
||||
locale="en-GB"
|
||||
themes={[lightTheme]}
|
||||
defaultTheme="light"
|
||||
>
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger>Hover the @suaveplan handle</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<h4 className="font-semibold">@suaveplan</h4>
|
||||
<p className="text-sm">
|
||||
Component library for the Suaveplan platform.
|
||||
</p>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</ProviderShellSync>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import { TranslationProvider } from "@suaveplan/react-i18n";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { renderComponent } from "@suaveplan/testing-dom";
|
||||
import { createRef } from "react";
|
||||
import { createTestI18n } from "../../../tests/setup/test-i18n.js";
|
||||
import { ThemeProvider } from "../../theme/theme-provider/theme-provider.js";
|
||||
import { lightTheme } from "../../theme/themes/light.js";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardArrow,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "./hover-card.js";
|
||||
import { hoverCardContentVariants } from "./hover-card.variants.js";
|
||||
|
||||
async function withProviders(
|
||||
node: React.ReactElement
|
||||
): Promise<React.ReactElement> {
|
||||
const i18n = await createTestI18n({ locale: "en-GB" });
|
||||
return (
|
||||
<TranslationProvider i18n={i18n}>
|
||||
<ThemeProvider themes={[lightTheme]} defaultTheme="light">
|
||||
{node}
|
||||
</ThemeProvider>
|
||||
</TranslationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("<HoverCard>", () => {
|
||||
it("renders the trigger", { timeout: 5000 }, async () => {
|
||||
const tree = await withProviders(
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>Hover me</HoverCardTrigger>
|
||||
<HoverCardContent>Body</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
const { getByText } = renderComponent(tree);
|
||||
expect(getByText("Hover me")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the content when defaultOpen", { timeout: 5000 }, async () => {
|
||||
const tree = await withProviders(
|
||||
<HoverCard defaultOpen openDelay={0} closeDelay={0}>
|
||||
<HoverCardTrigger>Hover me</HoverCardTrigger>
|
||||
<HoverCardContent>Body</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
const { findByText } = renderComponent(tree);
|
||||
const body = await findByText("Body");
|
||||
expect(body).not.toBeNull();
|
||||
});
|
||||
|
||||
it("forwards refs", { timeout: 5000 }, async () => {
|
||||
const triggerRef = createRef<HTMLAnchorElement>();
|
||||
const contentRef = createRef<HTMLDivElement>();
|
||||
const arrowRef = createRef<SVGSVGElement>();
|
||||
const tree = await withProviders(
|
||||
<HoverCard defaultOpen openDelay={0} closeDelay={0}>
|
||||
<HoverCardTrigger ref={triggerRef}>T</HoverCardTrigger>
|
||||
<HoverCardContent ref={contentRef}>
|
||||
<HoverCardArrow ref={arrowRef} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
renderComponent(tree);
|
||||
expect(triggerRef.current).not.toBeNull();
|
||||
expect(contentRef.current).not.toBeNull();
|
||||
expect(arrowRef.current).not.toBeNull();
|
||||
});
|
||||
|
||||
it("merges custom className on HoverCardContent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<HoverCard defaultOpen openDelay={0} closeDelay={0}>
|
||||
<HoverCardTrigger>T</HoverCardTrigger>
|
||||
<HoverCardContent className="c-extra">Body</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
const { findByText } = renderComponent(tree);
|
||||
const body = await findByText("Body");
|
||||
const content = body.closest("[class*='c-extra']");
|
||||
expect(content).not.toBeNull();
|
||||
});
|
||||
|
||||
it("forwards positional props (side, align)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tree = await withProviders(
|
||||
<HoverCard defaultOpen openDelay={0} closeDelay={0}>
|
||||
<HoverCardTrigger>T</HoverCardTrigger>
|
||||
<HoverCardContent side="top" align="end">
|
||||
Body
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
const { findByText } = renderComponent(tree);
|
||||
const body = await findByText("Body");
|
||||
const content = body.closest("[data-side]") as HTMLElement | null;
|
||||
expect(content?.getAttribute("data-side")).toBe("top");
|
||||
expect(content?.getAttribute("data-align")).toBe("end");
|
||||
});
|
||||
|
||||
it("supports controlled open state", { timeout: 5000 }, async () => {
|
||||
const tree = await withProviders(
|
||||
<HoverCard open onOpenChange={() => undefined}>
|
||||
<HoverCardTrigger>T</HoverCardTrigger>
|
||||
<HoverCardContent>Body</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
const { findByText } = renderComponent(tree);
|
||||
const body = await findByText("Body");
|
||||
expect(body).not.toBeNull();
|
||||
});
|
||||
|
||||
it("supports openDelay / closeDelay props", { timeout: 5000 }, async () => {
|
||||
const tree = await withProviders(
|
||||
<HoverCard defaultOpen openDelay={100} closeDelay={200}>
|
||||
<HoverCardTrigger>T</HoverCardTrigger>
|
||||
<HoverCardContent>Body</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
const { findByText } = renderComponent(tree);
|
||||
const body = await findByText("Body");
|
||||
expect(body).not.toBeNull();
|
||||
});
|
||||
|
||||
it("exposes hoverCardContentVariants resolver", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(hoverCardContentVariants().length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
import { type ComponentRef, forwardRef, type ReactElement } from "react";
|
||||
import { useThemedVariants } from "../../theme/use-themed-variants/use-themed-variants.js";
|
||||
import { cn } from "../../utils/cn/cn.js";
|
||||
import type {
|
||||
HoverCardArrowProps,
|
||||
HoverCardContentProps,
|
||||
HoverCardPortalProps,
|
||||
HoverCardProps,
|
||||
HoverCardTriggerProps,
|
||||
} from "./hover-card.types.js";
|
||||
import { hoverCardContentVariants } from "./hover-card.variants.js";
|
||||
|
||||
/**
|
||||
* `<HoverCard>` — chassis hover-card primitive built on
|
||||
* `@radix-ui/react-hover-card`.
|
||||
*
|
||||
* Opens on hover / focus over the trigger; closes on hover-out / blur.
|
||||
* `openDelay` / `closeDelay` control the hysteresis. Use this for
|
||||
* link previews, user mention cards, or contextual mini-bios where
|
||||
* the user is browsing rather than acting.
|
||||
*/
|
||||
export const HoverCard = (props: HoverCardProps): ReactElement => (
|
||||
<HoverCardPrimitive.Root {...props} />
|
||||
);
|
||||
|
||||
HoverCard.displayName = "HoverCard";
|
||||
|
||||
/**
|
||||
* `<HoverCardTrigger>` — the element that opens the card on hover.
|
||||
*/
|
||||
export const HoverCardTrigger = forwardRef<
|
||||
ComponentRef<typeof HoverCardPrimitive.Trigger>,
|
||||
HoverCardTriggerProps
|
||||
>(function HoverCardTrigger(props, ref): ReactElement {
|
||||
return <HoverCardPrimitive.Trigger ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
HoverCardTrigger.displayName = "HoverCardTrigger";
|
||||
|
||||
/**
|
||||
* `<HoverCardPortal>` — renders the hover card into the body.
|
||||
*/
|
||||
export const HoverCardPortal = (props: HoverCardPortalProps): ReactElement => (
|
||||
<HoverCardPrimitive.Portal {...props} />
|
||||
);
|
||||
|
||||
HoverCardPortal.displayName = "HoverCardPortal";
|
||||
|
||||
/**
|
||||
* `<HoverCardContent>` — the floating card. Self-portals via
|
||||
* `<HoverCardPortal>`. Forwards Radix positional props.
|
||||
*/
|
||||
export const HoverCardContent = forwardRef<
|
||||
ComponentRef<typeof HoverCardPrimitive.Content>,
|
||||
HoverCardContentProps
|
||||
>(function HoverCardContent(props, ref): ReactElement {
|
||||
const { className, align = "center", sideOffset = 4, ...rest } = props;
|
||||
const variants = useThemedVariants(
|
||||
"HoverCardContent",
|
||||
hoverCardContentVariants
|
||||
);
|
||||
return (
|
||||
<HoverCardPortal>
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(variants(), className)}
|
||||
{...rest}
|
||||
/>
|
||||
</HoverCardPortal>
|
||||
);
|
||||
});
|
||||
|
||||
HoverCardContent.displayName = "HoverCardContent";
|
||||
|
||||
/**
|
||||
* `<HoverCardArrow>` — small triangle decoration pointing back at the
|
||||
* trigger.
|
||||
*/
|
||||
export const HoverCardArrow = forwardRef<SVGSVGElement, HoverCardArrowProps>(
|
||||
function HoverCardArrow(props, ref): ReactElement {
|
||||
return <HoverCardPrimitive.Arrow ref={ref} {...props} />;
|
||||
}
|
||||
);
|
||||
|
||||
HoverCardArrow.displayName = "HoverCardArrow";
|
||||
|
||||
export { hoverCardContentVariants };
|
||||
@@ -0,0 +1,53 @@
|
||||
import type * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import type { hoverCardContentVariants } from "./hover-card.variants.js";
|
||||
|
||||
/**
|
||||
* Module augmentation registering HoverCard into `ThemableVariants`.
|
||||
*/
|
||||
declare module "../../theme/theme.types.js" {
|
||||
interface ThemableVariants {
|
||||
readonly HoverCardContent: typeof hoverCardContentVariants;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public props for `<HoverCard>` — wraps the Radix HoverCard Root.
|
||||
*
|
||||
* Opens on hover or focus over the trigger; closes on hover-out or
|
||||
* blur. Mouse-only by spec; touch devices fall back to tap-to-open.
|
||||
*
|
||||
* `openDelay` / `closeDelay` control the hover hysteresis (default
|
||||
* 700/300 ms — matches Radix defaults).
|
||||
*/
|
||||
export interface HoverCardProps
|
||||
extends ComponentPropsWithoutRef<typeof HoverCardPrimitive.Root> {}
|
||||
|
||||
/**
|
||||
* Public props for `<HoverCardTrigger>` — the element that opens the
|
||||
* card on hover/focus.
|
||||
*/
|
||||
export interface HoverCardTriggerProps
|
||||
extends ComponentPropsWithoutRef<typeof HoverCardPrimitive.Trigger> {}
|
||||
|
||||
/**
|
||||
* Public props for `<HoverCardPortal>`.
|
||||
*/
|
||||
export interface HoverCardPortalProps
|
||||
extends ComponentPropsWithoutRef<typeof HoverCardPrimitive.Portal> {}
|
||||
|
||||
/**
|
||||
* Public props for `<HoverCardContent>` — the floating card. Forwards
|
||||
* Radix positional props (`side`, `align`, `sideOffset`, `alignOffset`).
|
||||
*/
|
||||
export interface HoverCardContentProps
|
||||
extends Omit<
|
||||
ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>,
|
||||
"asChild"
|
||||
> {}
|
||||
|
||||
/**
|
||||
* Public props for `<HoverCardArrow>` — small triangle decoration.
|
||||
*/
|
||||
export interface HoverCardArrowProps
|
||||
extends ComponentPropsWithoutRef<typeof HoverCardPrimitive.Arrow> {}
|
||||
@@ -0,0 +1,52 @@
|
||||
# HoverCard Variants (cva config)
|
||||
|
||||
## Purpose
|
||||
|
||||
`hoverCardContentVariants` is the cva configuration powering the `<HoverCardContent>` floating panel. It is split from `hover-card.tsx` into its own module so an ejected source copy under `npx shadcn@latest add @suaveplan/hover-card` keeps the cva configuration in a dedicated file. Theme overrides flow through `useThemedVariants("HoverCardContent")`.
|
||||
|
||||
The cva carries the visual recipe (rounded border, popover-foreground colours, shadow, fixed `w-64` width — narrower than `<PopoverContent>` because hover cards typically hold richer content but live shorter on screen) plus the same animation recipe as Popover.
|
||||
|
||||
## Features
|
||||
|
||||
- Fixed `w-64` width (slightly narrower than Popover's `w-72`).
|
||||
- `data-state` open/close fade + zoom animations.
|
||||
- `data-side` slide-in direction matching the resolved positioning.
|
||||
- `bg-popover text-popover-foreground` so the panel reads against any surface.
|
||||
- `motion-reduce:animate-none` collapses animations under reduced-motion preference.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { hoverCardContentVariants } from "@suaveplan/react-ui";
|
||||
|
||||
const className = hoverCardContentVariants();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { hoverCardContentVariants, cn } from "@suaveplan/react-ui";
|
||||
|
||||
export function CustomHoverCardShell() {
|
||||
return (
|
||||
<div
|
||||
data-state="open"
|
||||
data-side="top"
|
||||
className={cn(hoverCardContentVariants(), "w-80 p-6")}
|
||||
>
|
||||
…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `hoverCardContentVariants(): string`
|
||||
|
||||
Returns the hover-card content classes; takes no arguments — the hover card is not parameterised by an axis.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Per the shadcn skill rule, the cva NEVER assigns a numeric `z-index`. Radix's `<Portal>` mounts the content above all page content via DOM placement.
|
||||
- Width is fixed at `w-64` to match the shadcn upstream recipe — consumers who need a wider panel layer their own width override via `cn(...)` at the call site.
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineVariants } from "../../variants/variants.js";
|
||||
|
||||
/**
|
||||
* `hoverCardContentVariants` — floating panel rendered on
|
||||
* hover/focus over the trigger. Same animation recipe as Popover.
|
||||
* NEVER carries a numeric `z-index`.
|
||||
*/
|
||||
export const hoverCardContentVariants = defineVariants(
|
||||
"HoverCardContent",
|
||||
[
|
||||
"w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2",
|
||||
"data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2",
|
||||
"data-[side=top]:slide-in-from-bottom-2",
|
||||
"motion-reduce:animate-none",
|
||||
].join(" "),
|
||||
{ variants: {}, defaultVariants: {} }
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
HoverCard,
|
||||
HoverCardArrow,
|
||||
HoverCardContent,
|
||||
HoverCardPortal,
|
||||
HoverCardTrigger,
|
||||
hoverCardContentVariants,
|
||||
} from "./hover-card.js";
|
||||
export type {
|
||||
HoverCardArrowProps,
|
||||
HoverCardContentProps,
|
||||
HoverCardPortalProps,
|
||||
HoverCardProps,
|
||||
HoverCardTriggerProps,
|
||||
} from "./hover-card.types.js";
|
||||
Reference in New Issue
Block a user