110 lines
3.8 KiB
TypeScript
110 lines
3.8 KiB
TypeScript
import { useState } from 'react';
|
|
import { Check, Plus } from 'lucide-react';
|
|
import { cn } from '../../lib/cn';
|
|
|
|
export interface MultiSelectOption {
|
|
value: string;
|
|
label: string;
|
|
}
|
|
|
|
export interface MultiSelectProps {
|
|
label?: string;
|
|
options?: Array<string | MultiSelectOption>;
|
|
/** Controlled selected values. */
|
|
value?: string[];
|
|
defaultValue?: string[];
|
|
onChange?: (next: string[]) => void;
|
|
className?: string;
|
|
/** Render chips as read-only (no toggling) and show `hint` instead of being editable. */
|
|
disabled?: boolean;
|
|
/** Small muted helper line — used for the disabled-state explanation. */
|
|
hint?: string;
|
|
}
|
|
|
|
/** Chip-based multiselect. Click options to toggle; selected render as filled chips. */
|
|
export function MultiSelect({
|
|
label,
|
|
options = [],
|
|
value,
|
|
defaultValue = [],
|
|
onChange,
|
|
className,
|
|
disabled = false,
|
|
hint,
|
|
}: MultiSelectProps) {
|
|
const controlled = value !== undefined;
|
|
const [internal, setInternal] = useState<string[]>(defaultValue);
|
|
const selected = controlled ? value : internal;
|
|
|
|
const set = (next: string[]) => {
|
|
if (!controlled) setInternal(next);
|
|
onChange?.(next);
|
|
};
|
|
const toggle = (v: string) =>
|
|
set(selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v]);
|
|
|
|
return (
|
|
<div className={cn('flex flex-col gap-1.5 font-sans', className)}>
|
|
{label && <span className="text-sm font-medium text-muted">{label}</span>}
|
|
{disabled ? (
|
|
// Read-only: chips don't toggle and a hint explains why (no product yet,
|
|
// or the plan offers no riders). Muted so it reads as inactive.
|
|
<div className="flex flex-col gap-1.5">
|
|
{options.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 opacity-60">
|
|
{options.map((o) => {
|
|
const val = typeof o === 'string' ? o : o.value;
|
|
const lab = typeof o === 'string' ? o : o.label;
|
|
const on = selected.includes(val);
|
|
return (
|
|
<span
|
|
key={val}
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 px-3 py-[7px] rounded-pill font-sans text-sm font-semibold cursor-default border',
|
|
on
|
|
? 'bg-sunrise-100 text-sunrise-600 border-transparent'
|
|
: 'bg-card text-body border-border-default',
|
|
)}
|
|
>
|
|
<span className={cn('flex', on ? 'opacity-100' : 'opacity-40')}>
|
|
{on ? <Check size={13} /> : <Plus size={13} />}
|
|
</span>
|
|
{lab}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{hint && <span className="text-xs text-faint">{hint}</span>}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-wrap gap-2">
|
|
{options.map((o) => {
|
|
const val = typeof o === 'string' ? o : o.value;
|
|
const lab = typeof o === 'string' ? o : o.label;
|
|
const on = selected.includes(val);
|
|
return (
|
|
<button
|
|
key={val}
|
|
type="button"
|
|
onClick={() => toggle(val)}
|
|
className={cn(
|
|
'inline-flex items-center gap-1.5 px-3 py-[7px] rounded-pill font-sans text-sm font-semibold cursor-pointer border transition-all duration-150',
|
|
on
|
|
? 'bg-sunrise-100 text-sunrise-600 border-transparent'
|
|
: 'bg-card text-body border-border-default',
|
|
)}
|
|
>
|
|
<span className={cn('flex', on ? 'opacity-100' : 'opacity-40')}>
|
|
{on ? <Check size={13} /> : <Plus size={13} />}
|
|
</span>
|
|
{lab}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|