109 lines
3.3 KiB
TypeScript
109 lines
3.3 KiB
TypeScript
import { JSX } from 'solid-js';
|
|
|
|
type ToggleSize = 'xs' | 'sm' | 'md';
|
|
|
|
export interface ToggleChangeEvent {
|
|
currentTarget: {
|
|
checked: boolean;
|
|
};
|
|
preventDefault: () => void;
|
|
stopPropagation: () => void;
|
|
readonly defaultPrevented: boolean;
|
|
}
|
|
|
|
interface BaseToggleProps {
|
|
checked: boolean;
|
|
disabled?: boolean;
|
|
onToggle?: () => void;
|
|
onChange?: (event: ToggleChangeEvent) => void;
|
|
size?: ToggleSize;
|
|
class?: string;
|
|
title?: string;
|
|
ariaLabel?: string;
|
|
}
|
|
|
|
const sizeConfig: Record<ToggleSize, { track: string; knob: string; translate: string }> = {
|
|
xs: { track: 'h-4 w-7', knob: 'h-3 w-3', translate: '12px' },
|
|
sm: { track: 'h-5 w-9', knob: 'h-4 w-4', translate: '16px' },
|
|
md: { track: 'h-6 w-11', knob: 'h-5 w-5', translate: '20px' },
|
|
};
|
|
|
|
export function TogglePrimitive(props: BaseToggleProps): JSX.Element {
|
|
const size = props.size ?? 'sm';
|
|
const config = sizeConfig[size];
|
|
const isDisabled = () => Boolean(props.disabled);
|
|
const checkedClass = 'bg-blue-500 dark:bg-blue-500';
|
|
const uncheckedClass = 'bg-gray-300 dark:bg-gray-600';
|
|
const disabledClass = 'bg-gray-200 dark:bg-gray-700 cursor-not-allowed opacity-60';
|
|
const knobBase = 'bg-white shadow-md';
|
|
|
|
const handleClick = () => {
|
|
if (isDisabled()) return;
|
|
const next = !props.checked;
|
|
let defaultPrevented = false;
|
|
|
|
const event: ToggleChangeEvent = {
|
|
currentTarget: { checked: next },
|
|
preventDefault() {
|
|
defaultPrevented = true;
|
|
},
|
|
stopPropagation() {
|
|
/* noop for synthetic toggle event */
|
|
},
|
|
get defaultPrevented() {
|
|
return defaultPrevented;
|
|
},
|
|
};
|
|
|
|
props.onChange?.(event);
|
|
if (!event.defaultPrevented) {
|
|
props.onToggle?.();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
class={`relative inline-flex ${config.track} shrink-0 items-center rounded-full p-0.5 transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900 ${
|
|
isDisabled() ? disabledClass : props.checked ? checkedClass : uncheckedClass
|
|
} ${props.class ?? ''}`.trim()}
|
|
onClick={handleClick}
|
|
disabled={props.disabled}
|
|
title={props.title}
|
|
aria-pressed={props.checked ? 'true' : 'false'}
|
|
aria-label={props.ariaLabel}
|
|
>
|
|
<span
|
|
class={`inline-block ${config.knob} rounded-full transition-transform duration-200 ease-in-out ${knobBase} ${
|
|
isDisabled() ? 'opacity-60' : ''
|
|
}`}
|
|
style={{ transform: props.checked ? `translateX(${config.translate})` : 'translateX(0)' }}
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface LabeledToggleProps extends BaseToggleProps {
|
|
label?: JSX.Element;
|
|
description?: JSX.Element;
|
|
containerClass?: string;
|
|
}
|
|
|
|
export function Toggle(props: LabeledToggleProps) {
|
|
const size = props.size ?? 'md';
|
|
return (
|
|
<label class={`flex items-center gap-3 ${props.containerClass ?? ''}`.trim()}>
|
|
<TogglePrimitive {...props} size={size} />
|
|
{(props.label || props.description) && (
|
|
<span class="flex flex-col text-sm text-gray-700 dark:text-gray-300">
|
|
{props.label}
|
|
{props.description && (
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">{props.description}</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export default Toggle;
|