@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.
- package/CHANGELOG.md +125 -0
- package/dist/api/generated/schema.d.ts +96 -4
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +47 -6
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +0 -19
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +45 -62
- 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 +4 -18
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts +12 -0
- package/dist/default/LocationSummaryLine.d.ts.map +1 -1
- package/dist/default/LocationSummaryLine.js +80 -6
- package/dist/default/LocationSummaryLine.js.map +1 -1
- package/dist/default/SearchPage.d.ts +6 -5
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +31 -21
- package/dist/default/SearchPage.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 +4 -3
- 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/FacetPanel.d.ts +29 -1
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +14 -0
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +57 -30
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +51 -36
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +7 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +43 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +51 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +9 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/state/degradations.d.ts +9 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +13 -0
- package/dist/state/degradations.js.map +1 -1
- package/dist/state/facets.d.ts +3 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +48 -1
- package/dist/state/facets.js.map +1 -1
- package/dist/state/urlState.d.ts +46 -3
- package/dist/state/urlState.d.ts.map +1 -1
- package/dist/state/urlState.js +69 -22
- package/dist/state/urlState.js.map +1 -1
- package/llms.txt +2 -2
- package/manifest.json +13 -2
- package/nav-manifest.json +1 -1
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +96 -4
- package/src/api/types.ts +51 -5
- package/src/default/FacetPanelPane.tsx +52 -126
- package/src/default/FilterChips.tsx +5 -70
- package/src/default/LocationSummaryLine.tsx +157 -32
- package/src/default/SearchPage.tsx +57 -31
- package/src/default/geoSheet.tsx +46 -7
- package/src/default/index.ts +4 -3
- package/src/headless/FacetPanel.tsx +49 -1
- package/src/headless/SearchStateProvider.tsx +113 -66
- package/src/i18n/es.ts +11 -0
- package/src/i18n/keys.ts +55 -0
- package/src/i18n/ru.ts +15 -0
- package/src/index.ts +3 -0
- package/src/state/degradations.ts +14 -0
- package/src/state/facets.ts +47 -1
- package/src/state/urlState.ts +98 -17
|
@@ -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,37 @@ 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;
|
|
74
|
+
/**
|
|
75
|
+
* Whether the location this search is USING is the one the host offered —
|
|
76
|
+
* i.e. the visitor pressed "near me" (or followed a link built by somebody
|
|
77
|
+
* who did) and has not moved the pin since.
|
|
78
|
+
*
|
|
79
|
+
* A fact about provenance, and only the provider holds it: `state.geo` is a
|
|
80
|
+
* centre and a radius, and every way of arriving at a centre produces the
|
|
81
|
+
* same three numbers. Without it a summary line has to guess, and the guess
|
|
82
|
+
* it shipped was "a chosen place on the map" — said to a person who had
|
|
83
|
+
* pressed a button and never opened a map.
|
|
84
|
+
*
|
|
85
|
+
* `false` whenever there is no offer to compare against, which includes
|
|
86
|
+
* every search on a host that offers none.
|
|
87
|
+
*/
|
|
88
|
+
readonly geoIsOffer: boolean;
|
|
62
89
|
|
|
63
90
|
setText(q: string): void;
|
|
64
91
|
setSort(sort: string | null): void;
|
|
@@ -72,50 +99,71 @@ export interface SearchStateBag {
|
|
|
72
99
|
clearAll(): void;
|
|
73
100
|
/** Move to a keyset page. The ONLY mutator that keeps a cursor. */
|
|
74
101
|
goToAnchor(anchor: string | null, direction: "next" | "prev"): void;
|
|
102
|
+
/** Accept {@link geoOffer}. A no-op when there is nothing on offer, so a
|
|
103
|
+
* control can be pressed without first re-checking what this bag says. */
|
|
104
|
+
acceptGeoOffer(): void;
|
|
75
105
|
/** Escape hatch for a host control this pair does not ship. Goes through
|
|
76
106
|
* `patchSearchState`, so it drops the cursor like every other change. */
|
|
77
107
|
patch(patch: SearchStatePatch): void;
|
|
78
108
|
}
|
|
79
109
|
|
|
110
|
+
/**
|
|
111
|
+
* How close two coordinates have to be to be the same place, in degrees.
|
|
112
|
+
*
|
|
113
|
+
* The applied location makes a round trip through the query string, so the
|
|
114
|
+
* numbers that come back are the ones `String(lat)` produced rather than the
|
|
115
|
+
* float the device handed over. 1e-6 degrees is ~11cm — far below any
|
|
116
|
+
* position a browser reports and far above any rounding the codec introduces.
|
|
117
|
+
*/
|
|
118
|
+
const SAME_PLACE_EPSILON = 1e-6;
|
|
119
|
+
|
|
120
|
+
/** Is the applied location the offered one? See {@link SearchStateBag.geoIsOffer}. */
|
|
121
|
+
function sameCenter(
|
|
122
|
+
applied: SearchGeo | undefined,
|
|
123
|
+
offered: SearchGeo | undefined
|
|
124
|
+
): boolean {
|
|
125
|
+
if (applied === undefined || offered === undefined) return false;
|
|
126
|
+
if (applied.kind !== "center" || offered.kind !== "center") return false;
|
|
127
|
+
return (
|
|
128
|
+
Math.abs(applied.lat - offered.lat) < SAME_PLACE_EPSILON &&
|
|
129
|
+
Math.abs(applied.lon - offered.lon) < SAME_PLACE_EPSILON
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
80
133
|
const StateContext = createContext<SearchStateBag | null>(null);
|
|
81
134
|
|
|
82
135
|
export interface SearchStateProviderProps extends ParseSearchStateOptions {
|
|
83
136
|
readonly adapter: SearchParamsAdapter;
|
|
84
137
|
/**
|
|
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).
|
|
138
|
+
* A location this search COULD be narrowed to — offered, never applied.
|
|
88
139
|
*
|
|
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.
|
|
140
|
+
* The host resolves it (a granted browser prompt, the server's IP guess);
|
|
141
|
+
* this provider does nothing with it except hand it back on the bag as
|
|
142
|
+
* {@link SearchStateBag.geoOffer}, so a control can draw "near me" and the
|
|
143
|
+
* PERSON decides. Nothing is written to the URL until they press it.
|
|
96
144
|
*
|
|
97
|
-
*
|
|
145
|
+
* ## Why this is an offer and not a default (defect: a silent radius)
|
|
98
146
|
*
|
|
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.
|
|
147
|
+
* The previous shape of this prop (`defaultGeo`) wrote the visitor's
|
|
148
|
+
* position into the query string on the first render that could, under four
|
|
149
|
+
* careful rules about not overruling a person. Every rule held, and the
|
|
150
|
+
* result was still a filter nobody asked for: a browser permission granted
|
|
151
|
+
* once, for one map, became a permanent 25 km wall around every category
|
|
152
|
+
* leaf and every result page in the deployment. Measured on a live board:
|
|
153
|
+
* 48 phones became 17, and three leaves with stock became "nothing found".
|
|
154
|
+
* The page looks perfectly healthy while doing it, which is what makes it
|
|
155
|
+
* dangerous — the honest reading of an empty leaf is "this board is empty",
|
|
156
|
+
* not "your browser told us where you are".
|
|
112
157
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* they
|
|
116
|
-
*
|
|
158
|
+
* There is no set of rules that fixes that, because the defect is not in
|
|
159
|
+
* the rules: applying a spatial filter is a decision about what the person
|
|
160
|
+
* wants to see, and only they hold it. So the prop carries the same value
|
|
161
|
+
* and the provider no longer commits it. Two more defects fall out with it:
|
|
162
|
+
* the URL is never rewritten behind the visitor (a hand-typed `radius_km`
|
|
163
|
+
* survives), and the results are fetched ONCE instead of being fetched and
|
|
164
|
+
* immediately superseded by a second, narrower query.
|
|
117
165
|
*/
|
|
118
|
-
readonly
|
|
166
|
+
readonly geoOffer?: SearchGeo | undefined;
|
|
119
167
|
readonly children: ReactNode;
|
|
120
168
|
}
|
|
121
169
|
|
|
@@ -128,7 +176,7 @@ export interface SearchStateProviderProps extends ParseSearchStateOptions {
|
|
|
128
176
|
export function SearchStateProvider(
|
|
129
177
|
props: SearchStateProviderProps
|
|
130
178
|
): ReactElement {
|
|
131
|
-
const { adapter, children,
|
|
179
|
+
const { adapter, children, geoOffer, ...parseOptions } = props;
|
|
132
180
|
const { params, setParams } = adapter;
|
|
133
181
|
|
|
134
182
|
// The parse options are spread into a stable dependency: a host that builds
|
|
@@ -165,19 +213,26 @@ export function SearchStateProvider(
|
|
|
165
213
|
[setParams, search]
|
|
166
214
|
);
|
|
167
215
|
|
|
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
216
|
|
|
179
217
|
const bag = useMemo<SearchStateBag>(() => {
|
|
180
218
|
const state = parsed.state;
|
|
219
|
+
/**
|
|
220
|
+
* The offer, carrying the radius the URL already asked for.
|
|
221
|
+
*
|
|
222
|
+
* `?radius_km=300` with no `lat`/`lon` narrows nothing — there is no
|
|
223
|
+
* point to measure from — but it IS a number the person typed, and the
|
|
224
|
+
* offer used to ignore it twice over: the button advertised the host's
|
|
225
|
+
* own 25km, and pressing it wrote 25 into the address over the 300 that
|
|
226
|
+
* was already there. Three things now agree, which is the whole of the
|
|
227
|
+
* fix: what the link asked for, what the button says, and what pressing
|
|
228
|
+
* it does. A bbox offer is left alone — a box has no radius to carry.
|
|
229
|
+
*/
|
|
230
|
+
const offer: SearchGeo | undefined =
|
|
231
|
+
geoOffer !== undefined &&
|
|
232
|
+
geoOffer.kind === "center" &&
|
|
233
|
+
parsed.orphanRadiusKm !== undefined
|
|
234
|
+
? { ...geoOffer, radiusKm: parsed.orphanRadiusKm }
|
|
235
|
+
: geoOffer;
|
|
181
236
|
const apply = (
|
|
182
237
|
next: SearchQueryState,
|
|
183
238
|
options?: { readonly replace?: boolean }
|
|
@@ -188,6 +243,13 @@ export function SearchStateProvider(
|
|
|
188
243
|
state,
|
|
189
244
|
issues: parsed.issues,
|
|
190
245
|
activeFilters: activeFilterCount(state),
|
|
246
|
+
// An offer stands only while the question is open. Once the search
|
|
247
|
+
// carries a place — from a link, or because somebody pressed the offer
|
|
248
|
+
// — there is nothing left to offer, and a control that kept drawing
|
|
249
|
+
// "near me" beside an applied location would be inviting a person to
|
|
250
|
+
// re-answer a question they can already see the answer to.
|
|
251
|
+
geoOffer: state.geo === undefined ? offer : undefined,
|
|
252
|
+
geoIsOffer: sameCenter(state.geo, offer),
|
|
191
253
|
|
|
192
254
|
// Typing replaces rather than pushes: one history entry per letter
|
|
193
255
|
// would make Back useless, which is the control the spec's acceptance
|
|
@@ -200,12 +262,14 @@ export function SearchStateProvider(
|
|
|
200
262
|
setFilter: (slug, values) => apply(setFilterValues(state, slug, values)),
|
|
201
263
|
setRange: (slug, range) => apply(setRangeValue(state, slug, range)),
|
|
202
264
|
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
265
|
apply(patchSearchState(state, { geo }));
|
|
208
266
|
},
|
|
267
|
+
acceptGeoOffer: () => {
|
|
268
|
+
if (offer === undefined || state.geo !== undefined) return;
|
|
269
|
+
// A PUSH, like any other filter the person applies: Back takes the
|
|
270
|
+
// narrowing off again, which is the same promise every chip makes.
|
|
271
|
+
apply(patchSearchState(state, { geo: offer }));
|
|
272
|
+
},
|
|
209
273
|
// A page size is a preference, not a step through the results.
|
|
210
274
|
setLimit: (limit) => apply(patchSearchState(state, { limit }), { replace: true }),
|
|
211
275
|
clearAll: () => apply(clearFilters(state)),
|
|
@@ -213,25 +277,8 @@ export function SearchStateProvider(
|
|
|
213
277
|
apply(patchSearchState(state, { anchor, direction })),
|
|
214
278
|
patch: (patch) => apply(patchSearchState(state, patch)),
|
|
215
279
|
};
|
|
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]);
|
|
280
|
+
}, [parsed, commit, geoOffer]);
|
|
281
|
+
|
|
235
282
|
|
|
236
283
|
return <StateContext.Provider value={bag}>{children}</StateContext.Provider>;
|
|
237
284
|
}
|
package/src/i18n/es.ts
CHANGED
|
@@ -75,6 +75,10 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
75
75
|
"Los recuentos son aproximados: había demasiados candidatos para contarlos todos.",
|
|
76
76
|
"search.facets.skipped":
|
|
77
77
|
"Estos filtros no se contaron para esta búsqueda: {slugs}",
|
|
78
|
+
"search.facets.withheld.one":
|
|
79
|
+
"{count} filtro se aplica a muy pocos de estos resultados",
|
|
80
|
+
"search.facets.withheld.other":
|
|
81
|
+
"{count} filtros se aplican a muy pocos de estos resultados",
|
|
78
82
|
"search.facets.not_counted": "sin contar",
|
|
79
83
|
"search.facets.drill_down_hint":
|
|
80
84
|
"Cada recuento es lo que obtendrías eligiendo ese valor en lugar del actual.",
|
|
@@ -125,11 +129,14 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
125
129
|
|
|
126
130
|
"search.geo.title": "Ubicación",
|
|
127
131
|
"search.geo.radius_km": "A menos de {km} km",
|
|
132
|
+
"search.geo.radius_km_short": "{km} km",
|
|
128
133
|
"search.geo.radius_label": "Radio, km",
|
|
129
134
|
"search.geo.clear": "En cualquier lugar",
|
|
135
|
+
"search.geo.near_me": "Cerca de mí",
|
|
130
136
|
"search.geo.everywhere": "Buscando en todas partes",
|
|
131
137
|
"search.geo.box": "Dentro del área mostrada",
|
|
132
138
|
"search.geo.chosen_place": "Un lugar elegido en el mapa",
|
|
139
|
+
"search.geo.near_you": "Cerca de ti",
|
|
133
140
|
|
|
134
141
|
"search.url.issues_title": "Parte de este enlace no se pudo leer",
|
|
135
142
|
"search.url.issue.not_a_number":
|
|
@@ -140,6 +147,8 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
140
147
|
"el área del mapa de este enlace está incompleta, así que se ignoró",
|
|
141
148
|
"search.url.issue.range_malformed":
|
|
142
149
|
"al rango «{param}» de este enlace le faltan números, así que se ignoró",
|
|
150
|
+
"search.url.issue.radius_without_place":
|
|
151
|
+
"este enlace pide un radio pero no nombra ningún lugar, así que aún no se acota nada — elige un lugar y se aplicará ese mismo radio",
|
|
143
152
|
|
|
144
153
|
"search.degraded.title": "Lo que esta búsqueda no pudo hacer",
|
|
145
154
|
"search.degraded.typo_tolerance":
|
|
@@ -150,6 +159,8 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
150
159
|
"search.degraded.exact_facet_counts": "Los recuentos de filtros son aproximados.",
|
|
151
160
|
"search.degraded.category_rollup":
|
|
152
161
|
"Pueden faltar subcategorías en estos resultados: el servicio de categorías no respondió.",
|
|
162
|
+
"search.degraded.facet_plan_evidence":
|
|
163
|
+
"No pudimos determinar qué filtros encajan con estos resultados, así que puede haber más de los que muestra el panel.",
|
|
153
164
|
"search.degraded.scorer":
|
|
154
165
|
"El parámetro de ranking «{scorer}» no se aplicó: el motor configurado no puede evaluarlo.",
|
|
155
166
|
"search.degraded.unknown":
|
package/src/i18n/keys.ts
CHANGED
|
@@ -116,6 +116,17 @@ export const SEARCH_I18N_KEYS = {
|
|
|
116
116
|
facetsClearAll: "search.facets.clear_all",
|
|
117
117
|
facetsApproximate: "search.facets.approximate",
|
|
118
118
|
facetsSkipped: "search.facets.skipped",
|
|
119
|
+
/**
|
|
120
|
+
* "N filters apply to too few of these results" — the sentence that
|
|
121
|
+
* REPLACES {@link facetsEmpty} whenever `facet_meta.withheld` is not
|
|
122
|
+
* empty. A PLURAL FAMILY: it counts a noun in words.
|
|
123
|
+
*
|
|
124
|
+
* D175: a branch category holding 46 phones that all carry a manufacturer
|
|
125
|
+
* printed "this search offers no filters". The groups were counted and
|
|
126
|
+
* then withheld for covering too little of the set — so "there are none"
|
|
127
|
+
* was false, and the honest sentence is this one, with the number in it.
|
|
128
|
+
*/
|
|
129
|
+
facetsWithheld: "search.facets.withheld",
|
|
119
130
|
/** Shown INSTEAD of a count for a slug the server did not count. */
|
|
120
131
|
facetsNotCounted: "search.facets.not_counted",
|
|
121
132
|
facetsDrillDownHint: "search.facets.drill_down_hint",
|
|
@@ -217,8 +228,22 @@ export const SEARCH_I18N_KEYS = {
|
|
|
217
228
|
// ── geo ──────────────────────────────────────────────────────────────────
|
|
218
229
|
geoTitle: "search.geo.title",
|
|
219
230
|
geoRadiusKm: "search.geo.radius_km",
|
|
231
|
+
/**
|
|
232
|
+
* The same number, said in the fewest words that still say it — for the
|
|
233
|
+
* OFFER, where the sentence shares a 390px line with a place name and the
|
|
234
|
+
* word "Filters".
|
|
235
|
+
*
|
|
236
|
+
* Measured at 390: "Near me · Within 25 km" made the control 272px wide
|
|
237
|
+
* against 231px of room, so the offer was cut mid-word by the group's own
|
|
238
|
+
* clip and the filters door overlapped it by 52px. Dropping the number
|
|
239
|
+
* instead was not an option — a button that says only "near me" asks a
|
|
240
|
+
* person to accept a radius they cannot see, which is the whole reason the
|
|
241
|
+
* offer states one. So the PREPOSITION goes and the number stays.
|
|
242
|
+
*/
|
|
243
|
+
geoRadiusKmShort: "search.geo.radius_km_short",
|
|
220
244
|
geoRadiusLabel: "search.geo.radius_label",
|
|
221
245
|
geoClear: "search.geo.clear",
|
|
246
|
+
geoNearMe: "search.geo.near_me",
|
|
222
247
|
/**
|
|
223
248
|
* What `<LocationSummaryLine>` says when NO location is applied.
|
|
224
249
|
*
|
|
@@ -242,6 +267,18 @@ export const SEARCH_I18N_KEYS = {
|
|
|
242
267
|
* `geoLabel` and this sentence never appears.
|
|
243
268
|
*/
|
|
244
269
|
geoChosenPlace: "search.geo.chosen_place",
|
|
270
|
+
/**
|
|
271
|
+
* What the line says when the applied location IS the position the host
|
|
272
|
+
* offered — the visitor pressed "Near me" and nothing else has moved since.
|
|
273
|
+
*
|
|
274
|
+
* Measured on a live leaf: pressing the offer turned the line into "A chosen
|
|
275
|
+
* place on the map", to a person who had never opened a map. The sentence
|
|
276
|
+
* was not wrong about the mechanism (a centre with a radius is what a map
|
|
277
|
+
* pick produces); it was wrong about the only thing the reader can check —
|
|
278
|
+
* how the search came to be looking there. The provider reports the fact
|
|
279
|
+
* (`geoIsOffer`) rather than this file guessing at it.
|
|
280
|
+
*/
|
|
281
|
+
geoNearYou: "search.geo.near_you",
|
|
245
282
|
|
|
246
283
|
// ── the URL that could not be read ───────────────────────────────────────
|
|
247
284
|
urlIssuesTitle: "search.url.issues_title",
|
|
@@ -249,6 +286,8 @@ export const SEARCH_I18N_KEYS = {
|
|
|
249
286
|
urlIssueGeoIncomplete: "search.url.issue.geo_incomplete",
|
|
250
287
|
urlIssueBboxMalformed: "search.url.issue.bbox_malformed",
|
|
251
288
|
urlIssueRangeMalformed: "search.url.issue.range_malformed",
|
|
289
|
+
/** A radius with no point to measure it from — see `urlState.ts`. */
|
|
290
|
+
urlIssueRadiusWithoutPlace: "search.url.issue.radius_without_place",
|
|
252
291
|
|
|
253
292
|
// ── degradations (the envelope's `degraded[]`) ───────────────────────────
|
|
254
293
|
degradedTitle: "search.degraded.title",
|
|
@@ -257,6 +296,10 @@ export const SEARCH_I18N_KEYS = {
|
|
|
257
296
|
degradedExactTotal: "search.degraded.exact_total",
|
|
258
297
|
degradedExactFacetCounts: "search.degraded.exact_facet_counts",
|
|
259
298
|
degradedCategoryRollup: "search.degraded.category_rollup",
|
|
299
|
+
/** The engine cannot say which categories the results are in, so no facet
|
|
300
|
+
* plan could be drawn from them — a READER-facing sentence, because the
|
|
301
|
+
* consequence is a filter panel that is thinner than the catalogue. */
|
|
302
|
+
degradedFacetPlanEvidence: "search.degraded.facet_plan_evidence",
|
|
260
303
|
degradedScorer: "search.degraded.scorer",
|
|
261
304
|
degradedUnknown: "search.degraded.unknown",
|
|
262
305
|
|
|
@@ -328,6 +371,7 @@ export const SEARCH_I18N_PLURAL_KEYS: readonly SearchI18nKey[] = [
|
|
|
328
371
|
SEARCH_I18N_KEYS.filtersShowCountAtLeast,
|
|
329
372
|
SEARCH_I18N_KEYS.boxCategoryCount,
|
|
330
373
|
SEARCH_I18N_KEYS.facetsMatchCount,
|
|
374
|
+
SEARCH_I18N_KEYS.facetsWithheld,
|
|
331
375
|
];
|
|
332
376
|
|
|
333
377
|
/**
|
|
@@ -400,6 +444,10 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
400
444
|
"Counts are approximate — there were too many candidates to count them all.",
|
|
401
445
|
"search.facets.skipped":
|
|
402
446
|
"These filters were not counted for this search: {slugs}",
|
|
447
|
+
"search.facets.withheld.one":
|
|
448
|
+
"{count} filter applies to too few of these results",
|
|
449
|
+
"search.facets.withheld.other":
|
|
450
|
+
"{count} filters apply to too few of these results",
|
|
403
451
|
"search.facets.not_counted": "not counted",
|
|
404
452
|
"search.facets.drill_down_hint":
|
|
405
453
|
"Each count is what you would get by choosing that value instead of the one you have.",
|
|
@@ -449,12 +497,15 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
449
497
|
"search.limit.from_link": "This link sets its own page size.",
|
|
450
498
|
|
|
451
499
|
"search.geo.title": "Location",
|
|
500
|
+
"search.geo.near_me": "Near me",
|
|
452
501
|
"search.geo.radius_km": "Within {km} km",
|
|
502
|
+
"search.geo.radius_km_short": "{km} km",
|
|
453
503
|
"search.geo.radius_label": "Radius, km",
|
|
454
504
|
"search.geo.clear": "Anywhere",
|
|
455
505
|
"search.geo.everywhere": "Searching everywhere",
|
|
456
506
|
"search.geo.box": "Inside the shown area",
|
|
457
507
|
"search.geo.chosen_place": "A chosen place on the map",
|
|
508
|
+
"search.geo.near_you": "Near you",
|
|
458
509
|
|
|
459
510
|
"search.url.issues_title": "Part of this link could not be read",
|
|
460
511
|
"search.url.issue.not_a_number": "“{param}” in this link is not a number, so it was ignored",
|
|
@@ -464,6 +515,8 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
464
515
|
"the map area in this link is incomplete, so it was ignored",
|
|
465
516
|
"search.url.issue.range_malformed":
|
|
466
517
|
"the range “{param}” in this link needs two numbers, so it was ignored",
|
|
518
|
+
"search.url.issue.radius_without_place":
|
|
519
|
+
"this link asks for a radius but names no place, so nothing is narrowed yet — choose a place and this radius is the one that applies",
|
|
467
520
|
|
|
468
521
|
"search.degraded.title": "What this search could not do",
|
|
469
522
|
"search.degraded.typo_tolerance":
|
|
@@ -474,6 +527,8 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
474
527
|
"search.degraded.exact_facet_counts": "The filter counts are approximate.",
|
|
475
528
|
"search.degraded.category_rollup":
|
|
476
529
|
"Subcategories may be missing from these results — the category service did not answer.",
|
|
530
|
+
"search.degraded.facet_plan_evidence":
|
|
531
|
+
"We could not work out which filters fit these results, so there may be more than the panel shows.",
|
|
477
532
|
"search.degraded.scorer":
|
|
478
533
|
"The ranking parameter “{scorer}” was not applied — the search engine in use cannot evaluate it.",
|
|
479
534
|
"search.degraded.unknown":
|
package/src/i18n/ru.ts
CHANGED
|
@@ -100,6 +100,14 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
100
100
|
"Счётчики приблизительные — подходящих объявлений слишком много, чтобы пересчитать все.",
|
|
101
101
|
"search.facets.skipped":
|
|
102
102
|
"Эти фильтры для текущего поиска не посчитаны: {slugs}",
|
|
103
|
+
"search.facets.withheld.one":
|
|
104
|
+
"Ещё {count} фильтр подходит слишком малой части результатов",
|
|
105
|
+
"search.facets.withheld.few":
|
|
106
|
+
"Ещё {count} фильтра подходят слишком малой части результатов",
|
|
107
|
+
"search.facets.withheld.many":
|
|
108
|
+
"Ещё {count} фильтров подходят слишком малой части результатов",
|
|
109
|
+
"search.facets.withheld.other":
|
|
110
|
+
"Ещё {count} фильтра подходят слишком малой части результатов",
|
|
103
111
|
"search.facets.not_counted": "не посчитано",
|
|
104
112
|
"search.facets.drill_down_hint":
|
|
105
113
|
"Рядом с каждым значением — сколько будет, если выбрать его вместо текущего.",
|
|
@@ -156,11 +164,14 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
156
164
|
|
|
157
165
|
"search.geo.title": "Где искать",
|
|
158
166
|
"search.geo.radius_km": "В радиусе {km} км",
|
|
167
|
+
"search.geo.radius_km_short": "{km} км",
|
|
159
168
|
"search.geo.radius_label": "Радиус, км",
|
|
160
169
|
"search.geo.clear": "Везде",
|
|
170
|
+
"search.geo.near_me": "Рядом со мной",
|
|
161
171
|
"search.geo.everywhere": "Ищем везде",
|
|
162
172
|
"search.geo.box": "В показанной области",
|
|
163
173
|
"search.geo.chosen_place": "Выбранное место на карте",
|
|
174
|
+
"search.geo.near_you": "Рядом с вами",
|
|
164
175
|
|
|
165
176
|
"search.url.issues_title": "Часть этой ссылки прочитать не удалось",
|
|
166
177
|
"search.url.issue.not_a_number":
|
|
@@ -171,6 +182,8 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
171
182
|
"область на карте в этой ссылке указана не полностью, поэтому она пропущена",
|
|
172
183
|
"search.url.issue.range_malformed":
|
|
173
184
|
"диапазону «{param}» в этой ссылке не хватает чисел, поэтому он пропущен",
|
|
185
|
+
"search.url.issue.radius_without_place":
|
|
186
|
+
"в ссылке задан радиус, но не задано место, поэтому пока ничего не сузилось — выберите место, и применится именно этот радиус",
|
|
174
187
|
|
|
175
188
|
"search.degraded.title": "Что этот поиск не смог сделать",
|
|
176
189
|
"search.degraded.typo_tolerance":
|
|
@@ -181,6 +194,8 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
181
194
|
"search.degraded.exact_facet_counts": "Счётчики фильтров приблизительные.",
|
|
182
195
|
"search.degraded.category_rollup":
|
|
183
196
|
"В выдаче могут отсутствовать подкатегории — сервис категорий не ответил.",
|
|
197
|
+
"search.degraded.facet_plan_evidence":
|
|
198
|
+
"Не удалось определить, какие фильтры подходят этим результатам, — их может быть больше, чем показано.",
|
|
184
199
|
"search.degraded.scorer":
|
|
185
200
|
"Параметр ранжирования «{scorer}» не применялся — используемый движок не умеет его считать.",
|
|
186
201
|
"search.degraded.unknown":
|
package/src/index.ts
CHANGED
|
@@ -51,8 +51,10 @@ export {
|
|
|
51
51
|
suggestTerms,
|
|
52
52
|
} from "./api/types.js";
|
|
53
53
|
export type {
|
|
54
|
+
FacetCategoryCount,
|
|
54
55
|
FacetMeta,
|
|
55
56
|
FacetSelection,
|
|
57
|
+
FacetWithheldGroup,
|
|
56
58
|
RankingResponse,
|
|
57
59
|
Scorer,
|
|
58
60
|
SearchDegradation,
|
|
@@ -95,6 +97,7 @@ export type {
|
|
|
95
97
|
} from "./state/urlState.js";
|
|
96
98
|
|
|
97
99
|
export {
|
|
100
|
+
FACET_PLAN_EVIDENCE,
|
|
98
101
|
countIsEstimate,
|
|
99
102
|
countKind,
|
|
100
103
|
degradationAudience,
|
|
@@ -17,12 +17,26 @@ import type { SearchDegradation, SearchDegradationKind } from "../api/types.js";
|
|
|
17
17
|
|
|
18
18
|
const SCORER_PREFIX = "scorer:";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* "This answer's facet plan could not be drawn from evidence."
|
|
22
|
+
*
|
|
23
|
+
* Named rather than inlined because the FACET PANEL reads the raw literal
|
|
24
|
+
* off the envelope: whether "this search offers no filters" is a true
|
|
25
|
+
* sentence is a question about THIS answer, not about the banner, and the
|
|
26
|
+
* panel must not have to parse the whole list to ask it.
|
|
27
|
+
*/
|
|
28
|
+
export const FACET_PLAN_EVIDENCE = "facet_plan_evidence";
|
|
29
|
+
|
|
20
30
|
const KNOWN: Readonly<Record<string, SearchDegradationKind>> = {
|
|
21
31
|
typo_tolerance: "typo_tolerance",
|
|
22
32
|
phrase_synonyms: "phrase_synonyms",
|
|
23
33
|
exact_total: "exact_total",
|
|
24
34
|
exact_facet_counts: "exact_facet_counts",
|
|
25
35
|
category_rollup: "category_rollup",
|
|
36
|
+
// Reader-facing by omission from OPERATOR_KINDS below, and deliberately:
|
|
37
|
+
// it changes what an empty filter panel MEANS, which is the one thing on
|
|
38
|
+
// the page a buyer can act on (widen, or filter by category instead).
|
|
39
|
+
[FACET_PLAN_EVIDENCE]: "facet_plan_evidence",
|
|
26
40
|
};
|
|
27
41
|
|
|
28
42
|
/** i18n key per kind (`search.degraded.*`). */
|
package/src/state/facets.ts
CHANGED
|
@@ -362,6 +362,9 @@ export function facetOptionLabel(
|
|
|
362
362
|
* a chip in the row. The APPLIED-filter clause outranks the type rule: a
|
|
363
363
|
* constraint the URL carries always gets its control back, whatever the schema
|
|
364
364
|
* now says about it, or a person is left holding a filter they cannot clear.
|
|
365
|
+
*
|
|
366
|
+
* A COUNTED group with zero coverage goes the same way, and for the same
|
|
367
|
+
* reason — see {@link keepsAnAxisOpen}.
|
|
365
368
|
*/
|
|
366
369
|
export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGroup[] {
|
|
367
370
|
const bySlug = new Map<string, FeatureDef>();
|
|
@@ -462,5 +465,48 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
|
|
|
462
465
|
selected: selected.includes(value),
|
|
463
466
|
})),
|
|
464
467
|
};
|
|
465
|
-
});
|
|
468
|
+
}).filter(keepsAnAxisOpen);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Is this group an axis a person can actually move along?
|
|
473
|
+
*
|
|
474
|
+
* The coverage floor stops at the queried category's own schema, on the
|
|
475
|
+
* server, on purpose: `FACET_MIN_COVERAGE` governs only the slugs an
|
|
476
|
+
* evidence plan BORROWED from sibling leaves, because "a closed option set
|
|
477
|
+
* answering with its zeros is a shipped decision". That is right about an
|
|
478
|
+
* option and wrong about a GROUP. A size chart showing `XL — 0` beside
|
|
479
|
+
* `M — 12` is telling the truth about a shape the reader wants to see whole;
|
|
480
|
+
* a group whose every option is 0 is not a shape, it is three checkboxes that
|
|
481
|
+
* are each guaranteed to return nothing, and it costs a heading in the rail
|
|
482
|
+
* and a chip on a 390px row to say so.
|
|
483
|
+
*
|
|
484
|
+
* Measured on the deployed phones leaf: `sim_config`, `device_history` and
|
|
485
|
+
* `set` are authored `select` features that no listing in the leaf fills.
|
|
486
|
+
* The server's `fill_zero_options` creates the slug and zero-fills every
|
|
487
|
+
* authored option, so all three arrive counted, complete and dead, and the
|
|
488
|
+
* withholding loop never looks at them because an authored plan has no
|
|
489
|
+
* `evidence`. Nothing on the wire marks them: the client has to sum the
|
|
490
|
+
* buckets itself, which is exactly what {@link facetCoverage} already does
|
|
491
|
+
* for two other surfaces.
|
|
492
|
+
*
|
|
493
|
+
* Three things this must NOT drop, which is why the predicate is this narrow:
|
|
494
|
+
*
|
|
495
|
+
* - an UNCOUNTED group (`counted: false`). Its options carry `count: null`,
|
|
496
|
+
* so it sums to zero for the opposite reason — nobody looked. "We did not
|
|
497
|
+
* count this" and "there are none" are different sentences, and dropping
|
|
498
|
+
* on the first is the regression the `MAX_FACET_FIELDS` branch below
|
|
499
|
+
* exists to prevent (a live cars leaf: 26 facetable features declared, 12
|
|
500
|
+
* counted, and `/query` accepts `f.<slug>` for all 26).
|
|
501
|
+
* - a group the reader has ALREADY filtered on, whatever its counts say —
|
|
502
|
+
* the same clause that outranks the type rule. Withholding that group
|
|
503
|
+
* leaves a constraint applied with no control to undo it.
|
|
504
|
+
* - a group with any non-zero option. A zero option beside a live one is
|
|
505
|
+
* drill-down working as designed: it reports what swapping to that value
|
|
506
|
+
* would get you, and the answer being "nothing" is information.
|
|
507
|
+
*/
|
|
508
|
+
function keepsAnAxisOpen(group: FacetGroup): boolean {
|
|
509
|
+
if (!group.counted) return true;
|
|
510
|
+
if (group.selected.length > 0) return true;
|
|
511
|
+
return facetCoverage(group) > 0;
|
|
466
512
|
}
|