@stapel/search-react 0.10.0 → 0.12.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 (130) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/MODULE.md +111 -0
  3. package/README.md +42 -0
  4. package/dist/api/generated/schema.d.ts +76 -6
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/api/searchApi.d.ts +13 -6
  7. package/dist/api/searchApi.d.ts.map +1 -1
  8. package/dist/api/searchApi.js.map +1 -1
  9. package/dist/api/types.d.ts +107 -1
  10. package/dist/api/types.d.ts.map +1 -1
  11. package/dist/api/types.js +29 -0
  12. package/dist/api/types.js.map +1 -1
  13. package/dist/default/DegradationNotice.d.ts +22 -13
  14. package/dist/default/DegradationNotice.d.ts.map +1 -1
  15. package/dist/default/DegradationNotice.js +7 -8
  16. package/dist/default/DegradationNotice.js.map +1 -1
  17. package/dist/default/FacetPanelPane.d.ts +13 -2
  18. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  19. package/dist/default/FacetPanelPane.js +29 -16
  20. package/dist/default/FacetPanelPane.js.map +1 -1
  21. package/dist/default/FilterChips.d.ts +88 -3
  22. package/dist/default/FilterChips.d.ts.map +1 -1
  23. package/dist/default/FilterChips.js +166 -13
  24. package/dist/default/FilterChips.js.map +1 -1
  25. package/dist/default/LocationSummaryLine.d.ts.map +1 -1
  26. package/dist/default/LocationSummaryLine.js +1 -1
  27. package/dist/default/LocationSummaryLine.js.map +1 -1
  28. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  29. package/dist/default/RangeFilterRow.js +49 -5
  30. package/dist/default/RangeFilterRow.js.map +1 -1
  31. package/dist/default/SearchBox.d.ts.map +1 -1
  32. package/dist/default/SearchBox.js +74 -4
  33. package/dist/default/SearchBox.js.map +1 -1
  34. package/dist/default/SearchPage.d.ts +29 -1
  35. package/dist/default/SearchPage.d.ts.map +1 -1
  36. package/dist/default/SearchPage.js +25 -10
  37. package/dist/default/SearchPage.js.map +1 -1
  38. package/dist/default/SearchResultsPane.d.ts +22 -0
  39. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  40. package/dist/default/SearchResultsPane.js +2 -2
  41. package/dist/default/SearchResultsPane.js.map +1 -1
  42. package/dist/default/SortSelect.d.ts +23 -0
  43. package/dist/default/SortSelect.d.ts.map +1 -1
  44. package/dist/default/SortSelect.js +29 -9
  45. package/dist/default/SortSelect.js.map +1 -1
  46. package/dist/default/index.d.ts +2 -2
  47. package/dist/default/index.d.ts.map +1 -1
  48. package/dist/default/index.js +1 -1
  49. package/dist/default/index.js.map +1 -1
  50. package/dist/headless/FacetPanel.d.ts +26 -0
  51. package/dist/headless/FacetPanel.d.ts.map +1 -1
  52. package/dist/headless/FacetPanel.js +16 -1
  53. package/dist/headless/FacetPanel.js.map +1 -1
  54. package/dist/headless/useFacetLabels.d.ts +39 -0
  55. package/dist/headless/useFacetLabels.d.ts.map +1 -0
  56. package/dist/headless/useFacetLabels.js +129 -0
  57. package/dist/headless/useFacetLabels.js.map +1 -0
  58. package/dist/headless/useSearchBox.d.ts +71 -0
  59. package/dist/headless/useSearchBox.d.ts.map +1 -1
  60. package/dist/headless/useSearchBox.js +67 -2
  61. package/dist/headless/useSearchBox.js.map +1 -1
  62. package/dist/i18n/es.d.ts.map +1 -1
  63. package/dist/i18n/es.js +8 -0
  64. package/dist/i18n/es.js.map +1 -1
  65. package/dist/i18n/keys.d.ts +48 -0
  66. package/dist/i18n/keys.d.ts.map +1 -1
  67. package/dist/i18n/keys.js +58 -0
  68. package/dist/i18n/keys.js.map +1 -1
  69. package/dist/i18n/ru.d.ts.map +1 -1
  70. package/dist/i18n/ru.js +10 -0
  71. package/dist/i18n/ru.js.map +1 -1
  72. package/dist/index.d.ts +9 -7
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +6 -5
  75. package/dist/index.js.map +1 -1
  76. package/dist/model/queries.d.ts +6 -5
  77. package/dist/model/queries.d.ts.map +1 -1
  78. package/dist/model/queries.js +4 -3
  79. package/dist/model/queries.js.map +1 -1
  80. package/dist/model/queryKeys.d.ts +12 -0
  81. package/dist/model/queryKeys.d.ts.map +1 -1
  82. package/dist/model/queryKeys.js +7 -0
  83. package/dist/model/queryKeys.js.map +1 -1
  84. package/dist/nav/manifest.d.ts +21 -0
  85. package/dist/nav/manifest.d.ts.map +1 -1
  86. package/dist/nav/manifest.js +3 -2
  87. package/dist/nav/manifest.js.map +1 -1
  88. package/dist/state/degradations.d.ts +32 -0
  89. package/dist/state/degradations.d.ts.map +1 -1
  90. package/dist/state/degradations.js +33 -0
  91. package/dist/state/degradations.js.map +1 -1
  92. package/dist/state/facets.d.ts +64 -0
  93. package/dist/state/facets.d.ts.map +1 -1
  94. package/dist/state/facets.js +134 -9
  95. package/dist/state/facets.js.map +1 -1
  96. package/dist/state/ranges.d.ts +45 -3
  97. package/dist/state/ranges.d.ts.map +1 -1
  98. package/dist/state/ranges.js +55 -14
  99. package/dist/state/ranges.js.map +1 -1
  100. package/llms.txt +2 -2
  101. package/manifest.json +27 -2
  102. package/nav-manifest.json +4 -3
  103. package/package.json +10 -10
  104. package/src/analytics/generated/events.json +1 -1
  105. package/src/api/generated/schema.ts +76 -6
  106. package/src/api/searchApi.ts +13 -6
  107. package/src/api/types.ts +115 -1
  108. package/src/default/DegradationNotice.tsx +30 -20
  109. package/src/default/FacetPanelPane.tsx +55 -25
  110. package/src/default/FilterChips.tsx +290 -38
  111. package/src/default/LocationSummaryLine.tsx +6 -1
  112. package/src/default/RangeFilterRow.tsx +61 -3
  113. package/src/default/SearchBox.tsx +134 -6
  114. package/src/default/SearchPage.tsx +79 -5
  115. package/src/default/SearchResultsPane.tsx +48 -11
  116. package/src/default/SortSelect.tsx +70 -9
  117. package/src/default/index.ts +10 -2
  118. package/src/headless/FacetPanel.tsx +44 -1
  119. package/src/headless/useFacetLabels.ts +172 -0
  120. package/src/headless/useSearchBox.ts +132 -5
  121. package/src/i18n/es.ts +9 -0
  122. package/src/i18n/keys.ts +60 -0
  123. package/src/i18n/ru.ts +11 -0
  124. package/src/index.ts +28 -4
  125. package/src/model/queries.ts +6 -5
  126. package/src/model/queryKeys.ts +23 -0
  127. package/src/nav/manifest.ts +24 -2
  128. package/src/state/degradations.ts +56 -0
  129. package/src/state/facets.ts +181 -9
  130. package/src/state/ranges.ts +85 -15
