@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.
- package/CHANGELOG.md +141 -0
- package/MODULE.md +12 -0
- package/README.md +143 -2
- package/dist/api/types.d.ts +37 -3
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +53 -5
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +189 -8
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +41 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +72 -18
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +2 -2
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/PartitionChips.d.ts +37 -0
- package/dist/default/PartitionChips.d.ts.map +1 -0
- package/dist/default/PartitionChips.js +120 -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/RangeFilterRow.d.ts.map +1 -1
- package/dist/default/RangeFilterRow.js +65 -11
- package/dist/default/RangeFilterRow.js.map +1 -1
- package/dist/default/SearchPage.d.ts +49 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +43 -2
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/index.d.ts +7 -3
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +8 -3
- package/dist/default/index.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 +9 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +52 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +62 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +9 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/state/facets.d.ts +90 -6
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +216 -15
- package/dist/state/facets.js.map +1 -1
- package/dist/state/ranges.d.ts +22 -0
- package/dist/state/ranges.d.ts.map +1 -1
- package/dist/state/ranges.js +30 -0
- package/dist/state/ranges.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/llms.txt +3 -1
- package/manifest.json +45 -1
- package/nav-manifest.json +1 -1
- package/package.json +8 -8
- package/src/analytics/generated/events.json +1 -1
- package/src/api/types.ts +42 -3
- package/src/default/FacetGroupControl.tsx +339 -9
- package/src/default/FacetPanelPane.tsx +131 -19
- package/src/default/FilterChips.tsx +5 -2
- package/src/default/PartitionChips.tsx +204 -0
- package/src/default/PopularValues.tsx +151 -0
- package/src/default/RangeFilterRow.tsx +116 -1
- package/src/default/SearchPage.tsx +61 -2
- package/src/default/index.ts +27 -2
- package/src/headless/useFacetLabels.ts +7 -7
- package/src/i18n/es.ts +9 -0
- package/src/i18n/keys.ts +63 -0
- package/src/i18n/ru.ts +9 -0
- package/src/index.ts +13 -0
- package/src/state/facets.ts +259 -27
- package/src/state/ranges.ts +44 -0
- 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,
|
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,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
|
|
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?:
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
|
|
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
|
};
|
package/src/state/ranges.ts
CHANGED
|
@@ -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,
|