@stapel/search-react 0.18.1 → 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 (69) hide show
  1. package/CHANGELOG.md +51 -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 +33 -65
  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 +11 -20
  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 +14 -5
  15. package/dist/default/SearchPage.d.ts.map +1 -1
  16. package/dist/default/SearchPage.js +27 -22
  17. package/dist/default/SearchPage.js.map +1 -1
  18. package/dist/default/SearchResultsPane.d.ts +37 -0
  19. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  20. package/dist/default/SearchResultsPane.js +7 -2
  21. package/dist/default/SearchResultsPane.js.map +1 -1
  22. package/dist/default/geoSheet.d.ts +12 -6
  23. package/dist/default/geoSheet.d.ts.map +1 -1
  24. package/dist/default/geoSheet.js +14 -3
  25. package/dist/default/geoSheet.js.map +1 -1
  26. package/dist/default/index.d.ts +5 -4
  27. package/dist/default/index.d.ts.map +1 -1
  28. package/dist/default/index.js +4 -3
  29. package/dist/default/index.js.map +1 -1
  30. package/dist/headless/SearchStateProvider.d.ts +42 -30
  31. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  32. package/dist/headless/SearchStateProvider.js +16 -36
  33. package/dist/headless/SearchStateProvider.js.map +1 -1
  34. package/dist/i18n/es.d.ts.map +1 -1
  35. package/dist/i18n/es.js +1 -0
  36. package/dist/i18n/es.js.map +1 -1
  37. package/dist/i18n/keys.d.ts +1 -0
  38. package/dist/i18n/keys.d.ts.map +1 -1
  39. package/dist/i18n/keys.js +2 -0
  40. package/dist/i18n/keys.js.map +1 -1
  41. package/dist/i18n/ru.d.ts.map +1 -1
  42. package/dist/i18n/ru.js +1 -0
  43. package/dist/i18n/ru.js.map +1 -1
  44. package/dist/state/facets.d.ts +18 -0
  45. package/dist/state/facets.d.ts.map +1 -1
  46. package/dist/state/facets.js +25 -0
  47. package/dist/state/facets.js.map +1 -1
  48. package/dist/state/urlState.d.ts +20 -2
  49. package/dist/state/urlState.d.ts.map +1 -1
  50. package/dist/state/urlState.js +26 -5
  51. package/dist/state/urlState.js.map +1 -1
  52. package/llms.txt +1 -1
  53. package/manifest.json +2 -1
  54. package/nav-manifest.json +1 -1
  55. package/package.json +3 -3
  56. package/src/analytics/generated/events.json +1 -1
  57. package/src/default/FacetPanelPane.tsx +72 -136
  58. package/src/default/FilterChips.tsx +12 -72
  59. package/src/default/LocationSummaryLine.tsx +65 -1
  60. package/src/default/SearchPage.tsx +64 -31
  61. package/src/default/SearchResultsPane.tsx +54 -7
  62. package/src/default/geoSheet.tsx +46 -7
  63. package/src/default/index.ts +5 -3
  64. package/src/headless/SearchStateProvider.tsx +57 -66
  65. package/src/i18n/es.ts +1 -0
  66. package/src/i18n/keys.ts +2 -0
  67. package/src/i18n/ru.ts +1 -0
  68. package/src/state/facets.ts +28 -0
  69. package/src/state/urlState.ts +26 -4
@@ -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": "Выбранное место на карте",
@@ -147,6 +147,34 @@ export function facetCoverage(group: FacetGroup): number {
147
147
  return total;
148
148
  }
149
149
 
150
+ /**
151
+ * The order two surfaces put COUNTED facet groups in: what the person has
152
+ * already answered, then what the corpus has evidence for.
153
+ *
154
+ * Stated once, because it was stated twice and the second surface never got
155
+ * it. The chip row has ranked by coverage since D16; the RAIL rendered
156
+ * `buildFacetGroups`' output in schema order, and on the deployed phones leaf
157
+ * that put battery health, four parcel dimensions and two wholesale packing
158
+ * counts above the brand — so a buyer looking at the category page's 280px
159
+ * rail saw seven axes of parcel logistics and not one brand (walker D120/D121
160
+ * on the desktop, D74 on the phone).
161
+ *
162
+ * Stable by construction: equal-ranked groups keep the order
163
+ * `buildFacetGroups` gave them, so a closed set's authored order survives.
164
+ */
165
+ export function compareFacetsByEvidence(a: FacetGroup, b: FacetGroup): number {
166
+ const answered = Number(b.selected.length > 0) - Number(a.selected.length > 0);
167
+ if (answered !== 0) return answered;
168
+ return facetCoverage(b) - facetCoverage(a);
169
+ }
170
+
171
+ /** {@link compareFacetsByEvidence}, applied. Never mutates the input. */
172
+ export function orderFacetGroups(
173
+ groups: readonly FacetGroup[]
174
+ ): readonly FacetGroup[] {
175
+ return [...groups].sort(compareFacetsByEvidence);
176
+ }
177
+
150
178
  export interface BuildFacetGroupsInput {
151
179
  /** The envelope's `facets`: `{slug: {value: count}}`. */
152
180
  readonly facets: Readonly<Record<string, Readonly<Record<string, number>>>>;
@@ -444,15 +444,37 @@ export function setRangeValue(
444
444
  * would be removing something they never set.
445
445
  */
446
446
  export function clearFilters(state: SearchQueryState): SearchQueryState {
447
- return patchSearchState(state, { filters: {}, ranges: {}, geo: null });
447
+ // The PLACE survives. It is not a filter (see `activeFilterCount`), it is
448
+ // not counted by the control that calls this, and a person who chose their
449
+ // city and then narrowed by price did not ask to be moved back to the whole
450
+ // country when they widen the price again. The location control has its own
451
+ // way off, and it says the name of the place it would remove.
452
+ return patchSearchState(state, { filters: {}, ranges: {} });
448
453
  }
449
454
 
450
- /** How many constraints the person has actually applied (facet values +
451
- * ranges + geo) what a "clear all (N)" control counts. */
455
+ /**
456
+ * How many constraints the person has actually applied facet values and
457
+ * ranges, and NOTHING ELSE. What a "clear all (N)" control counts.
458
+ *
459
+ * ## A latitude is not a filter
460
+ *
461
+ * `lat`/`lon` used to add 1 to this, and a place chosen on a map is a real
462
+ * narrowing, so that looked right. What a person saw was not. On a live board
463
+ * a landing announced "clear all filters (2)" over an empty page, with two
464
+ * filters that had no chip, no name and no row in the panel — the owner's
465
+ * words were "two active filters I can't even look at". A count that names
466
+ * nothing is worse than no count: it tells a person that something is hiding
467
+ * their results and gives them nothing to press.
468
+ *
469
+ * A coordinate pair is not a filter a person picked, it is the machine form
470
+ * of a place. The place is stated by the location control, in words, beside
471
+ * the radius it comes with — its own thing in the chrome, like the search box,
472
+ * not a row in the filter list and not a number in this sum. So this counts
473
+ * facets and ranges, and the location says its own name.
474
+ */
452
475
  export function activeFilterCount(state: SearchQueryState): number {
453
476
  let count = 0;
454
477
  for (const values of Object.values(state.filters)) count += values.length;
455
478
  count += Object.keys(state.ranges).length;
456
- if (state.geo !== undefined) count += 1;
457
479
  return count;
458
480
  }