@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
@@ -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
- * 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).
138
+ * A location this search COULD be narrowed to offered, never applied.
88
139
  *
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.
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
- * So the rules, all four of which are about not overruling a person:
145
+ * ## Why this is an offer and not a default (defect: a silent radius)
98
146
  *
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.
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
- * 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.
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 defaultGeo?: SearchGeo | undefined;
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, defaultGeo, ...parseOptions } = props;
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.*`). */
@@ -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
  }