@stapel/search-react 0.9.1 → 0.10.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 (58) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/default/FilterChips.d.ts.map +1 -1
  3. package/dist/default/FilterChips.js +8 -23
  4. package/dist/default/FilterChips.js.map +1 -1
  5. package/dist/default/LocationSummaryLine.d.ts +24 -0
  6. package/dist/default/LocationSummaryLine.d.ts.map +1 -0
  7. package/dist/default/LocationSummaryLine.js +83 -0
  8. package/dist/default/LocationSummaryLine.js.map +1 -0
  9. package/dist/default/SearchPage.d.ts +19 -0
  10. package/dist/default/SearchPage.d.ts.map +1 -1
  11. package/dist/default/SearchPage.js +6 -6
  12. package/dist/default/SearchPage.js.map +1 -1
  13. package/dist/default/geoSheet.d.ts +61 -0
  14. package/dist/default/geoSheet.d.ts.map +1 -0
  15. package/dist/default/geoSheet.js +63 -0
  16. package/dist/default/geoSheet.js.map +1 -0
  17. package/dist/default/index.d.ts +2 -0
  18. package/dist/default/index.d.ts.map +1 -1
  19. package/dist/default/index.js +1 -0
  20. package/dist/default/index.js.map +1 -1
  21. package/dist/headless/useSearchCount.d.ts +44 -0
  22. package/dist/headless/useSearchCount.d.ts.map +1 -0
  23. package/dist/headless/useSearchCount.js +121 -0
  24. package/dist/headless/useSearchCount.js.map +1 -0
  25. package/dist/i18n/es.d.ts.map +1 -1
  26. package/dist/i18n/es.js +1 -0
  27. package/dist/i18n/es.js.map +1 -1
  28. package/dist/i18n/keys.d.ts +10 -0
  29. package/dist/i18n/keys.d.ts.map +1 -1
  30. package/dist/i18n/keys.js +11 -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 +1 -0
  34. package/dist/i18n/ru.js.map +1 -1
  35. package/dist/index.d.ts +3 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/state/limits.d.ts +21 -0
  40. package/dist/state/limits.d.ts.map +1 -1
  41. package/dist/state/limits.js +21 -0
  42. package/dist/state/limits.js.map +1 -1
  43. package/llms.txt +2 -1
  44. package/manifest.json +30 -2
  45. package/nav-manifest.json +1 -1
  46. package/package.json +4 -4
  47. package/src/analytics/generated/events.json +1 -1
  48. package/src/default/FilterChips.tsx +17 -56
  49. package/src/default/LocationSummaryLine.tsx +187 -0
  50. package/src/default/SearchPage.tsx +33 -3
  51. package/src/default/geoSheet.tsx +161 -0
  52. package/src/default/index.ts +3 -0
  53. package/src/headless/useSearchCount.ts +189 -0
  54. package/src/i18n/es.ts +1 -0
  55. package/src/i18n/keys.ts +11 -0
  56. package/src/i18n/ru.ts +1 -0
  57. package/src/index.ts +8 -0
  58. package/src/state/limits.ts +23 -0
@@ -49,13 +49,12 @@
49
49
  */
50
50
  import { useState } from "react";
51
51
  import type { CSSProperties, ReactElement, ReactNode } from "react";
52
- import { Button, Flex, Typography } from "antd";
52
+ import { Button } from "antd";
53
53
  import { SkinDialog, useDialogSurface } from "@stapel/tokens-antd/skin";
54
- import { SlotPlaceholder, useT, useTPlural } from "@stapel/core";
54
+ import { useT } from "@stapel/core";
55
55
  import { radii, spacing } from "@stapel/tokens";
56
56
  import type { FeatureDef } from "@stapel/attributes-react";
57
57
  import { useFacetPanel } from "../headless/FacetPanel.js";
58
- import { useAppliedCount } from "../headless/useAppliedCount.js";
59
58
  import { useSearchState } from "../headless/SearchStateProvider.js";
60
59
  import { buildRangeGroups } from "../state/ranges.js";
