@mailwoman/react 9.4.0 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/lib/common/About.tsx +87 -71
  2. package/lib/common/index.ts +22 -0
  3. package/lib/index.ts +4 -99
  4. package/lib/map/BackendControl.tsx +7 -3
  5. package/lib/map/CompareToggle.tsx +3 -2
  6. package/lib/map/Geocoder.tsx +48 -28
  7. package/lib/map/GeocoderControls.tsx +258 -99
  8. package/lib/map/MapChipRow.tsx +77 -0
  9. package/lib/map/MapCompass.tsx +77 -0
  10. package/lib/map/MapControlStack.tsx +98 -0
  11. package/lib/map/MapFooter.tsx +115 -0
  12. package/lib/map/MapProgressBar.tsx +58 -0
  13. package/lib/map/MapSearchBar.tsx +68 -0
  14. package/lib/map/MapSheet.tsx +62 -0
  15. package/lib/map/index.ts +19 -0
  16. package/lib/map/types.ts +18 -4
  17. package/lib/map/useMapBearing.ts +56 -0
  18. package/lib/map/useMapLabelPick.ts +123 -0
  19. package/lib/pipeline/index.ts +36 -0
  20. package/lib/pipeline/useParsePipeline.ts +29 -18
  21. package/lib/poi/index.ts +39 -0
  22. package/lib/runtime/index.ts +16 -0
  23. package/out/common/About.d.ts +13 -4
  24. package/out/common/About.d.ts.map +1 -1
  25. package/out/common/About.js +5 -2
  26. package/out/common/About.js.map +1 -1
  27. package/out/common/index.d.ts +22 -0
  28. package/out/common/index.d.ts.map +1 -0
  29. package/out/common/index.js +15 -0
  30. package/out/common/index.js.map +1 -0
  31. package/out/index.d.ts +4 -59
  32. package/out/index.d.ts.map +1 -1
  33. package/out/index.js +4 -36
  34. package/out/index.js.map +1 -1
  35. package/out/map/BackendControl.d.ts.map +1 -1
  36. package/out/map/BackendControl.js +1 -1
  37. package/out/map/BackendControl.js.map +1 -1
  38. package/out/map/CompareToggle.d.ts.map +1 -1
  39. package/out/map/CompareToggle.js +1 -1
  40. package/out/map/CompareToggle.js.map +1 -1
  41. package/out/map/Geocoder.d.ts +11 -1
  42. package/out/map/Geocoder.d.ts.map +1 -1
  43. package/out/map/Geocoder.js +26 -26
  44. package/out/map/Geocoder.js.map +1 -1
  45. package/out/map/GeocoderControls.d.ts +29 -11
  46. package/out/map/GeocoderControls.d.ts.map +1 -1
  47. package/out/map/GeocoderControls.js +65 -16
  48. package/out/map/GeocoderControls.js.map +1 -1
  49. package/out/map/MapChipRow.d.ts +47 -0
  50. package/out/map/MapChipRow.d.ts.map +1 -0
  51. package/out/map/MapChipRow.js +12 -0
  52. package/out/map/MapChipRow.js.map +1 -0
  53. package/out/map/MapCompass.d.ts +42 -0
  54. package/out/map/MapCompass.d.ts.map +1 -0
  55. package/out/map/MapCompass.js +11 -0
  56. package/out/map/MapCompass.js.map +1 -0
  57. package/out/map/MapControlStack.d.ts +57 -0
  58. package/out/map/MapControlStack.d.ts.map +1 -0
  59. package/out/map/MapControlStack.js +15 -0
  60. package/out/map/MapControlStack.js.map +1 -0
  61. package/out/map/MapFooter.d.ts +40 -0
  62. package/out/map/MapFooter.d.ts.map +1 -0
  63. package/out/map/MapFooter.js +51 -0
  64. package/out/map/MapFooter.js.map +1 -0
  65. package/out/map/MapProgressBar.d.ts +34 -0
  66. package/out/map/MapProgressBar.d.ts.map +1 -0
  67. package/out/map/MapProgressBar.js +8 -0
  68. package/out/map/MapProgressBar.js.map +1 -0
  69. package/out/map/MapSearchBar.d.ts +49 -0
  70. package/out/map/MapSearchBar.d.ts.map +1 -0
  71. package/out/map/MapSearchBar.js +6 -0
  72. package/out/map/MapSearchBar.js.map +1 -0
  73. package/out/map/MapSheet.d.ts +32 -0
  74. package/out/map/MapSheet.d.ts.map +1 -0
  75. package/out/map/MapSheet.js +33 -0
  76. package/out/map/MapSheet.js.map +1 -0
  77. package/out/map/OverlayLayers.js +1 -1
  78. package/out/map/ResolvedPlaceLayers.js +1 -1
  79. package/out/map/ResultPanel.js +1 -1
  80. package/out/map/fake-runtime.js.map +1 -1
  81. package/out/map/index.d.ts +17 -0
  82. package/out/map/index.d.ts.map +1 -1
  83. package/out/map/index.js +10 -0
  84. package/out/map/index.js.map +1 -1
  85. package/out/map/map-debug.d.ts.map +1 -1
  86. package/out/map/map-debug.js.map +1 -1
  87. package/out/map/types.d.ts +20 -4
  88. package/out/map/types.d.ts.map +1 -1
  89. package/out/map/useMapBearing.d.ts +28 -0
  90. package/out/map/useMapBearing.d.ts.map +1 -0
  91. package/out/map/useMapBearing.js +35 -0
  92. package/out/map/useMapBearing.js.map +1 -0
  93. package/out/map/useMapLabelPick.d.ts +23 -0
  94. package/out/map/useMapLabelPick.d.ts.map +1 -0
  95. package/out/map/useMapLabelPick.js +104 -0
  96. package/out/map/useMapLabelPick.js.map +1 -0
  97. package/out/pipeline/CandidatePicker.js +1 -1
  98. package/out/pipeline/PipelineExplorer.js +1 -1
  99. package/out/pipeline/QueryForm.js +1 -1
  100. package/out/pipeline/ResolvedPlace.js +1 -1
  101. package/out/pipeline/TimingPanel.js +1 -1
  102. package/out/pipeline/index.d.ts +33 -0
  103. package/out/pipeline/index.d.ts.map +1 -0
  104. package/out/pipeline/index.js +21 -0
  105. package/out/pipeline/index.js.map +1 -0
  106. package/out/pipeline/useParsePipeline.d.ts +5 -2
  107. package/out/pipeline/useParsePipeline.d.ts.map +1 -1
  108. package/out/pipeline/useParsePipeline.js +6 -2
  109. package/out/pipeline/useParsePipeline.js.map +1 -1
  110. package/out/poi/LiveResultsBlock.js +1 -1
  111. package/out/poi/POIExplorer.js +1 -1
  112. package/out/poi/SubjectPanel.js +1 -1
  113. package/out/poi/index.d.ts +23 -0
  114. package/out/poi/index.d.ts.map +1 -0
  115. package/out/poi/index.js +15 -0
  116. package/out/poi/index.js.map +1 -0
  117. package/out/poi/runtime.d.ts.map +1 -1
  118. package/out/runtime/index.d.ts +9 -0
  119. package/out/runtime/index.d.ts.map +1 -0
  120. package/out/runtime/index.js +8 -0
  121. package/out/runtime/index.js.map +1 -0
  122. package/package.json +87 -6
  123. package/styleframe.config.ts +9 -1
  124. package/styles.css +948 -26
  125. package/tokens/index.css +2 -1
  126. package/tokens/tokens.json +7 -0
  127. package/tokens/tokens.resolver.json +1 -1
