@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.
- package/CHANGELOG.md +51 -0
- package/dist/default/FacetPanelPane.d.ts +0 -19
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +33 -65
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +1 -24
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +11 -20
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts +9 -0
- package/dist/default/LocationSummaryLine.d.ts.map +1 -1
- package/dist/default/LocationSummaryLine.js +29 -2
- package/dist/default/LocationSummaryLine.js.map +1 -1
- package/dist/default/SearchPage.d.ts +14 -5
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +27 -22
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +37 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +7 -2
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/geoSheet.d.ts +12 -6
- package/dist/default/geoSheet.d.ts.map +1 -1
- package/dist/default/geoSheet.js +14 -3
- package/dist/default/geoSheet.js.map +1 -1
- package/dist/default/index.d.ts +5 -4
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +4 -3
- package/dist/default/index.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +42 -30
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +16 -36
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +1 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +1 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +2 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +1 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/state/facets.d.ts +18 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +25 -0
- package/dist/state/facets.js.map +1 -1
- package/dist/state/urlState.d.ts +20 -2
- package/dist/state/urlState.d.ts.map +1 -1
- package/dist/state/urlState.js +26 -5
- package/dist/state/urlState.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +2 -1
- package/nav-manifest.json +1 -1
- package/package.json +3 -3
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FacetPanelPane.tsx +72 -136
- package/src/default/FilterChips.tsx +12 -72
- package/src/default/LocationSummaryLine.tsx +65 -1
- package/src/default/SearchPage.tsx +64 -31
- package/src/default/SearchResultsPane.tsx +54 -7
- package/src/default/geoSheet.tsx +46 -7
- package/src/default/index.ts +5 -3
- package/src/headless/SearchStateProvider.tsx +57 -66
- package/src/i18n/es.ts +1 -0
- package/src/i18n/keys.ts +2 -0
- package/src/i18n/ru.ts +1 -0
- package/src/state/facets.ts +28 -0
- 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
|
-
*
|
|
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
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
-
*
|
|
107
|
+
* ## Why this is an offer and not a default (defect: a silent radius)
|
|
98
108
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* they
|
|
116
|
-
*
|
|
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
|
|
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,
|
|
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": "Выбранное место на карте",
|
package/src/state/facets.ts
CHANGED
|
@@ -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>>>>;
|
package/src/state/urlState.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
451
|
-
*
|
|
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
|
}
|