@stapel/search-react 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/default/FacetPanelPane.d.ts +0 -19
  3. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  4. package/dist/default/FacetPanelPane.js +33 -65
  5. package/dist/default/FacetPanelPane.js.map +1 -1
  6. package/dist/default/FilterChips.d.ts +1 -24
  7. package/dist/default/FilterChips.d.ts.map +1 -1
  8. package/dist/default/FilterChips.js +11 -20
  9. package/dist/default/FilterChips.js.map +1 -1
  10. package/dist/default/LocationSummaryLine.d.ts +9 -0
  11. package/dist/default/LocationSummaryLine.d.ts.map +1 -1
  12. package/dist/default/LocationSummaryLine.js +29 -2
  13. package/dist/default/LocationSummaryLine.js.map +1 -1
  14. package/dist/default/SearchPage.d.ts +14 -5
  15. package/dist/default/SearchPage.d.ts.map +1 -1
  16. package/dist/default/SearchPage.js +27 -22
  17. package/dist/default/SearchPage.js.map +1 -1
  18. package/dist/default/SearchResultsPane.d.ts +37 -0
  19. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  20. package/dist/default/SearchResultsPane.js +7 -2
  21. package/dist/default/SearchResultsPane.js.map +1 -1
  22. package/dist/default/geoSheet.d.ts +12 -6
  23. package/dist/default/geoSheet.d.ts.map +1 -1
  24. package/dist/default/geoSheet.js +14 -3
  25. package/dist/default/geoSheet.js.map +1 -1
  26. package/dist/default/index.d.ts +5 -4
  27. package/dist/default/index.d.ts.map +1 -1
  28. package/dist/default/index.js +4 -3
  29. package/dist/default/index.js.map +1 -1
  30. package/dist/headless/SearchStateProvider.d.ts +42 -30
  31. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  32. package/dist/headless/SearchStateProvider.js +16 -36
  33. package/dist/headless/SearchStateProvider.js.map +1 -1
  34. package/dist/i18n/es.d.ts.map +1 -1
  35. package/dist/i18n/es.js +1 -0
  36. package/dist/i18n/es.js.map +1 -1
  37. package/dist/i18n/keys.d.ts +1 -0
  38. package/dist/i18n/keys.d.ts.map +1 -1
  39. package/dist/i18n/keys.js +2 -0
  40. package/dist/i18n/keys.js.map +1 -1
  41. package/dist/i18n/ru.d.ts.map +1 -1
  42. package/dist/i18n/ru.js +1 -0
  43. package/dist/i18n/ru.js.map +1 -1
  44. package/dist/state/facets.d.ts +18 -0
  45. package/dist/state/facets.d.ts.map +1 -1
  46. package/dist/state/facets.js +25 -0
  47. package/dist/state/facets.js.map +1 -1
  48. package/dist/state/urlState.d.ts +20 -2
  49. package/dist/state/urlState.d.ts.map +1 -1
  50. package/dist/state/urlState.js +26 -5
  51. package/dist/state/urlState.js.map +1 -1
  52. package/llms.txt +1 -1
  53. package/manifest.json +2 -1
  54. package/nav-manifest.json +1 -1
  55. package/package.json +3 -3
  56. package/src/analytics/generated/events.json +1 -1
  57. package/src/default/FacetPanelPane.tsx +72 -136
  58. package/src/default/FilterChips.tsx +12 -72
  59. package/src/default/LocationSummaryLine.tsx +65 -1
  60. package/src/default/SearchPage.tsx +64 -31
  61. package/src/default/SearchResultsPane.tsx +54 -7
  62. package/src/default/geoSheet.tsx +46 -7
  63. package/src/default/index.ts +5 -3
  64. package/src/headless/SearchStateProvider.tsx +57 -66
  65. package/src/i18n/es.ts +1 -0
  66. package/src/i18n/keys.ts +2 -0
  67. package/src/i18n/ru.ts +1 -0
  68. package/src/state/facets.ts +28 -0
  69. package/src/state/urlState.ts +26 -4
