WaypointSearch.res
1// client/src/ui/WaypointSearch.res
2// Preact component for searching places using Waldo geocoding API
3// with debouncing, ARIA accessibility, and focus management
4
5type status =
6 | Idle
7 | Loading
8 | Results(array<Waldo.result>)
9 | Empty
10 | Error(string)
11
12// Module-level signal to track if search is open - used for document-level key handlers
13let isOpen = Preact.Signal.make(false)
14
15@jsx.component
16let make = (
17 ~className: option<string>=?,
18 ~onSelect: Waldo.result => unit,
19 ~onClose: unit => unit,
20) => {
21 let query = Preact.Signal.useSignal("")
22 let status = Preact.Signal.useSignal(Idle)
23 let debounceHandle = Preact.useRef(None)
24 let inputRef = Preact.useRef(Nullable.null)
25 let activeIndex = Preact.Signal.useSignal(-1) // -1 means no option active, 0+ means option index
26 let listboxRef = Preact.useRef(Nullable.null) // Ref to listbox container
27
28 // Focus input on mount
29 Preact.useEffect0(() => {
30 switch inputRef.current->Nullable.toOption {
31 | Some(el) => PreactDOM.focus(el, ())
32 | None => ()
33 }
34 None
35 })
36
37 // Cleanup debounce on unmount
38 Preact.useEffect0(() => {
39 Some(
40 () => {
41 switch debounceHandle.current {
42 | Some(handle) => handle->Debounce.cancel
43 | None => ()
44 }
45 },
46 )
47 })
48
49 // Set isOpen signal when component mounts/unmounts
50 Preact.useEffect0(() => {
51 isOpen->Preact.Signal.set(true)
52 Some(
53 () => {
54 isOpen->Preact.Signal.set(false)
55 },
56 )
57 })
58
59 // Document-level Escape key handler - works anywhere in the component
60 Preact.Signal.effectWithCleanup(() => {
61 switch isOpen->Preact.Signal.get {
62 | true => {
63 let handleKeyDown = (evt: PreactDOM.KeyboardEvent.t) => {
64 if evt->PreactDOM.KeyboardEvent.key == "Escape" {
65 onClose()
66 }
67 }
68
69 let documentElement = Obj.magic(Doc.document)
70 let cleanup = PreactDOM.addEventListener(documentElement, "keydown", handleKeyDown)
71 Some(() => cleanup())
72 }
73 | false => None
74 }
75 })
76
77 // Debounced search function using Waldo API
78 let performSearch = async (searchText: string) => {
79 if searchText->String.length < 3 {
80 status->Preact.Signal.set(Idle)
81 } else {
82 status->Preact.Signal.set(Loading)
83
84 // Get bias point from current map center
85 let (lon, lat) = State.center->Preact.Signal.get
86 Log.debug(
87 "WaypointSearch: Current map center",
88 ~kv={
89 "lon": Float.toString(lon),
90 "lat": Float.toString(lat),
91 },
92 )
93 let biasPoint = Some((lon, lat))
94
95 let params: Waldo.Forward.params = {
96 query: searchText,
97 ?biasPoint,
98 }
99
100 switch await Waldo.Forward.geocode(~params) {
101 | Success(results) => {
102 if results->Array.length == 0 {
103 status->Preact.Signal.set(Empty)
104 } else {
105 status->Preact.Signal.set(Results(results))
106 }
107 activeIndex->Preact.Signal.set(-1)
108 }
109 | Error(msg) => {
110 status->Preact.Signal.set(Error(msg))
111 activeIndex->Preact.Signal.set(-1)
112 }
113 | NetworkError(msg) => {
114 status->Preact.Signal.set(Error(msg))
115 activeIndex->Preact.Signal.set(-1)
116 }
117 }
118 }
119 }
120
121 let handleInputChange = (evt: JsxEvent.Form.t) => {
122 let value = evt->PreactDOM.formTarget->PreactDOM.InputElement.value
123 query->Preact.Signal.set(value)
124
125 // Cancel previous debounce
126 switch debounceHandle.current {
127 | Some(handle) => handle->Debounce.cancel
128 | None => ()
129 }
130
131 // Schedule new search if >= 3 chars
132 if value->String.length >= 3 {
133 let newHandle = Debounce.make(() => {
134 let _ = performSearch(value)
135 }, 300)
136 debounceHandle.current = Some(newHandle)
137 } else {
138 status->Preact.Signal.set(Idle)
139 }
140 }
141
142 let handleCloseClick = _evt => {
143 Log.debug("WaypointSearch: Close button clicked, calling onClose")
144 onClose()
145 }
146
147 let handleResultClick = (result: Waldo.result) => {
148 Log.debug("WaypointSearch: Result clicked", ~kv={"gid": Int.toString(result.gid)})
149 onSelect(result)
150 }
151
152 let baseStyles = "absolute inset-0 z-10 bg-paper w-full h-full overflow-y-auto"
153 let containerClass = switch className {
154 | Some(cls) => baseStyles ++ " " ++ cls
155 | None => baseStyles
156 }
157
158 <div className={containerClass} role="dialog" ariaLabel="Search for a place">
159 <div className="flex items-center gap-2 p-2 border-blue-custom">
160 {
161 let activeDescendantId = switch status->Preact.Signal.get {
162 | Results(results) =>
163 let idx = activeIndex->Preact.Signal.get
164 if idx >= 0 && idx < results->Array.length {
165 Some(`search-option-${idx->Int.toString}`)
166 } else {
167 None
168 }
169 | _ => None
170 }
171
172 // Visual loading indicator next to input
173 let isLoading = switch status->Preact.Signal.get {
174 | Loading => true
175 | _ => false
176 }
177
178 <>
179 <input
180 ref={PreactDOM.Ref.domRef(inputRef)}
181 type_="text"
182 value={query->Preact.Signal.get}
183 onChange={handleInputChange}
184 onKeyDown={evt => {
185 let key = evt->JsxEvent.Keyboard.key
186 let shiftKey = Obj.magic(evt)["shiftKey"]
187 let results = switch status->Preact.Signal.get {
188 | Results(r) if r->Array.length > 0 => r
189 | _ => []
190 }
191 let maxIndex = results->Array.length - 1
192 let currentIndex = activeIndex->Preact.Signal.get
193
194 // Arrow keys from input focus listbox and handle navigation
195 if key == "ArrowDown" {
196 if results->Array.length > 0 {
197 evt->JsxEvent.Keyboard.preventDefault
198 if currentIndex == -1 {
199 // No selection yet - start at first item
200 activeIndex->Preact.Signal.set(0)
201 } else {
202 // Already have selection - move to next item
203 let nextIndex = if currentIndex >= maxIndex {
204 0
205 } else {
206 currentIndex + 1
207 }
208 activeIndex->Preact.Signal.set(nextIndex)
209 }
210 // Focus the listbox container
211 switch listboxRef.current->Nullable.toOption {
212 | Some(el) => PreactDOM.focus(el, ())
213 | None => ()
214 }
215 }
216 } else if key == "ArrowUp" {
217 if results->Array.length > 0 {
218 evt->JsxEvent.Keyboard.preventDefault
219 if currentIndex == -1 {
220 // No selection yet - start at last item
221 activeIndex->Preact.Signal.set(maxIndex)
222 } else {
223 // Already have selection - move to previous item
224 let prevIndex = if currentIndex <= 0 {
225 maxIndex
226 } else {
227 currentIndex - 1
228 }
229 activeIndex->Preact.Signal.set(prevIndex)
230 }
231 // Focus the listbox container
232 switch listboxRef.current->Nullable.toOption {
233 | Some(el) => PreactDOM.focus(el, ())
234 | None => ()
235 }
236 }
237 } else if key == "Tab" && !shiftKey {
238 // Tab focuses listbox without changing selection (if any)
239 if results->Array.length > 0 {
240 evt->JsxEvent.Keyboard.preventDefault
241 if currentIndex == -1 {
242 activeIndex->Preact.Signal.set(0)
243 }
244 switch listboxRef.current->Nullable.toOption {
245 | Some(el) => PreactDOM.focus(el, ())
246 | None => ()
247 }
248 }
249 }
250 // Shift+Tab naturally goes to previous focusable element (close button)
251 }}
252 placeholder="Start typing to search for a place..."
253 className="flex-1 px-3 py-2 border rounded bg-white"
254 ariaAutocomplete=#list
255 ariaControls="search-results"
256 ariaExpanded={switch status->Preact.Signal.get {
257 | Results(_) => true
258 | _ => false
259 }}
260 ariaActivedescendant={activeDescendantId->Option.getOr("")}
261 />
262 {isLoading
263 ? <div
264 className="w-5 h-5 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin"
265 ariaHidden=true
266 />
267 : <div className="w-5 h-5" ariaHidden=true />}
268 </>
269 }
270 <button
271 onClick={handleCloseClick}
272 ariaLabel="Close search"
273 className="px-3 py-2 border rounded hover:bg-gray-100"
274 >
275 {"Close"->Preact.string}
276 </button>
277 </div>
278
279 <div className="sr-only" ariaLive=#polite ariaAtomic=true>
280 {switch status->Preact.Signal.get {
281 | Idle => ""
282 | Loading => "Searching..."
283 | Empty => "No results found"
284 | Error(msg) => `Error: ${msg}`
285 | Results(results) => `${results->Array.length->Int.toString} results found`
286 }->Preact.string}
287 </div>
288
289 {switch status->Preact.Signal.get {
290 | Results(results) if results->Array.length > 0 =>
291 <div
292 id="search-results"
293 ref={PreactDOM.Ref.domRef(listboxRef)}
294 role="listbox"
295 ariaLabel="Search results"
296 tabIndex={0}
297 className="h-full overflow-y-auto focus:outline-none focus:ring-2 focus:ring-blue-custom"
298 onKeyDown={evt => {
299 let key = evt->JsxEvent.Keyboard.key
300 let maxIndex = results->Array.length - 1
301 let currentIndex = activeIndex->Preact.Signal.get
302
303 if key == "ArrowDown" {
304 evt->JsxEvent.Keyboard.preventDefault
305 let nextIndex = if currentIndex >= maxIndex {
306 0
307 } else {
308 currentIndex + 1
309 }
310 activeIndex->Preact.Signal.set(nextIndex)
311 } else if key == "ArrowUp" {
312 evt->JsxEvent.Keyboard.preventDefault
313 let prevIndex = if currentIndex <= 0 {
314 maxIndex
315 } else {
316 currentIndex - 1
317 }
318 activeIndex->Preact.Signal.set(prevIndex)
319 } else if key == "Enter" && currentIndex >= 0 {
320 evt->JsxEvent.Keyboard.preventDefault
321 switch results->Array.get(currentIndex) {
322 | Some(result) => handleResultClick(result)
323 | None => ()
324 }
325 }
326 }}
327 >
328 {results
329 ->Array.mapWithIndex((result, index) => {
330 let isActive = activeIndex->Preact.Signal.get == index
331 let gidString = Int.toString(result.gid)
332 let locationDisplay = result.region->Array.join(", ") ++ ", " ++ result.country
333 <div
334 key={gidString}
335 id={`search-option-${index->Int.toString}`}
336 role="option"
337 tabIndex={-1}
338 ariaSelected={isActive}
339 className={Preact.cls(
340 "px-3 py-2 cursor-pointer border-b border-bg-2",
341 dict{"bg-blue-100": isActive},
342 )}
343 onClick={_ => handleResultClick(result)}
344 onMouseEnter={_ => activeIndex->Preact.Signal.set(index)}
345 >
346 <div className="font-medium"> {result.name->Preact.string} </div>
347 <div className="flex flex-row text-sm text-gray-600">
348 <div className="grow"> {locationDisplay->Preact.string} </div>
349 <div
350 className="w-fit place-content-center"
351 style={{transform: "rotate(" ++ result.bearing->Float.toFixed(~digits=0) ++ "deg)"}}
352 >
353 <Icons.ArrowUp
354 className="w-4 h-4 text-[var(--color-blue)]" strokeWidth={2} size={16}
355 />
356 </div>
357 <div className="w-fit">
358 {((result.distance / 1000.0)->Float.toFixed(~digits=0) ++ "km")->Preact.string}
359 </div>
360 </div>
361 </div>
362 })
363 ->Preact.array}
364 </div>
365 | Results(_) =>
366 // Empty results array - treat same as Empty
367 <div className="p-4 text-gray-600" role="status"> {"No results found"->Preact.string} </div>
368 | Empty =>
369 <div className="p-4 text-gray-600" role="status"> {"No results found"->Preact.string} </div>
370 | Error(msg) =>
371 <div className="p-4 text-red-600" role="alert"> {`Error: ${msg}`->Preact.string} </div>
372 | Loading =>
373 <div className="p-4 text-gray-600" role="status"> {"Searching..."->Preact.string} </div>
374 | Idle =>
375 <div className="p-4 text-gray-500">
376 {"Type at least 3 characters to search for a place"->Preact.string}
377 </div>
378 }}
379 </div>
380}