@mailwoman/react 10.0.0 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (389) hide show
  1. package/README.md +18 -16
  2. package/fonts.css +97 -22
  3. package/lib/common/About.tsx +10 -10
  4. package/lib/common/ClientOnly.tsx +2 -14
  5. package/lib/common/CopyButton.tsx +9 -3
  6. package/lib/common/LoadingIndicator.tsx +13 -5
  7. package/lib/common/PresetChips.tsx +11 -8
  8. package/lib/common/confidence-tiers.ts +8 -7
  9. package/lib/common/cx.ts +3 -1
  10. package/lib/common/text-tokens.ts +10 -13
  11. package/lib/common/useBuildInfo.ts +54 -0
  12. package/lib/common/useClipboard.ts +2 -11
  13. package/lib/common.ts +22 -0
  14. package/lib/index.ts +27 -18
  15. package/lib/map/AppIdentity.tsx +89 -0
  16. package/lib/map/CompareToggle.tsx +8 -31
  17. package/lib/map/DebugInfo.tsx +75 -0
  18. package/lib/map/Geocoder.tsx +65 -50
  19. package/lib/map/GeocoderControls.tsx +425 -141
  20. package/lib/map/GraticuleLayer.tsx +56 -0
  21. package/lib/map/MapCanvas.tsx +26 -13
  22. package/lib/map/MapChipRow.tsx +4 -8
  23. package/lib/map/MapCompass.tsx +18 -25
  24. package/lib/map/MapControlStack.tsx +11 -10
  25. package/lib/map/MapFooter.tsx +5 -17
  26. package/lib/map/MapProgressBar.tsx +10 -13
  27. package/lib/map/MapSearchBar.tsx +31 -17
  28. package/lib/map/MapSheet.tsx +24 -15
  29. package/lib/map/OverlayLayers.tsx +14 -3
  30. package/lib/map/PlaceAutocomplete.tsx +12 -21
  31. package/lib/map/PlaceMarker.tsx +10 -4
  32. package/lib/map/ResolvedPlaceLayers.tsx +14 -15
  33. package/lib/map/ResultCamera.tsx +10 -31
  34. package/lib/map/ResultOverlay.tsx +14 -16
  35. package/lib/map/ResultPanel.tsx +1 -20
  36. package/lib/map/VersionPicker.tsx +10 -19
  37. package/lib/map/fake-runtime.ts +46 -19
  38. package/lib/map/geometry.ts +14 -47
  39. package/lib/map/graticule.ts +80 -0
  40. package/lib/map/map-debug.ts +4 -2
  41. package/lib/map/place-render.ts +30 -62
  42. package/lib/map/types.ts +57 -116
  43. package/lib/map/useCompareState.ts +5 -4
  44. package/lib/map/useGeocode.ts +7 -5
  45. package/lib/map/useMapBearing.ts +13 -13
  46. package/lib/map/useMapLabelPick.ts +21 -30
  47. package/lib/map/useMapPlaceRender.ts +3 -7
  48. package/lib/map/usePlaceAutocomplete.ts +44 -50
  49. package/lib/map.ts +92 -0
  50. package/lib/pipeline/CandidatePicker.tsx +13 -5
  51. package/lib/pipeline/ConfidenceCell.tsx +2 -2
  52. package/lib/pipeline/FailureDiagnostic.tsx +8 -2
  53. package/lib/pipeline/PipelineExplorer.tsx +6 -3
  54. package/lib/pipeline/QueryForm.tsx +5 -2
  55. package/lib/pipeline/ResolvedPlace.tsx +2 -2
  56. package/lib/pipeline/SpanHighlight.tsx +7 -5
  57. package/lib/pipeline/TimingPanel.tsx +1 -1
  58. package/lib/pipeline/TreeView.tsx +2 -2
  59. package/lib/pipeline/copy.ts +22 -25
  60. package/lib/pipeline/presets.ts +15 -13
  61. package/lib/pipeline/types.ts +20 -17
  62. package/lib/pipeline/useParsePipeline.ts +16 -13
  63. package/lib/pipeline.ts +36 -0
  64. package/lib/poi/AbstainPanel.tsx +1 -1
  65. package/lib/poi/LiveResultsBlock.tsx +2 -1
  66. package/lib/poi/POIExplorer.tsx +29 -17
  67. package/lib/poi/QueryInput.tsx +1 -1
  68. package/lib/poi/SubjectPanel.tsx +4 -3
  69. package/lib/poi/runtime.ts +14 -16
  70. package/lib/poi/types.ts +58 -47
  71. package/lib/poi/usePOISearch.ts +91 -82
  72. package/lib/poi.ts +40 -0
  73. package/lib/runtime/useReleaseRuntime.ts +122 -90
  74. package/lib/search/SearchModal.tsx +478 -0
  75. package/lib/search/search.css +282 -0
  76. package/lib/search/types.ts +68 -0
  77. package/lib/search.ts +11 -0
  78. package/out/common/About.d.ts +6 -6
  79. package/out/common/About.d.ts.map +1 -1
  80. package/out/common/About.js +1 -1
  81. package/out/common/About.js.map +1 -1
  82. package/out/common/ClientOnly.d.ts +2 -11
  83. package/out/common/ClientOnly.d.ts.map +1 -1
  84. package/out/common/ClientOnly.js +1 -13
  85. package/out/common/ClientOnly.js.map +1 -1
  86. package/out/common/CopyButton.d.ts +9 -3
  87. package/out/common/CopyButton.d.ts.map +1 -1
  88. package/out/common/CopyButton.js.map +1 -1
  89. package/out/common/LoadingIndicator.d.ts +13 -5
  90. package/out/common/LoadingIndicator.d.ts.map +1 -1
  91. package/out/common/LoadingIndicator.js.map +1 -1
  92. package/out/common/PresetChips.d.ts +11 -8
  93. package/out/common/PresetChips.d.ts.map +1 -1
  94. package/out/common/PresetChips.js.map +1 -1
  95. package/out/common/confidence-tiers.d.ts +8 -7
  96. package/out/common/confidence-tiers.d.ts.map +1 -1
  97. package/out/common/confidence-tiers.js +7 -6
  98. package/out/common/confidence-tiers.js.map +1 -1
  99. package/out/common/cx.d.ts +3 -1
  100. package/out/common/cx.d.ts.map +1 -1
  101. package/out/common/cx.js +3 -1
  102. package/out/common/cx.js.map +1 -1
  103. package/out/common/text-tokens.d.ts +6 -11
  104. package/out/common/text-tokens.d.ts.map +1 -1
  105. package/out/common/text-tokens.js +6 -3
  106. package/out/common/text-tokens.js.map +1 -1
  107. package/out/common/useBuildInfo.d.ts +27 -0
  108. package/out/common/useBuildInfo.d.ts.map +1 -0
  109. package/out/common/useBuildInfo.js +32 -0
  110. package/out/common/useBuildInfo.js.map +1 -0
  111. package/out/common/useClipboard.d.ts +1 -3
  112. package/out/common/useClipboard.d.ts.map +1 -1
  113. package/out/common/useClipboard.js +2 -11
  114. package/out/common/useClipboard.js.map +1 -1
  115. package/out/common.d.ts +22 -0
  116. package/out/common.d.ts.map +1 -0
  117. package/out/common.js +15 -0
  118. package/out/common.js.map +1 -0
  119. package/out/index.d.ts +26 -16
  120. package/out/index.d.ts.map +1 -1
  121. package/out/index.js +21 -15
  122. package/out/index.js.map +1 -1
  123. package/out/map/AppIdentity.d.ts +46 -0
  124. package/out/map/AppIdentity.d.ts.map +1 -0
  125. package/out/map/AppIdentity.js +16 -0
  126. package/out/map/AppIdentity.js.map +1 -0
  127. package/out/map/CompareToggle.d.ts +8 -31
  128. package/out/map/CompareToggle.d.ts.map +1 -1
  129. package/out/map/CompareToggle.js +4 -1
  130. package/out/map/CompareToggle.js.map +1 -1
  131. package/out/map/DebugInfo.d.ts +27 -0
  132. package/out/map/DebugInfo.d.ts.map +1 -0
  133. package/out/map/DebugInfo.js +36 -0
  134. package/out/map/DebugInfo.js.map +1 -0
  135. package/out/map/Geocoder.d.ts +41 -30
  136. package/out/map/Geocoder.d.ts.map +1 -1
  137. package/out/map/Geocoder.js +23 -37
  138. package/out/map/Geocoder.js.map +1 -1
  139. package/out/map/GeocoderControls.d.ts +15 -44
  140. package/out/map/GeocoderControls.d.ts.map +1 -1
  141. package/out/map/GeocoderControls.js +250 -39
  142. package/out/map/GeocoderControls.js.map +1 -1
  143. package/out/map/GraticuleLayer.d.ts +30 -0
  144. package/out/map/GraticuleLayer.d.ts.map +1 -0
  145. package/out/map/GraticuleLayer.js +25 -0
  146. package/out/map/GraticuleLayer.js.map +1 -0
  147. package/out/map/MapCanvas.d.ts +26 -13
  148. package/out/map/MapCanvas.d.ts.map +1 -1
  149. package/out/map/MapCanvas.js +5 -2
  150. package/out/map/MapCanvas.js.map +1 -1
  151. package/out/map/MapChipRow.d.ts +4 -8
  152. package/out/map/MapChipRow.d.ts.map +1 -1
  153. package/out/map/MapChipRow.js.map +1 -1
  154. package/out/map/MapCompass.d.ts +14 -21
  155. package/out/map/MapCompass.d.ts.map +1 -1
  156. package/out/map/MapCompass.js +5 -1
  157. package/out/map/MapCompass.js.map +1 -1
  158. package/out/map/MapControlStack.d.ts +11 -10
  159. package/out/map/MapControlStack.d.ts.map +1 -1
  160. package/out/map/MapControlStack.js +4 -1
  161. package/out/map/MapControlStack.js.map +1 -1
  162. package/out/map/MapFooter.d.ts +5 -16
  163. package/out/map/MapFooter.d.ts.map +1 -1
  164. package/out/map/MapFooter.js +0 -14
  165. package/out/map/MapFooter.js.map +1 -1
  166. package/out/map/MapProgressBar.d.ts +10 -13
  167. package/out/map/MapProgressBar.d.ts.map +1 -1
  168. package/out/map/MapProgressBar.js.map +1 -1
  169. package/out/map/MapSearchBar.d.ts +16 -17
  170. package/out/map/MapSearchBar.d.ts.map +1 -1
  171. package/out/map/MapSearchBar.js +6 -0
  172. package/out/map/MapSearchBar.js.map +1 -1
  173. package/out/map/MapSheet.d.ts +15 -12
  174. package/out/map/MapSheet.d.ts.map +1 -1
  175. package/out/map/MapSheet.js +8 -12
  176. package/out/map/MapSheet.js.map +1 -1
  177. package/out/map/OverlayLayers.d.ts +8 -2
  178. package/out/map/OverlayLayers.d.ts.map +1 -1
  179. package/out/map/OverlayLayers.js +3 -3
  180. package/out/map/OverlayLayers.js.map +1 -1
  181. package/out/map/PlaceAutocomplete.d.ts +12 -21
  182. package/out/map/PlaceAutocomplete.d.ts.map +1 -1
  183. package/out/map/PlaceAutocomplete.js +2 -1
  184. package/out/map/PlaceAutocomplete.js.map +1 -1
  185. package/out/map/PlaceMarker.d.ts +10 -4
  186. package/out/map/PlaceMarker.d.ts.map +1 -1
  187. package/out/map/PlaceMarker.js +3 -1
  188. package/out/map/PlaceMarker.js.map +1 -1
  189. package/out/map/ResolvedPlaceLayers.d.ts +14 -15
  190. package/out/map/ResolvedPlaceLayers.d.ts.map +1 -1
  191. package/out/map/ResolvedPlaceLayers.js +1 -1
  192. package/out/map/ResolvedPlaceLayers.js.map +1 -1
  193. package/out/map/ResultCamera.d.ts +10 -31
  194. package/out/map/ResultCamera.d.ts.map +1 -1
  195. package/out/map/ResultCamera.js +3 -28
  196. package/out/map/ResultCamera.js.map +1 -1
  197. package/out/map/ResultOverlay.d.ts +14 -16
  198. package/out/map/ResultOverlay.d.ts.map +1 -1
  199. package/out/map/ResultOverlay.js +1 -2
  200. package/out/map/ResultOverlay.js.map +1 -1
  201. package/out/map/ResultPanel.d.ts +1 -20
  202. package/out/map/ResultPanel.d.ts.map +1 -1
  203. package/out/map/ResultPanel.js.map +1 -1
  204. package/out/map/VersionPicker.d.ts +7 -19
  205. package/out/map/VersionPicker.d.ts.map +1 -1
  206. package/out/map/VersionPicker.js +5 -2
  207. package/out/map/VersionPicker.js.map +1 -1
  208. package/out/map/fake-runtime.d.ts +9 -11
  209. package/out/map/fake-runtime.d.ts.map +1 -1
  210. package/out/map/fake-runtime.js +45 -19
  211. package/out/map/fake-runtime.js.map +1 -1
  212. package/out/map/geometry.d.ts +13 -34
  213. package/out/map/geometry.d.ts.map +1 -1
  214. package/out/map/geometry.js +9 -34
  215. package/out/map/geometry.js.map +1 -1
  216. package/out/map/graticule.d.ts +17 -0
  217. package/out/map/graticule.d.ts.map +1 -0
  218. package/out/map/graticule.js +67 -0
  219. package/out/map/graticule.js.map +1 -0
  220. package/out/map/map-debug.d.ts +4 -2
  221. package/out/map/map-debug.d.ts.map +1 -1
  222. package/out/map/map-debug.js +1 -1
  223. package/out/map/map-debug.js.map +1 -1
  224. package/out/map/place-render.d.ts +25 -45
  225. package/out/map/place-render.d.ts.map +1 -1
  226. package/out/map/place-render.js +4 -39
  227. package/out/map/place-render.js.map +1 -1
  228. package/out/map/types.d.ts +57 -113
  229. package/out/map/types.d.ts.map +1 -1
  230. package/out/map/types.js +1 -10
  231. package/out/map/types.js.map +1 -1
  232. package/out/map/useCompareState.d.ts +5 -4
  233. package/out/map/useCompareState.d.ts.map +1 -1
  234. package/out/map/useCompareState.js +3 -3
  235. package/out/map/useCompareState.js.map +1 -1
  236. package/out/map/useGeocode.d.ts +5 -3
  237. package/out/map/useGeocode.d.ts.map +1 -1
  238. package/out/map/useGeocode.js +4 -4
  239. package/out/map/useGeocode.js.map +1 -1
  240. package/out/map/useMapBearing.d.ts +11 -11
  241. package/out/map/useMapBearing.d.ts.map +1 -1
  242. package/out/map/useMapBearing.js +6 -11
  243. package/out/map/useMapBearing.js.map +1 -1
  244. package/out/map/useMapLabelPick.d.ts +4 -14
  245. package/out/map/useMapLabelPick.d.ts.map +1 -1
  246. package/out/map/useMapLabelPick.js +21 -30
  247. package/out/map/useMapLabelPick.js.map +1 -1
  248. package/out/map/useMapPlaceRender.d.ts +3 -7
  249. package/out/map/useMapPlaceRender.d.ts.map +1 -1
  250. package/out/map/useMapPlaceRender.js +2 -6
  251. package/out/map/useMapPlaceRender.js.map +1 -1
  252. package/out/map/usePlaceAutocomplete.d.ts +30 -28
  253. package/out/map/usePlaceAutocomplete.d.ts.map +1 -1
  254. package/out/map/usePlaceAutocomplete.js +6 -33
  255. package/out/map/usePlaceAutocomplete.js.map +1 -1
  256. package/out/map.d.ts +67 -0
  257. package/out/map.d.ts.map +1 -0
  258. package/out/map.js +45 -0
  259. package/out/map.js.map +1 -0
  260. package/out/pipeline/CandidatePicker.d.ts +12 -2
  261. package/out/pipeline/CandidatePicker.d.ts.map +1 -1
  262. package/out/pipeline/CandidatePicker.js +1 -1
  263. package/out/pipeline/CandidatePicker.js.map +1 -1
  264. package/out/pipeline/ConfidenceCell.d.ts +2 -2
  265. package/out/pipeline/ConfidenceCell.d.ts.map +1 -1
  266. package/out/pipeline/ConfidenceCell.js +1 -1
  267. package/out/pipeline/FailureDiagnostic.d.ts +8 -2
  268. package/out/pipeline/FailureDiagnostic.d.ts.map +1 -1
  269. package/out/pipeline/FailureDiagnostic.js +3 -0
  270. package/out/pipeline/FailureDiagnostic.js.map +1 -1
  271. package/out/pipeline/PipelineExplorer.d.ts +6 -2
  272. package/out/pipeline/PipelineExplorer.d.ts.map +1 -1
  273. package/out/pipeline/PipelineExplorer.js +1 -1
  274. package/out/pipeline/PipelineExplorer.js.map +1 -1
  275. package/out/pipeline/QueryForm.d.ts +5 -2
  276. package/out/pipeline/QueryForm.d.ts.map +1 -1
  277. package/out/pipeline/QueryForm.js.map +1 -1
  278. package/out/pipeline/ResolvedPlace.d.ts +1 -1
  279. package/out/pipeline/ResolvedPlace.d.ts.map +1 -1
  280. package/out/pipeline/SpanHighlight.d.ts +4 -3
  281. package/out/pipeline/SpanHighlight.d.ts.map +1 -1
  282. package/out/pipeline/SpanHighlight.js +3 -2
  283. package/out/pipeline/SpanHighlight.js.map +1 -1
  284. package/out/pipeline/TimingPanel.d.ts +1 -1
  285. package/out/pipeline/TreeView.d.ts +1 -1
  286. package/out/pipeline/TreeView.js.map +1 -1
  287. package/out/pipeline/copy.d.ts +1 -1
  288. package/out/pipeline/copy.d.ts.map +1 -1
  289. package/out/pipeline/copy.js +4 -3
  290. package/out/pipeline/copy.js.map +1 -1
  291. package/out/pipeline/presets.d.ts +5 -4
  292. package/out/pipeline/presets.d.ts.map +1 -1
  293. package/out/pipeline/presets.js +15 -13
  294. package/out/pipeline/presets.js.map +1 -1
  295. package/out/pipeline/types.d.ts +20 -17
  296. package/out/pipeline/types.d.ts.map +1 -1
  297. package/out/pipeline/types.js +2 -4
  298. package/out/pipeline/types.js.map +1 -1
  299. package/out/pipeline/useParsePipeline.d.ts +14 -9
  300. package/out/pipeline/useParsePipeline.d.ts.map +1 -1
  301. package/out/pipeline/useParsePipeline.js +7 -8
  302. package/out/pipeline/useParsePipeline.js.map +1 -1
  303. package/out/pipeline.d.ts +33 -0
  304. package/out/pipeline.d.ts.map +1 -0
  305. package/out/pipeline.js +21 -0
  306. package/out/pipeline.js.map +1 -0
  307. package/out/poi/AbstainPanel.d.ts +1 -1
  308. package/out/poi/LiveResultsBlock.d.ts +2 -1
  309. package/out/poi/LiveResultsBlock.d.ts.map +1 -1
  310. package/out/poi/LiveResultsBlock.js.map +1 -1
  311. package/out/poi/POIExplorer.d.ts +22 -12
  312. package/out/poi/POIExplorer.d.ts.map +1 -1
  313. package/out/poi/POIExplorer.js +11 -10
  314. package/out/poi/POIExplorer.js.map +1 -1
  315. package/out/poi/QueryInput.d.ts +1 -1
  316. package/out/poi/SubjectPanel.d.ts +2 -2
  317. package/out/poi/SubjectPanel.d.ts.map +1 -1
  318. package/out/poi/SubjectPanel.js +2 -1
  319. package/out/poi/SubjectPanel.js.map +1 -1
  320. package/out/poi/runtime.d.ts +5 -14
  321. package/out/poi/runtime.d.ts.map +1 -1
  322. package/out/poi/runtime.js +14 -16
  323. package/out/poi/runtime.js.map +1 -1
  324. package/out/poi/types.d.ts +53 -45
  325. package/out/poi/types.d.ts.map +1 -1
  326. package/out/poi/types.js +2 -4
  327. package/out/poi/types.js.map +1 -1
  328. package/out/poi/usePOISearch.d.ts +30 -17
  329. package/out/poi/usePOISearch.d.ts.map +1 -1
  330. package/out/poi/usePOISearch.js +48 -64
  331. package/out/poi/usePOISearch.js.map +1 -1
  332. package/out/poi.d.ts +23 -0
  333. package/out/poi.d.ts.map +1 -0
  334. package/out/poi.js +15 -0
  335. package/out/poi.js.map +1 -0
  336. package/out/runtime/useReleaseRuntime.d.ts +63 -74
  337. package/out/runtime/useReleaseRuntime.d.ts.map +1 -1
  338. package/out/runtime/useReleaseRuntime.js +31 -32
  339. package/out/runtime/useReleaseRuntime.js.map +1 -1
  340. package/out/{runtime/index.d.ts → runtime.d.ts} +1 -1
  341. package/out/runtime.d.ts.map +1 -0
  342. package/out/{runtime/index.js → runtime.js} +1 -1
  343. package/out/runtime.js.map +1 -0
  344. package/out/search/SearchModal.d.ts +40 -0
  345. package/out/search/SearchModal.d.ts.map +1 -0
  346. package/out/search/SearchModal.js +255 -0
  347. package/out/search/SearchModal.js.map +1 -0
  348. package/out/search/types.d.ts +65 -0
  349. package/out/search/types.d.ts.map +1 -0
  350. package/out/search/types.js +13 -0
  351. package/out/search/types.js.map +1 -0
  352. package/out/search.d.ts +11 -0
  353. package/out/search.d.ts.map +1 -0
  354. package/out/search.js +9 -0
  355. package/out/search.js.map +1 -0
  356. package/package.json +73 -165
  357. package/styles.css +482 -266
  358. package/tokens/index.css +24 -6
  359. package/tokens/tokens.json +114 -6
  360. package/lib/common/index.ts +0 -22
  361. package/lib/map/BackendControl.tsx +0 -51
  362. package/lib/map/index.ts +0 -95
  363. package/lib/pipeline/index.ts +0 -36
  364. package/lib/poi/index.ts +0 -39
  365. package/out/common/index.d.ts +0 -22
  366. package/out/common/index.d.ts.map +0 -1
  367. package/out/common/index.js +0 -15
  368. package/out/common/index.js.map +0 -1
  369. package/out/map/BackendControl.d.ts +0 -32
  370. package/out/map/BackendControl.d.ts.map +0 -1
  371. package/out/map/BackendControl.js +0 -8
  372. package/out/map/BackendControl.js.map +0 -1
  373. package/out/map/index.d.ts +0 -69
  374. package/out/map/index.d.ts.map +0 -1
  375. package/out/map/index.js +0 -46
  376. package/out/map/index.js.map +0 -1
  377. package/out/pipeline/index.d.ts +0 -33
  378. package/out/pipeline/index.d.ts.map +0 -1
  379. package/out/pipeline/index.js +0 -21
  380. package/out/pipeline/index.js.map +0 -1
  381. package/out/poi/index.d.ts +0 -23
  382. package/out/poi/index.d.ts.map +0 -1
  383. package/out/poi/index.js +0 -15
  384. package/out/poi/index.js.map +0 -1
  385. package/out/runtime/index.d.ts.map +0 -1
  386. package/out/runtime/index.js.map +0 -1
  387. package/styleframe.config.ts +0 -329
  388. package/vitest.node.config.ts +0 -20
  389. /package/lib/{runtime/index.ts → runtime.ts} +0 -0
