@stapel/search-react 0.19.0 → 0.20.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 (59) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/default/FacetPanelPane.d.ts +0 -19
  3. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  4. package/dist/default/FacetPanelPane.js +18 -61
  5. package/dist/default/FacetPanelPane.js.map +1 -1
  6. package/dist/default/FilterChips.d.ts +1 -24
  7. package/dist/default/FilterChips.d.ts.map +1 -1
  8. package/dist/default/FilterChips.js +4 -18
  9. package/dist/default/FilterChips.js.map +1 -1
  10. package/dist/default/LocationSummaryLine.d.ts +9 -0
  11. package/dist/default/LocationSummaryLine.d.ts.map +1 -1
  12. package/dist/default/LocationSummaryLine.js +29 -2
  13. package/dist/default/LocationSummaryLine.js.map +1 -1
  14. package/dist/default/SearchPage.d.ts +6 -5
  15. package/dist/default/SearchPage.d.ts.map +1 -1
  16. package/dist/default/SearchPage.js +26 -21
  17. package/dist/default/SearchPage.js.map +1 -1
  18. package/dist/default/geoSheet.d.ts +12 -6
  19. package/dist/default/geoSheet.d.ts.map +1 -1
  20. package/dist/default/geoSheet.js +14 -3
  21. package/dist/default/geoSheet.js.map +1 -1
  22. package/dist/default/index.d.ts +4 -3
  23. package/dist/default/index.d.ts.map +1 -1
  24. package/dist/default/index.js +4 -3
  25. package/dist/default/index.js.map +1 -1
  26. package/dist/headless/SearchStateProvider.d.ts +42 -30
  27. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  28. package/dist/headless/SearchStateProvider.js +16 -36
  29. package/dist/headless/SearchStateProvider.js.map +1 -1
  30. package/dist/i18n/es.d.ts.map +1 -1
  31. package/dist/i18n/es.js +1 -0
  32. package/dist/i18n/es.js.map +1 -1
  33. package/dist/i18n/keys.d.ts +1 -0
  34. package/dist/i18n/keys.d.ts.map +1 -1
  35. package/dist/i18n/keys.js +2 -0
  36. package/dist/i18n/keys.js.map +1 -1
  37. package/dist/i18n/ru.d.ts.map +1 -1
  38. package/dist/i18n/ru.js +1 -0
  39. package/dist/i18n/ru.js.map +1 -1
  40. package/dist/state/urlState.d.ts +20 -2
  41. package/dist/state/urlState.d.ts.map +1 -1
  42. package/dist/state/urlState.js +26 -5
  43. package/dist/state/urlState.js.map +1 -1
  44. package/llms.txt +1 -1
  45. package/manifest.json +2 -1
  46. package/nav-manifest.json +1 -1
  47. package/package.json +2 -2
  48. package/src/analytics/generated/events.json +1 -1
  49. package/src/default/FacetPanelPane.tsx +16 -125
  50. package/src/default/FilterChips.tsx +5 -70
  51. package/src/default/LocationSummaryLine.tsx +65 -1
  52. package/src/default/SearchPage.tsx +52 -31
  53. package/src/default/geoSheet.tsx +46 -7
  54. package/src/default/index.ts +4 -3
  55. package/src/headless/SearchStateProvider.tsx +57 -66
  56. package/src/i18n/es.ts +1 -0
  57. package/src/i18n/keys.ts +2 -0
  58. package/src/i18n/ru.ts +1 -0
  59. package/src/state/urlState.ts +26 -4
@@ -23,6 +23,24 @@
23
23
  * applied it says the search is looking everywhere, which is the truth and is
24
24
  * also the invitation to narrow it.
25
25
  *
