@stapel/search-react 0.20.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 (69) hide show
  1. package/CHANGELOG.md +88 -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.map +1 -1
  8. package/dist/default/FacetPanelPane.js +27 -1
  9. package/dist/default/FacetPanelPane.js.map +1 -1
  10. package/dist/default/LocationSummaryLine.d.ts +3 -0
  11. package/dist/default/LocationSummaryLine.d.ts.map +1 -1
  12. package/dist/default/LocationSummaryLine.js +55 -8
  13. package/dist/default/LocationSummaryLine.js.map +1 -1
  14. package/dist/default/SearchPage.d.ts.map +1 -1
  15. package/dist/default/SearchPage.js +5 -0
  16. package/dist/default/SearchPage.js.map +1 -1
  17. package/dist/headless/FacetPanel.d.ts +29 -1
  18. package/dist/headless/FacetPanel.d.ts.map +1 -1
  19. package/dist/headless/FacetPanel.js +14 -0
  20. package/dist/headless/FacetPanel.js.map +1 -1
  21. package/dist/headless/SearchStateProvider.d.ts +15 -0
  22. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  23. package/dist/headless/SearchStateProvider.js +38 -3
  24. package/dist/headless/SearchStateProvider.js.map +1 -1
  25. package/dist/i18n/es.d.ts.map +1 -1
  26. package/dist/i18n/es.js +6 -0
  27. package/dist/i18n/es.js.map +1 -1
  28. package/dist/i18n/keys.d.ts +42 -0
  29. package/dist/i18n/keys.d.ts.map +1 -1
  30. package/dist/i18n/keys.js +49 -0
  31. package/dist/i18n/keys.js.map +1 -1
  32. package/dist/i18n/ru.d.ts.map +1 -1
  33. package/dist/i18n/ru.js +8 -0
  34. package/dist/i18n/ru.js.map +1 -1
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/state/degradations.d.ts +9 -0
  40. package/dist/state/degradations.d.ts.map +1 -1
  41. package/dist/state/degradations.js +13 -0
  42. package/dist/state/degradations.js.map +1 -1
  43. package/dist/state/facets.d.ts +3 -0
  44. package/dist/state/facets.d.ts.map +1 -1
  45. package/dist/state/facets.js +48 -1
  46. package/dist/state/facets.js.map +1 -1
  47. package/dist/state/urlState.d.ts +26 -1
  48. package/dist/state/urlState.d.ts.map +1 -1
  49. package/dist/state/urlState.js +43 -17
  50. package/dist/state/urlState.js.map +1 -1
  51. package/llms.txt +2 -2
  52. package/manifest.json +12 -2
  53. package/nav-manifest.json +1 -1
  54. package/package.json +4 -4
  55. package/src/analytics/generated/events.json +1 -1
  56. package/src/api/generated/schema.ts +96 -4
  57. package/src/api/types.ts +51 -5
  58. package/src/default/FacetPanelPane.tsx +36 -1
  59. package/src/default/LocationSummaryLine.tsx +114 -53
  60. package/src/default/SearchPage.tsx +5 -0
  61. package/src/headless/FacetPanel.tsx +49 -1
  62. package/src/headless/SearchStateProvider.tsx +59 -3
  63. package/src/i18n/es.ts +10 -0
  64. package/src/i18n/keys.ts +53 -0
  65. package/src/i18n/ru.ts +14 -0
  66. package/src/index.ts +3 -0
  67. package/src/state/degradations.ts +14 -0
  68. package/src/state/facets.ts +47 -1
  69. package/src/state/urlState.ts +72 -13
@@ -94,6 +94,9 @@ import { GeoSheet, SUMMARY_GEO_TEST_IDS } from "./geoSheet.js";
94
94
 
95
95
  /** The class the row carries, for {@link locationLineCss}. */
96
96
  export const LOCATION_LINE_CLASS = "stapel-search-location-line";
97
+ /** The class the ONE location control carries — the place and the offer,
98
+ * inside a single box. See {@link locationLineCss} for what it fixes. */
99
+ export const LOCATION_LINE_GROUP_CLASS = "stapel-search-location-line-group";
97
100
  /** The class the shrinking half carries. */
