WaypointSearch.res.mjs

  1// Generated by ReScript, PLEASE EDIT WITH CARE
  2
  3import * as Log from "../utils/Log.res.mjs";
  4import * as State from "../State.res.mjs";
  5import * as Waldo from "../ffi/Waldo.res.mjs";
  6import * as Preact from "../ffi/Preact.res.mjs";
  7import * as Debounce from "../utils/Debounce.res.mjs";
  8import * as PreactDOM from "../ffi/PreactDOM.res.mjs";
  9import * as Core__Option from "@rescript/core/lib/es6/src/Core__Option.res.mjs";
 10import * as Hooks from "preact/hooks";
 11import * as LucidePreact from "lucide-preact";
 12import * as Signals from "@preact/signals";
 13import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
 14import * as JsxRuntime from "preact/jsx-runtime";
 15import * as SignalsReact from "@preact/signals-react";
 16
 17let isOpen = Signals.signal(false);
 18
 19function WaypointSearch(props) {
 20  let onClose = props.onClose;
 21  let onSelect = props.onSelect;
 22  let className = props.className;
 23  let query = SignalsReact.useSignal("");
 24  let status = SignalsReact.useSignal("Idle");
 25  let debounceHandle = Hooks.useRef(undefined);
 26  let inputRef = Hooks.useRef(null);
 27  let activeIndex = SignalsReact.useSignal(-1);
 28  let listboxRef = Hooks.useRef(null);
 29  Hooks.useEffect(() => {
 30    let el = inputRef.current;
 31    if (!(el == null)) {
 32      el.focus();
 33    }
 34  }, []);
 35  Hooks.useEffect(() => (() => {
 36    let handle = debounceHandle.current;
 37    if (handle !== undefined) {
 38      return Debounce.cancel(handle);
 39    }
 40  }), []);
 41  Hooks.useEffect(() => {
 42    isOpen.value = true;
 43    return () => {
 44      isOpen.value = false;
 45    };
 46  }, []);
 47  Signals.effect(() => {
 48    if (!isOpen.value) {
 49      return;
 50    }
 51    let handleKeyDown = evt => {
 52      if (evt.key === "Escape") {
 53        return onClose();
 54      }
 55    };
 56    let documentElement = document;
 57    let cleanup = PreactDOM.addEventListener(documentElement, "keydown", handleKeyDown);
 58    return () => cleanup();
 59  });
 60  let performSearch = async searchText => {
 61    if (searchText.length < 3) {
 62      status.value = "Idle";
 63      return;
 64    }
 65    status.value = "Loading";
 66    let match = State.center.value;
 67    let lat = match[1];
 68    let lon = match[0];
 69    Log.debug("WaypointSearch: Current map center", {
 70      lon: lon.toString(),
 71      lat: lat.toString()
 72    });
 73    let biasPoint = [
 74      lon,
 75      lat
 76    ];
 77    let params = {
 78      query: searchText,
 79      biasPoint: biasPoint
 80    };
 81    let results = await Waldo.Forward.geocode(params);
 82    switch (results.TAG) {
 83      case "Success" :
 84        let results$1 = results._0;
 85        if (results$1.length === 0) {
 86          status.value = "Empty";
 87        } else {
 88          status.value = {
 89            TAG: "Results",
 90            _0: results$1
 91          };
 92        }
 93        activeIndex.value = -1;
 94        return;
 95      case "Error" :
 96      case "NetworkError" :
 97        break;
 98    }
 99    status.value = {
100      TAG: "Error",
101      _0: results._0
102    };
103    activeIndex.value = -1;
104  };
105  let handleInputChange = evt => {
106    let value = evt.target.value;
107    query.value = value;
108    let handle = debounceHandle.current;
109    if (handle !== undefined) {
110      Debounce.cancel(handle);
111    }
112    if (value.length >= 3) {
113      let newHandle = Debounce.make(() => {
114        performSearch(value);
115      }, 300);
116      debounceHandle.current = newHandle;
117      return;
118    }
119    status.value = "Idle";
120  };
121  let handleCloseClick = _evt => {
122    Log.debug("WaypointSearch: Close button clicked, calling onClose", undefined);
123    onClose();
124  };
125  let handleResultClick = result => {
126    Log.debug("WaypointSearch: Result clicked", {
127      gid: result.gid.toString()
128    });
129    onSelect(result);
130  };
131  let baseStyles = "absolute inset-0 z-10 bg-paper w-full h-full overflow-y-auto";
132  let containerClass = className !== undefined ? baseStyles + " " + className : baseStyles;
133  let results = status.value;
134  let activeDescendantId;
135  if (typeof results !== "object" || results.TAG !== "Results") {
136    activeDescendantId = undefined;
137  } else {
138    let idx = activeIndex.value;
139    activeDescendantId = idx >= 0 && idx < results._0.length ? `search-option-` + idx.toString() : undefined;
140  }
141  let match = status.value;
142  let isLoading;
143  isLoading = typeof match !== "object" ? match === "Loading" : false;
144  let match$1 = status.value;
145  let tmp;
146  tmp = typeof match$1 !== "object" ? false : match$1.TAG === "Results";
147  let msg = status.value;
148  let tmp$1;
149  if (typeof msg !== "object") {
150    switch (msg) {
151      case "Idle" :
152        tmp$1 = "";
153        break;
154      case "Loading" :
155        tmp$1 = "Searching...";
156        break;
157      case "Empty" :
158        tmp$1 = "No results found";
159        break;
160    }
161  } else {
162    tmp$1 = msg.TAG === "Results" ? msg._0.length.toString() + ` results found` : `Error: ` + msg._0;
163  }
164  let results$1 = status.value;
165  let tmp$2;
166  if (typeof results$1 !== "object") {
167    switch (results$1) {
168      case "Idle" :
169        tmp$2 = JsxRuntime.jsx("div", {
170          children: "Type at least 3 characters to search for a place",
171          className: "p-4 text-gray-500"
172        });
173        break;
174      case "Loading" :
175        tmp$2 = JsxRuntime.jsx("div", {
176          children: "Searching...",
177          className: "p-4 text-gray-600",
178          role: "status"
179        });
180        break;
181      case "Empty" :
182        tmp$2 = JsxRuntime.jsx("div", {
183          children: "No results found",
184          className: "p-4 text-gray-600",
185          role: "status"
186        });
187        break;
188    }
189  } else if (results$1.TAG === "Results") {
190    let results$2 = results$1._0;
191    tmp$2 = results$2.length > 0 ? JsxRuntime.jsx("div", {
192        children: results$2.map((result, index) => {
193          let isActive = activeIndex.value === index;
194          let gidString = result.gid.toString();
195          let locationDisplay = result.region.join(", ") + ", " + result.country;
196          return JsxRuntime.jsxs("div", {
197            children: [
198              JsxRuntime.jsx("div", {
199                children: result.name,
200                className: "font-medium"
201              }),
202              JsxRuntime.jsxs("div", {
203                children: [
204                  JsxRuntime.jsx("div", {
205                    children: locationDisplay,
206                    className: "grow"
207                  }),
208                  JsxRuntime.jsx("div", {
209                    children: JsxRuntime.jsx(LucidePreact.ArrowUp, {
210                      size: 16,
211                      strokeWidth: 2,
212                      className: "w-4 h-4 text-[var(--color-blue)]"
213                    }),
214                    className: "w-fit place-content-center",
215                    style: {
216                      transform: "rotate(" + result.bearing.toFixed(0) + "deg)"
217                    }
218                  }),
219                  JsxRuntime.jsx("div", {
220                    children: (result.distance / 1000.0).toFixed(0) + "km",
221                    className: "w-fit"
222                  })
223                ],
224                className: "flex flex-row text-sm text-gray-600"
225              })
226            ],
227            "aria-selected": isActive,
228            className: Preact.cls("px-3 py-2 cursor-pointer border-b border-bg-2", {
229              "bg-blue-100": isActive
230            }),
231            id: `search-option-` + index.toString(),
232            role: "option",
233            tabIndex: -1,
234            onClick: param => handleResultClick(result),
235            onMouseEnter: param => {
236              activeIndex.value = index;
237            }
238          }, gidString);
239        }),
240        ref: Primitive_option.some(listboxRef),
241        "aria-label": "Search results",
242        className: "h-full overflow-y-auto focus:outline-none focus:ring-2 focus:ring-blue-custom",
243        id: "search-results",
244        role: "listbox",
245        tabIndex: 0,
246        onKeyDown: evt => {
247          let key = evt.key;
248          let maxIndex = results$2.length - 1 | 0;
249          let currentIndex = activeIndex.value;
250          if (key === "ArrowDown") {
251            evt.preventDefault();
252            let nextIndex = currentIndex >= maxIndex ? 0 : currentIndex + 1 | 0;
253            activeIndex.value = nextIndex;
254            return;
255          }
256          if (key === "ArrowUp") {
257            evt.preventDefault();
258            let prevIndex = currentIndex <= 0 ? maxIndex : currentIndex - 1 | 0;
259            activeIndex.value = prevIndex;
260            return;
261          }
262          if (!(key === "Enter" && currentIndex >= 0)) {
263            return;
264          }
265          evt.preventDefault();
266          let result = results$2[currentIndex];
267          if (result !== undefined) {
268            return handleResultClick(result);
269          }
270        }
271      }) : JsxRuntime.jsx("div", {
272        children: "No results found",
273        className: "p-4 text-gray-600",
274        role: "status"
275      });
276  } else {
277    tmp$2 = JsxRuntime.jsx("div", {
278      children: `Error: ` + results$1._0,
279      className: "p-4 text-red-600",
280      role: "alert"
281    });
282  }
283  return JsxRuntime.jsxs("div", {
284    children: [
285      JsxRuntime.jsxs("div", {
286        children: [
287          JsxRuntime.jsxs(JsxRuntime.Fragment, {
288            children: [
289              JsxRuntime.jsx("input", {
290                ref: Primitive_option.some(inputRef),
291                "aria-autocomplete": "list",
292                "aria-expanded": tmp,
293                "aria-activedescendant": Core__Option.getOr(activeDescendantId, ""),
294                "aria-controls": "search-results",
295                className: "flex-1 px-3 py-2 border rounded bg-white",
296                placeholder: "Start typing to search for a place...",
297                type: "text",
298                value: query.value,
299                onKeyDown: evt => {
300                  let key = evt.key;
301                  let shiftKey = evt.shiftKey;
302                  let r = status.value;
303                  let results;
304                  if (typeof r !== "object") {
305                    results = [];
306                  } else if (r.TAG === "Results") {
307                    let r$1 = r._0;
308                    results = r$1.length > 0 ? r$1 : [];
309                  } else {
310                    results = [];
311                  }
312                  let maxIndex = results.length - 1 | 0;
313                  let currentIndex = activeIndex.value;
314                  if (key === "ArrowDown") {
315                    if (results.length <= 0) {
316                      return;
317                    }
318                    evt.preventDefault();
319                    if (currentIndex === -1) {
320                      activeIndex.value = 0;
321                    } else {
322                      let nextIndex = currentIndex >= maxIndex ? 0 : currentIndex + 1 | 0;
323                      activeIndex.value = nextIndex;
324                    }
325                    let el = listboxRef.current;
326                    if (!(el == null)) {
327                      el.focus();
328                      return;
329                    } else {
330                      return;
331                    }
332                  }
333                  if (key === "ArrowUp") {
334                    if (results.length <= 0) {
335                      return;
336                    }
337                    evt.preventDefault();
338                    if (currentIndex === -1) {
339                      activeIndex.value = maxIndex;
340                    } else {
341                      let prevIndex = currentIndex <= 0 ? maxIndex : currentIndex - 1 | 0;
342                      activeIndex.value = prevIndex;
343                    }
344                    let el$1 = listboxRef.current;
345                    if (!(el$1 == null)) {
346                      el$1.focus();
347                      return;
348                    } else {
349                      return;
350                    }
351                  }
352                  if (key !== "Tab" || shiftKey) {
353                    return;
354                  }
355                  if (results.length <= 0) {
356                    return;
357                  }
358                  evt.preventDefault();
359                  if (currentIndex === -1) {
360                    activeIndex.value = 0;
361                  }
362                  let el$2 = listboxRef.current;
363                  if (!(el$2 == null)) {
364                    el$2.focus();
365                    return;
366                  }
367                },
368                onChange: handleInputChange
369              }),
370              isLoading ? JsxRuntime.jsx("div", {
371                  "aria-hidden": true,
372                  className: "w-5 h-5 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin"
373                }) : JsxRuntime.jsx("div", {
374                  "aria-hidden": true,
375                  className: "w-5 h-5"
376                })
377            ]
378          }),
379          JsxRuntime.jsx("button", {
380            children: "Close",
381            "aria-label": "Close search",
382            className: "px-3 py-2 border rounded hover:bg-gray-100",
383            onClick: handleCloseClick
384          })
385        ],
386        className: "flex items-center gap-2 p-2 border-blue-custom"
387      }),
388      JsxRuntime.jsx("div", {
389        children: tmp$1,
390        "aria-atomic": true,
391        "aria-live": "polite",
392        className: "sr-only"
393      }),
394      tmp$2
395    ],
396    "aria-label": "Search for a place",
397    className: containerClass,
398    role: "dialog"
399  });
400}
401
402let make = WaypointSearch;
403
404export {
405  isOpen,
406  make,
407}
408/* isOpen Not a pure module */