26
+ * ## The offer, and why it is a BUTTON on this row
27
+ *
28
+ * A visitor whose browser has already granted geolocation is one the host can
29
+ * place precisely — and for a long time this pair took that as licence to
30
+ * place them, through a `defaultGeo` that wrote a 25 km radius into the URL
31
+ * before anybody had said anything (see {@link SearchStateProviderProps.geoOffer}
32
+ * for what that cost: whole category leaves reading "nothing found" while
33
+ * their stock sat 30 km away). The position is still worth having. It is just
34
+ * not the pair's to apply.
35
+ *
36
+ * So the offer lands HERE, on the one row that is on screen at every width
37
+ * without opening a sheet, beside the sentence it would change ("searching
38
+ * everywhere" → "within 25 km of you"). It states its own radius, because a
39
+ * button that says only "near me" is asking a person to accept a number they
40
+ * cannot see; and the moment it is pressed the radius becomes the ordinary
41
+ * adjustable one in the panel, and the location becomes an ordinary chip with
42
+ * an ordinary way off.
43
+ *
26
44
  * ## The right-hand affordance carries a COUNT, and the chip row carries a dot
27
45
  *
28
46
  * `<FilterChips>`'s leading chip is a 32px circle: a number inside it is a
@@ -139,6 +157,10 @@ const LOCATION: CSSProperties = {
139
157
  * noise, and it costs 16px. */
140
158
  const PIN: CSSProperties = { flex: "0 0 auto", display: "inline-flex" };
141
159
 
160
+ /** The offer. It never shrinks: it is three words and a number, and half of
161
+ * "Near me" is not an offer. */
162
+ const OFFER: CSSProperties = { flex: "0 0 auto", paddingInline: 0 };
163
+
142
164
  /**
143
165
  * The count, IN the flow.
144
166
  *
@@ -179,6 +201,15 @@ export interface LocationSummaryLineProps {
179
201
  * because the surface is what it covers.
180
202
  */
181
203
  readonly onOpenAll: () => void;
204
+ /**
205
+ * Draw the trailing "Filters (N)" door. Default `true`.
206
+ *
207
+ * `false` where the panel is ALREADY on screen — a desktop column layout —
208
+ * because a door beside the room it opens is not a door, and the count it
209
+ * carries is printed again on the panel's own "clear all" a few hundred
210
+ * pixels to the left.
211
+ */
212
+ readonly filtersDoor?: boolean;
182
213
  }
183
214
 
184
215
  /** A map pin in `currentColor` — the house convention: an inline monochrome
@@ -208,7 +239,7 @@ export function LocationSummaryLine(
208
239
  props: LocationSummaryLineProps
209
240
  ): ReactElement {
210
241
  const t = useT();
211
- const { state, activeFilters } = useSearchState();
242
+ const { state, activeFilters, geoOffer, acceptGeoOffer } = useSearchState();
212
243
  const [open, setOpen] = useState(false);
213
244
  const geo = state.geo;
214
245
 
@@ -221,6 +252,14 @@ export function LocationSummaryLine(
221
252
  ? t(SEARCH_I18N_KEYS.geoRadiusKm, { km: geo.radiusKm })
222
253
  : undefined;
223
254
 
255
+ // The offer's own radius, said out loud on the button. `geoOffer` is
256
+ // already `undefined` whenever a location is applied (the provider closes
257
+ // the question), so this row never shows an offer beside a place.
258
+ const offerRadius =
259
+ geoOffer !== undefined && geoOffer.kind === "center" && geoOffer.radiusKm !== undefined
260
+ ? t(SEARCH_I18N_KEYS.geoRadiusKm, { km: geoOffer.radiusKm })
261
+ : undefined;
262
+
224
263
  const where: ReactNode =
225
264
  geo === undefined
226
265
  ? t(SEARCH_I18N_KEYS.geoEverywhere)
@@ -272,6 +311,29 @@ export function LocationSummaryLine(
272
311
  </span>
273
312
  </Button>
274
313
 
314
+ {/* The offer, and nothing is applied until it is pressed. Drawn only
315
+ when the host has a position to offer AND the search carries no
316
+ location of its own — the provider enforces the second half, so
317
+ this is one condition, not two that could disagree. */}
318
+ {geoOffer !== undefined && (
319
+ <Button
320
+ type="link"
321
+ style={OFFER}
322
+ data-testid="search-location-offer"
323
+ data-analytics="none"
324
+ data-analytics-reason="applying a filter the person pressed is search state, and search state is the URL"
325
+ onClick={acceptGeoOffer}
326
+ >
327
+ {t(SEARCH_I18N_KEYS.geoNearMe)}
328
+ {offerRadius !== undefined && (
329
+ <span data-testid="search-location-offer-radius">
330
+ {" · "}
331
+ {offerRadius}
332
+ </span>
333
+ )}
334
+ </Button>
335
+ )}
336
+
275
337
  {/* "Filters", not "All filters": this end of the row shares 390px
276
338
  with a place name that can run to fifteen characters, and the word
277
339
  the person is looking for is the noun. The panel's own heading
@@ -279,6 +341,7 @@ export function LocationSummaryLine(
279
341
  door. */}
280
342
  {/* The count, not a dot: this row has the width to say how many — and
281
343
  it rides IN the line rather than floating off its corner. */}