61
60
  import type { FacetGroup } from "../state/facets.js";
@@ -64,6 +63,7 @@ import { FacetGroupControl } from "./FacetGroupControl.js";
64
63
  import { RangeFilterRow } from "./RangeFilterRow.js";
65
64
  import { geoSummaryFallback } from "./FacetPanelPane.js";
66
65
  import type { GeoFilterSlotProps } from "./FacetPanelPane.js";
66
+ import { CHIP_GEO_TEST_IDS, GeoSheet, useApplyLabel } from "./geoSheet.js";
67
67
 
68
68
  /** The class the scroller carries, for {@link chipRowCss}. */
69
69
  export const CHIP_ROW_CLASS = "stapel-filter-chips";
@@ -142,15 +142,14 @@ function chipLabel(group: FacetGroup, t: (key: string, p?: Record<string, unknow
142
142
 
143
143
  export function FilterChips(props: FilterChipsProps): ReactElement {
144
144
  const t = useT();
145
- const tPlural = useTPlural();
146
- const { state, setGeo } = useSearchState();
145
+ const { state } = useSearchState();
147
146
  const bag = useFacetPanel({
148
147
  ...(props.categoryFeatures !== undefined
149
148
  ? { categoryFeatures: props.categoryFeatures }
150
149
  : {}),
151
150
  ...(props.locale !== undefined ? { locale: props.locale } : {}),
152
151
  });
153
- const applied = useAppliedCount();
152
+ const applyLabel = useApplyLabel();
154
153
  const surface = useDialogSurface();
155
154
  const [open, setOpen] = useState<OpenChip>(null);
156
155
 
@@ -166,19 +165,6 @@ export function FilterChips(props: FilterChipsProps): ReactElement {
166
165
  ? bag.state.data.filter((group) => group.options.length > 0)
167
166
  : [];
168
167
 
169
- // The same sentence the panel's own commit button says, for the same reason:
170
- // the results are BEHIND this sheet, so the button that closes it is the
171
- // only place a person learns what their choice did.
172
- const applyLabel =
173
- applied.count === null || applied.kind === "unknown"
174
- ? t(SEARCH_I18N_KEYS.filtersApply)
175
- : tPlural(
176
- applied.kind === "at_least"
177
- ? SEARCH_I18N_KEYS.filtersShowCountAtLeast
178
- : SEARCH_I18N_KEYS.filtersShowCount,
179
- { count: applied.count }
180
- );
181
-
182
168
  const close = (): void => {
183
169
  setOpen(null);
184
170
  };
@@ -338,43 +324,18 @@ export function FilterChips(props: FilterChipsProps): ReactElement {
338
324
  );
339
325
  })()}
340
326
 
341
- {open === "geo" &&
342
- sheetFor(
343
- "geo",
344
- t(SEARCH_I18N_KEYS.geoTitle),
345
- <Flex vertical gap={spacing[3]}>
346
- {/* SETTING a centre needs a geocoder, which is `geo-react`'s and
347
- the deployment's. ADJUSTING or CLEARING one the URL already
348
- carries does not — which is why the chip still opens on a
349
- shared link with no slot filled, and why the absence of the
350
- slot is NAMED in development rather than left as a gap under
351
- the sheet's title. */}
352
- {props.renderGeoFilter?.({
353
- value: geo,
354
- onChange: (next) => {
355
- setGeo(next);
356
- },
357
- }) ?? <SlotPlaceholder name="renderGeoFilter" data-testid="search-chip-geo-slot" />}
358
- {geo !== undefined && (
359
- <>
360
- <Typography.Text type="secondary" data-testid="search-chip-geo-summary">
361
- {geoChipLabel}
362
- </Typography.Text>
363
- <Button
364
- style={{ alignSelf: "flex-start" }}
365
- data-testid="search-chip-geo-clear"
366
- data-analytics="none"
367
- data-analytics-reason="a filter is a read, not a flow step"
368
- onClick={() => {
369
- setGeo(null);
370
- }}
371
- >
372
- {t(SEARCH_I18N_KEYS.geoClear)}
373
- </Button>
374
- </>
375
- )}
376
- </Flex>
377
- )}
327
+ {/* The LOCATION sheet is shared with `<LocationSummaryLine>` — the ref
328
+ puts a location control on both rows, and they must land in the same
329
+ place. See `geoSheet.tsx`. */}
330
+ <GeoSheet
331
+ open={open === "geo"}
332
+ onClose={close}
333
+ testIds={CHIP_GEO_TEST_IDS}
334
+ {...(props.renderGeoFilter !== undefined
335
+ ? { renderGeoFilter: props.renderGeoFilter }
336
+ : {})}
337
+ {...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
338
+ />
378
339
  </>
379
340
  );
380
341
  }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `<LocationSummaryLine>` — the row above a phone SERP's chips: WHERE this
