Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -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).

View File

@@ -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();
});
}
});

View File

@@ -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>
),
};

View File

@@ -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);
});
});

View File

@@ -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 };

View File

@@ -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> {}

View File

@@ -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.

View File

@@ -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: {} }
);

View File

@@ -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";