@stapel/search-react 0.16.0 → 0.18.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 (56) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/dist/api/generated/schema.d.ts +18 -10
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/default/FacetGroupControl.d.ts +10 -0
  5. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  6. package/dist/default/FacetGroupControl.js +122 -27
  7. package/dist/default/FacetGroupControl.js.map +1 -1
  8. package/dist/default/FacetPanelPane.d.ts +25 -38
  9. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  10. package/dist/default/FacetPanelPane.js +157 -6
  11. package/dist/default/FacetPanelPane.js.map +1 -1
  12. package/dist/default/FilterChips.d.ts +25 -0
  13. package/dist/default/FilterChips.d.ts.map +1 -1
  14. package/dist/default/FilterChips.js +39 -3
  15. package/dist/default/FilterChips.js.map +1 -1
  16. package/dist/default/SearchPage.d.ts.map +1 -1
  17. package/dist/default/SearchPage.js +10 -1
  18. package/dist/default/SearchPage.js.map +1 -1
  19. package/dist/default/SearchResultsPane.d.ts +9 -1
  20. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  21. package/dist/default/SearchResultsPane.js +20 -7
  22. package/dist/default/SearchResultsPane.js.map +1 -1
  23. package/dist/default/index.d.ts +1 -1
  24. package/dist/default/index.d.ts.map +1 -1
  25. package/dist/default/index.js +1 -1
  26. package/dist/default/index.js.map +1 -1
  27. package/dist/i18n/es.d.ts.map +1 -1
  28. package/dist/i18n/es.js +5 -0
  29. package/dist/i18n/es.js.map +1 -1
  30. package/dist/i18n/keys.d.ts +19 -0
  31. package/dist/i18n/keys.d.ts.map +1 -1
  32. package/dist/i18n/keys.js +25 -0
  33. package/dist/i18n/keys.js.map +1 -1
  34. package/dist/i18n/ru.d.ts.map +1 -1
  35. package/dist/i18n/ru.js +7 -0
  36. package/dist/i18n/ru.js.map +1 -1
  37. package/dist/state/facets.d.ts +14 -0
  38. package/dist/state/facets.d.ts.map +1 -1
  39. package/dist/state/facets.js +19 -0
  40. package/dist/state/facets.js.map +1 -1
  41. package/llms.txt +2 -2
  42. package/manifest.json +8 -2
  43. package/nav-manifest.json +1 -1
  44. package/package.json +7 -7
  45. package/src/analytics/generated/events.json +1 -1
  46. package/src/api/generated/schema.ts +18 -10
  47. package/src/default/FacetGroupControl.tsx +217 -60
  48. package/src/default/FacetPanelPane.tsx +226 -25
  49. package/src/default/FilterChips.tsx +69 -2
  50. package/src/default/SearchPage.tsx +15 -1
  51. package/src/default/SearchResultsPane.tsx +20 -7
  52. package/src/default/index.ts +2 -0
  53. package/src/i18n/es.ts +5 -0
  54. package/src/i18n/keys.ts +25 -0
  55. package/src/i18n/ru.ts +7 -0
  56. package/src/state/facets.ts +19 -0
@@ -133,6 +133,7 @@ import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
133
133
  import { useSearchState } from "../headless/SearchStateProvider.js";
134
134
  import { buildRangeGroups } from "../state/ranges.js";
135
135
  import type { RangeGroup } from "../state/ranges.js";
136
+ import { facetCoverage } from "../state/facets.js";
136
137
  import type { FacetGroup } from "../state/facets.js";
137
138
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
138
139
  import { FacetGroupControl } from "./FacetGroupControl.js";
@@ -242,10 +243,48 @@ export function orderChipFilters(
242
243
  return [...specs].sort((a, b) => {
243
244
  const applied = Number(specApplied(b)) - Number(specApplied(a));
244
245
  if (applied !== 0) return applied;
245
- return CHIP_BAND_ORDER.indexOf(a.band) - CHIP_BAND_ORDER.indexOf(b.band);
246
+ const band = CHIP_BAND_ORDER.indexOf(a.band) - CHIP_BAND_ORDER.indexOf(b.band);
247
+ if (band !== 0) return band;
248
+ // WITHIN the counted-facet band: coverage — the answer's own evidence of
249
+ // which axes this corpus actually fills (D16 reopen: an imported
250
+ // catalogue gave the phones leaf option tables for its wholesale
251
+ // plumbing, and schema order put them ahead of the brand). A group the
252
+ // server did not count sums to zero and trails every counted one; ties
253
+ // keep the authored order because the sort is stable.
254
+ if (a.band === "facet" && b.band === "facet") {
255
+ return facetCoverage(b.facet) - facetCoverage(a.facet);
256
+ }
257
+ return 0;
246
258
  });
247
259
  }