@@ -0,0 +1,478 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * The docs search modal. A native `<dialog>` opened with `showModal()` supplies the focus trap, the inert
7
+ * background, the backdrop, the close on Escape and the return of focus to the opener. The query field is an
8
+ * `<input type="search">` with the ARIA combobox role. DOM focus stays on the input, and
9
+ * `aria-activedescendant` identifies the selected hit. The component owns no data access: `search` is
10
+ * passed in. Recent queries are a per-viewer convenience in `localStorage`; the modal renders the same
11
+ * without them.
12
+ */
13
+
14
+ import { stringifyJSON, tryParsingJSON } from "@mailwoman/core/json"
15
+ import { type KeyboardEvent, type MouseEvent, type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react"
16
+
17
+ import { useDebouncedValue } from "#common/useDebouncedValue"
18
+
19
+ import type { SearchHit, SearchResponse } from "./types.ts"
20
+
21
+ export interface SearchModalProps {
22
+ open: boolean
23
+ onClose: () => void
24
+ /**
25
+ * Called with the hit's site-relative link on Enter or on an unmodified click.
26
+ * Absent, the link navigates as an ordinary anchor.
27
+ */
28
+ onNavigate?: (href: string) => void
29
+ /**
30
+ * Runs the query.
31
+ *
32
+ * The function must keep a stable identity across renders (for example through `useCallback`),
33
+ * because a new identity refires the request for the current query.
34
+ */
35
+ search: (q: string, signal: AbortSignal) => Promise<SearchResponse>
36
+ /**
37
+ * The `localStorage` key under which recent queries are kept.
38
+ * Absent, recent queries are not kept.
39
+ */
40
+ recentKey?: string
41
+ }
42
+
43
+ type Status =
44
+ | { kind: "idle" }
45
+ | { kind: "loading" }
46
+ | { kind: "results"; response: SearchResponse }
47
+ | { kind: "failed" }
48
+
49
+ const DEBOUNCE_MS = 150
50
+
51
+ /**
52
+ * How long a request runs before the live region announces that the index is loading.
53
+ */
54
+ const LOADING_ANNOUNCE_MS = 300
55
+ const MAX_QUERY_LENGTH = 200
56
+ const DEFAULT_CATEGORY = "Documentation"
57
+ const RECENT_LIMIT = 5
58
+
59
+ /**
60
+ * The site-relative link for a hit.
61
+ */
62
+ export function hitHref(hit: Pick<SearchHit, "url" | "anchor">): string {
63
+ return hit.anchor === "" ? hit.url : `${hit.url}#${hit.anchor}`
64
+ }
65
+
66
+ function statusText(status: Status): string {
67
+ if (status.kind === "failed") return "Search is unavailable. Try again in a moment."
68
+
69
+ if (status.kind === "loading") return "Loading the search index…"
70
+
71
+ if (status.kind === "idle") return ""
72
+
73
+ const { response } = status
74
+
75
+ if (!response.hits.length) return `No results for “${response.query}”.`
76
+
77
+ return response.hits.length === 1 ? "1 result." : `${response.hits.length} results.`
78
+ }
79
+
80
+ /**
81
+ * The ranges of `text` that match a query token, case-insensitively, for emphasis in a title.
82
+ */
83
+ function tokenRanges(text: string, query: string): [number, number][] {
84
+ const lower = text.toLowerCase()
85
+ const ranges: [number, number][] = []
86
+
87
+ for (const token of new Set(
88
+ query
89
+ .toLowerCase()
90
+ .split(/\s+/)
91
+ .filter((part) => part.length >= 2)
92
+ )) {
93
+ for (let index = lower.indexOf(token); index >= 0; index = lower.indexOf(token, index + token.length)) {
94
+ ranges.push([index, index + token.length])
95
+ }
96
+ }
97
+
98
+ return ranges.toSorted((a, b) => a[0] - b[0])
99
+ }
100
+
101
+ function emphasized(text: string, ranges: readonly [number, number][]): ReactNode[] {
102
+ const parts: ReactNode[] = []
103
+ let cursor = 0
104
+
105
+ for (const [start, end] of ranges) {
106
+ if (start < cursor) continue
107
+
108
+ parts.push(text.slice(cursor, start), <mark key={start}>{text.slice(start, end)}</mark>)
109
+ cursor = end
110
+ }
111
+
112
+ parts.push(text.slice(cursor))
113
+
114
+ return parts
115
+ }
116
+
117
+ /**
118
+ * The deepest heading of a hit is its title.
119
+ * The headings between the category and it are its path.
120
+ */
121
+ function headings(hit: SearchHit): { title: string; path: string[] } {
122
+ const named = hit.hierarchy.slice(1).filter((entry): entry is string => entry !== null)
123
+ const title = named.at(-1) ?? hit.url
124
+
125
+ return { title, path: named.slice(0, -1) }
126
+ }
127
+
128
+ function readRecent(key: string | undefined): string[] {
129
+ if (!key) return []
130
+
131
+ try {
132
+ const stored = tryParsingJSON<unknown>(globalThis.localStorage.getItem(key))
133
+
134
+ return Array.isArray(stored) ? stored.filter((entry): entry is string => typeof entry === "string") : []
135
+ } catch {
136
+ return []
137
+ }
138
+ }
139
+
140
+ function writeRecent(key: string | undefined, entries: readonly string[]): void {
141
+ if (!key) return
142
+
143
+ try {
144
+ globalThis.localStorage.setItem(key, stringifyJSON(entries))
145
+ } catch {
146
+ // Storage can be absent or blocked.
147
+ // The modal works without it.
148
+ }
149
+ }
150
+
151
+ const SearchIcon = () => (
152
+ <svg className="mw-search__icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false">
153
+ <circle cx="8.5" cy="8.5" r="5.5" fill="none" stroke="currentColor" strokeWidth="2" />
154
+ <path d="M13 13l4.5 4.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
155
+ </svg>
156
+ )
157
+
158
+ const Spinner = () => (
159
+ <svg className="mw-search__icon mw-search__icon--spinning" viewBox="0 0 20 20" aria-hidden="true" focusable="false">
160
+ <circle
161
+ cx="10"
162
+ cy="10"
163
+ r="7"
164
+ fill="none"
165
+ stroke="currentColor"
166
+ strokeWidth="2"
167
+ strokeDasharray="22 22"
168
+ strokeLinecap="round"
169
+ />
170
+ </svg>
171
+ )
172
+
173
+ export function SearchModal({ open, onClose, onNavigate, search, recentKey }: SearchModalProps) {
174
+ const dialogRef = useRef<HTMLDialogElement>(null)
175
+ const inputRef = useRef<HTMLInputElement>(null)
176
+ const baseID = useId()
177
+ const listboxID = `${baseID}-listbox`
178
+ const [query, setQuery] = useState("")
179
+ const [settled, setSettled] = useState<Status>({ kind: "idle" })
180
+ const [selected, setSelected] = useState(0)
181
+ const [loading, setLoading] = useState(false)
182
+ const [recent, setRecent] = useState<string[]>([])
183
+ const trimmed = query.trim().slice(0, MAX_QUERY_LENGTH)
184
+ const debounced = useDebouncedValue(trimmed, DEBOUNCE_MS)
185
+
186
+ useEffect(() => {
187
+ const dialog = dialogRef.current
188
+
189
+ if (!dialog) return
190
+
191
+ if (open && !dialog.open) {
192
+ dialog.showModal()
193
+ inputRef.current?.focus()
194
+ setRecent(readRecent(recentKey))
195
+ } else if (!open && dialog.open) {
196
+ dialog.close()
197
+ }
198
+ }, [open, recentKey])
199
+
200
+ useEffect(() => {
201
+ if (debounced === "") return
202
+
203
+ // The abort of the earlier request keeps a late response from replacing newer results.
204
+ const controller = new AbortController()
205
+ const loadingTimer = setTimeout(() => setLoading(true), LOADING_ANNOUNCE_MS)
206
+
207
+ search(debounced, controller.signal).then(
208
+ (response) => {
209
+ if (controller.signal.aborted) return
210
+
211
+ clearTimeout(loadingTimer)
212
+ setLoading(false)
213
+ setSettled({ kind: "results", response })
214
+ setSelected(0)
215
+ },
216
+ () => {
217
+ if (controller.signal.aborted) return
218
+
219
+ clearTimeout(loadingTimer)
220
+ setLoading(false)
221
+ setSettled({ kind: "failed" })
222
+ }
223
+ )
224
+
225
+ return () => {
226
+ clearTimeout(loadingTimer)
227
+ setLoading(false)
228
+ controller.abort()
229
+ }
230
+ }, [debounced, search])
231
+
232
+ // An empty query shows no results, whatever the last settled response was.
233
+ const status: Status = debounced === "" ? { kind: "idle" } : settled
234
+ const response = status.kind === "results" ? status.response : undefined
235
+ // The announcement reports a slow request while the last results stay on screen.
236
+ const announced: Status = loading && debounced !== "" ? { kind: "loading" } : status
237
+
238
+ const groups = useMemo(() => {
239
+ const hits = response?.hits ?? []
240
+ const byCategory = new Map<string, SearchHit[]>()
241
+
242
+ for (const hit of hits) {
243
+ const category = hit.hierarchy[0] ?? DEFAULT_CATEGORY
244
+
245
+ byCategory.set(category, [...(byCategory.get(category) ?? []), hit])
246
+ }
247
+
248
+ // Options are numbered in display order so the arrow keys follow what the reader sees.
249
+ let order = 0
250
+
251
+ return [...byCategory].map(([category, entries]) => ({
252
+ category,
253
+ entries: entries.map((hit) => ({ hit, order: order++ })),
254
+ }))
255
+ }, [response])
256
+
257
+ const ordered = groups.flatMap((group) => group.entries)
258
+ const optionID = (order: number) => `${baseID}-option-${order}`
259
+ const emphasis = response?.corrected ?? debounced
260
+
261
+ function remember(text: string) {
262
+ const entries = [text, ...recent.filter((entry) => entry !== text)].slice(0, RECENT_LIMIT)
263
+
264
+ setRecent(entries)
265
+ writeRecent(recentKey, entries)
266
+ }
267
+
268
+ function forget(text: string) {
269
+ const entries = recent.filter((entry) => entry !== text)
270
+
271
+ setRecent(entries)
272
+ writeRecent(recentKey, entries)
273
+ }
274
+
275
+ function go(hit: SearchHit) {
276
+ const href = hitHref(hit)
277
+
278
+ if (trimmed !== "") {
279
+ remember(trimmed)
280
+ }
281
+
282
+ onClose()
283
+
284
+ if (onNavigate) {
285
+ onNavigate(href)
286
+ } else {
287
+ globalThis.location.assign(href)
288
+ }
289
+ }
290
+
291
+ function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
292
+ // A key that commits or edits an IME composition is not a command.
293
+ if (event.nativeEvent.isComposing) return
294
+
295
+ if (!ordered.length) return
296
+
297
+ const last = ordered.length - 1
298
+
299
+ const next =
300
+ event.key === "ArrowDown"
301
+ ? selected === last
302
+ ? 0
303
+ : selected + 1
304
+ : event.key === "ArrowUp"
305
+ ? selected === 0
306
+ ? last
307
+ : selected - 1
308
+ : event.key === "Home"
309
+ ? 0
310
+ : event.key === "End"
311
+ ? last
312
+ : undefined
313
+
314
+ if (next !== undefined) {
315
+ event.preventDefault()
316
+ setSelected(next)
317
+ document.getElementById(optionID(next))?.scrollIntoView({ block: "nearest" })
318
+ } else if (event.key === "Enter") {
319
+ event.preventDefault()
320
+
321
+ // The hits on screen answer an earlier query until the debounce settles.
322
+ if (trimmed !== debounced) return
323
+
324
+ const entry = ordered[selected]
325
+
326
+ if (entry) {
327
+ go(entry.hit)
328
+ }
329
+ }
330
+ }
331
+
332
+ function onDialogClick(event: MouseEvent<HTMLDialogElement>) {
333
+ // A click whose target is the dialog element itself landed on the backdrop.
334
+ if (event.target === event.currentTarget) {
335
+ event.currentTarget.close()
336
+ }
337
+ }
338
+
339
+ function onLinkClick(event: MouseEvent<HTMLAnchorElement>, hit: SearchHit) {
340
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return
341
+
342
+ event.preventDefault()
343
+ go(hit)
344
+ }
345
+
346
+ const showRecent = trimmed === "" && recent.length > 0
347
+ const noResults = response !== undefined && response.hits.length === 0
348
+
349
+ return (
350
+ <dialog
351
+ ref={dialogRef}
352
+ className="mw-search"
353
+ aria-label="Search the documentation"
354
+ onClose={onClose}
355
+ onClick={onDialogClick}
356
+ >
357
+ <form role="search" className="mw-search__form" onSubmit={(event) => event.preventDefault()}>
358
+ <label className="mw-search__field">
359
+ {loading ? <Spinner /> : <SearchIcon />}
360
+ <input
361
+ ref={inputRef}
362
+ type="search"
363
+ className="mw-search__input"
364
+ role="combobox"
365
+ aria-label="Search the documentation"
366
+ aria-autocomplete="list"
367
+ aria-expanded={ordered.length > 0}
368
+ aria-controls={listboxID}
369
+ aria-activedescendant={ordered.length ? optionID(selected) : undefined}
370
+ autoComplete="off"
371
+ spellCheck={false}
372
+ maxLength={MAX_QUERY_LENGTH}
373
+ placeholder="Search the documentation"
374
+ value={query}
375
+ onChange={(event) => setQuery(event.target.value)}
376
+ onKeyDown={onKeyDown}
377
+ />
378
+ </label>
379
+ <button type="button" className="mw-search__cancel" onClick={() => dialogRef.current?.close()}>
380
+ Cancel
381
+ </button>
382
+ </form>
383
+
384
+ {response?.corrected != null && (
385
+ <p className="mw-search__corrected">Showing results for “{response.corrected}”</p>
386
+ )}
387
+
388
+ <div className="mw-search__body">
389
+ {showRecent && (
390
+ <section className="mw-search__recent" aria-label="Recent searches">
391
+ <div className="mw-search__category">Recent</div>
392
+ <ul className="mw-search__recent-list">
393
+ {recent.map((entry) => (
394
+ <li key={entry} className="mw-search__recent-item">
395
+ <button type="button" className="mw-search__recent-query" onClick={() => setQuery(entry)}>
396
+ {entry}
397
+ </button>
398
+ <button
399
+ type="button"
400
+ className="mw-search__recent-remove"
401
+ aria-label={`Remove “${entry}” from recent searches`}
402
+ onClick={() => forget(entry)}
403
+ >
404
+ ×
405
+ </button>
406
+ </li>
407
+ ))}
408
+ </ul>
409
+ </section>
410
+ )}
411
+
412
+ {noResults && (
413
+ <p className="mw-search__empty">
414
+ No results for “{response.query}”. Try a different spelling or a shorter query.
415
+ </p>
416
+ )}
417
+
418
+ <div id={listboxID} role="listbox" aria-label="Search results" className="mw-search__results">
419
+ {groups.map((group, groupIndex) => (
420
+ <div
421
+ key={group.category}
422
+ role="group"
423
+ aria-labelledby={`${baseID}-group-${groupIndex}`}
424
+ className="mw-search__group"
425
+ >
426
+ <div id={`${baseID}-group-${groupIndex}`} className="mw-search__category">
427
+ {group.category}
428
+ </div>
429
+ {group.entries.map(({ hit, order }) => {
430
+ const { title, path } = headings(hit)
431
+
432
+ return (
433
+ // The option is the link itself: an option may not contain an interactive element, and
434
+ // `option` is an allowed role on `a[href]`, which keeps the URL for open-in-new-tab.
435
+ <a
436
+ key={order}
437
+ id={optionID(order)}
438
+ role="option"
439
+ aria-selected={order === selected}
440
+ href={hitHref(hit)}
441
+ tabIndex={-1}
442
+ className={`mw-search__option ${hit.anchor === "" ? "mw-search__option--page" : "mw-search__option--section"}`}
443
+ onPointerMove={() => setSelected(order)}
444
+ onClick={(event) => onLinkClick(event, hit)}
445
+ >
446
+ {path.length > 0 && <span className="mw-search__path">{path.join(" › ")}</span>}
447
+ <span className="mw-search__title">{emphasized(title, tokenRanges(title, emphasis))}</span>
448
+ {hit.snippet !== "" && (
449
+ <span className="mw-search__snippet">{emphasized(hit.snippet, hit.highlights)}</span>
450
+ )}
451
+ </a>
452
+ )
453
+ })}
454
+ </div>
455
+ ))}
456
+ </div>
457
+ </div>
458
+
459
+ <footer className="mw-search__footer">
460
+ <p aria-live="polite" className="mw-search__status">
461
+ {statusText(announced)}
462
+ </p>
463
+ <ul className="mw-search__hints" aria-label="Keyboard shortcuts">
464
+ <li>
465
+ <kbd>↵</kbd> open
466
+ </li>
467
+ <li>
468
+ <kbd>↑</kbd>
469
+ <kbd>↓</kbd> move
470
+ </li>
471
+ <li>
472
+ <kbd>esc</kbd> close
473
+ </li>
474
+ </ul>
475
+ </footer>
476
+ </dialog>
477
+ )
478
+ }