2026-04-27 14:40:16 +02:00

166 lines
4.8 KiB
TypeScript

// components/ui/Switch.tsx
'use client'
import type { ChangeEvent } from 'react'
import clsx from 'clsx'
type SwitchSize = 'default' | 'short'
type SwitchVariant = 'simple' | 'icon'
export type SwitchProps = {
checked: boolean
onChange: (checked: boolean) => void
id?: string
name?: string
disabled?: boolean
required?: boolean
ariaLabel?: string
ariaLabelledby?: string
ariaDescribedby?: string
size?: SwitchSize
variant?: SwitchVariant
compact?: boolean
className?: string
}
export default function Switch({
checked,
onChange,
id,
name,
disabled,
required,
ariaLabel,
ariaLabelledby,
ariaDescribedby,
size = 'default',
variant = 'simple',
compact = false,
className,
}: SwitchProps) {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
if (disabled) return
onChange(e.target.checked)
}
const baseInput = clsx(
'absolute inset-0 size-full appearance-none focus:outline-hidden',
disabled && 'cursor-not-allowed'
)
const rootBase =
'group relative inline-flex shrink-0 rounded-full bg-gray-200 p-0.5 inset-ring inset-ring-gray-900/5 outline-offset-2 outline-indigo-600 transition-colors duration-200 ease-in-out has-checked:bg-indigo-600 has-focus-visible:outline-2 dark:bg-white/5 dark:inset-ring-white/10 dark:outline-indigo-500 dark:has-checked:bg-indigo-500'
if (size === 'short') {
const trackClass = compact ? 'h-4 w-8' : 'h-5 w-10'
const thumbClass = compact
? 'size-3 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-4'
: 'size-4 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5'
return (
<div
className={clsx(
rootBase,
trackClass,
disabled && 'opacity-60',
className
)}
>
<span className={thumbClass} />
<input
id={id}
name={name}
type="checkbox"
checked={checked}
onChange={handleChange}
disabled={disabled}
required={required}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
className={baseInput}
/>
</div>
)
}
const trackClass = compact ? 'h-5 w-9' : 'h-6 w-11'
const thumbClass = compact
? 'size-4 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-4'
: 'size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5'
const iconSizeClass = compact ? 'size-2.5' : 'size-3'
return (
<div
className={clsx(
rootBase,
trackClass,
disabled && 'opacity-60',
className
)}
>
{variant === 'icon' ? (
<span className={clsx('relative', thumbClass)}>
<span
aria-hidden="true"
className={clsx(
'absolute inset-0 flex size-full items-center justify-center transition-opacity ease-in',
checked ? 'opacity-0 duration-100' : 'opacity-100 duration-200'
)}
>
<svg
fill="none"
viewBox="0 0 12 12"
className={clsx(iconSizeClass, 'text-gray-400 dark:text-gray-600')}
>
<path
d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<span
aria-hidden="true"
className={clsx(
'absolute inset-0 flex size-full items-center justify-center transition-opacity ease-out',
checked ? 'opacity-100 duration-200' : 'opacity-0 duration-100'
)}
>
<svg
fill="currentColor"
viewBox="0 0 12 12"
className={clsx(iconSizeClass, 'text-indigo-600 dark:text-indigo-500')}
>
<path d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z" />
</svg>
</span>
</span>
) : (
<span className={thumbClass} />
)}
<input
id={id}
name={name}
type="checkbox"
checked={checked}
onChange={handleChange}
disabled={disabled}
required={required}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
className={baseInput}
/>
</div>
)
}