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`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant default and size lg 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant default and size sm 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant destructive and size default 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant destructive and size icon 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant destructive and size lg 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant destructive and size sm 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant ghost and size default 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant ghost and size icon 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant ghost and size lg 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant ghost and size sm 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant link and size default 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant link and size icon 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant link and size lg 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant link and size sm 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant outline and size default 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant outline and size icon 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant outline and size lg 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant outline and size sm 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant secondary and size default 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant secondary and size icon 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant secondary and size lg 1`] = ` +
+ +
+`; + +exports[`Button component > renders correctly for variant secondary and size sm 1`] = ` +
+
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(); - expect(container).toMatchSnapshot(); - }); + it.each(combinations)( + "renders correctly for variant %s and size %s", + (variant, size) => { + const { container } = render( + + ); + 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 ; +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/", - }, - }, });