@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
@@ -23,6 +23,24 @@
23
23
  * applied it says the search is looking everywhere, which is the truth and is
24
24
  * also the invitation to narrow it.
25
25
  *
26
+ * ## The offer, and why it is a BUTTON on this row
27
+ *
28
+ * A visitor whose browser has already granted geolocation is one the host can
29
+ * place precisely — and for a long time this pair took that as licence to
30
+ * place them, through a `defaultGeo` that wrote a 25 km radius into the URL
31
+ * before anybody had said anything (see {@link SearchStateProviderProps.geoOffer}
32
+ * for what that cost: whole category leaves reading "nothing found" while
33
+ * their stock sat 30 km away). The position is still worth having. It is just
34
+ * not the pair's to apply.
35
+ *
36
+ * So the offer lands HERE, on the one row that is on screen at every width
37
+ * without opening a sheet, beside the sentence it would change ("searching
38
+ * everywhere" → "within 25 km of you"). It states its own radius, because a
39
+ * button that says only "near me" is asking a person to accept a number they
40
+ * cannot see; and the moment it is pressed the radius becomes the ordinary
41
+ * adjustable one in the panel, and the location becomes an ordinary chip with
42
+ * an ordinary way off.
43
+ *
26
44
  * ## The right-hand affordance carries a COUNT, and the chip row carries a dot
27
45
  *
28
46
  * `<FilterChips>`'s leading chip is a 32px circle: a number inside it is a
@@ -76,6 +94,9 @@ import { GeoSheet, SUMMARY_GEO_TEST_IDS } from "./geoSheet.js";
76
94
 
77
95
  /** The class the row carries, for {@link locationLineCss}. */
78
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";
79
100
  /** The class the shrinking half carries. */
80
101
  export const LOCATION_LINE_WHERE_CLASS = "stapel-search-location-line-where";
81
102
  /** The class the truncating label carries. */
@@ -99,6 +120,7 @@ export function locationLineCss(): string {
99
120
  const where = `.${LOCATION_LINE_WHERE_CLASS}`;
100
121
  const label = `.${LOCATION_LINE_LABEL_CLASS}`;
101
122
  const end = `.${LOCATION_LINE_END_CLASS}`;
123
+ const group = `.${LOCATION_LINE_GROUP_CLASS}`;
102
124
  return [
103
125
  // `min-width:0` on the row too: a flex item's default `min-width:auto`
104
126
  // is what makes a nested flex row refuse to shrink at all.
@@ -121,6 +143,35 @@ export function locationLineCss(): string {
121
143
  `text-overflow:ellipsis;white-space:nowrap}`,
122
144
  // The word a person is looking for never loses a pixel to a place name.
123
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}`,
124
175
  ].join("");
125
176
  }
126
177
 
@@ -131,7 +182,9 @@ const ROW: CSSProperties = { width: "100%" };
131
182
  /** The shrinking half. The flex rules it needs live in the hoisted sheet
132
183
  * (they have to reach antd's own wrapper span); this is the chrome. */
133
184
  const LOCATION: CSSProperties = {
134
- 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.
135
188
  textAlign: "start",
136
189
  };
137
190
 
@@ -139,6 +192,10 @@ const LOCATION: CSSProperties = {
139
192
  * noise, and it costs 16px. */
140
193
  const PIN: CSSProperties = { flex: "0 0 auto", display: "inline-flex" };
141
194
 
195
+ /** The offer. It never shrinks: it is three words and a number, and half of
196
+ * "Near me" is not an offer. */
197
+ const OFFER: CSSProperties = { flex: "0 0 auto" };
198
+
142
199
  /**
143
200
  * The count, IN the flow.
144
201
  *
@@ -179,6 +236,15 @@ export interface LocationSummaryLineProps {
179
236
  * because the surface is what it covers.
180
237
  */
181
238
  readonly onOpenAll: () => void;
239
+ /**
240
+ * Draw the trailing "Filters (N)" door. Default `true`.
241
+ *
242
+ * `false` where the panel is ALREADY on screen — a desktop column layout —
243
+ * because a door beside the room it opens is not a door, and the count it
244
+ * carries is printed again on the panel's own "clear all" a few hundred
245
+ * pixels to the left.
246
+ */
247
+ readonly filtersDoor?: boolean;
182
248
  }
183
249
 
184
250
  /** A map pin in `currentColor` — the house convention: an inline monochrome
@@ -208,7 +274,8 @@ export function LocationSummaryLine(
208
274
  props: LocationSummaryLineProps
209
275
  ): ReactElement {
210
276
  const t = useT();
211
- const { state, activeFilters } = useSearchState();
277
+ const { state, activeFilters, geoOffer, geoIsOffer, acceptGeoOffer } =
278
+ useSearchState();
212
279
  const [open, setOpen] = useState(false);
213
280
  const geo = state.geo;
214
281
 
@@ -221,10 +288,30 @@ export function LocationSummaryLine(
221
288
  ? t(SEARCH_I18N_KEYS.geoRadiusKm, { km: geo.radiusKm })
222
289
  : undefined;
223
290
 
291
+ // The offer's own radius, said out loud on the button. `geoOffer` is
292
+ // already `undefined` whenever a location is applied (the provider closes
293
+ // the question), so this row never shows an offer beside a place.
294
+ const offerRadius =
295
+ geoOffer !== undefined && geoOffer.kind === "center" && geoOffer.radiusKm !== undefined
296
+ ? t(SEARCH_I18N_KEYS.geoRadiusKmShort, { km: geoOffer.radiusKm })
297
+ : undefined;
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.
224
308
  const where: ReactNode =
225
309
  geo === undefined
226
310
  ? t(SEARCH_I18N_KEYS.geoEverywhere)
227
- : (props.geoLabel ?? geoSummaryFallback(geo, t));
311
+ : (props.geoLabel ??
312
+ (geoIsOffer
313
+ ? t(SEARCH_I18N_KEYS.geoNearYou)
314
+ : geoSummaryFallback(geo, t)));
228
315
 
229
316
  return (
230
317
  <>
@@ -240,37 +327,73 @@ export function LocationSummaryLine(
240
327
  data-testid="search-location-summary"
241
328
  data-geo={geo === undefined ? "off" : "on"}
242
329
  >
243
- {/* The glyph is rendered as a CHILD rather than through antd's `icon`
244
- prop: the icon slot sits outside the wrapper span, so the label
245
- beside it could not be given a min-width of its own — and a label
246
- that cannot shrink is a label that overflows. */}
247
- <Button
248
- type="link"
249
- style={LOCATION}
250
- className={LOCATION_LINE_WHERE_CLASS}
251
- data-testid="search-location-open"
252
- data-analytics="none"
253
- data-analytics-reason="opening the location sheet is a read, not a flow step"
254
- onClick={() => {
255
- setOpen(true);
256
- }}
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"
257
336
  >
258
- <span style={PIN}>
259
- <PinGlyph />
260
- </span>
261
- <span
262
- className={LOCATION_LINE_LABEL_CLASS}
263
- data-testid="search-location-label"
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.
341
+
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. */}
346
+ <Button
347
+ type="text"
348
+ style={LOCATION}
349
+ className={LOCATION_LINE_WHERE_CLASS}
350
+ data-testid="search-location-open"
351
+ data-analytics="none"
352
+ data-analytics-reason="opening the location sheet is a read, not a flow step"
353
+ onClick={() => {
354
+ setOpen(true);
355
+ }}
264
356
  >
265
- {where}
266
- {radius !== undefined && (
267
- <span data-testid="search-location-radius">
268
- {" · "}
269
- {radius}
270
- </span>
271
- )}
272
- </span>
273
- </Button>
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>
372
+ </Button>
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>
274
397
 
275
398
  {/* "Filters", not "All filters": this end of the row shares 390px
276
399
  with a place name that can run to fifteen characters, and the word
@@ -279,6 +402,7 @@ export function LocationSummaryLine(
279
402
  door. */}
280
403
  {/* The count, not a dot: this row has the width to say how many — and
281
404
  it rides IN the line rather than floating off its corner. */}
405
+ {props.filtersDoor === false ? null : (
282
406
  <Flex
283
407
  align="center"
284
408
  gap={spacing[1]}
@@ -302,6 +426,7 @@ export function LocationSummaryLine(
302
426
  </span>
303
427
  )}
304
428
  </Flex>
429
+ )}
305
430
  </Flex>
306
431
 
307
432
  <GeoSheet
@@ -35,17 +35,17 @@
35
35
  *
36
36
  * ── Where the search opens, and what it calls the place ───────────────────
37
37
  *
38
- * `defaultGeo` centres a fresh search on the visitora granted browser
39
- * prompt, or the server's IP guess when there was none — and `geoLabel` says
40
- * what that place is CALLED. Both are the host's to resolve: this page has a
41
- * `lat` and a `lon` in its query string and no way on earth to turn them into
42
- * "Berlin Mitte", which is exactly why it must not print them. A search
43
- * package that grew a geocoder to say a nicer sentence would have taken on the
44
- * whole of `geo-react` to avoid one bad line.
38
+ * `geoOffer` is a place this search COULD be narrowed to the visitor's own
39
+ * position, as the host resolved it — and `geoLabel` says what the place a
40
+ * search is ALREADY narrowed to is CALLED. Both are the host's to resolve:
41
+ * this page has a `lat` and a `lon` in its query string and no way on earth to
42
+ * turn them into "Berlin Mitte", which is exactly why it must not print them.
43
+ * A search package that grew a geocoder to say a nicer sentence would have
44
+ * taken on the whole of `geo-react` to avoid one bad line.
45
45
  *
46
- * The centring is applied once, only into a URL that carries no location, and
47
- * never again after the visitor has said anything about location themselves
48
- * see {@link SearchStateProviderProps.defaultGeo}, which owns the rules.
46
+ * The offer is never applied on the page's own initiative: it is drawn as a
47
+ * button on the location row, and pressing it is the person's word. See
48
+ * {@link SearchStateProviderProps.geoOffer} for the defect that shape closes.
49
49
  *
50
50
  * ── The filter column is laid out only when there is something in it ──────
51
51
  *
@@ -83,6 +83,7 @@ import type {
83
83
  GeoFilterSlotProps,
84
84
  } from "./FacetPanelPane.js";
85
85
  import { FilterChips } from "./FilterChips.js";
86
+ import { LocationSummaryLine } from "./LocationSummaryLine.js";
86
87
  import { PageSizeSelect } from "./PageSizeSelect.js";
87
88
  import { SearchBox } from "./SearchBox.js";
88
89
  import { SearchResultsPane } from "./SearchResultsPane.js";
@@ -211,16 +212,17 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
211
212
  * Default `false` — see {@link FacetPanelPaneProps.skippedNotice}. */
212
213
  readonly skippedNotice?: boolean;
213
214
  /**
214
- * Open the search centred where the visitor is, when the URL says nothing
215
- * about location — see {@link SearchStateProviderProps.defaultGeo} for the
216
- * four rules that keep it from overruling a link or a person.
215
+ * A location this search could be narrowed to, OFFERED to the visitor see
216
+ * {@link SearchStateProviderProps.geoOffer} for why it is an offer and not a
217
+ * default, and for the defect that distinction closes.
217
218
  *
218
219
  * The host resolves the position; this page does not know what a map or a
219
220
  * geocoder is and must not learn. A browser prompt (`usePermission`
220
221
  * + `geolocation`) or the server's IP guess both arrive here as the same two
221
- * numbers, and `geoLabel` is where the name of the place they mean comes in.
222
+ * numbers. Nothing is applied until the person presses the offer, and
223
+ * `geoOfferLabel` is what the button calls the place.
222
224
  */
223
- readonly defaultGeo?: SearchGeo | undefined;
225
+ readonly geoOffer?: SearchGeo | undefined;
224
226
  /** Container chrome under the results — e.g. the ranking-disclosure link. */
225
227
  readonly footer?: ReactNode;
226
228
  /**
@@ -369,7 +371,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
369
371
  const t = useT();
370
372
  const tPlural = useTPlural();
371
373
  const { categoryFeatures, locale, resolveFacetLabels, filtersHeader } = props;
372
- const { state } = useSearchState();
374
+ const { state, geoOffer } = useSearchState();
373
375
  const facets = useFacetPanel({
374
376
  ...(categoryFeatures !== undefined ? { categoryFeatures } : {}),
375
377
  ...(locale !== undefined ? { locale } : {}),
@@ -424,12 +426,16 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
424
426
  const filtersEmpty =
425
427
  facets.state.status === "ready" &&
426
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 &&
427
434
  facets.activeFilters === 0 &&
428
435
  ranges.length === 0 &&
429
436
  state.category === undefined &&
430
437
  state.lang === undefined &&
431
438
  props.renderCategoryFilter === undefined &&
432
- props.renderGeoFilter === undefined &&
433
439
  (props.languages ?? []).length === 0;
434
440
  const showFilters = filtersHeader !== undefined || !filtersEmpty;
435
441
 
@@ -460,10 +466,6 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
460
466
  {...(props.renderCategoryFilter !== undefined
461
467
  ? { renderCategoryFilter: props.renderCategoryFilter }
462
468
  : {})}
463
- {...(props.renderGeoFilter !== undefined
464
- ? { renderGeoFilter: props.renderGeoFilter }
465
- : {})}
466
- {...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
467
469
  {...(props.skippedNotice !== undefined
468
470
  ? { skippedNotice: props.skippedNotice }
469
471
  : {})}
@@ -543,6 +545,38 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
543
545
  )}
544
546
  <UrlIssueNotice />
545
547
 
548
+ {/* THE LOCATION CONTROL — a place, its radius, and the way to change
549
+ either. Mounted by the PAGE, not by each host, which is how a
550
+ category results page came to have no way to say where it was
551
+ looking while `/s` had one: the row was a slot every surface had to
552
+ remember to fill, and one of them did not.
553
+
554
+ Drawn when this deployment can resolve a place at all, or when the
555
+ address already carries one (a shared link must always be widenable
556
+ by whoever opens it). It is not a filter and it is not in the filter
557
+ count — see `activeFilterCount`.
558
+
559
+ Three reasons to draw it, and an offer is one of them: a deployment
560
+ that can place its visitor but ships no place picker still needs
561
+ somewhere for them to accept — otherwise the offer is a value with
562
+ no control, which is the same silence this pack is about. */}
563
+ {(props.renderGeoFilter !== undefined ||
564
+ state.geo !== undefined ||
565
+ geoOffer !== undefined) && (
566
+ <LocationSummaryLine
567
+ {...(props.renderGeoFilter !== undefined
568
+ ? { renderGeoFilter: props.renderGeoFilter }
569
+ : {})}
570
+ {...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
571
+ // No door where the room is already open: the desktop column layout
572
+ // has the whole panel on screen beside this row.
573
+ filtersDoor={layout === "sheet"}
574
+ onOpenAll={() => {
575
+ setSheetOpen(true);
576
+ }}
577
+ />
578
+ )}
579
+
546
580
  {/* Above the chips in the sheet layout and above the columns in the
547
581
  other one — see {@link SearchPageProps.resultsHeader}. `?? null` is
548
582
  the written decision, not an oversight: a page with nothing to say
@@ -575,14 +609,6 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
575
609
  {...(props.categoryLabel !== undefined
576
610
  ? { categoryLabel: props.categoryLabel }
577
611
  : {})}
578
- {...(props.renderGeoFilter !== undefined
579
- ? { renderGeoFilter: props.renderGeoFilter }
580
- : {})}
581
- {...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
582
- /* The row above already states the location and opens its own
583
- sheet, so the chip would be the second control over one filter
584
- — see `FilterChipsProps.geoChip`. */
585
- {...(props.resultsHeader !== undefined ? { geoChip: false } : {})}
586
612
  />
587
613
  <SkinDialog
588
614
  open={sheetOpen}
@@ -643,7 +669,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
643
669
  renderGeoFilter,
644
670
  geoLabel,
645
671
  skippedNotice,
646
- defaultGeo,
672
+ geoOffer,
647
673
  footer,
648
674
  filtersHeader,
649
675
  resultsHeader,
@@ -665,7 +691,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
665
691
 
666
692
  return (
667
693
  <SkinTheme surface="base" {...(mode !== undefined ? { mode } : {})}>
668
- <SearchStateProvider adapter={adapter} defaultGeo={defaultGeo} {...parseOptions}>
694
+ <SearchStateProvider adapter={adapter} geoOffer={geoOffer} {...parseOptions}>
669
695
  <SearchPageBody
670
696
  {...(renderCard !== undefined ? { renderCard } : {})}
671
697
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
@@ -1,12 +1,17 @@
1
1
  /**
2
2
  * The LOCATION sheet — one implementation, two doors.
3
3
  *
4
- * `<FilterChips>`'s geo chip opened it and `<LocationSummaryLine>` opens it
5
- * too: on the ref's SERP both controls say where the search is centred, one
6
- * on the summary row and one in the chip strip, and a person tapping either
7
- * must land in the same place. Two copies of a bottom sheet is two places for
8
- * "clear the location" to behave differently, so the sheet is a component and
9
- * the two surfaces are two `open` flags on it.
4
+ * ONE control, and this is the inside of it. `<LocationSummaryLine>` is the
5
+ * door: a place on the left, its radius beside it, and this sheet behind
6
+ * both.
7
+ *
8
+ * It used to be three doors the chip row's geo chip, the facet panel's
9
+ * "Location" group, and the row over one pair of numbers, with the radius
10
+ * living in the panel and the place in the sheet. That arrangement made a
11
+ * latitude look like a filter, which is what it is not: a coordinate pair is
12
+ * the machine form of a place, and a place with a radius is its own thing in
13
+ * the chrome, like the search box. The chip and the panel group are gone; the
14
+ * radius moved in here, beside the place it is a radius OF.
10
15
  *
11
16
  * ── What the sheet can do without a geocoder, and what it cannot ───────────
12
17
  *
@@ -25,7 +30,7 @@
25
30
  * the DOM, on either surface.
26
31
  */
27
32
  import type { ReactElement, ReactNode } from "react";
28
- import { Button, Flex, Typography } from "antd";
33
+ import { Button, Flex, InputNumber, Typography } from "antd";
29
34
  import { SkinDialog } from "@stapel/tokens-antd/skin";
30
35
  import { SlotPlaceholder, useT, useTPlural } from "@stapel/core";
31
36
  import { spacing } from "@stapel/tokens";
@@ -42,6 +47,7 @@ export interface GeoSheetTestIds {
42
47
  readonly apply: string;
43
48
  readonly slot: string;
44
49
  readonly summary: string;
50
+ readonly radius: string;
45
51
  readonly clear: string;
46
52
  }
47
53
 
@@ -53,6 +59,7 @@ export const CHIP_GEO_TEST_IDS: GeoSheetTestIds = {
53
59
  apply: "filter-chip-apply-geo",
54
60
  slot: "search-chip-geo-slot",
55
61
  summary: "search-chip-geo-summary",
62
+ radius: "search-chip-geo-radius",
56
63
  clear: "search-chip-geo-clear",
57
64
  };
58
65
 
@@ -63,6 +70,9 @@ export const SUMMARY_GEO_TEST_IDS: GeoSheetTestIds = {
63
70
  apply: "search-location-apply",
64
71
  slot: "search-location-slot",
65
72
  summary: "search-location-sheet-summary",
73
+ // The one radius control in the pair. It was `search-geo-radius` in the
74
+ // facet panel, which no longer draws a location group at all.
75
+ radius: "search-geo-radius",
66
76
  clear: "search-location-clear",
67
77
  };
68
78
 
@@ -142,6 +152,35 @@ export function GeoSheet(props: GeoSheetProps): ReactElement {
142
152
  <Typography.Text type="secondary" data-testid={props.testIds.summary}>
143
153
  {summary}
144
154
  </Typography.Text>
155
+ {/* HOW WIDE, beside WHERE. A radius means nothing without a place,
156
+ so this exists only once one is set and disappears with it —
157
+ and it lives here rather than in the filter panel, where it was
158
+ a number about a place the panel could not name. */}
159
+ {geo.kind === "center" && (
160
+ <Flex vertical gap={spacing[1]}>
161
+ <Typography.Text type="secondary">
162
+ {t(SEARCH_I18N_KEYS.geoRadiusLabel)}
163
+ </Typography.Text>
164
+ <InputNumber
165
+ min={1}
166
+ style={{ alignSelf: "flex-start" }}
167
+ value={geo.radiusKm ?? null}
168
+ aria-label={t(SEARCH_I18N_KEYS.geoRadiusLabel)}
169
+ data-testid={props.testIds.radius}
170
+ onChange={(value) => {
171
+ setGeo({
172
+ kind: "center",
173
+ lat: geo.lat,
174
+ lon: geo.lon,
175
+ ...(typeof value === "number" ? { radiusKm: value } : {}),
176
+ });
177
+ }}
178
+ />
179
+ </Flex>
180
+ )}
181
+ {/* One control off. Clearing the place clears the radius with it —
182
+ `setGeo(null)` drops both, because half a location is not a
183
+ state this search can be in. */}
145
184
  <Button
146
185
  style={{ alignSelf: "flex-start" }}
147
186
  data-testid={props.testIds.clear}
@@ -24,9 +24,10 @@
24
24
  * keeps the controls that WIDEN a location a link already carries. Its two
25
25
  * companions divide the same seam the same way: `geoLabel` is what the
26
26
  * current place is CALLED (owning a coordinate is not permission to print
27
- * one), and `defaultGeo` is where a fresh search opens when the URL names
28
- * nowhere — the visitor's own position, resolved by whoever is allowed to
29
- * ask for it.
27
+ * one), and `geoOffer` is a place the search COULD be narrowed to — the
28
+ * visitor's own position, resolved by whoever is allowed to ask for it,
29
+ * drawn as an invitation on the location row and applied only when
30
+ * somebody presses it.
30
31
  * 5. **retheming through the §68 token JSON** — every surface wraps itself
31
32
  * in the shared `SkinTheme`, so a host's regenerated `--stapel-*` custom
32
33
  * properties reach this skin with zero code.
@@ -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,