move tsx kit -> uikit
This commit is contained in:
61
web/uikit/ToggleSwitch.tsx
Normal file
61
web/uikit/ToggleSwitch.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user