185 lines
6.5 KiB
TypeScript
185 lines
6.5 KiB
TypeScript
// /components/ui/Button.tsx
|
||
import * as React from 'react';
|
||
import clsx from 'clsx';
|
||
|
||
export type ButtonVariant = 'primary' | 'secondary' | 'soft';
|
||
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||
export type ButtonShape = 'default' | 'pill' | 'circle';
|
||
export type ButtonTone = 'indigo' | 'gray' | 'rose' | 'emerald';
|
||
|
||
export interface ButtonProps
|
||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||
variant?: ButtonVariant;
|
||
size?: ButtonSize;
|
||
shape?: ButtonShape; // "default" (normal), "pill" (rounded-full), "circle" (nur Icon)
|
||
icon?: React.ReactNode;
|
||
iconPosition?: 'leading' | 'trailing';
|
||
fullWidth?: boolean;
|
||
tone?: ButtonTone; // NEU: Farbton
|
||
}
|
||
|
||
const baseClasses =
|
||
'inline-flex items-center justify-center font-semibold shadow-xs ' +
|
||
'focus-visible:outline-2 focus-visible:outline-offset-2 ' +
|
||
'disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-150';
|
||
|
||
// Farb-Kombinationen pro Variant + Tone
|
||
const variantToneClasses: Record<ButtonVariant, Record<ButtonTone, string>> = {
|
||
primary: {
|
||
indigo:
|
||
'bg-indigo-600 text-white hover:bg-indigo-500 ' +
|
||
'focus-visible:outline-indigo-600 ' +
|
||
'dark:bg-indigo-500 dark:text-white dark:hover:bg-indigo-400 dark:shadow-none dark:focus-visible:outline-indigo-500',
|
||
gray:
|
||
'bg-gray-900 text-white hover:bg-gray-800 ' +
|
||
'focus-visible:outline-gray-900 ' +
|
||
'dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 dark:shadow-none dark:focus-visible:outline-gray-700',
|
||
rose:
|
||
'bg-rose-600 text-white hover:bg-rose-500 ' +
|
||
'focus-visible:outline-rose-600 ' +
|
||
'dark:bg-rose-500 dark:text-white dark:hover:bg-rose-400 dark:shadow-none dark:focus-visible:outline-rose-500',
|
||
emerald:
|
||
'bg-emerald-600 text-white hover:bg-emerald-500 ' +
|
||
'focus-visible:outline-emerald-600 ' +
|
||
'dark:bg-emerald-500 dark:text-white dark:hover:bg-emerald-400 dark:shadow-none dark:focus-visible:outline-emerald-500',
|
||
},
|
||
secondary: {
|
||
indigo:
|
||
'bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
|
||
'focus-visible:outline-indigo-600 ' +
|
||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20 dark:focus-visible:outline-indigo-500',
|
||
gray:
|
||
'bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
|
||
'focus-visible:outline-gray-900 ' +
|
||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20 dark:focus-visible:outline-gray-700',
|
||
rose:
|
||
'bg-white text-rose-600 inset-ring inset-ring-rose-200 hover:bg-rose-50 ' +
|
||
'focus-visible:outline-rose-600 ' +
|
||
'dark:bg-white/10 dark:text-rose-300 dark:shadow-none dark:inset-ring-rose-500/40 dark:hover:bg-rose-500/10 dark:focus-visible:outline-rose-500',
|
||
emerald:
|
||
'bg-white text-emerald-600 inset-ring inset-ring-emerald-200 hover:bg-emerald-50 ' +
|
||
'focus-visible:outline-emerald-600 ' +
|
||
'dark:bg-white/10 dark:text-emerald-300 dark:shadow-none dark:inset-ring-emerald-500/40 dark:hover:bg-emerald-500/10 dark:focus-visible:outline-emerald-500',
|
||
},
|
||
soft: {
|
||
indigo:
|
||
'bg-indigo-50 text-indigo-600 hover:bg-indigo-100 ' +
|
||
'focus-visible:outline-indigo-600 ' +
|
||
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:hover:bg-indigo-500/30 dark:focus-visible:outline-indigo-500',
|
||
gray:
|
||
'bg-gray-100 text-gray-800 hover:bg-gray-200 ' +
|
||
'focus-visible:outline-gray-900 ' +
|
||
'dark:bg-gray-700/40 dark:text-gray-100 dark:shadow-none dark:hover:bg-gray-700/60 dark:focus-visible:outline-gray-600',
|
||
rose:
|
||
'bg-rose-50 text-rose-600 hover:bg-rose-100 ' +
|
||
'focus-visible:outline-rose-600 ' +
|
||
'dark:bg-rose-500/20 dark:text-rose-300 dark:shadow-none dark:hover:bg-rose-500/30 dark:focus-visible:outline-rose-500',
|
||
emerald:
|
||
'bg-emerald-50 text-emerald-600 hover:bg-emerald-100 ' +
|
||
'focus-visible:outline-emerald-600 ' +
|
||
'dark:bg-emerald-500/20 dark:text-emerald-300 dark:shadow-none dark:hover:bg-emerald-500/30 dark:focus-visible:outline-emerald-500',
|
||
},
|
||
};
|
||
|
||
// Größen wie in deinen Snippets (rectangular)
|
||
const sizeClasses: Record<ButtonSize, string> = {
|
||
xs: 'rounded-sm px-2 py-1 text-xs',
|
||
sm: 'rounded-sm px-2 py-1 text-sm',
|
||
md: 'rounded-md px-2.5 py-1.5 text-sm',
|
||
lg: 'rounded-md px-3 py-2 text-sm',
|
||
xl: 'rounded-md px-3.5 py-2.5 text-sm',
|
||
};
|
||
|
||
// Pill / "rounded primary/secondary buttons" Größen
|
||
const pillSizeClasses: Record<ButtonSize, string> = {
|
||
xs: 'rounded-full px-2.5 py-1 text-xs',
|
||
sm: 'rounded-full px-2.5 py-1 text-sm',
|
||
md: 'rounded-full px-3 py-1.5 text-sm',
|
||
lg: 'rounded-full px-3.5 py-2 text-sm',
|
||
xl: 'rounded-full px-4 py-2.5 text-sm',
|
||
};
|
||
|
||
// Circular Buttons – nur Icon
|
||
const circleSizeClasses: Record<ButtonSize, string> = {
|
||
xs: 'rounded-full p-1',
|
||
sm: 'rounded-full p-1.5',
|
||
md: 'rounded-full p-2',
|
||
lg: 'rounded-full p-2',
|
||
xl: 'rounded-full p-2.5',
|
||
};
|
||
|
||
function getSizeClasses(size: ButtonSize, shape: ButtonShape): string {
|
||
switch (shape) {
|
||
case 'pill':
|
||
return pillSizeClasses[size];
|
||
case 'circle':
|
||
return circleSizeClasses[size];
|
||
default:
|
||
return sizeClasses[size];
|
||
}
|
||
}
|
||
|
||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||
(
|
||
{
|
||
variant = 'primary',
|
||
tone = 'indigo', // NEU: Default
|
||
size = 'md',
|
||
shape = 'default',
|
||
icon,
|
||
iconPosition = 'leading',
|
||
fullWidth,
|
||
className,
|
||
children,
|
||
type = 'button',
|
||
...props
|
||
},
|
||
ref
|
||
) => {
|
||
const hasIcon = !!icon;
|
||
const hasLabel = React.Children.count(children) > 0;
|
||
|
||
const gapClasses =
|
||
hasIcon && hasLabel
|
||
? size === 'xl'
|
||
? 'gap-x-2'
|
||
: 'gap-x-1.5'
|
||
: '';
|
||
|
||
return (
|
||
<button
|
||
ref={ref}
|
||
type={type}
|
||
className={clsx(
|
||
baseClasses,
|
||
variantToneClasses[variant][tone],
|
||
getSizeClasses(size, shape),
|
||
gapClasses,
|
||
fullWidth && 'w-full',
|
||
className
|
||
)}
|
||
{...props}
|
||
>
|
||
{hasIcon && iconPosition === 'leading' && (
|
||
<span aria-hidden="true">{icon}</span>
|
||
)}
|
||
|
||
{hasLabel ? (
|
||
children
|
||
) : hasIcon ? (
|
||
// Für reine Icon-Buttons: Text nur für Screenreader, per aria-label steuerbar
|
||
<span className="sr-only">{props['aria-label'] ?? 'Button'}</span>
|
||
) : null}
|
||
|
||
{hasIcon && iconPosition === 'trailing' && (
|
||
<span aria-hidden="true">{icon}</span>
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
);
|
||
|
||
Button.displayName = 'Button';
|
||
|
||
export default Button;
|