@stapel/search-react 0.19.0 → 0.21.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 (86) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/api/generated/schema.d.ts +96 -4
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/types.d.ts +47 -6
  5. package/dist/api/types.d.ts.map +1 -1
  6. package/dist/api/types.js.map +1 -1
  7. package/dist/default/FacetPanelPane.d.ts +0 -19
  8. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  9. package/dist/default/FacetPanelPane.js +45 -62
  10. package/dist/default/FacetPanelPane.js.map +1 -1
  11. package/dist/default/FilterChips.d.ts +1 -24
  12. package/dist/default/FilterChips.d.ts.map +1 -1
  13. package/dist/default/FilterChips.js +4 -18
  14. package/dist/default/FilterChips.js.map +1 -1
  15. package/dist/default/LocationSummaryLine.d.ts +12 -0
  16. package/dist/default/LocationSummaryLine.d.ts.map +1 -1
  17. package/dist/default/LocationSummaryLine.js +80 -6
  18. package/dist/default/LocationSummaryLine.js.map +1 -1
  19. package/dist/default/SearchPage.d.ts +6 -5
  20. package/dist/default/SearchPage.d.ts.map +1 -1
  21. package/dist/default/SearchPage.js +31 -21
  22. package/dist/default/SearchPage.js.map +1 -1
  23. package/dist/default/geoSheet.d.ts +12 -6
  24. package/dist/default/geoSheet.d.ts.map +1 -1
  25. package/dist/default/geoSheet.js +14 -3
  26. package/dist/default/geoSheet.js.map +1 -1
  27. package/dist/default/index.d.ts +4 -3
  28. package/dist/default/index.d.ts.map +1 -1
  29. package/dist/default/index.js +4 -3
  30. package/dist/default/index.js.map +1 -1
  31. package/dist/headless/FacetPanel.d.ts +29 -1
  32. package/dist/headless/FacetPanel.d.ts.map +1 -1
  33. package/dist/headless/FacetPanel.js +14 -0
  34. package/dist/headless/FacetPanel.js.map +1 -1
  35. package/dist/headless/SearchStateProvider.d.ts +57 -30
  36. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  37. package/dist/headless/SearchStateProvider.js +51 -36
  38. package/dist/headless/SearchStateProvider.js.map +1 -1
  39. package/dist/i18n/es.d.ts.map +1 -1
  40. package/dist/i18n/es.js +7 -0
  41. package/dist/i18n/es.js.map +1 -1
  42. package/dist/i18n/keys.d.ts +43 -0
  43. package/dist/i18n/keys.d.ts.map +1 -1
  44. package/dist/i18n/keys.js +51 -0
  45. package/dist/i18n/keys.js.map +1 -1
  46. package/dist/i18n/ru.d.ts.map +1 -1
  47. package/dist/i18n/ru.js +9 -0
  48. package/dist/i18n/ru.js.map +1 -1
  49. package/dist/index.d.ts +2 -2
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +1 -1
  52. package/dist/index.js.map +1 -1
  53. package/dist/state/degradations.d.ts +9 -0
  54. package/dist/state/degradations.d.ts.map +1 -1
  55. package/dist/state/degradations.js +13 -0
  56. package/dist/state/degradations.js.map +1 -1
  57. package/dist/state/facets.d.ts +3 -0
  58. package/dist/state/facets.d.ts.map +1 -1
  59. package/dist/state/facets.js +48 -1
  60. package/dist/state/facets.js.map +1 -1
  61. package/dist/state/urlState.d.ts +46 -3
  62. package/dist/state/urlState.d.ts.map +1 -1
  63. package/dist/state/urlState.js +69 -22
  64. package/dist/state/urlState.js.map +1 -1
  65. package/llms.txt +2 -2
  66. package/manifest.json +13 -2
  67. package/nav-manifest.json +1 -1
  68. package/package.json +5 -5
  69. package/src/analytics/generated/events.json +1 -1
  70. package/src/api/generated/schema.ts +96 -4
  71. package/src/api/types.ts +51 -5
  72. package/src/default/FacetPanelPane.tsx +52 -126
  73. package/src/default/FilterChips.tsx +5 -70
  74. package/src/default/LocationSummaryLine.tsx +157 -32
  75. package/src/default/SearchPage.tsx +57 -31
  76. package/src/default/geoSheet.tsx +46 -7
  77. package/src/default/index.ts +4 -3
  78. package/src/headless/FacetPanel.tsx +49 -1
  79. package/src/headless/SearchStateProvider.tsx +113 -66
  80. package/src/i18n/es.ts +11 -0
  81. package/src/i18n/keys.ts +55 -0
  82. package/src/i18n/ru.ts +15 -0
  83. package/src/index.ts +3 -0
  84. package/src/state/degradations.ts +14 -0
  85. package/src/state/facets.ts +47 -1
  86. package/src/state/urlState.ts +98 -17
