Files
kjol/web/kit/ToggleSwitch.tsx

62 lines
2.6 KiB
TypeScript

// ToggleSwitch is a reusable on/off switch styled with Tailwind. It renders a
// real <button role="switch"> so it stays keyboard- and screen-reader-friendly,
// with an optional inline label/description to its right.
//
// Props accept either plain values or zero-arg accessors (the SegmentedButtons
// convention), so callers can pass a signal directly: checked={mySignal}.
type Reactive<T> = T | (() => T);
interface ToggleSwitchProps {
checked: Reactive<boolean>;
onchange: (next: boolean) => void;
label?: Reactive<string>;
description?: Reactive<string>;
disabled?: Reactive<boolean>;
class?: string;
}
const resolve = <T,>(v: Reactive<T>): T => (typeof v === "function" ? (v as () => T)() : v);
export function ToggleSwitch(props: ToggleSwitchProps) {
const isChecked = () => !!resolve(props.checked);
const isDisabled = () => !!resolve(props.disabled);
const toggle = () => {
if (isDisabled()) return;
props.onchange(!isChecked());
};
const trackCls = () =>
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 "
+ (isChecked() ? "bg-primary" : "bg-neutral-300");
// Track is w-9 (36px) with a w-4 (16px) knob, so a symmetric 2px gap means
// the knob sits at 2px (translate-x-0.5) when off and 36-16-2=18px when on.
const knobCls = () =>
"inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform "
+ (isChecked() ? "translate-x-[18px]" : "translate-x-0.5");
const hasText = () => props.label !== undefined || props.description !== undefined;
return <div class={"flex items-center gap-2 " + (props.class || "")}>
<button
type="button"
role="switch"
aria-checked={isChecked() ? "true" : "false"}
disabled={isDisabled()}
onclick={(_e: MouseEvent) => toggle()}
class={trackCls()}
>
<span class={knobCls()}></span>
</button>
{hasText() && <div class="flex flex-col leading-tight">
{props.label !== undefined && <span
class={"text-sm select-none " + (isDisabled() ? "text-neutral-400" : "text-neutral-800")}
onclick={(_e: MouseEvent) => toggle()}
>{resolve(props.label)}</span>}
{props.description !== undefined && <span class="text-xs text-neutral-500">{resolve(props.description)}</span>}
</div>}
</div>;
}