@@ -41,11 +41,69 @@
41
41
  *
42
42
  * ## The library is not inventing a classified's filters
43
43
  *
44
- * A chip exists for each facet group the SERVER returned, each numeric range
45
- * the CATEGORY SCHEMA declares, and the location plus the leading
46
- * "all filters" chip. Nothing here knows what a "brand" or a "mileage" is;
47
- * a deployment with three facets gets three chips and a deployment with none
48
- * gets the leading chip alone.
44
+ * A chip exists for each facet group the SERVER returned AND the category
45
+ * schema types as choosable (`isFacetableFeature` an `imei` is counted and
46
+ * is not a filter), each numeric range the CATEGORY SCHEMA declares, the
47
+ * location, and the host's category control plus the leading "all filters"
48
+ * chip. Nothing here knows what a "brand" or a "mileage" is; a deployment with
49
+ * three facets gets three chips and a deployment with none gets the leading
50
+ * chip alone.
51
+ *
52
+ * ## The ORDER of the row is the whole product
53
+ *
54
+ * At 390px a person sees roughly four chips before the fold, and everything
55
+ * past them costs a horizontal flick most people never make. On a live phone
56
+ * category the first seven were battery health, four parcel dimensions and two
57
+ * wholesale counts — every one of them a numeric ATTRIBUTE the category
58
+ * happens to declare, drawn before the condition, the vendor and the model
59
+ * anybody actually narrows by, and before the price.
60
+ *
61
+ * That is an ORDERING defect, not a facetability one, and it is fixed as one:
62
+ * nothing is deleted, because this package cannot tell a battery-health axis
63
+ * from a parcel-width axis and must not pretend to. Two categories declare
64
+ * `int` attributes and one of them is `mileage`. So the row states its order
65
+ * instead, out of evidence it actually has:
66
+ *
67
+ * 1. **the category chip** — narrowing the category decides which chips
68
+ * exist at all, the facet plan being derived from the leaf;
69
+ * 2. **the location chip** — the other host-slot filter;
70
+ * 3. **everything APPLIED**, in band order below. A constraint a person has
71
+ * set has to be reachable without a flick, or the row states filters that
72
+ * are on screen only if you go looking;
73
+ * 4. then everything unapplied, in band order:
74
+ * **core range axes** (`facet_meta.core_ranges` — the SERVER declaring an
75
+ * axis that exists for every document in every category: `price`), then
76
+ * **counted facet groups** (the server counted them for this search and
77
+ * each carries its remaining counts — the strongest evidence the row has
78
+ * that these are the axes this corpus is narrowed by), then
79
+ * **the category's numeric attributes**, which are form fields the
80
+ * composer collects and which no flag in the schema distinguishes from an
81
+ * axis a buyer uses.
82
+ *
83
+ * Band 3's attributes keep their controls, whole, in the panel behind the
84
+ * leading circle and at the tail of this row. `buildRangeGroups` is untouched:
85
+ * a rule that DELETED them would have to answer "on what evidence", and
86
+ * `facet_meta.skipped` — the one server signal that names a slug — means the
87
+ * counter ran out of plan slots at `MAX_FACET_FIELDS`, not that a person
88
+ * cannot filter by it. `r.<slug>` still answers for a skipped slug.
89
+ *
90
+ * ## Why the CATEGORY leads the row
91
+ *
92
+ * The owner's navigation model puts levels 1-2 of the catalogue on tiles and
93
+ * every level below them behind a cascading child selector, chosen "as a
94
+ * characteristic" — on the result list and in the composer alike. On the SERP
95
+ * that selector is a chip like any other, and it is the FIRST one, because
96
+ * narrowing the category is what changes which other chips exist at all: the
97
+ * facet plan is derived from the leaf category, so every chip to its right is
98
+ * downstream of it.
99
+ *
100
+ * The pair does not draw that selector. Walking the tree belongs to
101
+ * `categories-react`, so the chip is the host's `renderCategoryFilter` in the
102
+ * same sheet the other chips open, and a row whose host filled no such slot
103
+ * renders exactly as it did before. There is no synthesized category FACET
104
+ * anywhere here and there must not be one: the server counts no category
105
+ * buckets and the index has no read path for them, so any count this row drew
106
+ * beside a child category would be a number nobody could check.
49
107
  */
