@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.
- package/CHANGELOG.md +34 -0
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +8 -23
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts +24 -0
- package/dist/default/LocationSummaryLine.d.ts.map +1 -0
- package/dist/default/LocationSummaryLine.js +83 -0
- package/dist/default/LocationSummaryLine.js.map +1 -0
- package/dist/default/SearchPage.d.ts +19 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +6 -6
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/geoSheet.d.ts +61 -0
- package/dist/default/geoSheet.d.ts.map +1 -0
- package/dist/default/geoSheet.js +63 -0
- package/dist/default/geoSheet.js.map +1 -0
- package/dist/default/index.d.ts +2 -0
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -0
- package/dist/default/index.js.map +1 -1
- package/dist/headless/useSearchCount.d.ts +44 -0
- package/dist/headless/useSearchCount.d.ts.map +1 -0
- package/dist/headless/useSearchCount.js +121 -0
- package/dist/headless/useSearchCount.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +1 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +10 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +11 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +1 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/state/limits.d.ts +21 -0
- package/dist/state/limits.d.ts.map +1 -1
- package/dist/state/limits.js +21 -0
- package/dist/state/limits.js.map +1 -1
- package/llms.txt +2 -1
- package/manifest.json +30 -2
- package/nav-manifest.json +1 -1
- package/package.json +4 -4
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FilterChips.tsx +17 -56
- package/src/default/LocationSummaryLine.tsx +187 -0
- package/src/default/SearchPage.tsx +33 -3
- package/src/default/geoSheet.tsx +161 -0
- package/src/default/index.ts +3 -0
- package/src/headless/useSearchCount.ts +189 -0
- package/src/i18n/es.ts +1 -0
- package/src/i18n/keys.ts +11 -0
- package/src/i18n/ru.ts +1 -0
- package/src/index.ts +8 -0
- 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
|
|
52
|
+
import { Button } from "antd";
|
|
53
53
|
import { SkinDialog, useDialogSurface } from "@stapel/tokens-antd/skin";
|
|
54
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
{
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
|
16
|
-
* None of them is optional behaviour in disguise — every one
|
|
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
|
+
}
|
package/src/default/index.ts
CHANGED
|
@@ -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,
|