@@ -3,28 +3,36 @@
3
3
  * @license AGPL-3.0
4
4
  * @author Teffen Ellis, et al.
5
5
  *
6
- * `<GeocoderControls>` — the geocoder's floating control panel, assembled from the geocoder-specific units
7
- * (VersionPicker / CompareToggle / BackendControl / PlaceAutocomplete / ResultPanel) and the REUSED
8
- * pipeline units (QueryForm / PresetChips / LoadingIndicator). It reads the injected {@link GeocoderRuntime}
9
- * for ready/version/backend/loading state and threads the {@link useGeocode} + {@link usePlaceAutocomplete}
10
- * + {@link useCompareState} hooks into the controls. Host-specific visualizers arrive via {@link GeocoderPanels}.
6
+ * The geocoder's chrome over the map: a search pill on top, example chips on one line beneath it, a control column
7
+ * down the right edge, and a result sheet that rises from the bottom when there is something to show.
11
8
  *
12
- * NODE-SAFE: pure React + the shared/geocoder units, no maplibre. It rides `@mailwoman/react/map` as a
13
- * geocoder-specific control, not for WebGL.
9
+ * It replaces a 400px full-height panel that carried every control at once. The layout is the one the reference
10
+ * map apps use, and the reason is the map: chrome that floats over it leaves the map the page, where a slab makes
11
+ * the map the leftover third.
12
+ *
13
+ * NODE-SAFE: pure React + the shared units, no maplibre.
14
14
  */