3
+ * search is looking on the left, and how much has been narrowed on the right.
4
+ *
5
+ * ## Why the location gets a line of its own, above the chips
6
+ *
7
+ * It is already a chip (`<FilterChips>` draws one), and on the refs it is a
8
+ * ROW as well, and that is not redundancy. A chip row scrolls: the geo chip is
9
+ * one of eight and it is off screen the moment somebody has scrolled to
10
+ * "Year". Location is the one constraint on a classified that changes what a
11
+ * result MEANS rather than narrowing a set — "1 200 €" is a different offer in
12
+ * the next city — so it is the one that must be readable without scrolling
13
+ * anything. The two controls open the same sheet (`geoSheet.tsx`), so tapping
14
+ * either lands in the same place.
15
+ *
16
+ * ## Never a coordinate
17
+ *
18
+ * This pair holds a `lat` and a `lon` and has no way on earth to turn them
19
+ * into "Berlin Mitte" — that is a geocoder, and a search package that grew one
20
+ * to say a nicer sentence would have taken on the whole of `geo-react`. So the
21
+ * line says the name it was HANDED (`geoLabel`), or that a place is chosen,
22
+ * and adds the radius, which is a number this pair does own. With nothing
23
+ * applied it says the search is looking everywhere, which is the truth and is
24
+ * also the invitation to narrow it.
25
+ *
26
+ * ## The right-hand affordance carries a COUNT, and the chip row carries a dot
27
+ *
28
+ * `<FilterChips>`'s leading chip is a 32px circle: a number inside it is a
29
+ * number nobody reads, so it shows a dot. This is a full-width row with a word
30
+ * on it, so the badge has room to say HOW MANY constraints are applied — which
31
+ * is the difference between "something is filtered" and "four things are, and
32
+ * that is why there are three results". Both read the same
33
+ * `activeFilters` off the URL state; neither invents a second counter.
34
+ */
35
+ import { useState } from "react";
36
+ import type { CSSProperties, ReactElement, ReactNode } from "react";
37
+ import { Badge, Button, Flex } from "antd";
38
+ import { useT } from "@stapel/core";
39
+ import { spacing } from "@stapel/tokens";
40
+ import { useSearchState } from "../headless/SearchStateProvider.js";
41
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
42
+ import { geoSummaryFallback } from "./FacetPanelPane.js";
43
+ import type { GeoFilterSlotProps } from "./FacetPanelPane.js";
44
+ import { GeoSheet, SUMMARY_GEO_TEST_IDS } from "./geoSheet.js";
45
+
46
+ /** The row. Both ends are text buttons, so the row reads as a line of type
47
+ * rather than as two controls bolted onto a results page. */
48
+ const ROW: CSSProperties = { width: "100%" };
49
+
50
+ /** Each end shrinks before it wraps; the location is the half that may
51
+ * ellipsis, because a long place name must not push "Filters" off screen. */
52
+ const LOCATION: CSSProperties = {
53
+ minWidth: 0,
54
+ paddingInline: 0,
55
+ textAlign: "start",
56
+ };
57
+
58
+ export interface LocationSummaryLineProps {
59
+ /**
60
+ * The location control the sheet opens onto — the same slot the panel and
61
+ * the chip row take. Without it the sheet still opens (a location the URL
62
+ * already carries can be widened and cleared with no geocoder), and the
63
+ * absence is NAMED in development rather than left as a blank sheet.
64
+ */
65
+ readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
66
+ /**
67
+ * What the current place is CALLED. See the file header: this pair prints
68
+ * the name it is handed or admits it does not know one, never the numbers.
69
+ */
70
+ readonly geoLabel?: ReactNode;
71
+ /**
72
+ * Open the whole filter panel — the same contract `<FilterChips.onOpenAll>`
73
+ * states, and conventionally the same callback: the SURFACE owns that sheet
74
+ * because the surface is what it covers.
75
+ */
76
+ readonly onOpenAll: () => void;
77
+ }
78
+
79
+ /** A map pin in `currentColor` — the house convention: an inline monochrome
80
+ * SVG, no icon-font dependency, and it inherits the theme rather than carrying
81
+ * a colour. `aria-hidden` because the text beside it is the name. */
82
+ function PinGlyph(): ReactElement {
83
+ return (
84
+ <svg
85
+ width="16"
86
+ height="16"
87
+ viewBox="0 0 24 24"
88
+ fill="none"
89
+ stroke="currentColor"
90
+ strokeWidth="1.75"
91
+ strokeLinecap="round"
92
+ strokeLinejoin="round"
93
+ role="img"
94
+ aria-hidden="true"
95
+ >
96
+ <path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11z" />
97
+ <circle cx="12" cy="10" r="2.6" />
98
+ </svg>
99
+ );
100
+ }
101
+
102
+ export function LocationSummaryLine(
103
+ props: LocationSummaryLineProps
104
+ ): ReactElement {
105
+ const t = useT();
106
+ const { state, activeFilters } = useSearchState();
107
+ const [open, setOpen] = useState(false);
108
+ const geo = state.geo;
109
+
110
+ // The radius is a NUMBER this pair owns (`radius_km` in the URL), so unlike
111
+ // the place's name it can be stated without asking anyone. A bbox has no
112
+ // radius to state; a centre without one is "around here" and says nothing
113
+ // rather than inventing a default the server never applied.
114
+ const radius =
115
+ geo !== undefined && geo.kind === "center" && geo.radiusKm !== undefined
116
+ ? t(SEARCH_I18N_KEYS.geoRadiusKm, { km: geo.radiusKm })
117
+ : undefined;
118
+
119
+ const where: ReactNode =
120
+ geo === undefined
121
+ ? t(SEARCH_I18N_KEYS.geoEverywhere)
122
+ : (props.geoLabel ?? geoSummaryFallback(geo, t));
123
+
124
+ return (
125
+ <>
126
+ <Flex
127
+ align="center"
128
+ justify="space-between"
129
+ gap={spacing[2]}
130
+ style={ROW}
131
+ data-testid="search-location-summary"
132
+ data-geo={geo === undefined ? "off" : "on"}
133
+ >
134
+ <Button
135
+ type="link"
136
+ style={LOCATION}
137
+ icon={<PinGlyph />}
138
+ data-testid="search-location-open"
139
+ data-analytics="none"
140
+ data-analytics-reason="opening the location sheet is a read, not a flow step"
141
+ onClick={() => {
142
+ setOpen(true);
143
+ }}
144
+ >
145
+ {where}
146
+ {radius !== undefined && (
147
+ <span data-testid="search-location-radius">
148
+ {" · "}
149
+ {radius}
150
+ </span>
151
+ )}
152
+ </Button>
153
+
154
+ {/* The count, not a dot: this row has the width to say how many. */}
155
+ <Badge
156
+ count={activeFilters}
157
+ size="small"
158
+ data-testid="search-location-filters-badge"
159
+ >
160
+ <Button
161
+ type="link"
162
+ style={{ paddingInline: 0 }}
163
+ data-testid="search-location-filters"
164
+ data-active={activeFilters > 0 ? "true" : "false"}
165
+ data-analytics="none"
166
+ data-analytics-reason="opening the filter sheet is a read, not a flow step"
167
+ onClick={props.onOpenAll}
168
+ >
169
+ {t(SEARCH_I18N_KEYS.filtersAll)}
170
+ </Button>
171
+ </Badge>
172
+ </Flex>
173
+
174
+ <GeoSheet
175
+ open={open}
176
+ onClose={() => {
177
+ setOpen(false);
178
+ }}
179
+ testIds={SUMMARY_GEO_TEST_IDS}
180
+ {...(props.renderGeoFilter !== undefined
181
+ ? { renderGeoFilter: props.renderGeoFilter }
182
+ : {})}
183
+ {...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
184
+ />
185
+ </>
186
+ );
187
+ }
@@ -12,9 +12,9 @@
12
12
  * The seams a storefront fills: `renderCard` (a `<ListingCard>`),
13
13
  * `categoryFeatures` (from categories-react, for facet labels and range rows),
14
14
  * `renderCategoryFilter` / `renderGeoFilter` (controls other pairs own),
15
- * `footer` (the container's own chrome), `filtersHeader` and `resultsHeading`.
16
- * None of them is optional behaviour in disguise — every one has a working
17
- * default or a visible placeholder.
15
+ * `footer` (the container's own chrome), `filtersHeader`, `resultsHeader` and
16
+ * `resultsHeading`. None of them is optional behaviour in disguise — every one
17
+ * has a working default or a visible placeholder.
18
18
  *
19
19
  * ── The screen could not start a search ───────────────────────────────────
20
20
  *
@@ -185,6 +185,25 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
185
185
  * sense that matters and not a decoration bolted on top.
186
186
  */
187
187
  readonly filtersHeader?: ReactNode;
188
+ /**
189
+ * The row ABOVE the chips and the results — where `<LocationSummaryLine>`
190
+ * goes on the phone SERP.
191
+ *
192
+ * A NEW slot rather than a reuse, and the existing four were each checked
193
+ * first: `filtersHeader` is inside the filter panel (it is a filter, and on
194
+ * a phone it is behind the sheet, which is precisely where a location
195
+ * summary must NOT be); `breadcrumb` renders in the right place but names a
196
+ * walk up the CATEGORY tree, and a host wanting a trail AND a location row
197
+ * would have to choose; `resultsHeading` and the pane's `toolbar` are inside
198
+ * the results pane, below the chips. Nothing sat between "the search box"
199
+ * and "the filters", and that gap is exactly the ref's location row.
200
+ *
201
+ * It is rendered in the page's vertical stack, so it spans the full width in
202
+ * BOTH layouts — above the chip row on a phone, above the two columns on a
203
+ * desktop. That is deliberate: whatever states where a search is looking
204
+ * describes the whole page, not the results column of it.
205
+ */
206
+ readonly resultsHeader?: ReactNode;
188
207
  /** What this surface calls its result list. See
189
208
  * {@link SearchResultsPaneProps.heading}. */
190
209
  readonly resultsHeading?: ReactNode;
@@ -261,6 +280,7 @@ interface SearchPageBodyProps {
261
280
  readonly geoLabel?: ReactNode;
262
281
  readonly footer?: ReactNode;
263
282
  readonly filtersHeader?: ReactNode;
283
+ readonly resultsHeader?: ReactNode;
264
284
  readonly resultsHeading?: ReactNode;
265
285
  readonly degradationNotice?: DegradationNoticeVariant;
266
286
  readonly filtersLayout?: SearchFiltersLayout;
@@ -414,6 +434,14 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
414
434
  )}
415
435
  <UrlIssueNotice />
416
436
 
437
+ {/* Above the chips in the sheet layout and above the columns in the
438
+ other one — see {@link SearchPageProps.resultsHeader}. `?? null` is
439
+ the written decision, not an oversight: a page with nothing to say
440
+ about location says nothing rather than reserving a blank row. */}
441
+ {props.resultsHeader !== undefined && (
442
+ <div data-testid="search-results-header">{props.resultsHeader}</div>
443
+ )}
444
+
417
445
  {showFilters && layout === "sheet" ? (
418
446
  <>
419
447
  {/* The phone's filter row. It REPLACES the full-width "Filters (3)"
@@ -492,6 +520,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
492
520
  defaultGeo,
493
521
  footer,
494
522
  filtersHeader,
523
+ resultsHeader,
495
524
  resultsHeading,
496
525
  degradationNotice,
497
526
  filtersLayout,
@@ -521,6 +550,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
521
550
  {...(geoLabel !== undefined ? { geoLabel } : {})}
522
551
  {...(footer !== undefined ? { footer } : {})}
523
552
  {...(filtersHeader !== undefined ? { filtersHeader } : {})}
553
+ {...(resultsHeader !== undefined ? { resultsHeader } : {})}
524
554
  {...(resultsHeading !== undefined ? { resultsHeading } : {})}
525
555
  {...(degradationNotice !== undefined ? { degradationNotice } : {})}
526
556
  {...(filtersLayout !== undefined ? { filtersLayout } : {})}
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The LOCATION sheet — one implementation, two doors.
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.
10
+ *
11
+ * ── What the sheet can do without a geocoder, and what it cannot ───────────
12
+ *
13
+ * SETTING a centre needs a map and a place-name lookup, which belong to
14
+ * `geo-react` and to the deployment: that is `renderGeoFilter`, and when it is
15
+ * unfilled the gap is NAMED (`SlotPlaceholder`) rather than left as a blank
16
+ * area under the sheet's title. ADJUSTING or CLEARING a location the URL
17
+ * already carries needs neither — `lat`/`lon`/`radius_km` are numbers this
18
+ * pair owns — which is why the sheet still opens usefully on a shared link
19
+ * with no slot wired.
20
+ *
21
+ * NAMING the place is a third thing and it is the host's: this package holds
22
+ * two coordinates and has no way to turn them into "Berlin Mitte", so it
23
+ * prints the name it was handed (`geoLabel`) or says that a place is chosen —
24
+ * never the numbers. `test/geo.test.tsx` asserts no digit of the point reaches
25
+ * the DOM, on either surface.
26
+ */
27
+ import type { ReactElement, ReactNode } from "react";
28
+ import { Button, Flex, Typography } from "antd";
29
+ import { SkinDialog } from "@stapel/tokens-antd/skin";
30
+ import { SlotPlaceholder, useT, useTPlural } from "@stapel/core";
31
+ import { spacing } from "@stapel/tokens";
32
+ import { useAppliedCount } from "../headless/useAppliedCount.js";
33
+ import { useSearchState } from "../headless/SearchStateProvider.js";
34
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
35
+ import { geoSummaryFallback } from "./FacetPanelPane.js";
36
+ import type { GeoFilterSlotProps } from "./FacetPanelPane.js";
37
+
38
+ /** The test ids a surface gives its own copy of the sheet — the chip row and
39
+ * the summary line each keep the names their tests already know. */
40
+ export interface GeoSheetTestIds {
41
+ readonly sheet: string;
42
+ readonly apply: string;
43
+ readonly slot: string;
44
+ readonly summary: string;
45
+ readonly clear: string;
46
+ }
47
+
48
+ /** The chip row's door. These names predate the split and are what
49
+ * `test/geo.test.tsx` already asks for, so they travel with the sheet rather
50
+ * than being renamed by a refactor nobody asked to observe. */
51
+ export const CHIP_GEO_TEST_IDS: GeoSheetTestIds = {
52
+ sheet: "filter-chip-sheet-geo",
53
+ apply: "filter-chip-apply-geo",
54
+ slot: "search-chip-geo-slot",
55
+ summary: "search-chip-geo-summary",
56
+ clear: "search-chip-geo-clear",
57
+ };
58
+
59
+ /** The summary line's door — a different set, so a page holding both rows
60
+ * never hands a test two elements under one name. */
61
+ export const SUMMARY_GEO_TEST_IDS: GeoSheetTestIds = {
62
+ sheet: "search-location-sheet",
63
+ apply: "search-location-apply",
64
+ slot: "search-location-slot",
65
+ summary: "search-location-sheet-summary",
66
+ clear: "search-location-clear",
67
+ };
68
+
69
+ export interface GeoSheetProps {
70
+ readonly open: boolean;
71
+ readonly onClose: () => void;
72
+ readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
73
+ readonly geoLabel?: ReactNode;
74
+ readonly testIds: GeoSheetTestIds;
75
+ }
76
+
77
+ /**
78
+ * "Show 25 results", not "Show results".
79
+ *
80
+ * The results are BEHIND this sheet, so the button that closes it is the only
81
+ * place a person learns what their choice did. When the engine cannot say how
82
+ * many there are it says nothing rather than a number it made up.
83
+ */
84
+ export function useApplyLabel(): string {
85
+ const t = useT();
86
+ const tPlural = useTPlural();
87
+ const applied = useAppliedCount();
88
+ return applied.count === null || applied.kind === "unknown"
89
+ ? t(SEARCH_I18N_KEYS.filtersApply)
90
+ : tPlural(
91
+ applied.kind === "at_least"
92
+ ? SEARCH_I18N_KEYS.filtersShowCountAtLeast
93
+ : SEARCH_I18N_KEYS.filtersShowCount,
94
+ { count: applied.count }
95
+ );
96
+ }
97
+
98
+ export function GeoSheet(props: GeoSheetProps): ReactElement {
99
+ const t = useT();
100
+ const { state, setGeo } = useSearchState();
101
+ const applyLabel = useApplyLabel();
102
+ const geo = state.geo;
103
+ const summary: ReactNode =
104
+ geo === undefined
105
+ ? null
106
+ : (props.geoLabel ?? geoSummaryFallback(geo, t));
107
+
108
+ return (
109
+ <SkinDialog
110
+ open={props.open}
111
+ onClose={props.onClose}
112
+ title={t(SEARCH_I18N_KEYS.geoTitle)}
113
+ dismissLabel={t(SEARCH_I18N_KEYS.filtersDismiss)}
114
+ data-testid={props.testIds.sheet}
115
+ footer={
116
+ <Button
117
+ block
118
+ type="primary"
119
+ data-testid={props.testIds.apply}
120
+ data-analytics="none"
121
+ data-analytics-reason="the filter is already applied; this closes the sheet"
122
+ onClick={props.onClose}
123
+ >
124
+ {applyLabel}
125
+ </Button>
126
+ }
127
+ >
128
+ <Flex vertical gap={spacing[3]}>
129
+ {props.renderGeoFilter?.({
130
+ value: geo,
131
+ onChange: (next) => {
132
+ setGeo(next);
133
+ },
134
+ }) ?? (
135
+ <SlotPlaceholder
136
+ name="renderGeoFilter"
137
+ data-testid={props.testIds.slot}
138
+ />
139
+ )}
140
+ {geo !== undefined && (
141
+ <>
142
+ <Typography.Text type="secondary" data-testid={props.testIds.summary}>
143
+ {summary}
144
+ </Typography.Text>
145
+ <Button
146
+ style={{ alignSelf: "flex-start" }}
147
+ data-testid={props.testIds.clear}
148
+ data-analytics="none"
149
+ data-analytics-reason="a filter is a read, not a flow step"
150
+ onClick={() => {
151
+ setGeo(null);
152
+ }}
153
+ >
154
+ {t(SEARCH_I18N_KEYS.geoClear)}
155
+ </Button>
156
+ </>
157
+ )}
158
+ </Flex>
159
+ </SkinDialog>
160
+ );
161
+ }
@@ -51,6 +51,9 @@ export type {
51
51
  export { FilterChips, CHIP_ROW_CLASS, CHIP_ROW_STYLE_HREF, chipRowCss } from "./FilterChips.js";
52
52
  export type { FilterChipsProps } from "./FilterChips.js";
53
53
 
54
+ export { LocationSummaryLine } from "./LocationSummaryLine.js";
55
+ export type { LocationSummaryLineProps } from "./LocationSummaryLine.js";
56
+
54
57
  export {
55
58
  FacetGroupControl,
56
59
  facetGroupShape,