248
260
 
261
+ /**
262
+ * How many banded chips the row draws before the "more" door — enough for the
263
+ * axes with real evidence behind them, small enough that the tail of a
264
+ * 44-axis cars leaf lives in the panel instead of in four flicks. A host
265
+ * passes `maxRowChips` to move it, or `null` to disable the door.
266
+ */
267
+ export const CHIP_ROW_CAP = 8;
268
+
269
+ /**
270
+ * The visible row and what the door owes: the first `max` specs — and EVERY
271
+ * applied one, however many, because a constraint on screen must keep the
272
+ * control that removes it (the same rule the barren filter follows). The
273
+ * overflow count is what the door chip prints; the chips behind it are not
274
+ * deleted, they are the panel's — one tap behind the door itself.
275
+ */
276
+ export function capChipRow(
277
+ specs: readonly ChipSpec[],
278
+ max: number | null
279
+ ): { readonly visible: readonly ChipSpec[]; readonly overflow: number } {
280
+ if (max === null || specs.length <= max) {
281
+ return { visible: specs, overflow: 0 };
282
+ }
283
+ const applied = specs.filter(specApplied).length;
284
+ const cut = Math.max(max, applied);
285
+ return { visible: specs.slice(0, cut), overflow: specs.length - cut };
286
+ }
287
+
249
288
  export interface FilterChipsProps {
250
289
  /** The category's feature schema — the source of option labels, of which
251
290
  * slugs get a range chip, of which slugs are choosable at all, and of how
@@ -295,6 +334,13 @@ export interface FilterChipsProps {
295
334
  /** Open the whole panel — the leading chip's action. The page owns that
296
335
  * sheet, because the page is the surface it covers. */
297
336
  readonly onOpenAll: () => void;
337
+ /**
338
+ * The row's chip budget before the "more" door — see {@link CHIP_ROW_CAP}
339
+ * (the default). `null` draws every chip, door-less, as the row did before
340
+ * it was capped. Applied filters never count against the budget's loss:
341
+ * they are always drawn.
342
+ */
343
+ readonly maxRowChips?: number | null;
298
344
  /**
299
345
  * Draw the location chip. Default `true`.
300
346
  *
@@ -437,7 +483,11 @@ export function FilterChips(props: FilterChipsProps): ReactElement | null {
437
483
  // need. Applied ones stay: a constraint always keeps the control that
438
484
  // removes it.
439
485
  const barren = bag.counted.length > 0 && bag.candidates === 0;
440
- const ordered = orderChipFilters(ranges, groups, { barren });
486
+ const orderedAll = orderChipFilters(ranges, groups, { barren });
487
+ const { visible: ordered, overflow } = capChipRow(
488
+ orderedAll,
489
+ props.maxRowChips === undefined ? CHIP_ROW_CAP : props.maxRowChips
490
+ );
441
491
 
442
492
  const geo = state.geo;
443
493
  const showGeoChip =
@@ -569,6 +619,23 @@ export function FilterChips(props: FilterChipsProps): ReactElement | null {
569
619
  </Button>
570
620
  )
571
621
  )}
622
+
623
+ {/* The "more" door (D16): the capped tail is not deleted, it is one
624
+ tap away — this opens the SAME full panel the leading circle
625
+ does, where every cut control lives whole. The count keeps the
626
+ door honest about how much it is standing in front of. */}
627
+ {overflow > 0 && (
628
+ <Button
629
+ style={CHIP}
630
+ shape="round"
631
+ data-testid="search-chips-overflow"
632
+ data-analytics="none"
633
+ data-analytics-reason="opening the filter sheet is a read, not a flow step"
634
+ onClick={props.onOpenAll}
635
+ >
636
+ {t(SEARCH_I18N_KEYS.filtersChipOverflow, { count: overflow })}
637
+ </Button>
638
+ )}
572
639
  </div>