15
15
 
16
- import type { ReactNode } from "react"
16
+ import { type ReactNode, useCallback, useState } from "react"
17
+ import type { MapInstance } from "react-map-gl/maplibre"
17
18
 
18
19
  import type { GeocoderPanels, GeocoderRuntime } from "#map/types"
19
20
  import type { UseCompareState } from "#map/useCompareState"
20
21
  import type { UseGeocode } from "#map/useGeocode"
22
+ import { useMapBearing } from "#map/useMapBearing"
23
+ import { useMapLabelPick } from "#map/useMapLabelPick"
21
24
  import type { UsePlaceAutocomplete } from "#map/usePlaceAutocomplete"
22
25
 
23
26
  import { LoadingIndicator } from "../common/LoadingIndicator.tsx"
24
- import { PresetChips, type Preset } from "../common/PresetChips.tsx"
25
- import { QueryForm } from "../pipeline/QueryForm.tsx"
27
+ import type { Preset } from "../common/PresetChips.tsx"
26
28
  import { BackendControl } from "./BackendControl.tsx"
27
29
  import { CompareToggle } from "./CompareToggle.tsx"
30
+ import { MapChipRow } from "./MapChipRow.tsx"
31
+ import { MapCompass } from "./MapCompass.tsx"
32
+ import { MapControlButton, MapControlGroup, MapControlStack } from "./MapControlStack.tsx"
33
+ import { MapProgressBar } from "./MapProgressBar.tsx"
34
+ import { MapSearchBar } from "./MapSearchBar.tsx"
35
+ import { MapSheet } from "./MapSheet.tsx"
28
36
  import { PlaceAutocomplete } from "./PlaceAutocomplete.tsx"
29
37
  import { ResultPanel } from "./ResultPanel.tsx"
30
38
  import { VersionPicker } from "./VersionPicker.tsx"
