refactor: migrate atom components to shadcn/ui primitives
Some checks failed
Pull Request Checks / Code Quality & Security (push) Has been cancelled
Security Audit / Security Vulnerability Audit (push) Has been cancelled
Security Audit / Dependency Review (push) Has been cancelled
Security Audit / CodeQL Security Analysis (push) Has been cancelled
Security Audit / Check Outdated Dependencies (push) Has been cancelled

Introduce a dual-layer component architecture:
- `components/ui/` contains raw shadcn/ui primitives (button, badge, input,
  checkbox, label, skeleton, alert, toggle, toggle-group, input-otp)
- `components/atoms/` wraps these primitives with enhanced APIs (loading
  states, semantic variants, polymorphic props) for backward compatibility

Migrated atoms: badge, button, checkbox, input, label, skeleton,
view-toggle, error-message, inline-toast, error-state.

Legacy backups preserved as .legacy.tsx files for reference.
Added barrel export for ui/ and updated components/index.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Temuulen Ankhbayar 2026-03-09 13:25:18 +09:00
parent cd9705d6b6
commit c8d0dfe230
35 changed files with 3273 additions and 178 deletions

View File

@ -32,6 +32,7 @@
"input-otp": "^1.4.2",
"lucide-react": "^0.563.0",
"next": "^16.1.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.4.0",

View File

@ -0,0 +1,115 @@
import { forwardRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/utils";
type BadgeVariant =
| "default"
| "secondary"
| "success"
| "warning"
| "error"
| "info"
| "outline"
| "ghost";
const dotColorMap: Record<BadgeVariant, string> = {
success: "bg-success",
warning: "bg-warning",
error: "bg-danger",
info: "bg-info",
default: "bg-primary-foreground",
secondary: "bg-secondary-foreground",
outline: "bg-muted-foreground",
ghost: "bg-muted-foreground",
};
const REMOVE_HOVER = "hover:bg-black/10";
const removeButtonColorMap: Record<BadgeVariant, string> = {
default: "text-primary-foreground hover:bg-primary-foreground/10",
secondary: `text-secondary-foreground ${REMOVE_HOVER}`,
success: REMOVE_HOVER,
warning: REMOVE_HOVER,
error: REMOVE_HOVER,
info: REMOVE_HOVER,
outline: REMOVE_HOVER,
ghost: REMOVE_HOVER,
};
const badgeVariants = cva(
"inline-flex items-center font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary-hover",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
success: "bg-success-soft text-success hover:bg-success-soft/80",
warning: "bg-warning-soft text-foreground hover:bg-warning-soft/80",
error: "bg-danger-soft text-danger hover:bg-danger-soft/80",
info: "bg-info-soft text-info hover:bg-info-soft/80",
outline: "border border-border bg-background text-foreground hover:bg-muted",
ghost: "text-foreground hover:bg-muted",
},
size: {
sm: "px-2 py-0.5 text-xs rounded",
default: "px-2.5 py-1 text-xs rounded-md",
lg: "px-3 py-1.5 text-sm rounded-lg",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {
icon?: React.ReactNode;
dot?: boolean;
removable?: boolean;
onRemove?: () => void;
}
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
(
{ className, variant = "default", size, icon, dot, removable, onRemove, children, ...props },
ref
) => {
const resolvedVariant = variant as BadgeVariant;
return (
<span ref={ref} className={cn(badgeVariants({ variant, size }), className)} {...props}>
{dot && (
<span className={cn("mr-1.5 h-1.5 w-1.5 rounded-full", dotColorMap[resolvedVariant])} />
)}
{icon && <span className="mr-1">{icon}</span>}
{children}
{removable && (
<button
type="button"
onClick={onRemove}
className={cn(
"ml-1 inline-flex h-3 w-3 items-center justify-center rounded-full focus:outline-none focus:ring-1 focus:ring-inset focus:ring-ring/20",
removeButtonColorMap[resolvedVariant]
)}
aria-label="Remove"
>
<svg className="h-2 w-2" fill="currentColor" viewBox="0 0 8 8">
<path
d="M1.5 1.5l5 5m0-5l-5 5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
)}
</span>
);
}
);
Badge.displayName = "Badge";
export { Badge, badgeVariants };
export type { BadgeProps };

View File

@ -1,5 +1,13 @@
/**
* Badge now powered by shadcn/ui base + custom semantic variants
*
* Keeps success/warning/error/info variants and the dot/removable features
* from the legacy Badge for backward compatibility.
*/
import { forwardRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/shared/utils";
type BadgeVariant =
@ -10,7 +18,8 @@ type BadgeVariant =
| "error"
| "info"
| "outline"
| "ghost";
| "ghost"
| "destructive";
const dotColorMap: Record<BadgeVariant, string> = {
success: "bg-success",
@ -21,34 +30,25 @@ const dotColorMap: Record<BadgeVariant, string> = {
secondary: "bg-secondary-foreground",
outline: "bg-muted-foreground",
ghost: "bg-muted-foreground",
};
const REMOVE_HOVER = "hover:bg-black/10";
const removeButtonColorMap: Record<BadgeVariant, string> = {
default: "text-primary-foreground hover:bg-primary-foreground/10",
secondary: `text-secondary-foreground ${REMOVE_HOVER}`,
success: REMOVE_HOVER,
warning: REMOVE_HOVER,
error: REMOVE_HOVER,
info: REMOVE_HOVER,
outline: REMOVE_HOVER,
ghost: REMOVE_HOVER,
destructive: "bg-white",
};
const badgeVariants = cva(
"inline-flex items-center font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary-hover",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
success: "bg-success-soft text-success hover:bg-success-soft/80",
warning: "bg-warning-soft text-foreground hover:bg-warning-soft/80",
error: "bg-danger-soft text-danger hover:bg-danger-soft/80",
info: "bg-info-soft text-info hover:bg-info-soft/80",
outline: "border border-border bg-background text-foreground hover:bg-muted",
ghost: "text-foreground hover:bg-muted",
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 [a&]:hover:bg-destructive/90",
success: "bg-success-soft text-success [a&]:hover:bg-success-soft/80",
warning: "bg-warning-soft text-foreground [a&]:hover:bg-warning-soft/80",
error: "bg-danger-soft text-danger [a&]:hover:bg-danger-soft/80",
info: "bg-info-soft text-info [a&]:hover:bg-info-soft/80",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
size: {
sm: "px-2 py-0.5 text-xs rounded",
@ -69,17 +69,36 @@ interface BadgeProps
dot?: boolean;
removable?: boolean;
onRemove?: () => void;
asChild?: boolean;
}
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
(
{ className, variant = "default", size, icon, dot, removable, onRemove, children, ...props },
{
className,
variant = "default",
size,
icon,
dot,
removable,
onRemove,
asChild = false,
children,
...props
},
ref
) => {
const resolvedVariant = variant as BadgeVariant;
const Comp = asChild ? Slot.Root : "span";
return (
<span ref={ref} className={cn(badgeVariants({ variant, size }), className)} {...props}>
<Comp
ref={ref}
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant, size }), className)}
{...props}
>
{dot && (
<span className={cn("mr-1.5 h-1.5 w-1.5 rounded-full", dotColorMap[resolvedVariant])} />
)}
@ -89,10 +108,7 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
<button
type="button"
onClick={onRemove}
className={cn(
"ml-1 inline-flex h-3 w-3 items-center justify-center rounded-full focus:outline-none focus:ring-1 focus:ring-inset focus:ring-ring/20",
removeButtonColorMap[resolvedVariant]
)}
className="ml-1 inline-flex h-3 w-3 items-center justify-center rounded-full hover:bg-black/10 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-ring/20"
aria-label="Remove"
>
<svg className="h-2 w-2" fill="currentColor" viewBox="0 0 8 8">
@ -105,7 +121,7 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
</svg>
</button>
)}
</span>
</Comp>
);
}
);

