@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.
- package/CHANGELOG.md +125 -0
- package/dist/api/generated/schema.d.ts +96 -4
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +47 -6
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +0 -19
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +45 -62
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +1 -24
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +4 -18
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts +12 -0
- package/dist/default/LocationSummaryLine.d.ts.map +1 -1
- package/dist/default/LocationSummaryLine.js +80 -6
- package/dist/default/LocationSummaryLine.js.map +1 -1
- package/dist/default/SearchPage.d.ts +6 -5
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +31 -21
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/geoSheet.d.ts +12 -6
- package/dist/default/geoSheet.d.ts.map +1 -1
- package/dist/default/geoSheet.js +14 -3
- package/dist/default/geoSheet.js.map +1 -1
- package/dist/default/index.d.ts +4 -3
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +4 -3
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +29 -1
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +14 -0
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +57 -30
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +51 -36
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +7 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +43 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +51 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +9 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/state/degradations.d.ts +9 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +13 -0
- package/dist/state/degradations.js.map +1 -1
- package/dist/state/facets.d.ts +3 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +48 -1
- package/dist/state/facets.js.map +1 -1
- package/dist/state/urlState.d.ts +46 -3
- package/dist/state/urlState.d.ts.map +1 -1
- package/dist/state/urlState.js +69 -22
- package/dist/state/urlState.js.map +1 -1
- package/llms.txt +2 -2
- package/manifest.json +13 -2
- package/nav-manifest.json +1 -1
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +96 -4
- package/src/api/types.ts +51 -5
- package/src/default/FacetPanelPane.tsx +52 -126
- package/src/default/FilterChips.tsx +5 -70
- package/src/default/LocationSummaryLine.tsx +157 -32
- package/src/default/SearchPage.tsx +57 -31
- package/src/default/geoSheet.tsx +46 -7
- package/src/default/index.ts +4 -3
- package/src/headless/FacetPanel.tsx +49 -1
- package/src/headless/SearchStateProvider.tsx +113 -66
- package/src/i18n/es.ts +11 -0
- package/src/i18n/keys.ts +55 -0
- package/src/i18n/ru.ts +15 -0
- package/src/index.ts +3 -0
- package/src/state/degradations.ts +14 -0
- package/src/state/facets.ts +47 -1
- 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:
|
|
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 } =
|
|
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 ??
|
|
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
|
-
{/*
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
{
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
* `
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* `lat` and a `lon` in its query string and no way on earth to
|
|
42
|
-
* "Berlin Mitte", which is exactly why it must not print them.
|
|
43
|
-
* package that grew a geocoder to say a nicer sentence would have
|
|
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
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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}
|
|
694
|
+
<SearchStateProvider adapter={adapter} geoOffer={geoOffer} {...parseOptions}>
|
|
669
695
|
<SearchPageBody
|
|
670
696
|
{...(renderCard !== undefined ? { renderCard } : {})}
|
|
671
697
|
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
package/src/default/geoSheet.tsx
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The LOCATION sheet — one implementation, two doors.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
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}
|
package/src/default/index.ts
CHANGED
|
@@ -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 `
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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 {
|
|
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,
|