import * as React from "react"; import { Check, Search } from "lucide-react"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Slider } from "@/components/ui/slider"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; /** * Form Primitives — WealthX DS (L3) * * Reusable input primitives for financial forms. * Used inside Opportunity edit modals and loan application forms. * * Components: * - CurrencyInputWithSlider — formatted currency text input + range slider * - ConcernScale — 1–5 priority/importance selector * - AddressAutocomplete — text input with filtered suggestion dropdown * - OwnershipSplit — two-owner percentage split (sums to 100 %) */ // --------------------------------------------------------------------------- // CurrencyInputWithSlider // --------------------------------------------------------------------------- export interface CurrencyInputWithSliderProps { /** Numeric value in dollars */ value?: number; /** Default value when uncontrolled */ defaultValue?: number; min?: number; max?: number; step?: number; disabled?: boolean; /** Called with the updated numeric value */ onValueChange?: (value: number) => void; className?: string; } function parseCurrencyToNumber(raw: string): number { const cleaned = raw.replace(/[^0-9.]/g, ""); const n = parseFloat(cleaned); return isNaN(n) ? 0 : n; } function formatNumberToCurrency(n: number): string { return "$" + Math.round(n).toLocaleString("en-AU"); } /** * Currency text input paired with a range slider. * The two controls stay in sync — editing the input moves the slider and vice versa. * * ```tsx * * ``` */ export function CurrencyInputWithSlider({ value: controlledValue, defaultValue = 0, min = 0, max = 5_000_000, step = 10_000, disabled = false, onValueChange, className, }: CurrencyInputWithSliderProps) { const [internalValue, setInternalValue] = React.useState(defaultValue); const numericValue = controlledValue !== undefined ? controlledValue : internalValue; const [inputText, setInputText] = React.useState( formatNumberToCurrency(numericValue), ); const [isFocused, setIsFocused] = React.useState(false); // Sync inputText when controlled value changes externally React.useEffect(() => { if (!isFocused) { setInputText(formatNumberToCurrency(numericValue)); } }, [numericValue, isFocused]); const commitValue = (n: number) => { const clamped = Math.max(min, Math.min(max, n)); if (controlledValue === undefined) setInternalValue(clamped); onValueChange?.(clamped); setInputText(formatNumberToCurrency(clamped)); }; return (
{/* Text input */} { setIsFocused(true); setInputText(formatNumberToCurrency(numericValue)); }} onChange={(e) => setInputText(e.target.value)} onBlur={() => { setIsFocused(false); commitValue(parseCurrencyToNumber(inputText)); }} onKeyDown={(e) => { if (e.key === "Enter") { (e.target as HTMLInputElement).blur(); } }} className="tabular-nums" aria-label="Currency amount" /> {/* Slider */} { if (controlledValue === undefined) setInternalValue(n); onValueChange?.(n); }} />
); } // --------------------------------------------------------------------------- // ConcernScale // --------------------------------------------------------------------------- const CONCERN_LABELS: Record = { 1: "Not important", 2: "Slightly important", 3: "Moderately important", 4: "Very important", 5: "Critical", }; export interface ConcernScaleProps { value?: number; defaultValue?: number; disabled?: boolean; /** Number of steps (default 5) */ steps?: number; lowLabel?: string; highLabel?: string; onValueChange?: (value: number) => void; className?: string; } /** * 1–N priority / importance selector using DS ToggleGroup buttons. * One button is active at a time; selecting the same value again clears to 0. * * ```tsx * * ``` */ export function ConcernScale({ value: controlledValue, defaultValue = 0, disabled = false, steps = 5, lowLabel = "Not important", highLabel = "Critical", onValueChange, className, }: ConcernScaleProps) { const [internalValue, setInternalValue] = React.useState(defaultValue); const selected = controlledValue !== undefined ? controlledValue : internalValue; const handleValueChange = (val: string[]) => { const n = val[0] ? Number(val[0]) : 0; if (controlledValue === undefined) setInternalValue(n); onValueChange?.(n); }; return (
0 ? [String(selected)] : []} onValueChange={handleValueChange} variant="outline" size="sm" disabled={disabled} className="w-full" > {Array.from({ length: steps }, (_, i) => i + 1).map((n) => ( {n} ))} {/* Extreme labels */}
{lowLabel} {highLabel}
{/* Selected label */} {selected > 0 && (

{CONCERN_LABELS[selected] ?? `Level ${selected}`}

)}
); } // --------------------------------------------------------------------------- // AddressAutocomplete // --------------------------------------------------------------------------- export interface AddressOption { id: string; label: string; suburb?: string; state?: string; postcode?: string; } export interface AddressAutocompleteProps { value?: string; placeholder?: string; suggestions?: AddressOption[]; disabled?: boolean; onValueChange?: (value: string) => void; onSelect?: (option: AddressOption) => void; className?: string; } const DEFAULT_SUGGESTIONS: AddressOption[] = [ { id: "1", label: "12 Harbour View Terrace, Mosman NSW 2088", suburb: "Mosman", state: "NSW", postcode: "2088", }, { id: "2", label: "5 Coastal Road, Manly NSW 2095", suburb: "Manly", state: "NSW", postcode: "2095", }, { id: "3", label: "24 Collins Street, Melbourne VIC 3000", suburb: "Melbourne", state: "VIC", postcode: "3000", }, { id: "4", label: "88 Pacific Highway, St Leonards NSW 2065", suburb: "St Leonards", state: "NSW", postcode: "2065", }, { id: "5", label: "1 Queen Street, Brisbane QLD 4000", suburb: "Brisbane", state: "QLD", postcode: "4000", }, ]; /** * Address text input with filtered suggestion dropdown. * Filters the `suggestions` list on each keystroke (case-insensitive substring match). * Pass `onSelect` to receive the full structured `AddressOption` on selection. * * In production, replace `suggestions` with results from a geocoding API. * * ```tsx * console.log(opt.postcode)} * /> * ``` */ export function AddressAutocomplete({ value: controlledValue, placeholder = "Start typing an address…", suggestions = DEFAULT_SUGGESTIONS, disabled = false, onValueChange, onSelect, className, }: AddressAutocompleteProps) { const [internalValue, setInternalValue] = React.useState(""); const inputValue = controlledValue !== undefined ? controlledValue : internalValue; const [open, setOpen] = React.useState(false); const [activeIndex, setActiveIndex] = React.useState(-1); const containerRef = React.useRef(null); const listRef = React.useRef(null); const filtered = React.useMemo(() => { if (!inputValue.trim()) return suggestions.slice(0, 5); const q = inputValue.toLowerCase(); return suggestions .filter((s) => s.label.toLowerCase().includes(q)) .slice(0, 5); }, [inputValue, suggestions]); const setValue = (v: string) => { if (controlledValue === undefined) setInternalValue(v); onValueChange?.(v); }; const handleSelect = (opt: AddressOption) => { setValue(opt.label); onSelect?.(opt); setOpen(false); setActiveIndex(-1); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (!open) return; if (e.key === "ArrowDown") { e.preventDefault(); setActiveIndex((i) => Math.min(i + 1, filtered.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActiveIndex((i) => Math.max(i - 1, -1)); } else if (e.key === "Enter" && activeIndex >= 0) { e.preventDefault(); handleSelect(filtered[activeIndex]); } else if (e.key === "Escape") { setOpen(false); } }; // Close on outside click React.useEffect(() => { const handler = (e: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(e.target as Node) ) { setOpen(false); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); return (
{ setValue(e.target.value); setOpen(true); setActiveIndex(-1); }} onFocus={() => setOpen(true)} onKeyDown={handleKeyDown} aria-autocomplete="list" aria-expanded={open} />
{open && filtered.length > 0 && (
    {filtered.map((opt, idx) => (
  • setActiveIndex(idx)} onMouseDown={(e) => { e.preventDefault(); handleSelect(opt); }} className={cn( "flex items-center gap-2 px-3 py-2 text-body-small cursor-pointer", idx === activeIndex ? "bg-accent text-accent-foreground" : "hover:bg-accent/50", )} > {opt.label} {idx === activeIndex && ( )}
  • ))}
)}
); } // --------------------------------------------------------------------------- // OwnershipSplit // --------------------------------------------------------------------------- export interface OwnershipOwner { id: string; name: string; /** Percentage 0–100 */ share: number; } export interface OwnershipSplitProps { owners?: OwnershipOwner[]; disabled?: boolean; onOwnersChange?: (owners: OwnershipOwner[]) => void; className?: string; } const DEFAULT_OWNERS: OwnershipOwner[] = [ { id: "main", name: "Main Applicant", share: 50 }, { id: "co", name: "Co-Applicant", share: 50 }, ]; /** * Two-owner (or N-owner) percentage split control. * Adjusting one owner's slider redistributes the remainder proportionally. * The sum always stays at 100 %. * * ```tsx * * ``` */ export function OwnershipSplit({ owners: controlledOwners, disabled = false, onOwnersChange, className, }: OwnershipSplitProps) { const [internalOwners, setInternalOwners] = React.useState(DEFAULT_OWNERS); const owners = controlledOwners !== undefined ? controlledOwners : internalOwners; const setOwners = (updated: OwnershipOwner[]) => { if (controlledOwners === undefined) setInternalOwners(updated); onOwnersChange?.(updated); }; const handleSliderChange = (id: string, newShare: number) => { if (owners.length !== 2) return; // Only 2-owner redistribution implemented const clamped = Math.max(0, Math.min(100, Math.round(newShare))); const other = owners.find((o) => o.id !== id); if (!other) return; setOwners( owners.map((o) => o.id === id ? { ...o, share: clamped } : { ...o, share: 100 - clamped }, ), ); }; const handleInputChange = (id: string, raw: string) => { const n = parseInt(raw.replace(/[^0-9]/g, ""), 10); if (isNaN(n)) return; handleSliderChange(id, n); }; return (
{owners.map((owner) => { const pct = Math.max(0, Math.min(100, owner.share)); return (
handleInputChange(owner.id, e.target.value)} onBlur={(e) => handleInputChange(owner.id, e.target.value)} />
handleSliderChange(owner.id, n)} />
); })}
); }