50
108
  import { useState } from "react";
51
109
  import type { CSSProperties, ReactElement, ReactNode } from "react";
@@ -55,14 +113,19 @@ import { useT } from "@stapel/core";
55
113
  import { radii, spacing } from "@stapel/tokens";
56
114
  import type { FeatureDef } from "@stapel/attributes-react";
57
115
  import { useFacetPanel } from "../headless/FacetPanel.js";
116
+ import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
58
117
  import { useSearchState } from "../headless/SearchStateProvider.js";
59
118
  import { buildRangeGroups } from "../state/ranges.js";
119
+ import type { RangeGroup } from "../state/ranges.js";
60
120
  import type { FacetGroup } from "../state/facets.js";
61
121
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
62
122
  import { FacetGroupControl } from "./FacetGroupControl.js";
63
123
  import { RangeFilterRow } from "./RangeFilterRow.js";
64
124
  import { geoSummaryFallback } from "./FacetPanelPane.js";
65
- import type { GeoFilterSlotProps } from "./FacetPanelPane.js";
125
+ import type {
126
+ CategoryFilterSlotProps,
127
+ GeoFilterSlotProps,
128
+ } from "./FacetPanelPane.js";
66
129
  import { CHIP_GEO_TEST_IDS, GeoSheet, useApplyLabel } from "./geoSheet.js";
