@stapel/search-react 0.19.0 → 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.
- package/CHANGELOG.md +37 -0
- package/dist/default/FacetPanelPane.d.ts +0 -19
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +18 -61
- 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 +9 -0
- package/dist/default/LocationSummaryLine.d.ts.map +1 -1
- package/dist/default/LocationSummaryLine.js +29 -2
- 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 +26 -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/SearchStateProvider.d.ts +42 -30
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +16 -36
- package/dist/headless/SearchStateProvider.js.map +1 -1
- 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 +1 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +2 -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/state/urlState.d.ts +20 -2
- package/dist/state/urlState.d.ts.map +1 -1
- package/dist/state/urlState.js +26 -5
- package/dist/state/urlState.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +2 -1
- package/nav-manifest.json +1 -1
- package/package.json +2 -2
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FacetPanelPane.tsx +16 -125
- package/src/default/FilterChips.tsx +5 -70
- package/src/default/LocationSummaryLine.tsx +65 -1
- package/src/default/SearchPage.tsx +52 -31
- package/src/default/geoSheet.tsx +46 -7
- package/src/default/index.ts +4 -3
- package/src/headless/SearchStateProvider.tsx +57 -66
- package/src/i18n/es.ts +1 -0
- package/src/i18n/keys.ts +2 -0
- package/src/i18n/ru.ts +1 -0
- 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
|
-
* `
|
|
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 } : {}),
|
|
@@ -429,7 +431,6 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
429
431
|
state.category === undefined &&
|
|
430
432
|
state.lang === undefined &&
|
|
431
433
|
props.renderCategoryFilter === undefined &&
|
|
432
|
-
props.renderGeoFilter === undefined &&
|
|
433
434
|
(props.languages ?? []).length === 0;
|
|
434
435
|
const showFilters = filtersHeader !== undefined || !filtersEmpty;
|
|
435
436
|
|
|
@@ -460,10 +461,6 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
460
461
|
{...(props.renderCategoryFilter !== undefined
|
|
461
462
|
? { renderCategoryFilter: props.renderCategoryFilter }
|
|
462
463
|
: {})}
|
|
463
|
-
{...(props.renderGeoFilter !== undefined
|
|
464
|
-
? { renderGeoFilter: props.renderGeoFilter }
|
|
465
|
-
: {})}
|
|
466
|
-
{...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
|
|
467
464
|
{...(props.skippedNotice !== undefined
|
|
468
465
|
? { skippedNotice: props.skippedNotice }
|
|
469
466
|
: {})}
|
|
@@ -543,6 +540,38 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
543
540
|
)}
|
|
544
541
|
<UrlIssueNotice />
|
|
545
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
|
+
|
|
546
575
|
{/* Above the chips in the sheet layout and above the columns in the
|
|
547
576
|
other one — see {@link SearchPageProps.resultsHeader}. `?? null` is
|
|
548
577
|
the written decision, not an oversight: a page with nothing to say
|
|
@@ -575,14 +604,6 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
575
604
|
{...(props.categoryLabel !== undefined
|
|
576
605
|
? { categoryLabel: props.categoryLabel }
|
|
577
606
|
: {})}
|
|
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
607
|
/>
|
|
587
608
|
<SkinDialog
|
|
588
609
|
open={sheetOpen}
|
|
@@ -643,7 +664,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
643
664
|
renderGeoFilter,
|
|
644
665
|
geoLabel,
|
|
645
666
|
skippedNotice,
|
|
646
|
-
|
|
667
|
+
geoOffer,
|
|
647
668
|
footer,
|
|
648
669
|
filtersHeader,
|
|
649
670
|
resultsHeader,
|
|
@@ -665,7 +686,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
665
686
|
|
|
666
687
|
return (
|
|
667
688
|
<SkinTheme surface="base" {...(mode !== undefined ? { mode } : {})}>
|
|
668
|
-
<SearchStateProvider adapter={adapter}
|
|
689
|
+
<SearchStateProvider adapter={adapter} geoOffer={geoOffer} {...parseOptions}>
|
|
669
690
|
<SearchPageBody
|
|
670
691
|
{...(renderCard !== undefined ? { renderCard } : {})}
|
|
671
692
|
{...(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,7 @@ import {
|
|
|
2
2
|
createContext,
|
|
3
3
|
useCallback,
|
|
4
4
|
useContext,
|
|
5
|
-
useEffect,
|
|
6
5
|
useMemo,
|
|
7
|
-
useRef,
|
|
8
6
|
} from "react";
|
|
9
7
|
import type { ReactElement, ReactNode } from "react";
|
|
10
8
|
import type {
|
|
@@ -57,8 +55,22 @@ export interface SearchStateBag {
|
|
|
57
55
|
readonly state: SearchQueryState;
|
|
58
56
|
/** What the URL carried that could not be read (see `SearchStateIssue`). */
|
|
59
57
|
readonly issues: readonly SearchStateIssue[];
|
|
60
|
-
/** Facet values + ranges + geo the person has applied.
|
|
58
|
+
/** Facet values + ranges + geo the person has applied.
|
|
59
|
+
*
|
|
60
|
+
* Everything counted here was chosen BY the person: this pair applies no
|
|
61
|
+
* filter of its own, so the number beside "clear all" can never be one the
|
|
62
|
+
* reader has to go hunting for.
|
|
63
|
+
*/
|
|
61
64
|
readonly activeFilters: number;
|
|
65
|
+
/**
|
|
66
|
+
* A location the host can narrow to, which nobody has accepted yet —
|
|
67
|
+
* `undefined` when the host offers none, or when the search already carries
|
|
68
|
+
* a location of its own (an offer would then be a second answer to a
|
|
69
|
+
* question already answered).
|
|
70
|
+
*
|
|
71
|
+
* Draw it as an invitation ("near me"), never as state.
|
|
72
|
+
*/
|
|
73
|
+
readonly geoOffer: SearchGeo | undefined;
|
|
62
74
|
|
|
63
75
|
setText(q: string): void;
|
|
64
76
|
setSort(sort: string | null): void;
|
|
@@ -72,6 +84,9 @@ export interface SearchStateBag {
|
|
|
72
84
|
clearAll(): void;
|
|
73
85
|
/** Move to a keyset page. The ONLY mutator that keeps a cursor. */
|
|
74
86
|
goToAnchor(anchor: string | null, direction: "next" | "prev"): void;
|
|
87
|
+
/** Accept {@link geoOffer}. A no-op when there is nothing on offer, so a
|
|
88
|
+
* control can be pressed without first re-checking what this bag says. */
|
|
89
|
+
acceptGeoOffer(): void;
|
|
75
90
|
/** Escape hatch for a host control this pair does not ship. Goes through
|
|
76
91
|
* `patchSearchState`, so it drops the cursor like every other change. */
|
|
77
92
|
patch(patch: SearchStatePatch): void;
|
|
@@ -82,40 +97,35 @@ const StateContext = createContext<SearchStateBag | null>(null);
|
|
|
82
97
|
export interface SearchStateProviderProps extends ParseSearchStateOptions {
|
|
83
98
|
readonly adapter: SearchParamsAdapter;
|
|
84
99
|
/**
|
|
85
|
-
*
|
|
86
|
-
* the visitor's own position, as the HOST resolved it (a granted browser
|
|
87
|
-
* geolocation prompt, or the server's IP guess when there was none).
|
|
100
|
+
* A location this search COULD be narrowed to — offered, never applied.
|
|
88
101
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* a centre that lived only in the parse would vanish from a link the visitor
|
|
94
|
-
* shares, and — worse — would be re-applied on the very next render after
|
|
95
|
-
* they cleared it, which is a filter that will not come off.
|
|
102
|
+
* The host resolves it (a granted browser prompt, the server's IP guess);
|
|
103
|
+
* this provider does nothing with it except hand it back on the bag as
|
|
104
|
+
* {@link SearchStateBag.geoOffer}, so a control can draw "near me" and the
|
|
105
|
+
* PERSON decides. Nothing is written to the URL until they press it.
|
|
96
106
|
*
|
|
97
|
-
*
|
|
107
|
+
* ## Why this is an offer and not a default (defect: a silent radius)
|
|
98
108
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* after the first paint, so `undefined` now and a value three renders
|
|
110
|
-
* later still applies — provided the URL is still empty of geo at that
|
|
111
|
-
* moment.
|
|
109
|
+
* The previous shape of this prop (`defaultGeo`) wrote the visitor's
|
|
110
|
+
* position into the query string on the first render that could, under four
|
|
111
|
+
* careful rules about not overruling a person. Every rule held, and the
|
|
112
|
+
* result was still a filter nobody asked for: a browser permission granted
|
|
113
|
+
* once, for one map, became a permanent 25 km wall around every category
|
|
114
|
+
* leaf and every result page in the deployment. Measured on a live board:
|
|
115
|
+
* 48 phones became 17, and three leaves with stock became "nothing found".
|
|
116
|
+
* The page looks perfectly healthy while doing it, which is what makes it
|
|
117
|
+
* dangerous — the honest reading of an empty leaf is "this board is empty",
|
|
118
|
+
* not "your browser told us where you are".
|
|
112
119
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* they
|
|
116
|
-
*
|
|
120
|
+
* There is no set of rules that fixes that, because the defect is not in
|
|
121
|
+
* the rules: applying a spatial filter is a decision about what the person
|
|
122
|
+
* wants to see, and only they hold it. So the prop carries the same value
|
|
123
|
+
* and the provider no longer commits it. Two more defects fall out with it:
|
|
124
|
+
* the URL is never rewritten behind the visitor (a hand-typed `radius_km`
|
|
125
|
+
* survives), and the results are fetched ONCE instead of being fetched and
|
|
126
|
+
* immediately superseded by a second, narrower query.
|
|
117
127
|
*/
|
|
118
|
-
readonly
|
|
128
|
+
readonly geoOffer?: SearchGeo | undefined;
|
|
119
129
|
readonly children: ReactNode;
|
|
120
130
|
}
|
|
121
131
|
|
|
@@ -128,7 +138,7 @@ export interface SearchStateProviderProps extends ParseSearchStateOptions {
|
|
|
128
138
|
export function SearchStateProvider(
|
|
129
139
|
props: SearchStateProviderProps
|
|
130
140
|
): ReactElement {
|
|
131
|
-
const { adapter, children,
|
|
141
|
+
const { adapter, children, geoOffer, ...parseOptions } = props;
|
|
132
142
|
const { params, setParams } = adapter;
|
|
133
143
|
|
|
134
144
|
// The parse options are spread into a stable dependency: a host that builds
|
|
@@ -165,16 +175,6 @@ export function SearchStateProvider(
|
|
|
165
175
|
[setParams, search]
|
|
166
176
|
);
|
|
167
177
|
|
|
168
|
-
/**
|
|
169
|
-
* Has anything at all said where to search yet?
|
|
170
|
-
*
|
|
171
|
-
* `true` once the URL was seen carrying a location, once `defaultGeo` has
|
|
172
|
-
* been applied, or once anybody called `setGeo` — including with `null`.
|
|
173
|
-
* That last one is the whole reason this is a ref and not a comparison: a
|
|
174
|
-
* cleared location and an unapplied default look identical in the state, and
|
|
175
|
-
* only the record of who spoke tells them apart.
|
|
176
|
-
*/
|
|
177
|
-
const geoSettled = useRef(false);
|
|
178
178
|
|
|
179
179
|
const bag = useMemo<SearchStateBag>(() => {
|
|
180
180
|
const state = parsed.state;
|
|
@@ -188,6 +188,12 @@ export function SearchStateProvider(
|
|
|
188
188
|
state,
|
|
189
189
|
issues: parsed.issues,
|
|
190
190
|
activeFilters: activeFilterCount(state),
|
|
191
|
+
// An offer stands only while the question is open. Once the search
|
|
192
|
+
// carries a place — from a link, or because somebody pressed the offer
|
|
193
|
+
// — there is nothing left to offer, and a control that kept drawing
|
|
194
|
+
// "near me" beside an applied location would be inviting a person to
|
|
195
|
+
// re-answer a question they can already see the answer to.
|
|
196
|
+
geoOffer: state.geo === undefined ? geoOffer : undefined,
|
|
191
197
|
|
|
192
198
|
// Typing replaces rather than pushes: one history entry per letter
|
|
193
199
|
// would make Back useless, which is the control the spec's acceptance
|
|
@@ -200,12 +206,14 @@ export function SearchStateProvider(
|
|
|
200
206
|
setFilter: (slug, values) => apply(setFilterValues(state, slug, values)),
|
|
201
207
|
setRange: (slug, range) => apply(setRangeValue(state, slug, range)),
|
|
202
208
|
setGeo: (geo) => {
|
|
203
|
-
// The person has now answered the location question themselves —
|
|
204
|
-
// including by answering "anywhere". `defaultGeo` does not get to ask
|
|
205
|
-
// it again.
|
|
206
|
-
geoSettled.current = true;
|
|
207
209
|
apply(patchSearchState(state, { geo }));
|
|
208
210
|
},
|
|
211
|
+
acceptGeoOffer: () => {
|
|
212
|
+
if (geoOffer === undefined || state.geo !== undefined) return;
|
|
213
|
+
// A PUSH, like any other filter the person applies: Back takes the
|
|
214
|
+
// narrowing off again, which is the same promise every chip makes.
|
|
215
|
+
apply(patchSearchState(state, { geo: geoOffer }));
|
|
216
|
+
},
|
|
209
217
|
// A page size is a preference, not a step through the results.
|
|
210
218
|
setLimit: (limit) => apply(patchSearchState(state, { limit }), { replace: true }),
|
|
211
219
|
clearAll: () => apply(clearFilters(state)),
|
|
@@ -213,25 +221,8 @@ export function SearchStateProvider(
|
|
|
213
221
|
apply(patchSearchState(state, { anchor, direction })),
|
|
214
222
|
patch: (patch) => apply(patchSearchState(state, patch)),
|
|
215
223
|
};
|
|
216
|
-
}, [parsed, commit]);
|
|
217
|
-
|
|
218
|
-
// An effect, not a render-time write: this puts a value in the URL, and a
|
|
219
|
-
// router asked to navigate during a render is a warning at best and a loop
|
|
220
|
-
// at worst. It runs after every commit, which is what makes a `defaultGeo`
|
|
221
|
-
// that arrives on the fourth render — the browser prompt finally answered —
|
|
222
|
-
// land as reliably as one present on the first.
|
|
223
|
-
useEffect(() => {
|
|
224
|
-
if (geoSettled.current) return;
|
|
225
|
-
if (parsed.state.geo !== undefined) {
|
|
226
|
-
// The link brought its own place. That settles the question for this
|
|
227
|
-
// visit even if the host is still resolving one of its own.
|
|
228
|
-
geoSettled.current = true;
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
if (defaultGeo === undefined) return;
|
|
232
|
-
geoSettled.current = true;
|
|
233
|
-
commit(patchSearchState(parsed.state, { geo: defaultGeo }), { replace: true });
|
|
234
|
-
}, [defaultGeo, parsed.state, commit]);
|
|
224
|
+
}, [parsed, commit, geoOffer]);
|
|
225
|
+
|
|
235
226
|
|
|
236
227
|
return <StateContext.Provider value={bag}>{children}</StateContext.Provider>;
|
|
237
228
|
}
|
package/src/i18n/es.ts
CHANGED
|
@@ -127,6 +127,7 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
127
127
|
"search.geo.radius_km": "A menos de {km} km",
|
|
128
128
|
"search.geo.radius_label": "Radio, km",
|
|
129
129
|
"search.geo.clear": "En cualquier lugar",
|
|
130
|
+
"search.geo.near_me": "Cerca de mí",
|
|
130
131
|
"search.geo.everywhere": "Buscando en todas partes",
|
|
131
132
|
"search.geo.box": "Dentro del área mostrada",
|
|
132
133
|
"search.geo.chosen_place": "Un lugar elegido en el mapa",
|
package/src/i18n/keys.ts
CHANGED
|
@@ -219,6 +219,7 @@ export const SEARCH_I18N_KEYS = {
|
|
|
219
219
|
geoRadiusKm: "search.geo.radius_km",
|
|
220
220
|
geoRadiusLabel: "search.geo.radius_label",
|
|
221
221
|
geoClear: "search.geo.clear",
|
|
222
|
+
geoNearMe: "search.geo.near_me",
|
|
222
223
|
/**
|
|
223
224
|
* What `<LocationSummaryLine>` says when NO location is applied.
|
|
224
225
|
*
|
|
@@ -449,6 +450,7 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
449
450
|
"search.limit.from_link": "This link sets its own page size.",
|
|
450
451
|
|
|
451
452
|
"search.geo.title": "Location",
|
|
453
|
+
"search.geo.near_me": "Near me",
|
|
452
454
|
"search.geo.radius_km": "Within {km} km",
|
|
453
455
|
"search.geo.radius_label": "Radius, km",
|
|
454
456
|
"search.geo.clear": "Anywhere",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -158,6 +158,7 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
158
158
|
"search.geo.radius_km": "В радиусе {km} км",
|
|
159
159
|
"search.geo.radius_label": "Радиус, км",
|
|
160
160
|
"search.geo.clear": "Везде",
|
|
161
|
+
"search.geo.near_me": "Рядом со мной",
|
|
161
162
|
"search.geo.everywhere": "Ищем везде",
|
|
162
163
|
"search.geo.box": "В показанной области",
|
|
163
164
|
"search.geo.chosen_place": "Выбранное место на карте",
|