98
101
  export const LOCATION_LINE_WHERE_CLASS = "stapel-search-location-line-where";
99
102
  /** The class the truncating label carries. */
@@ -117,6 +120,7 @@ export function locationLineCss(): string {
117
120
  const where = `.${LOCATION_LINE_WHERE_CLASS}`;
118
121
  const label = `.${LOCATION_LINE_LABEL_CLASS}`;
119
122
  const end = `.${LOCATION_LINE_END_CLASS}`;
123
+ const group = `.${LOCATION_LINE_GROUP_CLASS}`;
120
124
  return [
121
125
  // `min-width:0` on the row too: a flex item's default `min-width:auto`
122
126
  // is what makes a nested flex row refuse to shrink at all.
@@ -139,6 +143,35 @@ export function locationLineCss(): string {
139
143
  `text-overflow:ellipsis;white-space:nowrap}`,
140
144
  // The word a person is looking for never loses a pixel to a place name.
141
145
  `${end}{flex:0 0 auto}`,
146
+ // ── ONE control, not two red links at opposite ends of the page ────────
147
+ //
148
+ // Measured on a live 1440px leaf: the row is 992px wide, "📍 Searching
149
+ // everywhere" sat at x=370 and "Near me · Within 25 km" at x=776, both
150
+ // drawn in the same brand red, with ~400px of air between them and
151
+ // nothing — no border, no ground, no heading — saying they were about the
152
+ // same thing. Two identical-looking links at opposite ends of a strip are
153
+ // read as two unrelated controls, which is what they looked like.
154
+ //
155
+ // So the place and the offer share ONE box with a border and a ground,
156
+ // sitting together at the leading edge, with a hairline between them: the
157
+ // eye gets a single object that says where this search is looking and
158
+ // offers the one move that would change it. The border comes from the
159
+ // role token, so it is right in both themes by construction.
160
+ // `min-inline-size:0` explicitly, and `flex-shrink` on the box rather
161
+ // than on its contents: a flex item's `min-width:auto` refuses to go
162
+ // below its own content, so without this the group pushed the filters
163
+ // door off the end of a 390px row instead of letting the place name give
164
+ // way — which is the half that can.
165
+ `${group}{display:flex;align-items:stretch;flex:0 1 auto;min-inline-size:0;` +
166
+ `border:1px solid ${cssVar("border")};border-radius:${String(radii.md)}px;` +
167
+ `background:${cssVar("surface-raised")};overflow:hidden}`,
168
+ // The two halves are separated by the box's own line, never by a gap: a
169
+ // gap inside a bordered group reads as two groups again.
170
+ `${group}>*+*{border-inline-start:1px solid ${cssVar("border")}}`,
171
+ `${group}>*{border-radius:0}`,
172
+ // Both halves pay for their own breathing room; the antd Button's inline
173
+ // padding is zeroed above so the box, not the button, sets the measure.
174
+ `${group}>*{padding-inline:${String(spacing[2])}px}`,
142
175
  ].join("");
143
176
  }
144
177
 
@@ -149,7 +182,9 @@ const ROW: CSSProperties = { width: "100%" };
149
182
  /** The shrinking half. The flex rules it needs live in the hoisted sheet
150
183
  * (they have to reach antd's own wrapper span); this is the chrome. */
151
184
  const LOCATION: CSSProperties = {
152
- paddingInline: 0,
185
+ // No `paddingInline` here: the group's sheet sets it for both halves, and
186
+ // an inline style would beat the sheet and leave one half flush against the
187
+ // border while the other breathed.
153
188
  textAlign: "start",
154
189
  };
155
190
 
@@ -159,7 +194,7 @@ const PIN: CSSProperties = { flex: "0 0 auto", display: "inline-flex" };
159
194
 
160
195
  /** The offer. It never shrinks: it is three words and a number, and half of
161
196
  * "Near me" is not an offer. */
162
- const OFFER: CSSProperties = { flex: "0 0 auto", paddingInline: 0 };
197
+ const OFFER: CSSProperties = { flex: "0 0 auto" };
163
198
 
164
199
  /**
165
200
  * The count, IN the flow.
@@ -239,7 +274,8 @@ export function LocationSummaryLine(
239
274
  props: LocationSummaryLineProps
240
275
  ): ReactElement {
241
276
  const t = useT();
242
- const { state, activeFilters, geoOffer, acceptGeoOffer } = useSearchState();
277
+ const { state, activeFilters, geoOffer, geoIsOffer, acceptGeoOffer } =
278
+ useSearchState();
243
279
  const [open, setOpen] = useState(false);
244
280
  const geo = state.geo;
245
281
 
@@ -257,13 +293,25 @@ export function LocationSummaryLine(
257
293
  // the question), so this row never shows an offer beside a place.
258
294
  const offerRadius =
259
295
  geoOffer !== undefined && geoOffer.kind === "center" && geoOffer.radiusKm !== undefined
260
- ? t(SEARCH_I18N_KEYS.geoRadiusKm, { km: geoOffer.radiusKm })
296
+ ? t(SEARCH_I18N_KEYS.geoRadiusKmShort, { km: geoOffer.radiusKm })
261
297
  : undefined;
262
298
 
299
+ // What the line CALLS the place it is looking at.
300
+ //
301
+ // The host's name wins — it is the only one that can be checked by a reader
302
+ // — then the fact the provider holds, and only then the shape of the
303
+ // constraint. The middle arm is D184's second half: pressing "Near me"
304
+ // turned this line into "A chosen place on the map" for a person who had
305
+ // never opened a map. `geoIsOffer` is the provider reporting how the search
306
+ // came to be here, rather than this file inferring it from three numbers
307
+ // that look the same whatever produced them.
263
308
  const where: ReactNode =
264
309
  geo === undefined
265
310
  ? t(SEARCH_I18N_KEYS.geoEverywhere)
266
- : (props.geoLabel ?? geoSummaryFallback(geo, t));
311
+ : (props.geoLabel ??
312
+ (geoIsOffer
313
+ ? t(SEARCH_I18N_KEYS.geoNearYou)
314
+ : geoSummaryFallback(geo, t)));
267
315
 
268
316
  return (
269
317
  <>
@@ -279,60 +327,73 @@ export function LocationSummaryLine(
279
327
  data-testid="search-location-summary"
280
328
  data-geo={geo === undefined ? "off" : "on"}
281
329
  >
282
- {/* The glyph is rendered as a CHILD rather than through antd's `icon`
283
- prop: the icon slot sits outside the wrapper span, so the label
284
- beside it could not be given a min-width of its own — and a label
285
- that cannot shrink is a label that overflows. */}
286
- <Button
287
- type="link"
288
- style={LOCATION}
289
- className={LOCATION_LINE_WHERE_CLASS}
290
- data-testid="search-location-open"
291
- data-analytics="none"
292
- data-analytics-reason="opening the location sheet is a read, not a flow step"
293
- onClick={() => {
294
- setOpen(true);
295
- }}
330
+ {/* ONE control: where this search is looking, and while the
331
+ question is still open the one move that would change it. See
332
+ `locationLineCss` for the measurement that made them one box. */}
333
+ <div
334
+ className={LOCATION_LINE_GROUP_CLASS}
335
+ data-testid="search-location-group"
296
336
  >
