@stapel/search-react 0.21.0 → 0.23.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 (86) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/MODULE.md +12 -0
  3. package/README.md +143 -2
  4. package/dist/api/types.d.ts +37 -3
  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 +53 -5
  8. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  9. package/dist/default/FacetGroupControl.js +189 -8
  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.map +1 -1
  16. package/dist/default/FilterChips.js +2 -2
  17. package/dist/default/FilterChips.js.map +1 -1
  18. package/dist/default/PartitionChips.d.ts +37 -0
  19. package/dist/default/PartitionChips.d.ts.map +1 -0
  20. package/dist/default/PartitionChips.js +120 -0
  21. package/dist/default/PartitionChips.js.map +1 -0
  22. package/dist/default/PopularValues.d.ts +58 -0
  23. package/dist/default/PopularValues.d.ts.map +1 -0
  24. package/dist/default/PopularValues.js +46 -0
  25. package/dist/default/PopularValues.js.map +1 -0
  26. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  27. package/dist/default/RangeFilterRow.js +65 -11
  28. package/dist/default/RangeFilterRow.js.map +1 -1
  29. package/dist/default/SearchPage.d.ts +49 -0
  30. package/dist/default/SearchPage.d.ts.map +1 -1
  31. package/dist/default/SearchPage.js +43 -2
  32. package/dist/default/SearchPage.js.map +1 -1
  33. package/dist/default/index.d.ts +7 -3
  34. package/dist/default/index.d.ts.map +1 -1
  35. package/dist/default/index.js +8 -3
  36. package/dist/default/index.js.map +1 -1
  37. package/dist/headless/useFacetLabels.js +7 -7
  38. package/dist/headless/useFacetLabels.js.map +1 -1
  39. package/dist/i18n/es.d.ts.map +1 -1
  40. package/dist/i18n/es.js +9 -0
  41. package/dist/i18n/es.js.map +1 -1
  42. package/dist/i18n/keys.d.ts +52 -0
  43. package/dist/i18n/keys.d.ts.map +1 -1
  44. package/dist/i18n/keys.js +62 -0
  45. package/dist/i18n/keys.js.map +1 -1
  46. package/dist/i18n/ru.d.ts.map +1 -1
  47. package/dist/i18n/ru.js +9 -0
  48. package/dist/i18n/ru.js.map +1 -1
  49. package/dist/index.d.ts +5 -4
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +3 -2
  52. package/dist/index.js.map +1 -1
  53. package/dist/state/facets.d.ts +90 -6
  54. package/dist/state/facets.d.ts.map +1 -1
  55. package/dist/state/facets.js +216 -15
  56. package/dist/state/facets.js.map +1 -1
  57. package/dist/state/ranges.d.ts +22 -0
  58. package/dist/state/ranges.d.ts.map +1 -1
  59. package/dist/state/ranges.js +30 -0
  60. package/dist/state/ranges.js.map +1 -1
  61. package/dist/state/translit.d.ts +58 -0
  62. package/dist/state/translit.d.ts.map +1 -0
  63. package/dist/state/translit.js +115 -0
  64. package/dist/state/translit.js.map +1 -0
  65. package/llms.txt +3 -1
  66. package/manifest.json +45 -1
  67. package/nav-manifest.json +1 -1
  68. package/package.json +8 -8
  69. package/src/analytics/generated/events.json +1 -1
  70. package/src/api/types.ts +42 -3
  71. package/src/default/FacetGroupControl.tsx +339 -9
  72. package/src/default/FacetPanelPane.tsx +131 -19
  73. package/src/default/FilterChips.tsx +5 -2
  74. package/src/default/PartitionChips.tsx +204 -0
  75. package/src/default/PopularValues.tsx +151 -0
  76. package/src/default/RangeFilterRow.tsx +116 -1
  77. package/src/default/SearchPage.tsx +61 -2
  78. package/src/default/index.ts +27 -2
  79. package/src/headless/useFacetLabels.ts +7 -7
  80. package/src/i18n/es.ts +9 -0
  81. package/src/i18n/keys.ts +63 -0
  82. package/src/i18n/ru.ts +9 -0
  83. package/src/index.ts +13 -0
  84. package/src/state/facets.ts +259 -27
  85. package/src/state/ranges.ts +44 -0
  86. package/src/state/translit.ts +113 -0
