@stapel/search-react 0.8.0 → 0.9.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 (42) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/default/FacetPanelPane.d.ts +30 -0
  3. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  4. package/dist/default/FacetPanelPane.js +25 -7
  5. package/dist/default/FacetPanelPane.js.map +1 -1
  6. package/dist/default/FilterChips.d.ts +9 -0
  7. package/dist/default/FilterChips.d.ts.map +1 -1
  8. package/dist/default/FilterChips.js +8 -9
  9. package/dist/default/FilterChips.js.map +1 -1
  10. package/dist/default/SearchPage.d.ts +19 -0
  11. package/dist/default/SearchPage.d.ts.map +1 -1
  12. package/dist/default/SearchPage.js +18 -4
  13. package/dist/default/SearchPage.js.map +1 -1
  14. package/dist/default/index.d.ts +6 -1
  15. package/dist/default/index.d.ts.map +1 -1
  16. package/dist/default/index.js +6 -1
  17. package/dist/default/index.js.map +1 -1
  18. package/dist/headless/SearchStateProvider.d.ts +35 -0
  19. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  20. package/dist/headless/SearchStateProvider.js +38 -3
  21. package/dist/headless/SearchStateProvider.js.map +1 -1
  22. package/dist/i18n/es.js +1 -1
  23. package/dist/i18n/es.js.map +1 -1
  24. package/dist/i18n/keys.d.ts +12 -1
  25. package/dist/i18n/keys.d.ts.map +1 -1
  26. package/dist/i18n/keys.js +13 -2
  27. package/dist/i18n/keys.js.map +1 -1
  28. package/dist/i18n/ru.js +1 -1
  29. package/dist/i18n/ru.js.map +1 -1
  30. package/llms.txt +1 -1
  31. package/manifest.json +2 -2
  32. package/nav-manifest.json +1 -1
  33. package/package.json +4 -4
  34. package/src/analytics/generated/events.json +1 -1
  35. package/src/default/FacetPanelPane.tsx +46 -6
  36. package/src/default/FilterChips.tsx +17 -9
  37. package/src/default/SearchPage.tsx +40 -1
  38. package/src/default/index.ts +6 -1
  39. package/src/headless/SearchStateProvider.tsx +80 -3
  40. package/src/i18n/es.ts +1 -1
  41. package/src/i18n/keys.ts +13 -2
  42. package/src/i18n/ru.ts +1 -1
@@ -1,4 +1,11 @@
1
- import { createContext, useCallback, useContext, useMemo } from "react";
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ } from "react";
2
9
  import type { ReactElement, ReactNode } from "react";
