153 lines
4.4 KiB
JavaScript
153 lines
4.4 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.
|
||
*/
|
||
/** @param {any} props */
|
||
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).
|
||
*/
|
||
/**
|
||
* Two treatments for the same control.
|
||
*
|
||
* `brand` states a choice that changes what the data *says* — a date range, a
|
||
* scope — so the selection carries the page's accent colour and reads as a
|
||
* filter. `surface` states a choice that changes only how the same data is
|
||
* *drawn*, so the selection is a raised white chip on a sunken track: present
|
||
* enough to show which mode is on, quiet enough not to compete with the real
|
||
* filters sitting beside it in the same toolbar.
|
||
*/
|
||
const SEGMENT_VARIANTS = {
|
||
brand: {
|
||
track: 'bg-surface-sunken border-border',
|
||
active: 'bg-krow-blue text-white shadow-sm',
|
||
inactive: 'text-ink-3 hover:text-ink-1',
|
||
},
|
||
surface: {
|
||
track: 'bg-surface-sunken border-border',
|
||
active: 'bg-surface text-ink-1 shadow-xs',
|
||
inactive: 'text-ink-4 hover:text-ink-2',
|
||
},
|
||
};
|
||
|
||
/** @param {any} props */
|
||
export function SegmentedToggle({
|
||
options = [],
|
||
value = null,
|
||
onChange = null,
|
||
size = 'default',
|
||
variant = 'brand',
|
||
className = '',
|
||
ariaLabel = '',
|
||
}) {
|
||
const normalized = options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
||
const tone = SEGMENT_VARIANTS[variant] || SEGMENT_VARIANTS.brand;
|
||
|
||
return (
|
||
<div
|
||
role="radiogroup"
|
||
aria-label={ariaLabel}
|
||
className={cn('inline-flex items-center gap-0.5 rounded-full border p-0.5', tone.track, className)}
|
||
>
|
||
{normalized.map((option) => {
|
||
const active = option.value === value;
|
||
const Icon = option.icon;
|
||
/* Icon-only options carry their name on the button instead of beside
|
||
it, so the control stays compact without becoming unlabelled. */
|
||
const iconOnly = Boolean(Icon) && !option.label;
|
||
|
||
return (
|
||
<button
|
||
key={option.value}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={active}
|
||
aria-label={option.ariaLabel || (iconOnly ? String(option.value) : undefined)}
|
||
title={option.ariaLabel}
|
||
onClick={() => onChange?.(option.value)}
|
||
className={cn(
|
||
'inline-flex items-center justify-center gap-1.5 rounded-full font-medium',
|
||
'transition-all duration-base ease-out focus-ring-inset',
|
||
iconOnly
|
||
? (size === 'sm' ? 'h-6 w-7' : 'h-7 w-8')
|
||
: (size === 'sm' ? 'px-2.5 py-1 text-caption' : 'px-3 py-1.5 text-body-sm'),
|
||
active ? tone.active : tone.inactive
|
||
)}
|
||
>
|
||
{Icon && <Icon className={size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'} aria-hidden="true" />}
|
||
{option.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|