@@ -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
@@ -139,6 +157,10 @@ const LOCATION: CSSProperties = {
139
157
  * noise, and it costs 16px. */
140
158
  const PIN: CSSProperties = { flex: "0 0 auto", display: "inline-flex" };
141
159
 
160
+ /** The offer. It never shrinks: it is three words and a number, and half of
161
+ * "Near me" is not an offer. */
162
+ const OFFER: CSSProperties = { flex: "0 0 auto", paddingInline: 0 };
163
+
142
164
  /**
143
165
  * The count, IN the flow.
144
166
  *
@@ -179,6 +201,15 @@ export interface LocationSummaryLineProps {
179
201
  * because the surface is what it covers.
180
202
  */
181
203
  readonly onOpenAll: () => void;
204
+ /**
205
+ * Draw the trailing "Filters (N)" door. Default `true`.
206
+ *
207
+ * `false` where the panel is ALREADY on screen — a desktop column layout —
208
+ * because a door beside the room it opens is not a door, and the count it
209
+ * carries is printed again on the panel's own "clear all" a few hundred
210
+ * pixels to the left.
211
+ */
212
+ readonly filtersDoor?: boolean;
182
213
  }
183
214
 
184
215
  /** A map pin in `currentColor` — the house convention: an inline monochrome
@@ -208,7 +239,7 @@ export function LocationSummaryLine(
208
239
  props: LocationSummaryLineProps
209
240
  ): ReactElement {
210
241
  const t = useT();
211
- const { state, activeFilters } = useSearchState();
242
+ const { state, activeFilters, geoOffer, acceptGeoOffer } = useSearchState();
212
243
  const [open, setOpen] = useState(false);
213
244
  const geo = state.geo;
214
245
 
@@ -221,6 +252,14 @@ export function LocationSummaryLine(
221
252
  ? t(SEARCH_I18N_KEYS.geoRadiusKm, { km: geo.radiusKm })
222
253
  : undefined;
223
254
 
255
+ // The offer's own radius, said out loud on the button. `geoOffer` is
256
+ // already `undefined` whenever a location is applied (the provider closes
257
+ // the question), so this row never shows an offer beside a place.
258
+ const offerRadius =
259
+ geoOffer !== undefined && geoOffer.kind === "center" && geoOffer.radiusKm !== undefined
260
+ ? t(SEARCH_I18N_KEYS.geoRadiusKm, { km: geoOffer.radiusKm })
261
+ : undefined;
262
+
224
263
  const where: ReactNode =
225
264
  geo === undefined
226
265
  ? t(SEARCH_I18N_KEYS.geoEverywhere)
@@ -272,6 +311,29 @@ export function LocationSummaryLine(
272
311
  </span>
273
312
  </Button>
274
313
 
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 && (
319
+ <Button
320
+ type="link"
321
+ style={OFFER}
322
+ data-testid="search-location-offer"
323
+ 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}
326
+ >
327
+ {t(SEARCH_I18N_KEYS.geoNearMe)}
328
+ {offerRadius !== undefined && (
329
+ <span data-testid="search-location-offer-radius">
330
+ {" · "}
331
+ {offerRadius}
332
+ </span>
333
+ )}
334
+ </Button>
335
+ )}
336
+
275
337
  {/* "Filters", not "All filters": this end of the row shares 390px
276
338
  with a place name that can run to fifteen characters, and the word
277
339
  the person is looking for is the noun. The panel's own heading
@@ -279,6 +341,7 @@ export function LocationSummaryLine(
279
341
  door. */}
280
342
  {/* The count, not a dot: this row has the width to say how many — and
281
343
  it rides IN the line rather than floating off its corner. */}
344
+ {props.filtersDoor === false ? null : (
282
345
  <Flex
283
346
  align="center"
284
347
  gap={spacing[1]}
@@ -302,6 +365,7 @@ export function LocationSummaryLine(
302
365
  </span>
303
366
  )}
304
367
  </Flex>
368
+ )}
305
369
  </Flex>
306
370
 
307
371
  <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,9 +83,11 @@ 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";
90
+ import type { SearchResultsWrapper } from "./SearchResultsPane.js";
89
91
  import { SortSelect } from "./SortSelect.js";
90
92
  import { SEARCH_BUILTIN_VIEWS, ViewSwitch, resolveView } from "./ViewSwitch.js";
91
93
  import type { SearchView } from "./ViewSwitch.js";
