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 (
);
})}
);
}