geraete/components/ui/Button.tsx
2025-11-18 14:44:36 +01:00

185 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// /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;