@stapel/search-react 0.20.0 → 0.22.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 (98) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +65 -2
  3. package/dist/api/generated/schema.d.ts +96 -4
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/api/types.d.ts +68 -6
  6. package/dist/api/types.d.ts.map +1 -1
  7. package/dist/api/types.js.map +1 -1
  8. package/dist/default/FacetGroupControl.d.ts +34 -4
  9. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  10. package/dist/default/FacetGroupControl.js +112 -7
  11. package/dist/default/FacetGroupControl.js.map +1 -1
  12. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  13. package/dist/default/FacetPanelPane.js +27 -1
  14. package/dist/default/FacetPanelPane.js.map +1 -1
  15. package/dist/default/LocationSummaryLine.d.ts +3 -0
  16. package/dist/default/LocationSummaryLine.d.ts.map +1 -1
  17. package/dist/default/LocationSummaryLine.js +55 -8
  18. package/dist/default/LocationSummaryLine.js.map +1 -1
  19. package/dist/default/PartitionChips.d.ts +23 -0
  20. package/dist/default/PartitionChips.d.ts.map +1 -0
  21. package/dist/default/PartitionChips.js +94 -0
  22. package/dist/default/PartitionChips.js.map +1 -0
  23. package/dist/default/PopularValues.d.ts +58 -0
  24. package/dist/default/PopularValues.d.ts.map +1 -0
  25. package/dist/default/PopularValues.js +46 -0
  26. package/dist/default/PopularValues.js.map +1 -0
  27. package/dist/default/SearchPage.d.ts.map +1 -1
  28. package/dist/default/SearchPage.js +5 -0
  29. package/dist/default/SearchPage.js.map +1 -1
  30. package/dist/default/index.d.ts +5 -1
  31. package/dist/default/index.d.ts.map +1 -1
  32. package/dist/default/index.js +6 -1
  33. package/dist/default/index.js.map +1 -1
  34. package/dist/headless/FacetPanel.d.ts +29 -1
  35. package/dist/headless/FacetPanel.d.ts.map +1 -1
  36. package/dist/headless/FacetPanel.js +14 -0
  37. package/dist/headless/FacetPanel.js.map +1 -1
  38. package/dist/headless/SearchStateProvider.d.ts +15 -0
  39. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  40. package/dist/headless/SearchStateProvider.js +38 -3
  41. package/dist/headless/SearchStateProvider.js.map +1 -1
  42. package/dist/headless/useFacetLabels.js +7 -7
  43. package/dist/headless/useFacetLabels.js.map +1 -1
  44. package/dist/i18n/es.d.ts.map +1 -1
  45. package/dist/i18n/es.js +12 -0
  46. package/dist/i18n/es.js.map +1 -1
  47. package/dist/i18n/keys.d.ts +72 -0
  48. package/dist/i18n/keys.d.ts.map +1 -1
  49. package/dist/i18n/keys.js +86 -0
  50. package/dist/i18n/keys.js.map +1 -1
  51. package/dist/i18n/ru.d.ts.map +1 -1
  52. package/dist/i18n/ru.js +14 -0
  53. package/dist/i18n/ru.js.map +1 -1
  54. package/dist/index.d.ts +4 -3
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +2 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/state/degradations.d.ts +9 -0
  59. package/dist/state/degradations.d.ts.map +1 -1
  60. package/dist/state/degradations.js +13 -0
  61. package/dist/state/degradations.js.map +1 -1
  62. package/dist/state/facets.d.ts +29 -6
  63. package/dist/state/facets.d.ts.map +1 -1
  64. package/dist/state/facets.js +120 -16
  65. package/dist/state/facets.js.map +1 -1
  66. package/dist/state/translit.d.ts +58 -0
  67. package/dist/state/translit.d.ts.map +1 -0
  68. package/dist/state/translit.js +115 -0
  69. package/dist/state/translit.js.map +1 -0
  70. package/dist/state/urlState.d.ts +26 -1
  71. package/dist/state/urlState.d.ts.map +1 -1
  72. package/dist/state/urlState.js +43 -17
  73. package/dist/state/urlState.js.map +1 -1
  74. package/llms.txt +4 -2
  75. package/manifest.json +49 -2
  76. package/nav-manifest.json +1 -1
  77. package/package.json +8 -8
  78. package/src/analytics/generated/events.json +1 -1
  79. package/src/api/generated/schema.ts +96 -4
  80. package/src/api/types.ts +77 -5
  81. package/src/default/FacetGroupControl.tsx +213 -8
  82. package/src/default/FacetPanelPane.tsx +36 -1
  83. package/src/default/LocationSummaryLine.tsx +114 -53
  84. package/src/default/PartitionChips.tsx +161 -0
  85. package/src/default/PopularValues.tsx +151 -0
  86. package/src/default/SearchPage.tsx +5 -0
  87. package/src/default/index.ts +15 -0
  88. package/src/headless/FacetPanel.tsx +49 -1
  89. package/src/headless/SearchStateProvider.tsx +59 -3
  90. package/src/headless/useFacetLabels.ts +7 -7
  91. package/src/i18n/es.ts +16 -0
  92. package/src/i18n/keys.ts +91 -0
  93. package/src/i18n/ru.ts +20 -0
  94. package/src/index.ts +12 -0
  95. package/src/state/degradations.ts +14 -0
  96. package/src/state/facets.ts +154 -28
  97. package/src/state/translit.ts +113 -0
  98. package/src/state/urlState.ts +72 -13