package/src/i18n/es.ts CHANGED
@@ -93,6 +93,15 @@ export const searchI18nBundleEs: I18nDictionary = {
93
93
  "search.facets.show_less": "Ver menos",
94
94
  "search.facets.search": "Buscar un filtro",
95
95
  "search.facets.search_empty": "Ningún filtro coincide con esto",
96
+ "search.facets.dictionary_search": "Buscar un valor",
97
+ "search.facets.dictionary_empty": "Ningún valor coincide con esto",
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}",
102
+ "search.facets.popular_all": "Todos",
103
+ "search.partition.all": "Todos",
104
+ "search.partition.label": "Sección",
96
105
  "search.facets.match_count.one": "{count} anuncio coincide",
97
106
  "search.facets.match_count.other": "{count} anuncios coinciden",
98
107
  "search.facets.range_invalid":
package/src/i18n/keys.ts CHANGED
@@ -157,6 +157,46 @@ export const SEARCH_I18N_KEYS = {
157
157
  * Distinct from `facetsEmpty`, which says the SEARCH offers no filters —
158
158
  * here the filters exist and the query missed. */
159
159
  facetsSearchEmpty: "search.facets.search_empty",
160
+ /**
161
+ * The box inside ONE dictionary group — 418 car makes are not a list a
162
+ * person scrolls. Distinct from `facetsSearch`, which finds a GROUP among
163
+ * forty headings: one narrows the panel, the other narrows one axis of it,
164
+ * and a translator handed one string for both has to pick which surface to
165
+ * be wrong on.
166
+ */
167
+ facetsDictionarySearch: "search.facets.dictionary_search",
168
+ /** The dictionary box matched no value of this group. Not `facetsEmpty`,
169
+ * and not `facetsSearchEmpty`: the group exists and its values exist. */
170
+ facetsDictionaryEmpty: "search.facets.dictionary_empty",
171
+ /** Over the values already chosen, which stay visible whatever the box
172
+ * says — a filter a person cannot see is a filter they cannot remove. */
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",
196
+ /** The way from the "popular values" block into the whole control. The
197
+ * block is a shortcut over the busiest values, so the link has to say that
198
+ * the rest exist. */
199
+ facetsPopularAll: "search.facets.popular_all",
160
200
  /**
161
201
  * The rail's sticky footer: what the applied filters LEFT — "N listings
162
202
  * match". A PLURAL FAMILY: it counts a noun in words. Desktop filters
@@ -192,6 +232,20 @@ export const SEARCH_I18N_KEYS = {
192
232
  * `{count}` is how many chips it hides (D16). */
193
233
  filtersChipOverflow: "search.filters.chips_overflow",
194
234
 
235
+ // ── a partition: one template split by a value its children name ─────────
236
+ /**
237
+ * The first chip of a partition row — the parent itself, unnarrowed.
238
+ *
239
+ * Not `facetsPopularAll` reused: that one OPENS a control, this one is a
240
+ * CHOICE among the children (and the one that is selected by default). The
241
+ * two are the same word in English and stop being one the moment a
242
+ * translator reads either as an imperative.
243
+ */
244
+ partitionAll: "search.partition.all",
245
+ /** The accessible name of the chip row — a `radiogroup` with no name is
246
+ * announced as nothing at all. */
247
+ partitionLabel: "search.partition.label",
248
+
195
249
  // ── the way OUT of a search that found nothing ───────────────────────────
196
250
  /** Caption above the exit buttons. Not a second "nothing found" — that
197
251
  * sentence has already been said; this one says what can be done next. */
@@ -462,6 +516,15 @@ export const searchI18nBundleEn: Record<string, string> = {
462
516
  "search.facets.show_less": "Show fewer",
463
517
  "search.facets.search": "Find a filter",
464
518
  "search.facets.search_empty": "No filter matches this",
519
+ "search.facets.dictionary_search": "Find a value",
520
+ "search.facets.dictionary_empty": "No value matches this",
521
+ "search.facets.dictionary_chosen": "Chosen",
522
+ "search.facets.dictionary_any": "Any",
523
+ "search.facets.all_filters": "All filters ({count})",
524
+ "search.facets.range_bounds": "from {min} to {max}",
525
+ "search.facets.popular_all": "All",
526
+ "search.partition.all": "All",
527
+ "search.partition.label": "Section",
465
528
  "search.facets.match_count.one": "{count} listing matches",
466
529
  "search.facets.match_count.other": "{count} listings match",
467
530
  "search.facets.range_invalid":
package/src/i18n/ru.ts CHANGED
@@ -122,6 +122,15 @@ export const searchI18nBundleRu: I18nDictionary = {
122
122
  "search.facets.show_less": "Свернуть",
123
123
  "search.facets.search": "Найти фильтр",
124
124
  "search.facets.search_empty": "Среди фильтров такого нет",
125
+ "search.facets.dictionary_search": "Найти значение",
126
+ "search.facets.dictionary_empty": "Такого значения здесь нет",
127
+ "search.facets.dictionary_chosen": "Выбрано",
128
+ "search.facets.dictionary_any": "Любая",
129
+ "search.facets.all_filters": "Все фильтры ({count})",
130
+ "search.facets.range_bounds": "от {min} до {max}",
131
+ "search.facets.popular_all": "Все",
132
+ "search.partition.all": "Все",
133
+ "search.partition.label": "Раздел",
125
134
  "search.facets.match_count.one": "Найдено {count} объявление",
126
135
  "search.facets.match_count.few": "Найдено {count} объявления",
127
136
  "search.facets.match_count.many": "Найдено {count} объявлений",
package/src/index.ts CHANGED
@@ -52,6 +52,8 @@ export {
52
52
  } from "./api/types.js";
53
53
  export type {
54
54
  FacetCategoryCount,
55
+ FacetLabels,
56
+ FacetLabelsMap,
55
57
  FacetMeta,
56
58
  FacetSelection,
57
59
  FacetWithheldGroup,
@@ -114,17 +116,28 @@ export type {
114
116
  export {
115
117
  FACETABLE_FEATURE_TYPES,
116
118
  buildFacetGroups,
119
+ facetGroupHasEvidence,
120
+ facetGroupIsDrawable,
117
121
  facetOptionLabel,
118
122
  isFacetableFeature,
123
+ orderFacetGroupsBySchema,
119
124
  } from "./state/facets.js";
120
125
  export type {
121
126
  BuildFacetGroupsInput,
122
127
  FacetGroup,
128
+ FacetLabelSource,
123
129
  FacetOption,
124
130
  } from "./state/facets.js";
125
131
 
132
+ export {
133
+ consonantKey,
134
+ translitKey,
135
+ translitPrefixMatch,
136
+ } from "./state/translit.js";
137
+
126
138
  export {
127
139
  RANGE_FEATURE_TYPES,
140
+ RANGE_PICKER_MAX_VALUES,
128
141
  buildRangeGroups,
129
142
  coreRangeLabelKey,
130
143
  isRangeFeature,
@@ -8,9 +8,12 @@
8
8
  * they would have if you swapped to them. A panel that greys the siblings
9
9
  * out has silently converted a drill-down facet into a naive one, and the
10
10
  * e2e leg in the spec (§7.2) exists to catch exactly that.
11
- * 2. **Option LABELS have one stated order: ANSWER, then schema, then the
12
- * raw value** — with a host resolver between the last two, applied by
13
- * `useHostFacetLabels` on what this module could not name.
11
+ * 2. **LABELS have one stated order: ANSWER, then schema, then the raw
12
+ * term** — for the group's heading and for every option alike, with a host
13
+ * resolver between the last two for options, applied by
14
+ * `useHostFacetLabels` on what this module could not name. The bottom of
15
+ * that order is not a label and is marked as such: `labelSource: "none"`,
16
+ * a warning in development, and a data attribute on the drawn group.
14
17
  *
15
18
  * The answer leads because it is the only source that always exists and
16
19
  * the only one that has RESOLVED anything: `categoryFeatures` is an
@@ -50,12 +53,88 @@
50
53
  import {
51
54
  VOCABULARY_BACKED_TYPES,
52
55
  featureConfig,
53
- featureName,
54
56
  featureType,
55
57
  formatFeatureValue,
56
58
  } from "@stapel/attributes-react";
57
59
  import type { FeatureDef } from "@stapel/attributes-react";
58
- import type { FacetMeta, SearchQueryState } from "../api/types.js";
60
+ import type { FacetLabelsMap, FacetMeta, SearchQueryState } from "../api/types.js";
61
+
62
+ /**
63
+ * Where a caption came from — the group's heading and every option carry it,
64
+ * because "did anybody actually name this?" is a question two surfaces have
65
+ * to answer and neither can answer by looking at the string.
66
+ *
67
+ * `"none"` is the honest bottom: the raw slug for a heading, the raw index
68
+ * term for an option. It is not a label, it is what is printed when there is
69
+ * none, and it is the one value a storefront's own test asserts against.
70
+ */
71
+ export type FacetLabelSource = "server" | "schema" | "host" | "none";
72
+
73
+ declare const process: { readonly env: { readonly NODE_ENV?: string } };
74
+
75
+ /** Slugs already complained about — one warning per slug per page load, not
76
+ * one per render. */
77
+ const warnedSlugs = new Set<string>();
78
+
79
+ /**
80
+ * A heading nobody named, said once, in development only.
81
+ *
82
+ * A raw slug in a filter panel is a wiring fault — the category has no
83
+ * feature definition for the slug and the server sent no label — and it is
84
+ * invisible to everyone but the buyer who meets it. The group still renders,
85
+ * because a heading a person cannot read still beats options with no heading
86
+ * at all; `FacetGroup.labelSource` is how a surface marks it up for a test.
87
+ */
88
+ function warnUnnamedGroup(slug: string): void {
89
+ const env = typeof process === "undefined" ? undefined : process.env;
90
+ if (env?.NODE_ENV === "production") return;
91
+ if (warnedSlugs.has(slug)) return;
92
+ warnedSlugs.add(slug);
93
+ console.warn(
94
+ `[search-react] facet group "${slug}" has no label: the answer sent none ` +
95
+ `and no category feature defines it, so the heading is the raw slug.`
96
+ );
97
+ }
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
+ }
59
138
 
60
139
  /**
61
140
  * Value types whose values are a BOUNDED OPTION SET — the only kind of
@@ -111,14 +190,22 @@ export interface FacetOption {
111
190
  readonly count: number | null;
112
191
  /** Resolved through the category schema; the raw value when there is none. */
113
192
  readonly label: string;
193
+ /** Which of the three sources named it — `"none"` means {@link label} IS
194
+ * the raw index term. The host seam reads this rather than comparing the
195
+ * two strings. */
196
+ readonly labelSource: FacetLabelSource;
114
197
  readonly selected: boolean;
115
198
  }
116
199
 
117
200
  /** One facet slug, with its options. */
118
201
  export interface FacetGroup {
119
202
  readonly slug: string;
120
- /** The feature's display name (translated when it is a key), else the slug. */
203
+ /** The group's heading: the answer's own `label`, else the feature's
204
+ * display name, else — with a dev warning — the raw slug. */
121
205
  readonly label: string;
206
+ /** Which source named the heading. `"none"` is the slug standing in for a
207
+ * name nobody has; a surface marks it so a storefront test can fail on it. */
208
+ readonly labelSource: FacetLabelSource;
122
209
  /** The category-schema entry behind the slug, when the host supplied one. */
123
210
  readonly feature: FeatureDef | undefined;
124
211
  /** `false` when the server skipped this slug — counts are `null`. */
@@ -175,6 +262,105 @@ export function orderFacetGroups(
175
262
  return [...groups].sort(compareFacetsByEvidence);
176
263
  }
177
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
+
178
364
  export interface BuildFacetGroupsInput {
179
365
  /** The envelope's `facets`: `{slug: {value: count}}`. */
180
366
  readonly facets: Readonly<Record<string, Readonly<Record<string, number>>>>;
@@ -199,17 +385,17 @@ export interface BuildFacetGroupsInput {
199
385
  * `apple` or `chernyy`. The server read that vocabulary against the
200
386
  * snapshot the documents were written with.
201
387
  *
388
+ * Each entry also carries the GROUP's own `label` — the heading, resolved
389
+ * from the feature definition server-side and localized like everything
390
+ * else. It leads for the same two reasons, and it is what a category whose
391
+ * host passed no feature list has instead of a rail full of index slugs.
392
+ *
202
393
  * ABSENT on a server older than 0.4.0 — absent, not empty — which is why
203
394
  * every read of it here is optional-chained and every option falls through
204
395
  * to the schema and then to its raw self. No labels are invented at any
205
396
  * step.
206
397
  */
207
- readonly facetLabels?: Readonly<
208
- Record<
209
- string,
210
- { readonly translatable: boolean; readonly values: Readonly<Record<string, string>> }
211
- >
212
- >;
398
+ readonly facetLabels?: FacetLabelsMap;
213
399
  /** Translator for label keys. */
214
400
  readonly t?: (key: string) => string;
215
401
  /** BCP-47 tag, forwarded to `formatFeatureValue` for `date` options. */
@@ -287,11 +473,49 @@ function resolveLabel(
287
473
  slug: string,
288
474
  value: string,
289
475
  labelOptions: { t?: (key: string) => string; locale?: string }
290
- ): string {
291
- return (
292
- serverLabel(input.facetLabels, slug, value, input.t) ??
293
- facetOptionLabel(feature, value, labelOptions)
294
- );
476
+ ): { readonly label: string; readonly labelSource: FacetLabelSource } {
477
+ const answer = serverLabel(input.facetLabels, slug, value, input.t);
478
+ if (answer !== undefined) return { label: answer, labelSource: "server" };
479
+ const schema = facetOptionLabel(feature, value, labelOptions);
480
+ // `facetOptionLabel` hands back the value unchanged for an option it
481
+ // cannot name, and that identity is the whole test — a formatter that
482
+ // returned the term is a formatter that named nothing.
483
+ return schema === value
484
+ ? { label: value, labelSource: "none" }
485
+ : { label: schema, labelSource: "schema" };
486
+ }
487
+
488
+ /**
489
+ * The group's HEADING, in the one order the fleet states: the answer, then
490
+ * the category schema, then the slug under a dev warning.
491
+ *
492
+ * The answer leads because it is the only source that always exists. The
493
+ * schema slot is optional, and at a live classified's cars branch the
494
+ * storefront passed an empty feature list, so every heading in the rail was
495
+ * a raw index slug — the make group was on screen, unlabelled, and the
496
+ * complaint that came back was "I cannot pick a make".
497
+ *
498
+ * The slug is not a fallback anyone may ship: it renders, because a heading
499
+ * beats no heading, and it renders MARKED — `labelSource: "none"`, a warning
500
+ * in development, and a data attribute on the drawn group.
501
+ */
502
+ function resolveGroupLabel(
503
+ input: BuildFacetGroupsInput,
504
+ feature: FeatureDef | undefined,
505
+ slug: string
506
+ ): { readonly label: string; readonly labelSource: FacetLabelSource } {
507
+ const answer = input.facetLabels?.[slug]?.label;
508
+ if (typeof answer === "string" && answer.length > 0) {
509
+ return { label: translate(input.t, answer), labelSource: "server" };
510
+ }
511
+ // `featureName` falls back to the slug itself, so the def has to be asked
512
+ // for a NAME rather than for a name-or-slug: a def with none names nothing.
513
+ const declared = feature?.name;
514
+ if (typeof declared === "string" && declared.length > 0) {
515
+ return { label: translate(input.t, declared), labelSource: "schema" };
516
+ }
517
+ warnUnnamedGroup(slug);
518
+ return { label: slug, labelSource: "none" };
295
519
  }
296
520
 
297
521
  /**
@@ -382,6 +606,19 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
382
606
  seen.add(slug);
383
607
  // Applied first, type second — in that order, so an `imei` somebody
384
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`.
385
622
  const applied = (input.state.filters[slug] ?? []).length > 0;
386
623
  if (!applied && !isFacetableFeature(bySlug.get(slug))) continue;
387
624
  slugs.push(slug);
@@ -446,22 +683,17 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
446
683
 
447
684
  return {
448
685
  slug,
449
- label:
450
- feature === undefined
451
- ? slug
452
- : translate(input.t, featureName(feature)),
686
+ ...resolveGroupLabel(input, feature, slug),
453
687
  feature,
454
688
  counted,
455
689
  selected,
456
690
  options: values.map((value) => ({
457
691
  value,
458
692
  count: counted ? (counts[value] ?? 0) : null,
459
- // The host's schema first when it actually resolves the value, the
460
- // answer's caption when it does not (or when there is no schema at
461
- // all), the raw value when neither knows. `facetOptionLabel` returns
462
- // the value unchanged for an option it cannot name, which is what
463
- // makes "did it resolve?" answerable without a second lookup.
464
- label: resolveLabel(input, feature, slug, value, labelOptions),
693
+ // The answer's caption first, then the schema's own option table,
694
+ // then the raw value and the source is carried rather than
695
+ // re-derived, so "did it resolve?" needs no second lookup.
696
+ ...resolveLabel(input, feature, slug, value, labelOptions),
465
697
  selected: selected.includes(value),
466
698
  })),
467
699
  };
@@ -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,