573
640
 
574
641
  {/* One sheet per chip, rendered only for the open one: a dozen mounted
@@ -139,6 +139,15 @@ const RAIL: CSSProperties = {
139
139
  maxHeight: "100dvh",
140
140
  overflowY: "auto",
141
141
  overscrollBehavior: "contain",
142
+ // The inner scroll must be VISIBLE. On overlay-scrollbar platforms (every
143
+ // Mac by default, most phones) an `overflow-y: auto` column shows no
144
+ // scrollbar until a pointer happens to scroll INSIDE it — so a rail taller
145
+ // than the window is indistinguishable from a rail that ends at the fold,
146
+ // and the walker measured 5717px of panel whose tail nothing signposted.
147
+ // A thin, always-there scrollbar is the sign there is more; the stable
148
+ // gutter keeps the panel's right edge from jumping when it appears.
149
+ scrollbarWidth: "thin",
150
+ scrollbarGutter: "stable",
142
151
  // Room for the focus ring of the last control against the scroll edge.
143
152
  paddingBlockEnd: spacing[2],
144
153
  };
@@ -424,9 +433,14 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
424
433
  a hole, just a smaller one. */}
425
434
  {/* In the sheet the dialog's own title already says "Filters"; the panel
426
435
  repeating it printed the word twice, one line apart. */}
