search for select added
This commit is contained in:
parent
ddbb81b400
commit
9ede3fd490
@ -1,5 +1,14 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Modal } from '../../reusable/Modal';
|
||||||
import { Button } from '../../buttons/Button';
|
import { Button } from '../../buttons/Button';
|
||||||
|
import { useJsApiLoader, GoogleMap, Marker } from '@react-google-maps/api';
|
||||||
|
import { MapPin, Crosshair, Map as MapIcon, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
const mapContainerStyle = {
|
||||||
|
width: '100%',
|
||||||
|
height: '400px',
|
||||||
|
borderRadius: '8px'
|
||||||
|
};
|
||||||
|
|
||||||
export function GeolocationInput({
|
export function GeolocationInput({
|
||||||
label,
|
label,
|
||||||
@ -9,65 +18,190 @@ export function GeolocationInput({
|
|||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
value: { latitude: number; longitude: number } | null;
|
value: { latitude?: number; longitude?: number; lat?: number; lng?: number; accuracy?: number } | null;
|
||||||
onChange: (val: { latitude: number; longitude: number } | null) => void;
|
onChange: (val: { latitude: number; longitude: number; accuracy?: number } | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [isMapModalOpen, setIsMapModalOpen] = useState(false);
|
||||||
|
const [mapMarkerPos, setMapMarkerPos] = useState<{ lat: number; lng: number } | null>(null);
|
||||||
|
|
||||||
const autoFetchDone = useRef(false);
|
const isLocalhost = typeof window !== 'undefined' && (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1');
|
||||||
|
|
||||||
const fetchLocation = () => {
|
const { isLoaded } = useJsApiLoader({
|
||||||
if (!navigator.geolocation) {
|
id: 'google-map-script',
|
||||||
setError('Geolocation is not supported by your browser.');
|
googleMapsApiKey: isLocalhost ? '' : (import.meta.env.VITE_GOOGLE_MAPS_API_KEY || '')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract lat, lng & accuracy from value (supporting both latitude/longitude and lat/lng)
|
||||||
|
const lat = value ? (value.latitude ?? value.lat ?? null) : null;
|
||||||
|
const lng = value ? (value.longitude ?? value.lng ?? null) : null;
|
||||||
|
const accuracy = value?.accuracy;
|
||||||
|
|
||||||
|
const fetchLocation = useCallback(() => {
|
||||||
|
setError(null);
|
||||||
|
if (!("geolocation" in navigator)) {
|
||||||
|
setError("Geolocation not supported on this device");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(position) => {
|
(pos) => {
|
||||||
onChange({
|
onChange({
|
||||||
latitude: position.coords.latitude,
|
latitude: pos.coords.latitude,
|
||||||
longitude: position.coords.longitude,
|
longitude: pos.coords.longitude,
|
||||||
|
accuracy: pos.coords.accuracy,
|
||||||
});
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
setError(err.message);
|
setError(err.message || "Unable to get location");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
},
|
},
|
||||||
{ enableHighAccuracy: true }
|
{ enableHighAccuracy: true, timeout: 10000 }
|
||||||
);
|
);
|
||||||
};
|
}, [onChange]);
|
||||||
|
|
||||||
|
// Auto-fetch location on first mount if no value is present
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!value && !autoFetchDone.current) {
|
if (!value || (lat == null && lng == null)) {
|
||||||
autoFetchDone.current = true;
|
|
||||||
fetchLocation();
|
fetchLocation();
|
||||||
}
|
}
|
||||||
}, [value]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenMap = () => {
|
||||||
|
if (lat != null && lng != null) {
|
||||||
|
setMapMarkerPos({ lat, lng });
|
||||||
|
} else {
|
||||||
|
setMapMarkerPos(null);
|
||||||
|
}
|
||||||
|
setIsMapModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMapClick = useCallback((e: google.maps.MapMouseEvent) => {
|
||||||
|
if (e.latLng) {
|
||||||
|
setMapMarkerPos({
|
||||||
|
lat: e.latLng.lat(),
|
||||||
|
lng: e.latLng.lng()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmMapSelection = () => {
|
||||||
|
if (mapMarkerPos) {
|
||||||
|
onChange({
|
||||||
|
latitude: mapMarkerPos.lat,
|
||||||
|
longitude: mapMarkerPos.lng,
|
||||||
|
accuracy: 10,
|
||||||
|
});
|
||||||
|
setIsMapModalOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => onChange(null);
|
||||||
|
const hasValidCoords = lat != null && lng != null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 font-sans w-full">
|
<div className="space-y-2.5 font-sans w-full">
|
||||||
<span className="text-sm font-medium text-muted">
|
<label className="flex items-center gap-1.5 text-xs font-medium text-slate-700">
|
||||||
|
<MapPin className="h-3.5 w-3.5 text-slate-500" />
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="text-ruby-600"> *</span>}
|
{required && <span className="text-ruby-600">*</span>}
|
||||||
</span>
|
</label>
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Button type="button" variant="secondary" size="sm" onClick={fetchLocation} disabled={loading}>
|
<button
|
||||||
{loading ? 'Fetching...' : 'Re-fetch GPS'}
|
type="button"
|
||||||
</Button>
|
onClick={fetchLocation}
|
||||||
{value && (
|
disabled={loading}
|
||||||
<span className="text-sm text-strong bg-slate-100 px-3 py-1.5 rounded-md border border-slate-200">
|
className="inline-flex items-center gap-1.5 rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-xs font-semibold text-emerald-800 transition-colors hover:bg-emerald-100 disabled:opacity-60 cursor-pointer"
|
||||||
{value.latitude.toFixed(5)}, {value.longitude.toFixed(5)}
|
>
|
||||||
</span>
|
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Crosshair className="h-3.5 w-3.5" />}
|
||||||
)}
|
{loading ? "Locating…" : "Get Location"}
|
||||||
</div>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenMap}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-700 transition-colors hover:border-emerald-300 hover:bg-emerald-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<MapIcon className="h-3.5 w-3.5" />
|
||||||
|
Select from Map
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && <span className="text-xs text-ruby-600">{error}</span>}
|
|
||||||
{required && !value && <input type="text" className="sr-only" required />}
|
{hasValidCoords ? (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-lg border border-emerald-500/40 bg-emerald-50/50 px-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-mono text-sm font-semibold tabular-nums text-slate-800 truncate">
|
||||||
|
{lat.toFixed(5)}, {lng.toFixed(5)}
|
||||||
|
</div>
|
||||||
|
{typeof accuracy === "number" && (
|
||||||
|
<div className="text-[10px] text-slate-500">± {Math.round(accuracy)} m accuracy</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clear}
|
||||||
|
className="shrink-0 grid h-6 w-6 place-items-center rounded-full text-slate-400 transition-colors hover:text-red-500 cursor-pointer"
|
||||||
|
aria-label="Clear location"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-slate-400">No location captured yet.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="text-xs text-ruby-600">{error}</div>}
|
||||||
|
{required && !hasValidCoords && <input type="text" className="sr-only" required tabIndex={-1} />}
|
||||||
|
|
||||||
|
{isMapModalOpen && (
|
||||||
|
<Modal
|
||||||
|
open={isMapModalOpen}
|
||||||
|
onClose={() => setIsMapModalOpen(false)}
|
||||||
|
title="Select Location on Map"
|
||||||
|
width="lg"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-sm text-slate-600">Click on the map to place a pin at the desired store location.</p>
|
||||||
|
|
||||||
|
<div className="border border-slate-200 rounded-lg overflow-hidden relative min-h-[400px] bg-slate-100 flex items-center justify-center">
|
||||||
|
{!isLoaded ? (
|
||||||
|
<span className="text-slate-500 font-medium flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading Map...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<GoogleMap
|
||||||
|
mapContainerStyle={mapContainerStyle}
|
||||||
|
center={mapMarkerPos || { lat: 20.5937, lng: 78.9629 }}
|
||||||
|
zoom={mapMarkerPos ? 15 : 5}
|
||||||
|
onClick={handleMapClick}
|
||||||
|
options={{
|
||||||
|
streetViewControl: false,
|
||||||
|
mapTypeControl: false,
|
||||||
|
fullscreenControl: false
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mapMarkerPos && (
|
||||||
|
<Marker position={mapMarkerPos} />
|
||||||
|
)}
|
||||||
|
</GoogleMap>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-slate-100">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setIsMapModalOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="primary" onClick={confirmMapSelection} disabled={!mapMarkerPos}>
|
||||||
|
Confirm Location
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,11 +18,13 @@ export interface WfLookupFieldProps {
|
|||||||
export function WfLookupField({ label, required, value, onChange, client, config, activityId, fieldId, formData }: WfLookupFieldProps) {
|
export function WfLookupField({ label, required, value, onChange, client, config, activityId, fieldId, formData }: WfLookupFieldProps) {
|
||||||
const [options, setOptions] = useState<{ label: string; value: string }[]>([]);
|
const [options, setOptions] = useState<{ label: string; value: string }[]>([]);
|
||||||
const [records, setRecords] = useState<any[]>([]);
|
const [records, setRecords] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const formDataStr = JSON.stringify(formData);
|
const formDataStr = JSON.stringify(formData);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
client.wfLookupRecords({
|
client.wfLookupRecords({
|
||||||
@ -46,7 +48,7 @@ export function WfLookupField({ label, required, value, onChange, client, config
|
|||||||
.filter((v: any) => v != null && v !== '');
|
.filter((v: any) => v != null && v !== '');
|
||||||
|
|
||||||
const labelText = labelParts.length > 0
|
const labelText = labelParts.length > 0
|
||||||
? labelParts.join(', ')
|
? labelParts.join(' - ')
|
||||||
: `ID: ${row.instance_id || row.id}`;
|
: `ID: ${row.instance_id || row.id}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -59,6 +61,9 @@ export function WfLookupField({ label, required, value, onChange, client, config
|
|||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Failed to load wf_lookup records:", err);
|
console.error("Failed to load wf_lookup records:", err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (mounted) setLoading(false);
|
||||||
});
|
});
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
@ -70,7 +75,7 @@ export function WfLookupField({ label, required, value, onChange, client, config
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectField
|
<SelectField
|
||||||
label={label}
|
label={loading ? `${label} (Loading...)` : label}
|
||||||
required={required}
|
required={required}
|
||||||
value={String(value || '')}
|
value={String(value || '')}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { SelectHTMLAttributes } from 'react';
|
import { useState, useRef, useEffect, useCallback, type SelectHTMLAttributes } from 'react';
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { createPortal } from 'react-dom';
|
||||||
|
import { ChevronDown, Search, X, Check } from 'lucide-react';
|
||||||
import { cn } from '../../lib/cn';
|
import { cn } from '../../lib/cn';
|
||||||
|
|
||||||
export interface SelectOption {
|
export interface SelectOption {
|
||||||
@ -11,43 +12,208 @@ export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
|||||||
label?: string;
|
label?: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
placeholder?: string;
|
||||||
/** Either strings or {value,label} objects. */
|
/** Either strings or {value,label} objects. */
|
||||||
options?: Array<string | SelectOption>;
|
options?: Array<string | SelectOption>;
|
||||||
/** Class for the outer label wrapper. */
|
/** Class for the outer label wrapper. */
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** Disable search header filter if set to false */
|
||||||
|
searchable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Labeled native select styled to match Input. */
|
/** Custom searchable select component using React Portal to prevent container clipping. */
|
||||||
export function Select({ label, hint, error, options = [], required, className, ...rest }: SelectProps) {
|
export function Select({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
options = [],
|
||||||
|
required,
|
||||||
|
className,
|
||||||
|
disabled,
|
||||||
|
placeholder,
|
||||||
|
searchable = true,
|
||||||
|
...rest
|
||||||
|
}: SelectProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const normalizedOptions: SelectOption[] = options.map((o) =>
|
||||||
|
typeof o === 'string' ? { value: o, label: o } : { value: String(o.value ?? ''), label: o.label }
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentValue = String(rest.value ?? '');
|
||||||
|
const selectedOption = normalizedOptions.find((o) => o.value === currentValue);
|
||||||
|
const selectedOptionLabel = selectedOption ? selectedOption.label : '';
|
||||||
|
|
||||||
|
const filteredOptions = normalizedOptions.filter((o) =>
|
||||||
|
o.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatePosition = useCallback(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const dropdownHeight = 260; // Max height approximation
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
const openUpwards = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
|
||||||
|
|
||||||
|
setDropdownStyle({
|
||||||
|
position: 'fixed',
|
||||||
|
left: `${rect.left}px`,
|
||||||
|
width: `${rect.width}px`,
|
||||||
|
zIndex: 999999,
|
||||||
|
...(openUpwards
|
||||||
|
? { bottom: `${window.innerHeight - rect.top + 4}px` }
|
||||||
|
: { top: `${rect.bottom + 4}px` }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
updatePosition();
|
||||||
|
const handleScrollOrResize = () => updatePosition();
|
||||||
|
window.addEventListener('resize', handleScrollOrResize);
|
||||||
|
window.addEventListener('scroll', handleScrollOrResize, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleScrollOrResize);
|
||||||
|
window.removeEventListener('scroll', handleScrollOrResize, true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isOpen, updatePosition]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node;
|
||||||
|
const isOutsideContainer = containerRef.current && !containerRef.current.contains(target);
|
||||||
|
const isOutsideDropdown = dropdownRef.current && !dropdownRef.current.contains(target);
|
||||||
|
|
||||||
|
if (isOutsideContainer && isOutsideDropdown) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (val: string) => {
|
||||||
|
if (rest.onChange) {
|
||||||
|
const syntheticEvent = {
|
||||||
|
target: { value: val, name: rest.name, id: rest.id },
|
||||||
|
currentTarget: { value: val, name: rest.name, id: rest.id },
|
||||||
|
} as unknown as React.ChangeEvent<HTMLSelectElement>;
|
||||||
|
rest.onChange(syntheticEvent);
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label className={cn('flex flex-col gap-1.5 font-sans', className)}>
|
<div ref={containerRef} className={cn('flex flex-col gap-1.5 font-sans relative w-full', className)}>
|
||||||
{label && (
|
{label && (
|
||||||
<span className="text-sm font-bold text-slate-700">
|
<label className="text-sm font-bold text-slate-700">
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="text-ruby-600"> *</span>}
|
{required && <span className="text-ruby-600"> *</span>}
|
||||||
</span>
|
</label>
|
||||||
)}
|
)}
|
||||||
<div className={cn("relative bg-card rounded-md h-[42px] border transition-[border-color,box-shadow] duration-150 focus-ring", error ? "border-ruby-600" : "border-border-default")}>
|
|
||||||
<select
|
{/* Trigger Box */}
|
||||||
required={required}
|
<div
|
||||||
className="w-full h-full border-none outline-none bg-transparent appearance-none pl-3 pr-9 font-sans text-base text-strong cursor-pointer"
|
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||||
{...rest}
|
className={cn(
|
||||||
>
|
"relative bg-card rounded-md h-[42px] border px-3 flex items-center justify-between cursor-pointer transition-all duration-150 select-none",
|
||||||
{options.map((o) => {
|
disabled && "opacity-60 cursor-not-allowed bg-slate-50",
|
||||||
const val = typeof o === 'string' ? o : o.value;
|
isOpen ? "border-blue-600 ring-2 ring-blue-600/20" : error ? "border-ruby-600" : "border-border-default"
|
||||||
const lab = typeof o === 'string' ? o : o.label;
|
)}
|
||||||
return (
|
>
|
||||||
<option key={val} value={val}>
|
<span className={cn("text-base truncate pr-2", selectedOptionLabel ? "text-strong" : "text-faint")}>
|
||||||
{lab}
|
{selectedOptionLabel || placeholder || "Select..."}
|
||||||
</option>
|
</span>
|
||||||
);
|
<ChevronDown size={15} className={cn("transition-transform duration-150 text-faint shrink-0 ml-1", isOpen && "rotate-180")} />
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
<ChevronDown size={15} className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-faint" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Portaled Dropdown Menu Overlay */}
|
||||||
|
{isOpen && !disabled && createPortal(
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
style={dropdownStyle}
|
||||||
|
className="bg-card border border-border-default rounded-md shadow-2xl overflow-hidden flex flex-col max-h-64 animate-in fade-in-50 duration-100 z-[999999]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{searchable && (
|
||||||
|
<div className="p-2 border-b border-border-subtle bg-slate-50 flex items-center gap-2 shrink-0">
|
||||||
|
<Search size={14} className="text-muted shrink-0 ml-1" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search options..."
|
||||||
|
className="w-full text-xs bg-transparent border-none outline-none text-strong placeholder:text-muted"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="text-muted hover:text-strong p-0.5 rounded cursor-pointer"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-y-auto flex-1 py-1">
|
||||||
|
{filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((opt) => {
|
||||||
|
const isSelected = opt.value === currentValue;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => handleSelect(opt.value)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 text-sm flex items-center justify-between cursor-pointer transition-colors",
|
||||||
|
isSelected ? "bg-blue-50/50 text-blue-700 font-semibold" : "hover:bg-black/5 text-strong"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{opt.label}</span>
|
||||||
|
{isSelected && <Check size={14} className="text-blue-600 shrink-0 ml-2" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="px-3 py-3 text-xs text-muted text-center italic">
|
||||||
|
No matching options
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hidden Native Select for Required/Form Validation */}
|
||||||
|
<select
|
||||||
|
required={required}
|
||||||
|
value={currentValue}
|
||||||
|
disabled={disabled}
|
||||||
|
className="sr-only"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
onChange={() => {}}
|
||||||
|
>
|
||||||
|
{normalizedOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
{(hint || error) && (
|
{(hint || error) && (
|
||||||
<span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{error || hint}</span>
|
<span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{error || hint}</span>
|
||||||
)}
|
)}
|
||||||
</label>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user