Skip to content

Commit 86a09a1

Browse files
committed
chore: add notification UI components
1 parent 861c4b1 commit 86a09a1

16 files changed

+946
-2
lines changed

site/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"@radix-ui/react-dropdown-menu": "2.1.4",
5757
"@radix-ui/react-label": "2.1.0",
5858
"@radix-ui/react-popover": "1.1.5",
59+
"@radix-ui/react-scroll-area": "1.2.3",
5960
"@radix-ui/react-select": "2.1.4",
6061
"@radix-ui/react-slider": "1.2.2",
6162
"@radix-ui/react-slot": "1.1.1",
@@ -189,7 +190,11 @@
189190
"vite-plugin-checker": "0.8.0",
190191
"vite-plugin-turbosnap": "1.0.3"
191192
},
192-
"browserslist": ["chrome 110", "firefox 111", "safari 16.0"],
193+
"browserslist": [
194+
"chrome 110",
195+
"firefox 111",
196+
"safari 16.0"
197+
],
193198
"resolutions": {
194199
"optionator": "0.9.3",
195200
"semver": "7.6.2"

site/pnpm-lock.yaml

Lines changed: 71 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/src/components/Button/Button.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export const buttonVariants = cva(
2020
default:
2121
"bg-surface-invert-primary text-content-invert hover:bg-surface-invert-secondary border-none disabled:bg-surface-secondary font-semibold",
2222
outline:
23-
"border border-border-default text-content-primary bg-transparent hover:bg-surface-secondary",
23+
"border border-border-default text-content-primary bg-transparent bg-surface-primary hover:bg-surface-secondary",
2424
subtle:
2525
"border-none bg-transparent text-content-secondary hover:text-content-primary",
2626
destructive:
@@ -31,6 +31,7 @@ export const buttonVariants = cva(
3131
lg: "min-w-20 h-10 px-3 py-2 [&_svg]:size-icon-lg",
3232
sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&_svg]:size-icon-sm",
3333
icon: "size-8 px-1.5 [&_svg]:size-icon-sm",
34+
"icon-lg": "size-10 px-2 [&_svg]:size-icon-lg",
3435
},
3536
},
3637
defaultVariants: {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* Copied from shadc/ui on 03/05/2025
3+
* @see {@link https://ui.shadcn.com/docs/components/scroll-area}
4+
*/
5+
import * as React from "react";
6+
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
7+
import { cn } from "utils/cn";
8+
9+
export const ScrollArea = React.forwardRef<
10+
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
11+
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
12+
>(({ className, children, ...props }, ref) => (
13+
<ScrollAreaPrimitive.Root
14+
ref={ref}
15+
className={cn("relative overflow-hidden", className)}
16+
{...props}
17+
>
18+
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
19+
{children}
20+
</ScrollAreaPrimitive.Viewport>
21+
<ScrollBar />
22+
<ScrollAreaPrimitive.Corner />
23+
</ScrollAreaPrimitive.Root>
24+
));
25+
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
26+
27+
export const ScrollBar = React.forwardRef<
28+
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
29+
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
30+
>(({ className, orientation = "vertical", ...props }, ref) => (
31+
<ScrollAreaPrimitive.ScrollAreaScrollbar
32+
ref={ref}
33+
orientation={orientation}
34+
className={cn(
35+
"border-0 border-solid border-border flex touch-none select-none transition-colors",
36+
orientation === "vertical" &&
37+
"h-full w-2.5 border-l border-l-transparent p-[1px]",
38+
orientation === "horizontal" &&
39+
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
40+
className,
41+
)}
42+
{...props}
43+
>
44+
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
45+
</ScrollAreaPrimitive.ScrollAreaScrollbar>
46+
));
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { InboxButton } from "./InboxButton";
3+
4+
const meta: Meta<typeof InboxButton> = {
5+
title: "modules/notifications/NotificationsInbox/InboxButton",
6+
component: InboxButton,
7+
};
8+
9+
export default meta;
10+
type Story = StoryObj<typeof InboxButton>;
11+
12+
export const AllRead: Story = {};
13+
14+
export const Unread: Story = {
15+
args: {
16+
unreadCount: 3,
17+
},
18+
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Button, type ButtonProps } from "components/Button/Button";
2+
import { BellIcon } from "lucide-react";
3+
import { forwardRef, type FC } from "react";
4+
import { UnreadBadge } from "./UnreadBadge";
5+
6+
type InboxButtonProps = {
7+
unreadCount: number;
8+
} & ButtonProps;
9+
10+
export const InboxButton = forwardRef<HTMLButtonElement, InboxButtonProps>(
11+
({ unreadCount, ...props }, ref) => {
12+
return (
13+
<Button
14+
size="icon-lg"
15+
variant="outline"
16+
className="relative"
17+
ref={ref}
18+
{...props}
19+
>
20+
<BellIcon />
21+
{unreadCount > 0 && (
22+
<UnreadBadge
23+
count={unreadCount}
24+
className="absolute top-0 right-0 -translate-y-1/2 translate-x-1/2"
25+
/>
26+
)}
27+
</Button>
28+
);
29+
},
30+
);
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { InboxItem } from "./InboxItem";
3+
import { fn, userEvent, within, expect } from "@storybook/test";
4+
import { MockNotification } from "testHelpers/entities";
5+
6+
const meta: Meta<typeof InboxItem> = {
7+
title: "modules/notifications/NotificationsInbox/InboxItem",
8+
component: InboxItem,
9+
render: (args) => {
10+
return (
11+
<div className="max-w-[460px] border-solid border-border rounded">
12+
<InboxItem {...args} />
13+
</div>
14+
);
15+
},
16+
};
17+
18+
export default meta;
19+
type Story = StoryObj<typeof InboxItem>;
20+
21+
export const Read: Story = {
22+
args: {
23+
notification: MockNotification,
24+
},
25+
};
26+
27+
export const Unread: Story = {
28+
args: {
29+
notification: {
30+
...MockNotification,
31+
read_status: "unread",
32+
},
33+
},
34+
};
35+
36+
export const UnreadFocus: Story = {
37+
args: {
38+
notification: {
39+
...MockNotification,
40+
read_status: "unread",
41+
},
42+
},
43+
play: async ({ canvasElement }) => {
44+
const canvas = within(canvasElement);
45+
const notification = canvas.getByRole("menuitem");
46+
await userEvent.click(notification);
47+
},
48+
};
49+
50+
export const OnMarkNotificationAsRead: Story = {
51+
args: {
52+
notification: {
53+
...MockNotification,
54+
read_status: "unread",
55+
},
56+
onMarkNotificationAsRead: fn(),
57+
},
58+
play: async ({ canvasElement, args }) => {
59+
const canvas = within(canvasElement);
60+
const notification = canvas.getByRole("menuitem");
61+
await userEvent.click(notification);
62+
const markButton = canvas.getByRole("button", { name: /mark as read/i });
63+
await userEvent.click(markButton);
64+
await expect(args.onMarkNotificationAsRead).toHaveBeenCalledTimes(1);
65+
await expect(args.onMarkNotificationAsRead).toHaveBeenCalledWith(
66+
args.notification.id,
67+
);
68+
},
69+
parameters: {
70+
chromatic: {
71+
disableSnapshot: true,
72+
},
73+
},
74+
};
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { Avatar } from "components/Avatar/Avatar";
2+
import { Button } from "components/Button/Button";
3+
import { SquareCheckBig } from "lucide-react";
4+
import type { FC } from "react";
5+
import type { Notification } from "./types";
6+
import { relativeTime } from "utils/time";
7+
import { Link as RouterLink } from "react-router-dom";
8+
9+
type InboxItemProps = {
10+
notification: Notification;
11+
onMarkNotificationAsRead: (notificationId: string) => void;
12+
};
13+
14+
export const InboxItem: FC<InboxItemProps> = ({
15+
notification,
16+
onMarkNotificationAsRead,
17+
}) => {
18+
return (
19+
<div
20+
className="flex items-stretch gap-3 p-3 group"
21+
role="menuitem"
22+
tabIndex={-1}
23+
>
24+
<div className="flex-shrink-0">
25+
<Avatar fallback="AR" />
26+
</div>
27+
28+
<div className="flex flex-col gap-3">
29+
<span className="text-content-secondary text-sm font-medium">
30+
{notification.content}
31+
</span>
32+
<div className="flex items-center gap-1">
33+
{notification.actions.map((action) => {
34+
return (
35+
<Button variant="outline" size="sm" key={action.label} asChild>
36+
<RouterLink to={action.url}>{action.label}</RouterLink>
37+
</Button>
38+
);
39+
})}
40+
</div>
41+
</div>
42+
43+
<div className="w-12 flex flex-col items-end flex-shrink-0">
44+
{notification.read_status === "unread" && (
45+
<>
46+
<div className="group-focus:hidden group-hover:hidden size-2.5 rounded-full bg-highlight-sky">
47+
<span className="sr-only">Unread</span>
48+
</div>
49+
50+
<Button
51+
onClick={() => onMarkNotificationAsRead(notification.id)}
52+
className="hidden group-focus:flex group-hover:flex"
53+
variant="outline"
54+
size="sm"
55+
>
56+
<SquareCheckBig />
57+
mark as read
58+
</Button>
59+
</>
60+
)}
61+
62+
<span className="mt-auto text-content-secondary text-xs font-medium whitespace-nowrap">
63+
{relativeTime(new Date(notification.created_at))}
64+
</span>
65+
</div>
66+
</div>
67+
);
68+
};

0 commit comments

Comments
 (0)