diff --git a/src/components/core/PickerShell.tsx b/src/components/core/PickerShell.tsx new file mode 100644 index 0000000..cfbc23d --- /dev/null +++ b/src/components/core/PickerShell.tsx @@ -0,0 +1,182 @@ +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { ChevronDown, X } from 'lucide-react'; +import { cn } from '../../lib/cn'; + +export interface PickerShellProps { + label?: string; + required?: boolean; + hint?: string; + /** Class for the outer label wrapper (width etc.). */ + className?: string; + /** Text shown in the trigger for the current selection ('' = nothing picked). */ + displayLabel: string; + placeholder?: string; + /** Rows shown in the open panel (already filtered/fetched by the caller). */ + items: T[]; + renderItem: (item: T) => ReactNode; + itemKey: (item: T, i: number) => string | number; + onPick: (item: T) => void; + /** When provided and something is selected, a clear (✕) button shows. */ + onClear?: () => void; + /** Show the search box (default true). */ + searchable?: boolean; + search: string; + onSearch: (s: string) => void; + loading?: boolean; + emptyText?: string; + /** Notifies the caller so it can fetch on open / reset on close. */ + onOpenChange?: (open: boolean) => void; + disabled?: boolean; +} + +/** Shared searchable-picker chrome: trigger pill + portal dropdown with an + * optional search box and a scrollable option list. Both Select (static + * options) and LookupField (remote rows) render through this so the two are + * visually identical — one picker UI everywhere. The panel lives in a portal + * so a Card's `overflow-hidden` can't clip it. */ +export function PickerShell({ + label, + required, + hint, + className, + displayLabel, + placeholder = 'Select…', + items, + renderItem, + itemKey, + onPick, + onClear, + searchable = true, + search, + onSearch, + loading, + emptyText = 'No matches.', + onOpenChange, + disabled, +}: PickerShellProps) { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const panelRef = useRef(null); + const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null); + + const toggle = (next: boolean) => { + if (disabled) return; + setOpen(next); + onOpenChange?.(next); + }; + + // Anchor the portal panel under the trigger; track scroll/resize while open. + useLayoutEffect(() => { + if (!open) return; + const place = () => { + const el = triggerRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + setPos({ top: r.bottom + 4, left: r.left, width: r.width }); + }; + place(); + window.addEventListener('scroll', place, true); + window.addEventListener('resize', place); + return () => { + window.removeEventListener('scroll', place, true); + window.removeEventListener('resize', place); + }; + }, [open]); + + // Close on outside click — ignore clicks inside the trigger or the portal panel. + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + const t = e.target as Node; + if (triggerRef.current?.contains(t) || panelRef.current?.contains(t)) return; + toggle(false); + }; + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + return ( + + ); +} diff --git a/src/components/core/SelectField.tsx b/src/components/core/SelectField.tsx new file mode 100644 index 0000000..aa50c7a --- /dev/null +++ b/src/components/core/SelectField.tsx @@ -0,0 +1,91 @@ +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; + 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(); + 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 ( + + 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} + /> + ); +} diff --git a/src/components/core/index.ts b/src/components/core/index.ts index c58b84c..3335d05 100644 --- a/src/components/core/index.ts +++ b/src/components/core/index.ts @@ -10,5 +10,9 @@ export { Input } from './Input'; export type { InputProps } from './Input'; export { Select } from './Select'; export type { SelectProps, SelectOption } from './Select'; +export { SelectField } from './SelectField'; +export type { SelectFieldProps, SelectFieldOption } from './SelectField'; +export { PickerShell } from './PickerShell'; +export type { PickerShellProps } from './PickerShell'; export { MultiSelect } from './MultiSelect'; export type { MultiSelectProps, MultiSelectOption } from './MultiSelect'; diff --git a/src/components/form/ActivityForm.tsx b/src/components/form/ActivityForm.tsx index 4fdbe1f..355bc0a 100644 --- a/src/components/form/ActivityForm.tsx +++ b/src/components/form/ActivityForm.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { CheckCircle2 } from 'lucide-react'; import { Button } from '../core/Button'; import { Input } from '../core/Input'; -import { Select } from '../core/Select'; +import { SelectField } from '../core/SelectField'; import { MultiSelect } from '../core/MultiSelect'; import { LookupField } from './LookupField'; import type { LookupValue } from './LookupField'; @@ -204,12 +204,13 @@ function Field({ case 'select': return ( - setSearch(e.target.value)} - placeholder="Search…" - className="w-full h-9 px-2.5 border border-border-default rounded-md outline-none text-sm focus:border-sunrise-500" - /> - -
- {loading ? ( -
Searching…
- ) : rows.length ? ( - rows.map((row, i) => ( - - )) - ) : ( -
No matches.
- )} -
- , - document.body, - )} - + + label={label} + required={required} + displayLabel={labelOf(value)} + items={rows} + renderItem={(row) => labelOf(row) || 'Row'} + itemKey={(_row, i) => i} + onPick={pick} + onClear={() => onChange(null)} + search={search} + onSearch={setSearch} + loading={loading} + onOpenChange={(o) => { + setOpen(o); + if (!o) setSearch(''); + }} + /> ); } diff --git a/src/screens/AssignScreen.tsx b/src/screens/AssignScreen.tsx index 5fac15a..5f14373 100644 --- a/src/screens/AssignScreen.tsx +++ b/src/screens/AssignScreen.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { CheckCircle2, UserCheck } from 'lucide-react'; -import { AIDecisionCard, Avatar, Badge, Button, Card, formatINR, Select } from '../components'; +import { AIDecisionCard, Avatar, Badge, Button, Card, formatINR, SelectField } from '../components'; import { LookupField } from '../components/form'; import type { LookupValue } from '../components/form'; import { useQuery, useZino } from '../api/provider'; @@ -92,7 +92,7 @@ export function AssignScreen() { ) : (
Select a lead at the Qualified stage.
)} - ({ @@ -253,8 +253,8 @@ export function DocumentsScreen() { })), ]} value={selectedId} - onChange={(e) => { - setSelectedId(e.target.value); + onChange={(v) => { + setSelectedId(v); setResult(null); }} className="w-[260px]" @@ -342,14 +342,14 @@ export function DocumentsScreen() { value={verifiedIncome} onChange={(e) => setVerifiedIncome(Number(e.target.value) || 0)} /> - ({ + value: String(r.instance_id), + label: `#${r.instance_id} ${r.lead_name ?? ''} · ${r.current_state_name ?? ''}`, + })), + ]} value={selectedId} - onChange={(e) => { - setSelectedId(e.target.value); + onChange={(v) => { + setSelectedId(v); setResult(null); }} - className="w-[260px] font-sans text-base text-strong border border-border-default rounded-md px-3 py-2.5 outline-none focus:border-sunrise-500" - > - - {records.map((r) => ( - - ))} - + className="w-[260px]" + /> {result && ( diff --git a/src/screens/RecommendScreen.tsx b/src/screens/RecommendScreen.tsx index 5e23258..8c70a77 100644 --- a/src/screens/RecommendScreen.tsx +++ b/src/screens/RecommendScreen.tsx @@ -10,7 +10,7 @@ import { Input, MultiSelect, RupeeAmount, - Select, + SelectField, } from '../components'; import { LookupField } from '../components/form'; import type { LookupValue } from '../components/form'; @@ -158,7 +158,7 @@ export function RecommendScreen() { ) : (
Select a lead at the Meeting Scheduled stage.
)} - setFreq(e.target.value)} + onChange={setFreq} /> Select a lead at the Eligibility Assessed stage. )} - setStatus(e.target.value)} + onChange={setStatus} />