@mailwoman/react 10.1.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 +1 -0
- package/lib/common/About.tsx +3 -3
- package/lib/common/CopyButton.tsx +6 -2
- package/lib/common/LoadingIndicator.tsx +10 -4
- package/lib/common/PresetChips.tsx +3 -1
- package/lib/common/confidence-tiers.ts +1 -1
- package/lib/common/text-tokens.ts +3 -9
- package/lib/index.ts +0 -1
- package/lib/map/AppIdentity.tsx +6 -2
- package/lib/map/DebugInfo.tsx +3 -16
- package/lib/map/Geocoder.tsx +9 -8
- package/lib/map/GeocoderControls.tsx +1 -13
- package/lib/map/GraticuleLayer.tsx +3 -1
- package/lib/map/MapCanvas.tsx +3 -1
- package/lib/map/MapChipRow.tsx +1 -1
- package/lib/map/MapCompass.tsx +3 -1
- package/lib/map/MapControlStack.tsx +3 -1
- package/lib/map/MapFooter.tsx +3 -1
- package/lib/map/OverlayLayers.tsx +13 -2
- package/lib/map/PlaceAutocomplete.tsx +3 -1
- package/lib/map/PlaceMarker.tsx +6 -2
- package/lib/map/ResolvedPlaceLayers.tsx +13 -5
- package/lib/map/ResultCamera.tsx +3 -1
- package/lib/map/ResultOverlay.tsx +12 -4
- package/lib/map/VersionPicker.tsx +1 -1
- package/lib/map/fake-runtime.ts +36 -7
- package/lib/map/map-debug.ts +3 -1
- package/lib/map/place-render.ts +3 -3
- package/lib/map/types.ts +2 -15
- package/lib/map/useMapPlaceRender.ts +1 -1
- package/lib/map/usePlaceAutocomplete.ts +2 -2
- package/lib/map.ts +0 -3
- package/lib/pipeline/ConfidenceCell.tsx +1 -1
- package/lib/pipeline/PipelineExplorer.tsx +6 -3
- package/lib/pipeline/ResolvedPlace.tsx +1 -1
- package/lib/pipeline/TreeView.tsx +1 -1
- package/lib/pipeline/types.ts +0 -4
- package/lib/poi/POIExplorer.tsx +6 -3
- package/lib/poi/runtime.ts +9 -0
- package/lib/poi/types.ts +27 -28
- package/lib/poi/usePOISearch.ts +55 -41
- package/lib/poi.ts +1 -0
- package/lib/runtime/useReleaseRuntime.ts +14 -33
- package/lib/search/SearchModal.tsx +227 -66
- package/lib/search/search.css +160 -22
- package/lib/search/types.ts +2 -1
- package/out/common/About.d.ts +1 -1
- package/out/common/About.js +1 -1
- package/out/common/About.js.map +1 -1
- package/out/common/CopyButton.d.ts +6 -2
- package/out/common/CopyButton.d.ts.map +1 -1
- package/out/common/CopyButton.js.map +1 -1
- package/out/common/LoadingIndicator.d.ts +10 -4
- package/out/common/LoadingIndicator.d.ts.map +1 -1
- package/out/common/LoadingIndicator.js.map +1 -1
- package/out/common/PresetChips.d.ts +3 -1
- package/out/common/PresetChips.d.ts.map +1 -1
- package/out/common/PresetChips.js.map +1 -1
- package/out/common/confidence-tiers.d.ts +1 -1
- package/out/common/confidence-tiers.d.ts.map +1 -1
- package/out/common/confidence-tiers.js.map +1 -1
- package/out/common/text-tokens.d.ts +2 -8
- package/out/common/text-tokens.d.ts.map +1 -1
- package/out/common/text-tokens.js.map +1 -1
- package/out/index.d.ts +1 -1
- package/out/index.d.ts.map +1 -1
- package/out/map/AppIdentity.d.ts +6 -2
- package/out/map/AppIdentity.d.ts.map +1 -1
- package/out/map/AppIdentity.js.map +1 -1
- package/out/map/DebugInfo.d.ts +2 -9
- package/out/map/DebugInfo.d.ts.map +1 -1
- package/out/map/DebugInfo.js +3 -6
- package/out/map/DebugInfo.js.map +1 -1
- package/out/map/Geocoder.d.ts +4 -4
- package/out/map/Geocoder.d.ts.map +1 -1
- package/out/map/Geocoder.js +4 -2
- package/out/map/Geocoder.js.map +1 -1
- package/out/map/GeocoderControls.d.ts +2 -3
- package/out/map/GeocoderControls.d.ts.map +1 -1
- package/out/map/GeocoderControls.js +2 -3
- package/out/map/GeocoderControls.js.map +1 -1
- package/out/map/GraticuleLayer.d.ts +3 -1
- package/out/map/GraticuleLayer.d.ts.map +1 -1
- package/out/map/GraticuleLayer.js.map +1 -1
- package/out/map/MapCanvas.d.ts +3 -1
- package/out/map/MapCanvas.d.ts.map +1 -1
- package/out/map/MapCanvas.js.map +1 -1
- package/out/map/MapChipRow.d.ts +1 -1
- package/out/map/MapCompass.d.ts +3 -1
- package/out/map/MapCompass.d.ts.map +1 -1
- package/out/map/MapCompass.js.map +1 -1
- package/out/map/MapControlStack.d.ts +3 -1
- package/out/map/MapControlStack.d.ts.map +1 -1
- package/out/map/MapControlStack.js.map +1 -1
- package/out/map/MapFooter.d.ts +3 -1
- package/out/map/MapFooter.d.ts.map +1 -1
- package/out/map/MapFooter.js.map +1 -1
- package/out/map/OverlayLayers.d.ts +7 -1
- package/out/map/OverlayLayers.d.ts.map +1 -1
- package/out/map/OverlayLayers.js +2 -2
- package/out/map/OverlayLayers.js.map +1 -1
- package/out/map/PlaceAutocomplete.d.ts +3 -1
- package/out/map/PlaceAutocomplete.d.ts.map +1 -1
- package/out/map/PlaceAutocomplete.js.map +1 -1
- package/out/map/PlaceMarker.d.ts +6 -2
- package/out/map/PlaceMarker.d.ts.map +1 -1
- package/out/map/PlaceMarker.js.map +1 -1
- package/out/map/ResolvedPlaceLayers.d.ts +13 -5
- package/out/map/ResolvedPlaceLayers.d.ts.map +1 -1
- package/out/map/ResolvedPlaceLayers.js.map +1 -1
- package/out/map/ResultCamera.d.ts +3 -1
- package/out/map/ResultCamera.d.ts.map +1 -1
- package/out/map/ResultCamera.js.map +1 -1
- package/out/map/ResultOverlay.d.ts +12 -4
- package/out/map/ResultOverlay.d.ts.map +1 -1
- package/out/map/ResultOverlay.js.map +1 -1
- package/out/map/VersionPicker.d.ts +1 -1
- package/out/map/fake-runtime.d.ts.map +1 -1
- package/out/map/fake-runtime.js +36 -7
- package/out/map/fake-runtime.js.map +1 -1
- package/out/map/map-debug.d.ts +3 -1
- package/out/map/map-debug.d.ts.map +1 -1
- package/out/map/map-debug.js.map +1 -1
- package/out/map/place-render.d.ts +3 -3
- package/out/map/place-render.d.ts.map +1 -1
- package/out/map/types.d.ts +2 -14
- package/out/map/types.d.ts.map +1 -1
- package/out/map/useMapPlaceRender.d.ts +1 -1
- package/out/map/useMapPlaceRender.d.ts.map +1 -1
- package/out/map/useMapPlaceRender.js.map +1 -1
- package/out/map/usePlaceAutocomplete.d.ts +2 -2
- package/out/map.d.ts +1 -3
- package/out/map.d.ts.map +1 -1
- package/out/map.js +0 -1
- package/out/map.js.map +1 -1
- package/out/pipeline/ConfidenceCell.d.ts +1 -1
- package/out/pipeline/ConfidenceCell.d.ts.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/ResolvedPlace.d.ts +1 -1
- package/out/pipeline/ResolvedPlace.d.ts.map +1 -1
- package/out/pipeline/TreeView.js.map +1 -1
- package/out/pipeline/types.d.ts +0 -4
- package/out/pipeline/types.d.ts.map +1 -1
- package/out/poi/POIExplorer.d.ts +1 -1
- package/out/poi/POIExplorer.d.ts.map +1 -1
- package/out/poi/POIExplorer.js +2 -2
- package/out/poi/POIExplorer.js.map +1 -1
- package/out/poi/runtime.d.ts.map +1 -1
- package/out/poi/runtime.js +9 -0
- package/out/poi/runtime.js.map +1 -1
- package/out/poi/types.d.ts +24 -28
- package/out/poi/types.d.ts.map +1 -1
- package/out/poi/usePOISearch.d.ts +4 -2
- package/out/poi/usePOISearch.d.ts.map +1 -1
- package/out/poi/usePOISearch.js +42 -32
- package/out/poi/usePOISearch.js.map +1 -1
- package/out/poi.d.ts +1 -1
- package/out/poi.d.ts.map +1 -1
- package/out/poi.js.map +1 -1
- package/out/runtime/useReleaseRuntime.d.ts +13 -23
- package/out/runtime/useReleaseRuntime.d.ts.map +1 -1
- package/out/runtime/useReleaseRuntime.js +2 -8
- package/out/runtime/useReleaseRuntime.js.map +1 -1
- package/out/search/SearchModal.d.ts +10 -3
- package/out/search/SearchModal.d.ts.map +1 -1
- package/out/search/SearchModal.js +83 -15
- package/out/search/SearchModal.js.map +1 -1
- package/out/search/types.d.ts +2 -1
- package/out/search/types.d.ts.map +1 -1
- package/package.json +19 -29
- package/lib/map/BackendControl.tsx +0 -50
- package/out/map/BackendControl.d.ts +0 -31
- package/out/map/BackendControl.d.ts.map +0 -1
- package/out/map/BackendControl.js +0 -8
- package/out/map/BackendControl.js.map +0 -1
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
*
|
|
6
6
|
* The docs search modal. A native `<dialog>` opened with `showModal()` supplies the focus trap, the inert
|
|
7
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
|
-
*
|
|
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.
|
|
10
12
|
*/
|
|
11
13
|
|
|
14
|
+
import { stringifyJSON, tryParsingJSON } from "@mailwoman/core/json"
|
|
12
15
|
import { type KeyboardEvent, type MouseEvent, type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react"
|
|
13
16
|
|
|
14
17
|
import { useDebouncedValue } from "#common/useDebouncedValue"
|
|
@@ -30,6 +33,11 @@ export interface SearchModalProps {
|
|
|
30
33
|
* because a new identity refires the request for the current query.
|
|
31
34
|
*/
|
|
32
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
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
type Status =
|
|
@@ -46,6 +54,7 @@ const DEBOUNCE_MS = 150
|
|
|
46
54
|
const LOADING_ANNOUNCE_MS = 300
|
|
47
55
|
const MAX_QUERY_LENGTH = 200
|
|
48
56
|
const DEFAULT_CATEGORY = "Documentation"
|
|
57
|
+
const RECENT_LIMIT = 5
|
|
49
58
|
|
|
50
59
|
/**
|
|
51
60
|
* The site-relative link for a hit.
|
|
@@ -68,30 +77,100 @@ function statusText(status: Status): string {
|
|
|
68
77
|
return response.hits.length === 1 ? "1 result." : `${response.hits.length} results.`
|
|
69
78
|
}
|
|
70
79
|
|
|
71
|
-
|
|
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[] {
|
|
72
102
|
const parts: ReactNode[] = []
|
|
73
103
|
let cursor = 0
|
|
74
104
|
|
|
75
|
-
for (const [start, end] of
|
|
105
|
+
for (const [start, end] of ranges) {
|
|
76
106
|
if (start < cursor) continue
|
|
77
107
|
|
|
78
|
-
parts.push(
|
|
108
|
+
parts.push(text.slice(cursor, start), <mark key={start}>{text.slice(start, end)}</mark>)
|
|
79
109
|
cursor = end
|
|
80
110
|
}
|
|
81
111
|
|
|
82
|
-
parts.push(
|
|
112
|
+
parts.push(text.slice(cursor))
|
|
83
113
|
|
|
84
114
|
return parts
|
|
85
115
|
}
|
|
86
116
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
}
|
|
92
149
|
}
|
|
93
150
|
|
|
94
|
-
|
|
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) {
|
|
95
174
|
const dialogRef = useRef<HTMLDialogElement>(null)
|
|
96
175
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
97
176
|
const baseID = useId()
|
|
@@ -100,6 +179,7 @@ export function SearchModal({ open, onClose, onNavigate, search }: SearchModalPr
|
|
|
100
179
|
const [settled, setSettled] = useState<Status>({ kind: "idle" })
|
|
101
180
|
const [selected, setSelected] = useState(0)
|
|
102
181
|
const [loading, setLoading] = useState(false)
|
|
182
|
+
const [recent, setRecent] = useState<string[]>([])
|
|
103
183
|
const trimmed = query.trim().slice(0, MAX_QUERY_LENGTH)
|
|
104
184
|
const debounced = useDebouncedValue(trimmed, DEBOUNCE_MS)
|
|
105
185
|
|
|
@@ -111,15 +191,16 @@ export function SearchModal({ open, onClose, onNavigate, search }: SearchModalPr
|
|
|
111
191
|
if (open && !dialog.open) {
|
|
112
192
|
dialog.showModal()
|
|
113
193
|
inputRef.current?.focus()
|
|
194
|
+
setRecent(readRecent(recentKey))
|
|
114
195
|
} else if (!open && dialog.open) {
|
|
115
196
|
dialog.close()
|
|
116
197
|
}
|
|
117
|
-
}, [open])
|
|
198
|
+
}, [open, recentKey])
|
|
118
199
|
|
|
119
200
|
useEffect(() => {
|
|
120
201
|
if (debounced === "") return
|
|
121
202
|
|
|
122
|
-
//
|
|
203
|
+
// The abort of the earlier request keeps a late response from replacing newer results.
|
|
123
204
|
const controller = new AbortController()
|
|
124
205
|
const loadingTimer = setTimeout(() => setLoading(true), LOADING_ANNOUNCE_MS)
|
|
125
206
|
|
|
@@ -175,10 +256,29 @@ export function SearchModal({ open, onClose, onNavigate, search }: SearchModalPr
|
|
|
175
256
|
|
|
176
257
|
const ordered = groups.flatMap((group) => group.entries)
|
|
177
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
|
+
}
|
|
178
274
|
|
|
179
275
|
function go(hit: SearchHit) {
|
|
180
276
|
const href = hitHref(hit)
|
|
181
277
|
|
|
278
|
+
if (trimmed !== "") {
|
|
279
|
+
remember(trimmed)
|
|
280
|
+
}
|
|
281
|
+
|
|
182
282
|
onClose()
|
|
183
283
|
|
|
184
284
|
if (onNavigate) {
|
|
@@ -243,6 +343,9 @@ export function SearchModal({ open, onClose, onNavigate, search }: SearchModalPr
|
|
|
243
343
|
go(hit)
|
|
244
344
|
}
|
|
245
345
|
|
|
346
|
+
const showRecent = trimmed === "" && recent.length > 0
|
|
347
|
+
const noResults = response !== undefined && response.hits.length === 0
|
|
348
|
+
|
|
246
349
|
return (
|
|
247
350
|
<dialog
|
|
248
351
|
ref={dialogRef}
|
|
@@ -252,66 +355,124 @@ export function SearchModal({ open, onClose, onNavigate, search }: SearchModalPr
|
|
|
252
355
|
onClick={onDialogClick}
|
|
253
356
|
>
|
|
254
357
|
<form role="search" className="mw-search__form" onSubmit={(event) => event.preventDefault()}>
|
|
255
|
-
<
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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>
|
|
273
382
|
</form>
|
|
274
383
|
|
|
275
|
-
{response?.corrected
|
|
384
|
+
{response?.corrected != null && (
|
|
276
385
|
<p className="mw-search__corrected">Showing results for “{response.corrected}”</p>
|
|
277
386
|
)}
|
|
278
387
|
|
|
279
|
-
<div
|
|
280
|
-
{
|
|
281
|
-
<
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
+
})}
|
|
289
454
|
</div>
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
// `option` is an allowed role on `a[href]`, which keeps the URL for open-in-new-tab.
|
|
293
|
-
<a
|
|
294
|
-
key={order}
|
|
295
|
-
id={optionID(order)}
|
|
296
|
-
role="option"
|
|
297
|
-
aria-selected={order === selected}
|
|
298
|
-
href={hitHref(hit)}
|
|
299
|
-
tabIndex={-1}
|
|
300
|
-
className="mw-search__option"
|
|
301
|
-
onPointerMove={() => setSelected(order)}
|
|
302
|
-
onClick={(event) => onLinkClick(event, hit)}
|
|
303
|
-
>
|
|
304
|
-
<span className="mw-search__title">{title(hit)}</span>
|
|
305
|
-
{hit.snippet !== "" && <span className="mw-search__snippet">{highlighted(hit)}</span>}
|
|
306
|
-
</a>
|
|
307
|
-
))}
|
|
308
|
-
</div>
|
|
309
|
-
))}
|
|
455
|
+
))}
|
|
456
|
+
</div>
|
|
310
457
|
</div>
|
|
311
458
|
|
|
312
|
-
<
|
|
313
|
-
|
|
314
|
-
|
|
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>
|
|
315
476
|
</dialog>
|
|
316
477
|
)
|
|
317
478
|
}
|
package/lib/search/search.css
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* The docs search modal.
|
|
3
3
|
*
|
|
4
4
|
* The native <dialog> has zero padding, so the only click whose target is the dialog element itself is a click on
|
|
5
|
-
* the backdrop. The padding sits on the form, the
|
|
5
|
+
* the backdrop. The padding sits on the form, the body and the footer instead.
|
|
6
6
|
*
|
|
7
7
|
* The selected option is marked by an inset accent bar as well as a fill, so the selection holds 3:1 against the
|
|
8
8
|
* panel without depending on the fill alone. Colors, radii, spacing, type and motion read semantic tokens from
|
|
9
9
|
* tokens.css. The layout literals are the panel width and height caps, the 44px input height, the 3px selection
|
|
10
|
-
* bar and the backdrop scrim, for which no token exists.
|
|
10
|
+
* bar, the 640px phone breakpoint, the icon size and the backdrop scrim, for which no token exists.
|
|
11
11
|
* --------------------------------------------------------------------------- */
|
|
12
12
|
|
|
13
13
|
.mw-search {
|
|
@@ -36,27 +36,64 @@
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
.mw-search__form {
|
|
39
|
-
|
|
39
|
+
display: flex;
|
|
40
|
+
gap: var(--space-3);
|
|
41
|
+
align-items: center;
|
|
42
|
+
padding: var(--space-4) var(--space-5);
|
|
40
43
|
border-bottom: 1px solid var(--color-separator);
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
.mw-
|
|
44
|
-
|
|
46
|
+
.mw-search__field {
|
|
47
|
+
display: flex;
|
|
48
|
+
flex: 1 1 auto;
|
|
49
|
+
gap: var(--space-3);
|
|
50
|
+
align-items: center;
|
|
45
51
|
min-height: 44px;
|
|
46
|
-
padding:
|
|
52
|
+
padding: 0 var(--space-4);
|
|
47
53
|
color: var(--control-foreground);
|
|
48
|
-
font: inherit;
|
|
49
|
-
font-size: var(--font-size-body);
|
|
50
54
|
background: var(--control-background);
|
|
51
55
|
border: 1px solid var(--control-border);
|
|
52
56
|
border-radius: var(--radius-control);
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
.mw-
|
|
59
|
+
.mw-search__field:focus-within {
|
|
56
60
|
outline: 2px solid var(--color-accent);
|
|
57
61
|
outline-offset: 1px;
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
.mw-search__icon {
|
|
65
|
+
flex: 0 0 auto;
|
|
66
|
+
width: 20px;
|
|
67
|
+
height: 20px;
|
|
68
|
+
color: var(--color-accent);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.mw-search__icon--spinning {
|
|
72
|
+
animation: mw-search-spin 0.8s linear infinite;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.mw-search__input {
|
|
76
|
+
flex: 1 1 auto;
|
|
77
|
+
min-width: 0;
|
|
78
|
+
padding: var(--space-3) 0;
|
|
79
|
+
color: inherit;
|
|
80
|
+
font: inherit;
|
|
81
|
+
font-size: var(--font-size-body);
|
|
82
|
+
background: transparent;
|
|
83
|
+
border: 0;
|
|
84
|
+
outline: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.mw-search__cancel {
|
|
88
|
+
display: none;
|
|
89
|
+
padding: var(--space-2) var(--space-3);
|
|
90
|
+
color: var(--color-accent);
|
|
91
|
+
font: inherit;
|
|
92
|
+
background: transparent;
|
|
93
|
+
border: 0;
|
|
94
|
+
cursor: pointer;
|
|
95
|
+
}
|
|
96
|
+
|
|
60
97
|
.mw-search__corrected {
|
|
61
98
|
margin: 0;
|
|
62
99
|
padding: var(--space-3) var(--space-5) 0;
|
|
@@ -64,7 +101,7 @@
|
|
|
64
101
|
font-size: var(--font-size-footnote);
|
|
65
102
|
}
|
|
66
103
|
|
|
67
|
-
.mw-
|
|
104
|
+
.mw-search__body {
|
|
68
105
|
flex: 1 1 auto;
|
|
69
106
|
min-height: 0;
|
|
70
107
|
padding: var(--space-3) var(--space-4);
|
|
@@ -78,6 +115,57 @@
|
|
|
78
115
|
font-weight: var(--font-weight-semibold);
|
|
79
116
|
}
|
|
80
117
|
|
|
118
|
+
.mw-search__recent-list {
|
|
119
|
+
margin: 0;
|
|
120
|
+
padding: 0;
|
|
121
|
+
list-style: none;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.mw-search__recent-item {
|
|
125
|
+
display: flex;
|
|
126
|
+
align-items: center;
|
|
127
|
+
border-radius: var(--radius-control);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.mw-search__recent-item:hover {
|
|
131
|
+
background: var(--color-background-sunken);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.mw-search__recent-query {
|
|
135
|
+
flex: 1 1 auto;
|
|
136
|
+
padding: var(--space-3) var(--space-4);
|
|
137
|
+
color: inherit;
|
|
138
|
+
font: inherit;
|
|
139
|
+
text-align: left;
|
|
140
|
+
background: transparent;
|
|
141
|
+
border: 0;
|
|
142
|
+
cursor: pointer;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.mw-search__recent-remove {
|
|
146
|
+
flex: 0 0 auto;
|
|
147
|
+
width: 44px;
|
|
148
|
+
height: 44px;
|
|
149
|
+
color: var(--color-text-secondary);
|
|
150
|
+
font: inherit;
|
|
151
|
+
font-size: var(--font-size-body);
|
|
152
|
+
background: transparent;
|
|
153
|
+
border: 0;
|
|
154
|
+
cursor: pointer;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.mw-search__recent-remove:hover,
|
|
158
|
+
.mw-search__recent-remove:focus-visible {
|
|
159
|
+
color: var(--color-text-primary);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.mw-search__empty {
|
|
163
|
+
margin: 0;
|
|
164
|
+
padding: var(--space-5) var(--space-4);
|
|
165
|
+
color: var(--color-text-secondary);
|
|
166
|
+
text-align: center;
|
|
167
|
+
}
|
|
168
|
+
|
|
81
169
|
.mw-search__option {
|
|
82
170
|
border-radius: var(--radius-control);
|
|
83
171
|
display: flex;
|
|
@@ -88,11 +176,20 @@
|
|
|
88
176
|
text-decoration: none;
|
|
89
177
|
}
|
|
90
178
|
|
|
179
|
+
.mw-search__option--section {
|
|
180
|
+
padding-left: calc(var(--space-4) + var(--space-5));
|
|
181
|
+
}
|
|
182
|
+
|
|
91
183
|
.mw-search__option[aria-selected="true"] {
|
|
92
184
|
background: var(--color-background-sunken);
|
|
93
185
|
box-shadow: inset 3px 0 0 var(--color-accent);
|
|
94
186
|
}
|
|
95
187
|
|
|
188
|
+
.mw-search__path {
|
|
189
|
+
color: var(--color-text-secondary);
|
|
190
|
+
font-size: var(--font-size-footnote);
|
|
191
|
+
}
|
|
192
|
+
|
|
96
193
|
.mw-search__title {
|
|
97
194
|
font-size: var(--font-size-subheadline);
|
|
98
195
|
font-weight: var(--font-weight-semibold);
|
|
@@ -103,31 +200,66 @@
|
|
|
103
200
|
font-size: var(--font-size-footnote);
|
|
104
201
|
}
|
|
105
202
|
|
|
106
|
-
.mw-
|
|
203
|
+
.mw-search mark {
|
|
107
204
|
color: var(--color-text-primary);
|
|
108
205
|
font-weight: var(--font-weight-semibold);
|
|
109
206
|
background: var(--color-accent-background);
|
|
110
207
|
border-radius: var(--radius-tick);
|
|
111
208
|
}
|
|
112
209
|
|
|
113
|
-
.mw-
|
|
114
|
-
|
|
210
|
+
.mw-search__footer {
|
|
211
|
+
display: flex;
|
|
212
|
+
gap: var(--space-4);
|
|
213
|
+
align-items: center;
|
|
214
|
+
justify-content: space-between;
|
|
115
215
|
padding: var(--space-3) var(--space-5);
|
|
116
216
|
color: var(--color-text-secondary);
|
|
117
217
|
font-size: var(--font-size-footnote);
|
|
118
218
|
border-top: 1px solid var(--color-separator);
|
|
119
219
|
}
|
|
120
220
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
221
|
+
.mw-search__status {
|
|
222
|
+
margin: 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.mw-search__hints {
|
|
226
|
+
display: flex;
|
|
227
|
+
gap: var(--space-4);
|
|
228
|
+
margin: 0;
|
|
126
229
|
padding: 0;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
230
|
+
list-style: none;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.mw-search__hints kbd {
|
|
234
|
+
display: inline-block;
|
|
235
|
+
min-width: 1.5em;
|
|
236
|
+
margin-right: var(--space-1);
|
|
237
|
+
padding: 0 var(--space-1);
|
|
238
|
+
font: inherit;
|
|
239
|
+
text-align: center;
|
|
240
|
+
background: var(--color-background-sunken);
|
|
241
|
+
border: 1px solid var(--control-border);
|
|
242
|
+
border-radius: var(--radius-tick);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@media (width <= 640px) {
|
|
246
|
+
.mw-search {
|
|
247
|
+
width: 100vw;
|
|
248
|
+
max-width: none;
|
|
249
|
+
height: 100dvh;
|
|
250
|
+
max-height: none;
|
|
251
|
+
margin: 0;
|
|
252
|
+
border: 0;
|
|
253
|
+
border-radius: 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.mw-search__cancel {
|
|
257
|
+
display: inline-block;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.mw-search__hints {
|
|
261
|
+
display: none;
|
|
262
|
+
}
|
|
131
263
|
}
|
|
132
264
|
|
|
133
265
|
@media (prefers-reduced-motion: no-preference) {
|
|
@@ -142,3 +274,9 @@
|
|
|
142
274
|
transform: translateY(calc(var(--space-4) * -1));
|
|
143
275
|
}
|
|
144
276
|
}
|
|
277
|
+
|
|
278
|
+
@keyframes mw-search-spin {
|
|
279
|
+
to {
|
|
280
|
+
transform: rotate(360deg);
|
|
281
|
+
}
|
|
282
|
+
}
|
package/lib/search/types.ts
CHANGED
|
@@ -61,7 +61,8 @@ export interface SearchResponse {
|
|
|
61
61
|
query: string
|
|
62
62
|
/**
|
|
63
63
|
* The text actually searched when a token was corrected against the vocabulary.
|
|
64
|
+
* It is `null` when no token was.
|
|
64
65
|
*/
|
|
65
|
-
corrected
|
|
66
|
+
corrected: string | null
|
|
66
67
|
hits: SearchHit[]
|
|
67
68
|
}
|
package/out/common/About.d.ts
CHANGED