@@ -74,19 +74,45 @@ export type SearchStateIssueCode =
74
74
  /** `bbox` was not four numbers. */
75
75
  | "bbox_malformed"
76
76
  /** `r.<slug>` was not `from..to`. */
77
- | "range_malformed";
77
+ | "range_malformed"
78
+ /**
79
+ * `radius_km` with nothing to measure it FROM — no `lat`/`lon`, no `bbox`.
80
+ *
81
+ * Not malformed: `radius_km=300` is a perfectly good number, and the search
82
+ * that ran is the honest one (a radius with no centre narrows nothing, so
83
+ * the count is the full count). What was wrong was the SILENCE. The URL said
84
+ * 300km, the results were unnarrowed, and the location control went on
85
+ * advertising its own default — measured on a live leaf as "300 in the
86
+ * address bar, 25 on the screen" with no way for a person to tell which one
87
+ * the page had used. Neither number is dropped and neither is rewritten;
88
+ * the disagreement is simply stated.
89
+ */
90
+ | "radius_without_place";
78
91
 
79
92
  const ISSUE_KEY: Readonly<Record<SearchStateIssueCode, string>> = {
80
93
  not_a_number: "search.url.issue.not_a_number",
81
94
  geo_incomplete: "search.url.issue.geo_incomplete",
82
95
  bbox_malformed: "search.url.issue.bbox_malformed",
83
96
  range_malformed: "search.url.issue.range_malformed",
97
+ radius_without_place: "search.url.issue.radius_without_place",
84
98
  };
85
99
 
86
100
  /** What {@link parseSearchState} answers: the state, plus what it could not read. */
87
101
  export interface ParsedSearchState {
88
102
  readonly state: SearchQueryState;
89
103
  readonly issues: readonly SearchStateIssue[];
104
+ /**
105
+ * A `radius_km` the URL carries with no place to measure it from.
106
+ *
107
+ * NOT part of the state — a radius with no centre is not a filter and
108
+ * nothing is sent to the server — but it is a number the person typed, and
109
+ * dropping it on the floor is how the control and the address ended up
110
+ * disagreeing: `?radius_km=300`, a button offering "within 25 km", and a
111
+ * press that silently rewrote 300 to 25. Whoever draws the offer reads this
112
+ * so that what the button SAYS, what pressing it DOES, and what the link
113
+ * ASKED FOR are one number.
114
+ */
115
+ readonly orphanRadiusKm?: number;
90
116
  }
91
117
 