297
- <span style={PIN}>
298
- <PinGlyph />
299
- </span>
300
- <span
301
- className={LOCATION_LINE_LABEL_CLASS}
302
- data-testid="search-location-label"
303
- >
304
- {where}
305
- {radius !== undefined && (
306
- <span data-testid="search-location-radius">
307
- {" · "}
308
- {radius}
309
- </span>
310
- )}
311
- </span>
312
- </Button>
337
+ {/* The glyph is rendered as a CHILD rather than through antd's
338
+ `icon` prop: the icon slot sits outside the wrapper span, so the
339
+ label beside it could not be given a min-width of its own — and
340
+ a label that cannot shrink is a label that overflows.
313
341
 
314
- {/* The offer, and nothing is applied until it is pressed. Drawn only
315
- when the host has a position to offer AND the search carries no
316
- location of its own the provider enforces the second half, so
317
- this is one condition, not two that could disagree. */}
318
- {geoOffer !== undefined && (
342
+ `type="text"`, not `type="link"`: this half is a STATEMENT about
343
+ where the search is looking, and painting it the brand colour
344
+ made it indistinguishable from the offer beside it. The offer
345
+ keeps the colour, because the offer is the action. */}
319
346
  <Button
320
- type="link"
321
- style={OFFER}
322
- data-testid="search-location-offer"
347
+ type="text"
348
+ style={LOCATION}
349
+ className={LOCATION_LINE_WHERE_CLASS}
350
+ data-testid="search-location-open"
323
351
  data-analytics="none"
324
- data-analytics-reason="applying a filter the person pressed is search state, and search state is the URL"
325
- onClick={acceptGeoOffer}
352
+ data-analytics-reason="opening the location sheet is a read, not a flow step"
353
+ onClick={() => {
354
+ setOpen(true);
355
+ }}
326
356
  >
327
- {t(SEARCH_I18N_KEYS.geoNearMe)}
328
- {offerRadius !== undefined && (
329
- <span data-testid="search-location-offer-radius">
330
- {" · "}
331
- {offerRadius}
332
- </span>
333
- )}
357
+ <span style={PIN}>
358
+ <PinGlyph />
359
+ </span>
360
+ <span
361
+ className={LOCATION_LINE_LABEL_CLASS}
362
+ data-testid="search-location-label"
363
+ >
364
+ {where}
365
+ {radius !== undefined && (
366
+ <span data-testid="search-location-radius">
367
+ {" · "}
368
+ {radius}
369
+ </span>
370
+ )}
371
+ </span>
334
372
  </Button>
335
- )}
373
+
374
+ {/* The offer, and nothing is applied until it is pressed. Drawn only
375
+ when the host has a position to offer AND the search carries no
376
+ location of its own — the provider enforces the second half, so
377
+ this is one condition, not two that could disagree. */}
378
+ {geoOffer !== undefined && (
379
+ <Button
380
+ type="link"
381
+ style={OFFER}
382
+ data-testid="search-location-offer"
383
+ data-analytics="none"
384
+ data-analytics-reason="applying a filter the person pressed is search state, and search state is the URL"
385
+ onClick={acceptGeoOffer}
386
+ >
387
+ {t(SEARCH_I18N_KEYS.geoNearMe)}
388
+ {offerRadius !== undefined && (
389
+ <span data-testid="search-location-offer-radius">
390
+ {" · "}
391
+ {offerRadius}
392
+ </span>
393
+ )}
394
+ </Button>
395
+ )}
396
+ </div>
336
397
 