3
10
  import type {
4
11
  SearchGeo,
@@ -74,6 +81,41 @@ const StateContext = createContext<SearchStateBag | null>(null);
74
81
 
75
82
  export interface SearchStateProviderProps extends ParseSearchStateOptions {
76
83
  readonly adapter: SearchParamsAdapter;
84
+ /**
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).
88
+ *
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.
96
+ *
97
+ * So the rules, all four of which are about not overruling a person:
98
+ *
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.
112
+ *
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.
117
+ */
118
+ readonly defaultGeo?: SearchGeo | undefined;
77
119
  readonly children: ReactNode;
78
120
  }
79
121
 
@@ -86,7 +128,7 @@ export interface SearchStateProviderProps extends ParseSearchStateOptions {
86
128
  export function SearchStateProvider(
87
129
  props: SearchStateProviderProps
88
130
  ): ReactElement {
89
- const { adapter, children, ...parseOptions } = props;
131
+ const { adapter, children, defaultGeo, ...parseOptions } = props;
90
132
  const { params, setParams } = adapter;
91
133
 
92
134
  // The parse options are spread into a stable dependency: a host that builds
@@ -123,6 +165,17 @@ export function SearchStateProvider(
123
165
  [setParams, search]
124
166
  );
125
167
 
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
+
126
179
  const bag = useMemo<SearchStateBag>(() => {
127
180
  const state = parsed.state;
128
181
  const apply = (
@@ -146,7 +199,13 @@ export function SearchStateProvider(
146
199
  toggleFilter: (slug, value) => apply(toggleFilterValue(state, slug, value)),
147
200
  setFilter: (slug, values) => apply(setFilterValues(state, slug, values)),
148
201
  setRange: (slug, range) => apply(setRangeValue(state, slug, range)),
149
- setGeo: (geo) => apply(patchSearchState(state, { geo })),
202
+ 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
+ apply(patchSearchState(state, { geo }));
208
+ },
150
209
  // A page size is a preference, not a step through the results.
151
210
  setLimit: (limit) => apply(patchSearchState(state, { limit }), { replace: true }),
152
211
  clearAll: () => apply(clearFilters(state)),
@@ -156,6 +215,24 @@ export function SearchStateProvider(
156
215
  };
157
216
  }, [parsed, commit]);
158
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]);
235
+
159
236
  return <StateContext.Provider value={bag}>{children}</StateContext.Provider>;
160
237
  }
161
238
 
package/src/i18n/es.ts CHANGED
@@ -110,7 +110,7 @@ export const searchI18nBundleEs: I18nDictionary = {
110
110
  "search.geo.radius_label": "Radio, km",
111
111
  "search.geo.clear": "En cualquier lugar",
112
112
  "search.geo.box": "Dentro del área mostrada",
113
- "search.geo.center": "Cerca de {lat}, {lon}",
113
+ "search.geo.chosen_place": "Un lugar elegido en el mapa",
114
114
 
115
115
  "search.url.issues_title": "Parte de este enlace no se pudo leer",
116
116
  "search.url.issue.not_a_number":
package/src/i18n/keys.ts CHANGED
@@ -152,7 +152,18 @@ export const SEARCH_I18N_KEYS = {
152
152
  geoRadiusLabel: "search.geo.radius_label",
153
153
  geoClear: "search.geo.clear",
154
154
  geoBox: "search.geo.box",
155
- geoCenter: "search.geo.center",
155
+ /**
156
+ * What a location constraint is called when nobody has given it a NAME.
157
+ *
158
+ * It replaces `search.geo.center` ("Around {lat}, {lon}"), which printed the
159
+ * two numbers the URL happens to store — `55.756, 37.617` — to a person who
160
+ * chose a place. A coordinate is storage, not a description: it cannot be
161
+ * checked by the one reader who could check an address, so a wrong point
162
+ * reads as authoritative and a right one reads as machinery. Hosts that HAVE
163
+ * the name (the geocoder that resolved it, the IP guess's city) pass
164
+ * `geoLabel` and this sentence never appears.
165
+ */
166
+ geoChosenPlace: "search.geo.chosen_place",
156
167
 
157
168
  // ── the URL that could not be read ───────────────────────────────────────
158
169
  urlIssuesTitle: "search.url.issues_title",
@@ -316,7 +327,7 @@ export const searchI18nBundleEn: Record<string, string> = {
316
327
  "search.geo.radius_label": "Radius, km",
317
328
  "search.geo.clear": "Anywhere",
318
329
  "search.geo.box": "Inside the shown area",
319
- "search.geo.center": "Around {lat}, {lon}",
330
+ "search.geo.chosen_place": "A chosen place on the map",
320
331
 
321
332
  "search.url.issues_title": "Part of this link could not be read",
322
333
  "search.url.issue.not_a_number": "“{param}” in this link is not a number, so it was ignored",
package/src/i18n/ru.ts CHANGED
@@ -137,7 +137,7 @@ export const searchI18nBundleRu: I18nDictionary = {
137
137
  "search.geo.radius_label": "Радиус, км",
138
138
  "search.geo.clear": "Везде",
139
139
  "search.geo.box": "В показанной области",
140
- "search.geo.center": "Рядом с {lat}, {lon}",
140
+ "search.geo.chosen_place": "Выбранное место на карте",
141
141
 
142
142
  "search.url.issues_title": "Часть этой ссылки прочитать не удалось",
143
143
  "search.url.issue.not_a_number":