@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.
- package/CHANGELOG.md +138 -0
- package/README.md +65 -2
- package/dist/api/generated/schema.d.ts +96 -4
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +68 -6
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +34 -4
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +112 -7
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +27 -1
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts +3 -0
- package/dist/default/LocationSummaryLine.d.ts.map +1 -1
- package/dist/default/LocationSummaryLine.js +55 -8
- package/dist/default/LocationSummaryLine.js.map +1 -1
- package/dist/default/PartitionChips.d.ts +23 -0
- package/dist/default/PartitionChips.d.ts.map +1 -0
- package/dist/default/PartitionChips.js +94 -0
- package/dist/default/PartitionChips.js.map +1 -0
- package/dist/default/PopularValues.d.ts +58 -0
- package/dist/default/PopularValues.d.ts.map +1 -0
- package/dist/default/PopularValues.js +46 -0
- package/dist/default/PopularValues.js.map +1 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +5 -0
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/index.d.ts +5 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +6 -1
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +29 -1
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +14 -0
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +15 -0
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +38 -3
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/headless/useFacetLabels.js +7 -7
- package/dist/headless/useFacetLabels.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +12 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +72 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +86 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +14 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/state/degradations.d.ts +9 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +13 -0
- package/dist/state/degradations.js.map +1 -1
- package/dist/state/facets.d.ts +29 -6
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +120 -16
- package/dist/state/facets.js.map +1 -1
- package/dist/state/translit.d.ts +58 -0
- package/dist/state/translit.d.ts.map +1 -0
- package/dist/state/translit.js +115 -0
- package/dist/state/translit.js.map +1 -0
- package/dist/state/urlState.d.ts +26 -1
- package/dist/state/urlState.d.ts.map +1 -1
- package/dist/state/urlState.js +43 -17
- package/dist/state/urlState.js.map +1 -1
- package/llms.txt +4 -2
- package/manifest.json +49 -2
- package/nav-manifest.json +1 -1
- package/package.json +8 -8
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +96 -4
- package/src/api/types.ts +77 -5
- package/src/default/FacetGroupControl.tsx +213 -8
- package/src/default/FacetPanelPane.tsx +36 -1
- package/src/default/LocationSummaryLine.tsx +114 -53
- package/src/default/PartitionChips.tsx +161 -0
- package/src/default/PopularValues.tsx +151 -0
- package/src/default/SearchPage.tsx +5 -0
- package/src/default/index.ts +15 -0
- package/src/headless/FacetPanel.tsx +49 -1
- package/src/headless/SearchStateProvider.tsx +59 -3
- package/src/headless/useFacetLabels.ts +7 -7
- package/src/i18n/es.ts +16 -0
- package/src/i18n/keys.ts +91 -0
- package/src/i18n/ru.ts +20 -0
- package/src/index.ts +12 -0
- package/src/state/degradations.ts +14 -0
- package/src/state/facets.ts +154 -28
- package/src/state/translit.ts +113 -0
- 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.*`). */
|
package/src/state/facets.ts
CHANGED
|
@@ -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. **
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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?:
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
|
|
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
|
}
|