436
+ {/* Per layout: the sheet gets no second "Filters" title (the dialog owns
437
+ one) and no footer bar — its own "Show N results" footer is already
438
+ the count AND the exit. The rail gets the sticky footer bar: desktop
439
+ filters apply instantly, and without it the only feedback was a
440
+ result count scrolled out of sight above the fold. */}
427
441
  {filtersEmpty ? null : (
428
442
  <FacetPanelPane
429
- {...(layout === "sheet" ? { heading: null } : {})}
443
+ {...(layout === "sheet" ? { heading: null } : { footerBar: true })}
430
444
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
431
445
  {...(props.renderEmptyExits !== undefined
432
446
  ? { renderEmptyExits: props.renderEmptyExits }
@@ -70,20 +70,33 @@ export type SearchResultsRenderer = (
70
70
  * width of whatever it was dropped into — the visual pass measured a 2560px
71
71
  * pane with four cards floating at the top of it and a status row 1350px from
72
72
  * the buttons that act on it (class C-NOMAXW).
73
+ *
74
+ * 1400 and not the 1120 it started at: a card GRID is not prose — its rows
75
+ * have no line to lose the thread of — so its measure is the fleet's widest
76
+ * content column, not a paragraph's. At 1120 the cap quietly overrode the
77
+ * grid's own 260px floor (a 1440px desktop drew three 363px cards inside a
78
+ * 1392px content column, the very hole the floor was lowered to fill); at
79
+ * 1400 the floor decides — five columns open, four beside the filter rail —
80
+ * and the 2560px pane the constant was written against is still capped.
73
81
  */
74
- export const RESULTS_MAX_WIDTH = 1120;
82
+ export const RESULTS_MAX_WIDTH = 1400;
75
83
 
76
84
  /**
77
- * The results grid. `auto-fill` + `minmax(280px, 1fr)`: as many columns as fit,
78
- * each at least a readable card and never wider than its share — a catalogue
79
- * on a 1400px desktop is four columns, not four full-bleed rows, and the same
85
+ * The results grid. `auto-fill` + `minmax(260px, 1fr)`: as many columns as fit,
86
+ * each at least a readable card and never wider than its share — and the same
80
87
  * declaration collapses to one column on a phone with no breakpoint to
81
- * maintain. 280px is the width below which the default card's title, price and
82
- * location stop fitting on their own lines.
88
+ * maintain.
89
+ *
90
+ * The floor was 280px and is 260 deliberately: at a 1400px content measure the
91
+ * grid now draws five columns instead of three wide ones, and beside the 280px
92
+ * filter rail, four. The default card's own minimum — title, price and
93
+ * location each on their own line — still fits at 260; the 20px the old floor
94
+ * bought went into card whitespace, not into legibility, and cost a whole
95
+ * column of listings on the reference desktop.
83
96
  */
84
97
  const RESULTS_GRID: CSSProperties = {
85
98
  display: "grid",
86
- gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
99
+ gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
87
100
  gap: spacing[3],
88
101
  alignItems: "stretch",
89
102
  };
@@ -51,8 +51,10 @@ export type {
51
51
  export {
52
52
  FilterChips,
53
53
  CHIP_BAND_ORDER,
54
+ CHIP_ROW_CAP,
54
55
  CHIP_ROW_CLASS,
55
56
  CHIP_ROW_STYLE_HREF,
57
+ capChipRow,
56
58
  categoryLeaf,
57
59
  chipRowCss,
58
60
  orderChipFilters,
package/src/i18n/es.ts CHANGED
@@ -87,6 +87,10 @@ export const searchI18nBundleEs: I18nDictionary = {
87
87
  "search.facets.range_to_aria": "{feature}, hasta",
88
88
  "search.facets.show_all": "Ver todos ({count})",
89
89
  "search.facets.show_less": "Ver menos",
90
+ "search.facets.search": "Buscar un filtro",
91
+ "search.facets.search_empty": "Ningún filtro coincide con esto",
92
+ "search.facets.match_count.one": "{count} anuncio coincide",
93
+ "search.facets.match_count.other": "{count} anuncios coinciden",
90
94
  "search.facets.range_invalid":
91
95
  "«Desde» es mayor que «hasta», así que nada podría coincidir. Intercámbialos para aplicar el rango.",
92
96
 
@@ -101,6 +105,7 @@ export const searchI18nBundleEs: I18nDictionary = {
101
105
  "search.filters.all": "Todos los filtros",
102
106
  "search.filters.short": "Filtros",
103
107
  "search.filters.chip_more": ", +{count}",
108
+ "search.filters.chips_overflow": "Más · {count}",
104
109
 
105
110
  "search.empty.exits_title": "Prueba a ampliar la búsqueda",
106
111
  "search.empty.up_a_level": "Subir un nivel",
package/src/i18n/keys.ts CHANGED
@@ -138,6 +138,22 @@ export const SEARCH_I18N_KEYS = {
138
138
  * tail is longer than one row. */
139
139
  facetsShowAll: "search.facets.show_all",
140
140
  facetsShowLess: "search.facets.show_less",
141
+ /** The box that searches the FILTER PANEL itself — drawn from six groups
142
+ * up, because a panel a person can no longer scan is a panel they have to
143
+ * query. It narrows presentation only and never touches the URL. */
144
+ facetsSearch: "search.facets.search",
145
+ /** Said where the groups were when the panel-search matches none of them.
146
+ * Distinct from `facetsEmpty`, which says the SEARCH offers no filters —
147
+ * here the filters exist and the query missed. */
148
+ facetsSearchEmpty: "search.facets.search_empty",
149
+ /**
150
+ * The rail's sticky footer: what the applied filters LEFT — "N listings
151
+ * match". A PLURAL FAMILY: it counts a noun in words. Desktop filters
152
+ * apply instantly, so this is feedback, not an apply button; the floor
153
+ * case (`at_least`) reuses `resultsCountAtLeast` rather than growing a
154
+ * second family for the same number.
155
+ */
156
+ facetsMatchCount: "search.facets.match_count",
141
157
 
142
158
  // ── the filter panel as a whole (phone sheet + host slots) ───────────────
143
159
  filtersOpen: "search.filters.open",
@@ -161,6 +177,9 @@ export const SEARCH_I18N_KEYS = {
161
177
  /** A chip filtering on more than one value: "Bosch, +2". Not a plural
162
178
  * family — nothing is being counted in words. */
163
179
  filtersChipMore: "search.filters.chip_more",
180
+ /** The chip standing in for the row's capped tail — opens the full panel.
181
+ * `{count}` is how many chips it hides (D16). */
182
+ filtersChipOverflow: "search.filters.chips_overflow",
164
183
 
165
184
  // ── the way OUT of a search that found nothing ───────────────────────────
166
185
  /** Caption above the exit buttons. Not a second "nothing found" — that
@@ -308,6 +327,7 @@ export const SEARCH_I18N_PLURAL_KEYS: readonly SearchI18nKey[] = [
308
327
  SEARCH_I18N_KEYS.filtersShowCount,
309
328
  SEARCH_I18N_KEYS.filtersShowCountAtLeast,
310
329
  SEARCH_I18N_KEYS.boxCategoryCount,
330
+ SEARCH_I18N_KEYS.facetsMatchCount,
311
331
  ];
312
332
 
313
333
  /**
@@ -392,6 +412,10 @@ export const searchI18nBundleEn: Record<string, string> = {
392
412
  "search.facets.range_to_aria": "{feature}, up to",
393
413
  "search.facets.show_all": "Show all ({count})",
394
414
  "search.facets.show_less": "Show fewer",
415
+ "search.facets.search": "Find a filter",
416
+ "search.facets.search_empty": "No filter matches this",
417
+ "search.facets.match_count.one": "{count} listing matches",
418
+ "search.facets.match_count.other": "{count} listings match",
395
419
  "search.facets.range_invalid":
396
420
  "“From” is larger than “to”, so nothing could match. Swap them to apply this range.",
397
421
 
@@ -406,6 +430,7 @@ export const searchI18nBundleEn: Record<string, string> = {
406
430
  "search.filters.all": "All filters",
407
431
  "search.filters.short": "Filters",
408
432
  "search.filters.chip_more": ", +{count}",
433
+ "search.filters.chips_overflow": "More · {count}",
409
434
 
410
435
  "search.empty.exits_title": "Try widening the search",
411
436
  "search.empty.up_a_level": "Go up a level",
package/src/i18n/ru.ts CHANGED
@@ -112,6 +112,12 @@ export const searchI18nBundleRu: I18nDictionary = {
112
112
  "search.facets.range_to_aria": "{feature}, до",
113
113
  "search.facets.show_all": "Показать все ({count})",
114
114
  "search.facets.show_less": "Свернуть",
115
+ "search.facets.search": "Найти фильтр",
116
+ "search.facets.search_empty": "Среди фильтров такого нет",
117
+ "search.facets.match_count.one": "Найдено {count} объявление",
118
+ "search.facets.match_count.few": "Найдено {count} объявления",
119
+ "search.facets.match_count.many": "Найдено {count} объявлений",
120
+ "search.facets.match_count.other": "Найдено {count} объявления",
115
121
  "search.facets.range_invalid":
116
122
  "«От» больше, чем «до», — под такой диапазон ничего не подойдёт. Поменяйте значения местами.",
117
123
 
@@ -130,6 +136,7 @@ export const searchI18nBundleRu: I18nDictionary = {
130
136
  "search.filters.all": "Все фильтры",
131
137
  "search.filters.short": "Фильтры",
132
138
  "search.filters.chip_more": ", +{count}",
139
+ "search.filters.chips_overflow": "Ещё {count}",
133
140
 
134
141
  "search.empty.exits_title": "Попробуйте расширить поиск",
135
142
  "search.empty.up_a_level": "Подняться на уровень выше",
@@ -128,6 +128,25 @@ export interface FacetGroup {
128
128
  readonly selected: readonly string[];
129
129
  }
130
130
 
131
+ /**
132
+ * How many candidate documents carry ANY value of this axis — the evidence
133
+ * that people actually fill this field in. Uncounted options are `null`
134
+ * (never `0` — different sentences) and contribute nothing, so a group the
135
+ * server did not count sums to zero.
136
+ *
137
+ * Two surfaces rank by it, for the same reason: the chip row orders its
138
+ * counted band by coverage (D16 reopen — an imported catalogue gave the
139
+ * phones leaf option tables for its wholesale plumbing, and schema order put
140
+ * them ahead of the brand), and the filter panel opens its top-coverage
141
+ * groups and collapses the rest (the 5717px rail). One definition, or the
142
+ * two surfaces drift into two opinions about which axes matter.
143
+ */
144
+ export function facetCoverage(group: FacetGroup): number {
145
+ let total = 0;
146
+ for (const option of group.options) total += option.count ?? 0;
147
+ return total;
148
+ }
149
+
131
150
  export interface BuildFacetGroupsInput {
132
151
  /** The envelope's `facets`: `{slug: {value: count}}`. */
133
152
  readonly facets: Readonly<Record<string, Readonly<Record<string, number>>>>;