@stapel/search-react 0.22.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 +91 -0
- package/MODULE.md +12 -0
- package/README.md +84 -6
- package/dist/api/types.d.ts +13 -0
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +22 -4
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +85 -9
- 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 +14 -0
- package/dist/default/PartitionChips.d.ts.map +1 -1
- package/dist/default/PartitionChips.js +30 -4
- package/dist/default/PartitionChips.js.map +1 -1
- 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 +2 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +2 -2
- package/dist/default/index.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +3 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +22 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +25 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +3 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/state/facets.d.ts +64 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +144 -0
- 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/llms.txt +1 -1
- package/manifest.json +8 -1
- package/nav-manifest.json +1 -1
- package/package.json +7 -7
- package/src/analytics/generated/events.json +1 -1
- package/src/api/types.ts +13 -0
- package/src/default/FacetGroupControl.tsx +139 -14
- package/src/default/FacetPanelPane.tsx +131 -19
- package/src/default/FilterChips.tsx +5 -2
- package/src/default/PartitionChips.tsx +47 -4
- package/src/default/RangeFilterRow.tsx +116 -1
- package/src/default/SearchPage.tsx +61 -2
- package/src/default/index.ts +12 -2
- package/src/i18n/es.ts +3 -0
- package/src/i18n/keys.ts +25 -0
- package/src/i18n/ru.ts +3 -0
- package/src/index.ts +4 -0
- package/src/state/facets.ts +152 -0
- package/src/state/ranges.ts +44 -0
|
@@ -66,7 +66,7 @@ import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
|
66
66
|
import { Button, Flex } from "antd";
|
|
67
67
|
import { SkinDialog, SkinTheme, useDialogSurface } from "@stapel/tokens-antd/skin";
|
|
68
68
|
import { useT, useTPlural } from "@stapel/core";
|
|
69
|
-
import { spacing } from "@stapel/tokens";
|
|
69
|
+
import { cssVar, spacing } from "@stapel/tokens";
|
|
70
70
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
71
71
|
import { SearchStateProvider, useSearchState } from "../headless/SearchStateProvider.js";
|
|
72
72
|
import type { SearchParamsAdapter } from "../headless/SearchStateProvider.js";
|
|
@@ -126,6 +126,50 @@ export const FILTERS_RAIL_WIDTH = 280;
|
|
|
126
126
|
* `alignSelf: flex-start` is load-bearing: a flex child stretches to the row's
|
|
127
127
|
* height by default, and a stretched box has nothing to stick to.
|
|
128
128
|
*/
|
|
129
|
+
/** The class the rail's own scrollbar rules are hung on. */
|
|
130
|
+
export const RAIL_CLASS = "stapel-search-rail";
|
|
131
|
+
|
|
132
|
+
/** The `href` the hoisted rail sheet is deduplicated by. */
|
|
133
|
+
export const RAIL_STYLE_HREF = "stapel-search-rail";
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The rail scrolls, and its scrollbar must not sit ON the filters.
|
|
137
|
+
*
|
|
138
|
+
* `scrollbar-width: thin` and `scrollbar-gutter: stable` (below, in `RAIL`)
|
|
139
|
+
* are the standard half of this and they are not enough: on every WebKit
|
|
140
|
+
* platform with overlay scrollbars — a Mac by default, every iOS browser —
|
|
141
|
+
* the bar is drawn OVER the content and the gutter reserves nothing, so the
|
|
142
|
+
* walker saw the bar lying across the right edge of the checkbox labels.
|
|
143
|
+
*
|
|
144
|
+
* So the rail also declares a CLASSIC scrollbar through the WebKit
|
|
145
|
+
* pseudo-elements: a bar with a real width, which pushes the panel's content
|
|
146
|
+
* in by exactly that much instead of floating above it, drawn in the token
|
|
147
|
+
* palette so it is the panel's own hairline in both themes rather than a
|
|
148
|
+
* hard-coded grey that glows in the dark one. `--stapel-*` custom properties
|
|
149
|
+
* resolve per theme at paint time, which is why this is a sheet and not a
|
|
150
|
+
* pair of computed inline values: an inline colour would freeze whichever
|
|
151
|
+
* theme was mounted first.
|
|
152
|
+
*
|
|
153
|
+
* Emitted as one hoisted `<style>` (React 19 dedupes by `href`), because a
|
|
154
|
+
* pseudo-element is unreachable from an inline style — the same reason
|
|
155
|
+
* `<LocationSummaryLine>` hoists one.
|
|
156
|
+
*/
|
|
157
|
+
export function railScrollbarCss(): string {
|
|
158
|
+
const rail = `.${RAIL_CLASS}`;
|
|
159
|
+
return [
|
|
160
|
+
// A real width: an overlay bar occupies no space and therefore overlaps.
|
|
161
|
+
`${rail}::-webkit-scrollbar{inline-size:8px;block-size:8px}`,
|
|
162
|
+
`${rail}::-webkit-scrollbar-track{background:transparent}`,
|
|
163
|
+
`${rail}::-webkit-scrollbar-thumb{background:${cssVar("border")};` +
|
|
164
|
+
`border-radius:${cssVar("radius-full")}}`,
|
|
165
|
+
`${rail}::-webkit-scrollbar-thumb:hover{background:${cssVar("text-subtle")}}`,
|
|
166
|
+
// Firefox/Chromium's standard properties, stated here too so the rule
|
|
167
|
+
// travels with the class when the panel is used outside `<SearchPage>`.
|
|
168
|
+
`${rail}{scrollbar-width:thin;scrollbar-gutter:stable;` +
|
|
169
|
+
`scrollbar-color:${cssVar("border")} transparent}`,
|
|
170
|
+
].join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
129
173
|
const RAIL: CSSProperties = {
|
|
130
174
|
flex: `0 0 ${String(FILTERS_RAIL_WIDTH)}px`,
|
|
131
175
|
// Both bounds, not just the upper one. `flex-shrink: 0` already holds the
|
|
@@ -208,6 +252,14 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
208
252
|
* coordinate, with or without it.
|
|
209
253
|
*/
|
|
210
254
|
readonly geoLabel?: ReactNode;
|
|
255
|
+
/**
|
|
256
|
+
* The partition control, drawn at the top of the filter panel — see
|
|
257
|
+
* {@link FacetPanelPaneProps.partition}.
|
|
258
|
+
*/
|
|
259
|
+
readonly partition?: ReactNode;
|
|
260
|
+
/** Facet slugs pinned above every other group — see
|
|
261
|
+
* {@link FacetPanelPaneProps.pinnedFacets}. */
|
|
262
|
+
readonly pinnedFacets?: readonly string[];
|
|
211
263
|
/** Print the engine's list of uncounted facet slugs in the filter panel.
|
|
212
264
|
* Default `false` — see {@link FacetPanelPaneProps.skippedNotice}. */
|
|
213
265
|
readonly skippedNotice?: boolean;
|
|
@@ -639,7 +691,14 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
639
691
|
</>
|
|
640
692
|
) : showFilters ? (
|
|
641
693
|
<Flex align="flex-start" gap={spacing[5]} data-testid="search-page-columns">
|
|
642
|
-
<div style={RAIL}>
|
|
694
|
+
<div className={RAIL_CLASS} style={RAIL}>
|
|
695
|
+
{/* The rail's scrollbar, in the gutter and in the token palette —
|
|
696
|
+
see `railScrollbarCss`. Hoisted, deduped by `href`. */}
|
|
697
|
+
<style href={RAIL_STYLE_HREF} precedence="default">
|
|
698
|
+
{railScrollbarCss()}
|
|
699
|
+
</style>
|
|
700
|
+
{panel}
|
|
701
|
+
</div>
|
|
643
702
|
{/* ONE heading and ONE sort control. The page used to caption
|
|
644
703
|
the toolbar "Results" and then mount a pane whose own heading
|
|
645
704
|
says "Results" again — the live /s page printed both, one
|
package/src/default/index.ts
CHANGED
|
@@ -40,7 +40,12 @@
|
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
42
|
// ── surfaces ────────────────────────────────────────────────────────────────
|
|
43
|
-
export {
|
|
43
|
+
export {
|
|
44
|
+
SearchPage,
|
|
45
|
+
RAIL_CLASS,
|
|
46
|
+
RAIL_STYLE_HREF,
|
|
47
|
+
railScrollbarCss,
|
|
48
|
+
} from "./SearchPage.js";
|
|
44
49
|
export type { SearchPageProps, SearchFiltersLayout } from "./SearchPage.js";
|
|
45
50
|
|
|
46
51
|
export { SearchResultsPane, RESULTS_MAX_WIDTH } from "./SearchResultsPane.js";
|
|
@@ -95,7 +100,12 @@ export type { PopularValuesProps } from "./PopularValues.js";
|
|
|
95
100
|
export { PartitionChips } from "./PartitionChips.js";
|
|
96
101
|
export type { PartitionChild, PartitionChipsProps } from "./PartitionChips.js";
|
|
97
102
|
|
|
98
|
-
export {
|
|
103
|
+
export {
|
|
104
|
+
FacetPanelPane,
|
|
105
|
+
FACET_OPEN_GROUPS,
|
|
106
|
+
FACET_SEARCH_THRESHOLD,
|
|
107
|
+
FACET_VISIBLE_GROUPS,
|
|
108
|
+
} from "./FacetPanelPane.js";
|
|
99
109
|
export type {
|
|
100
110
|
FacetPanelPaneProps,
|
|
101
111
|
CategoryFilterSlotProps,
|
package/src/i18n/es.ts
CHANGED
|
@@ -96,6 +96,9 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
96
96
|
"search.facets.dictionary_search": "Buscar un valor",
|
|
97
97
|
"search.facets.dictionary_empty": "Ningún valor coincide con esto",
|
|
98
98
|
"search.facets.dictionary_chosen": "Elegido",
|
|
99
|
+
"search.facets.dictionary_any": "Cualquiera",
|
|
100
|
+
"search.facets.all_filters": "Todos los filtros ({count})",
|
|
101
|
+
"search.facets.range_bounds": "de {min} a {max}",
|
|
99
102
|
"search.facets.popular_all": "Todos",
|
|
100
103
|
"search.partition.all": "Todos",
|
|
101
104
|
"search.partition.label": "Sección",
|
package/src/i18n/keys.ts
CHANGED
|
@@ -171,6 +171,28 @@ export const SEARCH_I18N_KEYS = {
|
|
|
171
171
|
/** Over the values already chosen, which stay visible whatever the box
|
|
172
172
|
* says — a filter a person cannot see is a filter they cannot remove. */
|
|
173
173
|
facetsDictionaryChosen: "search.facets.dictionary_chosen",
|
|
174
|
+
/**
|
|
175
|
+
* The closed dictionary FIELD with nothing chosen — "no constraint on this
|
|
176
|
+
* axis", the way a classified says it ("Any make", shortened to "Any").
|
|
177
|
+
*
|
|
178
|
+
* Not `facetsPopularAll` and not `partitionAll`, which are both the word
|
|
179
|
+
* "All": those two name a WIDER SET a person can move to, and this one
|
|
180
|
+
* names the absence of a narrowing. The three read alike in English and
|
|
181
|
+
* diverge in every language with gender or case.
|
|
182
|
+
*/
|
|
183
|
+
facetsDictionaryAny: "search.facets.dictionary_any",
|
|
184
|
+
/**
|
|
185
|
+
* The tail of the rail, folded: how many groups are behind one control.
|
|
186
|
+
* `{count}` is a number in parentheses, not a counted noun, so this is not
|
|
187
|
+
* a plural family — the same shape as `facetsClearAll`.
|
|
188
|
+
*/
|
|
189
|
+
facetsAllFilters: "search.facets.all_filters",
|
|
190
|
+
/**
|
|
191
|
+
* The bounds of a picker axis, said in words when what was typed is
|
|
192
|
+
* outside them — a year before the catalogue's own floor otherwise does
|
|
193
|
+
* nothing at all, silently.
|
|
194
|
+
*/
|
|
195
|
+
facetsRangeBounds: "search.facets.range_bounds",
|
|
174
196
|
/** The way from the "popular values" block into the whole control. The
|
|
175
197
|
* block is a shortcut over the busiest values, so the link has to say that
|
|
176
198
|
* the rest exist. */
|
|
@@ -497,6 +519,9 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
497
519
|
"search.facets.dictionary_search": "Find a value",
|
|
498
520
|
"search.facets.dictionary_empty": "No value matches this",
|
|
499
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}",
|
|
500
525
|
"search.facets.popular_all": "All",
|
|
501
526
|
"search.partition.all": "All",
|
|
502
527
|
"search.partition.label": "Section",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -125,6 +125,9 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
125
125
|
"search.facets.dictionary_search": "Найти значение",
|
|
126
126
|
"search.facets.dictionary_empty": "Такого значения здесь нет",
|
|
127
127
|
"search.facets.dictionary_chosen": "Выбрано",
|
|
128
|
+
"search.facets.dictionary_any": "Любая",
|
|
129
|
+
"search.facets.all_filters": "Все фильтры ({count})",
|
|
130
|
+
"search.facets.range_bounds": "от {min} до {max}",
|
|
128
131
|
"search.facets.popular_all": "Все",
|
|
129
132
|
"search.partition.all": "Все",
|
|
130
133
|
"search.partition.label": "Раздел",
|
package/src/index.ts
CHANGED
|
@@ -116,8 +116,11 @@ export type {
|
|
|
116
116
|
export {
|
|
117
117
|
FACETABLE_FEATURE_TYPES,
|
|
118
118
|
buildFacetGroups,
|
|
119
|
+
facetGroupHasEvidence,
|
|
120
|
+
facetGroupIsDrawable,
|
|
119
121
|
facetOptionLabel,
|
|
120
122
|
isFacetableFeature,
|
|
123
|
+
orderFacetGroupsBySchema,
|
|
121
124
|
} from "./state/facets.js";
|
|
122
125
|
export type {
|
|
123
126
|
BuildFacetGroupsInput,
|
|
@@ -134,6 +137,7 @@ export {
|
|
|
134
137
|
|
|
135
138
|
export {
|
|
136
139
|
RANGE_FEATURE_TYPES,
|
|
140
|
+
RANGE_PICKER_MAX_VALUES,
|
|
137
141
|
buildRangeGroups,
|
|
138
142
|
coreRangeLabelKey,
|
|
139
143
|
isRangeFeature,
|
package/src/state/facets.ts
CHANGED
|
@@ -96,6 +96,46 @@ function warnUnnamedGroup(slug: string): void {
|
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/** Slugs already reported as undrawable — one warning per slug per page. */
|
|
100
|
+
const warnedUndrawable = new Set<string>();
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* An axis that reached the panel with nothing to draw, said once, in
|
|
104
|
+
* development only.
|
|
105
|
+
*
|
|
106
|
+
* Measured on a live classified's cars branch: `make_ref_select`,
|
|
107
|
+
* `model` and `generation` are `ref_select` features whose config is a bare
|
|
108
|
+
* `optionsRef` pointer into a vocabulary — there is no option table in the
|
|
109
|
+
* schema and there never will be — so whenever the server's facet plan does
|
|
110
|
+
* not COUNT them there is nothing on the client to enumerate, and the group
|
|
111
|
+
* left the rail without a word while every `select`-typed comfort option
|
|
112
|
+
* (steering side, power steering, heating) drew its schema table and stayed.
|
|
113
|
+
* buyer's report was "I cannot pick a make".
|
|
114
|
+
*
|
|
115
|
+
* The panel still refuses to draw a heading over nothing — that is the right
|
|
116
|
+
* call — but the disappearance is a WIRING FAULT with two possible owners
|
|
117
|
+
* (the server's plan skipped a required axis, or the host threaded the wrong
|
|
118
|
+
* category's schema), and neither of them can see it from the page.
|
|
119
|
+
*/
|
|
120
|
+
function warnUndrawableGroup(group: FacetGroup): void {
|
|
121
|
+
const env = typeof process === "undefined" ? undefined : process.env;
|
|
122
|
+
if (env?.NODE_ENV === "production") return;
|
|
123
|
+
if (warnedUndrawable.has(group.slug)) return;
|
|
124
|
+
warnedUndrawable.add(group.slug);
|
|
125
|
+
console.warn(
|
|
126
|
+
`[search-react] facet group "${group.slug}" has no values to draw: the ` +
|
|
127
|
+
`answer did not count it${
|
|
128
|
+
group.feature === undefined
|
|
129
|
+
? " and the category schema passed to this page does not define it"
|
|
130
|
+
: " and its config carries a vocabulary pointer, not an option table"
|
|
131
|
+
}, so the group is not rendered.` +
|
|
132
|
+
(group.feature?.mandatory === true
|
|
133
|
+
? " The schema marks this axis REQUIRED — a buyer cannot narrow by a" +
|
|
134
|
+
" field every seller had to fill."
|
|
135
|
+
: "")
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
99
139
|
/**
|
|
100
140
|
* Value types whose values are a BOUNDED OPTION SET — the only kind of
|
|
101
141
|
* feature a person can be offered as a filter.
|
|
@@ -222,6 +262,105 @@ export function orderFacetGroups(
|
|
|
222
262
|
return [...groups].sort(compareFacetsByEvidence);
|
|
223
263
|
}
|
|
224
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Does the ANSWER have evidence for this axis — at least one value some
|
|
267
|
+
* candidate actually carries?
|
|
268
|
+
*
|
|
269
|
+
* The one fact that outranks every other rule in this module. A bucket with a
|
|
270
|
+
* count above zero is the server saying "documents in this result set have
|
|
271
|
+
* this value", and no schema opinion, no missing feature list and no type
|
|
272
|
+
* table may take an axis like that off the screen.
|
|
273
|
+
*/
|
|
274
|
+
export function facetGroupHasEvidence(group: FacetGroup): boolean {
|
|
275
|
+
return group.options.some((option) => (option.count ?? 0) > 0);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Is there anything for a surface to DRAW here?
|
|
280
|
+
*
|
|
281
|
+
* Shared by the rail and the chip row, which each used to hold their own
|
|
282
|
+
* `options.length > 0` — one predicate, or the two surfaces drift into two
|
|
283
|
+
* opinions about what an empty group is.
|
|
284
|
+
*
|
|
285
|
+
* A group with no options is a heading over nothing: after
|
|
286
|
+
* {@link buildFacetGroups} learned to read the schema, what is left in that
|
|
287
|
+
* state is a `ref_select` whose config is a bare `optionsRef` pointer and
|
|
288
|
+
* which the server did not count — nothing to enumerate from either side. It
|
|
289
|
+
* is not drawn, and in development it is NAMED: a required axis (the make on
|
|
290
|
+
* cars leaf) disappearing out of a rail is exactly the fault this pair spent
|
|
291
|
+
* a release chasing, and it must not disappear silently a second time.
|
|
292
|
+
*
|
|
293
|
+
* A group the reader has already FILTERED on is drawn whatever its options
|
|
294
|
+
* say — a constraint with no control to remove it is worse than a bare
|
|
295
|
+
* heading.
|
|
296
|
+
*/
|
|
297
|
+
export function facetGroupIsDrawable(group: FacetGroup): boolean {
|
|
298
|
+
if (group.selected.length > 0) return true;
|
|
299
|
+
if (group.options.length > 0) return true;
|
|
300
|
+
warnUndrawableGroup(group);
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The order the RAIL puts groups in: the category's own schema order, with
|
|
306
|
+
* the axes the schema calls required in front of it.
|
|
307
|
+
*
|
|
308
|
+
* Evidence order ({@link compareFacetsByEvidence}) answers "which axis does
|
|
309
|
+
* this corpus fill in", which is the right question for a chip row that has
|
|
310
|
+
* room for four. It is the wrong question for a rail: on a cars leaf with
|
|
311
|
+
* three listings the busiest axis is whichever three values happen to be
|
|
312
|
+
* counted, so the rail opened on condition and colour while make, model and
|
|
313
|
+
* year — the three fields the schema marks
|
|
314
|
+
* `mandatory`, i.e. the three every seller had to fill and every buyer
|
|
315
|
+
* narrows by first — sat below them or off the fold entirely.
|
|
316
|
+
*
|
|
317
|
+
* So: the schema's own order, which is the order the composer asks the
|
|
318
|
+
* seller to fill the form in, with required first. Four bands:
|
|
319
|
+
*
|
|
320
|
+
* 1. `pinned` slugs, in the order given — the axis a page has already
|
|
321
|
+
* decided is its subject (a partition's own field).
|
|
322
|
+
* 2. schema-required (`mandatory: true`), in schema order.
|
|
323
|
+
* 3. everything else the schema names, in schema order.
|
|
324
|
+
* 4. what the schema does not name at all — including EVERY group when the
|
|
325
|
+
* host passed no feature list, which is the live parent-node case — in
|
|
326
|
+
* evidence order, because with no schema there is no other order to have.
|
|
327
|
+
*
|
|
328
|
+
* Stable: within a band the comparator falls through to evidence and then to
|
|
329
|
+
* the order `buildFacetGroups` gave, so equal-ranked groups never reshuffle.
|
|
330
|
+
*/
|
|
331
|
+
export function orderFacetGroupsBySchema(input: {
|
|
332
|
+
readonly groups: readonly FacetGroup[];
|
|
333
|
+
/** The category schema, in the order the category declares it. */
|
|
334
|
+
readonly categoryFeatures?: readonly FeatureDef[];
|
|
335
|
+
/** Slugs pinned above everything, in the order given. */
|
|
336
|
+
readonly pinned?: readonly string[];
|
|
337
|
+
}): readonly FacetGroup[] {
|
|
338
|
+
const schemaIndex = new Map<string, number>();
|
|
339
|
+
(input.categoryFeatures ?? []).forEach((feature, index) => {
|
|
340
|
+
if (!schemaIndex.has(feature.slug)) schemaIndex.set(feature.slug, index);
|
|
341
|
+
});
|
|
342
|
+
const pinnedIndex = new Map<string, number>();
|
|
343
|
+
(input.pinned ?? []).forEach((slug, index) => {
|
|
344
|
+
if (!pinnedIndex.has(slug)) pinnedIndex.set(slug, index);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const band = (group: FacetGroup): number => {
|
|
348
|
+
if (pinnedIndex.has(group.slug)) return 0;
|
|
349
|
+
if (!schemaIndex.has(group.slug)) return 3;
|
|
350
|
+
return group.feature?.mandatory === true ? 1 : 2;
|
|
351
|
+
};
|
|
352
|
+
const within = (group: FacetGroup): number =>
|
|
353
|
+
pinnedIndex.get(group.slug) ?? schemaIndex.get(group.slug) ?? 0;
|
|
354
|
+
|
|
355
|
+
return [...input.groups].sort((a, b) => {
|
|
356
|
+
const byBand = band(a) - band(b);
|
|
357
|
+
if (byBand !== 0) return byBand;
|
|
358
|
+
const byOrder = within(a) - within(b);
|
|
359
|
+
if (byOrder !== 0) return byOrder;
|
|
360
|
+
return compareFacetsByEvidence(a, b);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
225
364
|
export interface BuildFacetGroupsInput {
|
|
226
365
|
/** The envelope's `facets`: `{slug: {value: count}}`. */
|
|
227
366
|
readonly facets: Readonly<Record<string, Readonly<Record<string, number>>>>;
|
|
@@ -467,6 +606,19 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
|
|
|
467
606
|
seen.add(slug);
|
|
468
607
|
// Applied first, type second — in that order, so an `imei` somebody
|
|
469
608
|
// somehow got into a link keeps the control that removes it.
|
|
609
|
+
//
|
|
610
|
+
// EVIDENCE does not enter here, and the reason is worth stating because
|
|
611
|
+
// the opposite was tried: a counted bucket cannot promote a slug the
|
|
612
|
+
// schema NAMES AND DISOWNS. An `imei` the engine counted is still not a
|
|
613
|
+
// filter — a free-text identifier enumerates one term per document — and
|
|
614
|
+
// a `visibility: "owner"` feature is one the canon says is never
|
|
615
|
+
// facetable at all. What evidence does outrank is SILENCE: an absent def,
|
|
616
|
+
// an untyped def, and the whole empty feature list the live cars page
|
|
617
|
+
// passes at its parent node all answer "not a verdict"
|
|
618
|
+
// (see {@link isFacetableFeature}), so a counted axis is never dropped
|
|
619
|
+
// for a schema that says nothing about it. That is the live case; a
|
|
620
|
+
// wrong-schema case where some other category types `make_ref_select` as
|
|
621
|
+
// free text is not one this pair can tell apart from a real `imei`.
|
|
470
622
|
const applied = (input.state.filters[slug] ?? []).length > 0;
|
|
471
623
|
if (!applied && !isFacetableFeature(bySlug.get(slug))) continue;
|
|
472
624
|
slugs.push(slug);
|
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,
|