diff --git a/.storybook/main.ts b/.storybook/main.ts
index c3d3b98..702df6b 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -9,7 +9,11 @@ const config: StorybookConfig = {
],
framework: {
name: "@storybook/react-vite",
- options: {},
+ options: {
+ builder: {
+ viteConfigPath: ".storybook/vite.config.ts",
+ },
+ },
},
};
export default config;
diff --git a/.storybook/vite.config.ts b/.storybook/vite.config.ts
new file mode 100644
index 0000000..59d0ba8
--- /dev/null
+++ b/.storybook/vite.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vite";
+import tsconfigPaths from "vite-tsconfig-paths";
+
+const storybookViteConfig = defineConfig({
+ plugins: [tsconfigPaths()],
+});
+
+export default storybookViteConfig;
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..fa55380
--- /dev/null
+++ b/components.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.ts",
+ "css": "src/global.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "lib": "@/lib"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/package-lock.json b/package-lock.json
index 3bb15fa..75a68fd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,13 @@
"name": "@fidlabs/common-react-ui",
"version": "0.0.0-semantically-released",
"license": "ISC",
+ "dependencies": {
+ "@radix-ui/react-slot": "^1.1.1",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.474.0",
+ "tailwind-merge": "^2.6.0"
+ },
"devDependencies": {
"@eslint/js": "^9.19.0",
"@storybook/addon-essentials": "^8.5.2",
@@ -35,6 +42,7 @@
"typescript": "^5.7.3",
"typescript-eslint": "^8.22.0",
"vite": "^6.0.11",
+ "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.4"
},
"peerDependencies": {
@@ -1572,6 +1580,39 @@
"node": ">=12"
}
},
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
+ "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
+ "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@rollup/pluginutils": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
@@ -2842,7 +2883,7 @@
"version": "19.0.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz",
"integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -4016,6 +4057,18 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
+ "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
"node_modules/clean-stack": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz",
@@ -4349,6 +4402,15 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4639,7 +4701,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/data-urls": {
@@ -6303,6 +6365,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/globrex": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
+ "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -7702,6 +7771,15 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/lucide-react": {
+ "version": "0.474.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.474.0.tgz",
+ "integrity": "sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
@@ -13269,6 +13347,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/tailwind-merge": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
+ "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
"node_modules/tailwindcss": {
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
@@ -13693,6 +13781,27 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/tsconfck": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.4.tgz",
+ "integrity": "sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tsconfck": "bin/tsconfck.js"
+ },
+ "engines": {
+ "node": "^18 || >=20"
+ },
+ "peerDependencies": {
+ "typescript": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/tsconfig-paths": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
@@ -14203,6 +14312,26 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/vite-tsconfig-paths": {
+ "version": "5.1.4",
+ "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
+ "integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "globrex": "^0.1.2",
+ "tsconfck": "^3.0.3"
+ },
+ "peerDependencies": {
+ "vite": "*"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
"node_modules/vitest": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.4.tgz",
diff --git a/package.json b/package.json
index c2a89f4..3615b3a 100644
--- a/package.json
+++ b/package.json
@@ -59,9 +59,17 @@
"typescript": "^5.7.3",
"typescript-eslint": "^8.22.0",
"vite": "^6.0.11",
+ "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.4"
},
"publishConfig": {
"registry": "https://npm.pkg.github.com"
+ },
+ "dependencies": {
+ "@radix-ui/react-slot": "^1.1.1",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.474.0",
+ "tailwind-merge": "^2.6.0"
}
}
diff --git a/src/components/__snapshots__/button.spec.tsx.snap b/src/components/__snapshots__/button.spec.tsx.snap
index 8b83216..9dd1728 100644
--- a/src/components/__snapshots__/button.spec.tsx.snap
+++ b/src/components/__snapshots__/button.spec.tsx.snap
@@ -1,8 +1,240 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`Button component > renders correctly 1`] = `
+exports[`Button component > renders correctly for variant default and size default 1`] = `
-
+`;
+
+exports[`Button component > renders correctly for variant default and size icon 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant default and size lg 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant default and size sm 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant destructive and size default 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant destructive and size icon 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant destructive and size lg 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant destructive and size sm 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant ghost and size default 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant ghost and size icon 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant ghost and size lg 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant ghost and size sm 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant link and size default 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant link and size icon 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant link and size lg 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant link and size sm 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant outline and size default 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant outline and size icon 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant outline and size lg 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant outline and size sm 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant secondary and size default 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant secondary and size icon 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant secondary and size lg 1`] = `
+
+
+ Label
+
+
+`;
+
+exports[`Button component > renders correctly for variant secondary and size sm 1`] = `
+
+
Label
diff --git a/src/components/button.spec.tsx b/src/components/button.spec.tsx
index 319355f..c2bb7ac 100644
--- a/src/components/button.spec.tsx
+++ b/src/components/button.spec.tsx
@@ -1,10 +1,35 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
-import { Button } from "./button";
+import { Button, ButtonProps } from "./button";
+
+type Variant = NonNullable;
+type Size = NonNullable;
+
+const variants: Variant[] = [
+ "default",
+ "secondary",
+ "destructive",
+ "outline",
+ "ghost",
+ "link",
+];
+
+const sizes: Size[] = ["default", "sm", "lg", "icon"];
+
+const combinations = variants.flatMap((variant) =>
+ sizes.map((size) => [variant, size] as [Variant, Size])
+);
describe("Button component", () => {
- it("renders correctly", () => {
- const { container } = render(Label);
- expect(container).toMatchSnapshot();
- });
+ it.each(combinations)(
+ "renders correctly for variant %s and size %s",
+ (variant, size) => {
+ const { container } = render(
+
+ Label
+
+ );
+ expect(container).toMatchSnapshot();
+ }
+ );
});
diff --git a/src/components/button.stories.tsx b/src/components/button.stories.tsx
index 09b0de8..2ba876c 100644
--- a/src/components/button.stories.tsx
+++ b/src/components/button.stories.tsx
@@ -2,22 +2,96 @@ import type { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import { Button } from "./button";
+import { ChevronRight, Loader2, MailOpen } from "lucide-react";
-// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = {
title: "Button",
component: Button,
parameters: {
layout: "centered",
},
+ argTypes: {
+ disabled: {
+ control: "boolean",
+ },
+ variant: {
+ options: [
+ "default",
+ "secondary",
+ "destructive",
+ "outline",
+ "ghost",
+ "link",
+ ],
+ control: "select",
+ },
+ },
args: { onClick: fn() },
} satisfies Meta;
export default meta;
type Story = StoryObj;
-export const Default: Story = {
+export const Primary: Story = {
+ args: {
+ children: "Button",
+ variant: "default",
+ },
+};
+
+export const Secondary: Story = {
+ args: {
+ children: "Button",
+ variant: "secondary",
+ },
+};
+
+export const Destructive: Story = {
+ args: {
+ children: "Button",
+ variant: "destructive",
+ },
+};
+
+export const Outline: Story = {
+ args: {
+ children: "Button",
+ variant: "outline",
+ },
+};
+
+export const Ghost: Story = {
+ args: {
+ children: "Button",
+ variant: "ghost",
+ },
+};
+
+export const Link: Story = {
args: {
children: "Button",
+ variant: "link",
+ },
+};
+
+export const Icon: Story = {
+ args: {
+ children: ,
+ variant: "outline",
+ },
+};
+
+export const WithIcon: Story = {
+ args: {
+ // eslint-disable-next-line react/jsx-key
+ children: [, "Login with Email"],
+ },
+};
+
+export const Loading: Story = {
+ args: {
+ // eslint-disable-next-line react/jsx-key
+ children: [, "Please wait"],
+ disabled: true,
},
};
diff --git a/src/components/button.tsx b/src/components/button.tsx
index 65c2154..94ae3c7 100644
--- a/src/components/button.tsx
+++ b/src/components/button.tsx
@@ -1,7 +1,58 @@
-import type { HTMLAttributes } from "react";
+import { cn } from "@/lib/utils";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
-export type ButtonProps = HTMLAttributes;
+const buttonVariants = cva(
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 gap-2",
+ {
+ variants: {
+ variant: {
+ default: "bg-dodger-blue text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "!p-0 !h-auto text-primary overflow-hidden rounded-none relative button-link text-link",
+ linkSimple:
+ "!p-0 !h-auto text-primary overflow-hidden rounded-none relative text-link",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
-export function Button({ children, ...rest }: ButtonProps) {
- return {children};
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return (
+
+ );
+ }
+);
+
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/src/components/input.tsx b/src/components/input.tsx
deleted file mode 100644
index 4385ddf..0000000
--- a/src/components/input.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { HTMLAttributes } from "react";
-
-export type InputProps = HTMLAttributes;
-
-export function Input(props: InputProps) {
- return ;
-}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..a5ef193
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/tsconfig.json b/tsconfig.json
index 7983362..70ecefd 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "rootDir": ".",
"paths": {
"@/*": ["./src/*"]
},
diff --git a/vitest.config.ts b/vitest.config.ts
index 78d6024..a8a4e52 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,15 +1,11 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
+import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
- plugins: [react()],
+ plugins: [tsconfigPaths(), react()],
test: {
include: ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx"],
environment: "jsdom",
},
- resolve: {
- alias: {
- "@": "src/",
- },
- },
});