@mailwoman/react 10.1.0 → 10.2.1

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 (178) hide show
  1. package/README.md +1 -0
  2. package/lib/common/About.tsx +3 -3
  3. package/lib/common/CopyButton.tsx +6 -2
  4. package/lib/common/LoadingIndicator.tsx +10 -4
  5. package/lib/common/PresetChips.tsx +3 -1
  6. package/lib/common/confidence-tiers.ts +1 -1
  7. package/lib/common/text-tokens.ts +3 -9
  8. package/lib/index.ts +0 -1
  9. package/lib/map/AppIdentity.tsx +6 -2
  10. package/lib/map/DebugInfo.tsx +3 -16
  11. package/lib/map/Geocoder.tsx +9 -8
  12. package/lib/map/GeocoderControls.tsx +1 -13
  13. package/lib/map/GraticuleLayer.tsx +3 -1
  14. package/lib/map/MapCanvas.tsx +3 -1
  15. package/lib/map/MapChipRow.tsx +1 -1
  16. package/lib/map/MapCompass.tsx +3 -1
  17. package/lib/map/MapControlStack.tsx +3 -1
  18. package/lib/map/MapFooter.tsx +3 -1
  19. package/lib/map/OverlayLayers.tsx +13 -2
  20. package/lib/map/PlaceAutocomplete.tsx +3 -1
  21. package/lib/map/PlaceMarker.tsx +6 -2
  22. package/lib/map/ResolvedPlaceLayers.tsx +13 -5
  23. package/lib/map/ResultCamera.tsx +3 -1
  24. package/lib/map/ResultOverlay.tsx +12 -4
  25. package/lib/map/VersionPicker.tsx +1 -1
  26. package/lib/map/fake-runtime.ts +36 -7
  27. package/lib/map/map-debug.ts +3 -1
  28. package/lib/map/place-render.ts +3 -3
  29. package/lib/map/types.ts +2 -15
  30. package/lib/map/useMapPlaceRender.ts +1 -1
  31. package/lib/map/usePlaceAutocomplete.ts +2 -2
  32. package/lib/map.ts +0 -3
  33. package/lib/pipeline/ConfidenceCell.tsx +1 -1
  34. package/lib/pipeline/PipelineExplorer.tsx +6 -3
  35. package/lib/pipeline/ResolvedPlace.tsx +1 -1
  36. package/lib/pipeline/TreeView.tsx +1 -1
  37. package/lib/pipeline/types.ts +0 -4
  38. package/lib/poi/POIExplorer.tsx +6 -3
  39. package/lib/poi/runtime.ts +9 -0
  40. package/lib/poi/types.ts +27 -28
  41. package/lib/poi/usePOISearch.ts +55 -41
  42. package/lib/poi.ts +1 -0
  43. package/lib/runtime/useReleaseRuntime.ts +14 -33
  44. package/lib/search/SearchModal.tsx +227 -66
  45. package/lib/search/search.css +160 -22
  46. package/lib/search/types.ts +2 -1
  47. package/out/common/About.d.ts +1 -1
  48. package/out/common/About.js +1 -1
  49. package/out/common/About.js.map +1 -1
  50. package/out/common/CopyButton.d.ts +6 -2
  51. package/out/common/CopyButton.d.ts.map +1 -1
  52. package/out/common/CopyButton.js.map +1 -1
  53. package/out/common/LoadingIndicator.d.ts +10 -4
  54. package/out/common/LoadingIndicator.d.ts.map +1 -1
  55. package/out/common/LoadingIndicator.js.map +1 -1
  56. package/out/common/PresetChips.d.ts +3 -1
  57. package/out/common/PresetChips.d.ts.map +1 -1
  58. package/out/common/PresetChips.js.map +1 -1
  59. package/out/common/confidence-tiers.d.ts +1 -1
  60. package/out/common/confidence-tiers.d.ts.map +1 -1
  61. package/out/common/confidence-tiers.js.map +1 -1
  62. package/out/common/text-tokens.d.ts +2 -8
  63. package/out/common/text-tokens.d.ts.map +1 -1
  64. package/out/common/text-tokens.js.map +1 -1
  65. package/out/index.d.ts +1 -1
  66. package/out/index.d.ts.map +1 -1
  67. package/out/map/AppIdentity.d.ts +6 -2
  68. package/out/map/AppIdentity.d.ts.map +1 -1
  69. package/out/map/AppIdentity.js.map +1 -1
  70. package/out/map/DebugInfo.d.ts +2 -9
  71. package/out/map/DebugInfo.d.ts.map +1 -1
  72. package/out/map/DebugInfo.js +3 -6
  73. package/out/map/DebugInfo.js.map +1 -1
  74. package/out/map/Geocoder.d.ts +4 -4
  75. package/out/map/Geocoder.d.ts.map +1 -1
  76. package/out/map/Geocoder.js +4 -2
  77. package/out/map/Geocoder.js.map +1 -1
  78. package/out/map/GeocoderControls.d.ts +2 -3
  79. package/out/map/GeocoderControls.d.ts.map +1 -1
  80. package/out/map/GeocoderControls.js +2 -3
  81. package/out/map/GeocoderControls.js.map +1 -1
  82. package/out/map/GraticuleLayer.d.ts +3 -1
  83. package/out/map/GraticuleLayer.d.ts.map +1 -1
  84. package/out/map/GraticuleLayer.js.map +1 -1
  85. package/out/map/MapCanvas.d.ts +3 -1
  86. package/out/map/MapCanvas.d.ts.map +1 -1
  87. package/out/map/MapCanvas.js.map +1 -1
  88. package/out/map/MapChipRow.d.ts +1 -1
  89. package/out/map/MapCompass.d.ts +3 -1
  90. package/out/map/MapCompass.d.ts.map +1 -1
  91. package/out/map/MapCompass.js.map +1 -1
  92. package/out/map/MapControlStack.d.ts +3 -1
  93. package/out/map/MapControlStack.d.ts.map +1 -1
  94. package/out/map/MapControlStack.js.map +1 -1
  95. package/out/map/MapFooter.d.ts +3 -1
  96. package/out/map/MapFooter.d.ts.map +1 -1
  97. package/out/map/MapFooter.js.map +1 -1
  98. package/out/map/OverlayLayers.d.ts +7 -1
  99. package/out/map/OverlayLayers.d.ts.map +1 -1
  100. package/out/map/OverlayLayers.js +2 -2
  101. package/out/map/OverlayLayers.js.map +1 -1
  102. package/out/map/PlaceAutocomplete.d.ts +3 -1
  103. package/out/map/PlaceAutocomplete.d.ts.map +1 -1
  104. package/out/map/PlaceAutocomplete.js.map +1 -1
  105. package/out/map/PlaceMarker.d.ts +6 -2
  106. package/out/map/PlaceMarker.d.ts.map +1 -1
  107. package/out/map/PlaceMarker.js.map +1 -1
  108. package/out/map/ResolvedPlaceLayers.d.ts +13 -5
  109. package/out/map/ResolvedPlaceLayers.d.ts.map +1 -1
  110. package/out/map/ResolvedPlaceLayers.js.map +1 -1
  111. package/out/map/ResultCamera.d.ts +3 -1
  112. package/out/map/ResultCamera.d.ts.map +1 -1
  113. package/out/map/ResultCamera.js.map +1 -1
  114. package/out/map/ResultOverlay.d.ts +12 -4
  115. package/out/map/ResultOverlay.d.ts.map +1 -1
  116. package/out/map/ResultOverlay.js.map +1 -1
  117. package/out/map/VersionPicker.d.ts +1 -1
  118. package/out/map/fake-runtime.d.ts.map +1 -1
  119. package/out/map/fake-runtime.js +36 -7
  120. package/out/map/fake-runtime.js.map +1 -1
  121. package/out/map/map-debug.d.ts +3 -1
  122. package/out/map/map-debug.d.ts.map +1 -1
  123. package/out/map/map-debug.js.map +1 -1
  124. package/out/map/place-render.d.ts +3 -3
  125. package/out/map/place-render.d.ts.map +1 -1
  126. package/out/map/types.d.ts +2 -14
  127. package/out/map/types.d.ts.map +1 -1
  128. package/out/map/useMapPlaceRender.d.ts +1 -1
  129. package/out/map/useMapPlaceRender.d.ts.map +1 -1
  130. package/out/map/useMapPlaceRender.js.map +1 -1
  131. package/out/map/usePlaceAutocomplete.d.ts +2 -2
  132. package/out/map.d.ts +1 -3
  133. package/out/map.d.ts.map +1 -1
  134. package/out/map.js +0 -1
  135. package/out/map.js.map +1 -1
  136. package/out/pipeline/ConfidenceCell.d.ts +1 -1
  137. package/out/pipeline/ConfidenceCell.d.ts.map +1 -1
  138. package/out/pipeline/PipelineExplorer.d.ts +6 -2
  139. package/out/pipeline/PipelineExplorer.d.ts.map +1 -1
  140. package/out/pipeline/PipelineExplorer.js +1 -1
  141. package/out/pipeline/PipelineExplorer.js.map +1 -1
  142. package/out/pipeline/ResolvedPlace.d.ts +1 -1
  143. package/out/pipeline/ResolvedPlace.d.ts.map +1 -1
  144. package/out/pipeline/TreeView.js.map +1 -1
  145. package/out/pipeline/types.d.ts +0 -4
  146. package/out/pipeline/types.d.ts.map +1 -1
  147. package/out/poi/POIExplorer.d.ts +1 -1
  148. package/out/poi/POIExplorer.d.ts.map +1 -1
  149. package/out/poi/POIExplorer.js +2 -2
  150. package/out/poi/POIExplorer.js.map +1 -1
  151. package/out/poi/runtime.d.ts.map +1 -1
  152. package/out/poi/runtime.js +9 -0
  153. package/out/poi/runtime.js.map +1 -1
  154. package/out/poi/types.d.ts +24 -28
  155. package/out/poi/types.d.ts.map +1 -1
  156. package/out/poi/usePOISearch.d.ts +4 -2
  157. package/out/poi/usePOISearch.d.ts.map +1 -1
  158. package/out/poi/usePOISearch.js +42 -32
  159. package/out/poi/usePOISearch.js.map +1 -1
  160. package/out/poi.d.ts +1 -1
  161. package/out/poi.d.ts.map +1 -1
  162. package/out/poi.js.map +1 -1
  163. package/out/runtime/useReleaseRuntime.d.ts +13 -23
  164. package/out/runtime/useReleaseRuntime.d.ts.map +1 -1
  165. package/out/runtime/useReleaseRuntime.js +2 -8
  166. package/out/runtime/useReleaseRuntime.js.map +1 -1
  167. package/out/search/SearchModal.d.ts +10 -3
  168. package/out/search/SearchModal.d.ts.map +1 -1
  169. package/out/search/SearchModal.js +83 -15
  170. package/out/search/SearchModal.js.map +1 -1
  171. package/out/search/types.d.ts +2 -1
  172. package/out/search/types.d.ts.map +1 -1
  173. package/package.json +19 -29
  174. package/lib/map/BackendControl.tsx +0 -50
  175. package/out/map/BackendControl.d.ts +0 -31
  176. package/out/map/BackendControl.d.ts.map +0 -1
  177. package/out/map/BackendControl.js +0 -8
  178. 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 the selected hit is
