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

156 lines
4.7 KiB
TypeScript

// components/ui/Checkbox.tsx
'use client';
import * as React from 'react';
import clsx from 'clsx';
export type CheckboxProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'type'
> & {
/**
* Label neben der Checkbox
*/
label?: React.ReactNode;
/**
* Beschreibung unter/neben dem Label
*/
description?: React.ReactNode;
/**
* Visueller indeterminate-Zustand (Strich statt Haken)
*/
indeterminate?: boolean;
/**
* Zusätzliche Klassen für das Wrapper-Element (Label+Beschreibung+Checkbox)
*/
wrapperClassName?: string;
/**
* Zusätzliche Klassen für das Label
*/
labelClassName?: string;
/**
* Zusätzliche Klassen für die Beschreibung
*/
descriptionClassName?: string;
};
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
function Checkbox(
{
label,
description,
className,
wrapperClassName,
labelClassName,
descriptionClassName,
indeterminate,
id,
...inputProps
},
ref,
) {
const innerRef = React.useRef<HTMLInputElement | null>(null);
// externe + interne Ref zusammenführen
React.useImperativeHandle(ref, () => innerRef.current as HTMLInputElement);
React.useEffect(() => {
if (innerRef.current) {
innerRef.current.indeterminate = Boolean(indeterminate);
}
}, [indeterminate]);
// Fallback-ID, falls keine übergeben wurde
const inputId =
id ??
(typeof label === 'string'
? label.toLowerCase().replace(/\s+/g, '-')
: undefined);
const descriptionId =
description && inputId ? `${inputId}-description` : undefined;
return (
<div className={clsx('flex gap-3', wrapperClassName)}>
{/* Checkbox-Icon */}
<div className="flex h-6 shrink-0 items-center">
<div className="group grid size-4 grid-cols-1">
<input
id={inputId}
ref={innerRef}
type="checkbox"
aria-describedby={descriptionId}
className={clsx(
'col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white',
'checked:border-indigo-600 checked:bg-indigo-600',
'indeterminate:border-indigo-600 indeterminate: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:checked:bg-gray-100',
'dark:border-white/10 dark:bg-white/5',
'dark:checked:border-indigo-500 dark:checked:bg-indigo-500',
'dark:indeterminate:border-indigo-500 dark:indeterminate:bg-indigo-500',
'dark:focus-visible:outline-indigo-500',
'dark:disabled:border-white/5 dark:disabled:bg-white/10 dark:disabled:checked:bg-white/10',
'forced-colors:appearance-auto',
className,
)}
{...inputProps}
/>
<svg
fill="none"
viewBox="0 0 14 14"
aria-hidden="true"
className="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-gray-950/25 dark:group-has-disabled:stroke-white/25"
>
{/* Haken */}
<path
d="M3 8L6 11L11 3.5"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-0 group-has-checked:opacity-100"
/>
{/* Indeterminate-Strich */}
<path
d="M3 7H11"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-0 group-has-indeterminate:opacity-100"
/>
</svg>
</div>
</div>
{/* Label + Beschreibung (optional) */}
{(label || description) && (
<div className="text-sm/6">
{label && (
<label
htmlFor={inputId}
className={clsx(
'font-medium text-gray-900 dark:text-white',
labelClassName,
)}
>
{label}
</label>
)}
{description && (
<p
id={descriptionId}
className={clsx(
'text-gray-500 dark:text-gray-400',
descriptionClassName,
)}
>
{description}
</p>
)}
</div>
)}
</div>
);
},
);