geraete/components/ui/RadioGroup.tsx
2025-11-26 15:00:05 +01:00

308 lines
9.3 KiB
TypeScript

// components/ui/RadioGroup.tsx
'use client';
import * as React from 'react';
import clsx from 'clsx';
export type RadioGroupOption = {
value: string;
label: React.ReactNode;
description?: React.ReactNode;
disabled?: boolean;
className?: string;
};
export type RadioGroupVariant =
| 'simple' // Radio links, Label rechts
| 'withDescription' // Label + Description untereinander
| 'right' // Label links, Radio rechts
| 'panel'; // Segment-Panel (Pricing/Privacy-Style)
type RadioGroupProps = {
name?: string;
legend?: React.ReactNode;
helpText?: React.ReactNode;
options: RadioGroupOption[];
/** Kontrollierter Wert (oder null für nichts gewählt) */
value: string | null;
onChange: (value: string) => void;
orientation?: 'vertical' | 'horizontal'; // nur relevant für simple
variant?: RadioGroupVariant;
className?: string;
optionClassName?: string;
idPrefix?: string;
};
const baseRadioClasses =
'relative size-4 appearance-none rounded-full border border-gray-300 bg-white ' +
'before:absolute before:inset-1 before:rounded-full before:bg-white ' +
'not-checked:before:hidden checked:border-indigo-600 checked:bg-indigo-600 ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 ' +
'disabled:border-gray-300 disabled:bg-gray-100 disabled:before:bg-gray-400 ' +
'dark:border-white/10 dark:bg-white/5 dark:checked:border-indigo-500 dark:checked:bg-indigo-500 ' +
'dark:focus-visible:outline-indigo-500 dark:disabled:border-white/5 dark:disabled:bg-white/10 ' +
'dark:disabled:before:bg-white/20 forced-colors:appearance-auto forced-colors:before:hidden';
export function RadioGroup({
name,
legend,
helpText,
options,
value,
onChange,
orientation = 'vertical',
variant = 'simple',
className,
optionClassName,
idPrefix = 'rg',
}: RadioGroupProps) {
const internalName = React.useId();
const groupName = name ?? internalName;
const isHorizontal = orientation === 'horizontal';
const handleChange = (nextValue: string) => {
if (nextValue !== value) {
onChange(nextValue);
}
};
const renderSimple = () => (
<div
className={clsx(
'mt-3',
isHorizontal
? 'space-y-3 sm:flex sm:items-center sm:space-y-0 sm:space-x-8'
: 'space-y-3',
)}
>
{options.map((opt) => {
const id = `${idPrefix}-${groupName}-${opt.value}`;
return (
<div
key={opt.value}
className={clsx('flex items-center', optionClassName, opt.className)}
>
<input
id={id}
name={groupName}
type="radio"
value={opt.value}
checked={value === opt.value}
disabled={opt.disabled}
onChange={() => handleChange(opt.value)}
className={baseRadioClasses}
/>
<label
htmlFor={id}
className={clsx(
'ml-3 block text-sm/6 font-medium',
opt.disabled
? 'text-gray-400 dark:text-gray-500'
: 'text-gray-900 dark:text-white',
)}
>
{opt.label}
</label>
</div>
);
})}
</div>
);
const renderWithDescription = () => (
<div className="mt-3 space-y-5">
{options.map((opt) => {
const id = `${idPrefix}-${groupName}-${opt.value}`;
const descId = opt.description ? `${id}-description` : undefined;
return (
<div
key={opt.value}
className={clsx(
'relative flex items-start',
optionClassName,
opt.className,
)}
>
<div className="flex h-6 items-center">
<input
id={id}
name={groupName}
type="radio"
value={opt.value}
checked={value === opt.value}
disabled={opt.disabled}
aria-describedby={descId}
onChange={() => handleChange(opt.value)}
className={baseRadioClasses}
/>
</div>
<div className="ml-3 text-sm/6">
<label
htmlFor={id}
className={clsx(
'font-medium',
opt.disabled
? 'text-gray-400 dark:text-gray-500'
: 'text-gray-900 dark:text-white',
)}
>
{opt.label}
</label>
{opt.description && (
<p
id={descId}
className={clsx(
'text-gray-500 dark:text-gray-400',
opt.disabled && 'opacity-70',
)}
>
{opt.description}
</p>
)}
</div>
</div>
);
})}
</div>
);
const renderRight = () => (
<div className="mt-3 divide-y divide-gray-200 border-t border-b border-gray-200 dark:divide-white/10 dark:border-white/10">
{options.map((opt, idx) => {
const id = `${idPrefix}-${groupName}-${opt.value || idx}`;
const descId = opt.description ? `${id}-description` : undefined;
return (
<div
key={opt.value}
className={clsx(
'relative flex items-start py-4',
optionClassName,
opt.className,
)}
>
<div className="min-w-0 flex-1 text-sm/6">
<label
htmlFor={id}
className={clsx(
'font-medium select-none',
opt.disabled
? 'text-gray-400 dark:text-gray-500'
: 'text-gray-900 dark:text-white',
)}
>
{opt.label}
</label>
{opt.description && (
<p
id={descId}
className="text-gray-500 dark:text-gray-400"
>
{opt.description}
</p>
)}
</div>
<div className="ml-3 flex h-6 items-center">
<input
id={id}
name={groupName}
type="radio"
value={opt.value}
checked={value === opt.value}
disabled={opt.disabled}
aria-describedby={descId}
onChange={() => handleChange(opt.value)}
className={baseRadioClasses}
/>
</div>
</div>
);
})}
</div>
);
const renderPanel = () => (
<div className="-space-y-px rounded-md bg-white dark:bg-gray-800/50">
{options.map((opt, index) => {
const id = `${idPrefix}-${groupName}-${opt.value}`;
return (
<label
key={opt.value}
htmlFor={id}
className={clsx(
'group flex border border-gray-200 p-4 focus:outline-hidden',
'first:rounded-tl-md first:rounded-tr-md last:rounded-br-md last:rounded-bl-md',
'has-checked:relative has-checked:border-indigo-200 has-checked:bg-indigo-50',
'dark:border-gray-700 dark:has-checked:border-indigo-800 dark:has-checked:bg-indigo-500/10',
optionClassName,
opt.className,
)}
>
<input
id={id}
name={groupName}
type="radio"
value={opt.value}
checked={value === opt.value}
disabled={opt.disabled}
onChange={() => handleChange(opt.value)}
className={clsx(
baseRadioClasses,
'mt-0.5 shrink-0',
opt.disabled && 'opacity-70',
)}
/>
<span className="ml-3 flex flex-col text-sm">
<span
className={clsx(
'font-medium',
'text-gray-900 group-has-checked:text-indigo-900',
'dark:text-white dark:group-has-checked:text-indigo-300',
opt.disabled && 'opacity-70',
)}
>
{opt.label}
</span>
{opt.description && (
<span
className={clsx(
'text-gray-500 group-has-checked:text-indigo-700',
'dark:text-gray-400 dark:group-has-checked:text-indigo-300/75',
opt.disabled && 'opacity-70',
)}
>
{opt.description}
</span>
)}
</span>
</label>
);
})}
</div>
);
return (
<fieldset className={className}>
{legend && (
<legend className="text-sm/6 font-semibold text-gray-900 dark:text-white">
{legend}
</legend>
)}
{helpText && (
<p className="mt-1 text-sm/6 text-gray-600 dark:text-gray-400">
{helpText}
</p>
)}
{variant === 'withDescription' && renderWithDescription()}
{variant === 'right' && renderRight()}
{variant === 'panel' && renderPanel()}
{variant === 'simple' && renderSimple()}
</fieldset>
);
}