@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.
Files changed (94) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/api/generated/schema.d.ts +16 -0
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/default/DegradationNotice.d.ts +22 -13
  5. package/dist/default/DegradationNotice.d.ts.map +1 -1
  6. package/dist/default/DegradationNotice.js +7 -8
  7. package/dist/default/DegradationNotice.js.map +1 -1
  8. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  9. package/dist/default/FacetPanelPane.js +27 -16
  10. package/dist/default/FacetPanelPane.js.map +1 -1
  11. package/dist/default/FilterChips.d.ts.map +1 -1
  12. package/dist/default/FilterChips.js +13 -23
  13. package/dist/default/FilterChips.js.map +1 -1
  14. package/dist/default/LocationSummaryLine.d.ts +24 -0
  15. package/dist/default/LocationSummaryLine.d.ts.map +1 -0
  16. package/dist/default/LocationSummaryLine.js +83 -0
  17. package/dist/default/LocationSummaryLine.js.map +1 -0
  18. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  19. package/dist/default/RangeFilterRow.js +49 -5
  20. package/dist/default/RangeFilterRow.js.map +1 -1
  21. package/dist/default/SearchPage.d.ts +19 -0
  22. package/dist/default/SearchPage.d.ts.map +1 -1
  23. package/dist/default/SearchPage.js +6 -6
  24. package/dist/default/SearchPage.js.map +1 -1
  25. package/dist/default/geoSheet.d.ts +61 -0
  26. package/dist/default/geoSheet.d.ts.map +1 -0
  27. package/dist/default/geoSheet.js +63 -0
  28. package/dist/default/geoSheet.js.map +1 -0
  29. package/dist/default/index.d.ts +2 -0
  30. package/dist/default/index.d.ts.map +1 -1
  31. package/dist/default/index.js +1 -0
  32. package/dist/default/index.js.map +1 -1
  33. package/dist/headless/FacetPanel.d.ts +16 -0
  34. package/dist/headless/FacetPanel.d.ts.map +1 -1
  35. package/dist/headless/FacetPanel.js +7 -0
  36. package/dist/headless/FacetPanel.js.map +1 -1
  37. package/dist/headless/useSearchCount.d.ts +44 -0
  38. package/dist/headless/useSearchCount.d.ts.map +1 -0
  39. package/dist/headless/useSearchCount.js +121 -0
  40. package/dist/headless/useSearchCount.js.map +1 -0
  41. package/dist/i18n/es.d.ts.map +1 -1
  42. package/dist/i18n/es.js +2 -0
  43. package/dist/i18n/es.js.map +1 -1
  44. package/dist/i18n/keys.d.ts +13 -0
  45. package/dist/i18n/keys.d.ts.map +1 -1
  46. package/dist/i18n/keys.js +15 -0
  47. package/dist/i18n/keys.js.map +1 -1
  48. package/dist/i18n/ru.d.ts.map +1 -1
  49. package/dist/i18n/ru.js +2 -0
  50. package/dist/i18n/ru.js.map +1 -1
  51. package/dist/index.d.ts +6 -4
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +4 -3
  54. package/dist/index.js.map +1 -1
  55. package/dist/state/degradations.d.ts +32 -0
  56. package/dist/state/degradations.d.ts.map +1 -1
  57. package/dist/state/degradations.js +33 -0
  58. package/dist/state/degradations.js.map +1 -1
  59. package/dist/state/facets.d.ts +23 -0
  60. package/dist/state/facets.d.ts.map +1 -1
  61. package/dist/state/facets.js +59 -8
  62. package/dist/state/facets.js.map +1 -1
  63. package/dist/state/limits.d.ts +21 -0
  64. package/dist/state/limits.d.ts.map +1 -1
  65. package/dist/state/limits.js +21 -0
  66. package/dist/state/limits.js.map +1 -1
  67. package/dist/state/ranges.d.ts +45 -3
  68. package/dist/state/ranges.d.ts.map +1 -1
  69. package/dist/state/ranges.js +55 -14
  70. package/dist/state/ranges.js.map +1 -1
  71. package/llms.txt +3 -2
  72. package/manifest.json +36 -3
  73. package/nav-manifest.json +1 -1
  74. package/package.json +6 -6
  75. package/src/analytics/generated/events.json +1 -1
  76. package/src/api/generated/schema.ts +16 -0
  77. package/src/default/DegradationNotice.tsx +30 -20
  78. package/src/default/FacetPanelPane.tsx +39 -23
  79. package/src/default/FilterChips.tsx +22 -56
  80. package/src/default/LocationSummaryLine.tsx +187 -0
  81. package/src/default/RangeFilterRow.tsx +61 -3
  82. package/src/default/SearchPage.tsx +33 -3
  83. package/src/default/geoSheet.tsx +161 -0
  84. package/src/default/index.ts +3 -0
  85. package/src/headless/FacetPanel.tsx +24 -0
  86. package/src/headless/useSearchCount.ts +189 -0
  87. package/src/i18n/es.ts +2 -0
  88. package/src/i18n/keys.ts +15 -0
  89. package/src/i18n/ru.ts +2 -0
  90. package/src/index.ts +15 -1
  91. package/src/state/degradations.ts +56 -0
  92. package/src/state/facets.ts +96 -8
  93. package/src/state/limits.ts +23 -0
  94. 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. **`exact_total` alone never raises a banner.** It is a count NUANCE, not
16
- * a failed search: the rows are right, and the single consequence — that
17
- * the total is a floor is already spoken by the count as "N+". A warning
18
- * box over a perfectly good result page teaches the reader that the page
19
- * is broken, and a banner that cries wolf on every landing page is a
20
- * banner nobody reads on the day `category_rollup` appears in it. Beside
21
- * any other degradation it renders normally, because the list is then
22
- * describing an answer that really is degraded. See
23
- * {@link isCountNuanceOnly}.
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"`. `"off"` is a decision the
27
- * CONTAINER makes about ITS surface the notice stays the default, and
28
- * nothing here silences a degradation for everyone.
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 { isCountNuanceOnly } from "../state/degradations.js";
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
- if (props.degradations.length === 0) return null;
67
- // A count nuance is not a degraded search — the count already says "N+".
68
- if (isCountNuanceOnly(props.degradations)) return null;
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 = props.degradations.map((degradation) => (
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
- {props.degradations.map((degradation) => (
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, Flex, Typography } from "antd";
52
+ import { Button } from "antd";
53
53
  import { SkinDialog, useDialogSurface } from "@stapel/tokens-antd/skin";
54
- import { SlotPlaceholder, useT, useTPlural } from "@stapel/core";
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 tPlural = useTPlural();
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 applied = useAppliedCount();
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
- {open === "geo" &&
342
- sheetFor(
343
- "geo",
344
- t(SEARCH_I18N_KEYS.geoTitle),
345
- <Flex vertical gap={spacing[3]}>
346
- {/* SETTING a centre needs a geocoder, which is `geo-react`'s and
347
- the deployment's. ADJUSTING or CLEARING one the URL already
348
- carries does not — which is why the chip still opens on a
349
- shared link with no slot filled, and why the absence of the
350
- slot is NAMED in development rather than left as a gap under
351
- the sheet's title. */}
352
- {props.renderGeoFilter?.({
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 unit = group.unit === undefined ? "" : ` ${group.unit}`;
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
- <Typography.Text strong>{group.label}</Typography.Text>
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 } : {})}