package/src/i18n/keys.ts CHANGED
@@ -116,6 +116,17 @@ export const SEARCH_I18N_KEYS = {
116
116
  facetsClearAll: "search.facets.clear_all",
117
117
  facetsApproximate: "search.facets.approximate",
118
118
  facetsSkipped: "search.facets.skipped",
119
+ /**
120
+ * "N filters apply to too few of these results" — the sentence that
121
+ * REPLACES {@link facetsEmpty} whenever `facet_meta.withheld` is not
122
+ * empty. A PLURAL FAMILY: it counts a noun in words.
123
+ *
124
+ * D175: a branch category holding 46 phones that all carry a manufacturer
125
+ * printed "this search offers no filters". The groups were counted and
126
+ * then withheld for covering too little of the set — so "there are none"
127
+ * was false, and the honest sentence is this one, with the number in it.
128
+ */
129
+ facetsWithheld: "search.facets.withheld",
119
130
  /** Shown INSTEAD of a count for a slug the server did not count. */
120
131
  facetsNotCounted: "search.facets.not_counted",
121
132
  facetsDrillDownHint: "search.facets.drill_down_hint",
@@ -146,6 +157,24 @@ export const SEARCH_I18N_KEYS = {
146
157
  * Distinct from `facetsEmpty`, which says the SEARCH offers no filters —
147
158
  * here the filters exist and the query missed. */
148
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
+ /** The way from the "popular values" block into the whole control. The
175
+ * block is a shortcut over the busiest values, so the link has to say that
176
+ * the rest exist. */
177
+ facetsPopularAll: "search.facets.popular_all",
149
178
  /**
150
179
  * The rail's sticky footer: what the applied filters LEFT — "N listings
151
180
  * match". A PLURAL FAMILY: it counts a noun in words. Desktop filters
@@ -181,6 +210,20 @@ export const SEARCH_I18N_KEYS = {
181
210
  * `{count}` is how many chips it hides (D16). */
182
211
  filtersChipOverflow: "search.filters.chips_overflow",
183
212
 
213
+ // ── a partition: one template split by a value its children name ─────────
214
+ /**
215
+ * The first chip of a partition row — the parent itself, unnarrowed.
216
+ *
217
+ * Not `facetsPopularAll` reused: that one OPENS a control, this one is a
218
+ * CHOICE among the children (and the one that is selected by default). The
219
+ * two are the same word in English and stop being one the moment a
220
+ * translator reads either as an imperative.
221
+ */
222
+ partitionAll: "search.partition.all",
223
+ /** The accessible name of the chip row — a `radiogroup` with no name is
224
+ * announced as nothing at all. */
225
+ partitionLabel: "search.partition.label",
226
+
184
227
  // ── the way OUT of a search that found nothing ───────────────────────────
185
228
  /** Caption above the exit buttons. Not a second "nothing found" — that
186
229
  * sentence has already been said; this one says what can be done next. */
@@ -217,6 +260,19 @@ export const SEARCH_I18N_KEYS = {
217
260
  // ── geo ──────────────────────────────────────────────────────────────────
218
261
  geoTitle: "search.geo.title",
219
262
  geoRadiusKm: "search.geo.radius_km",
263
+ /**
264
+ * The same number, said in the fewest words that still say it — for the
265
+ * OFFER, where the sentence shares a 390px line with a place name and the
266
+ * word "Filters".
267
+ *
268
+ * Measured at 390: "Near me · Within 25 km" made the control 272px wide
269
+ * against 231px of room, so the offer was cut mid-word by the group's own
270
+ * clip and the filters door overlapped it by 52px. Dropping the number
271
+ * instead was not an option — a button that says only "near me" asks a
272
+ * person to accept a radius they cannot see, which is the whole reason the
273
+ * offer states one. So the PREPOSITION goes and the number stays.
274
+ */
275
+ geoRadiusKmShort: "search.geo.radius_km_short",
220
276
  geoRadiusLabel: "search.geo.radius_label",
221
277
  geoClear: "search.geo.clear",
222
278
  geoNearMe: "search.geo.near_me",
@@ -243,6 +299,18 @@ export const SEARCH_I18N_KEYS = {
243
299
  * `geoLabel` and this sentence never appears.
244
300
  */
245
301
  geoChosenPlace: "search.geo.chosen_place",
302
+ /**
303
+ * What the line says when the applied location IS the position the host
304
+ * offered — the visitor pressed "Near me" and nothing else has moved since.
305
+ *
306
+ * Measured on a live leaf: pressing the offer turned the line into "A chosen
307
+ * place on the map", to a person who had never opened a map. The sentence
308
+ * was not wrong about the mechanism (a centre with a radius is what a map
309
+ * pick produces); it was wrong about the only thing the reader can check —
310
+ * how the search came to be looking there. The provider reports the fact
311
+ * (`geoIsOffer`) rather than this file guessing at it.
312
+ */
313
+ geoNearYou: "search.geo.near_you",
246
314
 
247
315
  // ── the URL that could not be read ───────────────────────────────────────
248
316
  urlIssuesTitle: "search.url.issues_title",
@@ -250,6 +318,8 @@ export const SEARCH_I18N_KEYS = {
250
318
  urlIssueGeoIncomplete: "search.url.issue.geo_incomplete",
251
319
  urlIssueBboxMalformed: "search.url.issue.bbox_malformed",
252
320
  urlIssueRangeMalformed: "search.url.issue.range_malformed",
321
+ /** A radius with no point to measure it from — see `urlState.ts`. */
322
+ urlIssueRadiusWithoutPlace: "search.url.issue.radius_without_place",
253
323
 
254
324
  // ── degradations (the envelope's `degraded[]`) ───────────────────────────
255
325
  degradedTitle: "search.degraded.title",
@@ -258,6 +328,10 @@ export const SEARCH_I18N_KEYS = {
258
328
  degradedExactTotal: "search.degraded.exact_total",
259
329
  degradedExactFacetCounts: "search.degraded.exact_facet_counts",
260
330
  degradedCategoryRollup: "search.degraded.category_rollup",
331
+ /** The engine cannot say which categories the results are in, so no facet
332
+ * plan could be drawn from them — a READER-facing sentence, because the
333
+ * consequence is a filter panel that is thinner than the catalogue. */
334
+ degradedFacetPlanEvidence: "search.degraded.facet_plan_evidence",
261
335
  degradedScorer: "search.degraded.scorer",
262
336
  degradedUnknown: "search.degraded.unknown",
263
337
 
@@ -329,6 +403,7 @@ export const SEARCH_I18N_PLURAL_KEYS: readonly SearchI18nKey[] = [
329
403
  SEARCH_I18N_KEYS.filtersShowCountAtLeast,
330
404
  SEARCH_I18N_KEYS.boxCategoryCount,
331
405
  SEARCH_I18N_KEYS.facetsMatchCount,
406
+ SEARCH_I18N_KEYS.facetsWithheld,
332
407
  ];
333
408
 
334
409
  /**
@@ -401,6 +476,10 @@ export const searchI18nBundleEn: Record<string, string> = {
401
476
  "Counts are approximate — there were too many candidates to count them all.",
402
477
  "search.facets.skipped":
403
478
  "These filters were not counted for this search: {slugs}",
479
+ "search.facets.withheld.one":
480
+ "{count} filter applies to too few of these results",
481
+ "search.facets.withheld.other":
482
+ "{count} filters apply to too few of these results",
404
483
  "search.facets.not_counted": "not counted",
405
484
  "search.facets.drill_down_hint":
406
485
  "Each count is what you would get by choosing that value instead of the one you have.",
@@ -415,6 +494,12 @@ export const searchI18nBundleEn: Record<string, string> = {
415
494
  "search.facets.show_less": "Show fewer",
416
495
  "search.facets.search": "Find a filter",
417
496
  "search.facets.search_empty": "No filter matches this",
497
+ "search.facets.dictionary_search": "Find a value",
498
+ "search.facets.dictionary_empty": "No value matches this",
499
+ "search.facets.dictionary_chosen": "Chosen",
500
+ "search.facets.popular_all": "All",
501
+ "search.partition.all": "All",
502
+ "search.partition.label": "Section",
418
503
  "search.facets.match_count.one": "{count} listing matches",
419
504
  "search.facets.match_count.other": "{count} listings match",
420
505
  "search.facets.range_invalid":
@@ -452,11 +537,13 @@ export const searchI18nBundleEn: Record<string, string> = {
452
537
  "search.geo.title": "Location",
453
538
  "search.geo.near_me": "Near me",
454
539
  "search.geo.radius_km": "Within {km} km",
540
+ "search.geo.radius_km_short": "{km} km",
455
541
  "search.geo.radius_label": "Radius, km",
456
542
  "search.geo.clear": "Anywhere",
457
543
  "search.geo.everywhere": "Searching everywhere",
458
544
  "search.geo.box": "Inside the shown area",
459
545
  "search.geo.chosen_place": "A chosen place on the map",
546
+ "search.geo.near_you": "Near you",
460
547
 
461
548
  "search.url.issues_title": "Part of this link could not be read",
462
549
  "search.url.issue.not_a_number": "“{param}” in this link is not a number, so it was ignored",
@@ -466,6 +553,8 @@ export const searchI18nBundleEn: Record<string, string> = {
466
553
  "the map area in this link is incomplete, so it was ignored",
467
554
  "search.url.issue.range_malformed":
468
555
  "the range “{param}” in this link needs two numbers, so it was ignored",
556
+ "search.url.issue.radius_without_place":
557
+ "this link asks for a radius but names no place, so nothing is narrowed yet — choose a place and this radius is the one that applies",
469
558
 
470
559
  "search.degraded.title": "What this search could not do",
471
560
  "search.degraded.typo_tolerance":
@@ -476,6 +565,8 @@ export const searchI18nBundleEn: Record<string, string> = {
476
565
  "search.degraded.exact_facet_counts": "The filter counts are approximate.",
477
566
  "search.degraded.category_rollup":
478
567
  "Subcategories may be missing from these results — the category service did not answer.",
568
+ "search.degraded.facet_plan_evidence":
569
+ "We could not work out which filters fit these results, so there may be more than the panel shows.",
479
570
  "search.degraded.scorer":
480
571
  "The ranking parameter “{scorer}” was not applied — the search engine in use cannot evaluate it.",
481
572
  "search.degraded.unknown":
package/src/i18n/ru.ts CHANGED
@@ -100,6 +100,14 @@ export const searchI18nBundleRu: I18nDictionary = {
100
100
  "Счётчики приблизительные — подходящих объявлений слишком много, чтобы пересчитать все.",
101
101
  "search.facets.skipped":
102
102
  "Эти фильтры для текущего поиска не посчитаны: {slugs}",
103
+ "search.facets.withheld.one":
104
+ "Ещё {count} фильтр подходит слишком малой части результатов",
105
+ "search.facets.withheld.few":
106
+ "Ещё {count} фильтра подходят слишком малой части результатов",
107
+ "search.facets.withheld.many":
108
+ "Ещё {count} фильтров подходят слишком малой части результатов",
109
+ "search.facets.withheld.other":
110
+ "Ещё {count} фильтра подходят слишком малой части результатов",
103
111
  "search.facets.not_counted": "не посчитано",
104
112
  "search.facets.drill_down_hint":
105
113
  "Рядом с каждым значением — сколько будет, если выбрать его вместо текущего.",
@@ -114,6 +122,12 @@ export const searchI18nBundleRu: I18nDictionary = {
114
122
  "search.facets.show_less": "Свернуть",
115
123
  "search.facets.search": "Найти фильтр",
116
124
  "search.facets.search_empty": "Среди фильтров такого нет",
125
+ "search.facets.dictionary_search": "Найти значение",
126
+ "search.facets.dictionary_empty": "Такого значения здесь нет",
127
+ "search.facets.dictionary_chosen": "Выбрано",
128
+ "search.facets.popular_all": "Все",
129
+ "search.partition.all": "Все",
130
+ "search.partition.label": "Раздел",
117
131
  "search.facets.match_count.one": "Найдено {count} объявление",
118
132
  "search.facets.match_count.few": "Найдено {count} объявления",
119
133
  "search.facets.match_count.many": "Найдено {count} объявлений",
@@ -156,12 +170,14 @@ export const searchI18nBundleRu: I18nDictionary = {
156
170
 
157
171
  "search.geo.title": "Где искать",
158
172
  "search.geo.radius_km": "В радиусе {km} км",
173
+ "search.geo.radius_km_short": "{km} км",
159
174
  "search.geo.radius_label": "Радиус, км",
160
175
  "search.geo.clear": "Везде",
161
176
  "search.geo.near_me": "Рядом со мной",
162
177
  "search.geo.everywhere": "Ищем везде",
163
178
  "search.geo.box": "В показанной области",
164
179
  "search.geo.chosen_place": "Выбранное место на карте",
180
+ "search.geo.near_you": "Рядом с вами",
165
181
 
166
182
  "search.url.issues_title": "Часть этой ссылки прочитать не удалось",
167
183
  "search.url.issue.not_a_number":
@@ -172,6 +188,8 @@ export const searchI18nBundleRu: I18nDictionary = {
172
188
  "область на карте в этой ссылке указана не полностью, поэтому она пропущена",
173
189
  "search.url.issue.range_malformed":
174
190
  "диапазону «{param}» в этой ссылке не хватает чисел, поэтому он пропущен",
191
+ "search.url.issue.radius_without_place":
192
+ "в ссылке задан радиус, но не задано место, поэтому пока ничего не сузилось — выберите место, и применится именно этот радиус",
175
193
 
176
194
  "search.degraded.title": "Что этот поиск не смог сделать",
177
195
  "search.degraded.typo_tolerance":
@@ -182,6 +200,8 @@ export const searchI18nBundleRu: I18nDictionary = {
182
200
  "search.degraded.exact_facet_counts": "Счётчики фильтров приблизительные.",
183
201
  "search.degraded.category_rollup":
184
202
  "В выдаче могут отсутствовать подкатегории — сервис категорий не ответил.",
203
+ "search.degraded.facet_plan_evidence":
204
+ "Не удалось определить, какие фильтры подходят этим результатам, — их может быть больше, чем показано.",
185
205
  "search.degraded.scorer":
186
206
  "Параметр ранжирования «{scorer}» не применялся — используемый движок не умеет его считать.",
187
207
  "search.degraded.unknown":
package/src/index.ts CHANGED
@@ -51,8 +51,12 @@ export {
51
51
  suggestTerms,
52
52
  } from "./api/types.js";
53
53
  export type {
54
+ FacetCategoryCount,
55
+ FacetLabels,
56
+ FacetLabelsMap,
54
57
  FacetMeta,
55
58
  FacetSelection,
59
+ FacetWithheldGroup,
56
60
  RankingResponse,
57
61
  Scorer,
58
62
  SearchDegradation,
@@ -95,6 +99,7 @@ export type {
95
99
  } from "./state/urlState.js";
96
100
 
97
101
  export {
102
+ FACET_PLAN_EVIDENCE,
98
103
  countIsEstimate,
99
104
  countKind,
100
105
  degradationAudience,
@@ -117,9 +122,16 @@ export {
117
122
  export type {
118
123
  BuildFacetGroupsInput,
119
124
  FacetGroup,
125
+ FacetLabelSource,
120
126
  FacetOption,
121
127
  } from "./state/facets.js";
122
128
 
129
+ export {
130
+ consonantKey,
131
+ translitKey,
132
+ translitPrefixMatch,
133
+ } from "./state/translit.js";
134
+
123
135
  export {
124
136
  RANGE_FEATURE_TYPES,
125
137
  buildRangeGroups,
@@ -17,12 +17,26 @@ import type { SearchDegradation, SearchDegradationKind } from "../api/types.js";
17
17
 
18
18
  const SCORER_PREFIX = "scorer:";
19
19
 
20
+ /**
21
+ * "This answer's facet plan could not be drawn from evidence."
22
+ *
23
+ * Named rather than inlined because the FACET PANEL reads the raw literal
24
+ * off the envelope: whether "this search offers no filters" is a true
25
+ * sentence is a question about THIS answer, not about the banner, and the
26
+ * panel must not have to parse the whole list to ask it.
27
+ */
28
+ export const FACET_PLAN_EVIDENCE = "facet_plan_evidence";
29
+
20
30
  const KNOWN: Readonly<Record<string, SearchDegradationKind>> = {
21
31
  typo_tolerance: "typo_tolerance",
22
32
  phrase_synonyms: "phrase_synonyms",
23
33
  exact_total: "exact_total",
24
34
  exact_facet_counts: "exact_facet_counts",
25
35
  category_rollup: "category_rollup",
36
+ // Reader-facing by omission from OPERATOR_KINDS below, and deliberately:
37
+ // it changes what an empty filter panel MEANS, which is the one thing on
38
+ // the page a buyer can act on (widen, or filter by category instead).
39
+ [FACET_PLAN_EVIDENCE]: "facet_plan_evidence",
26
40
  };
27
41
 
28
42
  /** i18n key per kind (`search.degraded.*`). */
@@ -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,48 @@
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
+ }
59
98
 
60
99
  /**
61
100
  * Value types whose values are a BOUNDED OPTION SET — the only kind of
@@ -111,14 +150,22 @@ export interface FacetOption {
111
150
  readonly count: number | null;
112
151
  /** Resolved through the category schema; the raw value when there is none. */
113
152
  readonly label: string;
153
+ /** Which of the three sources named it — `"none"` means {@link label} IS
154
+ * the raw index term. The host seam reads this rather than comparing the
155
+ * two strings. */
156
+ readonly labelSource: FacetLabelSource;
114
157
  readonly selected: boolean;
115
158
  }
116
159
 
117
160
  /** One facet slug, with its options. */
118
161
  export interface FacetGroup {
119
162
  readonly slug: string;
120
- /** The feature's display name (translated when it is a key), else the slug. */
163
+ /** The group's heading: the answer's own `label`, else the feature's
164
+ * display name, else — with a dev warning — the raw slug. */
121
165
  readonly label: string;
166
+ /** Which source named the heading. `"none"` is the slug standing in for a
167
+ * name nobody has; a surface marks it so a storefront test can fail on it. */
168
+ readonly labelSource: FacetLabelSource;
122
169
  /** The category-schema entry behind the slug, when the host supplied one. */
123
170
  readonly feature: FeatureDef | undefined;
124
171
  /** `false` when the server skipped this slug — counts are `null`. */
@@ -199,17 +246,17 @@ export interface BuildFacetGroupsInput {
199
246
  * `apple` or `chernyy`. The server read that vocabulary against the
200
247
  * snapshot the documents were written with.
201
248
  *
249
+ * Each entry also carries the GROUP's own `label` — the heading, resolved
250
+ * from the feature definition server-side and localized like everything
251
+ * else. It leads for the same two reasons, and it is what a category whose
252
+ * host passed no feature list has instead of a rail full of index slugs.
253
+ *
202
254
  * ABSENT on a server older than 0.4.0 — absent, not empty — which is why
203
255
  * every read of it here is optional-chained and every option falls through
204
256
  * to the schema and then to its raw self. No labels are invented at any
205
257
  * step.
206
258
  */
207
- readonly facetLabels?: Readonly<
208
- Record<
209
- string,
210
- { readonly translatable: boolean; readonly values: Readonly<Record<string, string>> }
211
- >
212
- >;
259
+ readonly facetLabels?: FacetLabelsMap;
213
260
  /** Translator for label keys. */
214
261
  readonly t?: (key: string) => string;
215
262
  /** BCP-47 tag, forwarded to `formatFeatureValue` for `date` options. */
@@ -287,11 +334,49 @@ function resolveLabel(
287
334
  slug: string,
288
335
  value: string,
289
336
  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
- );
337
+ ): { readonly label: string; readonly labelSource: FacetLabelSource } {
338
+ const answer = serverLabel(input.facetLabels, slug, value, input.t);
339
+ if (answer !== undefined) return { label: answer, labelSource: "server" };
340
+ const schema = facetOptionLabel(feature, value, labelOptions);
341
+ // `facetOptionLabel` hands back the value unchanged for an option it
342
+ // cannot name, and that identity is the whole test — a formatter that
343
+ // returned the term is a formatter that named nothing.
344
+ return schema === value
345
+ ? { label: value, labelSource: "none" }
346
+ : { label: schema, labelSource: "schema" };
347
+ }
348
+
349
+ /**
350
+ * The group's HEADING, in the one order the fleet states: the answer, then
351
+ * the category schema, then the slug under a dev warning.
352
+ *
353
+ * The answer leads because it is the only source that always exists. The
354
+ * schema slot is optional, and at a live classified's cars branch the
355
+ * storefront passed an empty feature list, so every heading in the rail was
356
+ * a raw index slug — the make group was on screen, unlabelled, and the
357
+ * complaint that came back was "I cannot pick a make".
358
+ *
359
+ * The slug is not a fallback anyone may ship: it renders, because a heading
360
+ * beats no heading, and it renders MARKED — `labelSource: "none"`, a warning
361
+ * in development, and a data attribute on the drawn group.
362
+ */
363
+ function resolveGroupLabel(
364
+ input: BuildFacetGroupsInput,
365
+ feature: FeatureDef | undefined,
366
+ slug: string
367
+ ): { readonly label: string; readonly labelSource: FacetLabelSource } {
368
+ const answer = input.facetLabels?.[slug]?.label;
369
+ if (typeof answer === "string" && answer.length > 0) {
370
+ return { label: translate(input.t, answer), labelSource: "server" };
371
+ }
372
+ // `featureName` falls back to the slug itself, so the def has to be asked
373
+ // for a NAME rather than for a name-or-slug: a def with none names nothing.
374
+ const declared = feature?.name;
375
+ if (typeof declared === "string" && declared.length > 0) {
376
+ return { label: translate(input.t, declared), labelSource: "schema" };
377
+ }
378
+ warnUnnamedGroup(slug);
379
+ return { label: slug, labelSource: "none" };
295
380
  }
296
381
 
297
382
  /**
@@ -362,6 +447,9 @@ export function facetOptionLabel(
362
447
  * a chip in the row. The APPLIED-filter clause outranks the type rule: a
363
448
  * constraint the URL carries always gets its control back, whatever the schema
364
449
  * now says about it, or a person is left holding a filter they cannot clear.
450
+ *
451
+ * A COUNTED group with zero coverage goes the same way, and for the same
452
+ * reason — see {@link keepsAnAxisOpen}.
365
453
  */
366
454
  export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGroup[] {
367
455
  const bySlug = new Map<string, FeatureDef>();
@@ -443,24 +531,62 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
443
531
 
444
532
  return {
445
533
  slug,
446
- label:
447
- feature === undefined
448
- ? slug
449
- : translate(input.t, featureName(feature)),
534
+ ...resolveGroupLabel(input, feature, slug),
450
535
  feature,
451
536
  counted,
452
537
  selected,
453
538
  options: values.map((value) => ({
454
539
  value,
455
540
  count: counted ? (counts[value] ?? 0) : null,
456
- // The host's schema first when it actually resolves the value, the
457
- // answer's caption when it does not (or when there is no schema at
458
- // all), the raw value when neither knows. `facetOptionLabel` returns
459
- // the value unchanged for an option it cannot name, which is what
460
- // makes "did it resolve?" answerable without a second lookup.
461
- label: resolveLabel(input, feature, slug, value, labelOptions),
541
+ // The answer's caption first, then the schema's own option table,
542
+ // then the raw value and the source is carried rather than
543
+ // re-derived, so "did it resolve?" needs no second lookup.
544
+ ...resolveLabel(input, feature, slug, value, labelOptions),
462
545
  selected: selected.includes(value),
463
546
  })),
464
547
  };
465
- });
548
+ }).filter(keepsAnAxisOpen);
549
+ }
550
+
551
+ /**
552
+ * Is this group an axis a person can actually move along?
553
+ *
554
+ * The coverage floor stops at the queried category's own schema, on the
555
+ * server, on purpose: `FACET_MIN_COVERAGE` governs only the slugs an
556
+ * evidence plan BORROWED from sibling leaves, because "a closed option set
557
+ * answering with its zeros is a shipped decision". That is right about an
558
+ * option and wrong about a GROUP. A size chart showing `XL — 0` beside
559
+ * `M — 12` is telling the truth about a shape the reader wants to see whole;
560
+ * a group whose every option is 0 is not a shape, it is three checkboxes that
561
+ * are each guaranteed to return nothing, and it costs a heading in the rail
562
+ * and a chip on a 390px row to say so.
563
+ *
564
+ * Measured on the deployed phones leaf: `sim_config`, `device_history` and
565
+ * `set` are authored `select` features that no listing in the leaf fills.
566
+ * The server's `fill_zero_options` creates the slug and zero-fills every
567
+ * authored option, so all three arrive counted, complete and dead, and the
568
+ * withholding loop never looks at them because an authored plan has no
569
+ * `evidence`. Nothing on the wire marks them: the client has to sum the
570
+ * buckets itself, which is exactly what {@link facetCoverage} already does
571
+ * for two other surfaces.
572
+ *
573
+ * Three things this must NOT drop, which is why the predicate is this narrow:
574
+ *
575
+ * - an UNCOUNTED group (`counted: false`). Its options carry `count: null`,
576
+ * so it sums to zero for the opposite reason — nobody looked. "We did not
577
+ * count this" and "there are none" are different sentences, and dropping
578
+ * on the first is the regression the `MAX_FACET_FIELDS` branch below
579
+ * exists to prevent (a live cars leaf: 26 facetable features declared, 12
580
+ * counted, and `/query` accepts `f.<slug>` for all 26).
581
+ * - a group the reader has ALREADY filtered on, whatever its counts say —
582
+ * the same clause that outranks the type rule. Withholding that group
583
+ * leaves a constraint applied with no control to undo it.
584
+ * - a group with any non-zero option. A zero option beside a live one is
585
+ * drill-down working as designed: it reports what swapping to that value
586
+ * would get you, and the answer being "nothing" is information.
587
+ */
588
+ function keepsAnAxisOpen(group: FacetGroup): boolean {
589
+ if (!group.counted) return true;
590
+ if (group.selected.length > 0) return true;
591
+ return facetCoverage(group) > 0;
466
592
  }