@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.
- package/README.md +18 -16
- package/fonts.css +97 -22
- package/lib/common/About.tsx +10 -10
- package/lib/common/ClientOnly.tsx +2 -14
- package/lib/common/CopyButton.tsx +9 -3
- package/lib/common/LoadingIndicator.tsx +13 -5
- package/lib/common/PresetChips.tsx +11 -8
- package/lib/common/confidence-tiers.ts +8 -7
- package/lib/common/cx.ts +3 -1
- package/lib/common/text-tokens.ts +10 -13
- package/lib/common/useBuildInfo.ts +54 -0
- package/lib/common/useClipboard.ts +2 -11
- package/lib/common.ts +22 -0
- package/lib/index.ts +27 -18
- package/lib/map/AppIdentity.tsx +89 -0
- package/lib/map/CompareToggle.tsx +8 -31
- package/lib/map/DebugInfo.tsx +75 -0
- package/lib/map/Geocoder.tsx +65 -50
- package/lib/map/GeocoderControls.tsx +425 -141
- package/lib/map/GraticuleLayer.tsx +56 -0
- package/lib/map/MapCanvas.tsx +26 -13
- package/lib/map/MapChipRow.tsx +4 -8
- package/lib/map/MapCompass.tsx +18 -25
- package/lib/map/MapControlStack.tsx +11 -10
- package/lib/map/MapFooter.tsx +5 -17
- package/lib/map/MapProgressBar.tsx +10 -13
- package/lib/map/MapSearchBar.tsx +31 -17
- package/lib/map/MapSheet.tsx +24 -15
- package/lib/map/OverlayLayers.tsx +14 -3
- package/lib/map/PlaceAutocomplete.tsx +12 -21
- package/lib/map/PlaceMarker.tsx +10 -4
- package/lib/map/ResolvedPlaceLayers.tsx +14 -15
- package/lib/map/ResultCamera.tsx +10 -31
- package/lib/map/ResultOverlay.tsx +14 -16
- package/lib/map/ResultPanel.tsx +1 -20
- package/lib/map/VersionPicker.tsx +10 -19
- package/lib/map/fake-runtime.ts +46 -19
- package/lib/map/geometry.ts +14 -47
- package/lib/map/graticule.ts +80 -0
- package/lib/map/map-debug.ts +4 -2
- package/lib/map/place-render.ts +30 -62
- package/lib/map/types.ts +57 -116
- package/lib/map/useCompareState.ts +5 -4
- package/lib/map/useGeocode.ts +7 -5
- package/lib/map/useMapBearing.ts +13 -13
- package/lib/map/useMapLabelPick.ts +21 -30
- package/lib/map/useMapPlaceRender.ts +3 -7
- package/lib/map/usePlaceAutocomplete.ts +44 -50
- package/lib/map.ts +92 -0
- package/lib/pipeline/CandidatePicker.tsx +13 -5
- package/lib/pipeline/ConfidenceCell.tsx +2 -2
- package/lib/pipeline/FailureDiagnostic.tsx +8 -2
- package/lib/pipeline/PipelineExplorer.tsx +6 -3
- package/lib/pipeline/QueryForm.tsx +5 -2
- package/lib/pipeline/ResolvedPlace.tsx +2 -2
- package/lib/pipeline/SpanHighlight.tsx +7 -5
- package/lib/pipeline/TimingPanel.tsx +1 -1
- package/lib/pipeline/TreeView.tsx +2 -2
- package/lib/pipeline/copy.ts +22 -25
- package/lib/pipeline/presets.ts +15 -13
- package/lib/pipeline/types.ts +20 -17
- package/lib/pipeline/useParsePipeline.ts +16 -13
- package/lib/pipeline.ts +36 -0
- package/lib/poi/AbstainPanel.tsx +1 -1
- package/lib/poi/LiveResultsBlock.tsx +2 -1
- package/lib/poi/POIExplorer.tsx +29 -17
- package/lib/poi/QueryInput.tsx +1 -1
- package/lib/poi/SubjectPanel.tsx +4 -3
- package/lib/poi/runtime.ts +14 -16
- package/lib/poi/types.ts +58 -47
- package/lib/poi/usePOISearch.ts +91 -82
- package/lib/poi.ts +40 -0
- package/lib/runtime/useReleaseRuntime.ts +122 -90
- package/lib/search/SearchModal.tsx +478 -0
- package/lib/search/search.css +282 -0
- package/lib/search/types.ts +68 -0
- package/lib/search.ts +11 -0
- package/out/common/About.d.ts +6 -6
- package/out/common/About.d.ts.map +1 -1
- package/out/common/About.js +1 -1
- package/out/common/About.js.map +1 -1
- package/out/common/ClientOnly.d.ts +2 -11
- package/out/common/ClientOnly.d.ts.map +1 -1
- package/out/common/ClientOnly.js +1 -13
- package/out/common/ClientOnly.js.map +1 -1
- package/out/common/CopyButton.d.ts +9 -3
- package/out/common/CopyButton.d.ts.map +1 -1
- package/out/common/CopyButton.js.map +1 -1
- package/out/common/LoadingIndicator.d.ts +13 -5
- package/out/common/LoadingIndicator.d.ts.map +1 -1
- package/out/common/LoadingIndicator.js.map +1 -1
- package/out/common/PresetChips.d.ts +11 -8
- package/out/common/PresetChips.d.ts.map +1 -1
- package/out/common/PresetChips.js.map +1 -1
- package/out/common/confidence-tiers.d.ts +8 -7
- package/out/common/confidence-tiers.d.ts.map +1 -1
- package/out/common/confidence-tiers.js +7 -6
- package/out/common/confidence-tiers.js.map +1 -1
- package/out/common/cx.d.ts +3 -1
- package/out/common/cx.d.ts.map +1 -1
- package/out/common/cx.js +3 -1
- package/out/common/cx.js.map +1 -1
- package/out/common/text-tokens.d.ts +6 -11
- package/out/common/text-tokens.d.ts.map +1 -1
- package/out/common/text-tokens.js +6 -3
- package/out/common/text-tokens.js.map +1 -1
- package/out/common/useBuildInfo.d.ts +27 -0
- package/out/common/useBuildInfo.d.ts.map +1 -0
- package/out/common/useBuildInfo.js +32 -0
- package/out/common/useBuildInfo.js.map +1 -0
- package/out/common/useClipboard.d.ts +1 -3
- package/out/common/useClipboard.d.ts.map +1 -1
- package/out/common/useClipboard.js +2 -11
- package/out/common/useClipboard.js.map +1 -1
- package/out/common.d.ts +22 -0
- package/out/common.d.ts.map +1 -0
- package/out/common.js +15 -0
- package/out/common.js.map +1 -0
- package/out/index.d.ts +26 -16
- package/out/index.d.ts.map +1 -1
- package/out/index.js +21 -15
- package/out/index.js.map +1 -1
- package/out/map/AppIdentity.d.ts +46 -0
- package/out/map/AppIdentity.d.ts.map +1 -0
- package/out/map/AppIdentity.js +16 -0
- package/out/map/AppIdentity.js.map +1 -0
- package/out/map/CompareToggle.d.ts +8 -31
- package/out/map/CompareToggle.d.ts.map +1 -1
- package/out/map/CompareToggle.js +4 -1
- package/out/map/CompareToggle.js.map +1 -1
- package/out/map/DebugInfo.d.ts +27 -0
- package/out/map/DebugInfo.d.ts.map +1 -0
- package/out/map/DebugInfo.js +36 -0
- package/out/map/DebugInfo.js.map +1 -0
- package/out/map/Geocoder.d.ts +41 -30
- package/out/map/Geocoder.d.ts.map +1 -1
- package/out/map/Geocoder.js +23 -37
- package/out/map/Geocoder.js.map +1 -1
- package/out/map/GeocoderControls.d.ts +15 -44
- package/out/map/GeocoderControls.d.ts.map +1 -1
- package/out/map/GeocoderControls.js +250 -39
- package/out/map/GeocoderControls.js.map +1 -1
- package/out/map/GraticuleLayer.d.ts +30 -0
- package/out/map/GraticuleLayer.d.ts.map +1 -0
- package/out/map/GraticuleLayer.js +25 -0
- package/out/map/GraticuleLayer.js.map +1 -0
- package/out/map/MapCanvas.d.ts +26 -13
- package/out/map/MapCanvas.d.ts.map +1 -1
- package/out/map/MapCanvas.js +5 -2
- package/out/map/MapCanvas.js.map +1 -1
- package/out/map/MapChipRow.d.ts +4 -8
- package/out/map/MapChipRow.d.ts.map +1 -1
- package/out/map/MapChipRow.js.map +1 -1
- package/out/map/MapCompass.d.ts +14 -21
- package/out/map/MapCompass.d.ts.map +1 -1
- package/out/map/MapCompass.js +5 -1
- package/out/map/MapCompass.js.map +1 -1
- package/out/map/MapControlStack.d.ts +11 -10
- package/out/map/MapControlStack.d.ts.map +1 -1
- package/out/map/MapControlStack.js +4 -1
- package/out/map/MapControlStack.js.map +1 -1
- package/out/map/MapFooter.d.ts +5 -16
- package/out/map/MapFooter.d.ts.map +1 -1
- package/out/map/MapFooter.js +0 -14
- package/out/map/MapFooter.js.map +1 -1
- package/out/map/MapProgressBar.d.ts +10 -13
- package/out/map/MapProgressBar.d.ts.map +1 -1
- package/out/map/MapProgressBar.js.map +1 -1
- package/out/map/MapSearchBar.d.ts +16 -17
- package/out/map/MapSearchBar.d.ts.map +1 -1
- package/out/map/MapSearchBar.js +6 -0
- package/out/map/MapSearchBar.js.map +1 -1
- package/out/map/MapSheet.d.ts +15 -12
- package/out/map/MapSheet.d.ts.map +1 -1
- package/out/map/MapSheet.js +8 -12
- package/out/map/MapSheet.js.map +1 -1
- package/out/map/OverlayLayers.d.ts +8 -2
- package/out/map/OverlayLayers.d.ts.map +1 -1
- package/out/map/OverlayLayers.js +3 -3
- package/out/map/OverlayLayers.js.map +1 -1
- package/out/map/PlaceAutocomplete.d.ts +12 -21
- package/out/map/PlaceAutocomplete.d.ts.map +1 -1
- package/out/map/PlaceAutocomplete.js +2 -1
- package/out/map/PlaceAutocomplete.js.map +1 -1
- package/out/map/PlaceMarker.d.ts +10 -4
- package/out/map/PlaceMarker.d.ts.map +1 -1
- package/out/map/PlaceMarker.js +3 -1
- package/out/map/PlaceMarker.js.map +1 -1
- package/out/map/ResolvedPlaceLayers.d.ts +14 -15
- package/out/map/ResolvedPlaceLayers.d.ts.map +1 -1
- package/out/map/ResolvedPlaceLayers.js +1 -1
- package/out/map/ResolvedPlaceLayers.js.map +1 -1
- package/out/map/ResultCamera.d.ts +10 -31
- package/out/map/ResultCamera.d.ts.map +1 -1
- package/out/map/ResultCamera.js +3 -28
- package/out/map/ResultCamera.js.map +1 -1
- package/out/map/ResultOverlay.d.ts +14 -16
- package/out/map/ResultOverlay.d.ts.map +1 -1
- package/out/map/ResultOverlay.js +1 -2
- package/out/map/ResultOverlay.js.map +1 -1
- package/out/map/ResultPanel.d.ts +1 -20
- package/out/map/ResultPanel.d.ts.map +1 -1
- package/out/map/ResultPanel.js.map +1 -1
- package/out/map/VersionPicker.d.ts +7 -19
- package/out/map/VersionPicker.d.ts.map +1 -1
- package/out/map/VersionPicker.js +5 -2
- package/out/map/VersionPicker.js.map +1 -1
- package/out/map/fake-runtime.d.ts +9 -11
- package/out/map/fake-runtime.d.ts.map +1 -1
- package/out/map/fake-runtime.js +45 -19
- package/out/map/fake-runtime.js.map +1 -1
- package/out/map/geometry.d.ts +13 -34
- package/out/map/geometry.d.ts.map +1 -1
- package/out/map/geometry.js +9 -34
- package/out/map/geometry.js.map +1 -1
- package/out/map/graticule.d.ts +17 -0
- package/out/map/graticule.d.ts.map +1 -0
- package/out/map/graticule.js +67 -0
- package/out/map/graticule.js.map +1 -0
- package/out/map/map-debug.d.ts +4 -2
- package/out/map/map-debug.d.ts.map +1 -1
- package/out/map/map-debug.js +1 -1
- package/out/map/map-debug.js.map +1 -1
- package/out/map/place-render.d.ts +25 -45
- package/out/map/place-render.d.ts.map +1 -1
- package/out/map/place-render.js +4 -39
- package/out/map/place-render.js.map +1 -1
- package/out/map/types.d.ts +57 -113
- package/out/map/types.d.ts.map +1 -1
- package/out/map/types.js +1 -10
- package/out/map/types.js.map +1 -1
- package/out/map/useCompareState.d.ts +5 -4
- package/out/map/useCompareState.d.ts.map +1 -1
- package/out/map/useCompareState.js +3 -3
- package/out/map/useCompareState.js.map +1 -1
- package/out/map/useGeocode.d.ts +5 -3
- package/out/map/useGeocode.d.ts.map +1 -1
- package/out/map/useGeocode.js +4 -4
- package/out/map/useGeocode.js.map +1 -1
- package/out/map/useMapBearing.d.ts +11 -11
- package/out/map/useMapBearing.d.ts.map +1 -1
- package/out/map/useMapBearing.js +6 -11
- package/out/map/useMapBearing.js.map +1 -1
- package/out/map/useMapLabelPick.d.ts +4 -14
- package/out/map/useMapLabelPick.d.ts.map +1 -1
- package/out/map/useMapLabelPick.js +21 -30
- package/out/map/useMapLabelPick.js.map +1 -1
- package/out/map/useMapPlaceRender.d.ts +3 -7
- package/out/map/useMapPlaceRender.d.ts.map +1 -1
- package/out/map/useMapPlaceRender.js +2 -6
- package/out/map/useMapPlaceRender.js.map +1 -1
- package/out/map/usePlaceAutocomplete.d.ts +30 -28
- package/out/map/usePlaceAutocomplete.d.ts.map +1 -1
- package/out/map/usePlaceAutocomplete.js +6 -33
- package/out/map/usePlaceAutocomplete.js.map +1 -1
- package/out/map.d.ts +67 -0
- package/out/map.d.ts.map +1 -0
- package/out/map.js +45 -0
- package/out/map.js.map +1 -0
- package/out/pipeline/CandidatePicker.d.ts +12 -2
- package/out/pipeline/CandidatePicker.d.ts.map +1 -1
- package/out/pipeline/CandidatePicker.js +1 -1
- package/out/pipeline/CandidatePicker.js.map +1 -1
- package/out/pipeline/ConfidenceCell.d.ts +2 -2
- package/out/pipeline/ConfidenceCell.d.ts.map +1 -1
- package/out/pipeline/ConfidenceCell.js +1 -1
- package/out/pipeline/FailureDiagnostic.d.ts +8 -2
- package/out/pipeline/FailureDiagnostic.d.ts.map +1 -1
- package/out/pipeline/FailureDiagnostic.js +3 -0
- package/out/pipeline/FailureDiagnostic.js.map +1 -1
- package/out/pipeline/PipelineExplorer.d.ts +6 -2
- package/out/pipeline/PipelineExplorer.d.ts.map +1 -1
- package/out/pipeline/PipelineExplorer.js +1 -1
- package/out/pipeline/PipelineExplorer.js.map +1 -1
- package/out/pipeline/QueryForm.d.ts +5 -2
- package/out/pipeline/QueryForm.d.ts.map +1 -1
- package/out/pipeline/QueryForm.js.map +1 -1
- package/out/pipeline/ResolvedPlace.d.ts +1 -1
- package/out/pipeline/ResolvedPlace.d.ts.map +1 -1
- package/out/pipeline/SpanHighlight.d.ts +4 -3
- package/out/pipeline/SpanHighlight.d.ts.map +1 -1
- package/out/pipeline/SpanHighlight.js +3 -2
- package/out/pipeline/SpanHighlight.js.map +1 -1
- package/out/pipeline/TimingPanel.d.ts +1 -1
- package/out/pipeline/TreeView.d.ts +1 -1
- package/out/pipeline/TreeView.js.map +1 -1
- package/out/pipeline/copy.d.ts +1 -1
- package/out/pipeline/copy.d.ts.map +1 -1
- package/out/pipeline/copy.js +4 -3
- package/out/pipeline/copy.js.map +1 -1
- package/out/pipeline/presets.d.ts +5 -4
- package/out/pipeline/presets.d.ts.map +1 -1
- package/out/pipeline/presets.js +15 -13
- package/out/pipeline/presets.js.map +1 -1
- package/out/pipeline/types.d.ts +20 -17
- package/out/pipeline/types.d.ts.map +1 -1
- package/out/pipeline/types.js +2 -4
- package/out/pipeline/types.js.map +1 -1
- package/out/pipeline/useParsePipeline.d.ts +14 -9
- package/out/pipeline/useParsePipeline.d.ts.map +1 -1
- package/out/pipeline/useParsePipeline.js +7 -8
- package/out/pipeline/useParsePipeline.js.map +1 -1
- package/out/pipeline.d.ts +33 -0
- package/out/pipeline.d.ts.map +1 -0
- package/out/pipeline.js +21 -0
- package/out/pipeline.js.map +1 -0
- package/out/poi/AbstainPanel.d.ts +1 -1
- package/out/poi/LiveResultsBlock.d.ts +2 -1
- package/out/poi/LiveResultsBlock.d.ts.map +1 -1
- package/out/poi/LiveResultsBlock.js.map +1 -1
- package/out/poi/POIExplorer.d.ts +22 -12
- package/out/poi/POIExplorer.d.ts.map +1 -1
- package/out/poi/POIExplorer.js +11 -10
- package/out/poi/POIExplorer.js.map +1 -1
- package/out/poi/QueryInput.d.ts +1 -1
- package/out/poi/SubjectPanel.d.ts +2 -2
- package/out/poi/SubjectPanel.d.ts.map +1 -1
- package/out/poi/SubjectPanel.js +2 -1
- package/out/poi/SubjectPanel.js.map +1 -1
- package/out/poi/runtime.d.ts +5 -14
- package/out/poi/runtime.d.ts.map +1 -1
- package/out/poi/runtime.js +14 -16
- package/out/poi/runtime.js.map +1 -1
- package/out/poi/types.d.ts +53 -45
- package/out/poi/types.d.ts.map +1 -1
- package/out/poi/types.js +2 -4
- package/out/poi/types.js.map +1 -1
- package/out/poi/usePOISearch.d.ts +30 -17
- package/out/poi/usePOISearch.d.ts.map +1 -1
- package/out/poi/usePOISearch.js +48 -64
- package/out/poi/usePOISearch.js.map +1 -1
- package/out/poi.d.ts +23 -0
- package/out/poi.d.ts.map +1 -0
- package/out/poi.js +15 -0
- package/out/poi.js.map +1 -0
- package/out/runtime/useReleaseRuntime.d.ts +63 -74
- package/out/runtime/useReleaseRuntime.d.ts.map +1 -1
- package/out/runtime/useReleaseRuntime.js +31 -32
- package/out/runtime/useReleaseRuntime.js.map +1 -1
- package/out/{runtime/index.d.ts → runtime.d.ts} +1 -1
- package/out/runtime.d.ts.map +1 -0
- package/out/{runtime/index.js → runtime.js} +1 -1
- package/out/runtime.js.map +1 -0
- package/out/search/SearchModal.d.ts +40 -0
- package/out/search/SearchModal.d.ts.map +1 -0
- package/out/search/SearchModal.js +255 -0
- package/out/search/SearchModal.js.map +1 -0
- package/out/search/types.d.ts +65 -0
- package/out/search/types.d.ts.map +1 -0
- package/out/search/types.js +13 -0
- package/out/search/types.js.map +1 -0
- package/out/search.d.ts +11 -0
- package/out/search.d.ts.map +1 -0
- package/out/search.js +9 -0
- package/out/search.js.map +1 -0
- package/package.json +73 -165
- package/styles.css +482 -266
- package/tokens/index.css +24 -6
- package/tokens/tokens.json +114 -6
- package/lib/common/index.ts +0 -22
- package/lib/map/BackendControl.tsx +0 -51
- package/lib/map/index.ts +0 -95
- package/lib/pipeline/index.ts +0 -36
- package/lib/poi/index.ts +0 -39
- package/out/common/index.d.ts +0 -22
- package/out/common/index.d.ts.map +0 -1
- package/out/common/index.js +0 -15
- package/out/common/index.js.map +0 -1
- package/out/map/BackendControl.d.ts +0 -32
- package/out/map/BackendControl.d.ts.map +0 -1
- package/out/map/BackendControl.js +0 -8
- package/out/map/BackendControl.js.map +0 -1
- package/out/map/index.d.ts +0 -69
- package/out/map/index.d.ts.map +0 -1
- package/out/map/index.js +0 -46
- package/out/map/index.js.map +0 -1
- package/out/pipeline/index.d.ts +0 -33
- package/out/pipeline/index.d.ts.map +0 -1
- package/out/pipeline/index.js +0 -21
- package/out/pipeline/index.js.map +0 -1
- package/out/poi/index.d.ts +0 -23
- package/out/poi/index.d.ts.map +0 -1
- package/out/poi/index.js +0 -15
- package/out/poi/index.js.map +0 -1
- package/out/runtime/index.d.ts.map +0 -1
- package/out/runtime/index.js.map +0 -1
- package/styleframe.config.ts +0 -329
- package/vitest.node.config.ts +0 -20
- /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
|
+
}
|