67
130
 
68
131
  /** The class the scroller carries, for {@link chipRowCss}. */
@@ -102,11 +165,99 @@ const CHIP: CSSProperties = { flex: "0 0 auto", borderRadius: radii.full };
102
165
  /** Which picker is open, if any. `null` closes everything. */
103
166
  type OpenChip = string | null;
104
167
 
168
+ /**
169
+ * The three bands the row's non-slot chips fall into, most-evidenced first.
170
+ *
171
+ * `core_range` is the SERVER's own declaration (`facet_meta.core_ranges`) that
172
+ * an axis exists for every document in every category. `facet` is a group the
173
+ * server COUNTED for this search, which arrives with the remaining counts that
174
+ * make it a drill-down. `attribute_range` is a numeric field the category
175
+ * declares and nothing ranks — see this module's ordering note.
176
+ */
177
+ export type ChipBand = "core_range" | "facet" | "attribute_range";
178
+
179
+ /** Band order, stated once. */
180
+ export const CHIP_BAND_ORDER: readonly ChipBand[] = [
181
+ "core_range",
182
+ "facet",
183
+ "attribute_range",
184
+ ];
185
+
186
+ /** One non-slot chip, resolved to the band and the applied state it sorts by. */
187
+ export type ChipSpec =
188
+ | { readonly band: "core_range" | "attribute_range"; readonly range: RangeGroup }
189
+ | { readonly band: "facet"; readonly facet: FacetGroup };
190
+
191
+ /** Has the person set this filter? */
192
+ function specApplied(spec: ChipSpec): boolean {
193
+ return spec.band === "facet" ? spec.facet.selected.length > 0 : spec.range.active;
194
+ }
195
+
196
+ /**
197
+ * The row's order: applied first, then band, then the order each source
198
+ * already came in.
199
+ *
200
+ * Exported because the order IS the fix — the defect it closes is invisible to
201
+ * a test that only asks whether a chip exists, and a rule stated in prose next
202
+ * to an unexercised implementation is a rule that drifts.
203
+ *
204
+ * `Array.prototype.sort` is stable in every runtime this pair supports, so
205
+ * equal-ranked chips keep the order `buildRangeGroups` and `buildFacetGroups`
206
+ * gave them — a closed set's authored order survives all the way to the row.
207
+ */
208
+ export function orderChipFilters(
209
+ ranges: readonly RangeGroup[],
210
+ facets: readonly FacetGroup[]
211
+ ): readonly ChipSpec[] {
212
+ const specs: ChipSpec[] = [
213
+ ...ranges.map(
214
+ (range): ChipSpec =>
215
+ range.core
216
+ ? { band: "core_range", range }
217
+ : { band: "attribute_range", range }
218
+ ),
219
+ ...facets.map((facet): ChipSpec => ({ band: "facet", facet })),
220
+ ];
221
+ return [...specs].sort((a, b) => {
222
+ const applied = Number(specApplied(b)) - Number(specApplied(a));
223
+ if (applied !== 0) return applied;
224
+ return CHIP_BAND_ORDER.indexOf(a.band) - CHIP_BAND_ORDER.indexOf(b.band);
225
+ });
226
+ }
227
+
105
228
  export interface FilterChipsProps {
106
229
  /** The category's feature schema — the source of option labels, of which
107
- * slugs get a range chip, and of how each group is drawn. */
230
+ * slugs get a range chip, of which slugs are choosable at all, and of how
231
+ * each group is drawn. */
108
232
  readonly categoryFeatures?: readonly FeatureDef[];
109
233
  readonly locale?: string;
234
+ /**
235
+ * Name the values neither the answer nor the schema names — see
236
+ * {@link FacetLabelResolver}. The same prop reaches the filter panel, so a
237
+ * value cannot read one way on a chip and another way inside the sheet.
238
+ */
239
+ readonly resolveFacetLabels?: FacetLabelResolver;
240
+ /**
241
+ * The catalogue picker (`categories-react`), same slot the panel takes.
242
+ *
243
+ * Filled, it becomes the row's LEADING chip and opens in the same sheet as
244
+ * every other chip. Unfilled, the row draws no category chip at all — and
245
+ * that is not a constraint left without a control: the whole panel is one
246
+ * tap away behind the leading circle, and it carries the "search the whole
247
+ * catalogue" button for a link that arrived narrowed.
248
+ */
249
+ readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
250
+ /**
251
+ * What the current category is CALLED, in words.
252
+ *
253
+ * The pair holds a `root/leaf` PATH of slugs and nothing that turns one into
254
+ * a catalogue name — the tree belongs to `categories-react`, and whoever
255
+ * rendered the picker has the name already. Absent, the chip falls back to
256
+ * the path's last segment, which is the honest half-answer: it is what the
257
+ * search is actually narrowed to, it fits a 390px row where the whole path
258
+ * does not, and it is never invented.
259
+ */
260
+ readonly categoryLabel?: ReactNode;
110
261
  /** The location control (`geo-react`), same slot the panel takes. Without
111
262
  * it the location chip appears only when the URL already carries a point,
112
263
  * so a shared link can still be widened. */
@@ -123,6 +274,16 @@ export interface FilterChipsProps {
123
274
  /** Open the whole panel — the leading chip's action. The page owns that
124
275
  * sheet, because the page is the surface it covers. */
125
276
  readonly onOpenAll: () => void;
277
+ /**
278
+ * Draw the location chip. Default `true`.
279
+ *
280
+ * `false` for a surface that already states the location ABOVE this row —
281
+ * `<LocationSummaryLine>` is that surface, and the phone SERP mounts both.
282
+ * Together they printed two location controls one line apart — the summary
283
+ * sentence over the chip's own prompt — opening two different sheets over
284
+ * one filter, which is the same constraint asked about twice.
285
+ */
286
+ readonly geoChip?: boolean;
126
287
  }
127
288
 
128
289
  /**
@@ -140,24 +301,47 @@ function chipLabel(group: FacetGroup, t: (key: string, p?: Record<string, unknow
140
301
  : `${first.label}${t(SEARCH_I18N_KEYS.filtersChipMore, { count: chosen.length - 1 })}`;
141
302
  }
142
303
 
143
- export function FilterChips(props: FilterChipsProps): ReactElement {
304
+ /**
305
+ * The last segment of a `root/leaf` category path.
306
+ *
307
+ * A chip has room for one word, and the whole path is what the panel prints on
308
+ * a surface with a column to spend. This is the pair's LAST resort — a host
309
+ * that renders the picker knows the catalogue's own name for the node and
310
+ * passes `categoryLabel` — but it is a real narrowing stated with a value the
311
+ * URL genuinely carries, which is the line the geo chip draws too: never print
312
+ * a coordinate, always print the name you actually have.
313
+ */
314
+ export function categoryLeaf(path: string): string {
315
+ const parts = path.split("/").filter((part) => part.length > 0);
316
+ return parts[parts.length - 1] ?? path;
317
+ }
318
+
319
+ export function FilterChips(props: FilterChipsProps): ReactElement | null {
144
320
  const t = useT();
145
- const { state } = useSearchState();
321
+ const { state, setCategory } = useSearchState();
146
322
  const bag = useFacetPanel({
147
323
  ...(props.categoryFeatures !== undefined
148
324
  ? { categoryFeatures: props.categoryFeatures }
149
325
  : {}),
150
326
  ...(props.locale !== undefined ? { locale: props.locale } : {}),
327
+ ...(props.resolveFacetLabels !== undefined
328
+ ? { resolveFacetLabels: props.resolveFacetLabels }
329
+ : {}),
151
330
  });
152
331
  const applyLabel = useApplyLabel();
153
332
  const surface = useDialogSurface();
154
333
  const [open, setOpen] = useState<OpenChip>(null);
155
334
 
335
+ // Same source as the panel's rows, so the phone chip row and the desktop
336
+ // panel cannot disagree about which axes exist: the core columns come from
337
+ // the ANSWER (`facet_meta.core_ranges`) and the currency off its cards.
156
338
  const ranges = buildRangeGroups({
157
339
  state,
158
340
  ...(props.categoryFeatures !== undefined
159
341
  ? { categoryFeatures: props.categoryFeatures }
160
342
  : {}),
343
+ coreRanges: bag.coreRanges,
344
+ ...(bag.currency !== undefined ? { currency: bag.currency } : {}),
161
345
  t,
162
346
  });
163
347
  const groups =
@@ -197,8 +381,25 @@ export function FilterChips(props: FilterChipsProps): ReactElement {
197
381
  </SkinDialog>
198
382
  );
199
383
 
384
+ // The category chip exists only where a host can actually draw the picker:
385
+ // a chip that opened an empty sheet would be a filter affordance leading
386
+ // nowhere, and the whole panel behind the leading circle already carries the
387
+ // control that widens a narrowed link.
388
+ const showCategoryChip = props.renderCategoryFilter !== undefined;
389
+ const category = state.category;
390
+ // Same rule as every other chip: the CHOICE when there is one, the filter's
391
+ // own name when there is not.
392
+ const categoryChipLabel: ReactNode =
393
+ category === undefined
394
+ ? t(SEARCH_I18N_KEYS.categoryTitle)
395
+ : (props.categoryLabel ?? categoryLeaf(category));
396
+
397
+ const ordered = orderChipFilters(ranges, groups);
398
+
200
399
  const geo = state.geo;
201
- const showGeoChip = props.renderGeoFilter !== undefined || geo !== undefined;
400
+ const showGeoChip =
401
+ props.geoChip !== false &&
402
+ (props.renderGeoFilter !== undefined || geo !== undefined);
202
403
  // Nothing applied: the chip is the FILTER's name ("Location"), because there
203
404
  // is no constraint to describe yet. Applied: the host's name for the place,
204
405
  // and failing that the sentence that admits the pair does not know it.
@@ -207,6 +408,21 @@ export function FilterChips(props: FilterChipsProps): ReactElement {
207
408
  ? t(SEARCH_I18N_KEYS.geoTitle)
208
409
  : (props.geoLabel ?? geoSummaryFallback(geo, t));
209
410
 
411
+ /*
412
+ * A row of one button is not a chip row.
413
+ *
414
+ * The leading circle is the whole-panel door, and it is the only child this
415
+ * row is guaranteed. On a deployment whose plan has no facets for the
416
+ * current query — a free-text search with no category is exactly that: the
417
+ * plan comes from the CATEGORY's feature defs, so `facets` comes back `{}` —
418
+ * the row rendered as a lone circle floating between the location line and
419
+ * the results, a third filter affordance next to two working ones. When
420
+ * there is nothing to state, the row states nothing and the surface above
421
+ * keeps its own door.
422
+ */
423
+ const hasChips = showCategoryChip || showGeoChip || ordered.length > 0;
424
+ if (!hasChips) return null;
425
+
210
426
  return (
211
427
  <>
212
428
  <style href={CHIP_ROW_STYLE_HREF} precedence="default">
@@ -238,59 +454,95 @@ export function FilterChips(props: FilterChipsProps): ReactElement {
238
454
  {bag.activeFilters > 0 && <ActiveDot />}
239
455
  </Button>
240
456
 
241
- {showGeoChip && (
457
+ {/* FIRST, before every facet chip: narrowing the category is what
458
+ decides which facet chips exist at all. */}
459
+ {showCategoryChip && (
242
460
  <Button
243
461
  style={CHIP}
244
462
  shape="round"
245
- type={geo !== undefined ? "primary" : "default"}
246
- data-testid="search-chip-geo"
463
+ type={category !== undefined ? "primary" : "default"}
464
+ data-testid="search-chip-category"
247
465
  data-analytics="none"
248
466
  data-analytics-reason="a filter is a read, not a flow step"
249
467
  onClick={() => {
250
- setOpen("geo");
468
+ setOpen("category");
251
469
  }}
252
470
  >
253
- {geoChipLabel}
471
+ {categoryChipLabel}
254
472
  </Button>
255
473
  )}
256
474
 
257
- {ranges.map((group) => (
475
+ {showGeoChip && (
258
476
  <Button
259
- key={group.slug}
260
477
  style={CHIP}
261
478
  shape="round"
262
- type={group.active ? "primary" : "default"}
263
- data-testid={`search-chip-range-${group.slug}`}
479
+ type={geo !== undefined ? "primary" : "default"}
480
+ data-testid="search-chip-geo"
264
481
  data-analytics="none"
265
482
  data-analytics-reason="a filter is a read, not a flow step"
266
483
  onClick={() => {
267
- setOpen(`range:${group.slug}`);
484
+ setOpen("geo");
268
485
  }}
269
486
  >
270
- {group.label}
487
+ {geoChipLabel}
271
488
  </Button>
272
- ))}
489
+ )}
273
490
 
274
- {groups.map((group) => (
275
- <Button
276
- key={group.slug}
277
- style={CHIP}
278
- shape="round"
279
- type={group.selected.length > 0 ? "primary" : "default"}
280
- data-testid={`search-chip-${group.slug}`}
281
- data-analytics="none"
282
- data-analytics-reason="a filter is a read, not a flow step"
283
- onClick={() => {
284
- setOpen(`facet:${group.slug}`);
285
- }}
286
- >
287
- {chipLabel(group, t)}
288
- </Button>
289
- ))}
491
+ {/* One list, in the row's stated order — see this module's ordering
492
+ note. Rendering ranges and facets as two separate `.map`s is what
493
+ put seven parcel dimensions in front of the price. */}
494
+ {ordered.map((spec) =>
495
+ spec.band === "facet" ? (
496
+ <Button
497
+ key={`facet:${spec.facet.slug}`}
498
+ style={CHIP}
499
+ shape="round"
500
+ type={spec.facet.selected.length > 0 ? "primary" : "default"}
501
+ data-testid={`search-chip-${spec.facet.slug}`}
502
+ data-band={spec.band}
503
+ data-analytics="none"
504
+ data-analytics-reason="a filter is a read, not a flow step"
505
+ onClick={() => {
506
+ setOpen(`facet:${spec.facet.slug}`);
507
+ }}
508
+ >
509
+ {chipLabel(spec.facet, t)}
510
+ </Button>
511
+ ) : (
512
+ <Button
513
+ key={`range:${spec.range.slug}`}
514
+ style={CHIP}
515
+ shape="round"
516
+ type={spec.range.active ? "primary" : "default"}
517
+ data-testid={`search-chip-range-${spec.range.slug}`}
518
+ data-band={spec.band}
519
+ data-analytics="none"
520
+ data-analytics-reason="a filter is a read, not a flow step"
521
+ onClick={() => {
522
+ setOpen(`range:${spec.range.slug}`);
523
+ }}
524
+ >
525
+ {spec.range.label}
526
+ </Button>
527
+ )
528
+ )}
290
529
  </div>
291
530
 
292
531
  {/* One sheet per chip, rendered only for the open one: a dozen mounted
293
532
  dialogs is a dozen focus traps waiting for a stray `open`. */}
533
+ {open === "category" &&
534
+ props.renderCategoryFilter !== undefined &&
535
+ sheetFor(
536
+ "category",
537
+ t(SEARCH_I18N_KEYS.categoryTitle),
538
+ props.renderCategoryFilter({
539
+ value: category,
540
+ onChange: (path) => {
541
+ setCategory(path);
542
+ },
543
+ })
544
+ )}
545
+
294
546
  {open?.startsWith("facet:") === true &&
295
547
  (() => {
296
548
  const slug = open.slice("facet:".length);
@@ -151,6 +151,11 @@ export function LocationSummaryLine(
151
151
  )}
152
152
  </Button>
153
153
 
154
+ {/* "Filters", not "All filters": this end of the row shares 390px
155
+ with a place name that can run to fifteen characters, and the word
156
+ the person is looking for is the noun. The panel's own heading
157
+ still says "All filters" — there it is naming a sheet, not a
158
+ door. */}
154
159
  {/* The count, not a dot: this row has the width to say how many. */}
155
160
  <Badge
156
161
  count={activeFilters}
@@ -166,7 +171,7 @@ export function LocationSummaryLine(
166
171
  data-analytics-reason="opening the filter sheet is a read, not a flow step"
167
172
  onClick={props.onOpenAll}
168
173
  >
169
- {t(SEARCH_I18N_KEYS.filtersAll)}
174
+ {t(SEARCH_I18N_KEYS.filtersShort)}
170
175
  </Button>
171
176
  </Badge>
172
177
  </Flex>
@@ -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 } : {})}