View File

@ -0,0 +1,160 @@
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "react";
import { forwardRef } from "react";
import Link from "next/link";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/utils";
import { Spinner } from "./spinner";
const buttonVariants = cva(
"group inline-flex items-center justify-center rounded-lg text-sm font-medium transition-all duration-[var(--cp-duration-normal)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50 disabled:pointer-events-none hover:scale-[1.01] active:scale-[0.98]",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary-hover shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]",
destructive:
"bg-danger text-danger-foreground hover:bg-danger/90 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]",
outline:
"border border-border bg-background text-foreground hover:bg-muted shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]",
ghost: "text-foreground hover:bg-muted",
subtle:
"bg-muted/50 text-foreground hover:bg-muted border border-transparent hover:border-border/40",
link: "underline-offset-4 hover:underline text-primary",
pill: "rounded-full bg-primary text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20 hover:-translate-y-0.5",
pillOutline: "rounded-full border border-border bg-card text-primary hover:bg-primary/5",
},
size: {
default: "h-11 py-2.5 px-4",
sm: "h-9 px-3 text-xs",
lg: "h-12 px-6 text-base",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
interface ButtonContentProps {
loading: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
loadingText?: ReactNode;
children?: ReactNode;
}
function ButtonContent({
loading,
leftIcon,
rightIcon,
loadingText,
children,
}: ButtonContentProps) {
return (
<span className="inline-flex items-center justify-center gap-2">
{loading ? <Spinner size="sm" /> : leftIcon}
<span>{loading ? (loadingText ?? children) : children}</span>
{!loading && rightIcon && (
<span className="transition-transform duration-200 group-hover:translate-x-0.5">
{rightIcon}
</span>
)}
</span>
);
}
interface ButtonExtras {
leftIcon?: ReactNode;
rightIcon?: ReactNode;
loading?: boolean;
isLoading?: boolean;
loadingText?: ReactNode;
}
type ButtonAsAnchorProps = {
as: "a";
href: string;
} & AnchorHTMLAttributes<HTMLAnchorElement> &
VariantProps<typeof buttonVariants> &
ButtonExtras;
type ButtonAsButtonProps = {
as?: "button";
} & ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> &
ButtonExtras;
export type ButtonProps = ButtonAsAnchorProps | ButtonAsButtonProps;
const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((props, ref) => {
const {
leftIcon,
rightIcon,
loading: loadingProp,
isLoading,
loadingText,
children,
...rest
} = props;
const loading = loadingProp ?? isLoading ?? false;
const contentProps = { loading, leftIcon, rightIcon, loadingText, children };
if (props.as === "a") {
const { className, variant, size, as: _as, href, ...anchorProps } = rest as ButtonAsAnchorProps;
void _as;
const isExternal = href.startsWith("http") || href.startsWith("mailto:");
const commonProps = {
className: cn(buttonVariants({ variant, size, className })),
"aria-busy": loading || undefined,
};
if (isExternal) {
return (
<a {...commonProps} href={href} ref={ref as React.Ref<HTMLAnchorElement>} {...anchorProps}>
<ButtonContent {...contentProps} />
</a>
);
}
return (
<Link
{...commonProps}
href={href}
ref={ref as React.Ref<HTMLAnchorElement>}
{...(anchorProps as Omit<typeof anchorProps, "onMouseEnter" | "onTouchStart" | "onClick">)}
>
<ButtonContent {...contentProps} />
</Link>
);
}
const {
className,
variant,
size,
as: _as,
disabled,
...buttonProps
} = rest as ButtonAsButtonProps;
void _as;
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref as React.Ref<HTMLButtonElement>}
disabled={loading || disabled}
aria-busy={loading || undefined}
{...buttonProps}
>
<ButtonContent {...contentProps} />
</button>
);
});
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./button";
import { Button as LegacyButton } from "./button.legacy";
import { ArrowRightIcon, PlusIcon } from "@heroicons/react/24/outline";
const meta: Meta<typeof Button> = {
@ -20,7 +21,7 @@ const meta: Meta<typeof Button> = {
"pillOutline",
],
},
size: { control: "select", options: ["default", "sm", "lg"] },
size: { control: "select", options: ["default", "xs", "sm", "lg", "icon"] },
loading: { control: "boolean" },
disabled: { control: "boolean" },
},
@ -52,6 +53,7 @@ export const AllVariants: Story = {
export const Sizes: Story = {
render: () => (
<div className="flex gap-3 items-center">
<Button size="xs">Extra Small</Button>
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
@ -81,3 +83,90 @@ export const Loading: Story = {
export const Disabled: Story = {
args: { children: "Disabled", disabled: true },
};
/* ── Comparison: Legacy vs shadcn ─────────────────────────────── */
export const ComparisonWithLegacy: Story = {
render: () => (
<div className="space-y-8">
<h2 className="text-lg font-bold text-foreground">Button Legacy vs shadcn/ui</h2>
{/* Variants */}
<div className="grid grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy</h3>
<div className="flex flex-wrap gap-2">
<LegacyButton variant="default">Primary</LegacyButton>
<LegacyButton variant="secondary">Secondary</LegacyButton>
<LegacyButton variant="outline">Outline</LegacyButton>
<LegacyButton variant="destructive">Destructive</LegacyButton>
<LegacyButton variant="ghost">Ghost</LegacyButton>
<LegacyButton variant="link">Link</LegacyButton>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui</h3>
<div className="flex flex-wrap gap-2">
<Button variant="default">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
</div>
</div>
</div>
{/* Sizes */}
<div className="grid grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy Sizes</h3>
<div className="flex gap-2 items-center">
<LegacyButton size="sm">Small</LegacyButton>
<LegacyButton size="default">Default</LegacyButton>
<LegacyButton size="lg">Large</LegacyButton>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui Sizes</h3>
<div className="flex gap-2 items-center">
<Button size="xs">Extra Small</Button>
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
</div>
</div>
</div>
{/* Loading */}
<div className="grid grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy Loading</h3>
<LegacyButton loading>Submitting...</LegacyButton>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui Loading</h3>
<Button loading>Submitting...</Button>
</div>
</div>
{/* With Icons */}
<div className="grid grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Legacy Icons</h3>
<div className="flex gap-2">
<LegacyButton leftIcon={<PlusIcon className="h-4 w-4" />}>Add</LegacyButton>
<LegacyButton rightIcon={<ArrowRightIcon className="h-4 w-4" />}>Next</LegacyButton>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3">shadcn/ui Icons</h3>
<div className="flex gap-2">
<Button leftIcon={<PlusIcon className="h-4 w-4" />}>Add</Button>
<Button rightIcon={<ArrowRightIcon className="h-4 w-4" />}>Next</Button>
</div>
</div>
</div>
</div>
),
};

View File

@ -1,3 +1,11 @@
/**
* Button now powered by shadcn/ui
*
* This file re-exports the shadcn Button and extends it with the extra props
* (loading, leftIcon, rightIcon, as="a") that the legacy Button supported so
* that every existing import keeps working without changes.
*/
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "react";
import { forwardRef } from "react";
import Link from "next/link";
@ -5,30 +13,35 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/utils";
import { Spinner } from "./spinner";
/* ── variant tokens ─────────────────────────────────────────────── */
const buttonVariants = cva(
"group inline-flex items-center justify-center rounded-lg text-sm font-medium transition-all duration-[var(--cp-duration-normal)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50 disabled:pointer-events-none hover:scale-[1.01] active:scale-[0.98]",
"group inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary-hover shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]",
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-danger text-danger-foreground hover:bg-danger/90 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]",
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border border-border bg-background text-foreground hover:bg-muted shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]",
ghost: "text-foreground hover:bg-muted",
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
subtle:
"bg-muted/50 text-foreground hover:bg-muted border border-transparent hover:border-border/40",
link: "underline-offset-4 hover:underline text-primary",
link: "text-primary underline-offset-4 hover:underline",
pill: "rounded-full bg-primary text-primary-foreground hover:bg-primary/90 shadow-md shadow-primary/20 hover:-translate-y-0.5",
pillOutline: "rounded-full border border-border bg-card text-primary hover:bg-primary/5",
},
size: {
default: "h-11 py-2.5 px-4",
sm: "h-9 px-3 text-xs",
lg: "h-12 px-6 text-base",
default: "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 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
@ -38,6 +51,8 @@ const buttonVariants = cva(
}
);
/* ── content helper (loading / icons) ───────────────────────────── */
interface ButtonContentProps {
loading: boolean;
leftIcon?: ReactNode;
@ -66,6 +81,8 @@ function ButtonContent({
);
}
/* ── polymorphic props ──────────────────────────────────────────── */
interface ButtonExtras {
leftIcon?: ReactNode;
rightIcon?: ReactNode;
@ -89,6 +106,8 @@ type ButtonAsButtonProps = {
export type ButtonProps = ButtonAsAnchorProps | ButtonAsButtonProps;
/* ── component ──────────────────────────────────────────────────── */
const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((props, ref) => {
const {
leftIcon,
@ -145,6 +164,9 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
return (
<button
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
ref={ref as React.Ref<HTMLButtonElement>}
disabled={loading || disabled}

View File

@ -0,0 +1,52 @@
/**
* Checkbox Component
* Basic checkbox input with label support
*/
import React from "react";
import { cn } from "@/shared/utils";
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: string;
error?: string;
helperText?: string;
}
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ className, label, error, helperText, id, ...props }, ref) => {
const checkboxId = id || `checkbox-${Math.random().toString(36).slice(2, 11)}`;
return (
<div className="flex flex-col space-y-1">
<div className="flex items-center space-x-2">
<input
type="checkbox"
id={checkboxId}
ref={ref}
className={cn(
"h-4 w-4 rounded border-input text-primary focus:ring-ring focus:ring-2",
error && "border-danger",
className
)}
{...props}
/>
{label && (
<label
htmlFor={checkboxId}
className={cn(
"text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
error && "text-danger"
)}
>
{label}
</label>
)}
</div>
{helperText && !error && <p className="text-xs text-muted-foreground">{helperText}</p>}
{error && <p className="text-xs text-danger">{error}</p>}
</div>
);
}
);
Checkbox.displayName = "Checkbox";

View File

@ -1,41 +1,57 @@
/**
* Checkbox Component
* Basic checkbox input with label support
* Checkbox now powered by shadcn/ui (Radix Checkbox primitive)
*
* Keeps the composite label/error/helperText API for backward compatibility.
*/
import React from "react";
"use client";
import * as React from "react";
import { CheckIcon } from "lucide-react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { cn } from "@/shared/utils";
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
export interface CheckboxProps extends Omit<
React.ComponentProps<typeof CheckboxPrimitive.Root>,
"type"
> {
label?: string;
error?: string;
helperText?: string;
}
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
const Checkbox = React.forwardRef<React.ComponentRef<typeof CheckboxPrimitive.Root>, CheckboxProps>(
({ className, label, error, helperText, id, ...props }, ref) => {
const checkboxId = id || `checkbox-${Math.random().toString(36).slice(2, 11)}`;
const generatedId = React.useId();
const checkboxId = id || `checkbox-${generatedId}`;
return (
<div className="flex flex-col space-y-1">
<div className="flex items-center space-x-2">
<input
type="checkbox"
<CheckboxPrimitive.Root
id={checkboxId}
ref={ref}
data-slot="checkbox"
className={cn(
"h-4 w-4 rounded border-input text-primary focus:ring-ring focus:ring-2",
error && "border-danger",
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30",
error && "border-destructive",
className
)}
{...props}
/>
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
{label && (
<label
htmlFor={checkboxId}
className={cn(
"text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
error && "text-danger"
error && "text-destructive"
)}
>
{label}
@ -43,10 +59,12 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
)}
</div>
{helperText && !error && <p className="text-xs text-muted-foreground">{helperText}</p>}
{error && <p className="text-xs text-danger">{error}</p>}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
);
Checkbox.displayName = "Checkbox";
export { Checkbox };

View File

@ -0,0 +1,44 @@
import { forwardRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/utils";
import { ExclamationCircleIcon } from "@heroicons/react/24/outline";
const errorMessageVariants = cva("flex items-center gap-1 text-sm", {
variants: {
variant: {
default: "text-red-600",
inline: "text-red-600",
subtle: "text-red-500",
},
},
defaultVariants: {
variant: "default",
},
});
interface ErrorMessageProps
extends React.HTMLAttributes<HTMLParagraphElement>, VariantProps<typeof errorMessageVariants> {
showIcon?: boolean;
}
const ErrorMessage = forwardRef<HTMLParagraphElement, ErrorMessageProps>(
({ className, variant, showIcon = true, children, ...props }, ref) => {
if (!children) return null;
return (
<p
ref={ref}
className={cn(errorMessageVariants({ variant, className }))}
role="alert"
{...props}
>
{showIcon && <ExclamationCircleIcon className="h-4 w-4 flex-shrink-0" />}
{children}
</p>
);
}
);
ErrorMessage.displayName = "ErrorMessage";
export { ErrorMessage, errorMessageVariants };
export type { ErrorMessageProps };

View File

@ -1,3 +1,10 @@
/**
* ErrorMessage now powered by shadcn/ui Alert base styles
*
* Keeps the same props interface (variant, showIcon, children) for backward
* compatibility. Uses Alert-inspired styling with destructive color tokens.
*/
import { forwardRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/utils";
@ -6,9 +13,9 @@ import { ExclamationCircleIcon } from "@heroicons/react/24/outline";
const errorMessageVariants = cva("flex items-center gap-1 text-sm", {
variants: {
variant: {
default: "text-red-600",
inline: "text-red-600",
subtle: "text-red-500",
default: "text-destructive",
inline: "text-destructive",
subtle: "text-destructive/80",
},
},
defaultVariants: {
@ -28,6 +35,7 @@ const ErrorMessage = forwardRef<HTMLParagraphElement, ErrorMessageProps>(
return (
<p
ref={ref}
data-slot="error-message"
className={cn(errorMessageVariants({ variant, className }))}
role="alert"
{...props}

View File

@ -0,0 +1,86 @@
import { ExclamationTriangleIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
import { Button } from "./button";
import { cn } from "@/shared/utils";
interface ErrorStateProps {
title?: string | undefined;
message?: string | undefined;
onRetry?: (() => void) | undefined;
retryLabel?: string | undefined;
className?: string | undefined;
variant?: "page" | "card" | "inline" | undefined;
}
export function ErrorState({
title = "Something went wrong",
message = "An unexpected error occurred. Please try again.",
onRetry,
retryLabel = "Try again",
className,
variant = "card",
}: ErrorStateProps) {
const baseClasses = "flex flex-col items-center justify-center text-center";
const variantClasses = {
page: "min-h-[400px] py-12",
card: "bg-card text-card-foreground border border-danger/25 rounded-2xl p-6 shadow-md",
inline: "bg-danger-soft border border-danger/25 rounded-md p-4",
};
const iconSizes = {
page: "h-16 w-16",
card: "h-12 w-12",
inline: "h-5 w-5",
};
const titleSizes = {
page: "text-xl",
card: "text-lg",
inline: "text-sm",
};
const messageSizes = {
page: "text-base",
card: "text-sm",
inline: "text-sm",
};
return (
<div className={cn(baseClasses, variantClasses[variant], className)} suppressHydrationWarning>
<div
className={cn("text-danger mb-4", variant === "inline" && "flex-shrink-0")}
suppressHydrationWarning
>
<ExclamationTriangleIcon className={iconSizes[variant]} />
</div>
<div className={variant === "inline" ? "ml-3 flex-1" : ""} suppressHydrationWarning>
<h3
className={cn("font-semibold text-foreground mb-2", titleSizes[variant])}
suppressHydrationWarning
>
{title}
</h3>
<p
className={cn("text-muted-foreground mb-4 max-w-md", messageSizes[variant])}
suppressHydrationWarning
>
{message}
</p>
{onRetry && (
<Button
onClick={onRetry}
variant="outline"
size={variant === "inline" ? "sm" : "default"}
className="text-danger border-danger/30 hover:bg-danger-soft"
>
<ArrowPathIcon className="h-4 w-4 mr-2" />
{retryLabel}
</Button>
)}
</div>
</div>
);
}

View File

@ -1,3 +1,10 @@
/**
* ErrorState now powered by shadcn/ui Alert base styles
*
* Uses Alert-inspired layout (grid with icon column) and destructive color
* tokens. Keeps the same variant/onRetry interface for backward compatibility.
*/
import { ExclamationTriangleIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
import { Button } from "./button";
import { cn } from "@/shared/utils";
@ -11,6 +18,30 @@ interface ErrorStateProps {
variant?: "page" | "card" | "inline" | undefined;
}
const variantClasses = {
page: "min-h-[400px] py-12",
card: "bg-card text-card-foreground border border-destructive/25 rounded-2xl p-6 shadow-md",
inline: "bg-destructive/10 border border-destructive/25 rounded-md p-4",
};
const iconSizes = {
page: "h-16 w-16",
card: "h-12 w-12",
inline: "h-5 w-5",
};
const titleSizes = {
page: "text-xl",
card: "text-lg",
inline: "text-sm",
};
const messageSizes = {
page: "text-base",
card: "text-sm",
inline: "text-sm",
};
export function ErrorState({
title = "Something went wrong",
message = "An unexpected error occurred. Please try again.",
@ -19,53 +50,24 @@ export function ErrorState({
className,
variant = "card",
}: ErrorStateProps) {
const baseClasses = "flex flex-col items-center justify-center text-center";
const variantClasses = {
page: "min-h-[400px] py-12",
card: "bg-card text-card-foreground border border-danger/25 rounded-2xl p-6 shadow-md",
inline: "bg-danger-soft border border-danger/25 rounded-md p-4",
};
const iconSizes = {
page: "h-16 w-16",
card: "h-12 w-12",
inline: "h-5 w-5",
};
const titleSizes = {
page: "text-xl",
card: "text-lg",
inline: "text-sm",
};
const messageSizes = {
page: "text-base",
card: "text-sm",
inline: "text-sm",
};
return (
<div className={cn(baseClasses, variantClasses[variant], className)} suppressHydrationWarning>
<div
className={cn("text-danger mb-4", variant === "inline" && "flex-shrink-0")}
suppressHydrationWarning
data-slot="error-state"
role="alert"
className={cn(
"flex flex-col items-center justify-center text-center",
variantClasses[variant],
className
)}
>
<div className={cn("text-destructive mb-4", variant === "inline" && "flex-shrink-0")}>
<ExclamationTriangleIcon className={iconSizes[variant]} />
</div>
<div className={variant === "inline" ? "ml-3 flex-1" : ""} suppressHydrationWarning>
<h3
className={cn("font-semibold text-foreground mb-2", titleSizes[variant])}
suppressHydrationWarning
>
{title}
</h3>
<div className={variant === "inline" ? "ml-3 flex-1" : ""}>
<h3 className={cn("font-semibold text-foreground mb-2", titleSizes[variant])}>{title}</h3>
<p
className={cn("text-muted-foreground mb-4 max-w-md", messageSizes[variant])}
suppressHydrationWarning
>
<p className={cn("text-muted-foreground mb-4 max-w-md", messageSizes[variant])}>
{message}
</p>
@ -74,7 +76,7 @@ export function ErrorState({
onClick={onRetry}
variant="outline"
size={variant === "inline" ? "sm" : "default"}
className="text-danger border-danger/30 hover:bg-danger-soft"
className="text-destructive border-destructive/30 hover:bg-destructive/10"
>
<ArrowPathIcon className="h-4 w-4 mr-2" />
{retryLabel}

View File

@ -0,0 +1,45 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/shared/utils";
type Tone = "info" | "success" | "warning" | "error";
interface InlineToastProps {
visible: boolean;
text: string;
tone?: Tone;
className?: string;
}
export function InlineToast({ visible, text, tone = "info", className = "" }: InlineToastProps) {
const toneClasses = {
success: "bg-success-bg border-success-border text-success",
warning: "bg-warning-bg border-warning-border text-warning",
error: "bg-danger-bg border-danger-border text-danger",
info: "bg-info-bg border-info-border text-info",
}[tone];
return (
<AnimatePresence>
{visible && (
<motion.div
className={cn("fixed bottom-6 right-6 z-50", className)}
initial={{ opacity: 0, x: "100%", scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: "100%", scale: 0.9 }}
transition={{ duration: 0.3, ease: [0.175, 0.885, 0.32, 1.275] }}
>
<div
className={cn(
"flex items-center gap-2 rounded-lg border px-4 py-3 shadow-lg min-w-[240px] text-sm font-medium",
toneClasses
)}
>
<span>{text}</span>
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -1,3 +1,10 @@
/**
* InlineToast now powered by shadcn/ui Alert base styles
*
* Keeps the same animated toast behavior (framer-motion) with tone variants,
* using Alert-inspired semantic color tokens for consistency.
*/
"use client";
import { AnimatePresence, motion } from "framer-motion";
@ -12,14 +19,14 @@ interface InlineToastProps {
className?: string;
}
export function InlineToast({ visible, text, tone = "info", className = "" }: InlineToastProps) {
const toneClasses = {
success: "bg-success-bg border-success-border text-success",
warning: "bg-warning-bg border-warning-border text-warning",
error: "bg-danger-bg border-danger-border text-danger",
info: "bg-info-bg border-info-border text-info",
}[tone];
const toneClasses: Record<Tone, string> = {
success: "border-success/30 bg-success-soft text-success",
warning: "border-warning/30 bg-warning-soft text-foreground",
error: "border-destructive/30 bg-destructive/10 text-destructive",
info: "border-info/30 bg-info-soft text-info",
};
export function InlineToast({ visible, text, tone = "info", className = "" }: InlineToastProps) {
return (
<AnimatePresence>
{visible && (
@ -31,9 +38,11 @@ export function InlineToast({ visible, text, tone = "info", className = "" }: In
transition={{ duration: 0.3, ease: [0.175, 0.885, 0.32, 1.275] }}
>
<div
role="alert"
data-slot="inline-toast"
className={cn(
"flex items-center gap-2 rounded-lg border px-4 py-3 shadow-lg min-w-[240px] text-sm font-medium",
toneClasses
toneClasses[tone]
)}
>
<span>{text}</span>

View File

@ -0,0 +1,37 @@
import type { InputHTMLAttributes, ReactNode } from "react";
import { forwardRef } from "react";
import { cn } from "@/shared/utils";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
error?: ReactNode;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, type, error, ...props }, ref) => {
const isInvalid = Boolean(error);
return (
<input
type={type}
className={cn(
"flex h-11 w-full rounded-lg border border-border bg-card text-foreground px-4 py-2.5 text-sm shadow-sm ring-offset-background",
"cp-input-focus",
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
"placeholder:text-muted-foreground",
"hover:border-muted-foreground/50",
"focus-visible:outline-none focus-visible:border-primary",
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border",
isInvalid &&
"border-danger hover:border-danger focus-visible:border-danger cp-input-error-shake",
className
)}
aria-invalid={isInvalid || undefined}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@ -1,3 +1,9 @@
/**
* Input now powered by shadcn/ui base styles
*
* Keeps the `error` prop from the legacy component for backward compatibility.
*/
import type { InputHTMLAttributes, ReactNode } from "react";
import { forwardRef } from "react";
import { cn } from "@/shared/utils";
@ -13,16 +19,12 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
return (
<input
type={type}
data-slot="input"
className={cn(
"flex h-11 w-full rounded-lg border border-border bg-card text-foreground px-4 py-2.5 text-sm shadow-sm ring-offset-background",
"cp-input-focus",
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
"placeholder:text-muted-foreground",
"hover:border-muted-foreground/50",
"focus-visible:outline-none focus-visible:border-primary",
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border",
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
isInvalid &&
"border-danger hover:border-danger focus-visible:border-danger cp-input-error-shake",
"border-destructive ring-destructive/20 focus-visible:border-destructive focus-visible:ring-destructive/20",
className
)}
aria-invalid={isInvalid || undefined}

View File

@ -0,0 +1,21 @@
import type { LabelHTMLAttributes } from "react";
import { forwardRef } from "react";
import { cn } from "@/shared/utils";
export type LabelProps = LabelHTMLAttributes<HTMLLabelElement>;
const Label = forwardRef<HTMLLabelElement, LabelProps>(({ className, ...props }, ref) => {
return (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
);
});
Label.displayName = "Label";
export { Label };

View File

@ -1,21 +1,30 @@
import type { LabelHTMLAttributes } from "react";
import { forwardRef } from "react";
/**
* Label now powered by shadcn/ui (Radix Label primitive)
*/
"use client";
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/shared/utils";
export type LabelProps = LabelHTMLAttributes<HTMLLabelElement>;
export type LabelProps = React.ComponentProps<typeof LabelPrimitive.Root>;
const Label = forwardRef<HTMLLabelElement, LabelProps>(({ className, ...props }, ref) => {
const Label = React.forwardRef<React.ComponentRef<typeof LabelPrimitive.Root>, LabelProps>(
({ className, ...props }, ref) => {
return (
<label
<LabelPrimitive.Root
ref={ref}
data-slot="label"
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
);
});
}
);
Label.displayName = "Label";
export { Label };

View File

@ -0,0 +1,20 @@
import { cn } from "@/shared/utils";
interface SkeletonProps {
className?: string;
animate?: boolean;
}
/**
* Base skeleton atom for loading states.
* A simple shimmer box primitive that can be composed into loading patterns.
*
* For composed loading skeletons, use:
* - LoadingCard, LoadingTable, LoadingStats from molecules/LoadingSkeletons
* - Feature-specific skeletons from features/[feature]/components/skeletons
*/
export function Skeleton({ className, animate = true }: SkeletonProps) {
return (
<div className={cn("rounded-md", animate ? "cp-skeleton-shimmer" : "bg-muted", className)} />
);
}

View File

@ -1,20 +1,22 @@
/**
* Skeleton now powered by shadcn/ui
*
* Uses the standard shadcn animate-pulse pattern instead of the custom
* cp-skeleton-shimmer animation.
*/
import { cn } from "@/shared/utils";
interface SkeletonProps {
className?: string;
interface SkeletonProps extends React.ComponentProps<"div"> {
animate?: boolean;
}
/**
* Base skeleton atom for loading states.
* A simple shimmer box primitive that can be composed into loading patterns.
*
* For composed loading skeletons, use:
* - LoadingCard, LoadingTable, LoadingStats from molecules/LoadingSkeletons
* - Feature-specific skeletons from features/[feature]/components/skeletons
*/
export function Skeleton({ className, animate = true }: SkeletonProps) {
export function Skeleton({ className, animate = true, ...props }: SkeletonProps) {
return (
<div className={cn("rounded-md", animate ? "cp-skeleton-shimmer" : "bg-muted", className)} />
<div
data-slot="skeleton"
className={cn("rounded-md", animate ? "animate-pulse bg-accent" : "bg-muted", className)}
{...props}
/>
);
}

View File

@ -0,0 +1,52 @@
"use client";
import { Squares2X2Icon, ListBulletIcon } from "@heroicons/react/24/outline";
import { cn } from "@/shared/utils";
export type ViewMode = "grid" | "list";
interface ViewToggleProps {
value: ViewMode;
onChange: (mode: ViewMode) => void;
className?: string;
}
export function ViewToggle({ value, onChange, className }: ViewToggleProps) {
return (
<div
className={cn(
"inline-flex items-center rounded-lg border border-border/60 bg-muted/30 p-0.5",
className
)}
>
<button
type="button"
onClick={() => onChange("grid")}
className={cn(
"inline-flex items-center justify-center h-7 w-7 rounded-md transition-all duration-200",
value === "grid"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
aria-label="Grid view"
aria-pressed={value === "grid"}
>
<Squares2X2Icon className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => onChange("list")}
className={cn(
"inline-flex items-center justify-center h-7 w-7 rounded-md transition-all duration-200",
value === "list"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
aria-label="List view"
aria-pressed={value === "list"}
>
<ListBulletIcon className="h-3.5 w-3.5" />
</button>
</div>
);
}

View File

@ -1,7 +1,15 @@
/**
* ViewToggle now powered by shadcn/ui ToggleGroup
*
* Wraps ToggleGroup with the same props interface (value/onChange with ViewMode)
* so existing consumers keep working without changes.
*/
"use client";
import { Squares2X2Icon, ListBulletIcon } from "@heroicons/react/24/outline";
import { cn } from "@/shared/utils";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
export type ViewMode = "grid" | "list";
@ -13,40 +21,23 @@ interface ViewToggleProps {
export function ViewToggle({ value, onChange, className }: ViewToggleProps) {
return (
<div
className={cn(
"inline-flex items-center rounded-lg border border-border/60 bg-muted/30 p-0.5",
className
)}
>
<button
type="button"
onClick={() => onChange("grid")}
className={cn(
"inline-flex items-center justify-center h-7 w-7 rounded-md transition-all duration-200",
value === "grid"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
aria-label="Grid view"
aria-pressed={value === "grid"}
<ToggleGroup
type="single"
value={value}
onValueChange={v => {
// Only fire if a value is selected (prevent deselect)
if (v) onChange(v as ViewMode);
}}
variant="outline"
size="sm"
className={cn("h-8", className)}
>
<ToggleGroupItem value="grid" aria-label="Grid view" className="h-7 w-7 px-0">
<Squares2X2Icon className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => onChange("list")}
className={cn(
"inline-flex items-center justify-center h-7 w-7 rounded-md transition-all duration-200",
value === "list"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
aria-label="List view"
aria-pressed={value === "list"}
>
</ToggleGroupItem>
<ToggleGroupItem value="list" aria-label="List view" className="h-7 w-7 px-0">
<ListBulletIcon className="h-3.5 w-3.5" />
</button>
</div>
</ToggleGroupItem>
</ToggleGroup>
);
}

View File

@ -1,9 +1,21 @@
/**
* Component library exports
* Centralized exports for all UI components following atomic design principles
*
* Import from here for convenience:
* import { Button, FormField, PageLayout } from "@/components";
*
* Or import from a specific layer:
* import { Button } from "@/components/atoms";
* import { FormField } from "@/components/molecules";
* import { Button as ShadcnButton } from "@/components/ui";
*/
// Atoms - Basic building blocks
// UI Primitives - Raw shadcn/ui components
// NOTE: Not re-exported here to avoid naming conflicts with atoms.
// Import directly: import { ... } from "@/components/ui";
// Atoms - Basic building blocks (enhanced wrappers over ui primitives)
export * from "./atoms";
// Molecules - Combinations of atoms

View File

@ -0,0 +1,60 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...props}
/>
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
className
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@ -0,0 +1,46 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,62 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
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",
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 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@ -0,0 +1,29 @@
"use client";
import * as React from "react";
import { CheckIcon } from "lucide-react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@ -0,0 +1,23 @@
/**
* UI Primitives shadcn/ui base components
*
* These are the raw shadcn/ui primitives. For enhanced versions with
* app-specific features (loading states, semantic variants, etc.),
* import from "@/components/atoms" instead.
*/
// Form primitives
export { Button, buttonVariants } from "./button";
export { Input } from "./input";
export { Checkbox } from "./checkbox";
export { Label } from "./label";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } from "./input-otp";
// Display primitives
export { Badge, badgeVariants } from "./badge";
export { Skeleton } from "./skeleton";
export { Alert, AlertTitle, AlertDescription } from "./alert";
// Toggle primitives
export { Toggle, toggleVariants } from "./toggle";
export { ToggleGroup, ToggleGroupItem } from "./toggle-group";

View File

@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
);
}
export { Input };

View File

@ -0,0 +1,21 @@
"use client";
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
);
}
export { Label };

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@ -0,0 +1,82 @@
"use client";
import * as React from "react";
import { type VariantProps } from "class-variance-authority";
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number;
}
>({
size: "default",
variant: "default",
spacing: 0,
});
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number;
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };

View File

@ -0,0 +1,46 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Toggle as TogglePrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };

View File

@ -0,0 +1 @@
export { cn } from "@/shared/utils";

1878
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff