@stapel/search-react 0.22.0 → 0.24.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 (74) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/MODULE.md +12 -0
  3. package/README.md +122 -6
  4. package/dist/api/types.d.ts +13 -0
  5. package/dist/api/types.d.ts.map +1 -1
  6. package/dist/api/types.js.map +1 -1
  7. package/dist/default/FacetGroupControl.d.ts +22 -4
  8. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  9. package/dist/default/FacetGroupControl.js +85 -9
  10. package/dist/default/FacetGroupControl.js.map +1 -1
  11. package/dist/default/FacetPanelPane.d.ts +41 -0
  12. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  13. package/dist/default/FacetPanelPane.js +72 -18
  14. package/dist/default/FacetPanelPane.js.map +1 -1
  15. package/dist/default/FilterChips.d.ts +86 -2
  16. package/dist/default/FilterChips.d.ts.map +1 -1
  17. package/dist/default/FilterChips.js +210 -4
  18. package/dist/default/FilterChips.js.map +1 -1
  19. package/dist/default/PartitionChips.d.ts +14 -0
  20. package/dist/default/PartitionChips.d.ts.map +1 -1
  21. package/dist/default/PartitionChips.js +30 -4
  22. package/dist/default/PartitionChips.js.map +1 -1
  23. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  24. package/dist/default/RangeFilterRow.js +65 -11
  25. package/dist/default/RangeFilterRow.js.map +1 -1
  26. package/dist/default/SearchPage.d.ts +65 -1
  27. package/dist/default/SearchPage.d.ts.map +1 -1
  28. package/dist/default/SearchPage.js +47 -5
  29. package/dist/default/SearchPage.js.map +1 -1
  30. package/dist/default/index.d.ts +4 -4
  31. package/dist/default/index.d.ts.map +1 -1
  32. package/dist/default/index.js +3 -3
  33. package/dist/default/index.js.map +1 -1
  34. package/dist/i18n/es.d.ts.map +1 -1
  35. package/dist/i18n/es.js +9 -0
  36. package/dist/i18n/es.js.map +1 -1
  37. package/dist/i18n/keys.d.ts +39 -0
  38. package/dist/i18n/keys.d.ts.map +1 -1
  39. package/dist/i18n/keys.js +49 -0
  40. package/dist/i18n/keys.js.map +1 -1
  41. package/dist/i18n/ru.d.ts.map +1 -1
  42. package/dist/i18n/ru.js +9 -0
  43. package/dist/i18n/ru.js.map +1 -1
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -2
  47. package/dist/index.js.map +1 -1
  48. package/dist/state/facets.d.ts +64 -0
  49. package/dist/state/facets.d.ts.map +1 -1
  50. package/dist/state/facets.js +144 -0
  51. package/dist/state/facets.js.map +1 -1
  52. package/dist/state/ranges.d.ts +22 -0
  53. package/dist/state/ranges.d.ts.map +1 -1
  54. package/dist/state/ranges.js +30 -0
  55. package/dist/state/ranges.js.map +1 -1
  56. package/llms.txt +1 -1
  57. package/manifest.json +14 -1
  58. package/nav-manifest.json +1 -1
  59. package/package.json +7 -7
  60. package/src/analytics/generated/events.json +1 -1
  61. package/src/api/types.ts +13 -0
  62. package/src/default/FacetGroupControl.tsx +139 -14
  63. package/src/default/FacetPanelPane.tsx +131 -19
  64. package/src/default/FilterChips.tsx +313 -6
  65. package/src/default/PartitionChips.tsx +47 -4
  66. package/src/default/RangeFilterRow.tsx +116 -1
  67. package/src/default/SearchPage.tsx +94 -3
  68. package/src/default/index.ts +27 -3
  69. package/src/i18n/es.ts +9 -0
  70. package/src/i18n/keys.ts +50 -0
  71. package/src/i18n/ru.ts +9 -0
  72. package/src/index.ts +4 -0
  73. package/src/state/facets.ts +152 -0
  74. package/src/state/ranges.ts +44 -0
@@ -66,7 +66,7 @@ import type { CSSProperties, ReactElement, ReactNode } from "react";
66
66
  import { Button, Flex } from "antd";
67
67
  import { SkinDialog, SkinTheme, useDialogSurface } from "@stapel/tokens-antd/skin";
68
68
  import { useT, useTPlural } from "@stapel/core";
69
- import { spacing } from "@stapel/tokens";
69
+ import { cssVar, spacing } from "@stapel/tokens";
70
70
  import type { FeatureDef } from "@stapel/attributes-react";
71
71
  import { SearchStateProvider, useSearchState } from "../headless/SearchStateProvider.js";
72
72
  import type { SearchParamsAdapter } from "../headless/SearchStateProvider.js";
@@ -126,6 +126,50 @@ export const FILTERS_RAIL_WIDTH = 280;
126
126
  * `alignSelf: flex-start` is load-bearing: a flex child stretches to the row's
127
127
  * height by default, and a stretched box has nothing to stick to.
128
128
  */
129
+ /** The class the rail's own scrollbar rules are hung on. */
130
+ export const RAIL_CLASS = "stapel-search-rail";
131
+
132
+ /** The `href` the hoisted rail sheet is deduplicated by. */
133
+ export const RAIL_STYLE_HREF = "stapel-search-rail";
134
+
135
+ /**
136
+ * The rail scrolls, and its scrollbar must not sit ON the filters.
137
+ *
138
+ * `scrollbar-width: thin` and `scrollbar-gutter: stable` (below, in `RAIL`)
139
+ * are the standard half of this and they are not enough: on every WebKit
140
+ * platform with overlay scrollbars — a Mac by default, every iOS browser —
141
+ * the bar is drawn OVER the content and the gutter reserves nothing, so the
142
+ * walker saw the bar lying across the right edge of the checkbox labels.
143
+ *
144
+ * So the rail also declares a CLASSIC scrollbar through the WebKit
145
+ * pseudo-elements: a bar with a real width, which pushes the panel's content
146
+ * in by exactly that much instead of floating above it, drawn in the token
147
+ * palette so it is the panel's own hairline in both themes rather than a
148
+ * hard-coded grey that glows in the dark one. `--stapel-*` custom properties
149
+ * resolve per theme at paint time, which is why this is a sheet and not a
150
+ * pair of computed inline values: an inline colour would freeze whichever
151
+ * theme was mounted first.
152
+ *
153
+ * Emitted as one hoisted `<style>` (React 19 dedupes by `href`), because a
154
+ * pseudo-element is unreachable from an inline style — the same reason
155
+ * `<LocationSummaryLine>` hoists one.
156
+ */
157
+ export function railScrollbarCss(): string {
158
+ const rail = `.${RAIL_CLASS}`;
159
+ return [
160
+ // A real width: an overlay bar occupies no space and therefore overlaps.
161
+ `${rail}::-webkit-scrollbar{inline-size:8px;block-size:8px}`,
162
+ `${rail}::-webkit-scrollbar-track{background:transparent}`,
163
+ `${rail}::-webkit-scrollbar-thumb{background:${cssVar("border")};` +
164
+ `border-radius:${cssVar("radius-full")}}`,
165
+ `${rail}::-webkit-scrollbar-thumb:hover{background:${cssVar("text-subtle")}}`,
166
+ // Firefox/Chromium's standard properties, stated here too so the rule
167
+ // travels with the class when the panel is used outside `<SearchPage>`.
168
+ `${rail}{scrollbar-width:thin;scrollbar-gutter:stable;` +
169
+ `scrollbar-color:${cssVar("border")} transparent}`,
170
+ ].join("\n");
171
+ }
172
+
129
173
  const RAIL: CSSProperties = {
130
174
  flex: `0 0 ${String(FILTERS_RAIL_WIDTH)}px`,
131
175
  // Both bounds, not just the upper one. `flex-shrink: 0` already holds the
@@ -196,7 +240,7 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
196
240
  * What the current category is CALLED — the chip's own text. The pair holds
197
241
  * a path of slugs and no way to turn one into a catalogue name; absent, the
198
242
  * chip states the path's last segment. See
199
- * {@link FilterChipsProps.categoryLabel}.
243
+ * {@link FilterChipsOpenerProps.categoryLabel}.
200
244
  */
201
245
  readonly categoryLabel?: ReactNode;
202
246
  /** The location control slot (`geo-react`). */
@@ -208,6 +252,14 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
208
252
  * coordinate, with or without it.
209
253
  */
210
254
  readonly geoLabel?: ReactNode;
255
+ /**
256
+ * The partition control, drawn at the top of the filter panel — see
257
+ * {@link FacetPanelPaneProps.partition}.
258
+ */
259
+ readonly partition?: ReactNode;
260
+ /** Facet slugs pinned above every other group — see
261
+ * {@link FacetPanelPaneProps.pinnedFacets}. */
262
+ readonly pinnedFacets?: readonly string[];
211
263
  /** Print the engine's list of uncounted facet slugs in the filter panel.
212
264
  * Default `false` — see {@link FacetPanelPaneProps.skippedNotice}. */
213
265
  readonly skippedNotice?: boolean;
@@ -253,6 +305,21 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
253
305
  * describes the whole page, not the results column of it.
254
306
  */
255
307
  readonly resultsHeader?: ReactNode;
308
+ /**
309
+ * Draw the APPLIED filter row in the results header — one chip per applied
310
+ * value and per applied range, each of which removes it
311
+ * (`<FilterChips mode="applied">`).
312
+ *
313
+ * `"desktop"` is the shape this exists for: where the rail is on screen a
314
+ * choice otherwise leaves no trace above the results and dropping one of
315
+ * two constraints means hunting its button back down the column, while on
316
+ * the phone the opener row below already states every applied filter on its
317
+ * own chips. `true` draws it in both layouts; omitted, nothing changes.
318
+ *
319
+ * It renders itself away when nothing is applied, so a host never has to
320
+ * ask.
321
+ */
322
+ readonly appliedChips?: boolean | "desktop";
256
323
  /** What this surface calls its result list. See
257
324
  * {@link SearchResultsPaneProps.heading}. */
258
325
  readonly resultsHeading?: ReactNode;
@@ -349,6 +416,7 @@ interface SearchPageBodyProps {
349
416
  readonly footer?: ReactNode;
350
417
  readonly filtersHeader?: ReactNode;
351
418
  readonly resultsHeader?: ReactNode;
419
+ readonly appliedChips?: boolean | "desktop";
352
420
  readonly resultsHeading?: ReactNode;
353
421
  readonly degradationNotice?: DegradationNoticeVariant;
354
422
  readonly filtersLayout?: SearchFiltersLayout;
@@ -585,6 +653,20 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
585
653
  <div data-testid="search-results-header">{props.resultsHeader}</div>
586
654
  )}
