97 lines
3.1 KiB
TypeScript
97 lines
3.1 KiB
TypeScript
import * as React from 'react';
|
|
import { clsx } from 'clsx';
|
|
import { ChevronDown } from 'lucide-react';
|
|
|
|
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
|
label?: string;
|
|
error?: string;
|
|
options: Array<{ value: string; label: string; disabled?: boolean }>;
|
|
placeholder?: string;
|
|
controlSize?: 'sm' | 'md' | 'lg';
|
|
variant?: 'surface' | 'muted' | 'ghost';
|
|
}
|
|
|
|
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
|
(
|
|
{
|
|
label,
|
|
error,
|
|
options,
|
|
placeholder,
|
|
controlSize = 'md',
|
|
variant = 'surface',
|
|
className,
|
|
id,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const generatedId = React.useId();
|
|
const selectId = id ?? (label || error ? `select-${generatedId}` : undefined);
|
|
const sizes: Record<NonNullable<SelectProps['controlSize']>, string> = {
|
|
sm: 'h-8 px-2.5 pr-8 text-xs',
|
|
md: 'h-10 px-3 pr-8 text-sm',
|
|
lg: 'h-11 px-4 pr-9 text-sm',
|
|
};
|
|
const variants: Record<NonNullable<SelectProps['variant']>, string> = {
|
|
surface: 'bg-[var(--bl-input,var(--bl-surface-card,#1a1a2e))]',
|
|
muted: 'bg-[var(--bl-surface-muted,#252540)]',
|
|
ghost: 'bg-transparent',
|
|
};
|
|
|
|
return (
|
|
<div className="grid gap-1.5">
|
|
{label && (
|
|
<label
|
|
htmlFor={selectId}
|
|
className="block text-xs font-semibold uppercase tracking-[0.08em] text-[var(--bl-text-secondary,#a0a0b0)]"
|
|
>
|
|
{label}
|
|
</label>
|
|
)}
|
|
<div className="relative">
|
|
<select
|
|
ref={ref}
|
|
id={selectId}
|
|
className={clsx(
|
|
'w-full appearance-none rounded-lg border shadow-sm shadow-black/[0.03] outline-none transition duration-150',
|
|
variants[variant],
|
|
sizes[controlSize],
|
|
'text-[var(--bl-text-primary,#fff)]',
|
|
'disabled:cursor-not-allowed disabled:opacity-60',
|
|
'focus:border-[var(--bl-focus-ring,var(--bl-accent,#5A8CFF))] focus:ring-2 focus:ring-[var(--bl-focus-ring-muted,var(--bl-accent-muted,rgba(90,140,255,0.2)))] focus:ring-offset-0',
|
|
error ? 'border-[var(--bl-danger)]' : 'border-[var(--bl-border,#2a2a4a)]',
|
|
className
|
|
)}
|
|
aria-invalid={error ? 'true' : undefined}
|
|
aria-describedby={error ? `${selectId}-error` : undefined}
|
|
{...props}
|
|
>
|
|
{placeholder && (
|
|
<option value="" disabled>
|
|
{placeholder}
|
|
</option>
|
|
)}
|
|
{options.map(opt => (
|
|
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<ChevronDown
|
|
className="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--bl-text-tertiary,#555)]"
|
|
aria-hidden="true"
|
|
/>
|
|
</div>
|
|
{error && (
|
|
<p id={`${selectId}-error`} className="text-xs text-[var(--bl-danger)]" role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
Select.displayName = 'Select';
|