62 lines
2.6 KiB
TypeScript
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-surface-strong");
|
|
|
|
// 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-surface 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-ink-faint" : "text-ink")}
|
|
onclick={(_e: MouseEvent) => toggle()}
|
|
>{resolve(props.label)}</span>}
|
|
{props.description !== undefined && <span class="text-ss text-ink-muted">{resolve(props.description)}</span>}
|
|
</div>}
|
|
</div>;
|
|
}
|