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",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"tailwind-merge": "^3.4.0",
|
"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 { forwardRef } from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { Slot } from "radix-ui";
|
||||||
import { cn } from "@/shared/utils";
|
import { cn } from "@/shared/utils";
|
||||||
|
|
||||||
type BadgeVariant =
|
type BadgeVariant =
|
||||||
@ -10,7 +18,8 @@ type BadgeVariant =
|
|||||||
| "error"
|
| "error"
|
||||||
| "info"
|
| "info"
|
||||||
| "outline"
|
| "outline"
|
||||||
| "ghost";
|
| "ghost"
|
||||||
|
| "destructive";
|
||||||
|
|
||||||
const dotColorMap: Record<BadgeVariant, string> = {
|
const dotColorMap: Record<BadgeVariant, string> = {
|
||||||
success: "bg-success",
|
success: "bg-success",
|
||||||
@ -21,34 +30,25 @@ const dotColorMap: Record<BadgeVariant, string> = {
|
|||||||
secondary: "bg-secondary-foreground",
|
secondary: "bg-secondary-foreground",
|
||||||
outline: "bg-muted-foreground",
|
outline: "bg-muted-foreground",
|
||||||
ghost: "bg-muted-foreground",
|
ghost: "bg-muted-foreground",
|
||||||
};
|
destructive: "bg-white",
|
||||||
|
|
||||||
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(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary-hover",
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
success: "bg-success-soft text-success hover:bg-success-soft/80",
|
destructive:
|
||||||
warning: "bg-warning-soft text-foreground hover:bg-warning-soft/80",
|
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 [a&]:hover:bg-destructive/90",
|
||||||
error: "bg-danger-soft text-danger hover:bg-danger-soft/80",
|
success: "bg-success-soft text-success [a&]:hover:bg-success-soft/80",
|
||||||
info: "bg-info-soft text-info hover:bg-info-soft/80",
|
warning: "bg-warning-soft text-foreground [a&]:hover:bg-warning-soft/80",
|
||||||
outline: "border border-border bg-background text-foreground hover:bg-muted",
|
error: "bg-danger-soft text-danger [a&]:hover:bg-danger-soft/80",
|
||||||
ghost: "text-foreground hover:bg-muted",
|
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: {
|
size: {
|
||||||
sm: "px-2 py-0.5 text-xs rounded",
|
sm: "px-2 py-0.5 text-xs rounded",
|
||||||
@ -69,17 +69,36 @@ interface BadgeProps
|
|||||||
dot?: boolean;
|
dot?: boolean;
|
||||||
removable?: boolean;
|
removable?: boolean;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
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
|
ref
|
||||||
) => {
|
) => {
|
||||||
const resolvedVariant = variant as BadgeVariant;
|
const resolvedVariant = variant as BadgeVariant;
|
||||||
|
const Comp = asChild ? Slot.Root : "span";
|
||||||
|
|
||||||
return (
|
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 && (
|
{dot && (
|
||||||
<span className={cn("mr-1.5 h-1.5 w-1.5 rounded-full", dotColorMap[resolvedVariant])} />
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
className={cn(
|
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"
|
||||||
"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"
|
aria-label="Remove"
|
||||||
>
|
>
|
||||||
<svg className="h-2 w-2" fill="currentColor" viewBox="0 0 8 8">
|
<svg className="h-2 w-2" fill="currentColor" viewBox="0 0 8 8">
|
||||||
@ -105,7 +121,7 @@ const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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 type { Meta, StoryObj } from "@storybook/react";
|
||||||
import { Button } from "./button";
|
import { Button } from "./button";
|
||||||
|
import { Button as LegacyButton } from "./button.legacy";
|
||||||
import { ArrowRightIcon, PlusIcon } from "@heroicons/react/24/outline";
|
import { ArrowRightIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
const meta: Meta<typeof Button> = {
|
const meta: Meta<typeof Button> = {
|
||||||
@ -20,7 +21,7 @@ const meta: Meta<typeof Button> = {
|
|||||||
"pillOutline",
|
"pillOutline",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
size: { control: "select", options: ["default", "sm", "lg"] },
|
size: { control: "select", options: ["default", "xs", "sm", "lg", "icon"] },
|
||||||
loading: { control: "boolean" },
|
loading: { control: "boolean" },
|
||||||
disabled: { control: "boolean" },
|
disabled: { control: "boolean" },
|
||||||
},
|
},
|
||||||
@ -52,6 +53,7 @@ export const AllVariants: Story = {
|
|||||||
export const Sizes: Story = {
|
export const Sizes: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center">
|
||||||
|
<Button size="xs">Extra Small</Button>
|
||||||
<Button size="sm">Small</Button>
|
<Button size="sm">Small</Button>
|
||||||
<Button size="default">Default</Button>
|
<Button size="default">Default</Button>
|
||||||
<Button size="lg">Large</Button>
|
<Button size="lg">Large</Button>
|
||||||
@ -81,3 +83,90 @@ export const Loading: Story = {
|
|||||||
export const Disabled: Story = {
|
export const Disabled: Story = {
|
||||||
args: { children: "Disabled", disabled: true },
|
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 type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
import { forwardRef } from "react";
|
import { forwardRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@ -5,30 +13,35 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/shared/utils";
|
import { cn } from "@/shared/utils";
|
||||||
import { Spinner } from "./spinner";
|
import { Spinner } from "./spinner";
|
||||||
|
|
||||||
|
/* ── variant tokens ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
"bg-primary text-primary-foreground hover:bg-primary-hover shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]",
|
|
||||||
destructive:
|
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:
|
outline:
|
||||||
"border border-border bg-background text-foreground hover:bg-muted shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]",
|
"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:
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)]",
|
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
ghost: "text-foreground hover:bg-muted",
|
|
||||||
subtle:
|
subtle:
|
||||||
"bg-muted/50 text-foreground hover:bg-muted border border-transparent hover:border-border/40",
|
"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",
|
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",
|
pillOutline: "rounded-full border border-border bg-card text-primary hover:bg-primary/5",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-11 py-2.5 px-4",
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
sm: "h-9 px-3 text-xs",
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
lg: "h-12 px-6 text-base",
|
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: {
|
defaultVariants: {
|
||||||
@ -38,6 +51,8 @@ const buttonVariants = cva(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ── content helper (loading / icons) ───────────────────────────── */
|
||||||
|
|
||||||
interface ButtonContentProps {
|
interface ButtonContentProps {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
leftIcon?: ReactNode;
|
leftIcon?: ReactNode;
|
||||||
@ -66,6 +81,8 @@ function ButtonContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── polymorphic props ──────────────────────────────────────────── */
|
||||||
|
|
||||||
interface ButtonExtras {
|
interface ButtonExtras {
|
||||||
leftIcon?: ReactNode;
|
leftIcon?: ReactNode;
|
||||||
rightIcon?: ReactNode;
|
rightIcon?: ReactNode;
|
||||||
@ -89,6 +106,8 @@ type ButtonAsButtonProps = {
|
|||||||
|
|
||||||
export type ButtonProps = ButtonAsAnchorProps | ButtonAsButtonProps;
|
export type ButtonProps = ButtonAsAnchorProps | ButtonAsButtonProps;
|
||||||
|
|
||||||
|
/* ── component ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((props, ref) => {
|
const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((props, ref) => {
|
||||||
const {
|
const {
|
||||||
leftIcon,
|
leftIcon,
|
||||||
@ -145,6 +164,9 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
ref={ref as React.Ref<HTMLButtonElement>}
|
ref={ref as React.Ref<HTMLButtonElement>}
|
||||||
disabled={loading || disabled}
|
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
|
* Checkbox — now powered by shadcn/ui (Radix Checkbox primitive)
|
||||||
* Basic checkbox input with label support
|
*
|
||||||
|
* 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";
|
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;
|
label?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
helperText?: 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) => {
|
({ 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 (
|
return (
|
||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<CheckboxPrimitive.Root
|
||||||
type="checkbox"
|
|
||||||
id={checkboxId}
|
id={checkboxId}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="checkbox"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 rounded border-input text-primary focus:ring-ring focus:ring-2",
|
"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-danger",
|
error && "border-destructive",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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 && (
|
||||||
<label
|
<label
|
||||||
htmlFor={checkboxId}
|
htmlFor={checkboxId}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
"text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
error && "text-danger"
|
error && "text-destructive"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@ -43,10 +59,12 @@ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{helperText && !error && <p className="text-xs text-muted-foreground">{helperText}</p>}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
Checkbox.displayName = "Checkbox";
|
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 { forwardRef } from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cn } from "@/shared/utils";
|
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", {
|
const errorMessageVariants = cva("flex items-center gap-1 text-sm", {
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "text-red-600",
|
default: "text-destructive",
|
||||||
inline: "text-red-600",
|
inline: "text-destructive",
|
||||||
subtle: "text-red-500",
|
subtle: "text-destructive/80",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@ -28,6 +35,7 @@ const ErrorMessage = forwardRef<HTMLParagraphElement, ErrorMessageProps>(
|
|||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
data-slot="error-message"
|
||||||
className={cn(errorMessageVariants({ variant, className }))}
|
className={cn(errorMessageVariants({ variant, className }))}
|
||||||
role="alert"
|
role="alert"
|
||||||
{...props}
|
{...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 { ExclamationTriangleIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||||
import { Button } from "./button";
|
import { Button } from "./button";
|
||||||
import { cn } from "@/shared/utils";
|
import { cn } from "@/shared/utils";
|
||||||
@ -11,6 +18,30 @@ interface ErrorStateProps {
|
|||||||
variant?: "page" | "card" | "inline" | undefined;
|
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({
|
export function ErrorState({
|
||||||
title = "Something went wrong",
|
title = "Something went wrong",
|
||||||
message = "An unexpected error occurred. Please try again.",
|
message = "An unexpected error occurred. Please try again.",
|
||||||
@ -19,53 +50,24 @@ export function ErrorState({
|
|||||||
className,
|
className,
|
||||||
variant = "card",
|
variant = "card",
|
||||||
}: ErrorStateProps) {
|
}: 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 (
|
return (
|
||||||
<div className={cn(baseClasses, variantClasses[variant], className)} suppressHydrationWarning>
|
<div
|
||||||
<div
|
data-slot="error-state"
|
||||||
className={cn("text-danger mb-4", variant === "inline" && "flex-shrink-0")}
|
role="alert"
|
||||||
suppressHydrationWarning
|
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]} />
|
<ExclamationTriangleIcon className={iconSizes[variant]} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={variant === "inline" ? "ml-3 flex-1" : ""} suppressHydrationWarning>
|
<div className={variant === "inline" ? "ml-3 flex-1" : ""}>
|
||||||
<h3
|
<h3 className={cn("font-semibold text-foreground mb-2", titleSizes[variant])}>{title}</h3>
|
||||||
className={cn("font-semibold text-foreground mb-2", titleSizes[variant])}
|
|
||||||
suppressHydrationWarning
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p
|
<p className={cn("text-muted-foreground mb-4 max-w-md", messageSizes[variant])}>
|
||||||
className={cn("text-muted-foreground mb-4 max-w-md", messageSizes[variant])}
|
|
||||||
suppressHydrationWarning
|
|
||||||
>
|
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -74,7 +76,7 @@ export function ErrorState({
|
|||||||
onClick={onRetry}
|
onClick={onRetry}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size={variant === "inline" ? "sm" : "default"}
|
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" />
|
<ArrowPathIcon className="h-4 w-4 mr-2" />
|
||||||
{retryLabel}
|
{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";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
@ -12,14 +19,14 @@ interface InlineToastProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InlineToast({ visible, text, tone = "info", className = "" }: InlineToastProps) {
|
const toneClasses: Record<Tone, string> = {
|
||||||
const toneClasses = {
|
success: "border-success/30 bg-success-soft text-success",
|
||||||
success: "bg-success-bg border-success-border text-success",
|
warning: "border-warning/30 bg-warning-soft text-foreground",
|
||||||
warning: "bg-warning-bg border-warning-border text-warning",
|
error: "border-destructive/30 bg-destructive/10 text-destructive",
|
||||||
error: "bg-danger-bg border-danger-border text-danger",
|
info: "border-info/30 bg-info-soft text-info",
|
||||||
info: "bg-info-bg border-info-border text-info",
|
};
|
||||||
}[tone];
|
|
||||||
|
|
||||||
|
export function InlineToast({ visible, text, tone = "info", className = "" }: InlineToastProps) {
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{visible && (
|
{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] }}
|
transition={{ duration: 0.3, ease: [0.175, 0.885, 0.32, 1.275] }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="inline-toast"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-lg border px-4 py-3 shadow-lg min-w-[240px] text-sm font-medium",
|
"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>
|
<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 type { InputHTMLAttributes, ReactNode } from "react";
|
||||||
import { forwardRef } from "react";
|
import { forwardRef } from "react";
|
||||||
import { cn } from "@/shared/utils";
|
import { cn } from "@/shared/utils";
|
||||||
@ -13,16 +19,12 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
className={cn(
|
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",
|
"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",
|
||||||
"cp-input-focus",
|
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||||
"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 &&
|
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
|
className
|
||||||
)}
|
)}
|
||||||
aria-invalid={isInvalid || undefined}
|
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";
|
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>(
|
||||||
return (
|
({ className, ...props }, ref) => {
|
||||||
<label
|
return (
|
||||||
ref={ref}
|
<LabelPrimitive.Root
|
||||||
className={cn(
|
ref={ref}
|
||||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
data-slot="label"
|
||||||
className
|
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",
|
||||||
{...props}
|
className
|
||||||
/>
|
)}
|
||||||
);
|
{...props}
|
||||||
});
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
Label.displayName = "Label";
|
Label.displayName = "Label";
|
||||||
|
|
||||||
export { 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";
|
import { cn } from "@/shared/utils";
|
||||||
|
|
||||||
interface SkeletonProps {
|
interface SkeletonProps extends React.ComponentProps<"div"> {
|
||||||
className?: string;
|
|
||||||
animate?: boolean;
|
animate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function Skeleton({ className, animate = true, ...props }: SkeletonProps) {
|
||||||
* 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 (
|
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";
|
"use client";
|
||||||
|
|
||||||
import { Squares2X2Icon, ListBulletIcon } from "@heroicons/react/24/outline";
|
import { Squares2X2Icon, ListBulletIcon } from "@heroicons/react/24/outline";
|
||||||
import { cn } from "@/shared/utils";
|
import { cn } from "@/shared/utils";
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
|
||||||
export type ViewMode = "grid" | "list";
|
export type ViewMode = "grid" | "list";
|
||||||
|
|
||||||
@ -13,40 +21,23 @@ interface ViewToggleProps {
|
|||||||
|
|
||||||
export function ViewToggle({ value, onChange, className }: ViewToggleProps) {
|
export function ViewToggle({ value, onChange, className }: ViewToggleProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<ToggleGroup
|
||||||
className={cn(
|
type="single"
|
||||||
"inline-flex items-center rounded-lg border border-border/60 bg-muted/30 p-0.5",
|
value={value}
|
||||||
className
|
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)}
|
||||||
>
|
>
|
||||||
<button
|
<ToggleGroupItem value="grid" aria-label="Grid view" className="h-7 w-7 px-0">
|
||||||
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" />
|
<Squares2X2Icon className="h-3.5 w-3.5" />
|
||||||
</button>
|
</ToggleGroupItem>
|
||||||
<button
|
<ToggleGroupItem value="list" aria-label="List view" className="h-7 w-7 px-0">
|
||||||
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" />
|
<ListBulletIcon className="h-3.5 w-3.5" />
|
||||||
</button>
|
</ToggleGroupItem>
|
||||||
</div>
|
</ToggleGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Component library exports
|
* Component library exports
|
||||||
* Centralized exports for all UI components following atomic design principles
|
* 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";
|
export * from "./atoms";
|
||||||
|
|
||||||
// Molecules - Combinations of 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