308 lines
9.3 KiB
TypeScript
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>
|
|
);
|
|
}
|