92
118
  export interface ParseSearchStateOptions {
@@ -122,10 +148,16 @@ function num(
122
148
  return value;
123
149
  }
124
150
 
151
+ /** Where `parseGeo` leaves a radius it could not attach to anything. */
152
+ interface GeoParse {
153
+ readonly geo: SearchGeo | undefined;
154
+ readonly orphanRadiusKm?: number;
155
+ }
156
+
125
157
  function parseGeo(
126
158
  params: URLSearchParams,
127
159
  issues: SearchStateIssue[]
128
- ): SearchGeo | undefined {
160
+ ): GeoParse {
129
161
  const bbox = params.get(SEARCH_PARAM.bbox);
130
162
  if (bbox !== null) {
131
163
  const parts = bbox.split(",");
@@ -135,7 +167,7 @@ function parseGeo(
135
167
  code: "bbox_malformed",
136
168
  messageKey: ISSUE_KEY.bbox_malformed,
137
169
  });
138
- return undefined;
170
+ return { geo: undefined };
139
171
  }
140
172
  const values = parts.map((p) => Number(p));
141
173
  if (values.some((v) => !Number.isFinite(v))) {
@@ -144,43 +176,64 @@ function parseGeo(
144
176
  code: "bbox_malformed",
145
177
  messageKey: ISSUE_KEY.bbox_malformed,
146
178
  });
147
- return undefined;
179
+ return { geo: undefined };
148
180
  }
149
181
  // `minLon > maxLon` is LEGAL — it means the box crosses the antimeridian
150
182
  // (`stapel-search/query.py`). Normalizing it here would silently turn a
151
183
  // Pacific search into the rest of the world.
152
184
  return {
185
+ geo: {
153
186
  kind: "bbox",
154
187
  minLat: values[0] as number,
155
188
  minLon: values[1] as number,
156
189
  maxLat: values[2] as number,
157
190
  maxLon: values[3] as number,
191
+ },
158
192
  };
159
193
  }
160
194
 
161
195
  const rawLat = params.get(SEARCH_PARAM.lat);
162
196
  const rawLon = params.get(SEARCH_PARAM.lon);
163
- if (rawLat === null && rawLon === null) return undefined;
197
+ if (rawLat === null && rawLon === null) {
198
+ // A radius parked in the URL with no point to measure it from. See
199
+ // `radius_without_place`: the search below is correct, the screen was the
200
+ // liar.
201
+ const orphan = params.get(SEARCH_PARAM.radiusKm);
202
+ if (orphan !== null) {
203
+ issues.push({
204
+ param: SEARCH_PARAM.radiusKm,
205
+ code: "radius_without_place",
206
+ messageKey: ISSUE_KEY.radius_without_place,
207
+ });
208
+ const km = Number(orphan);
209
+ if (Number.isFinite(km) && km > 0) {
210
+ return { geo: undefined, orphanRadiusKm: km };
211
+ }
212
+ }
213
+ return { geo: undefined };
214
+ }
164
215
  if (rawLat === null || rawLon === null) {
165
216
  issues.push({
166
217
  param: rawLat === null ? SEARCH_PARAM.lat : SEARCH_PARAM.lon,
167
218
  code: "geo_incomplete",
168
219
  messageKey: ISSUE_KEY.geo_incomplete,
169
220
  });
170
- return undefined;
221
+ return { geo: undefined };
171
222
  }
172
223
  const lat = num(rawLat, SEARCH_PARAM.lat, issues);
173
224
  const lon = num(rawLon, SEARCH_PARAM.lon, issues);
174
- if (lat === undefined || lon === undefined) return undefined;
225
+ if (lat === undefined || lon === undefined) return { geo: undefined };
175
226
 
176
227
  const rawRadius = params.get(SEARCH_PARAM.radiusKm);
177
228
  const radiusKm =
178
229
  rawRadius === null ? undefined : num(rawRadius, SEARCH_PARAM.radiusKm, issues);
179
230
  return {
180
- kind: "center",
181
- lat,
182
- lon,
183
- ...(radiusKm !== undefined ? { radiusKm } : {}),
231
+ geo: {
232
+ kind: "center",
233
+ lat,
234
+ lon,
235
+ ...(radiusKm !== undefined ? { radiusKm } : {}),
236
+ },
184
237
  };
