Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/frappe-ui-react/src/components/hoverCard/hoverCard.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@keyframes hovercard-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}

@keyframes hovercard-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}

.hovercard-popup[data-open] {
animation: hovercard-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
}

.hovercard-popup[data-closed] {
animation: hovercard-out 140ms cubic-bezier(0.16, 1, 0.3, 1);
}

@media (prefers-reduced-motion: reduce) {
.hovercard-popup {
animation: none !important;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import HoverCard from "./hoverCard";
import { Avatar } from "../avatar";

const meta: Meta<typeof HoverCard> = {
title: "Components/HoverCard",
component: HoverCard,
parameters: { docs: { source: { type: "dynamic" } }, layout: "centered" },
tags: ["autodocs"],
argTypes: {
side: {
control: "select",
options: ["top", "right", "bottom", "left"],
description: "Side of the trigger the card is placed on",
},
align: {
control: "select",
options: ["start", "center", "end"],
description: "Alignment of the card relative to the trigger",
},
offset: {
control: "number",
description: "Distance in pixels between the card and the trigger",
},
hoverDelay: {
control: "number",
description: "Delay in seconds before showing the card on hover",
},
leaveDelay: {
control: "number",
description:
"Delay in seconds before closing the card after pointer leaves",
},
arrow: {
control: "boolean",
description: "Whether to render a small arrow pointing at the trigger",
},
trigger: {
control: false,
},
children: {
control: false,
},
portalTo: {
control: false,
},
collisionPadding: {
control: false,
},
open: {
control: false,
},
defaultOpen: {
control: false,
},
onOpenChange: {
control: false,
},
},
args: {
side: "bottom",
align: "start",
offset: 4,
hoverDelay: 0.3,
leaveDelay: 0.3,
arrow: false,
},
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
render: (args) => {
return (
<HoverCard
{...args}
trigger={
<a
href="#"
className="text-ink-gray-9 underline underline-offset-2 hover:text-ink-blue-3 focus:text-ink-blue-3 focus:outline-none"
>
@jane
</a>
}
>
<div className="flex w-64 gap-3 p-3">
<Avatar
label="Jane Doe"
image="https://avatars.githubusercontent.com/u/499550?v=4"
size="2xl"
/>
<div>
<p className="text-p-base font-medium text-ink-gray-9">Jane Doe</p>
<p className="mt-1 text-p-sm text-ink-gray-6">
Designer. Likes hover cards.
</p>
</div>
</div>
</HoverCard>
);
},
};

export const WithDelays: Story = {
render: (args) => {
return (
<HoverCard
{...args}
trigger={
<a
href="#"
className="text-ink-gray-9 underline underline-offset-2 hover:text-ink-blue-3 focus:text-ink-blue-3 focus:outline-none"
>
Hover me (slow entry/exit)
</a>
}
>
<div className="p-3 text-p-sm text-ink-gray-7">
Opens after 1.0s, closes 0.8s after the pointer leaves.
</div>
</HoverCard>
);
},
args: {
side: "top",
align: "center",
hoverDelay: 1.0,
leaveDelay: 0.8,
},
};

export const WithArrow: Story = {
render: (args) => {
return (
<HoverCard
{...args}
trigger={
<a
href="#"
className="text-ink-gray-9 underline underline-offset-2 hover:text-ink-blue-3 focus:text-ink-blue-3 focus:outline-none"
>
@jane (with arrow)
</a>
}
>
<div className="flex w-64 gap-3 p-3">
<Avatar
label="Jane Doe"
image="https://avatars.githubusercontent.com/u/499550?v=4"
size="2xl"
/>
<div>
<p className="text-p-base font-medium text-ink-gray-9">Jane Doe</p>
<p className="mt-1 text-p-sm text-ink-gray-6">
Designer. Likes hover cards.
</p>
</div>
</div>
</HoverCard>
);
},
args: {
side: "top",
align: "start",
arrow: true,
},
};
88 changes: 88 additions & 0 deletions packages/frappe-ui-react/src/components/hoverCard/hoverCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React, { useMemo } from "react";
import { PreviewCard } from "@base-ui/react/preview-card";
import clsx from "clsx";
import type { HoverCardProps } from "./types";
import "./hoverCard.css";

const HoverCard: React.FC<HoverCardProps> = ({
children,
trigger,
side = "bottom",
align = "start",
offset = 4,
portalTo = "body",
collisionPadding = 10,
hoverDelay = 0.3,
leaveDelay = 0.3,
arrow = false,
open,
defaultOpen,
onOpenChange,
}) => {
const container = useMemo(() => {
if (typeof portalTo === "string") {
if (portalTo === "body") return document.body;
return document.querySelector(portalTo) as HTMLElement;
}
return portalTo;
}, [portalTo]);
Comment on lines +22 to +28

const delayMs = useMemo(() => hoverDelay * 1000, [hoverDelay]);
const closeDelayMs = useMemo(() => leaveDelay * 1000, [leaveDelay]);

return (
<PreviewCard.Root
open={open}
defaultOpen={defaultOpen}
onOpenChange={
onOpenChange ? (nextOpen: boolean) => onOpenChange(nextOpen) : undefined
}
>
<PreviewCard.Trigger
render={trigger as React.ReactElement}
delay={delayMs}
closeDelay={closeDelayMs}
/>
<PreviewCard.Portal container={container}>
<PreviewCard.Positioner
side={side}
align={align}
sideOffset={offset}
collisionPadding={collisionPadding}
className="z-[100]"
>
<PreviewCard.Popup className="hovercard-popup select-none rounded-lg border border-outline-gray-1 bg-surface-modal shadow-2xl outline-none">
<div>{children}</div>
{arrow && (
<PreviewCard.Arrow
className={clsx(
"data-[side=top]:rotate-180",
"data-[side=left]:rotate-90",
"data-[side=right]:-rotate-90"
)}
>
<svg
width={10}
height={5}
viewBox="0 0 10 5"
style={{ display: "block" }}
>
<path
d="M0 5L5 0L10 5"
fill="var(--color-surface-modal)"
stroke="var(--color-outline-gray-1)"
strokeWidth="1"
/>
</svg>
</PreviewCard.Arrow>
)}
</PreviewCard.Popup>
</PreviewCard.Positioner>
</PreviewCard.Portal>
</PreviewCard.Root>
);
};

HoverCard.displayName = "HoverCard";

export default HoverCard;
2 changes: 2 additions & 0 deletions packages/frappe-ui-react/src/components/hoverCard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as HoverCard } from "./hoverCard";
export type { HoverCardProps, HoverCardSide, HoverCardAlign } from "./types";
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import userEvent from "@testing-library/user-event";
import HoverCard from "../hoverCard";

describe("HoverCard", () => {
it("renders trigger element initially without popup", () => {
render(
<HoverCard trigger={<button>Hover trigger</button>}>
<div>Popup content</div>
</HoverCard>
);

expect(screen.getByText("Hover trigger")).toBeInTheDocument();
expect(screen.queryByText("Popup content")).not.toBeInTheDocument();
});

it("shows popup content on hover", async () => {
const user = userEvent.setup();

render(
<HoverCard hoverDelay={0} trigger={<button>Hover trigger</button>}>
<div>Popup content</div>
</HoverCard>
);

const trigger = screen.getByText("Hover trigger");
await user.hover(trigger);

expect(await screen.findByText("Popup content")).toBeInTheDocument();
});

it("hides popup on mouse leave", async () => {
const user = userEvent.setup();

render(
<HoverCard
hoverDelay={0}
leaveDelay={0}
trigger={<button>Hover trigger</button>}
>
<div>Popup content</div>
</HoverCard>
);

const trigger = screen.getByText("Hover trigger");
await user.hover(trigger);
expect(await screen.findByText("Popup content")).toBeInTheDocument();

await user.unhover(trigger);

await waitFor(() => {
expect(screen.queryByText("Popup content")).not.toBeInTheDocument();
});
});

it("supports controlled mode via open and onOpenChange", async () => {
const onOpenChange = jest.fn();

const { rerender } = render(
<HoverCard
open={false}
onOpenChange={onOpenChange}
trigger={<button>Hover trigger</button>}
>
<div>Popup content</div>
</HoverCard>
);

expect(screen.queryByText("Popup content")).not.toBeInTheDocument();

Comment on lines +58 to +72
// Rerender with open={true}
rerender(
<HoverCard
open={true}
onOpenChange={onOpenChange}
trigger={<button>Hover trigger</button>}
>
<div>Popup content</div>
</HoverCard>
);

expect(await screen.findByText("Popup content")).toBeInTheDocument();
});
});
Loading