9
- * named by `aria-activedescendant`. The component owns no data access: `search` is passed in.
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
- function highlighted(hit: SearchHit): ReactNode[] {
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 hit.highlights) {
105
+ for (const [start, end] of ranges) {
76
106
  if (start < cursor) continue
77
107
 
78
- parts.push(hit.snippet.slice(cursor, start), <mark key={start}>{hit.snippet.slice(start, end)}</mark>)
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(hit.snippet.slice(cursor))
112
+ parts.push(text.slice(cursor))
83
113
 
84
114
  return parts
85
115
  }
86
116
 
87
- function title(hit: SearchHit): string {
88
- return hit.hierarchy
89
- .slice(1)
90
- .filter((entry): entry is string => entry !== null)
91
- .join(" › ")
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
- export function SearchModal({ open, onClose, onNavigate, search }: SearchModalProps) {
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
- // Aborting the earlier request is what keeps a late response from replacing newer results.
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
- <input
256
- ref={inputRef}
257
- type="search"
258
- className="mw-search__input"
259
- role="combobox"
260
- aria-label="Search the documentation"
261
- aria-autocomplete="list"
262
- aria-expanded={ordered.length > 0}
263
- aria-controls={listboxID}
264
- aria-activedescendant={ordered.length ? optionID(selected) : undefined}
265
- autoComplete="off"
266
- spellCheck={false}
267
- maxLength={MAX_QUERY_LENGTH}
268
- placeholder="Search the documentation"
269
- value={query}
270
- onChange={(event) => setQuery(event.target.value)}
271
- onKeyDown={onKeyDown}
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 !== undefined && (
384
+ {response?.corrected != null && (
276
385
  <p className="mw-search__corrected">Showing results for “{response.corrected}”</p>
277
386
  )}
278
387
 
279
- <div id={listboxID} role="listbox" aria-label="Search results" className="mw-search__results">
280
- {groups.map((group, groupIndex) => (
281
- <div
282
- key={group.category}
283
- role="group"
284
- aria-labelledby={`${baseID}-group-${groupIndex}`}
285
- className="mw-search__group"
286
- >
287
- <div id={`${baseID}-group-${groupIndex}`} className="mw-search__category">
288
- {group.category}
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
- {group.entries.map(({ hit, order }) => (
291
- // The option is the link itself: an option may not contain an interactive element, and
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
- <p aria-live="polite" className="mw-search__status">
313
- {statusText(announced)}
314
- </p>
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
  }
@@ -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 results and the status line instead.
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
- padding: var(--space-5);
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-search__input {
44
- width: 100%;
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: var(--space-3) var(--space-4);
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-search__input:focus-visible {
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-search__results {
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-search__snippet mark {
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-search__status {
114
- margin: 0;
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
- /* An empty live region stays rendered, because a region that appears with its text is often not announced. */
122
- .mw-search__status:empty {
123
- position: absolute;
124
- width: 1px;
125
- height: 1px;
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
- overflow: hidden;
128
- border: 0;
129
- clip-path: inset(50%);
130
- white-space: nowrap;
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
+ }
@@ -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?: string
66
+ corrected: string | null
66
67
  hits: SearchHit[]
67
68
  }
@@ -13,7 +13,7 @@ export interface AboutProps {
13
13
  * Set false where the host is already the disclosure, because a summary repeating
14
14
  * an About button's words is one control too many.
15
15
  *
16
- * @default true
16
+ * @defaultValue `true`
17
17
  */
18
18
  collapsible?: boolean;
19
19
  }