@stapel/search-react 0.9.1 → 0.11.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 +74 -0
- package/dist/api/generated/schema.d.ts +16 -0
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/DegradationNotice.d.ts +22 -13
- package/dist/default/DegradationNotice.d.ts.map +1 -1
- package/dist/default/DegradationNotice.js +7 -8
- package/dist/default/DegradationNotice.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +27 -16
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +13 -23
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts +24 -0
- package/dist/default/LocationSummaryLine.d.ts.map +1 -0
- package/dist/default/LocationSummaryLine.js +83 -0
- package/dist/default/LocationSummaryLine.js.map +1 -0
- package/dist/default/RangeFilterRow.d.ts.map +1 -1
- package/dist/default/RangeFilterRow.js +49 -5
- package/dist/default/RangeFilterRow.js.map +1 -1
- package/dist/default/SearchPage.d.ts +19 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +6 -6
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/geoSheet.d.ts +61 -0
- package/dist/default/geoSheet.d.ts.map +1 -0
- package/dist/default/geoSheet.js +63 -0
- package/dist/default/geoSheet.js.map +1 -0
- package/dist/default/index.d.ts +2 -0
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -0
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +16 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +7 -0
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/useSearchCount.d.ts +44 -0
- package/dist/headless/useSearchCount.d.ts.map +1 -0
- package/dist/headless/useSearchCount.js +121 -0
- package/dist/headless/useSearchCount.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +2 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +13 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +15 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +2 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/state/degradations.d.ts +32 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +33 -0
- package/dist/state/degradations.js.map +1 -1
- package/dist/state/facets.d.ts +23 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +59 -8
- package/dist/state/facets.js.map +1 -1
- package/dist/state/limits.d.ts +21 -0
- package/dist/state/limits.d.ts.map +1 -1
- package/dist/state/limits.js +21 -0
- package/dist/state/limits.js.map +1 -1
- package/dist/state/ranges.d.ts +45 -3
- package/dist/state/ranges.d.ts.map +1 -1
- package/dist/state/ranges.js +55 -14
- package/dist/state/ranges.js.map +1 -1
- package/llms.txt +3 -2
- package/manifest.json +36 -3
- package/nav-manifest.json +1 -1
- package/package.json +6 -6
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +16 -0
- package/src/default/DegradationNotice.tsx +30 -20
- package/src/default/FacetPanelPane.tsx +39 -23
- package/src/default/FilterChips.tsx +22 -56
- package/src/default/LocationSummaryLine.tsx +187 -0
- package/src/default/RangeFilterRow.tsx +61 -3
- package/src/default/SearchPage.tsx +33 -3
- package/src/default/geoSheet.tsx +161 -0
- package/src/default/index.ts +3 -0
- package/src/headless/FacetPanel.tsx +24 -0
- package/src/headless/useSearchCount.ts +189 -0
- package/src/i18n/es.ts +2 -0
- package/src/i18n/keys.ts +15 -0
- package/src/i18n/ru.ts +2 -0
- package/src/index.ts +15 -1
- package/src/state/degradations.ts +56 -0
- package/src/state/facets.ts +96 -8
- package/src/state/limits.ts +23 -0
- package/src/state/ranges.ts +85 -15
|
@@ -12,27 +12,34 @@
|
|
|
12
12
|
*
|
|
13
13
|
* ── Two deliberate ways NOT to shout ───────────────────────────────────────
|
|
14
14
|
*
|
|
15
|
-
* 1.
|
|
16
|
-
*
|
|
17
|
-
* the
|
|
18
|
-
*
|
|
19
|
-
* is
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
15
|
+
* 1. **A degradation addressed to the OPERATOR never reaches the reader.**
|
|
16
|
+
* `typo_tolerance` and `phrase_synonyms` both say, in the shipped ru copy,
|
|
17
|
+
* "the search engine in use cannot do this" — a sentence about which
|
|
18
|
+
* engine somebody licensed, printed at a person trying to buy a phone. It
|
|
19
|
+
* is the same sentence on every query forever, which is what makes it
|
|
20
|
+
* invisible by the day `category_rollup` shows up in the same box.
|
|
21
|
+
* `exact_total` joins them: the count already says "N+".
|
|
22
|
+
*
|
|
23
|
+
* Measured live on a classified board: a full-screen yellow "synonyms
|
|
24
|
+
* were not substituted" between the sort control and the first card, on every
|
|
25
|
+
* query, for every buyer. Note what the fix is NOT — the string was not
|
|
26
|
+
* deleted and the kind was not special-cased. `readerFacing` names the
|
|
27
|
+
* *audience*, so the next engine-capability literal is filtered by the
|
|
28
|
+
* same rule instead of growing its own copy of this comment.
|
|
24
29
|
* 2. **`variant`.** A catalogue page wants the banner; a landing page that
|
|
25
30
|
* shows six cards under a hero has no room for a warning box and passes
|
|
26
|
-
* `"inline"` (one quiet line) or `"off"`. `"
|
|
27
|
-
*
|
|
28
|
-
*
|
|
31
|
+
* `"inline"` (one quiet line) or `"off"`. `"debug"` is the operator's
|
|
32
|
+
* view: everything, unfiltered, for a status page or a support tool.
|
|
33
|
+
* `"off"` is a decision the CONTAINER makes about ITS surface — the
|
|
34
|
+
* notice stays the default, and nothing here silences a reader-facing
|
|
35
|
+
* degradation for everyone.
|
|
29
36
|
*/
|
|
30
37
|
import type { ReactElement } from "react";
|
|
31
38
|
import { Alert, Flex, Typography } from "antd";
|
|
32
39
|
import { fontSize, spacing } from "@stapel/tokens";
|
|
33
40
|
import { useT } from "@stapel/core";
|
|
34
41
|
import type { SearchDegradation } from "../api/types.js";
|
|
35
|
-
import {
|
|
42
|
+
import { readerFacing } from "../state/degradations.js";
|
|
36
43
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
37
44
|
|
|
38
45
|
/**
|
|
@@ -40,10 +47,12 @@ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
|
40
47
|
*
|
|
41
48
|
* - `"banner"` (default) — an antd warning `Alert` with one line per item.
|
|
42
49
|
* - `"inline"` — the same sentences as quiet secondary text, no box.
|
|
50
|
+
* - `"debug"` — every degradation including the operator's, for a status page
|
|
51
|
+
* or a support tool. The one variant that does not filter by audience.
|
|
43
52
|
* - `"off"` — nothing. For a surface where the notice does not belong at all;
|
|
44
53
|
* the container that turns it off owns saying so somewhere else.
|
|
45
54
|
*/
|
|
46
|
-
export type DegradationNoticeVariant = "banner" | "inline" | "off";
|
|
55
|
+
export type DegradationNoticeVariant = "banner" | "inline" | "debug" | "off";
|
|
47
56
|
|
|
48
57
|
export interface DegradationNoticeProps {
|
|
49
58
|
readonly degradations: readonly SearchDegradation[];
|
|
@@ -63,9 +72,10 @@ export function DegradationNotice(
|
|
|
63
72
|
const t = useT();
|
|
64
73
|
const variant: DegradationNoticeVariant = props.variant ?? "banner";
|
|
65
74
|
if (variant === "off") return null;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
// Everything, or only what this page's reader can act on.
|
|
76
|
+
const degradations =
|
|
77
|
+
variant === "debug" ? props.degradations : readerFacing(props.degradations);
|
|
78
|
+
if (degradations.length === 0) return null;
|
|
69
79
|
|
|
70
80
|
const say = (degradation: SearchDegradation): string => {
|
|
71
81
|
const slug = degradation.scorer;
|
|
@@ -82,7 +92,7 @@ export function DegradationNotice(
|
|
|
82
92
|
// theme's warning tint, and grey body text on it measured under 3:1 in the
|
|
83
93
|
// visual pass. Inside a coloured box the readable colour is the box's own
|
|
84
94
|
// text colour, which is what a plain `<Typography.Text>` inherits.
|
|
85
|
-
const lines =
|
|
95
|
+
const lines = degradations.map((degradation) => (
|
|
86
96
|
<li key={degradation.raw} data-degradation={degradation.raw}>
|
|
87
97
|
<Typography.Text>{say(degradation)}</Typography.Text>
|
|
88
98
|
</li>
|
|
@@ -95,7 +105,7 @@ export function DegradationNotice(
|
|
|
95
105
|
{t(SEARCH_I18N_KEYS.degradedTitle)}
|
|
96
106
|
</Typography.Text>
|
|
97
107
|
<ul style={{ margin: 0, paddingInlineStart: spacing[5], fontSize: fontSize.xs.fontSize }}>
|
|
98
|
-
{
|
|
108
|
+
{degradations.map((degradation) => (
|
|
99
109
|
<li key={degradation.raw} data-degradation={degradation.raw}>
|
|
100
110
|
<Typography.Text type="secondary">{say(degradation)}</Typography.Text>
|
|
101
111
|
</li>
|
|
@@ -110,7 +120,7 @@ export function DegradationNotice(
|
|
|
110
120
|
type="warning"
|
|
111
121
|
showIcon
|
|
112
122
|
data-testid="search-degraded"
|
|
113
|
-
data-variant="banner"
|
|
123
|
+
data-variant={variant === "debug" ? "debug" : "banner"}
|
|
114
124
|
title={t(SEARCH_I18N_KEYS.degradedTitle)}
|
|
115
125
|
description={
|
|
116
126
|
<ul style={{ margin: 0, paddingInlineStart: spacing[5] }}>{lines}</ul>
|
|
@@ -306,13 +306,6 @@ function skippedNames(
|
|
|
306
306
|
export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
307
307
|
const t = useT();
|
|
308
308
|
const { state } = useSearchState();
|
|
309
|
-
const ranges = buildRangeGroups({
|
|
310
|
-
state,
|
|
311
|
-
...(props.categoryFeatures !== undefined
|
|
312
|
-
? { categoryFeatures: props.categoryFeatures }
|
|
313
|
-
: {}),
|
|
314
|
-
t,
|
|
315
|
-
});
|
|
316
309
|
|
|
317
310
|
return (
|
|
318
311
|
<SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
@@ -323,7 +316,24 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
323
316
|
{...(props.locale !== undefined ? { locale: props.locale } : {})}
|
|
324
317
|
{...(props.enabled !== undefined ? { enabled: props.enabled } : {})}
|
|
325
318
|
>
|
|
326
|
-
{(bag) =>
|
|
319
|
+
{(bag) => {
|
|
320
|
+
// Built INSIDE the bag, because which axes exist is a property of
|
|
321
|
+
// the ANSWER now: `facet_meta.core_ranges` names the core columns
|
|
322
|
+
// this server can actually filter on (`r.price`), and the corpus
|
|
323
|
+
// currency is read off the cards it just returned. Computed
|
|
324
|
+
// outside, the panel would have had to keep its own list of core
|
|
325
|
+
// slugs — which is how a board ends up offering a price filter
|
|
326
|
+
// against a server that answers zero for one.
|
|
327
|
+
const ranges = buildRangeGroups({
|
|
328
|
+
state,
|
|
329
|
+
...(props.categoryFeatures !== undefined
|
|
330
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
331
|
+
: {}),
|
|
332
|
+
coreRanges: bag.coreRanges,
|
|
333
|
+
...(bag.currency !== undefined ? { currency: bag.currency } : {}),
|
|
334
|
+
t,
|
|
335
|
+
});
|
|
336
|
+
return (
|
|
327
337
|
<Flex vertical gap={spacing[3]} data-testid="search-facets">
|
|
328
338
|
<Flex justify="space-between" align="center" gap={spacing[2]}>
|
|
329
339
|
{props.heading === null ? (
|
|
@@ -360,6 +370,25 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
360
370
|
{...(props.geoLabel !== undefined ? { label: props.geoLabel } : {})}
|
|
361
371
|
/>
|
|
362
372
|
|
|
373
|
+
{/* Price first. Every numeric row below it is an ATTRIBUTE the
|
|
374
|
+
category happens to declare — on the phone board this was
|
|
375
|
+
measured against, all seven of them were parcel dimensions
|
|
376
|
+
and wholesale packing. The one number a buyer narrows by is
|
|
377
|
+
the price, and `buildRangeGroups` puts the core axes first. */}
|
|
378
|
+
{ranges.length > 0 && (
|
|
379
|
+
<Flex vertical gap={spacing[3]} data-testid="search-ranges">
|
|
380
|
+
{ranges.map((group) => (
|
|
381
|
+
<RangeFilterRow
|
|
382
|
+
key={group.slug}
|
|
383
|
+
group={group}
|
|
384
|
+
onApply={bag.setRange}
|
|
385
|
+
/>
|
|
386
|
+
))}
|
|
387
|
+
</Flex>
|
|
388
|
+
)}
|
|
389
|
+
|
|
390
|
+
{ranges.length > 0 && <Divider style={{ margin: 0 }} />}
|
|
391
|
+
|
|
363
392
|
{/* Honesty flags, not failures: the counts ARE approximate and
|
|
364
393
|
those slugs WERE skipped, and a red box would teach a person
|
|
365
394
|
the page is broken. `ErrorAlert` is for a read that did not
|
|
@@ -383,20 +412,6 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
383
412
|
/>
|
|
384
413
|
)}
|
|
385
414
|
|
|
386
|
-
{ranges.length > 0 && (
|
|
387
|
-
<Flex vertical gap={spacing[3]} data-testid="search-ranges">
|
|
388
|
-
{ranges.map((group) => (
|
|
389
|
-
<RangeFilterRow
|
|
390
|
-
key={group.slug}
|
|
391
|
-
group={group}
|
|
392
|
-
onApply={bag.setRange}
|
|
393
|
-
/>
|
|
394
|
-
))}
|
|
395
|
-
</Flex>
|
|
396
|
-
)}
|
|
397
|
-
|
|
398
|
-
{ranges.length > 0 && <Divider style={{ margin: 0 }} />}
|
|
399
|
-
|
|
400
415
|
<LoadList
|
|
401
416
|
state={bag.state}
|
|
402
417
|
testId="facets"
|
|
@@ -440,7 +455,8 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
440
455
|
)}
|
|
441
456
|
</LoadList>
|
|
442
457
|
</Flex>
|
|
443
|
-
|
|
458
|
+
);
|
|
459
|
+
}}
|
|
444
460
|
</FacetPanel>
|
|
445
461
|
</SkinTheme>
|
|
446
462
|
);
|
|
@@ -49,13 +49,12 @@
|
|
|
49
49
|
*/
|
|
50
50
|
import { useState } from "react";
|
|
51
51
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
52
|
-
import { Button
|
|
52
|
+
import { Button } from "antd";
|
|
53
53
|
import { SkinDialog, useDialogSurface } from "@stapel/tokens-antd/skin";
|
|
54
|
-
import {
|
|
54
|
+
import { useT } from "@stapel/core";
|
|
55
55
|
import { radii, spacing } from "@stapel/tokens";
|
|
56
56
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
57
57
|
import { useFacetPanel } from "../headless/FacetPanel.js";
|
|
58
|
-
import { useAppliedCount } from "../headless/useAppliedCount.js";
|
|
59
58
|
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
60
59
|
import { buildRangeGroups } from "../state/ranges.js";
|
|
61
60
|
import type { FacetGroup } from "../state/facets.js";
|
|
@@ -64,6 +63,7 @@ import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
|
64
63
|
import { RangeFilterRow } from "./RangeFilterRow.js";
|
|
65
64
|
import { geoSummaryFallback } from "./FacetPanelPane.js";
|
|
66
65
|
import type { GeoFilterSlotProps } from "./FacetPanelPane.js";
|
|
66
|
+
import { CHIP_GEO_TEST_IDS, GeoSheet, useApplyLabel } from "./geoSheet.js";
|
|
67
67
|
|
|
68
68
|
/** The class the scroller carries, for {@link chipRowCss}. */
|
|
69
69
|
export const CHIP_ROW_CLASS = "stapel-filter-chips";
|
|
@@ -142,23 +142,27 @@ function chipLabel(group: FacetGroup, t: (key: string, p?: Record<string, unknow
|
|
|
142
142
|
|
|
143
143
|
export function FilterChips(props: FilterChipsProps): ReactElement {
|
|
144
144
|
const t = useT();
|
|
145
|
-
const
|
|
146
|
-
const { state, setGeo } = useSearchState();
|
|
145
|
+
const { state } = useSearchState();
|
|
147
146
|
const bag = useFacetPanel({
|
|
148
147
|
...(props.categoryFeatures !== undefined
|
|
149
148
|
? { categoryFeatures: props.categoryFeatures }
|
|
150
149
|
: {}),
|
|
151
150
|
...(props.locale !== undefined ? { locale: props.locale } : {}),
|
|
152
151
|
});
|
|
153
|
-
const
|
|
152
|
+
const applyLabel = useApplyLabel();
|
|
154
153
|
const surface = useDialogSurface();
|
|
155
154
|
const [open, setOpen] = useState<OpenChip>(null);
|
|
156
155
|
|
|
156
|
+
// Same source as the panel's rows, so the phone chip row and the desktop
|
|
157
|
+
// panel cannot disagree about which axes exist: the core columns come from
|
|
158
|
+
// the ANSWER (`facet_meta.core_ranges`) and the currency off its cards.
|
|
157
159
|
const ranges = buildRangeGroups({
|
|
158
160
|
state,
|
|
159
161
|
...(props.categoryFeatures !== undefined
|
|
160
162
|
? { categoryFeatures: props.categoryFeatures }
|
|
161
163
|
: {}),
|
|
164
|
+
coreRanges: bag.coreRanges,
|
|
165
|
+
...(bag.currency !== undefined ? { currency: bag.currency } : {}),
|
|
162
166
|
t,
|
|
163
167
|
});
|
|
164
168
|
const groups =
|
|
@@ -166,19 +170,6 @@ export function FilterChips(props: FilterChipsProps): ReactElement {
|
|
|
166
170
|
? bag.state.data.filter((group) => group.options.length > 0)
|
|
167
171
|
: [];
|
|
168
172
|
|
|
169
|
-
// The same sentence the panel's own commit button says, for the same reason:
|
|
170
|
-
// the results are BEHIND this sheet, so the button that closes it is the
|
|
171
|
-
// only place a person learns what their choice did.
|
|
172
|
-
const applyLabel =
|
|
173
|
-
applied.count === null || applied.kind === "unknown"
|
|
174
|
-
? t(SEARCH_I18N_KEYS.filtersApply)
|
|
175
|
-
: tPlural(
|
|
176
|
-
applied.kind === "at_least"
|
|
177
|
-
? SEARCH_I18N_KEYS.filtersShowCountAtLeast
|
|
178
|
-
: SEARCH_I18N_KEYS.filtersShowCount,
|
|
179
|
-
{ count: applied.count }
|
|
180
|
-
);
|
|
181
|
-
|
|
182
173
|
const close = (): void => {
|
|
183
174
|
setOpen(null);
|
|
184
175
|
};
|
|
@@ -338,43 +329,18 @@ export function FilterChips(props: FilterChipsProps): ReactElement {
|
|
|
338
329
|
);
|
|
339
330
|
})()}
|
|
340
331
|
|
|
341
|
-
{
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
value: geo,
|
|
354
|
-
onChange: (next) => {
|
|
355
|
-
setGeo(next);
|
|
356
|
-
},
|
|
357
|
-
}) ?? <SlotPlaceholder name="renderGeoFilter" data-testid="search-chip-geo-slot" />}
|
|
358
|
-
{geo !== undefined && (
|
|
359
|
-
<>
|
|
360
|
-
<Typography.Text type="secondary" data-testid="search-chip-geo-summary">
|
|
361
|
-
{geoChipLabel}
|
|
362
|
-
</Typography.Text>
|
|
363
|
-
<Button
|
|
364
|
-
style={{ alignSelf: "flex-start" }}
|
|
365
|
-
data-testid="search-chip-geo-clear"
|
|
366
|
-
data-analytics="none"
|
|
367
|
-
data-analytics-reason="a filter is a read, not a flow step"
|
|
368
|
-
onClick={() => {
|
|
369
|
-
setGeo(null);
|
|
370
|
-
}}
|
|
371
|
-
>
|
|
372
|
-
{t(SEARCH_I18N_KEYS.geoClear)}
|
|
373
|
-
</Button>
|
|
374
|
-
</>
|
|
375
|
-
)}
|
|
376
|
-
</Flex>
|
|
377
|
-
)}
|
|
332
|
+
{/* The LOCATION sheet is shared with `<LocationSummaryLine>` — the ref
|
|
333
|
+
puts a location control on both rows, and they must land in the same
|
|
334
|
+
place. See `geoSheet.tsx`. */}
|
|
335
|
+
<GeoSheet
|
|
336
|
+
open={open === "geo"}
|
|
337
|
+
onClose={close}
|
|
338
|
+
testIds={CHIP_GEO_TEST_IDS}
|
|
339
|
+
{...(props.renderGeoFilter !== undefined
|
|
340
|
+
? { renderGeoFilter: props.renderGeoFilter }
|
|
341
|
+
: {})}
|
|
342
|
+
{...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
|
|
343
|
+
/>
|
|
378
344
|
</>
|
|
379
345
|
);
|
|
380
346
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<LocationSummaryLine>` — the row above a phone SERP's chips: WHERE this
|
|
3
|
+
* search is looking on the left, and how much has been narrowed on the right.
|
|
4
|
+
*
|
|
5
|
+
* ## Why the location gets a line of its own, above the chips
|
|
6
|
+
*
|
|
7
|
+
* It is already a chip (`<FilterChips>` draws one), and on the refs it is a
|
|
8
|
+
* ROW as well, and that is not redundancy. A chip row scrolls: the geo chip is
|
|
9
|
+
* one of eight and it is off screen the moment somebody has scrolled to
|
|
10
|
+
* "Year". Location is the one constraint on a classified that changes what a
|
|
11
|
+
* result MEANS rather than narrowing a set — "1 200 €" is a different offer in
|
|
12
|
+
* the next city — so it is the one that must be readable without scrolling
|
|
13
|
+
* anything. The two controls open the same sheet (`geoSheet.tsx`), so tapping
|
|
14
|
+
* either lands in the same place.
|
|
15
|
+
*
|
|
16
|
+
* ## Never a coordinate
|
|
17
|
+
*
|
|
18
|
+
* This pair holds a `lat` and a `lon` and has no way on earth to turn them
|
|
19
|
+
* into "Berlin Mitte" — that is a geocoder, and a search package that grew one
|
|
20
|
+
* to say a nicer sentence would have taken on the whole of `geo-react`. So the
|
|
21
|
+
* line says the name it was HANDED (`geoLabel`), or that a place is chosen,
|
|
22
|
+
* and adds the radius, which is a number this pair does own. With nothing
|
|
23
|
+
* applied it says the search is looking everywhere, which is the truth and is
|
|
24
|
+
* also the invitation to narrow it.
|
|
25
|
+
*
|
|
26
|
+
* ## The right-hand affordance carries a COUNT, and the chip row carries a dot
|
|
27
|
+
*
|
|
28
|
+
* `<FilterChips>`'s leading chip is a 32px circle: a number inside it is a
|
|
29
|
+
* number nobody reads, so it shows a dot. This is a full-width row with a word
|
|
30
|
+
* on it, so the badge has room to say HOW MANY constraints are applied — which
|
|
31
|
+
* is the difference between "something is filtered" and "four things are, and
|
|
32
|
+
* that is why there are three results". Both read the same
|
|
33
|
+
* `activeFilters` off the URL state; neither invents a second counter.
|
|
34
|
+
*/
|
|
35
|
+
import { useState } from "react";
|
|
36
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
37
|
+
import { Badge, Button, Flex } from "antd";
|
|
38
|
+
import { useT } from "@stapel/core";
|
|
39
|
+
import { spacing } from "@stapel/tokens";
|
|
40
|
+
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
41
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
42
|
+
import { geoSummaryFallback } from "./FacetPanelPane.js";
|
|
43
|
+
import type { GeoFilterSlotProps } from "./FacetPanelPane.js";
|
|
44
|
+
import { GeoSheet, SUMMARY_GEO_TEST_IDS } from "./geoSheet.js";
|
|
45
|
+
|
|
46
|
+
/** The row. Both ends are text buttons, so the row reads as a line of type
|
|
47
|
+
* rather than as two controls bolted onto a results page. */
|
|
48
|
+
const ROW: CSSProperties = { width: "100%" };
|
|
49
|
+
|
|
50
|
+
/** Each end shrinks before it wraps; the location is the half that may
|
|
51
|
+
* ellipsis, because a long place name must not push "Filters" off screen. */
|
|
52
|
+
const LOCATION: CSSProperties = {
|
|
53
|
+
minWidth: 0,
|
|
54
|
+
paddingInline: 0,
|
|
55
|
+
textAlign: "start",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export interface LocationSummaryLineProps {
|
|
59
|
+
/**
|
|
60
|
+
* The location control the sheet opens onto — the same slot the panel and
|
|
61
|
+
* the chip row take. Without it the sheet still opens (a location the URL
|
|
62
|
+
* already carries can be widened and cleared with no geocoder), and the
|
|
63
|
+
* absence is NAMED in development rather than left as a blank sheet.
|
|
64
|
+
*/
|
|
65
|
+
readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
66
|
+
/**
|
|
67
|
+
* What the current place is CALLED. See the file header: this pair prints
|
|
68
|
+
* the name it is handed or admits it does not know one, never the numbers.
|
|
69
|
+
*/
|
|
70
|
+
readonly geoLabel?: ReactNode;
|
|
71
|
+
/**
|
|
72
|
+
* Open the whole filter panel — the same contract `<FilterChips.onOpenAll>`
|
|
73
|
+
* states, and conventionally the same callback: the SURFACE owns that sheet
|
|
74
|
+
* because the surface is what it covers.
|
|
75
|
+
*/
|
|
76
|
+
readonly onOpenAll: () => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A map pin in `currentColor` — the house convention: an inline monochrome
|
|
80
|
+
* SVG, no icon-font dependency, and it inherits the theme rather than carrying
|
|
81
|
+
* a colour. `aria-hidden` because the text beside it is the name. */
|
|
82
|
+
function PinGlyph(): ReactElement {
|
|
83
|
+
return (
|
|
84
|
+
<svg
|
|
85
|
+
width="16"
|
|
86
|
+
height="16"
|
|
87
|
+
viewBox="0 0 24 24"
|
|
88
|
+
fill="none"
|
|
89
|
+
stroke="currentColor"
|
|
90
|
+
strokeWidth="1.75"
|
|
91
|
+
strokeLinecap="round"
|
|
92
|
+
strokeLinejoin="round"
|
|
93
|
+
role="img"
|
|
94
|
+
aria-hidden="true"
|
|
95
|
+
>
|
|
96
|
+
<path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11z" />
|
|
97
|
+
<circle cx="12" cy="10" r="2.6" />
|
|
98
|
+
</svg>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function LocationSummaryLine(
|
|
103
|
+
props: LocationSummaryLineProps
|
|
104
|
+
): ReactElement {
|
|
105
|
+
const t = useT();
|
|
106
|
+
const { state, activeFilters } = useSearchState();
|
|
107
|
+
const [open, setOpen] = useState(false);
|
|
108
|
+
const geo = state.geo;
|
|
109
|
+
|
|
110
|
+
// The radius is a NUMBER this pair owns (`radius_km` in the URL), so unlike
|
|
111
|
+
// the place's name it can be stated without asking anyone. A bbox has no
|
|
112
|
+
// radius to state; a centre without one is "around here" and says nothing
|
|
113
|
+
// rather than inventing a default the server never applied.
|
|
114
|
+
const radius =
|
|
115
|
+
geo !== undefined && geo.kind === "center" && geo.radiusKm !== undefined
|
|
116
|
+
? t(SEARCH_I18N_KEYS.geoRadiusKm, { km: geo.radiusKm })
|
|
117
|
+
: undefined;
|
|
118
|
+
|
|
119
|
+
const where: ReactNode =
|
|
120
|
+
geo === undefined
|
|
121
|
+
? t(SEARCH_I18N_KEYS.geoEverywhere)
|
|
122
|
+
: (props.geoLabel ?? geoSummaryFallback(geo, t));
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<>
|
|
126
|
+
<Flex
|
|
127
|
+
align="center"
|
|
128
|
+
justify="space-between"
|
|
129
|
+
gap={spacing[2]}
|
|
130
|
+
style={ROW}
|
|
131
|
+
data-testid="search-location-summary"
|
|
132
|
+
data-geo={geo === undefined ? "off" : "on"}
|
|
133
|
+
>
|
|
134
|
+
<Button
|
|
135
|
+
type="link"
|
|
136
|
+
style={LOCATION}
|
|
137
|
+
icon={<PinGlyph />}
|
|
138
|
+
data-testid="search-location-open"
|
|
139
|
+
data-analytics="none"
|
|
140
|
+
data-analytics-reason="opening the location sheet is a read, not a flow step"
|
|
141
|
+
onClick={() => {
|
|
142
|
+
setOpen(true);
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
{where}
|
|
146
|
+
{radius !== undefined && (
|
|
147
|
+
<span data-testid="search-location-radius">
|
|
148
|
+
{" · "}
|
|
149
|
+
{radius}
|
|
150
|
+
</span>
|
|
151
|
+
)}
|
|
152
|
+
</Button>
|
|
153
|
+
|
|
154
|
+
{/* The count, not a dot: this row has the width to say how many. */}
|
|
155
|
+
<Badge
|
|
156
|
+
count={activeFilters}
|
|
157
|
+
size="small"
|
|
158
|
+
data-testid="search-location-filters-badge"
|
|
159
|
+
>
|
|
160
|
+
<Button
|
|
161
|
+
type="link"
|
|
162
|
+
style={{ paddingInline: 0 }}
|
|
163
|
+
data-testid="search-location-filters"
|
|
164
|
+
data-active={activeFilters > 0 ? "true" : "false"}
|
|
165
|
+
data-analytics="none"
|
|
166
|
+
data-analytics-reason="opening the filter sheet is a read, not a flow step"
|
|
167
|
+
onClick={props.onOpenAll}
|
|
168
|
+
>
|
|
169
|
+
{t(SEARCH_I18N_KEYS.filtersAll)}
|
|
170
|
+
</Button>
|
|
171
|
+
</Badge>
|
|
172
|
+
</Flex>
|
|
173
|
+
|
|
174
|
+
<GeoSheet
|
|
175
|
+
open={open}
|
|
176
|
+
onClose={() => {
|
|
177
|
+
setOpen(false);
|
|
178
|
+
}}
|
|
179
|
+
testIds={SUMMARY_GEO_TEST_IDS}
|
|
180
|
+
{...(props.renderGeoFilter !== undefined
|
|
181
|
+
? { renderGeoFilter: props.renderGeoFilter }
|
|
182
|
+
: {})}
|
|
183
|
+
{...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
|
|
184
|
+
/>
|
|
185
|
+
</>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { useRef, useState } from "react";
|
|
19
19
|
import type { ReactElement } from "react";
|
|
20
20
|
import { Button, Flex, InputNumber, Typography } from "antd";
|
|
21
|
-
import { actionAvailable, actionBlocked, useT } from "@stapel/core";
|
|
21
|
+
import { actionAvailable, actionBlocked, useFormat, useT } from "@stapel/core";
|
|
22
22
|
import type { ActionAvailability } from "@stapel/core";
|
|
23
23
|
import { GatedButton } from "@stapel/tokens-antd/skin";
|
|
24
24
|
import { spacing } from "@stapel/tokens";
|
|
@@ -40,8 +40,39 @@ function toDraft(value: string | undefined): string {
|
|
|
40
40
|
return value ?? "";
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The suffix a bound field carries: a currency SYMBOL for a money axis, the
|
|
45
|
+
* category's own unit string otherwise.
|
|
46
|
+
*
|
|
47
|
+
* `group.currency` is an ISO 4217 code and `RUB` beside an input is not what
|
|
48
|
+
* money looks like anywhere; `Intl` knows the symbol per locale, so the code
|
|
49
|
+
* goes in and «₽» comes out. An unsupported code falls back to the code
|
|
50
|
+
* itself — still better than nothing, and it never throws inside a render.
|
|
51
|
+
*/
|
|
52
|
+
function boundSuffix(
|
|
53
|
+
format: ReturnType<typeof useFormat>,
|
|
54
|
+
group: RangeGroup
|
|
55
|
+
): string | undefined {
|
|
56
|
+
if (group.currency === undefined) return group.unit;
|
|
57
|
+
if (!/^[A-Za-z]{3}$/.test(group.currency)) return group.currency;
|
|
58
|
+
try {
|
|
59
|
+
const sample = format.number(0, {
|
|
60
|
+
style: "currency",
|
|
61
|
+
currency: group.currency.toUpperCase(),
|
|
62
|
+
currencyDisplay: "narrowSymbol",
|
|
63
|
+
maximumFractionDigits: 0,
|
|
64
|
+
});
|
|
65
|
+
if (sample === null) return group.currency;
|
|
66
|
+
const symbol = sample.replace(/[\s\u00a0\u202f0-9.,]/g, "");
|
|
67
|
+
return symbol.length > 0 ? symbol : group.currency;
|
|
68
|
+
} catch {
|
|
69
|
+
return group.currency;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
43
73
|
export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
44
74
|
const t = useT();
|
|
75
|
+
const format = useFormat();
|
|
45
76
|
const { group } = props;
|
|
46
77
|
const [from, setFrom] = useState(toDraft(group.from));
|
|
47
78
|
const [to, setTo] = useState(toDraft(group.to));
|
|
@@ -71,7 +102,23 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
|
71
102
|
props.onApply(group.slug, empty ? null : draft);
|
|
72
103
|
};
|
|
73
104
|
|
|
74
|
-
const
|
|
105
|
+
const suffix = boundSuffix(format, group);
|
|
106
|
+
const unit = suffix === undefined ? "" : ` ${suffix}`;
|
|
107
|
+
// Thousands grouping inside the field, because a price is read in
|
|
108
|
+
// thousands: `119000` is a wall of digits and `119 000` is a number. The
|
|
109
|
+
// parser is digits-only, so whatever the locale's separator turns out to
|
|
110
|
+
// be, the value that leaves the control is still the one the URL carries.
|
|
111
|
+
const grouping =
|
|
112
|
+
group.core
|
|
113
|
+
? {
|
|
114
|
+
formatter: (value: string | number | undefined): string =>
|
|
115
|
+
value === undefined || value === ""
|
|
116
|
+
? ""
|
|
117
|
+
: (format.number(Number(value)) ?? String(value)),
|
|
118
|
+
parser: (value: string | undefined): string =>
|
|
119
|
+
(value ?? "").replace(/[^\d.-]/g, ""),
|
|
120
|
+
}
|
|
121
|
+
: {};
|
|
75
122
|
|
|
76
123
|
return (
|
|
77
124
|
<Flex
|
|
@@ -79,8 +126,17 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
|
79
126
|
gap={spacing[1]}
|
|
80
127
|
data-testid={`facet-range-${group.slug}`}
|
|
81
128
|
data-active={group.active ? "true" : "false"}
|
|
129
|
+
data-core={group.core ? "true" : "false"}
|
|
82
130
|
>
|
|
83
|
-
|
|
131
|
+
{/* The unit lives in the HEADING, not in the fields. It used to live
|
|
132
|
+
only in an aria-label, so a sighted reader of a money row saw two
|
|
133
|
+
bare integers and had to infer the currency from the results. antd
|
|
134
|
+
deprecated `addonAfter` on InputNumber in favour of `Space.Compact`,
|
|
135
|
+
and two addons plus an Apply button do not survive a 390px row —
|
|
136
|
+
"Price, RUB" states it once and costs no width. */}
|
|
137
|
+
<Typography.Text strong data-testid={`facet-range-${group.slug}-label`}>
|
|
138
|
+
{suffix === undefined ? group.label : `${group.label}, ${suffix}`}
|
|
139
|
+
</Typography.Text>
|
|
84
140
|
<Flex gap={spacing[2]} align="center" wrap>
|
|
85
141
|
<InputNumber
|
|
86
142
|
value={from === "" ? null : Number(from)}
|
|
@@ -90,6 +146,7 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
|
90
146
|
})}${unit}`}
|
|
91
147
|
data-testid={`facet-range-${group.slug}-from`}
|
|
92
148
|
style={{ minWidth: RANGE_FIELD_MIN_WIDTH }}
|
|
149
|
+
{...grouping}
|
|
93
150
|
{...(group.min !== undefined ? { min: group.min } : {})}
|
|
94
151
|
{...(group.max !== undefined ? { max: group.max } : {})}
|
|
95
152
|
{...(group.step !== undefined ? { step: group.step } : {})}
|
|
@@ -106,6 +163,7 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
|
106
163
|
})}${unit}`}
|
|
107
164
|
data-testid={`facet-range-${group.slug}-to`}
|
|
108
165
|
style={{ minWidth: RANGE_FIELD_MIN_WIDTH }}
|
|
166
|
+
{...grouping}
|
|
109
167
|
{...(group.min !== undefined ? { min: group.min } : {})}
|
|
110
168
|
{...(group.max !== undefined ? { max: group.max } : {})}
|
|
111
169
|
{...(group.step !== undefined ? { step: group.step } : {})}
|