Skip to content

Commit 35f2bcb

Browse files
feat: added generic badge component, event badge component, and icon wrapper (#39)
* feat: added generic badge component, event badge component, and icon wrapper * fix: updated darkmode colors and fixed incorrect icon colors * fix: small tweaks over rendering icons and eslint mutes * feat: added unplugin-icons and replaced old icons * fix: updated badge border property and storybook badge menu --------- Co-authored-by: AlexanderWangY <alexander.yisu.wang@outlook.com>
1 parent 4dfbb86 commit 35f2bcb

File tree

15 files changed

+933
-5
lines changed

15 files changed

+933
-5
lines changed

apps/web/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
"devDependencies": {
4545
"@chromatic-com/storybook": "^3",
4646
"@eslint/js": "^9.25.0",
47+
"@iconify-json/ic": "^1.2.2",
48+
"@iconify-json/tabler": "^1.2.19",
4749
"@storybook/addon-essentials": "^8.6.12",
4850
"@storybook/addon-onboarding": "^8.6.12",
4951
"@storybook/addon-styling-webpack": "^1.0.1",
@@ -52,6 +54,8 @@
5254
"@storybook/react": "^8.6.12",
5355
"@storybook/react-vite": "^8.6.12",
5456
"@storybook/test": "^8.6.12",
57+
"@svgr/core": "^8.1.0",
58+
"@svgr/plugin-jsx": "^8.1.0",
5559
"@tanstack/router-plugin": "^1.120.2",
5660
"@testing-library/jest-dom": "^6.6.3",
5761
"@testing-library/react": "^16.3.0",
@@ -75,6 +79,7 @@
7579
"storybook": "^8.6.12",
7680
"typescript": "~5.8.3",
7781
"typescript-eslint": "^8.30.1",
82+
"unplugin-icons": "^22.1.0",
7883
"vite": "^6.3.5",
7984
"vitest": "^3.1.3"
8085
},

apps/web/pnpm-lock.yaml

Lines changed: 420 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { cn } from "@/utils/cn";
2+
import type { JSX } from "react";
3+
import React from "react";
4+
5+
export type IconWrapperProps = React.HTMLAttributes<SVGElement> & {
6+
children: JSX.Element;
7+
};
8+
9+
// Simplified version of https://www.jacobparis.com/content/react-as-child
10+
// This wrapper is used to wrap any SVG icon which allows us to inject additional props to those icons (like className)
11+
// without requiring us to define those props on the icons themselves which reduces code duplication.
12+
const IconWrapper = ({ children, ...props }: IconWrapperProps) => {
13+
if (!React.isValidElement(children)) {
14+
throw new Error("Invalid icon component passed to IconWrapper.");
15+
}
16+
17+
if (children.type !== "svg") {
18+
throw new Error(
19+
`Icon must be an svg element. The erroneous icon has type \`${children.type}\``,
20+
);
21+
}
22+
23+
return React.cloneElement(children, {
24+
...props,
25+
className: cn(children.props.className, props.className), // custom props will override the icon's
26+
});
27+
};
28+
29+
export { IconWrapper };
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { JSX } from "react";
2+
import { IconWrapper, type IconWrapperProps } from "./IconWrapper";
3+
4+
// All SVG icons must call this function.
5+
// It allows us to do something like <Icon className="..."/> without requiring us to do define and inject
6+
// props inside the Icon directly.
7+
export const createIcon = (iconSvg: () => JSX.Element) => {
8+
return (props?: Omit<IconWrapperProps, "children">) => (
9+
<IconWrapper {...props}>{iconSvg()}</IconWrapper>
10+
);
11+
};
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { Badge } from ".";
3+
4+
const meta = {
5+
component: Badge,
6+
title: "UI/Badge",
7+
tags: ["autodocs"],
8+
} satisfies Meta<typeof Badge>;
9+
10+
export default meta;
11+
type Story = StoryObj<typeof meta>;
12+
13+
export const Default: Story = {
14+
args: {
15+
children: "Badge",
16+
type: "default",
17+
size: "sm",
18+
},
19+
};
20+
21+
export const DefaultWithIcon: Story = {
22+
args: {
23+
children: (
24+
<>
25+
<svg
26+
className="w-2.5 h-2.5"
27+
viewBox="0 0 11 11"
28+
fill="none"
29+
xmlns="http://www.w3.org/2000/svg"
30+
>
31+
<path
32+
d="M2.61248 2.61248L8.38748 8.38748M1.375 5.5C1.375 6.0417 1.4817 6.5781 1.689 7.07857C1.8963 7.57904 2.20014 8.03377 2.58318 8.41682C2.96623 8.79986 3.42096 9.1037 3.92143 9.311C4.4219 9.5183 4.9583 9.625 5.5 9.625C6.0417 9.625 6.5781 9.5183 7.07857 9.311C7.57904 9.1037 8.03377 8.79986 8.41682 8.41682C8.79986 8.03377 9.1037 7.57904 9.311 7.07857C9.5183 6.5781 9.625 6.0417 9.625 5.5C9.625 4.9583 9.5183 4.4219 9.311 3.92143C9.1037 3.42096 8.79986 2.96623 8.41682 2.58318C8.03377 2.20014 7.57904 1.8963 7.07857 1.689C6.5781 1.4817 6.0417 1.375 5.5 1.375C4.9583 1.375 4.4219 1.4817 3.92143 1.689C3.42096 1.8963 2.96623 2.20014 2.58318 2.58318C2.20014 2.96623 1.8963 3.42096 1.689 3.92143C1.4817 4.4219 1.375 4.9583 1.375 5.5Z"
33+
stroke="#E7000B"
34+
strokeLinecap="round"
35+
strokeLinejoin="round"
36+
/>
37+
</svg>
38+
Badge
39+
</>
40+
),
41+
size: "sm",
42+
},
43+
};
44+
45+
export const DefaultMediumSize: Story = {
46+
args: {
47+
children: <>Badge</>,
48+
size: "md",
49+
},
50+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
import { Badge } from ".";
4+
5+
describe("Badge component", () => {
6+
it("renders with correct text", () => {
7+
render(<Badge>A Badge!</Badge>);
8+
const badge = screen.getByText(/A Badge!/i);
9+
expect(badge).toBeInTheDocument();
10+
});
11+
12+
it("renders with correct icon and text", () => {
13+
render(
14+
<Badge>
15+
<svg
16+
data-testid="badge-icon"
17+
className="w-2.5 h-2.5"
18+
viewBox="0 0 11 11"
19+
fill="none"
20+
xmlns="http://www.w3.org/2000/svg"
21+
>
22+
<path
23+
d="M2.61248 2.61248L8.38748 8.38748M1.375 5.5C1.375 6.0417 1.4817 6.5781 1.689 7.07857C1.8963 7.57904 2.20014 8.03377 2.58318 8.41682C2.96623 8.79986 3.42096 9.1037 3.92143 9.311C4.4219 9.5183 4.9583 9.625 5.5 9.625C6.0417 9.625 6.5781 9.5183 7.07857 9.311C7.57904 9.1037 8.03377 8.79986 8.41682 8.41682C8.79986 8.03377 9.1037 7.57904 9.311 7.07857C9.5183 6.5781 9.625 6.0417 9.625 5.5C9.625 4.9583 9.5183 4.4219 9.311 3.92143C9.1037 3.42096 8.79986 2.96623 8.41682 2.58318C8.03377 2.20014 7.57904 1.8963 7.07857 1.689C6.5781 1.4817 6.0417 1.375 5.5 1.375C4.9583 1.375 4.4219 1.4817 3.92143 1.689C3.42096 1.8963 2.96623 2.20014 2.58318 2.58318C2.20014 2.96623 1.8963 3.42096 1.689 3.92143C1.4817 4.4219 1.375 4.9583 1.375 5.5Z"
24+
stroke="#E7000B"
25+
strokeLinecap="round"
26+
strokeLinejoin="round"
27+
/>
28+
</svg>
29+
A Badge!
30+
</Badge>,
31+
);
32+
let badge = screen.getByTestId("badge-icon");
33+
expect(badge).toBeInTheDocument();
34+
35+
badge = screen.getByText(/A Badge!/i);
36+
expect(badge).toBeInTheDocument();
37+
});
38+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/* eslint-disable react-refresh/only-export-components */
2+
import { forwardRef } from "react";
3+
import { tv, type VariantProps } from "tailwind-variants";
4+
5+
export const badge = tv({
6+
base: "inline-flex items-center gap-1 rounded-xl font-medium select-none",
7+
variants: {
8+
type: {
9+
default: "bg-badge-bg-default text-badge-text-default",
10+
},
11+
size: {
12+
sm: "px-2 py-1 text-xs",
13+
md: "px-2.5 py-1.5 text-sm",
14+
},
15+
border: {
16+
sm: "rounded-sm",
17+
md: "rounded-md",
18+
lg: "rounded-lg",
19+
xl: "rounded-xl",
20+
},
21+
},
22+
23+
defaultVariants: {
24+
size: "sm",
25+
type: "default",
26+
border: "xl",
27+
},
28+
});
29+
30+
type BadgeVariants = VariantProps<typeof badge>;
31+
32+
export interface BadgeProps
33+
extends BadgeVariants,
34+
React.HTMLAttributes<HTMLSpanElement> {}
35+
36+
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
37+
({ size, type, border, className, ...props }, ref) => {
38+
const badgeClassName = badge({ size, type, className, border });
39+
40+
return <span {...props} ref={ref} className={badgeClassName} />;
41+
},
42+
);
43+
44+
Badge.displayName = "Badge";
45+
46+
export { Badge };
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/* eslint-disable react-refresh/only-export-components */
2+
export { Badge, badge } from "./Badge";
3+
export type { BadgeProps } from "./Badge";

apps/web/src/features/Auth/components/Login.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { DiscordIcon } from "@/components/icons/Discord";
21
import { Button } from "@/components/ui/Button";
32
import { useTheme } from "@/components/ThemeProvider";
3+
import IcBaselineDiscord from "~icons/ic/baseline-discord";
44

55
import { useSearch } from "@tanstack/react-router";
66
import { auth } from "@/lib/authClient";
@@ -32,7 +32,7 @@ const Login = () => {
3232
onClick={() => auth.oauth.signIn("discord", redirect)}
3333
>
3434
<span>
35-
<DiscordIcon />
35+
<IcBaselineDiscord width="1.4em" height="1.4em" />
3636
</span>
3737
Log in with Discord
3838
</Button>
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { ComponentType } from "react";
2+
import TablerBan from "~icons/tabler/ban";
3+
import TablerUserCheck from "~icons/tabler/user-check";
4+
import TablerConfetti from "~icons/tabler/confetti";
5+
import TablerClockPause from "~icons/tabler/clock-pause";
6+
import TablerHourglassFilled from "~icons/tabler/hourglass-filled";
7+
import TablerPointFilled from "~icons/tabler/point-filled";
8+
import TablerId from "~icons/tabler/id";
9+
import TablerSettings2 from "~icons/tabler/settings-2";
10+
import TablerCalendarCheck from "~icons/tabler/calendar-check";
11+
12+
type ApplicationStatus = {
13+
[k: string]: {
14+
className: string;
15+
text: string;
16+
icon?: ComponentType<React.SVGProps<SVGSVGElement>>;
17+
};
18+
};
19+
20+
const defineStatus = <const T extends ApplicationStatus>(status: T) => {
21+
return status;
22+
};
23+
24+
const applicationStatus = defineStatus({
25+
rejected: {
26+
className: "bg-badge-bg-rejected text-badge-text-rejected",
27+
text: "Rejected",
28+
icon: TablerBan,
29+
},
30+
attending: {
31+
className: "bg-badge-bg-attending text-badge-text-attending",
32+
text: "Attending",
33+
icon: TablerUserCheck,
34+
},
35+
accepted: {
36+
className: "bg-badge-bg-accepted text-badge-text-accepted",
37+
text: "Accepted",
38+
icon: TablerConfetti,
39+
},
40+
waitlisted: {
41+
className: "bg-badge-bg-waitlisted text-badge-text-waitlisted",
42+
text: "Waitlisted",
43+
icon: TablerClockPause,
44+
},
45+
underReview: {
46+
className: "bg-badge-bg-underReview text-badge-text-underReview",
47+
text: "Under Review",
48+
icon: TablerHourglassFilled,
49+
},
50+
notApplied: {
51+
className: "bg-badge-bg-notApplied text-badge-text-notApplied",
52+
text: "Not Applied",
53+
icon: TablerPointFilled,
54+
},
55+
staff: {
56+
className: "bg-badge-bg-staff text-badge-text-staff",
57+
text: "Staff",
58+
icon: TablerId,
59+
},
60+
admin: {
61+
className: "bg-badge-bg-admin text-badge-text-admin",
62+
text: "Admin",
63+
icon: TablerSettings2,
64+
},
65+
notGoing: {
66+
className: "bg-badge-bg-notGoing text-badge-text-notGoing",
67+
text: "Not Going",
68+
icon: TablerBan,
69+
},
70+
completed: {
71+
className: "bg-badge-bg-completed text-badge-text-completed",
72+
text: "Completed",
73+
icon: TablerCalendarCheck,
74+
},
75+
});
76+
77+
export default applicationStatus;

0 commit comments

Comments
 (0)