@stapel/search-react 0.18.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/dist/default/FacetPanelPane.d.ts +0 -19
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +33 -65
- 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 +11 -20
- 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 +14 -5
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +27 -22
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +37 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +7 -2
- package/dist/default/SearchResultsPane.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 +5 -4
- 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/facets.d.ts +18 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +25 -0
- package/dist/state/facets.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 +3 -3
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FacetPanelPane.tsx +72 -136
- package/src/default/FilterChips.tsx +12 -72
- package/src/default/LocationSummaryLine.tsx +65 -1
- package/src/default/SearchPage.tsx +64 -31
- package/src/default/SearchResultsPane.tsx +54 -7
- package/src/default/geoSheet.tsx +46 -7
- package/src/default/index.ts +5 -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/facets.ts +28 -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,9 +83,11 @@ import type {
|
|
|
83
83
|
GeoFilterSlotProps,
|
|
84
84
|
} from "./FacetPanelPane.js";
|
|
85
85
|
import { FilterChips } from "./FilterChips.js";
|
|
86
|
+
import { LocationSummaryLine } from "./LocationSummaryLine.js";
|
|
86
87
|
import { PageSizeSelect } from "./PageSizeSelect.js";
|
|
87
88
|
import { SearchBox } from "./SearchBox.js";
|
|
88
89
|
import { SearchResultsPane } from "./SearchResultsPane.js";
|
|
90
|
+
import type { SearchResultsWrapper } from "./SearchResultsPane.js";
|
|
89
91
|
import { SortSelect } from "./SortSelect.js";
|
|
90
92
|
import { SEARCH_BUILTIN_VIEWS, ViewSwitch, resolveView } from "./ViewSwitch.js";
|
|
91
93
|
import type { SearchView } from "./ViewSwitch.js";
|
|
@@ -210,16 +212,17 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
210
212
|
* Default `false` — see {@link FacetPanelPaneProps.skippedNotice}. */
|
|
211
213
|
readonly skippedNotice?: boolean;
|
|
212
214
|
/**
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
215
|
+
* A location this search could be narrowed to, OFFERED to the visitor — see
|
|
216
|
+
* {@link SearchStateProviderProps.geoOffer} for why it is an offer and not a
|
|
217
|
+
* default, and for the defect that distinction closes.
|
|
216
218
|
*
|
|
217
219
|
* The host resolves the position; this page does not know what a map or a
|
|
218
220
|
* geocoder is and must not learn. A browser prompt (`usePermission`
|
|
219
221
|
* + `geolocation`) or the server's IP guess both arrive here as the same two
|
|
220
|
-
* numbers
|
|
222
|
+
* numbers. Nothing is applied until the person presses the offer, and
|
|
223
|
+
* `geoOfferLabel` is what the button calls the place.
|
|
221
224
|
*/
|
|
222
|
-
readonly
|
|
225
|
+
readonly geoOffer?: SearchGeo | undefined;
|
|
223
226
|
/** Container chrome under the results — e.g. the ranking-disclosure link. */
|
|
224
227
|
readonly footer?: ReactNode;
|
|
225
228
|
/**
|
|
@@ -292,6 +295,13 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
292
295
|
* describes rather than over the whole two-column page.
|
|
293
296
|
*/
|
|
294
297
|
readonly breadcrumb?: ReactNode;
|
|
298
|
+
/**
|
|
299
|
+
* Wrap the results the page drew — a provider, an observer, an analytics
|
|
300
|
+
* boundary — without taking over the arrangement. Handed straight to
|
|
301
|
+
* `<SearchResultsPane wrapResults>`; see {@link SearchResultsWrapper} for
|
|
302
|
+
* why a host cannot do this with `views`/`renderResults` alone.
|
|
303
|
+
*/
|
|
304
|
+
readonly wrapResults?: SearchResultsWrapper;
|
|
295
305
|
/**
|
|
296
306
|
* The arrangements the view switch offers. Default: the pair's own list and
|
|
297
307
|
* grid. A deployment adds its own — `{ id: "map", labelKey, icon, render }`
|
|
@@ -345,6 +355,7 @@ interface SearchPageBodyProps {
|
|
|
345
355
|
readonly defaultFiltersOpen?: boolean;
|
|
346
356
|
readonly pageSize?: boolean;
|
|
347
357
|
readonly breadcrumb?: ReactNode;
|
|
358
|
+
readonly wrapResults?: SearchResultsWrapper;
|
|
348
359
|
readonly views?: readonly SearchView[];
|
|
349
360
|
readonly defaultView?: string;
|
|
350
361
|
readonly onViewChange?: (id: string) => void;
|
|
@@ -360,7 +371,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
360
371
|
const t = useT();
|
|
361
372
|
const tPlural = useTPlural();
|
|
362
373
|
const { categoryFeatures, locale, resolveFacetLabels, filtersHeader } = props;
|
|
363
|
-
const { state } = useSearchState();
|
|
374
|
+
const { state, geoOffer } = useSearchState();
|
|
364
375
|
const facets = useFacetPanel({
|
|
365
376
|
...(categoryFeatures !== undefined ? { categoryFeatures } : {}),
|
|
366
377
|
...(locale !== undefined ? { locale } : {}),
|
|
@@ -420,7 +431,6 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
420
431
|
state.category === undefined &&
|
|
421
432
|
state.lang === undefined &&
|
|
422
433
|
props.renderCategoryFilter === undefined &&
|
|
423
|
-
props.renderGeoFilter === undefined &&
|
|
424
434
|
(props.languages ?? []).length === 0;
|
|
425
435
|
const showFilters = filtersHeader !== undefined || !filtersEmpty;
|
|
426
436
|
|
|
@@ -451,10 +461,6 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
451
461
|
{...(props.renderCategoryFilter !== undefined
|
|
452
462
|
? { renderCategoryFilter: props.renderCategoryFilter }
|
|
453
463
|
: {})}
|
|
454
|
-
{...(props.renderGeoFilter !== undefined
|
|
455
|
-
? { renderGeoFilter: props.renderGeoFilter }
|
|
456
|
-
: {})}
|
|
457
|
-
{...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
|
|
458
464
|
{...(props.skippedNotice !== undefined
|
|
459
465
|
? { skippedNotice: props.skippedNotice }
|
|
460
466
|
: {})}
|
|
@@ -503,6 +509,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
503
509
|
{...(phoneToolbar ? { header: "compact" as const } : {})}
|
|
504
510
|
headingLevel={props.resultsHeadingLevel ?? 1}
|
|
505
511
|
{...(view.render !== undefined ? { renderResults: view.render } : {})}
|
|
512
|
+
{...(props.wrapResults !== undefined ? { wrapResults: props.wrapResults } : {})}
|
|
506
513
|
{...(view.layout !== undefined ? { layout: view.layout } : {})}
|
|
507
514
|
{...(props.renderCard !== undefined ? { renderCard: props.renderCard } : {})}
|
|
508
515
|
{...(props.footer !== undefined ? { footer: props.footer } : {})}
|
|
@@ -533,6 +540,38 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
533
540
|
)}
|
|
534
541
|
<UrlIssueNotice />
|
|
535
542
|
|
|
543
|
+
{/* THE LOCATION CONTROL — a place, its radius, and the way to change
|
|
544
|
+
either. Mounted by the PAGE, not by each host, which is how a
|
|
545
|
+
category results page came to have no way to say where it was
|
|
546
|
+
looking while `/s` had one: the row was a slot every surface had to
|
|
547
|
+
remember to fill, and one of them did not.
|
|
548
|
+
|
|
549
|
+
Drawn when this deployment can resolve a place at all, or when the
|
|
550
|
+
address already carries one (a shared link must always be widenable
|
|
551
|
+
by whoever opens it). It is not a filter and it is not in the filter
|
|
552
|
+
count — see `activeFilterCount`.
|
|
553
|
+
|
|
554
|
+
Three reasons to draw it, and an offer is one of them: a deployment
|
|
555
|
+
that can place its visitor but ships no place picker still needs
|
|
556
|
+
somewhere for them to accept — otherwise the offer is a value with
|
|
557
|
+
no control, which is the same silence this pack is about. */}
|
|
558
|
+
{(props.renderGeoFilter !== undefined ||
|
|
559
|
+
state.geo !== undefined ||
|
|
560
|
+
geoOffer !== undefined) && (
|
|
561
|
+
<LocationSummaryLine
|
|
562
|
+
{...(props.renderGeoFilter !== undefined
|
|
563
|
+
? { renderGeoFilter: props.renderGeoFilter }
|
|
564
|
+
: {})}
|
|
565
|
+
{...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
|
|
566
|
+
// No door where the room is already open: the desktop column layout
|
|
567
|
+
// has the whole panel on screen beside this row.
|
|
568
|
+
filtersDoor={layout === "sheet"}
|
|
569
|
+
onOpenAll={() => {
|
|
570
|
+
setSheetOpen(true);
|
|
571
|
+
}}
|
|
572
|
+
/>
|
|
573
|
+
)}
|
|
574
|
+
|
|
536
575
|
{/* Above the chips in the sheet layout and above the columns in the
|
|
537
576
|
other one — see {@link SearchPageProps.resultsHeader}. `?? null` is
|
|
538
577
|
the written decision, not an oversight: a page with nothing to say
|
|
@@ -565,14 +604,6 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
565
604
|
{...(props.categoryLabel !== undefined
|
|
566
605
|
? { categoryLabel: props.categoryLabel }
|
|
567
606
|
: {})}
|
|
568
|
-
{...(props.renderGeoFilter !== undefined
|
|
569
|
-
? { renderGeoFilter: props.renderGeoFilter }
|
|
570
|
-
: {})}
|
|
571
|
-
{...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
|
|
572
|
-
/* The row above already states the location and opens its own
|
|
573
|
-
sheet, so the chip would be the second control over one filter
|
|
574
|
-
— see `FilterChipsProps.geoChip`. */
|
|
575
|
-
{...(props.resultsHeader !== undefined ? { geoChip: false } : {})}
|
|
576
607
|
/>
|
|
577
608
|
<SkinDialog
|
|
578
609
|
open={sheetOpen}
|
|
@@ -633,7 +664,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
633
664
|
renderGeoFilter,
|
|
634
665
|
geoLabel,
|
|
635
666
|
skippedNotice,
|
|
636
|
-
|
|
667
|
+
geoOffer,
|
|
637
668
|
footer,
|
|
638
669
|
filtersHeader,
|
|
639
670
|
resultsHeader,
|
|
@@ -643,6 +674,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
643
674
|
defaultFiltersOpen,
|
|
644
675
|
pageSize,
|
|
645
676
|
breadcrumb,
|
|
677
|
+
wrapResults,
|
|
646
678
|
views,
|
|
647
679
|
defaultView,
|
|
648
680
|
onViewChange,
|
|
@@ -654,7 +686,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
654
686
|
|
|
655
687
|
return (
|
|
656
688
|
<SkinTheme surface="base" {...(mode !== undefined ? { mode } : {})}>
|
|
657
|
-
<SearchStateProvider adapter={adapter}
|
|
689
|
+
<SearchStateProvider adapter={adapter} geoOffer={geoOffer} {...parseOptions}>
|
|
658
690
|
<SearchPageBody
|
|
659
691
|
{...(renderCard !== undefined ? { renderCard } : {})}
|
|
660
692
|
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
@@ -676,6 +708,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
676
708
|
{...(defaultFiltersOpen !== undefined ? { defaultFiltersOpen } : {})}
|
|
677
709
|
{...(pageSize !== undefined ? { pageSize } : {})}
|
|
678
710
|
{...(breadcrumb !== undefined ? { breadcrumb } : {})}
|
|
711
|
+
{...(wrapResults !== undefined ? { wrapResults } : {})}
|
|
679
712
|
{...(views !== undefined ? { views } : {})}
|
|
680
713
|
{...(defaultView !== undefined ? { defaultView } : {})}
|
|
681
714
|
{...(onViewChange !== undefined ? { onViewChange } : {})}
|
|
@@ -62,6 +62,29 @@ export type SearchResultsRenderer = (
|
|
|
62
62
|
items: readonly SearchItem[]
|
|
63
63
|
) => ReactNode;
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* A wrapper AROUND the results the pane drew, rather than a replacement for
|
|
67
|
+
* them. It receives the loaded rows and the pane's own rendered arrangement,
|
|
68
|
+
* and returns whatever should stand in its place — normally a provider with
|
|
69
|
+
* the arrangement still inside it.
|
|
70
|
+
*
|
|
71
|
+
* ```tsx
|
|
72
|
+
* wrapResults={(rows, results) => (
|
|
73
|
+
* <ListingEngagementScope ids={rows.map((row) => Number(row.key))}>
|
|
74
|
+
* {results}
|
|
75
|
+
* </ListingEngagementScope>
|
|
76
|
+
* )}
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* Called only on the LOADED arm: there are no rows to scope while a search is
|
|
80
|
+
* in flight, has failed, or found nothing, and the pane's four load arms stay
|
|
81
|
+
* the pane's own sentences.
|
|
82
|
+
*/
|
|
83
|
+
export type SearchResultsWrapper = (
|
|
84
|
+
items: readonly SearchItem[],
|
|
85
|
+
results: ReactNode
|
|
86
|
+
) => ReactNode;
|
|
87
|
+
|
|
65
88
|
/**
|
|
66
89
|
* The widest a column of results is allowed to get.
|
|
67
90
|
*
|
|
@@ -128,6 +151,24 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
|
|
|
128
151
|
* when the container's arrangement is not "cards in a grid".
|
|
129
152
|
*/
|
|
130
153
|
readonly renderResults?: SearchResultsRenderer;
|
|
154
|
+
/**
|
|
155
|
+
* The scope slot: WRAPS the arrangement instead of replacing it.
|
|
156
|
+
*
|
|
157
|
+
* A container that needs to publish something over the rows the pane just
|
|
158
|
+
* drew — a per-reader overlay, an intersection observer, an analytics
|
|
159
|
+
* boundary — needs the rows AND the pane's own grid, and `renderResults`
|
|
160
|
+
* only offers the first at the price of the second.
|
|
161
|
+
*
|
|
162
|
+
* The measured cost of not having it: a storefront that dims already-seen
|
|
163
|
+
* listings could open its engagement scope on its own landing feed (which
|
|
164
|
+
* mounts the pane directly) and NOT on `/s` or `/c/:slug`, which reach the
|
|
165
|
+
* pane through `<SearchPage>` and its internal `<SearchStateProvider>` —
|
|
166
|
+
* so the feature worked on one of the three screens a buyer scrolls and
|
|
167
|
+
* silently did nothing on the other two (walker D105). Re-implementing the
|
|
168
|
+
* grid to get the rows was the only way through, and a host's copy of this
|
|
169
|
+
* pane's grid is a copy that stops tracking it.
|
|
170
|
+
*/
|
|
171
|
+
readonly wrapResults?: SearchResultsWrapper;
|
|
131
172
|
/**
|
|
132
173
|
* Rendered beside the count in the heading row — the sort control, a view
|
|
133
174
|
* switch. The pane owns the heading, so a screen that composes it hands its
|
|
@@ -284,7 +325,7 @@ function Pager(props: { bag: SearchResultsBag }): ReactElement | null {
|
|
|
284
325
|
|
|
285
326
|
export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
286
327
|
const t = useT();
|
|
287
|
-
const { renderCard, renderResults } = props;
|
|
328
|
+
const { renderCard, renderResults, wrapResults } = props;
|
|
288
329
|
const scorerName = useScorerNames();
|
|
289
330
|
const maxWidth = props.maxWidth === undefined ? RESULTS_MAX_WIDTH : props.maxWidth;
|
|
290
331
|
|
|
@@ -377,9 +418,9 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
377
418
|
);
|
|
378
419
|
}}
|
|
379
420
|
>
|
|
380
|
-
{(items) =>
|
|
381
|
-
|
|
382
|
-
|
|
421
|
+
{(items) => {
|
|
422
|
+
const arrangement =
|
|
423
|
+
renderResults !== undefined ? (
|
|
383
424
|
renderResults(items)
|
|
384
425
|
) : (
|
|
385
426
|
<div
|
|
@@ -397,9 +438,15 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
397
438
|
</div>
|
|
398
439
|
))}
|
|
399
440
|
</div>
|
|
400
|
-
)
|
|
401
|
-
|
|
402
|
-
|
|
441
|
+
);
|
|
442
|
+
return (
|
|
443
|
+
<div data-testid="search-results">
|
|
444
|
+
{wrapResults !== undefined
|
|
445
|
+
? wrapResults(items, arrangement)
|
|
446
|
+
: arrangement}
|
|
447
|
+
</div>
|
|
448
|
+
);
|
|
449
|
+
}}
|
|
403
450
|
</LoadList>
|
|
404
451
|
|
|
405
452
|
<Pager bag={bag} />
|
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.
|
|
@@ -46,6 +47,7 @@ export { SearchResultsPane, RESULTS_MAX_WIDTH } from "./SearchResultsPane.js";
|
|
|
46
47
|
export type {
|
|
47
48
|
SearchResultsPaneProps,
|
|
48
49
|
SearchResultsRenderer,
|
|
50
|
+
SearchResultsWrapper,
|
|
49
51
|
} from "./SearchResultsPane.js";
|
|
50
52
|
|
|
51
53
|
export {
|