diff --git a/soroscan-frontend/.gitignore b/soroscan-frontend/.gitignore index beaba94d3..325546d00 100644 --- a/soroscan-frontend/.gitignore +++ b/soroscan-frontend/.gitignore @@ -42,3 +42,5 @@ next-env.d.ts # graphql codegen /src/generated/ + +*storybook.log diff --git a/soroscan-frontend/.storybook/main.ts b/soroscan-frontend/.storybook/main.ts new file mode 100644 index 000000000..c08e9d74c --- /dev/null +++ b/soroscan-frontend/.storybook/main.ts @@ -0,0 +1,23 @@ +import type { StorybookConfig } from '@storybook/nextjs'; + +const config: StorybookConfig = { + "stories": [ + "../src/**/*.mdx", + "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)", + "../components/**/*.stories.@(js|jsx|mjs|ts|tsx)", + "../components/**/*.mdx" + ], + "addons": [ + "@storybook/addon-essentials", + "@storybook/addon-onboarding", + "@storybook/addon-interactions" + ], + "framework": { + "name": "@storybook/nextjs", + "options": {} + }, + "staticDirs": [ + "..\\public" + ] +}; +export default config; \ No newline at end of file diff --git a/soroscan-frontend/.storybook/preview.ts b/soroscan-frontend/.storybook/preview.ts new file mode 100644 index 000000000..acccfa17e --- /dev/null +++ b/soroscan-frontend/.storybook/preview.ts @@ -0,0 +1,23 @@ +import type { Preview } from '@storybook/react'; +import '../app/globals.css'; + +const preview: Preview = { + parameters: { + layout: 'centered', + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + backgrounds: { + default: 'light', + values: [ + { name: 'light', value: '#ffffff' }, + { name: 'dark', value: '#0f172a' }, + ], + }, + }, +}; + +export default preview; diff --git a/soroscan-frontend/__tests__/description-count.test.tsx b/soroscan-frontend/__tests__/description-count.test.tsx index 10ca711b0..84f7baa28 100644 --- a/soroscan-frontend/__tests__/description-count.test.tsx +++ b/soroscan-frontend/__tests__/description-count.test.tsx @@ -48,5 +48,5 @@ describe("Description character count", () => { const counter = screen.getByText("230/256") expect(counter).toHaveClass("text-terminal-danger") - }) + }, 30000) }) diff --git a/soroscan-frontend/__tests__/ui-button.test.tsx b/soroscan-frontend/__tests__/ui-button.test.tsx new file mode 100644 index 000000000..69b7e9788 --- /dev/null +++ b/soroscan-frontend/__tests__/ui-button.test.tsx @@ -0,0 +1,280 @@ +/** + * __tests__/ui-button.test.tsx + * + * Unit tests for `components/ui/button.tsx` + * Covers: variants, sizes, disabled state, loading state, icon slots. + * Closes #788 + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { Button } from '@/components/ui/button'; + +/* -------------------------------------------------------------------------- */ +/* Helpers */ +/* -------------------------------------------------------------------------- */ + +const LeadingIcon = () => ; +const TrailingIcon = () => ; + +/* -------------------------------------------------------------------------- */ +/* Rendering */ +/* -------------------------------------------------------------------------- */ + +describe('Button — rendering', () => { + it('renders children as the button label', () => { + render(); + expect(screen.getByRole('button', { name: /index contract/i })).toBeInTheDocument(); + }); + + it('renders as a ); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('forwards data-slot="button" attribute', () => { + render(); + expect(screen.getByTestId('btn')).toHaveAttribute('data-slot', 'button'); + }); + + it('forwards extra HTML attributes', () => { + render(); + expect(screen.getByTestId('btn')).toHaveAttribute('aria-label', 'custom label'); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Variants */ +/* -------------------------------------------------------------------------- */ + +describe('Button — variants', () => { + const variants = ['default', 'primary', 'secondary', 'ghost', 'outline', 'destructive', 'link'] as const; + + it.each(variants)('renders variant="%s" without crashing', (variant) => { + render(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('sets data-variant attribute to the given variant', () => { + render(); + expect(screen.getByTestId('btn')).toHaveAttribute('data-variant', 'secondary'); + }); + + it('applies primary class (bg-primary) for variant="default"', () => { + render(); + expect(screen.getByTestId('btn')).toHaveClass('bg-primary'); + }); + + it('applies bg-secondary class for variant="secondary"', () => { + render(); + expect(screen.getByTestId('btn')).toHaveClass('bg-secondary'); + }); + + it('applies border class for variant="outline"', () => { + render(); + expect(screen.getByTestId('btn')).toHaveClass('border'); + }); + + it('applies bg-destructive class for variant="destructive"', () => { + render(); + expect(screen.getByTestId('btn')).toHaveClass('bg-destructive'); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Sizes */ +/* -------------------------------------------------------------------------- */ + +describe('Button — sizes', () => { + it('sets data-size attribute to the given size', () => { + render(); + expect(screen.getByTestId('btn')).toHaveAttribute('data-size', 'sm'); + }); + + it('applies h-8 class for size="sm"', () => { + render(); + expect(screen.getByTestId('btn')).toHaveClass('h-8'); + }); + + it('applies h-9 class for size="default" (medium)', () => { + render(); + expect(screen.getByTestId('btn')).toHaveClass('h-9'); + }); + + it('applies h-10 class for size="lg"', () => { + render(); + expect(screen.getByTestId('btn')).toHaveClass('h-10'); + }); + + it('applies size-9 class for size="icon"', () => { + render(); + expect(screen.getByTestId('btn')).toHaveClass('size-9'); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Disabled state */ +/* -------------------------------------------------------------------------- */ + +describe('Button — disabled state', () => { + it('is disabled when disabled prop is true', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('does not fire onClick when disabled', () => { + const handleClick = jest.fn(); + render(); + fireEvent.click(screen.getByRole('button')); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it('applies disabled:opacity-50 via the class list', () => { + render(); + expect(screen.getByTestId('btn')).toHaveClass('disabled:opacity-50'); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Loading state */ +/* -------------------------------------------------------------------------- */ + +describe('Button — loading state', () => { + it('renders a spinner (Loader2 svg) when isLoading=true', () => { + render(); + // Loader2 renders as an inside the button + const svg = screen.getByRole('button').querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('is disabled when isLoading=true', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('sets aria-busy="true" when isLoading=true', () => { + render(); + expect(screen.getByTestId('btn')).toHaveAttribute('aria-busy', 'true'); + }); + + it('sets data-loading attribute when isLoading=true', () => { + render(); + expect(screen.getByTestId('btn')).toHaveAttribute('data-loading', 'true'); + }); + + it('does not fire onClick when isLoading=true', () => { + const handleClick = jest.fn(); + render(); + fireEvent.click(screen.getByRole('button')); + expect(handleClick).not.toHaveBeenCalled(); + }); + + it('still renders label text alongside the spinner', () => { + render(); + expect(screen.getByText('Indexing…')).toBeInTheDocument(); + }); + + it('does not render aria-busy when not loading', () => { + render(); + expect(screen.getByTestId('btn')).not.toHaveAttribute('aria-busy'); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Icon slots */ +/* -------------------------------------------------------------------------- */ + +describe('Button — icon slots', () => { + it('renders leading icon before the label', () => { + render( + }>Search + ); + expect(screen.getByTestId('leading-icon')).toBeInTheDocument(); + expect(screen.getByText('Search')).toBeInTheDocument(); + }); + + it('renders trailing icon after the label', () => { + render( + }>Next + ); + expect(screen.getByTestId('trailing-icon')).toBeInTheDocument(); + expect(screen.getByText('Next')).toBeInTheDocument(); + }); + + it('renders both leading and trailing icons simultaneously', () => { + render( + } trailingIcon={}> + Export + + ); + expect(screen.getByTestId('leading-icon')).toBeInTheDocument(); + expect(screen.getByTestId('trailing-icon')).toBeInTheDocument(); + }); + + it('hides leading icon and shows spinner when isLoading=true', () => { + render( + }> + Loading + + ); + // The spinner SVG should be present instead of the leading icon + expect(screen.queryByTestId('leading-icon')).not.toBeInTheDocument(); + const svg = screen.getByRole('button').querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('hides trailing icon when isLoading=true', () => { + render( + }> + Loading + + ); + expect(screen.queryByTestId('trailing-icon')).not.toBeInTheDocument(); + }); + + it('renders no icon elements when no icon props are provided', () => { + render(); + expect(screen.getByTestId('btn').querySelector('svg')).toBeNull(); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Click behaviour */ +/* -------------------------------------------------------------------------- */ + +describe('Button — click behaviour', () => { + it('calls onClick when clicked in default state', () => { + const handleClick = jest.fn(); + render(); + fireEvent.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('works with all variants when clicked', () => { + const variants = ['default', 'secondary', 'ghost', 'outline'] as const; + variants.forEach((variant) => { + const handleClick = jest.fn(); + const { unmount } = render( + + ); + fireEvent.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalledTimes(1); + unmount(); + }); + }); + + it('works with all sizes when clicked', () => { + const sizes = ['sm', 'default', 'lg'] as const; + sizes.forEach((size) => { + const handleClick = jest.fn(); + const { unmount } = render( + + ); + fireEvent.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalledTimes(1); + unmount(); + }); + }); +}); diff --git a/soroscan-frontend/components/ui/Button.stories.tsx b/soroscan-frontend/components/ui/Button.stories.tsx new file mode 100644 index 000000000..955e109a3 --- /dev/null +++ b/soroscan-frontend/components/ui/Button.stories.tsx @@ -0,0 +1,411 @@ +/** + * Button.stories.tsx + * + * Storybook documentation for the SoroScan `Button` component. + * Covers every combination required by issue #788: + * - Variants : Primary, Secondary, Ghost, Outline (+ Destructive, Link) + * - Sizes : Small (sm), Medium (md / default), Large (lg) + * - States : Default, Hover (via CSS), Disabled, Loading + * - Icons : Leading icon slot, Trailing icon slot, Icon-only + */ + +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { Search, ArrowRight, Download, Plus, Trash2, ExternalLink } from 'lucide-react'; +import React from 'react'; + +import { Button } from '@/components/ui/button'; + +/* -------------------------------------------------------------------------- */ +/* Meta */ +/* -------------------------------------------------------------------------- */ + +const meta = { + title: 'UI/Button', + component: Button, + tags: ['autodocs'], + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +The \`Button\` component is the primary interactive element across SoroScan's UI. +It supports four visual **variants**, three **sizes**, two **states** (disabled / loading), +and optional **leading / trailing icon** slots. + +\`\`\`tsx +import { Button } from '@/components/ui/button'; + + +\`\`\` + `, + }, + }, + }, + argTypes: { + variant: { + description: 'Visual style of the button.', + control: 'select', + options: ['default', 'primary', 'secondary', 'ghost', 'outline', 'destructive', 'link'], + table: { + defaultValue: { summary: 'default' }, + }, + }, + size: { + description: 'Controls padding and height.', + control: 'select', + options: ['sm', 'default', 'md', 'lg', 'xs', 'icon', 'icon-sm', 'icon-lg'], + table: { + defaultValue: { summary: 'default' }, + }, + }, + disabled: { + description: 'Prevents interaction and renders an opacity-reduced style.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + isLoading: { + description: 'Shows a spinner, disables the button, and sets `aria-busy`.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + children: { + description: 'Button label / content.', + control: 'text', + }, + onClick: { action: 'clicked' }, + }, + args: { + onClick: fn(), + children: 'Button', + isLoading: false, + disabled: false, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/* -------------------------------------------------------------------------- */ +/* Playground (interactive controls) */ +/* -------------------------------------------------------------------------- */ + +/** + * Use the Controls panel below to toggle any prop in real-time. + */ +export const Playground: Story = { + args: { + variant: 'default', + size: 'default', + children: 'Button', + }, +}; + +/* -------------------------------------------------------------------------- */ +/* Variants */ +/* -------------------------------------------------------------------------- */ + +/** + * **Primary** — high-emphasis filled button for the most important action on screen. + * Maps to `variant="default"` or the explicit alias `variant="primary"`. + */ +export const Primary: Story = { + args: { variant: 'primary', children: 'Index Contract' }, +}; + +/** + * **Secondary** — muted filled button for supporting actions. + */ +export const Secondary: Story = { + args: { variant: 'secondary', children: 'View Details' }, +}; + +/** + * **Ghost** — no background; lowest visual weight, used inside toolbars and menus. + */ +export const Ghost: Story = { + args: { variant: 'ghost', children: 'Cancel' }, +}; + +/** + * **Outline** — bordered, transparent background. + * Use when a button needs to be visible but not dominant. + */ +export const Outline: Story = { + args: { variant: 'outline', children: 'Export CSV' }, +}; + +/** + * **Destructive** — signals a dangerous or irreversible action. + */ +export const Destructive: Story = { + args: { variant: 'destructive', children: 'Delete Contract' }, +}; + +/** + * **Link** — inline text link styled as a button. + */ +export const Link: Story = { + args: { variant: 'link', children: 'Learn more' }, +}; + +/* -------------------------------------------------------------------------- */ +/* All Variants — visual reference row */ +/* -------------------------------------------------------------------------- */ + +/** + * All six variants side-by-side for a quick visual comparison. + */ +export const AllVariants: Story = { + render: () => ( +
+ + + + + + +
+ ), + parameters: { + docs: { + description: { + story: 'Side-by-side view of every available `variant`.', + }, + }, + }, +}; + +/* -------------------------------------------------------------------------- */ +/* Sizes */ +/* -------------------------------------------------------------------------- */ + +/** + * **Small (sm)** — compact button for dense UIs, tables, and inline controls. + */ +export const Small: Story = { + args: { size: 'sm', children: 'Small' }, +}; + +/** + * **Medium (default)** — the standard size used in most contexts. + */ +export const Medium: Story = { + args: { size: 'default', children: 'Medium' }, +}; + +/** + * **Large (lg)** — prominent CTA buttons, hero sections, onboarding flows. + */ +export const Large: Story = { + args: { size: 'lg', children: 'Large' }, +}; + +/** + * All three primary sizes rendered together for quick visual comparison. + */ +export const AllSizes: Story = { + render: () => ( +
+ + + +
+ ), + parameters: { + docs: { + description: { + story: 'Small (`sm`), Medium (`default`), and Large (`lg`) next to each other.', + }, + }, + }, +}; + +/* -------------------------------------------------------------------------- */ +/* States */ +/* -------------------------------------------------------------------------- */ + +/** + * **Disabled** — `pointer-events-none` + 50 % opacity. No click events fire. + */ +export const Disabled: Story = { + args: { disabled: true, children: 'Disabled' }, +}; + +/** + * **Loading** — replaces the label icon with a `Loader2` spinner and sets + * `aria-busy="true"` plus `disabled` so the button cannot be clicked. + */ +export const Loading: Story = { + args: { isLoading: true, children: 'Indexing…' }, +}; + +/** + * Loading state applied across all variants simultaneously. + */ +export const LoadingAllVariants: Story = { + render: () => ( +
+ + + + + +
+ ), + parameters: { + docs: { + description: { + story: 'The loading spinner replaces any leading icon and the button becomes non-interactive.', + }, + }, + }, +}; + +/** + * Disabled state applied across all variants. + */ +export const DisabledAllVariants: Story = { + render: () => ( +
+ + + + + +
+ ), +}; + +/* -------------------------------------------------------------------------- */ +/* Icons */ +/* -------------------------------------------------------------------------- */ + +/** + * **Leading icon** — icon rendered to the left of the label. + * Use to reinforce the action (e.g. a search icon before "Search"). + */ +export const WithLeadingIcon: Story = { + args: { + variant: 'default', + leadingIcon: , + children: 'Search Events', + }, +}; + +/** + * **Trailing icon** — icon rendered to the right of the label. + * Use to indicate navigation or output actions (e.g. "Export →"). + */ +export const WithTrailingIcon: Story = { + args: { + variant: 'outline', + trailingIcon: , + children: 'View All', + }, +}; + +/** + * **Both icons** — leading and trailing icons together. + */ +export const WithBothIcons: Story = { + args: { + variant: 'secondary', + leadingIcon: , + trailingIcon: , + children: 'Download Report', + }, +}; + +/** + * **Icon-only** — use `size="icon"` with an accessible `aria-label`. + * No visible text; the icon carries all meaning. + */ +export const IconOnly: Story = { + args: { + variant: 'outline', + size: 'icon', + 'aria-label': 'Add contract', + children: , + }, +}; + +/** + * Icon-only destructive variant — e.g. a delete row button. + */ +export const IconOnlyDestructive: Story = { + args: { + variant: 'destructive', + size: 'icon', + 'aria-label': 'Delete record', + children: , + }, +}; + +/** + * Icons combined with the loading state — the spinner replaces the leading icon + * while the trailing icon is hidden to avoid confusion. + */ +export const IconWithLoading: Story = { + args: { + variant: 'default', + isLoading: true, + leadingIcon: , + trailingIcon: , + children: 'Downloading…', + }, +}; + +/* -------------------------------------------------------------------------- */ +/* Full matrix — all variants × all sizes */ +/* -------------------------------------------------------------------------- */ + +/** + * Complete reference grid: every variant at every size. + */ +export const FullMatrix: Story = { + render: () => { + const variants = ['default', 'secondary', 'ghost', 'outline', 'destructive'] as const; + const sizes = ['sm', 'default', 'lg'] as const; + const sizeLabels: Record = { sm: 'sm', default: 'md', lg: 'lg' }; + + return ( +
+ + + + + {sizes.map((s) => ( + + ))} + + + + {variants.map((v) => ( + + + {sizes.map((s) => ( + + ))} + + ))} + +
Variant \ Size + {sizeLabels[s]} +
{v} + +
+
+ ); + }, + parameters: { + layout: 'padded', + docs: { + description: { + story: 'Every variant × size combination rendered in a reference grid.', + }, + }, + }, +}; diff --git a/soroscan-frontend/components/ui/button.tsx b/soroscan-frontend/components/ui/button.tsx index b5ea4abd1..c7c4aa6df 100644 --- a/soroscan-frontend/components/ui/button.tsx +++ b/soroscan-frontend/components/ui/button.tsx @@ -1,6 +1,7 @@ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Slot } from "radix-ui" +import { Loader2 } from "lucide-react" import { cn } from "@/lib/utils" @@ -9,19 +10,27 @@ const buttonVariants = cva( { variants: { variant: { + /** Filled high-emphasis action — maps to the design system "Primary" */ default: "bg-primary text-primary-foreground hover:bg-primary/90", + /** Alias so callers can use variant="primary" explicitly */ + primary: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + /** Bordered, transparent-background button */ outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + /** Muted filled button for secondary actions */ secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + /** No background or border — low-emphasis action */ ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", + /** Alias: size="md" resolves to the default (medium) height */ + md: "h-9 px-4 py-2 has-[>svg]:px-3", xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", @@ -38,16 +47,30 @@ const buttonVariants = cva( } ) +export interface ButtonProps + extends React.ComponentProps<"button">, + VariantProps { + asChild?: boolean + /** Shows a spinner and sets the button to a non-interactive loading state */ + isLoading?: boolean + /** Icon rendered before the label */ + leadingIcon?: React.ReactNode + /** Icon rendered after the label */ + trailingIcon?: React.ReactNode +} + function Button({ className, variant = "default", size = "default", asChild = false, + isLoading = false, + leadingIcon, + trailingIcon, + children, + disabled, ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - }) { +}: ButtonProps) { const Comp = asChild ? Slot.Root : "button" return ( @@ -55,9 +78,20 @@ function Button({ data-slot="button" data-variant={variant} data-size={size} + data-loading={isLoading || undefined} + disabled={disabled || isLoading} + aria-busy={isLoading || undefined} className={cn(buttonVariants({ variant, size, className }))} {...props} - /> + > + {isLoading ? ( +