@@ -210,16 +212,17 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
210
212
  * Default `false` — see {@link FacetPanelPaneProps.skippedNotice}. */
211
213
  readonly skippedNotice?: boolean;
212
214
  /**
213
- * Open the search centred where the visitor is, when the URL says nothing
214
- * about location — see {@link SearchStateProviderProps.defaultGeo} for the
215
- * 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.
216
218
  *
217
219
  * The host resolves the position; this page does not know what a map or a
218
220
  * geocoder is and must not learn. A browser prompt (`usePermission`
219
221
  * + `geolocation`) or the server's IP guess both arrive here as the same two
220
- * 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.
221
224
  */
222
- readonly defaultGeo?: SearchGeo | undefined;
225
+ readonly geoOffer?: SearchGeo | undefined;
223
226
  /** Container chrome under the results — e.g. the ranking-disclosure link. */
224
227
  readonly footer?: ReactNode;
225
228
  /**
@@ -292,6 +295,13 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
292
295
  * describes rather than over the whole two-column page.
293
296
  */
294
297
  readonly breadcrumb?: ReactNode;
298
+ /**
299
+ * Wrap the results the page drew — a provider, an observer, an analytics
300
+ * boundary — without taking over the arrangement. Handed straight to
301
+ * `<SearchResultsPane wrapResults>`; see {@link SearchResultsWrapper} for
302
+ * why a host cannot do this with `views`/`renderResults` alone.
303
+ */
304
+ readonly wrapResults?: SearchResultsWrapper;
295
305
  /**
296
306
  * The arrangements the view switch offers. Default: the pair's own list and
297
307
  * grid. A deployment adds its own — `{ id: "map", labelKey, icon, render }`
@@ -345,6 +355,7 @@ interface SearchPageBodyProps {
345
355
  readonly defaultFiltersOpen?: boolean;
346
356
  readonly pageSize?: boolean;
347
357
  readonly breadcrumb?: ReactNode;
358
+ readonly wrapResults?: SearchResultsWrapper;
348
359
  readonly views?: readonly SearchView[];
349
360
  readonly defaultView?: string;
350
361
  readonly onViewChange?: (id: string) => void;
@@ -360,7 +371,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
360
371
  const t = useT();
361
372
  const tPlural = useTPlural();
362
373
  const { categoryFeatures, locale, resolveFacetLabels, filtersHeader } = props;
363
- const { state } = useSearchState();
374
+ const { state, geoOffer } = useSearchState();
364
375
  const facets = useFacetPanel({
365
376
  ...(categoryFeatures !== undefined ? { categoryFeatures } : {}),
366
377
  ...(locale !== undefined ? { locale } : {}),
@@ -420,7 +431,6 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
420
431
  state.category === undefined &&
421
432
  state.lang === undefined &&
422
433
  props.renderCategoryFilter === undefined &&
423
- props.renderGeoFilter === undefined &&
424
434
  (props.languages ?? []).length === 0;
425
435
  const showFilters = filtersHeader !== undefined || !filtersEmpty;
426
436
 
@@ -451,10 +461,6 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
451
461
  {...(props.renderCategoryFilter !== undefined
452
462
  ? { renderCategoryFilter: props.renderCategoryFilter }
453
463
  : {})}
454
- {...(props.renderGeoFilter !== undefined
455
- ? { renderGeoFilter: props.renderGeoFilter }
456
- : {})}
457
- {...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
458
464
  {...(props.skippedNotice !== undefined
459
465
  ? { skippedNotice: props.skippedNotice }
460
466
  : {})}
@@ -503,6 +509,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
503
509
  {...(phoneToolbar ? { header: "compact" as const } : {})}
504
510
  headingLevel={props.resultsHeadingLevel ?? 1}
505
511
  {...(view.render !== undefined ? { renderResults: view.render } : {})}
512
+ {...(props.wrapResults !== undefined ? { wrapResults: props.wrapResults } : {})}
506
513
  {...(view.layout !== undefined ? { layout: view.layout } : {})}
507
514
  {...(props.renderCard !== undefined ? { renderCard: props.renderCard } : {})}
508
515
  {...(props.footer !== undefined ? { footer: props.footer } : {})}
@@ -533,6 +540,38 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
533
540
  )}
534
541
  <UrlIssueNotice />
535
542
 
543
+ {/* THE LOCATION CONTROL — a place, its radius, and the way to change
544
+ either. Mounted by the PAGE, not by each host, which is how a
545
+ category results page came to have no way to say where it was
546
+ looking while `/s` had one: the row was a slot every surface had to
547
+ remember to fill, and one of them did not.
548
+
549
+ Drawn when this deployment can resolve a place at all, or when the
550
+ address already carries one (a shared link must always be widenable
551
+ by whoever opens it). It is not a filter and it is not in the filter
552
+ count — see `activeFilterCount`.
553
+
554
+ Three reasons to draw it, and an offer is one of them: a deployment
555
+ that can place its visitor but ships no place picker still needs
556
+ somewhere for them to accept — otherwise the offer is a value with
557
+ no control, which is the same silence this pack is about. */}
558
+ {(props.renderGeoFilter !== undefined ||
559
+ state.geo !== undefined ||
560
+ geoOffer !== undefined) && (
561
+ <LocationSummaryLine
562
+ {...(props.renderGeoFilter !== undefined
563
+ ? { renderGeoFilter: props.renderGeoFilter }
564
+ : {})}
565
+ {...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
566
+ // No door where the room is already open: the desktop column layout
567
+ // has the whole panel on screen beside this row.
568
+ filtersDoor={layout === "sheet"}
569
+ onOpenAll={() => {
570
+ setSheetOpen(true);
571
+ }}
572
+ />
573
+ )}
574
+
536
575
  {/* Above the chips in the sheet layout and above the columns in the
537
576
  other one — see {@link SearchPageProps.resultsHeader}. `?? null` is
538
577
  the written decision, not an oversight: a page with nothing to say
@@ -565,14 +604,6 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
565
604
  {...(props.categoryLabel !== undefined
566
605
  ? { categoryLabel: props.categoryLabel }
567
606
  : {})}
568
- {...(props.renderGeoFilter !== undefined
569
- ? { renderGeoFilter: props.renderGeoFilter }
570
- : {})}
571
- {...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
572
- /* The row above already states the location and opens its own
573
- sheet, so the chip would be the second control over one filter
574
- — see `FilterChipsProps.geoChip`. */
575
- {...(props.resultsHeader !== undefined ? { geoChip: false } : {})}
576
607
  />
577
608
  <SkinDialog
578
609
  open={sheetOpen}
@@ -633,7 +664,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
633
664
  renderGeoFilter,
634
665
  geoLabel,
635
666
  skippedNotice,
636
- defaultGeo,
667
+ geoOffer,
637
668
  footer,
638
669
  filtersHeader,
639
670
  resultsHeader,
@@ -643,6 +674,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
643
674
  defaultFiltersOpen,
644
675
  pageSize,
645
676
  breadcrumb,
677
+ wrapResults,
646
678
  views,
647
679
  defaultView,
648
680
  onViewChange,
@@ -654,7 +686,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
654
686
 
655
687
  return (
656
688
  <SkinTheme surface="base" {...(mode !== undefined ? { mode } : {})}>
657
- <SearchStateProvider adapter={adapter} defaultGeo={defaultGeo} {...parseOptions}>
689
+ <SearchStateProvider adapter={adapter} geoOffer={geoOffer} {...parseOptions}>
658
690
  <SearchPageBody
659
691
  {...(renderCard !== undefined ? { renderCard } : {})}
660
692
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
@@ -676,6 +708,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
676
708
  {...(defaultFiltersOpen !== undefined ? { defaultFiltersOpen } : {})}
677
709
  {...(pageSize !== undefined ? { pageSize } : {})}
678
710
  {...(breadcrumb !== undefined ? { breadcrumb } : {})}
711
+ {...(wrapResults !== undefined ? { wrapResults } : {})}
679
712
  {...(views !== undefined ? { views } : {})}
680
713
  {...(defaultView !== undefined ? { defaultView } : {})}
681
714
  {...(onViewChange !== undefined ? { onViewChange } : {})}
@@ -62,6 +62,29 @@ export type SearchResultsRenderer = (
62
62
  items: readonly SearchItem[]
63
63
  ) => ReactNode;
64
64
 
65
+ /**
66
+ * A wrapper AROUND the results the pane drew, rather than a replacement for
67
+ * them. It receives the loaded rows and the pane's own rendered arrangement,
68
+ * and returns whatever should stand in its place — normally a provider with
69
+ * the arrangement still inside it.
70
+ *
71
+ * ```tsx
72
+ * wrapResults={(rows, results) => (
73
+ * <ListingEngagementScope ids={rows.map((row) => Number(row.key))}>
74
+ * {results}
75
+ * </ListingEngagementScope>
76
+ * )}
77
+ * ```
78
+ *
79
+ * Called only on the LOADED arm: there are no rows to scope while a search is
80
+ * in flight, has failed, or found nothing, and the pane's four load arms stay
81
+ * the pane's own sentences.
82
+ */
83
+ export type SearchResultsWrapper = (
84
+ items: readonly SearchItem[],
85
+ results: ReactNode
86
+ ) => ReactNode;
87
+
65
88
  /**
66
89
  * The widest a column of results is allowed to get.
67
90
  *
@@ -128,6 +151,24 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
128
151
  * when the container's arrangement is not "cards in a grid".
129
152
  */
130
153
  readonly renderResults?: SearchResultsRenderer;
154
+ /**
155
+ * The scope slot: WRAPS the arrangement instead of replacing it.
156
+ *
157
+ * A container that needs to publish something over the rows the pane just
158
+ * drew — a per-reader overlay, an intersection observer, an analytics
159
+ * boundary — needs the rows AND the pane's own grid, and `renderResults`
160
+ * only offers the first at the price of the second.
161
+ *
162
+ * The measured cost of not having it: a storefront that dims already-seen
163
+ * listings could open its engagement scope on its own landing feed (which
164
+ * mounts the pane directly) and NOT on `/s` or `/c/:slug`, which reach the
165
+ * pane through `<SearchPage>` and its internal `<SearchStateProvider>` —
166
+ * so the feature worked on one of the three screens a buyer scrolls and
167
+ * silently did nothing on the other two (walker D105). Re-implementing the
168
+ * grid to get the rows was the only way through, and a host's copy of this
169
+ * pane's grid is a copy that stops tracking it.
170
+ */
171
+ readonly wrapResults?: SearchResultsWrapper;
131
172
  /**
132
173
  * Rendered beside the count in the heading row — the sort control, a view
133
174
  * switch. The pane owns the heading, so a screen that composes it hands its
@@ -284,7 +325,7 @@ function Pager(props: { bag: SearchResultsBag }): ReactElement | null {
284
325
 
285
326
  export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
286
327
  const t = useT();
287
- const { renderCard, renderResults } = props;
328
+ const { renderCard, renderResults, wrapResults } = props;
288
329
  const scorerName = useScorerNames();
289
330
  const maxWidth = props.maxWidth === undefined ? RESULTS_MAX_WIDTH : props.maxWidth;
290
331
 
@@ -377,9 +418,9 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
377
418
  );
378
419
  }}
379
420
  >
380
- {(items) => (
381
- <div data-testid="search-results">
382
- {renderResults !== undefined ? (
421
+ {(items) => {
422
+ const arrangement =
423
+ renderResults !== undefined ? (
383
424
  renderResults(items)
384
425
  ) : (
385
426
  <div
@@ -397,9 +438,15 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
397
438
  </div>
398
439
  ))}
399
440
  </div>
400
- )}
401
- </div>
402
- )}
441
+ );
442
+ return (
443
+ <div data-testid="search-results">
444
+ {wrapResults !== undefined
445
+ ? wrapResults(items, arrangement)
446
+ : arrangement}
447
+ </div>
448
+ );
449
+ }}
403
450
  </LoadList>
404
451
 
405
452
  <Pager bag={bag} />
@@ -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.
@@ -46,6 +47,7 @@ export { SearchResultsPane, RESULTS_MAX_WIDTH } from "./SearchResultsPane.js";
46
47
  export type {
47
48
  SearchResultsPaneProps,
48
49
  SearchResultsRenderer,
50
+ SearchResultsWrapper,
49
51
  } from "./SearchResultsPane.js";
50
52
 
51
53
  export {