344
+ {props.filtersDoor === false ? null : (
282
345
  <Flex
283
346
  align="center"
284
347
  gap={spacing[1]}
@@ -302,6 +365,7 @@ export function LocationSummaryLine(
302
365
  </span>
303
366
  )}
304
367
  </Flex>
368
+ )}
305
369
  </Flex>
306
370
 
307
371
  <GeoSheet
@@ -35,17 +35,17 @@
35
35
  *
36
36
  * ── Where the search opens, and what it calls the place ───────────────────
37
37
  *
38
- * `defaultGeo` centres a fresh search on the visitora granted browser
39
- * prompt, or the server's IP guess when there was none — and `geoLabel` says
40
- * what that place is CALLED. Both are the host's to resolve: this page has a
41
- * `lat` and a `lon` in its query string and no way on earth to turn them into
42
- * "Berlin Mitte", which is exactly why it must not print them. A search
43
- * package that grew a geocoder to say a nicer sentence would have taken on the
44
- * whole of `geo-react` to avoid one bad line.
38
+ * `geoOffer` is a place this search COULD be narrowed to the visitor's own
39
+ * position, as the host resolved it — and `geoLabel` says what the place a
40
+ * search is ALREADY narrowed to is CALLED. Both are the host's to resolve:
41
+ * this page has a `lat` and a `lon` in its query string and no way on earth to
42
+ * turn them into "Berlin Mitte", which is exactly why it must not print them.
43
+ * A search package that grew a geocoder to say a nicer sentence would have
44
+ * taken on the whole of `geo-react` to avoid one bad line.
45
45
  *
46
- * The centring is applied once, only into a URL that carries no location, and
47
- * never again after the visitor has said anything about location themselves
48
- * see {@link SearchStateProviderProps.defaultGeo}, which owns the rules.
46
+ * The offer is never applied on the page's own initiative: it is drawn as a
47
+ * button on the location row, and pressing it is the person's word. See
48
+ * {@link SearchStateProviderProps.geoOffer} for the defect that shape closes.
49
49
  *
50
50
  * ── The filter column is laid out only when there is something in it ──────
51
51
  *
@@ -83,6 +83,7 @@ import type {
83
83
  GeoFilterSlotProps,
84
84
  } from "./FacetPanelPane.js";
85
85
  import { FilterChips } from "./FilterChips.js";
86
+ import { LocationSummaryLine } from "./LocationSummaryLine.js";
86
87
  import { PageSizeSelect } from "./PageSizeSelect.js";
87
88
  import { SearchBox } from "./SearchBox.js";
88
89
  import { SearchResultsPane } from "./SearchResultsPane.js";
@@ -211,16 +212,17 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
211
212
  * Default `false` — see {@link FacetPanelPaneProps.skippedNotice}. */
212
213
  readonly skippedNotice?: boolean;
213
214
  /**
214
- * Open the search centred where the visitor is, when the URL says nothing
215
- * about location — see {@link SearchStateProviderProps.defaultGeo} for the
216
- * four rules that keep it from overruling a link or a person.
215
+ * A location this search could be narrowed to, OFFERED to the visitor see
216
+ * {@link SearchStateProviderProps.geoOffer} for why it is an offer and not a
217
+ * default, and for the defect that distinction closes.
217
218
  *
218
219
  * The host resolves the position; this page does not know what a map or a
219
220
  * geocoder is and must not learn. A browser prompt (`usePermission`
220
221
  * + `geolocation`) or the server's IP guess both arrive here as the same two
221
- * numbers, and `geoLabel` is where the name of the place they mean comes in.
222
+ * numbers. Nothing is applied until the person presses the offer, and
223
+ * `geoOfferLabel` is what the button calls the place.
222
224
  */
223
- readonly defaultGeo?: SearchGeo | undefined;
225
+ readonly geoOffer?: SearchGeo | undefined;
224
226
  /** Container chrome under the results — e.g. the ranking-disclosure link. */
225
227
  readonly footer?: ReactNode;