337
398
  {/* "Filters", not "All filters": this end of the row shares 390px
338
399
  with a place name that can run to fifteen characters, and the word
@@ -426,6 +426,11 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
426
426
  const filtersEmpty =
427
427
  facets.state.status === "ready" &&
428
428
  facets.state.data.length === 0 &&
429
+ // Zero groups is not zero filters: `withheld` names groups the server
430
+ // counted and held back for covering too little of the result set, and
431
+ // the panel's job is to say how many (D175). Skipping the column would
432
+ // put that sentence nowhere.
433
+ facets.withheld.length === 0 &&
429
434
  facets.activeFilters === 0 &&
430
435
  ranges.length === 0 &&
431
436
  state.category === undefined &&
@@ -2,9 +2,15 @@ import type { ReactNode } from "react";
2
2
  import { loadStateFromQuery, mapLoad, useT } from "@stapel/core";
3
3
  import type { LoadState } from "@stapel/core";
4
4
  import type { FeatureDef } from "@stapel/attributes-react";
5
- import type { FacetMeta, SearchRange } from "../api/types.js";
5
+ import type {
6
+ FacetCategoryCount,
7
+ FacetMeta,
8
+ FacetWithheldGroup,
9
+ SearchRange,
10
+ } from "../api/types.js";
6
11
  import { useSearchQuery } from "../model/queries.js";
7
12
  import { buildFacetGroups } from "../state/facets.js";
13
+ import { FACET_PLAN_EVIDENCE } from "../state/degradations.js";
8
14
  import type { FacetGroup } from "../state/facets.js";
9
15
  import { useHostFacetLabels } from "./useFacetLabels.js";
10
16
  import type { FacetLabelResolver } from "./useFacetLabels.js";
@@ -51,6 +57,34 @@ export interface FacetPanelBag {
51
57
  readonly currency: string | undefined;
52
58
  /** Size of the largest counted set — the number `approximate` is about. */
53
59
  readonly candidates: number;
60
+ /**
61
+ * Groups the counter COUNTED and then held back, because their buckets
62
+ * describe too little of the result set. Not empty means the panel may
63
+ * NOT say "this search offers no filters" — it says how many, instead.
64
+ */
65
+ readonly withheld: readonly FacetWithheldGroup[];
66
+ /**
67
+ * The categories this answer's candidate set is made of, busiest first —
68
+ * the evidence the plan was drawn from, and the counted category filter a
69
+ * text search has no other way to offer. Empty when the plan is the
70
+ * queried category's own. `category` is the same slash-joined id path
71
+ * `SearchQueryState.category` takes.
72
+ */
73
+ readonly categories: readonly FacetCategoryCount[];
74
+ /**
75
+ * Where the plan came from: `"category"` (the queried category's own
76
+ * authored schema) or `"evidence"` (the categories the candidate set
77
+ * actually contains).
78
+ */
79
+ readonly plan: string;
80
+ /**
81
+ * True when the server could not work out a plan AT ALL — the engine has
82
+ * no `category_counts` verb (`degraded: ["facet_plan_evidence"]`). An
83
+ * empty panel then means "we do not know", so a skin must not print
84
+ * "there are no filters" over it; the degradation notice is what tells
85
+ * the reader.
86
+ */
87
+ readonly planUnavailable: boolean;
54
88
  /** Facet values + ranges + geo currently applied. */
55
89
  readonly activeFilters: number;
56
90
 
@@ -67,6 +101,9 @@ const EMPTY_META: FacetMeta = {
67
101
  skipped: [],
68
102
  dropped_filters: [],
69
103
  core_ranges: [],
104
+ plan: "category",
105
+ withheld: [],
106
+ categories: [],
70
107
  };
71
108
 
72
109
  /**
@@ -174,6 +211,17 @@ export function useFacetPanel(props: {
174
211
  ?.card?.["currency"] as string | undefined
175
212
  : undefined,
176
213
  candidates: meta.candidates,
214
+ withheld: meta.withheld ?? [],
215
+ categories: meta.categories ?? [],
216
+ plan: meta.plan ?? "category",
217
+ // Read off the raw list rather than through `parseDegradations`: the
218
+ // question is whether THIS answer supports the sentence "no filters",
219
+ // and the banner's own parsing is a different job on a different
220
+ // surface. Guarded on the READY state, never flattened — a query still
221
+ // in flight has not said anything about its facet plan.
222
+ planUnavailable:
223
+ envelope.status === "ready" &&
224
+ envelope.data.degraded.includes(FACET_PLAN_EVIDENCE),
177
225
  activeFilters,
178
226
  toggle: toggleFilter,
179
227
  setRange,
@@ -71,6 +71,21 @@ export interface SearchStateBag {
71
71
  * Draw it as an invitation ("near me"), never as state.
72
72
  */
73
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;
74
89
 
75
90
  setText(q: string): void;
76
91
  setSort(sort: string | null): void;
@@ -92,6 +107,29 @@ export interface SearchStateBag {
92
107
  patch(patch: SearchStatePatch): void;
93
108
  }
94
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
+
95
133
  const StateContext = createContext<SearchStateBag | null>(null);
96
134
 
97
135
  export interface SearchStateProviderProps extends ParseSearchStateOptions {
@@ -178,6 +216,23 @@ export function SearchStateProvider(
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 }
@@ -193,7 +248,8 @@ export function SearchStateProvider(
193
248
  // — there is nothing left to offer, and a control that kept drawing
194
249
  // "near me" beside an applied location would be inviting a person to
195
250
  // re-answer a question they can already see the answer to.
196
- geoOffer: state.geo === undefined ? geoOffer : undefined,
251
+ geoOffer: state.geo === undefined ? offer : undefined,
252
+ geoIsOffer: sameCenter(state.geo, offer),
197
253
 
198
254
  // Typing replaces rather than pushes: one history entry per letter
199
255
  // would make Back useless, which is the control the spec's acceptance
@@ -209,10 +265,10 @@ export function SearchStateProvider(
209
265
  apply(patchSearchState(state, { geo }));
210
266
  },
211
267
  acceptGeoOffer: () => {
212
- if (geoOffer === undefined || state.geo !== undefined) return;
268
+ if (offer === undefined || state.geo !== undefined) return;
213
269
  // A PUSH, like any other filter the person applies: Back takes the
214
270
  // narrowing off again, which is the same promise every chip makes.
215
- apply(patchSearchState(state, { geo: geoOffer }));
271
+ apply(patchSearchState(state, { geo: offer }));
216
272
  },
217
273
  // A page size is a preference, not a step through the results.
218
274
  setLimit: (limit) => apply(patchSearchState(state, { limit }), { replace: true }),
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,12 +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",
130
135
  "search.geo.near_me": "Cerca de mí",
131
136
  "search.geo.everywhere": "Buscando en todas partes",
132
137
  "search.geo.box": "Dentro del área mostrada",
133
138
  "search.geo.chosen_place": "Un lugar elegido en el mapa",
139
+ "search.geo.near_you": "Cerca de ti",
134
140
 
135
141
  "search.url.issues_title": "Parte de este enlace no se pudo leer",
136
142
  "search.url.issue.not_a_number":
@@ -141,6 +147,8 @@ export const searchI18nBundleEs: I18nDictionary = {
141
147
  "el área del mapa de este enlace está incompleta, así que se ignoró",
142
148
  "search.url.issue.range_malformed":
143
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",
144
152
 
145
153
  "search.degraded.title": "Lo que esta búsqueda no pudo hacer",
146
154
  "search.degraded.typo_tolerance":
@@ -151,6 +159,8 @@ export const searchI18nBundleEs: I18nDictionary = {
151
159
  "search.degraded.exact_facet_counts": "Los recuentos de filtros son aproximados.",
152
160
  "search.degraded.category_rollup":
153
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.",
154
164
  "search.degraded.scorer":
155
165
  "El parámetro de ranking «{scorer}» no se aplicó: el motor configurado no puede evaluarlo.",
156
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,6 +228,19 @@ 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",
222
246
  geoNearMe: "search.geo.near_me",
@@ -243,6 +267,18 @@ export const SEARCH_I18N_KEYS = {
243
267
  * `geoLabel` and this sentence never appears.
244
268
  */
245
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",
246
282
 
247
283
  // ── the URL that could not be read ───────────────────────────────────────
248
284
  urlIssuesTitle: "search.url.issues_title",
@@ -250,6 +286,8 @@ export const SEARCH_I18N_KEYS = {
250
286
  urlIssueGeoIncomplete: "search.url.issue.geo_incomplete",
251
287
  urlIssueBboxMalformed: "search.url.issue.bbox_malformed",
252
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",
253
291
 
254
292
  // ── degradations (the envelope's `degraded[]`) ───────────────────────────
255
293
  degradedTitle: "search.degraded.title",
@@ -258,6 +296,10 @@ export const SEARCH_I18N_KEYS = {
258
296
  degradedExactTotal: "search.degraded.exact_total",
259
297
  degradedExactFacetCounts: "search.degraded.exact_facet_counts",
260
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",
261
303
  degradedScorer: "search.degraded.scorer",
262
304
  degradedUnknown: "search.degraded.unknown",
263
305
 
@@ -329,6 +371,7 @@ export const SEARCH_I18N_PLURAL_KEYS: readonly SearchI18nKey[] = [
329
371
  SEARCH_I18N_KEYS.filtersShowCountAtLeast,
330
372
  SEARCH_I18N_KEYS.boxCategoryCount,
331
373
  SEARCH_I18N_KEYS.facetsMatchCount,
374
+ SEARCH_I18N_KEYS.facetsWithheld,
332
375
  ];
333
376
 
334
377
  /**
@@ -401,6 +444,10 @@ export const searchI18nBundleEn: Record<string, string> = {
401
444
  "Counts are approximate — there were too many candidates to count them all.",
402
445
  "search.facets.skipped":
403
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",
404
451
  "search.facets.not_counted": "not counted",
405
452
  "search.facets.drill_down_hint":
406
453
  "Each count is what you would get by choosing that value instead of the one you have.",
@@ -452,11 +499,13 @@ export const searchI18nBundleEn: Record<string, string> = {
452
499
  "search.geo.title": "Location",
453
500
  "search.geo.near_me": "Near me",
454
501
  "search.geo.radius_km": "Within {km} km",
502
+ "search.geo.radius_km_short": "{km} km",
455
503
  "search.geo.radius_label": "Radius, km",
456
504
  "search.geo.clear": "Anywhere",
457
505
  "search.geo.everywhere": "Searching everywhere",
458
506
  "search.geo.box": "Inside the shown area",
459
507
  "search.geo.chosen_place": "A chosen place on the map",
508
+ "search.geo.near_you": "Near you",
460
509
 
461
510
  "search.url.issues_title": "Part of this link could not be read",
462
511
  "search.url.issue.not_a_number": "“{param}” in this link is not a number, so it was ignored",
@@ -466,6 +515,8 @@ export const searchI18nBundleEn: Record<string, string> = {
466
515
  "the map area in this link is incomplete, so it was ignored",
467
516
  "search.url.issue.range_malformed":
468
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",
469
520
 
470
521
  "search.degraded.title": "What this search could not do",
471
522
  "search.degraded.typo_tolerance":
@@ -476,6 +527,8 @@ export const searchI18nBundleEn: Record<string, string> = {
476
527
  "search.degraded.exact_facet_counts": "The filter counts are approximate.",
477
528
  "search.degraded.category_rollup":
478
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.",
479
532
  "search.degraded.scorer":
480
533
  "The ranking parameter “{scorer}” was not applied — the search engine in use cannot evaluate it.",
481
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,12 +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": "Везде",
161
170
  "search.geo.near_me": "Рядом со мной",
162
171
  "search.geo.everywhere": "Ищем везде",
163
172
  "search.geo.box": "В показанной области",
164
173
  "search.geo.chosen_place": "Выбранное место на карте",
174
+ "search.geo.near_you": "Рядом с вами",
165
175
 
166
176
  "search.url.issues_title": "Часть этой ссылки прочитать не удалось",
167
177
  "search.url.issue.not_a_number":
@@ -172,6 +182,8 @@ export const searchI18nBundleRu: I18nDictionary = {
172
182
  "область на карте в этой ссылке указана не полностью, поэтому она пропущена",
173
183
  "search.url.issue.range_malformed":
174
184
  "диапазону «{param}» в этой ссылке не хватает чисел, поэтому он пропущен",
185
+ "search.url.issue.radius_without_place":
186
+ "в ссылке задан радиус, но не задано место, поэтому пока ничего не сузилось — выберите место, и применится именно этот радиус",
175
187
 
176
188
  "search.degraded.title": "Что этот поиск не смог сделать",
177
189
  "search.degraded.typo_tolerance":
@@ -182,6 +194,8 @@ export const searchI18nBundleRu: I18nDictionary = {
182
194
  "search.degraded.exact_facet_counts": "Счётчики фильтров приблизительные.",
183
195
  "search.degraded.category_rollup":
184
196
  "В выдаче могут отсутствовать подкатегории — сервис категорий не ответил.",
197
+ "search.degraded.facet_plan_evidence":
198
+ "Не удалось определить, какие фильтры подходят этим результатам, — их может быть больше, чем показано.",
185
199
  "search.degraded.scorer":
186
200
  "Параметр ранжирования «{scorer}» не применялся — используемый движок не умеет его считать.",
187
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,