DocDoeAI / src /components /shared /SharedButton.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
11 kB
"use client";
// @ts-nocheck - pre-existing legacy design token (bare 'colors'/'spacing' etc) issues in shared components.
import Link from "next/link";
import { forwardRef, ButtonHTMLAttributes, AnchorHTMLAttributes } from "react";
import { clsx } from "clsx";
import { designSystem, colors, typography, spacing } from "@/lib/design-system";
import { Loader2 } from "lucide-react";
const { semanticColors } = designSystem; // colors/radius etc reserved for variants
// =============================================================================
// Button Types and Interfaces
// =============================================================================
export type ButtonVariant =
| "primary"
| "secondary"
| "ghost"
| "outline"
| "danger"
| "success"
| "emerald"
| "violet"
| "amber"
| "blue";
export type ButtonSize = "sm" | "md" | "lg" | "xl" | "icon";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
fullWidth?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
asLink?: boolean;
href?: string;
}
// =============================================================================
// Style Mappings
// =============================================================================
const variantStyles = {
primary: {
bg: colors.accentPrimary,
text: colors.white,
hoverBg: colors.accentPrimaryHover,
border: "none",
activeBg: colors.accentPrimary,
},
secondary: {
bg: colors.bgSurface,
text: colors.textPrimary,
hoverBg: colors.bgElevated,
border: colors.borderMedium,
hoverBorder: colors.borderMedium,
activeBg: colors.bgElevated,
},
ghost: {
bg: "transparent",
text: colors.textSecondary,
hoverBg: colors.bgGlass,
border: "none",
hoverBorder: "none",
hoverText: colors.textPrimary,
activeBg: colors.bgGlass,
},
outline: {
bg: "transparent",
text: colors.textPrimary,
hoverBg: colors.bgGlass,
border: colors.borderSubtle,
hoverBorder: colors.borderMedium,
activeBg: colors.bgGlass,
},
danger: {
bg: colors.accentRed,
text: colors.white,
hoverBg: "#F87171", // lighter red
border: "none",
activeBg: colors.accentRed,
},
success: {
bg: colors.accentEmerald,
text: colors.white,
hoverBg: "#10B981", // same or lighter
border: "none",
activeBg: colors.accentEmerald,
},
// Accent variants (use design system tokens)
emerald: {
bg: colors.accentEmerald,
text: colors.white,
hoverBg: "#34D399", // lighter emerald
border: "none",
activeBg: colors.accentEmerald,
},
violet: {
bg: semanticColors.info,
text: colors.white,
hoverBg: colors.accentViolet,
border: "none",
activeBg: semanticColors.info,
},
amber: {
bg: colors.accentPrimary,
text: colors.white,
hoverBg: colors.accentPrimaryHover,
border: "none",
activeBg: colors.accentPrimary,
},
blue: {
bg: colors.accentBlue,
text: colors.white,
hoverBg: "#93C5FD", // lighter blue
border: "none",
activeBg: colors.accentBlue,
},
} as const;
const sizeStyles = {
sm: {
height: "32px",
padding: "0 12px",
fontSize: typography.captionSmall.size,
fontWeight: typography.captionSmall.weight,
gap: "4px",
width: undefined,
},
md: {
height: "40px",
padding: "0 16px",
fontSize: typography.button.size,
fontWeight: typography.button.weight,
gap: spacing.button.gap,
width: undefined,
},
lg: {
height: "44px",
padding: "0 24px",
fontSize: typography.buttonPrimary.size,
fontWeight: typography.buttonPrimary.weight,
gap: spacing.button.gap,
width: undefined,
},
xl: {
height: "48px",
padding: "0 32px",
fontSize: "15px",
fontWeight: "600" as const,
gap: "10px",
width: undefined,
},
icon: {
height: "40px",
width: "40px",
padding: "0",
fontSize: "20px",
gap: "0",
},
} as const;
// Helper to extract Link-compatible props safely
function extractLinkProps(props: ButtonProps): Partial<AnchorHTMLAttributes<HTMLAnchorElement>> {
const {
type: _type,
form: _form,
formAction: _formAction,
formEncType: _formEncType,
formMethod: _formMethod,
formNoValidate: _formNoValidate,
formTarget: _formTarget,
name: _name,
value: _value,
autoFocus: _autoFocus,
tabIndex: _tabIndex,
...safeProps
} = props;
return safeProps as Partial<AnchorHTMLAttributes<HTMLAnchorElement>>;
}
// =============================================================================
// SharedButton Component
// =============================================================================
export const SharedButton = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(
(
{
variant = "primary",
size = "md",
loading = false,
fullWidth = false,
leftIcon,
rightIcon,
asLink = false,
href,
disabled,
className,
children,
...props
},
ref,
) => {
const styles = variantStyles[variant];
const sizes = sizeStyles[size];
const baseClasses = clsx(
"relative z-10 pointer-events-auto",
"inline-flex items-center justify-center",
"border",
"font-sans",
"transition-all",
"duration-150",
"focus:outline-none",
"focus-visible:ring-2",
"focus-visible:ring-[var(--accent-primary)]",
"focus-visible:ring-offset-2",
"focus-visible:ring-offset-[var(--bg-root)]",
fullWidth && "w-full",
className,
);
const sizeClasses = clsx(
`h-[${sizes.height}]`,
sizes.width && `w-[${sizes.width}]`,
sizes.padding && `px-[${sizes.padding.split(" ")[0].replace("px", "")}px]`,
sizes.fontSize && `text-[${sizes.fontSize}]`,
"fontWeight" in sizes && (sizes as { fontWeight?: string }).fontWeight && `font-[${(sizes as { fontWeight?: string }).fontWeight}]`,
sizes.gap && `gap-[${sizes.gap}]`,
);
const variantClasses = clsx(
!styles.bg || styles.bg === "transparent" ? "" : `bg-[${styles.bg}]`,
styles.text && `text-[${styles.text}]`,
!styles.border || styles.border === "none" ? "border-transparent" : `border-[${styles.border}]`,
// Hover states
styles.hoverBg && `hover:bg-[${styles.hoverBg}]`,
("hoverBorder" in styles && (styles as { hoverBorder?: string }).hoverBorder) && `hover:border-[${(styles as { hoverBorder?: string }).hoverBorder}]`,
("hoverText" in styles && (styles as { hoverText?: string }).hoverText) && `hover:text-[${(styles as { hoverText?: string }).hoverText}]`,
// Active states
styles.activeBg && `active:bg-[${styles.activeBg}]`,
// Disabled states
"disabled:cursor-not-allowed",
"disabled:opacity-40",
"disabled:transform-none",
);
const allClasses = clsx(baseClasses, sizeClasses, variantClasses);
// Loading state
if (loading) {
return (
<button
ref={ref as React.RefObject<HTMLButtonElement>}
type="button"
disabled={true}
aria-busy="true"
className={clsx(allClasses, "cursor-not-allowed")}
{...props}
>
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
<span className="sr-only">Loading...</span>
</button>
);
}
// Render as Link if asLink or href is provided
if (asLink || href) {
const linkProps = asLink ? { href: href || "#" } : {};
const finalHref = href || "#";
// Filter out button-specific props that Link doesn't accept
const { type: _type, onClick: buttonOnClick, ...restProps } = props;
// Extract only Link-compatible props
const linkCompatibleProps = extractLinkProps({ ...restProps } as ButtonProps);
// Safe onClick handler with proper typing
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (disabled) {
e.preventDefault();
return;
}
buttonOnClick?.(e as unknown as React.MouseEvent<HTMLButtonElement>);
};
return (
<Link
ref={ref as React.RefObject<HTMLAnchorElement>}
href={finalHref}
className={clsx(allClasses, "cursor-pointer", disabled && "pointer-events-none opacity-40")}
{...linkCompatibleProps}
{...linkProps}
onClick={handleClick}
>
{leftIcon && <span aria-hidden="true">{leftIcon}</span>}
{children}
{rightIcon && <span aria-hidden="true">{rightIcon}</span>}
</Link>
);
}
// Render as button
return (
<button
ref={ref as React.RefObject<HTMLButtonElement>}
type="button"
disabled={disabled}
className={clsx(allClasses, !disabled && "cursor-pointer")}
{...props}
>
{leftIcon && <span aria-hidden="true">{leftIcon}</span>}
{children}
{rightIcon && <span aria-hidden="true">{rightIcon}</span>}
</button>
);
},
);
SharedButton.displayName = "SharedButton";
// =============================================================================
// Convenience Button Components (matching existing patterns)
// =============================================================================
// Button with consistent primary style
export function BtnPrimary({
children,
onClick,
loading = false,
className,
...props
}: ButtonProps) {
return (
<SharedButton
variant="primary"
onClick={onClick}
loading={loading}
className={clsx("rounded-lg", className)}
{...props}
>
{children}
</SharedButton>
);
}
// Button with secondary style
export function BtnSecondary({
children,
onClick,
loading = false,
className,
...props
}: ButtonProps) {
return (
<SharedButton
variant="secondary"
onClick={onClick}
loading={loading}
className={clsx("rounded-lg", className)}
{...props}
>
{children}
</SharedButton>
);
}
// Ghost button
export function BtnGhost({
children,
onClick,
className,
...props
}: ButtonProps) {
return (
<SharedButton
variant="ghost"
onClick={onClick}
className={clsx("rounded-lg", className)}
{...props}
>
{children}
</SharedButton>
);
}
// Action button (for teach page pattern)
export function ActionButton({
children,
primary = false,
onClick,
className,
...props
}: ButtonProps & { primary?: boolean }) {
return (
<SharedButton
variant={primary ? "primary" : "secondary"}
size="sm"
onClick={onClick}
className={clsx(
"rounded-md",
primary ? "text-white" : "text-[var(--text-secondary)] hover:text-[var(--text-primary)]",
className,
)}
{...props}
>
{children}
</SharedButton>
);
}