92 lines
2.7 KiB
TypeScript
92 lines
2.7 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { PickerShell } from './PickerShell';
|
|
|
|
export interface SelectFieldOption {
|
|
value: string;
|
|
label: string;
|
|
}
|
|
|
|
export interface SelectFieldProps {
|
|
label?: string;
|
|
required?: boolean;
|
|
hint?: string;
|
|
className?: string;
|
|
/** Either strings or {value,label} objects. */
|
|
options?: Array<string | SelectFieldOption>;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
/** Force the search box on/off. Defaults to on when there are >7 options. */
|
|
searchable?: boolean;
|
|
/** Allow clearing back to ''. Defaults to true for non-required fields. */
|
|
clearable?: boolean;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
const norm = (o: string | SelectFieldOption): SelectFieldOption =>
|
|
typeof o === 'string' ? { value: o, label: o } : o;
|
|
|
|
/** Single-select that renders through the shared PickerShell, so it matches
|
|
* LookupField exactly. Static options, searched/filtered locally. An empty-
|
|
* value option (e.g. {value:'', label:'Choose…'}) is treated as the
|
|
* placeholder rather than a list row, and option values are de-duplicated. */
|
|
export function SelectField({
|
|
label,
|
|
required,
|
|
hint,
|
|
className,
|
|
options = [],
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
searchable,
|
|
clearable,
|
|
disabled,
|
|
}: SelectFieldProps) {
|
|
const [search, setSearch] = useState('');
|
|
|
|
// Normalize, lift any empty-value option's label to the placeholder, dedupe.
|
|
const { opts, ph } = useMemo(() => {
|
|
const all = options.map(norm);
|
|
const empty = all.find((o) => o.value === '');
|
|
const seen = new Set<string>();
|
|
const opts = all.filter((o) => {
|
|
if (o.value === '' || seen.has(o.value)) return false;
|
|
seen.add(o.value);
|
|
return true;
|
|
});
|
|
return { opts, ph: placeholder ?? empty?.label };
|
|
}, [options, placeholder]);
|
|
|
|
const items = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
return q ? opts.filter((o) => o.label.toLowerCase().includes(q)) : opts;
|
|
}, [opts, search]);
|
|
|
|
const display = opts.find((o) => o.value === value)?.label ?? '';
|
|
const canSearch = searchable ?? opts.length > 7;
|
|
const canClear = (clearable ?? !required) && value !== '';
|
|
|
|
return (
|
|
<PickerShell<SelectFieldOption>
|
|
label={label}
|
|
required={required}
|
|
hint={hint}
|
|
className={className}
|
|
displayLabel={display}
|
|
placeholder={ph}
|
|
items={items}
|
|
renderItem={(o) => o.label}
|
|
itemKey={(o) => o.value}
|
|
onPick={(o) => onChange(o.value)}
|
|
onClear={canClear ? () => onChange('') : undefined}
|
|
searchable={canSearch}
|
|
search={search}
|
|
onSearch={setSearch}
|
|
onOpenChange={(open) => !open && setSearch('')}
|
|
emptyText="No options."
|
|
disabled={disabled}
|
|
/>
|
|
);
|
|
}
|