@stapel/search-react 0.31.0 → 0.32.1
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 +103 -0
- package/dist/api/generated/schema.d.ts +69 -6
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +118 -33
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js +13 -0
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +18 -1
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +25 -0
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +27 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +100 -19
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +30 -0
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +35 -2
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/PopularValues.d.ts +49 -2
- package/dist/default/PopularValues.d.ts.map +1 -1
- package/dist/default/PopularValues.js +57 -3
- package/dist/default/PopularValues.js.map +1 -1
- package/dist/default/RangeFilterRow.d.ts +25 -0
- package/dist/default/RangeFilterRow.d.ts.map +1 -1
- package/dist/default/RangeFilterRow.js +77 -5
- package/dist/default/RangeFilterRow.js.map +1 -1
- package/dist/default/SearchPage.d.ts +28 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +13 -6
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +10 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +1 -1
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/SortSelect.d.ts +10 -10
- package/dist/default/SortSelect.d.ts.map +1 -1
- package/dist/default/SortSelect.js +19 -8
- package/dist/default/SortSelect.js.map +1 -1
- package/dist/default/index.d.ts +4 -4
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +3 -3
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +7 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +2 -1
- 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 +0 -0
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/state/facets.d.ts +13 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +13 -0
- package/dist/state/facets.js.map +1 -1
- package/dist/state/panel.d.ts +79 -0
- package/dist/state/panel.d.ts.map +1 -0
- package/dist/state/panel.js +75 -0
- package/dist/state/panel.js.map +1 -0
- package/dist/state/ranges.d.ts +88 -3
- package/dist/state/ranges.d.ts.map +1 -1
- package/dist/state/ranges.js +96 -20
- package/dist/state/ranges.js.map +1 -1
- package/llms.txt +4 -4
- package/manifest.json +12 -2
- package/nav-manifest.json +1 -1
- package/package.json +9 -9
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +69 -6
- package/src/api/types.ts +132 -32
- package/src/default/FacetGroupControl.tsx +29 -1
- package/src/default/FacetPanelPane.tsx +257 -124
- package/src/default/FilterChips.tsx +51 -1
- package/src/default/PopularValues.tsx +88 -4
- package/src/default/RangeFilterRow.tsx +117 -20
- package/src/default/SearchPage.tsx +46 -4
- package/src/default/SearchResultsPane.tsx +13 -0
- package/src/default/SortSelect.tsx +29 -19
- package/src/default/index.ts +8 -1
- package/src/headless/FacetPanel.tsx +17 -2
- package/src/headless/SearchStateProvider.tsx +23 -0
- package/src/index.ts +8 -0
- package/src/state/facets.ts +27 -0
- package/src/state/panel.ts +138 -0
- package/src/state/ranges.ts +209 -47
|
@@ -168,6 +168,21 @@ export interface SearchStateBag {
|
|
|
168
168
|
toggleFilter(slug: string, value: string): void;
|
|
169
169
|
setFilter(slug: string, values: readonly string[]): void;
|
|
170
170
|
setRange(slug: string, range: SearchRange | null): void;
|
|
171
|
+
/**
|
|
172
|
+
* Write SEVERAL ranges at once — the bulk half of {@link setRange}, and the
|
|
173
|
+
* same relationship `setFilter` has to `toggleFilter`.
|
|
174
|
+
*
|
|
175
|
+
* Every setter here closes over the state it edits, so two of them in one
|
|
176
|
+
* tick both fold their change into the SAME starting value and the second
|
|
177
|
+
* one wins. That is invisible while one control writes one axis; it is the
|
|
178
|
+
* whole story for a filter panel with one "Apply" over a price row and
|
|
179
|
+
* a mileage row, where two calls would have applied the mileage and quietly
|
|
180
|
+
* dropped the price. This folds them all into one state and commits once —
|
|
181
|
+
* one request, one history entry.
|
|
182
|
+
*
|
|
183
|
+
* `null` for a slug clears it, exactly as it does on {@link setRange}.
|
|
184
|
+
*/
|
|
185
|
+
setRanges(ranges: Readonly<Record<string, SearchRange | null>>): void;
|
|
171
186
|
setGeo(geo: SearchGeo | null): void;
|
|
172
187
|
setLimit(limit: number | null): void;
|
|
173
188
|
clearAll(): void;
|
|
@@ -506,6 +521,14 @@ export function SearchStateProvider(
|
|
|
506
521
|
apply(setFilterValues(state, slug, values), historyOptions("filter")),
|
|
507
522
|
setRange: (slug, range) =>
|
|
508
523
|
apply(setRangeValue(state, slug, range), historyOptions("range")),
|
|
524
|
+
setRanges: (ranges) =>
|
|
525
|
+
apply(
|
|
526
|
+
Object.entries(ranges).reduce(
|
|
527
|
+
(next, [slug, range]) => setRangeValue(next, slug, range),
|
|
528
|
+
state
|
|
529
|
+
),
|
|
530
|
+
historyOptions("range")
|
|
531
|
+
),
|
|
509
532
|
setGeo: (geo) => {
|
|
510
533
|
apply(patchSearchState(state, { geo }), historyOptions("geo"));
|
|
511
534
|
},
|
package/src/index.ts
CHANGED
|
@@ -49,16 +49,21 @@ export {
|
|
|
49
49
|
SUGGEST_DEGRADED_CATEGORIES,
|
|
50
50
|
SUGGEST_DEGRADED_ROLLUP,
|
|
51
51
|
suggestTerms,
|
|
52
|
+
withheldSlugs,
|
|
52
53
|
} from "./api/types.js";
|
|
53
54
|
export type {
|
|
55
|
+
FacetAxisKind,
|
|
54
56
|
FacetCategoryCount,
|
|
55
57
|
FacetLabels,
|
|
56
58
|
FacetLabelsMap,
|
|
57
59
|
FacetMeta,
|
|
60
|
+
FacetRangeAxis,
|
|
58
61
|
FacetRangeBounds,
|
|
59
62
|
FacetRangesMap,
|
|
60
63
|
FacetSelection,
|
|
64
|
+
FacetWithheldAxis,
|
|
61
65
|
FacetWithheldGroup,
|
|
66
|
+
FacetWithheldReason,
|
|
62
67
|
RankingResponse,
|
|
63
68
|
Scorer,
|
|
64
69
|
SearchDegradation,
|
|
@@ -156,6 +161,9 @@ export {
|
|
|
156
161
|
} from "./state/ranges.js";
|
|
157
162
|
export type { BuildRangeGroupsInput, RangeGroup } from "./state/ranges.js";
|
|
158
163
|
|
|
164
|
+
export { orderPanelItems } from "./state/panel.js";
|
|
165
|
+
export type { OrderPanelItemsInput, PanelItem } from "./state/panel.js";
|
|
166
|
+
|
|
159
167
|
export {
|
|
160
168
|
SEARCH_BOX_DEBOUNCE_MS,
|
|
161
169
|
SEARCH_BOX_SUGGEST_DEBOUNCE_MS,
|
package/src/state/facets.ts
CHANGED
|
@@ -239,6 +239,19 @@ export interface FacetGroup {
|
|
|
239
239
|
* hold. `undefined` means nobody said, not "inline".
|
|
240
240
|
*/
|
|
241
241
|
readonly vocabulary?: string;
|
|
242
|
+
/**
|
|
243
|
+
* Where this group sits in the ONE sequence the panel draws — the answer's
|
|
244
|
+
* `facet_labels[<slug>].order`, numbered together with the numeric axes'
|
|
245
|
+
* `facet_meta.ranges[<slug>].order` (stapel-search 0.16.0+).
|
|
246
|
+
*
|
|
247
|
+
* The point of the field is that a group and a range are two ways of
|
|
248
|
+
* narrowing one authored feature, so they share a scale: drawn sorted by it,
|
|
249
|
+
* "Price" and "Year" land among the makes and models instead of above and
|
|
250
|
+
* below all of them. `undefined` (an older server, or a group the plan has
|
|
251
|
+
* no place for, which the wire says as `null`) means "no stated position",
|
|
252
|
+
* and `orderPanelItems` sorts those after everything that has one.
|
|
253
|
+
*/
|
|
254
|
+
readonly order?: number | undefined;
|
|
242
255
|
/** The group's heading: the answer's own `label`, else the feature's
|
|
243
256
|
* display name, else — with a dev warning — the raw slug. */
|
|
244
257
|
readonly label: string;
|
|
@@ -606,6 +619,19 @@ function optionalVocabulary(
|
|
|
606
619
|
return vocabulary === undefined ? {} : { vocabulary };
|
|
607
620
|
}
|
|
608
621
|
|
|
622
|
+
/**
|
|
623
|
+
* The group's stated place in the panel, when the answer states one.
|
|
624
|
+
*
|
|
625
|
+
* `null` on the wire is the server's own "the plan has no position for this
|
|
626
|
+
* group", which reads exactly like a server too old to state one: both mean
|
|
627
|
+
* "sort me after everything that does have a place". So both spread to
|
|
628
|
+
* nothing, and `FacetGroup.order` is a number or absent — never a `null` a
|
|
629
|
+
* comparator would have to remember to special-case.
|
|
630
|
+
*/
|
|
631
|
+
function optionalOrder(order: number | null | undefined): { order?: number } {
|
|
632
|
+
return typeof order === "number" ? { order } : {};
|
|
633
|
+
}
|
|
634
|
+
|
|
609
635
|
function resolveVocabulary(
|
|
610
636
|
input: BuildFacetGroupsInput,
|
|
611
637
|
feature: FeatureDef | undefined,
|
|
@@ -813,6 +839,7 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
|
|
|
813
839
|
return {
|
|
814
840
|
slug,
|
|
815
841
|
urlKey: keys.write[slug] ?? slug,
|
|
842
|
+
...optionalOrder(input.facetLabels?.[slug]?.order),
|
|
816
843
|
...optionalVocabulary(resolveVocabulary(input, feature, slug)),
|
|
817
844
|
...resolveGroupLabel(input, feature, slug),
|
|
818
845
|
feature,
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE panel, not two halves of one — the order a filter rail draws its facet
|
|
3
|
+
* groups and its numeric axes in.
|
|
4
|
+
*
|
|
5
|
+
* ── The defect this closes ────────────────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* The rail drew three blocks in a fixed sequence: the CORE ranges (price),
|
|
8
|
+
* then every facet group, then every attribute range. That sequence is a
|
|
9
|
+
* client's opinion about a catalogue it has never read, and on a live cars
|
|
10
|
+
* leaf it put "Year" — a measurement the category authors second,
|
|
11
|
+
* right after the make — below forty checkbox groups, while "Price" sat above
|
|
12
|
+
* a make picker the schema puts first. A host asking for
|
|
13
|
+
* `partition → make → price → year` could pin the two GROUPS and had no way
|
|
14
|
+
* to say where the two RANGES went, because the two halves were ordered by
|
|
15
|
+
* different code and never compared.
|
|
16
|
+
*
|
|
17
|
+
* stapel-search 0.16.0 ends the argument by numbering both halves in ONE
|
|
18
|
+
* sequence: `facet_labels[<slug>].order` and `facet_meta.ranges[<slug>].order`
|
|
19
|
+
* are the same integer scale, assigned by the plan — core ranges first (they
|
|
20
|
+
* address a column every document in every corpus has), then the category's
|
|
21
|
+
* own schema order, mandatory first. Sorting both halves by that one key is
|
|
22
|
+
* all this module does.
|
|
23
|
+
*
|
|
24
|
+
* ── What happens when nobody numbered anything ────────────────────────────
|
|
25
|
+
*
|
|
26
|
+
* An older server states no `order` at all, and a row built from the CATEGORY
|
|
27
|
+
* SCHEMA alone never has one however new the server is. Those items keep the
|
|
28
|
+
* band order this pair has always used — core ranges, then the groups in the
|
|
29
|
+
* order the caller handed them (which is `orderFacetGroupsBySchema`'s), then
|
|
30
|
+
* the remaining measurements — and sit AFTER everything the plan did number,
|
|
31
|
+
* because a stated position is evidence and an assumed one is not.
|
|
32
|
+
*
|
|
33
|
+
* `pinned` outranks both. It is the host saying "this page is about make",
|
|
34
|
+
* and a page that has decided what it is about is not overruled by the
|
|
35
|
+
* category's own idea of a reading order.
|
|
36
|
+
*/
|
|
37
|
+
import type { FacetGroup } from "./facets.js";
|
|
38
|
+
import type { RangeGroup } from "./ranges.js";
|
|
39
|
+
|
|
40
|
+
/** One row of the panel: a bucket list, or a from/to picker. */
|
|
41
|
+
export type PanelItem =
|
|
42
|
+
| { readonly kind: "group"; readonly slug: string; readonly group: FacetGroup }
|
|
43
|
+
| { readonly kind: "range"; readonly slug: string; readonly range: RangeGroup };
|
|
44
|
+
|
|
45
|
+
export interface OrderPanelItemsInput {
|
|
46
|
+
/**
|
|
47
|
+
* The facet groups, already filtered to what is drawable and ordered the
|
|
48
|
+
* way the rail wants them — `orderFacetGroupsBySchema`'s output. Their
|
|
49
|
+
* relative order is preserved for every group the plan did not number.
|
|
50
|
+
*/
|
|
51
|
+
readonly groups: readonly FacetGroup[];
|
|
52
|
+
/** The range rows, `buildRangeGroups`' output. */
|
|
53
|
+
readonly ranges: readonly RangeGroup[];
|
|
54
|
+
/**
|
|
55
|
+
* Slugs pinned above everything, in the order given — the axis a page has
|
|
56
|
+
* already decided is its subject. A slug matches whichever half carries it,
|
|
57
|
+
* so `["make", "price", "year"]` is one sequence over both.
|
|
58
|
+
*/
|
|
59
|
+
readonly pinned?: readonly string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The panel's rows, in the one order they are read in.
|
|
64
|
+
*
|
|
65
|
+
* Three bands, and every one of them is stable — equal-ranked items keep the
|
|
66
|
+
* order they arrived in, so nothing reshuffles under a click:
|
|
67
|
+
*
|
|
68
|
+
* 1. `pinned`, in the order given.
|
|
69
|
+
* 2. everything the ANSWER numbered (`order`), ascending. Both halves share
|
|
70
|
+
* the scale, so a group and a range can interleave inside it.
|
|
71
|
+
* 3. everything nobody numbered: core ranges, then groups, then attribute
|
|
72
|
+
* ranges — the band order the rail shipped with, kept for exactly the case
|
|
73
|
+
* it was invented for (a server or a row with no stated position).
|
|
74
|
+
*/
|
|
75
|
+
export function orderPanelItems(
|
|
76
|
+
input: OrderPanelItemsInput
|
|
77
|
+
): readonly PanelItem[] {
|
|
78
|
+
const items: PanelItem[] = [
|
|
79
|
+
...input.groups.map(
|
|
80
|
+
(group): PanelItem => ({ kind: "group", slug: group.slug, group })
|
|
81
|
+
),
|
|
82
|
+
...input.ranges.map(
|
|
83
|
+
(range): PanelItem => ({ kind: "range", slug: range.slug, range })
|
|
84
|
+
),
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const pinnedIndex = new Map<string, number>();
|
|
88
|
+
(input.pinned ?? []).forEach((slug, index) => {
|
|
89
|
+
if (!pinnedIndex.has(slug)) pinnedIndex.set(slug, index);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const stated = (item: PanelItem): number | undefined =>
|
|
93
|
+
item.kind === "range" ? item.range.order : orderOf(item.group);
|
|
94
|
+
|
|
95
|
+
// The fallback band's own three tiers. A core axis first for the reason it
|
|
96
|
+
// has always been first: on a phones leaf every attribute range is parcel
|
|
97
|
+
// logistics and the one number a buyer narrows by is the price.
|
|
98
|
+
const fallbackTier = (item: PanelItem): number => {
|
|
99
|
+
if (item.kind === "range") return item.range.core ? 0 : 2;
|
|
100
|
+
return 1;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const decorated = items.map((item, index) => ({ item, index }));
|
|
104
|
+
decorated.sort((a, b) => {
|
|
105
|
+
const pinA = pinnedIndex.get(a.item.slug);
|
|
106
|
+
const pinB = pinnedIndex.get(b.item.slug);
|
|
107
|
+
if (pinA !== undefined || pinB !== undefined) {
|
|
108
|
+
if (pinA === undefined) return 1;
|
|
109
|
+
if (pinB === undefined) return -1;
|
|
110
|
+
if (pinA !== pinB) return pinA - pinB;
|
|
111
|
+
return a.index - b.index;
|
|
112
|
+
}
|
|
113
|
+
const orderA = stated(a.item);
|
|
114
|
+
const orderB = stated(b.item);
|
|
115
|
+
if (orderA !== undefined || orderB !== undefined) {
|
|
116
|
+
// A stated position beats an assumed one, so the unnumbered tail follows
|
|
117
|
+
// the plan rather than being interleaved into it by a guess.
|
|
118
|
+
if (orderA === undefined) return 1;
|
|
119
|
+
if (orderB === undefined) return -1;
|
|
120
|
+
if (orderA !== orderB) return orderA - orderB;
|
|
121
|
+
return a.index - b.index;
|
|
122
|
+
}
|
|
123
|
+
const tier = fallbackTier(a.item) - fallbackTier(b.item);
|
|
124
|
+
if (tier !== 0) return tier;
|
|
125
|
+
return a.index - b.index;
|
|
126
|
+
});
|
|
127
|
+
return decorated.map((entry) => entry.item);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A group's stated position, or `undefined`.
|
|
132
|
+
*
|
|
133
|
+
* `null` is the server's own "the plan has no place for this group" and reads
|
|
134
|
+
* exactly like an absent field — both mean "sort me with the rest".
|
|
135
|
+
*/
|
|
136
|
+
function orderOf(group: FacetGroup): number | undefined {
|
|
137
|
+
return typeof group.order === "number" ? group.order : undefined;
|
|
138
|
+
}
|
package/src/state/ranges.ts
CHANGED
|
@@ -12,14 +12,19 @@
|
|
|
12
12
|
* values; a range is not enumerable and no bucket is ever sent for one. So
|
|
13
13
|
* the rows come from three places:
|
|
14
14
|
*
|
|
15
|
-
* - the ANSWER's `facet_meta.ranges` (stapel-search 0.14.7
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
15
|
+
* - the ANSWER's `facet_meta.ranges` (stapel-search 0.14.7+, a whole axis
|
|
16
|
+
* rather than two numbers since 0.16.0) — `{slug: {min, max, label,
|
|
17
|
+
* label_translatable, unit?, order}}` for every axis this page has NUMBERS
|
|
18
|
+
* behind, measured with the range filters removed. When the answer reports
|
|
19
|
+
* it, it is the authority on which attribute axes exist, where their ends
|
|
20
|
+
* are, WHAT THEY ARE CALLED and where in the panel they sit: the schema
|
|
21
|
+
* says an axis COULD be numeric, and this says it IS, on these documents,
|
|
22
|
+
* under this name. It reaches axes the schema's own type cannot — a
|
|
23
|
+
* vocabulary-backed `year`, a `floor`, a `doors` are CHOICES in the
|
|
24
|
+
* catalogue and from/to's to a buyer, and both are served.
|
|
25
|
+
* - the ANSWER's `facet_meta.withheld` (0.16.0), which is the same authority
|
|
26
|
+
* used in the negative: an axis it names as a withheld `range` is one this
|
|
27
|
+
* answer planned and declined to offer, and no schema row resurrects it.
|
|
23
28
|
* - the CATEGORY SCHEMA — the same `categoryFeatures` slot that gives the
|
|
24
29
|
* checkboxes their labels — filtered to the numeric value types. It is the
|
|
25
30
|
* FALLBACK, and the only source against a server that reports no `ranges`
|
|
@@ -34,6 +39,15 @@
|
|
|
34
39
|
*
|
|
35
40
|
* Plus every slug the URL already carries a range for.
|
|
36
41
|
*
|
|
42
|
+
* ── One axis, one control ─────────────────────────────────────────────────
|
|
43
|
+
*
|
|
44
|
+
* A slug the answer COUNTED already has a bucket list on the rail, and a
|
|
45
|
+
* from/to picker over the same field is a second control writing the same
|
|
46
|
+
* filter — ticking 2019 and typing 2015..2020 reach the same URL by different
|
|
47
|
+
* routes and neither shows what the other did. The counted half wins because
|
|
48
|
+
* it is the one with evidence in it (a number per value, not two ends). See
|
|
49
|
+
* {@link BuildRangeGroupsInput.countedFacets} and its `bothAxes` opt-out.
|
|
50
|
+
*
|
|
37
51
|
* That last clause is the same rule `buildFacetGroups` follows for a filtered
|
|
38
52
|
* slug that fell out of the plan: a constraint that is ACTIVE must always have
|
|
39
53
|
* a control that removes it, even when the schema no longer explains it.
|
|
@@ -49,9 +63,11 @@ import { featureConfig, featureName, featureType } from "@stapel/attributes-reac
|
|
|
49
63
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
50
64
|
import type {
|
|
51
65
|
FacetRangesMap,
|
|
66
|
+
FacetWithheldAxis,
|
|
52
67
|
SearchQueryState,
|
|
53
68
|
SearchRange,
|
|
54
69
|
} from "../api/types.js";
|
|
70
|
+
import { withheldSlugs } from "../api/types.js";
|
|
55
71
|
|
|
56
72
|
/**
|
|
57
73
|
* Value types a numeric range row is drawn for (`config.type`, the
|
|
@@ -66,7 +82,13 @@ export const RANGE_FEATURE_TYPES: readonly string[] = [
|
|
|
66
82
|
/** One `r.<slug>` row of the filter panel. */
|
|
67
83
|
export interface RangeGroup {
|
|
68
84
|
readonly slug: string;
|
|
69
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* What to write above the picker: the ANSWER's own caption first (0.16.0
|
|
87
|
+
* resolves it from the same definition a group heading comes from), the
|
|
88
|
+
* schema's display name next, this package's key for a core axis, and — only
|
|
89
|
+
* for a slug the URL constrains that none of them explain — the slug itself.
|
|
90
|
+
* {@link named} says which of those two worlds the row is in.
|
|
91
|
+
*/
|
|
70
92
|
readonly label: string;
|
|
71
93
|
/** The schema entry behind the slug, when the host supplied one. */
|
|
72
94
|
readonly feature: FeatureDef | undefined;
|
|
@@ -87,7 +109,10 @@ export interface RangeGroup {
|
|
|
87
109
|
* has. Never set on a core axis — see {@link BuildRangeGroupsInput.ranges}.
|
|
88
110
|
*/
|
|
89
111
|
readonly measured: boolean;
|
|
90
|
-
/**
|
|
112
|
+
/** What the numbers are measured in: the ANSWER's `unit` (the definition's
|
|
113
|
+
* `postfix`, or the BASE unit of a convertible family) when it states one,
|
|
114
|
+
* the schema's own affixes otherwise. Absent, never `""`, on an axis nobody
|
|
115
|
+
* gave a unit — a price among them. */
|
|
91
116
|
readonly unit: string | undefined;
|
|
92
117
|
/** `1` for an integer feature — a whole-number input for a whole number. */
|
|
93
118
|
readonly step: number | undefined;
|
|
@@ -121,6 +146,31 @@ export interface RangeGroup {
|
|
|
121
146
|
* carries a `postfix`, not a currency.
|
|
122
147
|
*/
|
|
123
148
|
readonly currency: string | undefined;
|
|
149
|
+
/**
|
|
150
|
+
* Where this axis sits in the ONE sequence the panel draws — the answer's
|
|
151
|
+
* `facet_meta.ranges[<slug>].order`, numbered together with the groups'
|
|
152
|
+
* `facet_labels[<slug>].order` (stapel-search 0.16.0+).
|
|
153
|
+
*
|
|
154
|
+
* `undefined` means the answer stated no position (an older server, an axis
|
|
155
|
+
* the plan has no place for, or a row that came from the schema alone), and
|
|
156
|
+
* a panel then falls back to the band order it has always used: core axes
|
|
157
|
+
* first, then the groups, then the rest of the measurements. See
|
|
158
|
+
* `orderPanelItems`.
|
|
159
|
+
*/
|
|
160
|
+
readonly order: number | undefined;
|
|
161
|
+
/**
|
|
162
|
+
* `true` when {@link label} is a name SOMEBODY GAVE the axis — the answer's
|
|
163
|
+
* own caption, the schema's `FeatureDef.name`, or this package's key for a
|
|
164
|
+
* core axis — and `false` when it is the storage slug standing in for a name
|
|
165
|
+
* nobody has.
|
|
166
|
+
*
|
|
167
|
+
* A row is only ever unlabelled when the URL already constrains the slug: a
|
|
168
|
+
* constraint must keep the control that removes it, even one captioned
|
|
169
|
+
* `power_w`. Every other unnamed axis is dropped before it reaches a
|
|
170
|
+
* surface, which is the client half of the rule stapel-search 0.16.0 states
|
|
171
|
+
* on the wire by WITHHOLDING an axis it could not caption.
|
|
172
|
+
*/
|
|
173
|
+
readonly named: boolean;
|
|
124
174
|
}
|
|
125
175
|
|
|
126
176
|
/**
|
|
@@ -175,6 +225,57 @@ export interface BuildRangeGroupsInput {
|
|
|
175
225
|
* that wants to draw a histogram over it.
|
|
176
226
|
*/
|
|
177
227
|
readonly ranges?: FacetRangesMap;
|
|
228
|
+
/**
|
|
229
|
+
* `facet_meta.withheld` — the axes the server planned and then did NOT
|
|
230
|
+
* offer (stapel-search 0.16.0+).
|
|
231
|
+
*
|
|
232
|
+
* Only the rows whose `axis` is `"range"` are read here, and reading them is
|
|
233
|
+
* not optional: an axis withheld for `coverage` describes three of fifty-two
|
|
234
|
+
* documents and an axis withheld as `unlabelled` has no caption anyone could
|
|
235
|
+
* print, and the server leaving it out of `ranges` is only half the
|
|
236
|
+
* statement — the CATEGORY SCHEMA still names the same slug, so a rail that
|
|
237
|
+
* ignored this list would draw from the schema exactly the row the answer
|
|
238
|
+
* just declined to offer.
|
|
239
|
+
*
|
|
240
|
+
* The one exemption is a slug the URL constrains. The server promises never
|
|
241
|
+
* to withhold one (that would leave a filter applied with no control to undo
|
|
242
|
+
* it) and this module keeps the row anyway if it ever does, for the same
|
|
243
|
+
* reason it keeps a row the schema cannot explain.
|
|
244
|
+
*/
|
|
245
|
+
readonly withheld?: readonly FacetWithheldAxis[];
|
|
246
|
+
/**
|
|
247
|
+
* The slugs this answer COUNTED (`FacetPanelBag.counted`) — the axes that
|
|
248
|
+
* already have a bucket list on the rail.
|
|
249
|
+
*
|
|
250
|
+
* ONE AXIS, ONE CONTROL. A slug can be a choice and a measurement at once —
|
|
251
|
+
* an imported `year` is typed as a vocabulary in the catalogue and measured
|
|
252
|
+
* by the plan — and until now the panel drew BOTH: a checkbox group of
|
|
253
|
+
* years and, further down the same rail, a from/to picker over the same
|
|
254
|
+
* field. Measured on a live storefront, which worked around it by removing
|
|
255
|
+
* the range row itself from the rendered list. Two controls over one filter
|
|
256
|
+
* is not a choice a reader can make sense of: ticking 2019 and typing
|
|
257
|
+
* 2015..2020 write the same URL by two different routes, and neither
|
|
258
|
+
* control shows what the other did.
|
|
259
|
+
*
|
|
260
|
+
* The GROUP wins, because it is the half backed by evidence: it carries a
|
|
261
|
+
* count per value, so a reader sees what is actually there, where a picker
|
|
262
|
+
* only offers ends. A host that genuinely wants both says so with
|
|
263
|
+
* {@link bothAxes}.
|
|
264
|
+
*
|
|
265
|
+
* A CONSTRAINT still keeps its control: a slug the URL carries a range for
|
|
266
|
+
* is never dropped, whatever else is on the rail — the same rule the
|
|
267
|
+
* withheld list is exempt from.
|
|
268
|
+
*/
|
|
269
|
+
readonly countedFacets?: readonly string[];
|
|
270
|
+
/**
|
|
271
|
+
* Draw a range row for a slug that already has a counted facet group
|
|
272
|
+
* ({@link countedFacets}) instead of leaving it to the group.
|
|
273
|
+
*
|
|
274
|
+
* The opt-out, for a surface where the two really are different questions —
|
|
275
|
+
* a year picker beside a decade grouping, say. Off by default: the shipped
|
|
276
|
+
* defect was the panel doing this without being asked.
|
|
277
|
+
*/
|
|
278
|
+
readonly bothAxes?: boolean;
|
|
178
279
|
/** ISO 4217 code for the money axes, when the surface knows one. */
|
|
179
280
|
readonly currency?: string;
|
|
180
281
|
/** Translator for label keys (the schema's `name` is often one). */
|
|
@@ -279,6 +380,13 @@ export function buildRangeGroups(
|
|
|
279
380
|
const core = new Set(input.coreRanges ?? []);
|
|
280
381
|
const bounds = input.ranges;
|
|
281
382
|
const reported = new Set(Object.keys(bounds ?? {}));
|
|
383
|
+
// The axes this answer planned and declined to offer — sparse, or with no
|
|
384
|
+
// caption anyone could print. Dropped unless the URL constrains them.
|
|
385
|
+
const withheld = new Set(withheldSlugs(input.withheld, "range"));
|
|
386
|
+
// The axes that already have a bucket list. See `countedFacets`: one axis
|
|
387
|
+
// gets one control, and the counted half is the one with evidence in it.
|
|
388
|
+
const counted =
|
|
389
|
+
input.bothAxes === true ? new Set<string>() : new Set(input.countedFacets ?? []);
|
|
282
390
|
const slugs: string[] = [...core];
|
|
283
391
|
for (const feature of input.categoryFeatures ?? []) {
|
|
284
392
|
// A core slug shadows a same-named attribute — which is exactly what the
|
|
@@ -300,43 +408,97 @@ export function buildRangeGroups(
|
|
|
300
408
|
if (!slugs.includes(slug)) slugs.push(slug);
|
|
301
409
|
}
|
|
302
410
|
|
|
303
|
-
return slugs
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
to
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
411
|
+
return slugs
|
|
412
|
+
.map((slug): RangeGroup => {
|
|
413
|
+
const isCore = core.has(slug);
|
|
414
|
+
const feature = isCore ? undefined : bySlug.get(slug);
|
|
415
|
+
const config = feature === undefined ? {} : featureConfig(feature);
|
|
416
|
+
const applied: SearchRange | undefined = input.state.ranges[slug];
|
|
417
|
+
// The ANSWER's row for the slug. Read for its caption on every axis
|
|
418
|
+
// including a core one — the server names `price` out of its own
|
|
419
|
+
// `CORE_RANGE_LABELS` — and for its BOUNDS on an attribute axis only:
|
|
420
|
+
// see `BuildRangeGroupsInput.ranges` for why the price input keeps its
|
|
421
|
+
// own ends.
|
|
422
|
+
const axis = bounds?.[slug];
|
|
423
|
+
const measured = !isCore && axis !== undefined;
|
|
424
|
+
const min = measured ? axis.min : num(config["min"]);
|
|
425
|
+
const max = measured ? axis.max : num(config["max"]);
|
|
426
|
+
// The caption, in the order of who is entitled to give one:
|
|
427
|
+
// 1. the ANSWER. `label` is resolved server-side from the same
|
|
428
|
+
// definition a group heading comes from, it is present for a core
|
|
429
|
+
// axis the client has no definition for, and it is the only source
|
|
430
|
+
// a host that threaded no schema has at all.
|
|
431
|
+
// 2. the SCHEMA's own `name`, for a server too old to send one.
|
|
432
|
+
// 3. this package's key for a core axis, same reason as (2).
|
|
433
|
+
// and never the slug, which is storage. An axis none of the three name
|
|
434
|
+
// is dropped below.
|
|
435
|
+
const answered = str(axis?.label);
|
|
436
|
+
const named =
|
|
437
|
+
answered !== undefined || isCore || feature !== undefined;
|
|
438
|
+
const label =
|
|
439
|
+
answered !== undefined
|
|
440
|
+
? // `label_translatable: false` is literal text the catalogue wrote
|
|
441
|
+
// and must not be looked up; absent means the server did not say,
|
|
442
|
+
// and a lookup that misses returns the string unchanged.
|
|
443
|
+
axis?.label_translatable === false
|
|
444
|
+
? answered
|
|
445
|
+
: translate(input.t, answered)
|
|
446
|
+
: isCore
|
|
447
|
+
? translate(input.t, coreRangeLabelKey(slug))
|
|
448
|
+
: feature === undefined
|
|
449
|
+
? slug
|
|
450
|
+
: translate(input.t, featureName(feature));
|
|
451
|
+
return {
|
|
452
|
+
slug,
|
|
453
|
+
label,
|
|
454
|
+
named,
|
|
455
|
+
feature,
|
|
456
|
+
from: applied?.from,
|
|
457
|
+
to: applied?.to,
|
|
458
|
+
min,
|
|
459
|
+
max,
|
|
460
|
+
measured,
|
|
461
|
+
// The unit, same precedence and same reason: the answer resolved it
|
|
462
|
+
// off the definition (a `convertible_unit`'s BASE unit, which the
|
|
463
|
+
// client cannot work out from `unit_m`/`unit_i` alone), the schema's
|
|
464
|
+
// own affixes second. Translated, because the server states plainly
|
|
465
|
+
// that it never translates one and the string may be a key.
|
|
466
|
+
//
|
|
467
|
+
// A core money axis carries a CURRENCY, not a unit suffix: "₽" is
|
|
468
|
+
// formatted from the code for the reader's locale, a unit suffix is a
|
|
469
|
+
// literal the category author typed.
|
|
470
|
+
unit: isCore
|
|
471
|
+
? undefined
|
|
472
|
+
: str(axis?.unit) !== undefined
|
|
473
|
+
? translate(input.t, str(axis?.unit) ?? "")
|
|
474
|
+
: (str(config["postfix"]) ?? str(config["unit_m"]) ?? str(config["unit_i"])),
|
|
475
|
+
step: !isCore && isIntegerAxis(feature, min, max, measured) ? 1 : undefined,
|
|
476
|
+
picker: isCore ? undefined : pickerValues(feature, min, max, measured),
|
|
477
|
+
active: applied !== undefined,
|
|
478
|
+
core: isCore,
|
|
479
|
+
currency: isCore ? str(input.currency) : undefined,
|
|
480
|
+
order: typeof axis?.order === "number" ? axis.order : undefined,
|
|
481
|
+
};
|
|
482
|
+
})
|
|
483
|
+
.filter((group) => {
|
|
484
|
+
// A CONSTRAINT ALWAYS KEEPS ITS CONTROL. Everything below removes a row
|
|
485
|
+
// the reader has not used; a row they have is never one of them.
|
|
486
|
+
if (group.active) return true;
|
|
487
|
+
// The server planned this axis and declined to offer it — sparse, or
|
|
488
|
+
// unnameable. The schema still declares the same slug, so without this
|
|
489
|
+
// the rail would draw exactly the row the answer withheld.
|
|
490
|
+
if (withheld.has(group.slug)) return false;
|
|
491
|
+
// The same axis is already a bucket list on this rail. A CORE column is
|
|
492
|
+
// exempt: it is not part of the category plan (the server reserves the
|
|
493
|
+
// slug), so a same-named group is a different question and the price
|
|
494
|
+
// input must not disappear because one arrived.
|
|
495
|
+
if (!group.core && counted.has(group.slug)) return false;
|
|
496
|
+
// Nobody named it. A from/to picker captioned `kilometrage` is a control
|
|
497
|
+
// whose meaning a reader has to guess out of the numbers inside it,
|
|
498
|
+
// which is not a filter — the same judgement stapel-search 0.16.0 makes
|
|
499
|
+
// on the wire, made again here for the rows that come from the schema.
|
|
500
|
+
return group.named;
|
|
501
|
+
});
|
|
340
502
|
}
|
|
341
503
|
|
|
342
504
|
/**
|