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}