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
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:
parent
cd9705d6b6
commit
c8d0dfe230
@ -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",
|
||||
|
||||
115
apps/portal/src/components/atoms/badge.legacy.tsx
Normal file
115
apps/portal/src/components/atoms/badge.legacy.tsx
Normal 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 };
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
160
apps/portal/src/components/atoms/button.legacy.tsx
Normal file
160
apps/portal/src/components/atoms/button.legacy.tsx
Normal 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 };
|
||||
@ -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>
|
||||
),
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
52
apps/portal/src/components/atoms/checkbox.legacy.tsx
Normal file
52
apps/portal/src/components/atoms/checkbox.legacy.tsx
Normal 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";
|
||||
@ -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 };
|
||||
|
||||
44
apps/portal/src/components/atoms/error-message.legacy.tsx
Normal file
44
apps/portal/src/components/atoms/error-message.legacy.tsx
Normal 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 };
|
||||
@ -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}
|
||||
|
||||
86
apps/portal/src/components/atoms/error-state.legacy.tsx
Normal file
86
apps/portal/src/components/atoms/error-state.legacy.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
45
apps/portal/src/components/atoms/inline-toast.legacy.tsx
Normal file
45
apps/portal/src/components/atoms/inline-toast.legacy.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
37
apps/portal/src/components/atoms/input.legacy.tsx
Normal file
37
apps/portal/src/components/atoms/input.legacy.tsx
Normal 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 };
|
||||
@ -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}
|
||||
|
||||
21
apps/portal/src/components/atoms/label.legacy.tsx
Normal file
21
apps/portal/src/components/atoms/label.legacy.tsx
Normal 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 };
|
||||
@ -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 };
|
||||
|
||||
20
apps/portal/src/components/atoms/skeleton.legacy.tsx
Normal file
20
apps/portal/src/components/atoms/skeleton.legacy.tsx
Normal 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)} />
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
52
apps/portal/src/components/atoms/view-toggle.legacy.tsx
Normal file
52
apps/portal/src/components/atoms/view-toggle.legacy.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
60
apps/portal/src/components/ui/alert.tsx
Normal file
60
apps/portal/src/components/ui/alert.tsx
Normal 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 };
|
||||
46
apps/portal/src/components/ui/badge.tsx
Normal file
46
apps/portal/src/components/ui/badge.tsx
Normal 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 };
|
||||
62
apps/portal/src/components/ui/button.tsx
Normal file
62
apps/portal/src/components/ui/button.tsx
Normal 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 };
|
||||
29
apps/portal/src/components/ui/checkbox.tsx
Normal file
29
apps/portal/src/components/ui/checkbox.tsx
Normal 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 };
|
||||
23
apps/portal/src/components/ui/index.ts
Normal file
23
apps/portal/src/components/ui/index.ts
Normal 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";
|
||||
21
apps/portal/src/components/ui/input.tsx
Normal file
21
apps/portal/src/components/ui/input.tsx
Normal 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 };
|
||||
21
apps/portal/src/components/ui/label.tsx
Normal file
21
apps/portal/src/components/ui/label.tsx
Normal 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 };
|
||||
13
apps/portal/src/components/ui/skeleton.tsx
Normal file
13
apps/portal/src/components/ui/skeleton.tsx
Normal 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 };
|
||||
82
apps/portal/src/components/ui/toggle-group.tsx
Normal file
82
apps/portal/src/components/ui/toggle-group.tsx
Normal 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 };
|
||||
46
apps/portal/src/components/ui/toggle.tsx
Normal file
46
apps/portal/src/components/ui/toggle.tsx
Normal 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 };
|
||||
1
apps/portal/src/lib/utils.ts
Normal file
1
apps/portal/src/lib/utils.ts
Normal file
@ -0,0 +1 @@
|
||||
export { cn } from "@/shared/utils";
|
||||
1878
pnpm-lock.yaml
generated
1878
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user