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 */