587
655
 
656
+ {/* What the search is NARROWED to, above the results, each constraint
657
+ beside the control that drops it. Drawn in the same band as the
658
+ results header because that is where a host would otherwise hand-mount
659
+ it — and it draws nothing at all when nothing is applied. */}
660
+ {(props.appliedChips === true ||
661
+ (props.appliedChips === "desktop" && layout !== "sheet")) && (
662
+ <FilterChips
663
+ mode="applied"
664
+ {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
665
+ {...(locale !== undefined ? { locale } : {})}
666
+ {...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
667
+ />
668
+ )}
669
+
588
670
  {showFilters && layout === "sheet" ? (
589
671
  <>
590
672
  {/* The phone's filter row. It REPLACES the full-width "Filters (3)"
@@ -639,7 +721,14 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
639
721
  </>
640
722
  ) : showFilters ? (
641
723
  <Flex align="flex-start" gap={spacing[5]} data-testid="search-page-columns">
642
- <div style={RAIL}>{panel}</div>
724
+ <div className={RAIL_CLASS} style={RAIL}>
725
+ {/* The rail's scrollbar, in the gutter and in the token palette —
726
+ see `railScrollbarCss`. Hoisted, deduped by `href`. */}
727
+ <style href={RAIL_STYLE_HREF} precedence="default">
728
+ {railScrollbarCss()}
729
+ </style>
730
+ {panel}
731
+ </div>
643
732
  {/* ONE heading and ONE sort control. The page used to caption
644
733
  the toolbar "Results" and then mount a pane whose own heading
645
734
  says "Results" again — the live /s page printed both, one
@@ -673,6 +762,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
673
762
  footer,
674
763
  filtersHeader,
675
764
  resultsHeader,
765
+ appliedChips,
676
766
  resultsHeading,
677
767
  degradationNotice,
678
768
  filtersLayout,
@@ -707,6 +797,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
707
797
  {...(footer !== undefined ? { footer } : {})}
708
798
  {...(filtersHeader !== undefined ? { filtersHeader } : {})}
709
799
  {...(resultsHeader !== undefined ? { resultsHeader } : {})}
800
+ {...(appliedChips !== undefined ? { appliedChips } : {})}
710
801
  {...(resultsHeading !== undefined ? { resultsHeading } : {})}
711
802
  {...(degradationNotice !== undefined ? { degradationNotice } : {})}
712
803
  {...(filtersLayout !== undefined ? { filtersLayout } : {})}
@@ -40,7 +40,12 @@
40
40
  */
41
41
 
42
42
  // ── surfaces ────────────────────────────────────────────────────────────────
43
- export { SearchPage } from "./SearchPage.js";
43
+ export {
44
+ SearchPage,
45
+ RAIL_CLASS,
46
+ RAIL_STYLE_HREF,
47
+ railScrollbarCss,
48
+ } from "./SearchPage.js";
44
49
  export type { SearchPageProps, SearchFiltersLayout } from "./SearchPage.js";
45
50
 
46
51
  export { SearchResultsPane, RESULTS_MAX_WIDTH } from "./SearchResultsPane.js";
@@ -56,12 +61,26 @@ export {
56
61
  CHIP_ROW_CAP,
57
62
  CHIP_ROW_CLASS,
58
63
  CHIP_ROW_STYLE_HREF,
64
+ appliedChipTestId,
65
+ buildAppliedChips,
59
66
  capChipRow,
60
67
  categoryLeaf,
61
68
  chipRowCss,
62
69
  orderChipFilters,
70
+ rangeChipText,
71
+ rangeLabelSource,
72
+ } from "./FilterChips.js";
73
+ export type {
74
+ AppliedChip,
75
+ AppliedChipTarget,
76
+ ChipBand,
77
+ ChipSpec,
78
+ FilterChipsAppliedProps,
79
+ FilterChipsCommonProps,
80
+ FilterChipsMode,
81
+ FilterChipsOpenerProps,
82
+ FilterChipsProps,
63
83
  } from "./FilterChips.js";
64
- export type { ChipBand, ChipSpec, FilterChipsProps } from "./FilterChips.js";
65
84
  export { EmptyExits, RADIUS_WIDEN_FACTOR, parentCategory } from "./EmptyExits.js";
66
85
  export type { EmptyExitsProps } from "./EmptyExits.js";
67
86
 
@@ -95,7 +114,12 @@ export type { PopularValuesProps } from "./PopularValues.js";
95
114
  export { PartitionChips } from "./PartitionChips.js";
96
115
  export type { PartitionChild, PartitionChipsProps } from "./PartitionChips.js";
97
116
 
98
- export { FacetPanelPane } from "./FacetPanelPane.js";
117
+ export {
118
+ FacetPanelPane,
119
+ FACET_OPEN_GROUPS,
120
+ FACET_SEARCH_THRESHOLD,
121
+ FACET_VISIBLE_GROUPS,
122
+ } from "./FacetPanelPane.js";
99
123
  export type {
100
124
  FacetPanelPaneProps,
101
125
  CategoryFilterSlotProps,
package/src/i18n/es.ts CHANGED
@@ -96,6 +96,9 @@ export const searchI18nBundleEs: I18nDictionary = {
96
96
  "search.facets.dictionary_search": "Buscar un valor",
97
97
  "search.facets.dictionary_empty": "Ningún valor coincide con esto",
98
98
  "search.facets.dictionary_chosen": "Elegido",
99
+ "search.facets.dictionary_any": "Cualquiera",
100
+ "search.facets.all_filters": "Todos los filtros ({count})",
101
+ "search.facets.range_bounds": "de {min} a {max}",
99
102
  "search.facets.popular_all": "Todos",
100
103
  "search.partition.all": "Todos",
101
104
  "search.partition.label": "Sección",
@@ -116,6 +119,12 @@ export const searchI18nBundleEs: I18nDictionary = {
116
119
  "search.filters.short": "Filtros",
117
120
  "search.filters.chip_more": ", +{count}",
118
121
  "search.filters.chips_overflow": "Más · {count}",
122
+ "search.filters.applied_label": "Filtros aplicados",
123
+ "search.filters.chip_pair": "{name}: {value}",
124
+ "search.filters.chip_remove": "Quitar el filtro {filter}",
125
+ "search.filters.chip_range_between": "de {from} a {to}",
126
+ "search.filters.chip_range_from": "desde {from}",
127
+ "search.filters.chip_range_to": "hasta {to}",
119
128
 
120
129
  "search.empty.exits_title": "Prueba a ampliar la búsqueda",
121
130
  "search.empty.up_a_level": "Subir un nivel",
package/src/i18n/keys.ts CHANGED
@@ -171,6 +171,28 @@ export const SEARCH_I18N_KEYS = {
171
171
  /** Over the values already chosen, which stay visible whatever the box
172
172
  * says — a filter a person cannot see is a filter they cannot remove. */
173
173
  facetsDictionaryChosen: "search.facets.dictionary_chosen",
174
+ /**
175
+ * The closed dictionary FIELD with nothing chosen — "no constraint on this
176
+ * axis", the way a classified says it ("Any make", shortened to "Any").
177
+ *
178
+ * Not `facetsPopularAll` and not `partitionAll`, which are both the word
179
+ * "All": those two name a WIDER SET a person can move to, and this one
180
+ * names the absence of a narrowing. The three read alike in English and
181
+ * diverge in every language with gender or case.
182
+ */
183
+ facetsDictionaryAny: "search.facets.dictionary_any",
184
+ /**
185
+ * The tail of the rail, folded: how many groups are behind one control.
186
+ * `{count}` is a number in parentheses, not a counted noun, so this is not
187
+ * a plural family — the same shape as `facetsClearAll`.
188
+ */
189
+ facetsAllFilters: "search.facets.all_filters",
190
+ /**
191
+ * The bounds of a picker axis, said in words when what was typed is
192
+ * outside them — a year before the catalogue's own floor otherwise does
193
+ * nothing at all, silently.
194
+ */
195
+ facetsRangeBounds: "search.facets.range_bounds",
174
196
  /** The way from the "popular values" block into the whole control. The
175
197
  * block is a shortcut over the busiest values, so the link has to say that
176
198
  * the rest exist. */
@@ -210,6 +232,25 @@ export const SEARCH_I18N_KEYS = {
210
232
  * `{count}` is how many chips it hides (D16). */
211
233
  filtersChipOverflow: "search.filters.chips_overflow",
212
234
 
235
+ // ── the APPLIED row (`<FilterChips mode="applied">`) ─────────────────────
236
+ /** The accessible name of the applied row. Distinct from
237
+ * `filtersChipsLabel`: that row OPENS pickers, this one DROPS filters, and
238
+ * a screen reader announcing both as "Filters" states neither. */
239
+ filtersAppliedLabel: "search.filters.applied_label",
240
+ /** One applied chip's caption: the axis AND the value — "Brand: Bosch".
241
+ * A chip printing the value alone states a constraint without saying what
242
+ * it constrains, which beside a rail of a dozen axes names nothing. */
243
+ filtersChipPair: "search.filters.chip_pair",
244
+ /** The accessible name of an applied chip: pressing it REMOVES the filter,
245
+ * and the caption alone never says so. `{filter}` is the caption. */
246
+ filtersChipRemove: "search.filters.chip_remove",
247
+ /** A numeric constraint with both bounds, as one phrase. */
248
+ filtersChipRangeBetween: "search.filters.chip_range_between",
249
+ /** A lower bound alone. */
250
+ filtersChipRangeFrom: "search.filters.chip_range_from",
251
+ /** An upper bound alone. */
252
+ filtersChipRangeTo: "search.filters.chip_range_to",
253
+
213
254
  // ── a partition: one template split by a value its children name ─────────
214
255
  /**
215
256
  * The first chip of a partition row — the parent itself, unnarrowed.
@@ -497,6 +538,9 @@ export const searchI18nBundleEn: Record<string, string> = {
497
538
  "search.facets.dictionary_search": "Find a value",
498
539
  "search.facets.dictionary_empty": "No value matches this",
499
540
  "search.facets.dictionary_chosen": "Chosen",
541
+ "search.facets.dictionary_any": "Any",
542
+ "search.facets.all_filters": "All filters ({count})",
543
+ "search.facets.range_bounds": "from {min} to {max}",
500
544
  "search.facets.popular_all": "All",
501
545
  "search.partition.all": "All",
502
546
  "search.partition.label": "Section",
@@ -517,6 +561,12 @@ export const searchI18nBundleEn: Record<string, string> = {
517
561
  "search.filters.short": "Filters",
518
562
  "search.filters.chip_more": ", +{count}",
519
563
  "search.filters.chips_overflow": "More · {count}",
564
+ "search.filters.applied_label": "Applied filters",
565
+ "search.filters.chip_pair": "{name}: {value}",
566
+ "search.filters.chip_remove": "Remove filter {filter}",
567
+ "search.filters.chip_range_between": "from {from} to {to}",
568
+ "search.filters.chip_range_from": "from {from}",
569
+ "search.filters.chip_range_to": "up to {to}",
520
570
 
521
571
  "search.empty.exits_title": "Try widening the search",
522
572
  "search.empty.up_a_level": "Go up a level",
package/src/i18n/ru.ts CHANGED
@@ -125,6 +125,9 @@ export const searchI18nBundleRu: I18nDictionary = {
125
125
  "search.facets.dictionary_search": "Найти значение",
126
126
  "search.facets.dictionary_empty": "Такого значения здесь нет",
127
127
  "search.facets.dictionary_chosen": "Выбрано",
128
+ "search.facets.dictionary_any": "Любая",
129
+ "search.facets.all_filters": "Все фильтры ({count})",
130
+ "search.facets.range_bounds": "от {min} до {max}",
128
131
  "search.facets.popular_all": "Все",
129
132
  "search.partition.all": "Все",
130
133
  "search.partition.label": "Раздел",
@@ -151,6 +154,12 @@ export const searchI18nBundleRu: I18nDictionary = {
151
154
  "search.filters.short": "Фильтры",
152
155
  "search.filters.chip_more": ", +{count}",
153
156
  "search.filters.chips_overflow": "Ещё {count}",
157
+ "search.filters.applied_label": "Выбранные фильтры",
158
+ "search.filters.chip_pair": "{name}: {value}",
159
+ "search.filters.chip_remove": "Убрать фильтр «{filter}»",
160
+ "search.filters.chip_range_between": "от {from} до {to}",
161
+ "search.filters.chip_range_from": "от {from}",
162
+ "search.filters.chip_range_to": "до {to}",
154
163
 
155
164
  "search.empty.exits_title": "Попробуйте расширить поиск",
156
165
  "search.empty.up_a_level": "Подняться на уровень выше",
package/src/index.ts CHANGED
@@ -116,8 +116,11 @@ export type {
116
116
  export {
117
117
  FACETABLE_FEATURE_TYPES,
118
118
  buildFacetGroups,
119
+ facetGroupHasEvidence,
120
+ facetGroupIsDrawable,
119
121
  facetOptionLabel,
120
122
  isFacetableFeature,
123
+ orderFacetGroupsBySchema,
121
124
  } from "./state/facets.js";
122
125
  export type {
123
126
  BuildFacetGroupsInput,
@@ -134,6 +137,7 @@ export {
134
137
 
135
138
  export {
136
139
  RANGE_FEATURE_TYPES,
140
+ RANGE_PICKER_MAX_VALUES,
137
141
  buildRangeGroups,
138
142
  coreRangeLabelKey,
139
143
  isRangeFeature,
@@ -96,6 +96,46 @@ function warnUnnamedGroup(slug: string): void {
96
96
  );
97
97
  }
98
98
 
99
+ /** Slugs already reported as undrawable — one warning per slug per page. */
100
+ const warnedUndrawable = new Set<string>();
101
+
102
+ /**
103
+ * An axis that reached the panel with nothing to draw, said once, in
104
+ * development only.
105
+ *
106
+ * Measured on a live classified's cars branch: `make_ref_select`,
107
+ * `model` and `generation` are `ref_select` features whose config is a bare
108
+ * `optionsRef` pointer into a vocabulary — there is no option table in the
109
+ * schema and there never will be — so whenever the server's facet plan does
110
+ * not COUNT them there is nothing on the client to enumerate, and the group
111
+ * left the rail without a word while every `select`-typed comfort option
112
+ * (steering side, power steering, heating) drew its schema table and stayed.
113
+ * buyer's report was "I cannot pick a make".
114
+ *
115
+ * The panel still refuses to draw a heading over nothing — that is the right
116
+ * call — but the disappearance is a WIRING FAULT with two possible owners
117
+ * (the server's plan skipped a required axis, or the host threaded the wrong
118
+ * category's schema), and neither of them can see it from the page.
119
+ */
120
+ function warnUndrawableGroup(group: FacetGroup): void {
121
+ const env = typeof process === "undefined" ? undefined : process.env;
122
+ if (env?.NODE_ENV === "production") return;
123
+ if (warnedUndrawable.has(group.slug)) return;
124
+ warnedUndrawable.add(group.slug);
125
+ console.warn(
126
+ `[search-react] facet group "${group.slug}" has no values to draw: the ` +
127
+ `answer did not count it${
128
+ group.feature === undefined
129
+ ? " and the category schema passed to this page does not define it"
130
+ : " and its config carries a vocabulary pointer, not an option table"
131
+ }, so the group is not rendered.` +
132
+ (group.feature?.mandatory === true
133
+ ? " The schema marks this axis REQUIRED — a buyer cannot narrow by a" +
134
+ " field every seller had to fill."
135
+ : "")
136
+ );
137
+ }
138
+
99
139
  /**
100
140
  * Value types whose values are a BOUNDED OPTION SET — the only kind of
101
141
  * feature a person can be offered as a filter.
@@ -222,6 +262,105 @@ export function orderFacetGroups(
222
262
  return [...groups].sort(compareFacetsByEvidence);
223
263
  }
224
264
 
265
+ /**
266
+ * Does the ANSWER have evidence for this axis — at least one value some
267
+ * candidate actually carries?
268
+ *
269
+ * The one fact that outranks every other rule in this module. A bucket with a
270
+ * count above zero is the server saying "documents in this result set have
271
+ * this value", and no schema opinion, no missing feature list and no type
272
+ * table may take an axis like that off the screen.
273
+ */
274
+ export function facetGroupHasEvidence(group: FacetGroup): boolean {
275
+ return group.options.some((option) => (option.count ?? 0) > 0);
276
+ }
277
+
278
+ /**
279
+ * Is there anything for a surface to DRAW here?
280
+ *
281
+ * Shared by the rail and the chip row, which each used to hold their own
282
+ * `options.length > 0` — one predicate, or the two surfaces drift into two
283
+ * opinions about what an empty group is.
284
+ *
285
+ * A group with no options is a heading over nothing: after
286
+ * {@link buildFacetGroups} learned to read the schema, what is left in that
287
+ * state is a `ref_select` whose config is a bare `optionsRef` pointer and
288
+ * which the server did not count — nothing to enumerate from either side. It
289
+ * is not drawn, and in development it is NAMED: a required axis (the make on
290
+ * cars leaf) disappearing out of a rail is exactly the fault this pair spent
291
+ * a release chasing, and it must not disappear silently a second time.
292
+ *
293
+ * A group the reader has already FILTERED on is drawn whatever its options
294
+ * say — a constraint with no control to remove it is worse than a bare
295
+ * heading.
296
+ */
297
+ export function facetGroupIsDrawable(group: FacetGroup): boolean {
298
+ if (group.selected.length > 0) return true;
299
+ if (group.options.length > 0) return true;
300
+ warnUndrawableGroup(group);
301
+ return false;
302
+ }
303
+
304
+ /**
305
+ * The order the RAIL puts groups in: the category's own schema order, with
306
+ * the axes the schema calls required in front of it.
307
+ *
308
+ * Evidence order ({@link compareFacetsByEvidence}) answers "which axis does
309
+ * this corpus fill in", which is the right question for a chip row that has
310
+ * room for four. It is the wrong question for a rail: on a cars leaf with
311
+ * three listings the busiest axis is whichever three values happen to be
312
+ * counted, so the rail opened on condition and colour while make, model and
313
+ * year — the three fields the schema marks
314
+ * `mandatory`, i.e. the three every seller had to fill and every buyer
315
+ * narrows by first — sat below them or off the fold entirely.
316
+ *
317
+ * So: the schema's own order, which is the order the composer asks the
318
+ * seller to fill the form in, with required first. Four bands:
319
+ *
320
+ * 1. `pinned` slugs, in the order given — the axis a page has already
321
+ * decided is its subject (a partition's own field).
322
+ * 2. schema-required (`mandatory: true`), in schema order.
323
+ * 3. everything else the schema names, in schema order.
324
+ * 4. what the schema does not name at all — including EVERY group when the
325
+ * host passed no feature list, which is the live parent-node case — in
326
+ * evidence order, because with no schema there is no other order to have.
327
+ *
328
+ * Stable: within a band the comparator falls through to evidence and then to
329
+ * the order `buildFacetGroups` gave, so equal-ranked groups never reshuffle.
330
+ */
331
+ export function orderFacetGroupsBySchema(input: {
332
+ readonly groups: readonly FacetGroup[];
333
+ /** The category schema, in the order the category declares it. */
334
+ readonly categoryFeatures?: readonly FeatureDef[];
335
+ /** Slugs pinned above everything, in the order given. */
336
+ readonly pinned?: readonly string[];
337
+ }): readonly FacetGroup[] {
338
+ const schemaIndex = new Map<string, number>();
339
+ (input.categoryFeatures ?? []).forEach((feature, index) => {
340
+ if (!schemaIndex.has(feature.slug)) schemaIndex.set(feature.slug, index);
341
+ });
342
+ const pinnedIndex = new Map<string, number>();
343
+ (input.pinned ?? []).forEach((slug, index) => {
344
+ if (!pinnedIndex.has(slug)) pinnedIndex.set(slug, index);
345
+ });
346
+
347
+ const band = (group: FacetGroup): number => {
348
+ if (pinnedIndex.has(group.slug)) return 0;
349
+ if (!schemaIndex.has(group.slug)) return 3;
350
+ return group.feature?.mandatory === true ? 1 : 2;
351
+ };
352
+ const within = (group: FacetGroup): number =>
353
+ pinnedIndex.get(group.slug) ?? schemaIndex.get(group.slug) ?? 0;
354
+
355
+ return [...input.groups].sort((a, b) => {
356
+ const byBand = band(a) - band(b);
357
+ if (byBand !== 0) return byBand;
358
+ const byOrder = within(a) - within(b);
359
+ if (byOrder !== 0) return byOrder;
360
+ return compareFacetsByEvidence(a, b);
361
+ });
362
+ }
363
+
225
364
  export interface BuildFacetGroupsInput {
226
365
  /** The envelope's `facets`: `{slug: {value: count}}`. */
227
366
  readonly facets: Readonly<Record<string, Readonly<Record<string, number>>>>;
@@ -467,6 +606,19 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
467
606
  seen.add(slug);
468
607
  // Applied first, type second — in that order, so an `imei` somebody
469
608
  // somehow got into a link keeps the control that removes it.
609
+ //
610
+ // EVIDENCE does not enter here, and the reason is worth stating because
611
+ // the opposite was tried: a counted bucket cannot promote a slug the
612
+ // schema NAMES AND DISOWNS. An `imei` the engine counted is still not a
613
+ // filter — a free-text identifier enumerates one term per document — and
614
+ // a `visibility: "owner"` feature is one the canon says is never
615
+ // facetable at all. What evidence does outrank is SILENCE: an absent def,
616
+ // an untyped def, and the whole empty feature list the live cars page
617
+ // passes at its parent node all answer "not a verdict"
618
+ // (see {@link isFacetableFeature}), so a counted axis is never dropped
619
+ // for a schema that says nothing about it. That is the live case; a
620
+ // wrong-schema case where some other category types `make_ref_select` as
621
+ // free text is not one this pair can tell apart from a real `imei`.
470
622
  const applied = (input.state.filters[slug] ?? []).length > 0;
471
623
  if (!applied && !isFacetableFeature(bySlug.get(slug))) continue;
472
624
  slugs.push(slug);
@@ -76,6 +76,19 @@ export interface RangeGroup {
76
76
  * category forgot to declare an attribute for it.
77
77
  */
78
78
  readonly core: boolean;
79
+ /**
80
+ * The values this axis can take, when it is a BOUNDED INTEGER small enough
81
+ * to pick from — `undefined` for every other axis, including price.
82
+ *
83
+ * A year is not a number a person types, it is one of a hundred-odd values,
84
+ * and the reference classified draws it as two pickers. `min`/`max` from
85
+ * the schema (`year: 1900..2027` on a live cars leaf) bound the list;
86
+ * {@link RANGE_PICKER_MAX_VALUES} is where a picker stops being one and
87
+ * becomes a scroll with a search box in it, at which point two typed fields
88
+ * are the better control. Descending, because the busy end of a bounded
89
+ * axis is its top: a year picker that opens on 1900 is a picker nobody uses.
90
+ */
91
+ readonly picker: readonly number[] | undefined;
79
92
  /**
80
93
  * ISO 4217 code when the row is money, so the control can read as money
81
94
  * instead of as a bare integer. Only ever set on a core axis: an attribute
@@ -131,6 +144,36 @@ function translate(t: ((key: string) => string) | undefined, key: string): strin
131
144
  return resolved.length > 0 ? resolved : key;
132
145
  }
133
146
 
147
+ /**
148
+ * How many values a bounded integer may have and still be a PICKER.
149
+ *
150
+ * 300 covers every year range a catalogue declares (a live cars leaf is
151
+ * 1900..2027, 128 values) and a door count, a seat count, a floor number,
152
+ * and stops well short of a mileage (1..1000000), which is a number people
153
+ * type and never a list.
154
+ */
155
+ export const RANGE_PICKER_MAX_VALUES = 300;
156
+
157
+ /**
158
+ * The value list for a bounded integer axis, or `undefined`.
159
+ *
160
+ * A CORE axis never gets one: price is unbounded by construction and the
161
+ * server does not declare bounds for it.
162
+ */
163
+ function pickerValues(
164
+ feature: FeatureDef | undefined,
165
+ min: number | undefined,
166
+ max: number | undefined
167
+ ): readonly number[] | undefined {
168
+ if (feature === undefined) return undefined;
169
+ if (featureType(feature) !== "int") return undefined;
170
+ if (min === undefined || max === undefined) return undefined;
171
+ if (!Number.isInteger(min) || !Number.isInteger(max)) return undefined;
172
+ const span = max - min + 1;
173
+ if (span < 2 || span > RANGE_PICKER_MAX_VALUES) return undefined;
174
+ return Array.from({ length: span }, (_, i) => max - i);
175
+ }
176
+
134
177
  /** Is this feature one a numeric range row is drawn for? */
135
178
  export function isRangeFeature(feature: FeatureDef): boolean {
136
179
  const type = featureType(feature);
@@ -190,6 +233,7 @@ export function buildRangeGroups(
190
233
  ? undefined
191
234
  : (str(config["postfix"]) ?? str(config["unit_m"]) ?? str(config["unit_i"])),
192
235
  step: feature !== undefined && featureType(feature) === "int" ? 1 : undefined,
236
+ picker: isCore ? undefined : pickerValues(feature, num(config["min"]), num(config["max"])),
193
237
  active: applied !== undefined,
194
238
  core: isCore,
195
239
  currency: isCore ? str(input.currency) : undefined,