185
238
  }
186
239
 
@@ -246,6 +299,8 @@ export function parseSearchState(
246
299
  const direction: "next" | "prev" | undefined =
247
300
  rawDirection === null ? undefined : rawDirection === "prev" ? "prev" : "next";
248
301
 
302
+ const geoParse = parseGeo(params, issues);
303
+
249
304
  const state: SearchQueryState = {
250
305
  type: params.get(SEARCH_PARAM.type) ?? options.defaultType,
251
306
  q: params.get(SEARCH_PARAM.q) ?? options.defaultQ ?? "",
@@ -257,7 +312,7 @@ export function parseSearchState(
257
312
  params.get(SEARCH_PARAM.category) ?? options.defaultCategory
258
313
  ),
259
314
  ...optional(SEARCH_PARAM.owner, params.get(SEARCH_PARAM.owner) ?? undefined),
260
- ...optional("geo", parseGeo(params, issues)),
315
+ ...optional("geo", geoParse.geo),
261
316
  ...optional(
262
317
  SEARCH_PARAM.sort,
263
318
  params.get(SEARCH_PARAM.sort) ?? options.defaultSort
@@ -268,7 +323,11 @@ export function parseSearchState(
268
323
  ...optional("limit", limit),
269
324
  };
270
325
 
271
- return { state, issues };
326
+ return {
327
+ state,
328
+ issues,
329
+ ...optional("orphanRadiusKm", geoParse.orphanRadiusKm),
330
+ };
272
331
  }
273
332
 
274
333
  /** `exactOptionalPropertyTypes` makes `{k: undefined}` and "absent" different
@@ -444,15 +503,37 @@ export function setRangeValue(
444
503
  * would be removing something they never set.
445
504
  */
446
505
  export function clearFilters(state: SearchQueryState): SearchQueryState {
447
- return patchSearchState(state, { filters: {}, ranges: {}, geo: null });
506
+ // The PLACE survives. It is not a filter (see `activeFilterCount`), it is
507
+ // not counted by the control that calls this, and a person who chose their
508
+ // city and then narrowed by price did not ask to be moved back to the whole
509
+ // country when they widen the price again. The location control has its own
510
+ // way off, and it says the name of the place it would remove.
511
+ return patchSearchState(state, { filters: {}, ranges: {} });
448
512
  }
449
513
 
450
- /** How many constraints the person has actually applied (facet values +
451
- * ranges + geo) what a "clear all (N)" control counts. */
514
+ /**
515
+ * How many constraints the person has actually applied facet values and
516
+ * ranges, and NOTHING ELSE. What a "clear all (N)" control counts.
517
+ *
518
+ * ## A latitude is not a filter
519
+ *
520
+ * `lat`/`lon` used to add 1 to this, and a place chosen on a map is a real
521
+ * narrowing, so that looked right. What a person saw was not. On a live board
522
+ * a landing announced "clear all filters (2)" over an empty page, with two
523
+ * filters that had no chip, no name and no row in the panel — the owner's
524
+ * words were "two active filters I can't even look at". A count that names
525
+ * nothing is worse than no count: it tells a person that something is hiding
526
+ * their results and gives them nothing to press.
527
+ *
528
+ * A coordinate pair is not a filter a person picked, it is the machine form
529
+ * of a place. The place is stated by the location control, in words, beside
530
+ * the radius it comes with — its own thing in the chrome, like the search box,
531
+ * not a row in the filter list and not a number in this sum. So this counts
532
+ * facets and ranges, and the location says its own name.
533
+ */
452
534
  export function activeFilterCount(state: SearchQueryState): number {
453
535
  let count = 0;
454
536
  for (const values of Object.values(state.filters)) count += values.length;
455
537
  count += Object.keys(state.ranges).length;
456
- if (state.geo !== undefined) count += 1;
457
538
  return count;
458
539
  }