113 lines
2.9 KiB
JavaScript
113 lines
2.9 KiB
JavaScript
import * as React from 'react';
|
||
import { cn } from '@/lib/utils';
|
||
import { Switch } from '@/components/ui/switch';
|
||
|
||
/**
|
||
* Toggle — a labelled switch row for settings.
|
||
*
|
||
* Wraps the Switch primitive with the label/description layout it is almost
|
||
* always used with, and makes the whole row a label so the hit target is the
|
||
* full width rather than the 36px control.
|
||
*/
|
||
export function Toggle({
|
||
checked,
|
||
onCheckedChange,
|
||
label,
|
||
description,
|
||
disabled,
|
||
/** `end` puts the switch on the right (settings rows); `start` on the left. */
|
||
align = 'end',
|
||
className,
|
||
id: providedId,
|
||
}) {
|
||
const reactId = React.useId();
|
||
const id = providedId || reactId;
|
||
const descriptionId = description ? `${id}-description` : undefined;
|
||
|
||
const control = (
|
||
<Switch
|
||
id={id}
|
||
checked={checked}
|
||
onCheckedChange={onCheckedChange}
|
||
disabled={disabled}
|
||
aria-describedby={descriptionId}
|
||
/>
|
||
);
|
||
|
||
const text = (
|
||
<span className="min-w-0">
|
||
<span className="block text-body-sm font-medium text-ink-2">{label}</span>
|
||
{description && (
|
||
<span id={descriptionId} className="block text-caption text-ink-3 mt-0.5">
|
||
{description}
|
||
</span>
|
||
)}
|
||
</span>
|
||
);
|
||
|
||
return (
|
||
<label
|
||
htmlFor={id}
|
||
className={cn(
|
||
'flex items-center gap-3 cursor-pointer select-none',
|
||
disabled && 'cursor-not-allowed opacity-60',
|
||
align === 'end' && 'justify-between',
|
||
className
|
||
)}
|
||
>
|
||
{align === 'start' ? (
|
||
<>
|
||
{control}
|
||
{text}
|
||
</>
|
||
) : (
|
||
<>
|
||
{text}
|
||
{control}
|
||
</>
|
||
)}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* SegmentedToggle — a small pill group for switching between 2–4 mutually
|
||
* exclusive views. Used where Tabs would be too heavy (chart ranges, EN/ES).
|
||
*/
|
||
export function SegmentedToggle({ options = [], value, onChange, size = 'default', className, ariaLabel }) {
|
||
const normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
||
|
||
return (
|
||
<div
|
||
role="radiogroup"
|
||
aria-label={ariaLabel}
|
||
className={cn(
|
||
'inline-flex items-center gap-0.5 rounded-full bg-surface-sunken p-0.5 border border-border',
|
||
className
|
||
)}
|
||
>
|
||
{normalized.map((option) => {
|
||
const active = option.value === value;
|
||
return (
|
||
<button
|
||
key={option.value}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={active}
|
||
onClick={() => onChange?.(option.value)}
|
||
className={cn(
|
||
'rounded-full font-medium transition-all duration-base ease-out focus-ring-inset',
|
||
size === 'sm' ? 'px-2.5 py-1 text-caption' : 'px-3 py-1.5 text-body-sm',
|
||
active
|
||
? 'bg-krow-blue text-white shadow-sm'
|
||
: 'text-ink-3 hover:text-ink-1'
|
||
)}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|