226
228
  /**
@@ -369,7 +371,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
369
371
  const t = useT();
370
372
  const tPlural = useTPlural();
371
373
  const { categoryFeatures, locale, resolveFacetLabels, filtersHeader } = props;
372
- const { state } = useSearchState();
374
+ const { state, geoOffer } = useSearchState();
373
375
  const facets = useFacetPanel({
374
376
  ...(categoryFeatures !== undefined ? { categoryFeatures } : {}),
375
377
  ...(locale !== undefined ? { locale } : {}),
@@ -429,7 +431,6 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
429
431
  state.category === undefined &&
430
432
  state.lang === undefined &&
431
433
  props.renderCategoryFilter === undefined &&
432
- props.renderGeoFilter === undefined &&
433
434
  (props.languages ?? []).length === 0;
434
435
  const showFilters = filtersHeader !== undefined || !filtersEmpty;
435
436
 
@@ -460,10 +461,6 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
460
461
  {...(props.renderCategoryFilter !== undefined
461
462
  ? { renderCategoryFilter: props.renderCategoryFilter }
462
463
  : {})}
463
- {...(props.renderGeoFilter !== undefined
464
- ? { renderGeoFilter: props.renderGeoFilter }
465
- : {})}
466
- {...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
467
464
  {...(props.skippedNotice !== undefined
468
465
  ? { skippedNotice: props.skippedNotice }
469
466
  : {})}
@@ -543,6 +540,38 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
543
540
  )}
544
541
  <UrlIssueNotice />
545
542
 
543
+ {/* THE LOCATION CONTROL — a place, its radius, and the way to change
544
+ either. Mounted by the PAGE, not by each host, which is how a
545
+ category results page came to have no way to say where it was
546
+ looking while `/s` had one: the row was a slot every surface had to
547
+ remember to fill, and one of them did not.
548
+
549
+ Drawn when this deployment can resolve a place at all, or when the
550
+ address already carries one (a shared link must always be widenable
551
+ by whoever opens it). It is not a filter and it is not in the filter
552
+ count — see `activeFilterCount`.
553
+
554
+ Three reasons to draw it, and an offer is one of them: a deployment
555
+ that can place its visitor but ships no place picker still needs
556
+ somewhere for them to accept — otherwise the offer is a value with
557
+ no control, which is the same silence this pack is about. */}
558
+ {(props.renderGeoFilter !== undefined ||
559
+ state.geo !== undefined ||
560
+ geoOffer !== undefined) && (
561
+ <LocationSummaryLine
562
+ {...(props.renderGeoFilter !== undefined
563
+ ? { renderGeoFilter: props.renderGeoFilter }
564
+ : {})}
565
+ {...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
566
+ // No door where the room is already open: the desktop column layout
567
+ // has the whole panel on screen beside this row.
568
+ filtersDoor={layout === "sheet"}
569
+ onOpenAll={() => {
570
+ setSheetOpen(true);
571
+ }}
572
+ />
573
+ )}
574
+
546
575
  {/* Above the chips in the sheet layout and above the columns in the
547
576
  other one — see {@link SearchPageProps.resultsHeader}. `?? null` is
548
577
  the written decision, not an oversight: a page with nothing to say
@@ -575,14 +604,6 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
575
604
  {...(props.categoryLabel !== undefined
576
605
  ? { categoryLabel: props.categoryLabel }
577
606
  : {})}
578
- {...(props.renderGeoFilter !== undefined
579
- ? { renderGeoFilter: props.renderGeoFilter }
580
- : {})}
581
- {...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
582
- /* The row above already states the location and opens its own
583
- sheet, so the chip would be the second control over one filter
584
- — see `FilterChipsProps.geoChip`. */
585
- {...(props.resultsHeader !== undefined ? { geoChip: false } : {})}
586
607
  />
587
608
  <SkinDialog
588
609
  open={sheetOpen}
@@ -643,7 +664,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
643
664
  renderGeoFilter,
644
665
  geoLabel,
645
666
  skippedNotice,
646
- defaultGeo,
667
+ geoOffer,
647
668
  footer,
648
669
  filtersHeader,
649
670
  resultsHeader,
@@ -665,7 +686,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
665
686
 
666
687
  return (
667
688
  <SkinTheme surface="base" {...(mode !== undefined ? { mode } : {})}>
668
- <SearchStateProvider adapter={adapter} defaultGeo={defaultGeo} {...parseOptions}>
689
+ <SearchStateProvider adapter={adapter} geoOffer={geoOffer} {...parseOptions}>
669
690
  <SearchPageBody
670
691
  {...(renderCard !== undefined ? { renderCard } : {})}
671
692
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
@@ -1,12 +1,17 @@
1
1
  /**
2
2
  * The LOCATION sheet — one implementation, two doors.
3
3
  *
4
- * `<FilterChips>`'s geo chip opened it and `<LocationSummaryLine>` opens it
5
- * too: on the ref's SERP both controls say where the search is centred, one
6
- * on the summary row and one in the chip strip, and a person tapping either
7
- * must land in the same place. Two copies of a bottom sheet is two places for
8
- * "clear the location" to behave differently, so the sheet is a component and
9
- * the two surfaces are two `open` flags on it.
4
+ * ONE control, and this is the inside of it. `<LocationSummaryLine>` is the
5
+ * door: a place on the left, its radius beside it, and this sheet behind
6
+ * both.
7
+ *
8
+ * It used to be three doors the chip row's geo chip, the facet panel's
9
+ * "Location" group, and the row over one pair of numbers, with the radius
10
+ * living in the panel and the place in the sheet. That arrangement made a
11
+ * latitude look like a filter, which is what it is not: a coordinate pair is
12
+ * the machine form of a place, and a place with a radius is its own thing in
13
+ * the chrome, like the search box. The chip and the panel group are gone; the
14
+ * radius moved in here, beside the place it is a radius OF.
10
15
  *
11
16
  * ── What the sheet can do without a geocoder, and what it cannot ───────────
12
17
  *
@@ -25,7 +30,7 @@
25
30
  * the DOM, on either surface.
26
31
  */
27
32
  import type { ReactElement, ReactNode } from "react";
28
- import { Button, Flex, Typography } from "antd";
33
+ import { Button, Flex, InputNumber, Typography } from "antd";
29
34
  import { SkinDialog } from "@stapel/tokens-antd/skin";
30
35
  import { SlotPlaceholder, useT, useTPlural } from "@stapel/core";
31
36
  import { spacing } from "@stapel/tokens";
@@ -42,6 +47,7 @@ export interface GeoSheetTestIds {
42
47
  readonly apply: string;
43
48
  readonly slot: string;
44
49
  readonly summary: string;
50
+ readonly radius: string;
45
51
  readonly clear: string;
46
52
  }
47
53
 
@@ -53,6 +59,7 @@ export const CHIP_GEO_TEST_IDS: GeoSheetTestIds = {
53
59
  apply: "filter-chip-apply-geo",
54
60
  slot: "search-chip-geo-slot",
55
61
  summary: "search-chip-geo-summary",
62
+ radius: "search-chip-geo-radius",
56
63
  clear: "search-chip-geo-clear",
57
64
  };
58
65
 
@@ -63,6 +70,9 @@ export const SUMMARY_GEO_TEST_IDS: GeoSheetTestIds = {
63
70
  apply: "search-location-apply",
64
71
  slot: "search-location-slot",
65
72
  summary: "search-location-sheet-summary",
73
+ // The one radius control in the pair. It was `search-geo-radius` in the
74
+ // facet panel, which no longer draws a location group at all.
75
+ radius: "search-geo-radius",
66
76
  clear: "search-location-clear",
67
77
  };
68
78
 
@@ -142,6 +152,35 @@ export function GeoSheet(props: GeoSheetProps): ReactElement {
142
152
  <Typography.Text type="secondary" data-testid={props.testIds.summary}>
143
153
  {summary}
144
154
  </Typography.Text>
155
+ {/* HOW WIDE, beside WHERE. A radius means nothing without a place,
156
+ so this exists only once one is set and disappears with it —
157
+ and it lives here rather than in the filter panel, where it was
158
+ a number about a place the panel could not name. */}
159
+ {geo.kind === "center" && (
160
+ <Flex vertical gap={spacing[1]}>
161
+ <Typography.Text type="secondary">
162
+ {t(SEARCH_I18N_KEYS.geoRadiusLabel)}
163
+ </Typography.Text>
164
+ <InputNumber
165
+ min={1}
166
+ style={{ alignSelf: "flex-start" }}
167
+ value={geo.radiusKm ?? null}
168
+ aria-label={t(SEARCH_I18N_KEYS.geoRadiusLabel)}
169
+ data-testid={props.testIds.radius}
170
+ onChange={(value) => {
171
+ setGeo({
172
+ kind: "center",
173
+ lat: geo.lat,
174
+ lon: geo.lon,
175
+ ...(typeof value === "number" ? { radiusKm: value } : {}),
176
+ });
177
+ }}
178
+ />
179
+ </Flex>
180
+ )}
181
+ {/* One control off. Clearing the place clears the radius with it —
182
+ `setGeo(null)` drops both, because half a location is not a
183
+ state this search can be in. */}
145
184
  <Button
146
185
  style={{ alignSelf: "flex-start" }}
147
186
  data-testid={props.testIds.clear}
@@ -24,9 +24,10 @@
24
24
  * keeps the controls that WIDEN a location a link already carries. Its two
25
25
  * companions divide the same seam the same way: `geoLabel` is what the
26
26
  * current place is CALLED (owning a coordinate is not permission to print
27
- * one), and `defaultGeo` is where a fresh search opens when the URL names
28
- * nowhere — the visitor's own position, resolved by whoever is allowed to
29
- * ask for it.
27
+ * one), and `geoOffer` is a place the search COULD be narrowed to — the
28
+ * visitor's own position, resolved by whoever is allowed to ask for it,
29
+ * drawn as an invitation on the location row and applied only when
30
+ * somebody presses it.
30
31
  * 5. **retheming through the §68 token JSON** — every surface wraps itself
31
32
  * in the shared `SkinTheme`, so a host's regenerated `--stapel-*` custom
32
33
  * properties reach this skin with zero code.
@@ -2,9 +2,7 @@ import {
2
2
  createContext,
3
3
  useCallback,
4
4
  useContext,
5
- useEffect,
6
5
  useMemo,
7
- useRef,
8
6
  } from "react";
9
7
  import type { ReactElement, ReactNode } from "react";
10
8
  import type {
@@ -57,8 +55,22 @@ export interface SearchStateBag {
57
55
  readonly state: SearchQueryState;
58
56
  /** What the URL carried that could not be read (see `SearchStateIssue`). */
59
57
  readonly issues: readonly SearchStateIssue[];
60
- /** Facet values + ranges + geo the person has applied. */
58
+ /** Facet values + ranges + geo the person has applied.
59
+ *
60
+ * Everything counted here was chosen BY the person: this pair applies no
61
+ * filter of its own, so the number beside "clear all" can never be one the
62
+ * reader has to go hunting for.
63
+ */
61
64
  readonly activeFilters: number;
65
+ /**
66
+ * A location the host can narrow to, which nobody has accepted yet —
67
+ * `undefined` when the host offers none, or when the search already carries
68
+ * a location of its own (an offer would then be a second answer to a
69
+ * question already answered).
70
+ *
71
+ * Draw it as an invitation ("near me"), never as state.
72
+ */
73
+ readonly geoOffer: SearchGeo | undefined;
62
74
 
63
75
  setText(q: string): void;
64
76
  setSort(sort: string | null): void;
@@ -72,6 +84,9 @@ export interface SearchStateBag {
72
84
  clearAll(): void;
73
85
  /** Move to a keyset page. The ONLY mutator that keeps a cursor. */
74
86
  goToAnchor(anchor: string | null, direction: "next" | "prev"): void;
87
+ /** Accept {@link geoOffer}. A no-op when there is nothing on offer, so a
88
+ * control can be pressed without first re-checking what this bag says. */
89
+ acceptGeoOffer(): void;
75
90
  /** Escape hatch for a host control this pair does not ship. Goes through
76
91
  * `patchSearchState`, so it drops the cursor like every other change. */
77
92
  patch(patch: SearchStatePatch): void;
@@ -82,40 +97,35 @@ const StateContext = createContext<SearchStateBag | null>(null);
82
97
  export interface SearchStateProviderProps extends ParseSearchStateOptions {
83
98
  readonly adapter: SearchParamsAdapter;
84
99
  /**
85
- * Where to centre the results when the URL says nothing about location —
86
- * the visitor's own position, as the HOST resolved it (a granted browser
87
- * geolocation prompt, or the server's IP guess when there was none).
100
+ * A location this search COULD be narrowed to offered, never applied.
88
101
  *
89
- * Opt-in, and it is deliberately not one of the `default*` parse options
90
- * beside it. Those are read-time fallbacks: `defaultCategory` fills a gap in
91
- * the parsed state and never reaches the query string. A location cannot
92
- * work that way. It has to be WRITTEN, once, because the URL is the state:
93
- * a centre that lived only in the parse would vanish from a link the visitor
94
- * shares, and — worse — would be re-applied on the very next render after
95
- * they cleared it, which is a filter that will not come off.
102
+ * The host resolves it (a granted browser prompt, the server's IP guess);
103
+ * this provider does nothing with it except hand it back on the bag as
104
+ * {@link SearchStateBag.geoOffer}, so a control can draw "near me" and the
105
+ * PERSON decides. Nothing is written to the URL until they press it.
96
106
  *
97
- * So the rules, all four of which are about not overruling a person:
107
+ * ## Why this is an offer and not a default (defect: a silent radius)
98
108
  *
99
- * - **Only into an empty URL.** A link that carries `lat`/`lon` or `bbox`
100
- * already means a place, and it must mean the same place for everybody
101
- * who opens it. A default that overwrote it would make one address bar
102
- * two different searches.
103
- * - **Once.** Tracked as "has anyone spoken about location yet", in a ref
104
- * not by comparing the default against the current value, which cannot
105
- * tell "the visitor cleared it" from "it has not been applied".
106
- * - **Never after a clear.** `setGeo` marks the question answered, so a
107
- * person who widens the search back to everywhere stays there.
108
- * - **Late is fine.** A browser prompt and an IP round trip both resolve
109
- * after the first paint, so `undefined` now and a value three renders
110
- * later still applies — provided the URL is still empty of geo at that
111
- * moment.
109
+ * The previous shape of this prop (`defaultGeo`) wrote the visitor's
110
+ * position into the query string on the first render that could, under four
111
+ * careful rules about not overruling a person. Every rule held, and the
112
+ * result was still a filter nobody asked for: a browser permission granted
113
+ * once, for one map, became a permanent 25 km wall around every category
114
+ * leaf and every result page in the deployment. Measured on a live board:
115
+ * 48 phones became 17, and three leaves with stock became "nothing found".
116
+ * The page looks perfectly healthy while doing it, which is what makes it
117
+ * dangerous the honest reading of an empty leaf is "this board is empty",
118
+ * not "your browser told us where you are".
112
119
  *
113
- * It REPLACES the history entry rather than pushing one: the visitor did not
114
- * perform this change, and Back should leave the page, not undo a centring
115
- * they never asked for. The adapter's `setParams` takes `{ replace: true }`
116
- * for exactly this, and the react-router binding honours it.
120
+ * There is no set of rules that fixes that, because the defect is not in
121
+ * the rules: applying a spatial filter is a decision about what the person
122
+ * wants to see, and only they hold it. So the prop carries the same value
123
+ * and the provider no longer commits it. Two more defects fall out with it:
124
+ * the URL is never rewritten behind the visitor (a hand-typed `radius_km`
125
+ * survives), and the results are fetched ONCE instead of being fetched and
126
+ * immediately superseded by a second, narrower query.
117
127
  */
118
- readonly defaultGeo?: SearchGeo | undefined;
128
+ readonly geoOffer?: SearchGeo | undefined;
119
129
  readonly children: ReactNode;
120
130
  }
121
131
 
@@ -128,7 +138,7 @@ export interface SearchStateProviderProps extends ParseSearchStateOptions {
128
138
  export function SearchStateProvider(
129
139
  props: SearchStateProviderProps
130
140
  ): ReactElement {
131
- const { adapter, children, defaultGeo, ...parseOptions } = props;
141
+ const { adapter, children, geoOffer, ...parseOptions } = props;
132
142
  const { params, setParams } = adapter;
133
143
 
134
144
  // The parse options are spread into a stable dependency: a host that builds
@@ -165,16 +175,6 @@ export function SearchStateProvider(
165
175
  [setParams, search]
166
176
  );
167
177
 
168
- /**
169
- * Has anything at all said where to search yet?
170
- *
171
- * `true` once the URL was seen carrying a location, once `defaultGeo` has
172
- * been applied, or once anybody called `setGeo` — including with `null`.
173
- * That last one is the whole reason this is a ref and not a comparison: a
174
- * cleared location and an unapplied default look identical in the state, and
175
- * only the record of who spoke tells them apart.
176
- */
177
- const geoSettled = useRef(false);
178
178
 
179
179
  const bag = useMemo<SearchStateBag>(() => {
180
180
  const state = parsed.state;
@@ -188,6 +188,12 @@ export function SearchStateProvider(
188
188
  state,
189
189
  issues: parsed.issues,
190
190
  activeFilters: activeFilterCount(state),
191
+ // An offer stands only while the question is open. Once the search
192
+ // carries a place — from a link, or because somebody pressed the offer
193
+ // — there is nothing left to offer, and a control that kept drawing
194
+ // "near me" beside an applied location would be inviting a person to
195
+ // re-answer a question they can already see the answer to.
196
+ geoOffer: state.geo === undefined ? geoOffer : undefined,
191
197
 
192
198
  // Typing replaces rather than pushes: one history entry per letter
193
199
  // would make Back useless, which is the control the spec's acceptance
@@ -200,12 +206,14 @@ export function SearchStateProvider(
200
206
  setFilter: (slug, values) => apply(setFilterValues(state, slug, values)),
201
207
  setRange: (slug, range) => apply(setRangeValue(state, slug, range)),
202
208
  setGeo: (geo) => {
203
- // The person has now answered the location question themselves —
204
- // including by answering "anywhere". `defaultGeo` does not get to ask
205
- // it again.
206
- geoSettled.current = true;
207
209
  apply(patchSearchState(state, { geo }));
208
210
  },
211
+ acceptGeoOffer: () => {
212
+ if (geoOffer === undefined || state.geo !== undefined) return;
213
+ // A PUSH, like any other filter the person applies: Back takes the
214
+ // narrowing off again, which is the same promise every chip makes.
215
+ apply(patchSearchState(state, { geo: geoOffer }));
216
+ },
209
217
  // A page size is a preference, not a step through the results.
210
218
  setLimit: (limit) => apply(patchSearchState(state, { limit }), { replace: true }),
211
219
  clearAll: () => apply(clearFilters(state)),
@@ -213,25 +221,8 @@ export function SearchStateProvider(
213
221
  apply(patchSearchState(state, { anchor, direction })),
214
222
  patch: (patch) => apply(patchSearchState(state, patch)),
215
223
  };
216
- }, [parsed, commit]);
217
-
218
- // An effect, not a render-time write: this puts a value in the URL, and a
219
- // router asked to navigate during a render is a warning at best and a loop
220
- // at worst. It runs after every commit, which is what makes a `defaultGeo`
221
- // that arrives on the fourth render — the browser prompt finally answered —
222
- // land as reliably as one present on the first.
223
- useEffect(() => {
224
- if (geoSettled.current) return;
225
- if (parsed.state.geo !== undefined) {
226
- // The link brought its own place. That settles the question for this
227
- // visit even if the host is still resolving one of its own.
228
- geoSettled.current = true;
229
- return;
230
- }
231
- if (defaultGeo === undefined) return;
232
- geoSettled.current = true;
233
- commit(patchSearchState(parsed.state, { geo: defaultGeo }), { replace: true });
234
- }, [defaultGeo, parsed.state, commit]);
224
+ }, [parsed, commit, geoOffer]);
225
+
235
226
 
236
227
  return <StateContext.Provider value={bag}>{children}</StateContext.Provider>;
237
228
  }
package/src/i18n/es.ts CHANGED
@@ -127,6 +127,7 @@ export const searchI18nBundleEs: I18nDictionary = {
127
127
  "search.geo.radius_km": "A menos de {km} km",
128
128
  "search.geo.radius_label": "Radio, km",
129
129
  "search.geo.clear": "En cualquier lugar",
130
+ "search.geo.near_me": "Cerca de mí",
130
131
  "search.geo.everywhere": "Buscando en todas partes",
131
132
  "search.geo.box": "Dentro del área mostrada",
132
133
  "search.geo.chosen_place": "Un lugar elegido en el mapa",
package/src/i18n/keys.ts CHANGED
@@ -219,6 +219,7 @@ export const SEARCH_I18N_KEYS = {
219
219
  geoRadiusKm: "search.geo.radius_km",
220
220
  geoRadiusLabel: "search.geo.radius_label",
221
221
  geoClear: "search.geo.clear",
222
+ geoNearMe: "search.geo.near_me",
222
223
  /**
223
224
  * What `<LocationSummaryLine>` says when NO location is applied.
224
225
  *
@@ -449,6 +450,7 @@ export const searchI18nBundleEn: Record<string, string> = {
449
450
  "search.limit.from_link": "This link sets its own page size.",
450
451
 
451
452
  "search.geo.title": "Location",
453
+ "search.geo.near_me": "Near me",
452
454
  "search.geo.radius_km": "Within {km} km",
453
455
  "search.geo.radius_label": "Radius, km",
454
456
  "search.geo.clear": "Anywhere",
package/src/i18n/ru.ts CHANGED
@@ -158,6 +158,7 @@ export const searchI18nBundleRu: I18nDictionary = {
158
158
  "search.geo.radius_km": "В радиусе {km} км",
159
159
  "search.geo.radius_label": "Радиус, км",
160
160
  "search.geo.clear": "Везде",
161
+ "search.geo.near_me": "Рядом со мной",
161
162
  "search.geo.everywhere": "Ищем везде",
162
163
  "search.geo.box": "В показанной области",
163
164
  "search.geo.chosen_place": "Выбранное место на карте",