@@ -58,6 +66,16 @@ export interface GeocoderControlsProps {
58
66
  * Input placeholder.
59
67
  */
60
68
  placeholder: string
69
+ /**
70
+ * The live map, for the controls that read it: the compass takes its bearing, the host's layer control reads its
71
+ * style. `null` until react-map-gl instantiates the map.
72
+ */
73
+ map?: MapInstance | null
74
+ /**
75
+ * Fired with the query whenever one is submitted, before the parse starts. The host writes it into the URL; this
76
+ * package never touches `location`, because the address bar is the app's state, not a control's.
77
+ */
78
+ onSubmitQuery?: (query: string) => void
61
79
  /**
62
80
  * Select a model version (the composed geocoder also clears a now-colliding compare selection).
63
81
  */
@@ -66,10 +84,22 @@ export interface GeocoderControlsProps {
66
84
  * Toggle the forced WASM backend.
67
85
  */
68
86
  onForceWASMChange: (forceWASM: boolean) => void
87
+ /**
88
+ * Open the developer panel on mount. The model version, the backend readout and compare live inside it: they read on
89
+ * the model rather than on an address, and above the query field they were the first thing every visitor met.
90
+ *
91
+ * @default false
92
+ */
93
+ developer?: boolean
69
94
  }
70
95
 
71
96
  /**
72
- * The floating control panel.
97
+ * Which side sheet is open. At most one, because they share an edge and a phone gives each the whole panel.
98
+ */
99
+ type SheetName = "about" | "layers" | "developer" | null
100
+
101
+ /**
102
+ * The chrome. Everything positioned here floats over the map; nothing occupies a column of the page.
73
103
  */
74
104
  export function GeocoderControls({
75
105
  runtime,
@@ -79,115 +109,244 @@ export function GeocoderControls({
79
109
  panels,
80
110
  presets,
81
111
  placeholder,
112
+ map = null,
113
+ onSubmitQuery,
82
114
  onSelectVersion,
83
115
  onForceWASMChange,
116
+ developer = false,
84
117
  }: GeocoderControlsProps): ReactNode {
85
118
  const versions = runtime.availableVersions ?? []
86
119
  const { busy, result, selectedCandidate } = geocode
87
120
  const loading = runtime.loading
88
121
  const errorMessage = geocode.parseError ?? runtime.errorMessage ?? null
89
122
 
90
- return (
91
- <section className="mw-demo-controls">
92
- {panels.header}
93
- {panels.releaseInfo}
94
-
95
- <VersionPicker
96
- versions={versions}
97
- selected={runtime.selectedVersion ?? null}
98
- onSelect={onSelectVersion}
99
- disabled={busy}
100
- />
123
+ // One sheet at a time. Two open at once stack on the same edge, and on a phone each is the full panel.
124
+ const [openSheet, setOpenSheet] = useState<SheetName>(developer ? "developer" : null)
125
+ const { bearing, resetNorth } = useMapBearing(map)
101
126
 
102
- <BackendControl
103
- activeBackend={runtime.activeBackend}
104
- forceWASM={runtime.forceWASM ?? false}
105
- onForceWASMChange={onForceWASMChange}
106
- />
127
+ const toggleSheet = (name: Exclude<SheetName, null>) => setOpenSheet((current) => (current === name ? null : name))
128
+ const closeSheet = useCallback(() => setOpenSheet(null), [])
107
129
 
108
- <CompareToggle
109
- versions={versions}
110
- primaryVersion={runtime.selectedVersion ?? null}
111
- compareMode={compare.compareMode}
112
- onCompareModeChange={compare.setCompareMode}
113
- compareVersion={compare.compareVersion}
114
- onCompareVersionChange={compare.setCompareVersion}
115
- disabled={busy}
116
- />
130
+ // Every path that starts a query goes through here, so the URL is written in exactly one place — the event that
131
+ // caused it, rather than an effect watching the result after the fact.
132
+ const runQuery = useCallback(
133
+ (query: string) => {
134
+ onSubmitQuery?.(query)
135
+ void geocode.submit(query)
136
+ },
137
+ [onSubmitQuery, geocode]
138
+ )
117
139
 
118
- <QueryForm
119
- value={geocode.text}
120
- onChange={geocode.setText}
121
- onSubmit={geocode.submit}
122
- disabled={!runtime.ready}
123
- busy={busy}
124
- placeholder={placeholder}
125
- onKeyDown={autocomplete.onInputKeyDown}
126
- inputProps={autocomplete.inputProps}
127
- />
140
+ // A label on the map is a search a visitor already typed by pointing at it.
141
+ const pickLabel = useCallback(
142
+ (name: string) => {
143
+ geocode.setText(name)
144
+ geocode.reset()
145
+ runQuery(name)
146
+ },
147
+ [geocode, runQuery]
148
+ )
128
149
 
129
- {panels.bias}
150
+ useMapLabelPick(map, pickLabel)
130
151
 
131
- <PlaceAutocomplete
132
- suggestions={autocomplete.suggestions}
133
- activeIndex={autocomplete.activeIndex}
134
- onPick={autocomplete.pick}
135
- onHover={autocomplete.setActiveIndex}
136
- listboxID={autocomplete.listboxID}
137
- optionID={autocomplete.optionID}
138
- />
152
+ const chips = presets.map((preset) => ({ label: preset.label, value: preset.value }))
153
+ // The bundle load no longer opens the result sheet: it reports on the bar at the top of the viewport and in the
154
+ // footer, so an empty sheet does not sit over the map for the length of a 38 MB download.
155
+ const showSheet = Boolean(busy || result || errorMessage)
156
+
157
+ const bundleLoading = Boolean(loading && !runtime.ready)
158
+ const steps = loading?.stepLabels.length ?? 0
139
159
 
140
- <PresetChips
141
- presets={presets}
142
- disabled={!runtime.ready || busy}
143
- onPick={(value) => {
144
- geocode.setText(value)
145
- geocode.reset()
146
- }}
147
- trailing={panels.permalink ? panels.permalink(geocode.text) : null}
160
+ return (
161
+ <>
162
+ <MapProgressBar
163
+ active={bundleLoading}
164
+ fraction={steps ? ((loading?.stepIndex ?? 0) + 1) / steps : null}
165
+ label="Loading the geocoder"
148
166
  />
149
167
 
150
- {loading && !runtime.ready ? (
151
- <LoadingIndicator
152
- mode="staged"
153
- steps={loading.stepLabels.length ? loading.stepLabels : undefined}
154
- activeStep={loading.stepIndex}
155
- label={loading.progress}
168
+ <div className="mw-map-chrome mw-map-chrome--top">
169
+ <form
170
+ className="mw-map-chrome__search"
171
+ onSubmit={(event) => {
172
+ event.preventDefault()
173
+ runQuery(geocode.text)
174
+ }}
175
+ >
176
+ {/*
177
+ * `type="search"` brings its own clear button, so the pill carries no trailing slot: a second cross
178
+ * beside the native one is two controls for one job, and the spinner that used to live there changed
179
+ * the field's height on every submit.
180
+ */}
181
+ <MapSearchBar label="Search addresses" leading={<span aria-hidden="true">⌕</span>} busy={busy}>
182
+ <input
183
+ id="mw-pipeline-input"
184
+ type="search"
185
+ aria-label="Address"
186
+ value={geocode.text}
187
+ onChange={(event) => geocode.setText(event.target.value)}
188
+ disabled={!runtime.ready}
189
+ placeholder={placeholder}
190
+ onKeyDown={autocomplete.onInputKeyDown}
191
+ {...autocomplete.inputProps}
192
+ />
193
+ </MapSearchBar>
194
+ </form>
195
+
196
+ <PlaceAutocomplete
197
+ suggestions={autocomplete.suggestions}
198
+ activeIndex={autocomplete.activeIndex}
199
+ onPick={autocomplete.pick}
200
+ onHover={autocomplete.setActiveIndex}
201
+ listboxID={autocomplete.listboxID}
202
+ optionID={autocomplete.optionID}
156
203
  />
204
+
205
+ <MapChipRow
206
+ chips={chips}
207
+ label="Example addresses"
208
+ activeValue={geocode.text}
209
+ disabled={!runtime.ready || busy}
210
+ onPick={(value) => {
211
+ geocode.setText(value)
212
+ geocode.reset()
213
+ runQuery(value)
214
+ }}
215
+ />
216
+
217
+ {panels.bias}
218
+ </div>
219
+
220
+ {/*
221
+ * Every floating control lives in this one column, so nothing can land on top of anything else: the layer
222
+ * control joins the capsule rather than sitting in the left column, and the compass takes its own capsule
223
+ * below because it comes and goes and would otherwise resize the one above it.
224
+ */}
225
+ <MapControlStack label="Map controls">
226
+ <MapControlGroup>
227
+ <MapControlButton
228
+ label="About this geocoder"
229
+ active={openSheet === "about"}
230
+ onPress={() => toggleSheet("about")}
231
+ >
232
+ <span aria-hidden="true">i</span>
233
+ </MapControlButton>
234
+
235
+ {panels.layers ? (
236
+ <MapControlButton label="Map layers" active={openSheet === "layers"} onPress={() => toggleSheet("layers")}>
237
+ <span aria-hidden="true">≡</span>
238
+ </MapControlButton>
239
+ ) : null}
240
+
241
+ <MapControlButton
242
+ label="Developer controls"
243
+ active={openSheet === "developer"}
244
+ onPress={() => toggleSheet("developer")}
245
+ >
246
+ <span aria-hidden="true">⚙</span>
247
+ </MapControlButton>
248
+ </MapControlGroup>
249
+
250
+ <MapControlGroup className="mw-map-control-group--compass">
251
+ <MapCompass bearing={bearing} onResetNorth={resetNorth} />
252
+ </MapControlGroup>
253
+ </MapControlStack>
254
+
255
+ {openSheet === "about" ? (
256
+ <MapSheet title="About" onClose={closeSheet}>
257
+ {panels.header}
258
+ </MapSheet>
157
259
  ) : null}
158
- {errorMessage ? <p className="mw-error">{errorMessage}</p> : null}
159
-
160
- {panels.aboveResult ? panels.aboveResult({ result }) : null}
161
-
162
- {busy ? (
163
- <div className="mw-result">
164
- <LoadingIndicator mode="staged" steps={runtime.parseStageLabels} activeStep={geocode.parseStage} />
165
- </div>
166
- ) : result ? (
167
- panels.result ? (
168
- panels.result({
169
- result,
170
- selectedCandidate,
171
- selectedCandidateIndex: geocode.selectedCandidateIndex,
172
- onSelectCandidate: geocode.selectCandidate,
173
- })
174
- ) : (
175
- <ResultPanel
176
- result={result}
177
- selectedCandidate={selectedCandidate}
178
- selectedCandidateIndex={geocode.selectedCandidateIndex}
179
- onSelectCandidate={geocode.selectCandidate}
180
- extras={panels.extras}
181
- failure={panels.failure}
182
- />
183
- )
260
+
261
+ {openSheet === "layers" ? (
262
+ <MapSheet title="Layers" onClose={closeSheet}>
263
+ {panels.layers?.({ map })}
264
+ </MapSheet>
184
265
  ) : null}
185
266
 
186
- {panels.compare
187
- ? panels.compare({ result, compareMode: compare.compareMode, compareVersion: compare.compareVersion })
188
- : null}
267
+ {openSheet === "developer" ? (
268
+ <MapSheet title="Developer" onClose={closeSheet}>
269
+ {panels.releaseInfo ? (
270
+ <div className="mw-map-sheet__row">
271
+ <span className="mw-map-sheet__label">This release</span>
272
+ {panels.releaseInfo}
273
+ </div>
274
+ ) : null}
275
+
276
+ <div className="mw-map-sheet__row">
277
+ <VersionPicker
278
+ versions={versions}
279
+ selected={runtime.selectedVersion ?? null}
280
+ onSelect={onSelectVersion}
281
+ disabled={busy}
282
+ />
283
+ </div>
284
+
285
+ <div className="mw-map-sheet__row">
286
+ <BackendControl
287
+ activeBackend={runtime.activeBackend}
288
+ forceWASM={runtime.forceWASM ?? false}
289
+ onForceWASMChange={onForceWASMChange}
290
+ />
291
+ </div>
292
+
293
+ <div className="mw-map-sheet__row">
294
+ <CompareToggle
295
+ versions={versions}
296
+ primaryVersion={runtime.selectedVersion ?? null}
297
+ compareMode={compare.compareMode}
298
+ onCompareModeChange={compare.setCompareMode}
299
+ compareVersion={compare.compareVersion}
300
+ onCompareVersionChange={compare.setCompareVersion}
301
+ disabled={busy}
302
+ />
303
+ </div>
304
+
305
+ {panels.developerExtras}
306
+ </MapSheet>
307
+ ) : null}
308
+
309
+ {showSheet ? (
310
+ <section className="mw-map-sheet mw-map-sheet--bottom" aria-label="Result">
311
+ <div className="mw-map-sheet__handle" aria-hidden="true" />
312
+
313
+ {errorMessage ? <p className="mw-error">{errorMessage}</p> : null}
314
+
315
+ {panels.aboveResult ? panels.aboveResult({ result }) : null}
316
+
317
+ {busy ? (
318
+ <div className="mw-result">
319
+ <LoadingIndicator mode="staged" steps={runtime.parseStageLabels} activeStep={geocode.parseStage} />
320
+ </div>
321
+ ) : result ? (
322
+ panels.result ? (
323
+ panels.result({
324
+ result,
325
+ selectedCandidate,
326
+ selectedCandidateIndex: geocode.selectedCandidateIndex,
327
+ onSelectCandidate: geocode.selectCandidate,
328
+ })
329
+ ) : (
330
+ <ResultPanel
331
+ result={result}
332
+ selectedCandidate={selectedCandidate}
333
+ selectedCandidateIndex={geocode.selectedCandidateIndex}
334
+ onSelectCandidate={geocode.selectCandidate}
335
+ extras={panels.extras}
336
+ failure={panels.failure}
337
+ />
338
+ )
339
+ ) : null}
340
+
341
+ {panels.compare
342
+ ? panels.compare({ result, compareMode: compare.compareMode, compareVersion: compare.compareVersion })
343
+ : null}
344
+
345
+ {panels.permalink ? <div className="mw-map-sheet__actions">{panels.permalink(geocode.text)}</div> : null}
346
+ </section>
347
+ ) : null}
189
348
 
190
349
  {panels.footer}
191
- </section>
350
+ </>
192
351
  )
193
352
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `<MapChipRow>` — the row of example queries under a search bar.
7
+ *
8
+ * It SCROLLS horizontally and never wraps. Earth's twelve examples wrapped down five ragged rows (3/3/2/1/1/1) and
9
+ * took a third of the panel; one scrolling line is the shape the reference map apps use, and it costs a fixed
10
+ * height whatever the number of examples.
11
+ *
12
+ * NODE-SAFE: pure React, no maplibre.
13
+ */
14
+
15
+ import type { ReactNode } from "react"
16
+
17
+ import { cx } from "#common/cx"
18
+
19
+ export interface MapChip {
20
+ /**
21
+ * What the chip reads.
22
+ */
23
+ label: string
24
+ /**
25
+ * The query the chip stands for, handed back on press. Defaults to the label.
26
+ */
27
+ value?: string
28
+ /**
29
+ * Rendered before the label — a category mark.
30
+ */
31
+ icon?: ReactNode
32
+ }
33
+
34
+ export interface MapChipRowProps {
35
+ chips: MapChip[]
36
+ /**
37
+ * Fired with the chip's `value` (or its label) when one is pressed.
38
+ */
39
+ onPick: (value: string) => void
40
+ /**
41
+ * The chip matching this value reads as pressed.
42
+ */
43
+ activeValue?: string
44
+ disabled?: boolean
45
+ className?: string
46
+ /**
47
+ * Accessible name for the row, e.g. "Example addresses".
48
+ */
49
+ label?: string
50
+ }
51
+
52
+ export function MapChipRow({ chips, onPick, activeValue, disabled, className, label }: MapChipRowProps): ReactNode {
53
+ if (!chips.length) return null
54
+
55
+ return (
56
+ <div className={cx("mw-map-chiprow", className)} role="group" aria-label={label}>
57
+ {chips.map((chip) => {
58
+ const value = chip.value ?? chip.label
59
+ const active = value === activeValue
60
+
61
+ return (
62
+ <button
63
+ key={value}
64
+ type="button"
65
+ className={cx("mw-map-chip", active && "mw-map-chip--active")}
66
+ aria-pressed={active}
67
+ disabled={disabled}
68
+ onClick={() => onPick(value)}
69
+ >
70
+ {chip.icon ? <span className="mw-map-chip__icon">{chip.icon}</span> : null}
71
+ {chip.label}
72
+ </button>
73
+ )
74
+ })}
75
+ </div>
76
+ )
77
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `<MapCompass>` — the needle that appears when the map leaves north and fades out when it returns.
7
+ *
8
+ * It is a compass ROSE, the way the reference map apps draw it: a ringed dial carrying a two-tone needle whose red
9
+ * half points north and whose pale half points south. A single-color arrow cannot say which end is north, so a
10
+ * reader has to already know the convention to read it; two tones say it outright. There is no `N` on the dial —
11
+ * at this size the letter and the needle's north tip want the same few pixels, and the tip is the clearer of them.
12
+ *
13
+ * The whole dial counter-rotates the bearing, so the needle keeps pointing at true north while the map turns under
14
+ * it. Pressing it returns the map to north, which is why the control is a button rather than an ornament.
15
+ *
16
+ * It stays mounted through the fade rather than unmounting on the bearing crossing zero, because a control that
17
+ * vanishes mid-gesture is the thing that reads as a glitch. `HIDE_BELOW_DEGREES` is the dead zone: a map settled by
18
+ * a snap-to-north lands a fraction off zero, and a compass that lingers over that fraction never goes away.
19
+ *
20
+ * Under `prefers-reduced-motion` it appears and disappears with no transition, and the dial still rotates —
21
+ * rotation IS the information, not decoration.
22
+ *
23
+ * NODE-SAFE: pure React, no maplibre. The host reads the bearing off its own map and passes it in.
24
+ */
25
+
26
+ import type { ReactNode } from "react"
27
+
28
+ import { cx } from "#common/cx"
29
+
30
+ /**
31
+ * Below this many degrees off north the compass is treated as pointing north and fades out.
32
+ */
33
+ const HIDE_BELOW_DEGREES = 0.5
34
+
35
+ export interface MapCompassProps {
36
+ /**
37
+ * The map's bearing in degrees, as MapLibre reports it: 0 is north, positive is counter-clockwise.
38
+ */
39
+ bearing: number
40
+ /**
41
+ * Fired when the compass is pressed — the host resets its map to north.
42
+ */
43
+ onResetNorth: () => void
44
+ /**
45
+ * The control's accessible name. @default "Reset bearing to north"
46
+ */
47
+ label?: string
48
+ className?: string
49
+ }
50
+
51
+ export function MapCompass({ bearing, onResetNorth, label, className }: MapCompassProps): ReactNode {
52
+ const facingNorth = Math.abs(bearing) < HIDE_BELOW_DEGREES
53
+
54
+ return (
55
+ <button
56
+ type="button"
57
+ className={cx("mw-map-compass", facingNorth && "mw-map-compass--north", className)}
58
+ aria-label={label ?? "Reset bearing to north"}
59
+ title={label ?? "Reset bearing to north"}
60
+ // Out of the tab order and out of the accessibility tree while it is invisible, so a keyboard reaches only
61
+ // the controls a pointer can see.
62
+ aria-hidden={facingNorth}
63
+ tabIndex={facingNorth ? -1 : 0}
64
+ onClick={onResetNorth}
65
+ >
66
+ <svg viewBox="0 0 32 32" width="26" height="26" aria-hidden="true" focusable="false">
67
+ <g style={{ transform: `rotate(${-bearing}deg)`, transformOrigin: "16px 16px" }}>
68
+ <circle cx="16" cy="16" r="12.5" className="mw-map-compass__dial" />
69
+
70
+ {/* North, then south. Two triangles meeting at the hub rather than one arrow through it. */}
71
+ <path d="M16 5.5 L20.5 16 L16 16 Z M16 5.5 L11.5 16 L16 16 Z" className="mw-map-compass__north" />
72
+ <path d="M16 26.5 L20.5 16 L16 16 Z M16 26.5 L11.5 16 L16 16 Z" className="mw-map-compass__south" />
73
+ </g>
74
+ </svg>
75
+ </button>
76
+ )
77
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @copyright Sister Software
3
+ * @license AGPL-3.0
4
+ * @author Teffen Ellis, et al.
5
+ *
6
+ * `<MapControlStack>` — the column of map controls down one edge, and `<MapControlButton>`, the control inside it.
7
+ *
8
+ * Controls sit in GROUPS: a group is one glass capsule with hairlines between its buttons, and separate groups are
9
+ * separate capsules with a gap. That grouping is the whole layout idea — related controls read as one object, and
10
+ * an unrelated one (a compass that comes and goes) does not join a capsule it would resize.
11
+ *
12
+ * NODE-SAFE: pure React, no maplibre.
13
+ */
14
+
15
+ import type { ReactNode } from "react"
16
+
17
+ import { cx } from "#common/cx"
18
+
19
+ export interface MapControlButtonProps {
20
+ /**
21
+ * The mark inside the control — an SVG icon or a single glyph.
22
+ */
23
+ children: ReactNode
24
+ /**
25
+ * The control's accessible name. Required: the visible content is a mark, not a word.
26
+ */
27
+ label: string
28
+ onPress: () => void
29
+ /**
30
+ * Reads as active, for a control that toggles a layer or a mode.
31
+ */
32
+ active?: boolean
33
+ disabled?: boolean
34
+ className?: string
35
+ }
36
+
37
+ export function MapControlButton({
38
+ children,
39
+ label,
40
+ onPress,
41
+ active,
42
+ disabled,
43
+ className,
44
+ }: MapControlButtonProps): ReactNode {
45
+ return (
46
+ <button
47
+ type="button"
48
+ className={cx("mw-map-control", active && "mw-map-control--active", className)}
49
+ aria-label={label}
50
+ aria-pressed={active}
51
+ title={label}
52
+ disabled={disabled}
53
+ onClick={onPress}
54
+ >
55
+ {children}
56
+ </button>
57
+ )
58
+ }
59
+
60
+ export interface MapControlGroupProps {
61
+ children: ReactNode
62
+ className?: string
63
+ }
64
+
65
+ /**
66
+ * One capsule. Put related controls in the same group; give an unrelated one its own.
67
+ */
68
+ export function MapControlGroup({ children, className }: MapControlGroupProps): ReactNode {
69
+ return <div className={cx("mw-map-control-group", className)}>{children}</div>
70
+ }
71
+
72
+ export interface MapControlStackProps {
73
+ /**
74
+ * Groups, top to bottom.
75
+ */
76
+ children: ReactNode
77
+ /**
78
+ * Which edge the column sits against. @default "right"
79
+ */
80
+ side?: "left" | "right"
81
+ className?: string
82
+ /**
83
+ * Accessible name for the column, e.g. "Map controls".
84
+ */
85
+ label?: string
86
+ }
87
+
88
+ export function MapControlStack({ children, side = "right", className, label }: MapControlStackProps): ReactNode {
89
+ return (
90
+ <div
91
+ className={cx("mw-map-control-stack", `mw-map-control-stack--${side}`, className)}
92
+ role="group"
93
+ aria-label={label}
94
+ >
95
+ {children}
96
+ </div>
97
+ )
98
+ }