@stapel/search-react 0.29.0 → 0.30.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 +82 -0
- package/README.md +72 -0
- package/dist/api/generated/schema.d.ts +9 -1
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +46 -3
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +5 -0
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +63 -1
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +12 -2
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +2 -0
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/RangeFilterRow.d.ts +16 -0
- package/dist/default/RangeFilterRow.d.ts.map +1 -1
- package/dist/default/RangeFilterRow.js +40 -5
- package/dist/default/RangeFilterRow.js.map +1 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +4 -0
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +24 -1
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +19 -3
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +31 -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 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/state/degradations.d.ts +12 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +12 -0
- package/dist/state/degradations.js.map +1 -1
- package/dist/state/ranges.d.ts +41 -4
- package/dist/state/ranges.d.ts.map +1 -1
- package/dist/state/ranges.js +62 -11
- package/dist/state/ranges.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +7 -1
- package/nav-manifest.json +1 -1
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +9 -1
- package/src/api/types.ts +48 -3
- package/src/default/FacetGroupControl.tsx +64 -1
- package/src/default/FacetPanelPane.tsx +67 -15
- package/src/default/FilterChips.tsx +2 -0
- package/src/default/RangeFilterRow.tsx +61 -3
- package/src/default/SearchPage.tsx +4 -0
- package/src/headless/FacetPanel.tsx +53 -3
- package/src/headless/SearchStateProvider.tsx +86 -1
- package/src/index.ts +6 -0
- package/src/state/degradations.ts +13 -0
- package/src/state/ranges.ts +112 -14
|
@@ -229,6 +229,10 @@ export interface components {
|
|
|
229
229
|
values: {
|
|
230
230
|
[key: string]: string;
|
|
231
231
|
};
|
|
232
|
+
/** @description The vocabulary name this group's options come from, when the feature is `ref_select` (or any host type pointing at a vocabulary the same way). `null` for a group with an inline option set — a client that only sees this answer, with no leaf schema of its own (a branch page, a text query), still needs this to draw a vocabulary-backed axis as its picker rather than as a plain checkbox list. */
|
|
233
|
+
vocabulary: string | null;
|
|
234
|
+
/** @description The vocabulary level `vocabulary` resolves against. Present only alongside a non-null `vocabulary`. */
|
|
235
|
+
level?: string;
|
|
232
236
|
};
|
|
233
237
|
FacetMeta: {
|
|
234
238
|
/** @description True when counts came from a sample because the candidate set exceeded FACET_CANDIDATE_CAP. */
|
|
@@ -242,6 +246,10 @@ export interface components {
|
|
|
242
246
|
dropped_filters: string[];
|
|
243
247
|
/** @description Range slugs that address a core document column rather than an attribute (`r.price`). Offer them as filters unconditionally: they exist for every document in every category, which is why they are not in the category's own plan. */
|
|
244
248
|
core_ranges: string[];
|
|
249
|
+
/** @description `{slug: {min, max}}` — the low and high bound of every numeric axis that HAS a number on this page, core columns (`price`) and attributes (`year`, `mileage`, `engine_volume`) alike. What a from/to picker is drawn from: a range axis has no values to enumerate, so `facets` says nothing about it and a client without these two numbers either draws no picker or draws one over a guess. Measured with the range filters REMOVED, so the ends are the domain the picker can be widened back to and not the ends of its own selection. An axis absent here has no numbers behind it in this candidate set — which is a different fact from a bound of zero. Not capped by MAX_FACET_FIELDS: the budget governs counting, and every bound is one aggregate. Empty, with `facet_ranges` in `degraded[]`, on an engine that does not implement the optional `ranges` verb. */
|
|
250
|
+
ranges: {
|
|
251
|
+
[key: string]: unknown;
|
|
252
|
+
};
|
|
245
253
|
/** @description Where the plan came from. `category` — the queried category's own authored schema. `evidence` — the categories the CANDIDATE SET actually contains, used when that schema did not fill MAX_FACET_FIELDS, which is every branch category and every text query (`categories.features` resolves own + ANCESTOR-inherited features, so a branch owns no axes; its leaves do). */
|
|
246
254
|
plan: string;
|
|
247
255
|
/** @description `{slug, coverage, candidates}` for groups that were counted and then withheld because they describe too little of the result set (FACET_MIN_COVERAGE). Present so a panel can say «3 filters apply to too few of these» instead of «no filters» — the second is false whenever this list is not empty. Only slugs the evidence plan borrowed from another category are ever here, and never one the reader has already filtered on. */
|
|
@@ -372,7 +380,7 @@ export interface components {
|
|
|
372
380
|
[key: string]: number;
|
|
373
381
|
};
|
|
374
382
|
};
|
|
375
|
-
/** @description {slug: {label, label_translatable, url_key, translatable, values: {value: caption}}} — one entry for EVERY group in `facets`. `url_key` is the group's key in the address. `label` is the group's heading and is null when the definition has no name; `values` is empty for a slug whose options are not inline in the category schema and whose vocabulary resolved nothing, because this module will not invent a caption it has not read. */
|
|
383
|
+
/** @description {slug: {label, label_translatable, url_key, translatable, values: {value: caption}, vocabulary, level}} — one entry for EVERY group in `facets`. `url_key` is the group's key in the address. `label` is the group's heading and is null when the definition has no name; `values` is empty for a slug whose options are not inline in the category schema and whose vocabulary resolved nothing, because this module will not invent a caption it has not read. `vocabulary` names the vocabulary a `ref_select` axis reads its options from and is null for an inline `select` — the only way a client with no leaf schema of its own can tell the two apart. */
|
|
376
384
|
facet_labels: {
|
|
377
385
|
[key: string]: components["schemas"]["FacetLabels"];
|
|
378
386
|
};
|
package/src/api/types.ts
CHANGED
|
@@ -33,7 +33,7 @@ export type Schemas = components["schemas"];
|
|
|
33
33
|
*/
|
|
34
34
|
export type FacetLabels = Omit<
|
|
35
35
|
Schemas["FacetLabels"],
|
|
36
|
-
"label" | "label_translatable" | "url_key"
|
|
36
|
+
"label" | "label_translatable" | "url_key" | "vocabulary"
|
|
37
37
|
> & {
|
|
38
38
|
readonly label?: string | null;
|
|
39
39
|
/**
|
|
@@ -79,6 +79,14 @@ export type FacetLabels = Omit<
|
|
|
79
79
|
* category schema's own `optionsRef`, which a host that threaded no schema
|
|
80
80
|
* does not have. Absent on every server that does not state it — absence
|
|
81
81
|
* is not "inline", it is "unsaid", and the schema is asked next.
|
|
82
|
+
*
|
|
83
|
+
* WHY IT IS NOT THE GENERATED SHAPE: stapel-search 0.14.9 added it and
|
|
84
|
+
* declares it REQUIRED, and the announced contract is `>=0.14 <0.15` — a
|
|
85
|
+
* 0.14.0 server inside that range sends no `vocabulary` at all. Same
|
|
86
|
+
* treatment as `url_key` above: `Omit`ted from the generated member and
|
|
87
|
+
* re-declared optional here, because a type must not promise a field a
|
|
88
|
+
* server the pair says it supports does not send. `level` is generated
|
|
89
|
+
* optional already and is inherited unchanged.
|
|
82
90
|
*/
|
|
83
91
|
readonly vocabulary?: string | null;
|
|
84
92
|
};
|
|
@@ -125,6 +133,32 @@ export interface FacetCategoryCount {
|
|
|
125
133
|
readonly count: number;
|
|
126
134
|
}
|
|
127
135
|
|
|
136
|
+
/**
|
|
137
|
+
* The two ENDS of one numeric axis, measured over this answer's candidate set
|
|
138
|
+
* with the range filters removed (stapel-search 0.14.7+).
|
|
139
|
+
*
|
|
140
|
+
* Numbers, not strings: a slider end is arithmetic a client does immediately,
|
|
141
|
+
* and a price re-parsed from a formatted string is a price that has already
|
|
142
|
+
* been rounded once.
|
|
143
|
+
*/
|
|
144
|
+
export interface FacetRangeBounds {
|
|
145
|
+
readonly min: number;
|
|
146
|
+
readonly max: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* `facet_meta.ranges` — `{slug: {min, max}}` for every axis this answer has
|
|
151
|
+
* numbers behind, core columns and attribute axes in ONE report because one
|
|
152
|
+
* rail draws both.
|
|
153
|
+
*
|
|
154
|
+
* An axis ABSENT from the map has no numbers behind it on this page, which is
|
|
155
|
+
* a different fact from a bound of zero. The map itself absent is a different
|
|
156
|
+
* fact again: the server predates 0.14.7, or its engine has no `ranges` verb
|
|
157
|
+
* and said so as `facet_ranges` in `degraded[]`. The panel tells the
|
|
158
|
+
* three apart — see `state/ranges.ts`.
|
|
159
|
+
*/
|
|
160
|
+
export type FacetRangesMap = Readonly<Record<string, FacetRangeBounds>>;
|
|
161
|
+
|
|
128
162
|
/**
|
|
129
163
|
* The honesty block beside the counts: `approximate`, `candidates`,
|
|
130
164
|
* `counted`, `skipped`, and (stapel-search 0.12.0+) where the facet plan came
|
|
@@ -134,14 +168,25 @@ export interface FacetCategoryCount {
|
|
|
134
168
|
* `categories` as bare `object` arrays, so the generated members are
|
|
135
169
|
* `{[key: string]: unknown}[]` — the two fields a panel has to read
|
|
136
170
|
* field-by-field are the two it cannot. Both are corrected here to the
|
|
137
|
-
* documented row shapes
|
|
171
|
+
* documented row shapes.
|
|
172
|
+
*
|
|
173
|
+
* `ranges` (stapel-search 0.14.7) is now GENERATED, and is corrected here for
|
|
174
|
+
* both of the reasons the two fields above are. The generator lost the row
|
|
175
|
+
* shape — `additionalProperties: {}` becomes `{[key: string]: unknown}`, so
|
|
176
|
+
* the map a slider reads two numbers out of arrives with no numbers in the
|
|
177
|
+
* type — and drf-spectacular declares it required while the announced
|
|
178
|
+
* contract is `>=0.14 <0.15`, inside which a 0.14.0..0.14.6 server measures
|
|
179
|
+
* no bounds at all. Optional is the deployment truth: absent means "this
|
|
180
|
+
* server does not measure bounds", and a required field would compile while
|
|
181
|
+
* reading `undefined` from a key the compiler swore was there.
|
|
138
182
|
*/
|
|
139
183
|
export type FacetMeta = Omit<
|
|
140
184
|
Schemas["FacetMeta"],
|
|
141
|
-
"withheld" | "categories"
|
|
185
|
+
"withheld" | "categories" | "ranges"
|
|
142
186
|
> & {
|
|
143
187
|
readonly withheld: readonly FacetWithheldGroup[];
|
|
144
188
|
readonly categories: readonly FacetCategoryCount[];
|
|
189
|
+
readonly ranges?: FacetRangesMap;
|
|
145
190
|
};
|
|
146
191
|
|
|
147
192
|
/** `GET /suggest` 200, as the CURRENT generated schema describes it. */
|
|
@@ -145,6 +145,63 @@ function singleChoice(feature: FeatureDef | undefined): boolean {
|
|
|
145
145
|
return numberish(featureConfig(feature)["maxSelected"]) === 1;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Slugs a marketplace's own mapping conventionally normalizes an EITHER/OR
|
|
150
|
+
* axis to, whatever language the printed labels end up in — a scraped
|
|
151
|
+
* catalogue's own "condition" column, however it was captioned on the source
|
|
152
|
+
* site, becomes one `condition` slug upstream of this component, so the slug
|
|
153
|
+
* is the one part of a schemaless group that survives translation. `is_*`/
|
|
154
|
+
* `has_*` catches a bare boolean the same way.
|
|
155
|
+
*/
|
|
156
|
+
const EXCLUSIVE_AXIS_SLUGS: ReadonlySet<string> = new Set([
|
|
157
|
+
"condition",
|
|
158
|
+
"item_condition",
|
|
159
|
+
"product_condition",
|
|
160
|
+
"state",
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
function looksLikeExclusiveAxisSlug(slug: string): boolean {
|
|
164
|
+
const normalized = slug.toLowerCase();
|
|
165
|
+
if (/^(is|has)_/.test(normalized)) return true;
|
|
166
|
+
return EXCLUSIVE_AXIS_SLUGS.has(normalized);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Does a SCHEMALESS group's evidence look like a closed EITHER/OR rather
|
|
171
|
+
* than an open list — "pick one of these two or three" rather than "tick any
|
|
172
|
+
* of these"?
|
|
173
|
+
*
|
|
174
|
+
* There is no authoritative answer to read: `facet_meta` reports `skipped`,
|
|
175
|
+
* `withheld`, `ranges`, `plan`, `categories` and nothing that marks an axis
|
|
176
|
+
* single-valued, so a group with no `feature` (no schema, no `maxSelected`)
|
|
177
|
+
* has no `single` hint to defer to today. Once the plan sends one, THIS
|
|
178
|
+
* FUNCTION IS THE PLACE TO PREFER IT over the guess below.
|
|
179
|
+
*
|
|
180
|
+
* Until then: 2–3 counted buckets under a slug that reads as a condition or
|
|
181
|
+
* a boolean ({@link looksLikeExclusiveAxisSlug}) draw as segmented pills, on
|
|
182
|
+
* the same reasoning `singleChoice` already applies to a typed def — a
|
|
183
|
+
* two-way "new/used" read as tick-any-of-these is the wrong control before
|
|
184
|
+
* the first click. Every other schemaless small group (`color`, `size`)
|
|
185
|
+
* stays checkboxes: nothing here says a person can only want one, and
|
|
186
|
+
* assuming so for every short option list would turn `color` into a radio
|
|
187
|
+
* button the moment nobody threaded its schema through.
|
|
188
|
+
*
|
|
189
|
+
* Stated honestly, this is a GUESS keyed on the slug alone — it will miss an
|
|
190
|
+
* axis mapped under a slug not in {@link EXCLUSIVE_AXIS_SLUGS} and it will
|
|
191
|
+
* fire wrongly if some catalogue really does mean "condition" as a
|
|
192
|
+
* multi-select. Both failures draw checkboxes for a true either/or or pills
|
|
193
|
+
* for a true multi-select respectively — a shape mismatch, not a filter that
|
|
194
|
+
* stops working, and one a real `facet_meta` hint replaces outright.
|
|
195
|
+
*/
|
|
196
|
+
function looksSingleChoiceByEvidence(group: FacetGroup): boolean {
|
|
197
|
+
if (group.feature !== undefined) return false;
|
|
198
|
+
if (!looksLikeExclusiveAxisSlug(group.slug)) return false;
|
|
199
|
+
const buckets = group.options.filter(
|
|
200
|
+
(option) => option.count !== null && option.count > 0
|
|
201
|
+
).length;
|
|
202
|
+
return buckets >= 2 && buckets <= 3;
|
|
203
|
+
}
|
|
204
|
+
|
|
148
205
|
/**
|
|
149
206
|
* Is this group a DICTIONARY — an axis whose values live in a vocabulary?
|
|
150
207
|
*
|
|
@@ -201,6 +258,11 @@ export function isDictionaryFacet(group: FacetGroup): boolean {
|
|
|
201
258
|
* - and a dictionary is a dictionary before it is a checkbox list, because
|
|
202
259
|
* the checkbox list is the shape it was drawn as when nobody could pick a
|
|
203
260
|
* make.
|
|
261
|
+
*
|
|
262
|
+
* A group with NO schema falls to {@link looksSingleChoiceByEvidence} for the
|
|
263
|
+
* segmented/checkbox call, since `singleChoice` has no `feature` to read
|
|
264
|
+
* `maxSelected` off of — see that function for what it checks and why it is
|
|
265
|
+
* a documented guess, not a fact read off the wire.
|
|
204
266
|
*/
|
|
205
267
|
export function facetGroupShape(group: FacetGroup): FacetGroupShape {
|
|
206
268
|
const feature = group.feature;
|
|
@@ -208,7 +270,8 @@ export function facetGroupShape(group: FacetGroup): FacetGroupShape {
|
|
|
208
270
|
return "nested";
|
|
209
271
|
}
|
|
210
272
|
if (isDictionaryFacet(group)) return "dictionary";
|
|
211
|
-
|
|
273
|
+
if (singleChoice(feature) || looksSingleChoiceByEvidence(group)) return "segmented";
|
|
274
|
+
return "checkbox";
|
|
212
275
|
}
|
|
213
276
|
|
|
214
277
|
/**
|
|
@@ -105,7 +105,7 @@ import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
|
105
105
|
import { buildRangeGroups } from "../state/ranges.js";
|
|
106
106
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
107
107
|
import { LanguageSelect } from "./LanguageSelect.js";
|
|
108
|
-
import { RangeFilterRow } from "./RangeFilterRow.js";
|
|
108
|
+
import { RANGE_ROW_MIN_HEIGHT, RangeFilterRow, RangeRowSkeleton } from "./RangeFilterRow.js";
|
|
109
109
|
import type { ThemeModeProp } from "./types.js";
|
|
110
110
|
|
|
111
111
|
/**
|
|
@@ -579,6 +579,10 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
579
579
|
? { categoryFeatures: props.categoryFeatures }
|
|
580
580
|
: {}),
|
|
581
581
|
coreRanges: bag.coreRanges,
|
|
582
|
+
// The measured ends, and the axes the schema types as choices —
|
|
583
|
+
// a vocabulary-backed year is a from/to here because the answer
|
|
584
|
+
// says it has numbers behind it (stapel-search 0.14.7).
|
|
585
|
+
...(bag.ranges !== undefined ? { ranges: bag.ranges } : {}),
|
|
582
586
|
...(bag.currency !== undefined ? { currency: bag.currency } : {}),
|
|
583
587
|
t,
|
|
584
588
|
});
|
|
@@ -589,6 +593,11 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
589
593
|
// classified catalogue means parcel weight and wholesale packing.
|
|
590
594
|
const coreRanges = ranges.filter((group) => group.core);
|
|
591
595
|
const attributeRanges = ranges.filter((group) => !group.core);
|
|
596
|
+
// How many rows the block reserves while the answer is in flight:
|
|
597
|
+
// what this category was last MEASURED to have, else what the
|
|
598
|
+
// schema declares. See the reservation comment below.
|
|
599
|
+
const reservedAxes =
|
|
600
|
+
bag.reservedRangeAxes?.length ?? attributeRanges.length;
|
|
592
601
|
// Is there anything on this rail besides the facet groups? A price
|
|
593
602
|
// row, an applied location, or the partition slot all make "this
|
|
594
603
|
// search offers no filters" false even when the group list itself
|
|
@@ -876,23 +885,66 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
876
885
|
phone). It is a real filter for the person who wants it, so it
|
|
877
886
|
is not deleted; it is ranked where the chip row already ranks
|
|
878
887
|
it. */}
|
|
879
|
-
{
|
|
888
|
+
{/* The reservation, not just the rows (D361).
|
|
889
|
+
On a live category feed at 1536px this block's arrival was a
|
|
890
|
+
53px jump: `attributeRanges` draws from the CATEGORY SCHEMA
|
|
891
|
+
(`props.categoryFeatures`), and a host that fetches the
|
|
892
|
+
schema alongside the search answer had nothing here at all
|
|
893
|
+
until both landed — no host slot reserved the box, so the
|
|
894
|
+
rail grew under the reader's eye the instant it did.
|
|
895
|
+
|
|
896
|
+
Two things can be unknown at first paint, and each gets its
|
|
897
|
+
own reservation:
|
|
898
|
+
- the SCHEMA itself (`categoryFeatures` undefined) — the
|
|
899
|
+
axis count is unknown, so the fallback is one row's floor,
|
|
900
|
+
a guess rather than nothing;
|
|
901
|
+
- the ANSWER (`bag.state` not yet "ready") with a known
|
|
902
|
+
schema — the axis COUNT is already certain from the
|
|
903
|
+
schema, so the rail draws that many skeleton rows, each
|
|
904
|
+
`RANGE_ROW_MIN_HEIGHT` tall like the real one it will
|
|
905
|
+
become. Same count in both arms, so the swap from
|
|
906
|
+
skeleton to `<RangeFilterRow>` costs no further height.
|
|
907
|
+
|
|
908
|
+
And the schema is only the FIRST guess at that count. Since
|
|
909
|
+
stapel-search 0.14.7 the answer measures the axes that have
|
|
910
|
+
numbers behind them — including the ones the catalogue types
|
|
911
|
+
as choices, a vocabulary-backed year — so a leaf whose schema
|
|
912
|
+
declares two can answer with four. `bag.reservedRangeAxes` is
|
|
913
|
+
what an earlier answer FOR THIS CATEGORY reported, remembered
|
|
914
|
+
in the state provider; when there is one it sizes the block,
|
|
915
|
+
because it is the count the swap will actually land on. */}
|
|
916
|
+
{props.categoryFeatures === undefined ? (
|
|
880
917
|
<>
|
|
881
918
|
<Divider style={{ margin: 0 }} />
|
|
882
|
-
<
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
{attributeRanges.map((group) => (
|
|
888
|
-
<RangeFilterRow
|
|
889
|
-
key={group.slug}
|
|
890
|
-
group={group}
|
|
891
|
-
onApply={bag.setRange}
|
|
892
|
-
/>
|
|
893
|
-
))}
|
|
894
|
-
</Flex>
|
|
919
|
+
<div
|
|
920
|
+
aria-hidden="true"
|
|
921
|
+
data-testid="search-ranges-attributes-reserve"
|
|
922
|
+
style={{ minBlockSize: RANGE_ROW_MIN_HEIGHT }}
|
|
923
|
+
/>
|
|
895
924
|
</>
|
|
925
|
+
) : (
|
|
926
|
+
(attributeRanges.length > 0 || reservedAxes > 0) && (
|
|
927
|
+
<>
|
|
928
|
+
<Divider style={{ margin: 0 }} />
|
|
929
|
+
<Flex
|
|
930
|
+
vertical
|
|
931
|
+
gap={spacing[3]}
|
|
932
|
+
data-testid="search-ranges-attributes"
|
|
933
|
+
>
|
|
934
|
+
{bag.state.status === "ready"
|
|
935
|
+
? attributeRanges.map((group) => (
|
|
936
|
+
<RangeFilterRow
|
|
937
|
+
key={group.slug}
|
|
938
|
+
group={group}
|
|
939
|
+
onApply={bag.setRange}
|
|
940
|
+
/>
|
|
941
|
+
))
|
|
942
|
+
: Array.from({ length: reservedAxes }, (_, index) => (
|
|
943
|
+
<RangeRowSkeleton key={index} />
|
|
944
|
+
))}
|
|
945
|
+
</Flex>
|
|
946
|
+
</>
|
|
947
|
+
)
|
|
896
948
|
)}
|
|
897
949
|
|
|
898
950
|
{footerBar !== "none" && (
|
|
@@ -472,6 +472,7 @@ function OpenerChipRow(props: FilterChipsOpenerProps): ReactElement | null {
|
|
|
472
472
|
? { categoryFeatures: props.categoryFeatures }
|
|
473
473
|
: {}),
|
|
474
474
|
coreRanges: bag.coreRanges,
|
|
475
|
+
...(bag.ranges !== undefined ? { ranges: bag.ranges } : {}),
|
|
475
476
|
...(bag.currency !== undefined ? { currency: bag.currency } : {}),
|
|
476
477
|
t,
|
|
477
478
|
});
|
|
@@ -866,6 +867,7 @@ function AppliedChipRow(props: FilterChipsAppliedProps): ReactElement | null {
|
|
|
866
867
|
? { categoryFeatures: props.categoryFeatures }
|
|
867
868
|
: {}),
|
|
868
869
|
coreRanges: bag.coreRanges,
|
|
870
|
+
...(bag.ranges !== undefined ? { ranges: bag.ranges } : {}),
|
|
869
871
|
...(bag.currency !== undefined ? { currency: bag.currency } : {}),
|
|
870
872
|
t,
|
|
871
873
|
});
|
|
@@ -12,12 +12,16 @@
|
|
|
12
12
|
* range is TWO fields, and committing each keystroke would run a search for
|
|
13
13
|
* `1`, `10`, `100` on the way to `1000` — three wrong result pages, three
|
|
14
14
|
* history entries' worth of churn, and a facet panel that reshuffles under the
|
|
15
|
-
* hand still typing. So the row holds a draft and commits on Apply
|
|
16
|
-
*
|
|
15
|
+
* hand still typing. So the row holds a draft and commits on Apply, Enter, or
|
|
16
|
+
* leaving the field (blur) — the picker bounds already committed on blur, and
|
|
17
|
+
* a typed bound doing nothing until a second, separate click is a surprise
|
|
18
|
+
* the picker never had — which is also what makes "from > to" refusable
|
|
19
|
+
* instead of merely empty. A blur that changed nothing sends nothing, and
|
|
20
|
+
* Enter followed by the blur it does not itself cause never double-commits.
|
|
17
21
|
*/
|
|
18
22
|
import { useRef, useState } from "react";
|
|
19
23
|
import type { ReactElement } from "react";
|
|
20
|
-
import { Button, Flex, InputNumber, Select, Typography } from "antd";
|
|
24
|
+
import { Button, Flex, InputNumber, Select, Skeleton, Typography } from "antd";
|
|
21
25
|
import { actionAvailable, actionBlocked, useFormat, useT } from "@stapel/core";
|
|
22
26
|
import type { ActionAvailability } from "@stapel/core";
|
|
23
27
|
import { GatedButton } from "@stapel/tokens-antd/skin";
|
|
@@ -30,6 +34,49 @@ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
|
30
34
|
/** Floor width of one bound field, so two of them fit a 390px phone row. */
|
|
31
35
|
export const RANGE_FIELD_MIN_WIDTH = 96;
|
|
32
36
|
|
|
37
|
+
/**
|
|
38
|
+
* One row's reserved block-size — a label line plus one control row, the two
|
|
39
|
+
* lines every real row draws. Used both as the skeleton row's floor and as
|
|
40
|
+
* the whole reserve when the schema itself is not known yet (D361: a 53px
|
|
41
|
+
* jump inside the rail when the attribute-range block had no box to arrive
|
|
42
|
+
* into, because nothing reserved its height before the answer landed).
|
|
43
|
+
*/
|
|
44
|
+
export const RANGE_ROW_MIN_HEIGHT = 76;
|
|
45
|
+
|
|
46
|
+
/** Floor width of the label placeholder in {@link RangeRowSkeleton} — a
|
|
47
|
+
* named one-off, not a spacing step: it bounds a text skeleton, not a gap. */
|
|
48
|
+
const RANGE_SKELETON_LABEL_WIDTH = 160;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The unrevealed shape of one range row — same two lines, same field widths,
|
|
52
|
+
* no numbers. Drawn for a slug the schema already names while the answer
|
|
53
|
+
* that would fill it (bounds, applied value, unit) has not landed yet, so
|
|
54
|
+
* the rail's box is the row's own final height from the first paint rather
|
|
55
|
+
* than growing into it once the real row replaces this one.
|
|
56
|
+
*/
|
|
57
|
+
export function RangeRowSkeleton(): ReactElement {
|
|
58
|
+
return (
|
|
59
|
+
<Flex
|
|
60
|
+
vertical
|
|
61
|
+
gap={spacing[1]}
|
|
62
|
+
aria-hidden="true"
|
|
63
|
+
data-testid="facet-range-skeleton"
|
|
64
|
+
style={{ minBlockSize: RANGE_ROW_MIN_HEIGHT }}
|
|
65
|
+
>
|
|
66
|
+
<Skeleton.Input
|
|
67
|
+
active
|
|
68
|
+
size="small"
|
|
69
|
+
style={{ maxWidth: RANGE_SKELETON_LABEL_WIDTH }}
|
|
70
|
+
/>
|
|
71
|
+
<Flex gap={spacing[2]} align="center" wrap>
|
|
72
|
+
<Skeleton.Input active style={{ minWidth: RANGE_FIELD_MIN_WIDTH }} />
|
|
73
|
+
<Skeleton.Input active style={{ minWidth: RANGE_FIELD_MIN_WIDTH }} />
|
|
74
|
+
<Skeleton.Button active />
|
|
75
|
+
</Flex>
|
|
76
|
+
</Flex>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
33
80
|
export interface RangeFilterRowProps {
|
|
34
81
|
readonly group: RangeGroup;
|
|
35
82
|
/** `null` clears the slug's range entirely. */
|
|
@@ -175,8 +222,14 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
|
175
222
|
// results are no longer about.
|
|
176
223
|
const applied = useRef<string>(`${toDraft(group.from)}..${toDraft(group.to)}`);
|
|
177
224
|
const current = `${toDraft(group.from)}..${toDraft(group.to)}`;
|
|
225
|
+
// What the row last SENT — starts equal to the URL's own value, so a blur
|
|
226
|
+
// that never changed anything commits nothing. Enter and blur both go
|
|
227
|
+
// through {@link commit}, and both read this ref, so pressing Enter and
|
|
228
|
+
// then tabbing out of the same field fires the request once, not twice.
|
|
229
|
+
const lastSent = useRef<string>(current);
|
|
178
230
|
if (applied.current !== current) {
|
|
179
231
|
applied.current = current;
|
|
232
|
+
lastSent.current = current;
|
|
180
233
|
if (from !== toDraft(group.from)) setFrom(toDraft(group.from));
|
|
181
234
|
if (to !== toDraft(group.to)) setTo(toDraft(group.to));
|
|
182
235
|
}
|
|
@@ -193,6 +246,9 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
|
193
246
|
|
|
194
247
|
const commit = (): void => {
|
|
195
248
|
if (!usable) return;
|
|
249
|
+
const draftKey = `${from}..${to}`;
|
|
250
|
+
if (draftKey === lastSent.current) return;
|
|
251
|
+
lastSent.current = draftKey;
|
|
196
252
|
props.onApply(group.slug, empty ? null : draft);
|
|
197
253
|
};
|
|
198
254
|
|
|
@@ -267,6 +323,7 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
|
267
323
|
setFrom(value === null || value === undefined ? "" : String(value));
|
|
268
324
|
}}
|
|
269
325
|
onPressEnter={commit}
|
|
326
|
+
onBlur={commit}
|
|
270
327
|
/>
|
|
271
328
|
<InputNumber
|
|
272
329
|
value={to === "" ? null : Number(to)}
|
|
@@ -284,6 +341,7 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
|
|
|
284
341
|
setTo(value === null || value === undefined ? "" : String(value));
|
|
285
342
|
}}
|
|
286
343
|
onPressEnter={commit}
|
|
344
|
+
onBlur={commit}
|
|
287
345
|
/>
|
|
288
346
|
</>
|
|
289
347
|
)}
|
|
@@ -552,6 +552,10 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
552
552
|
const ranges = buildRangeGroups({
|
|
553
553
|
state,
|
|
554
554
|
...(categoryFeatures !== undefined ? { categoryFeatures } : {}),
|
|
555
|
+
// The answer's measured axes count as rows here too: a leaf whose numeric
|
|
556
|
+
// axes are all vocabulary-backed has filters, and the schema alone would
|
|
557
|
+
// have called that column empty.
|
|
558
|
+
...(facets.ranges !== undefined ? { ranges: facets.ranges } : {}),
|
|
555
559
|
});
|
|
556
560
|
const filtersEmpty =
|
|
557
561
|
facets.state.status === "ready" &&
|
|
@@ -5,16 +5,21 @@ import type { FeatureDef } from "@stapel/attributes-react";
|
|
|
5
5
|
import type {
|
|
6
6
|
FacetCategoryCount,
|
|
7
7
|
FacetMeta,
|
|
8
|
+
FacetRangesMap,
|
|
8
9
|
FacetWithheldGroup,
|
|
9
10
|
SearchRange,
|
|
10
11
|
} from "../api/types.js";
|
|
11
12
|
import { useSearchQuery } from "../model/queries.js";
|
|
12
13
|
import { buildFacetGroups } from "../state/facets.js";
|
|
13
|
-
import { FACET_PLAN_EVIDENCE } from "../state/degradations.js";
|
|
14
|
+
import { FACET_PLAN_EVIDENCE, FACET_RANGES } from "../state/degradations.js";
|
|
14
15
|
import type { FacetGroup } from "../state/facets.js";
|
|
15
16
|
import { useHostFacetLabels } from "./useFacetLabels.js";
|
|
16
17
|
import type { FacetLabelResolver } from "./useFacetLabels.js";
|
|
17
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
usePublishRangeAxes,
|
|
20
|
+
useRememberedRangeAxes,
|
|
21
|
+
useSearchState,
|
|
22
|
+
} from "./SearchStateProvider.js";
|
|
18
23
|
|
|
19
24
|
/** The bag `<FacetPanel>` hands its render prop. */
|
|
20
25
|
export interface FacetPanelBag {
|
|
@@ -48,6 +53,29 @@ export interface FacetPanelBag {
|
|
|
48
53
|
* filter the deployed server would answer zero for.
|
|
49
54
|
*/
|
|
50
55
|
readonly coreRanges: readonly string[];
|
|
56
|
+
/**
|
|
57
|
+
* `facet_meta.ranges` — the ends this answer MEASURED per axis, core
|
|
58
|
+
* columns and attributes in one map (stapel-search 0.14.7+).
|
|
59
|
+
*
|
|
60
|
+
* `undefined` when the server said nothing: it predates the report, or its
|
|
61
|
+
* engine has no `ranges` verb and said so ({@link rangesDegraded}). A rail
|
|
62
|
+
* falls back to the schema's declared bounds then; it never reads the
|
|
63
|
+
* silence as "this category has no numbers".
|
|
64
|
+
*/
|
|
65
|
+
readonly ranges: FacetRangesMap | undefined;
|
|
66
|
+
/** `true` when the engine listed `facet_ranges` in `degraded[]` — no axis
|
|
67
|
+
* was measured, and that is an engine fact, not a corpus fact. */
|
|
68
|
+
readonly rangesDegraded: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* The attribute axes an answer has already reported FOR THIS CATEGORY,
|
|
71
|
+
* remembered in the state provider across answers — or `undefined` when
|
|
72
|
+
* none ever has.
|
|
73
|
+
*
|
|
74
|
+
* A skin sizes its reservation with it: the schema's numeric count is the
|
|
75
|
+
* first guess, and this is what the server turned out to measure, so the
|
|
76
|
+
* block does not jump the second time a person opens the same leaf.
|
|
77
|
+
*/
|
|
78
|
+
readonly reservedRangeAxes: readonly string[] | undefined;
|
|
51
79
|
/**
|
|
52
80
|
* ISO 4217 code of the corpus, read off the first card of the answer, so
|
|
53
81
|
* a money range reads as money without the host wiring anything. The
|
|
@@ -208,12 +236,34 @@ export function useFacetPanel(props: {
|
|
|
208
236
|
// then this. See `useFacetLabels.ts`.
|
|
209
237
|
const labelled = useHostFacetLabels(groups, props.resolveFacetLabels, props.locale);
|
|
210
238
|
|
|
239
|
+
// An engine with no `ranges` verb reports it; its empty map is then an
|
|
240
|
+
// engine fact, and reading it as "no numeric axes here" is exactly the
|
|
241
|
+
// appear-then-vanish rail this release removes.
|
|
242
|
+
const rangesDegraded =
|
|
243
|
+
envelope.status === "ready" && envelope.data.degraded.includes(FACET_RANGES);
|
|
244
|
+
const coreRanges = meta.core_ranges ?? [];
|
|
245
|
+
const measured =
|
|
246
|
+
envelope.status === "ready" && !rangesDegraded ? meta.ranges : undefined;
|
|
247
|
+
// Remembered per category, and only the ATTRIBUTE half: the core axes are
|
|
248
|
+
// declared by the server for every document and never part of the
|
|
249
|
+
// schema-sized block a rail reserves.
|
|
250
|
+
usePublishRangeAxes(
|
|
251
|
+
searchState.category,
|
|
252
|
+
measured === undefined
|
|
253
|
+
? undefined
|
|
254
|
+
: Object.keys(measured).filter((slug) => !coreRanges.includes(slug))
|
|
255
|
+
);
|
|
256
|
+
const reservedRangeAxes = useRememberedRangeAxes(searchState.category);
|
|
257
|
+
|
|
211
258
|
return {
|
|
212
259
|
state: labelled,
|
|
213
260
|
approximate: meta.approximate,
|
|
214
261
|
skipped: meta.skipped,
|
|
215
262
|
counted: meta.counted,
|
|
216
|
-
coreRanges
|
|
263
|
+
coreRanges,
|
|
264
|
+
ranges: measured,
|
|
265
|
+
rangesDegraded,
|
|
266
|
+
reservedRangeAxes,
|
|
217
267
|
currency:
|
|
218
268
|
envelope.status === "ready"
|
|
219
269
|
? envelope.data.items.find((item) => typeof item.card?.["currency"] === "string")
|
|
@@ -256,6 +256,75 @@ export function useFacetKeys(): FacetKeyMap {
|
|
|
256
256
|
return useContext(FacetKeysContext)?.keys ?? EMPTY_FACET_KEYS;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/**
|
|
260
|
+
* WHICH NUMERIC AXES A CATEGORY HAS — remembered across answers.
|
|
261
|
+
*
|
|
262
|
+
* The rail reserves a box per numeric axis before the answer lands (D361),
|
|
263
|
+
* and until 0.14.7 the only thing that could count them was the category
|
|
264
|
+
* SCHEMA. The answer now knows better: `facet_meta.ranges` measures the axes
|
|
265
|
+
* that have numbers behind them, including the vocabulary-backed ones the
|
|
266
|
+
* schema calls choices. A schema count of 2 followed by a measured count of 4
|
|
267
|
+
* is the SAME layout jump the reservation exists to stop, one answer later.
|
|
268
|
+
*
|
|
269
|
+
* So the count is remembered, keyed by the category it was measured in —
|
|
270
|
+
* different leaves have different axes, and remembering one number for all of
|
|
271
|
+
* them would reserve a car's rail on a phone leaf. Memory, not cache: it is
|
|
272
|
+
* only ever read to size a placeholder, never to draw a row, so a stale entry
|
|
273
|
+
* costs a few pixels and never a wrong control.
|
|
274
|
+
*/
|
|
275
|
+
export type RangeAxisMemory = Readonly<Record<string, readonly string[]>>;
|
|
276
|
+
|
|
277
|
+
interface RangeAxisRegistry {
|
|
278
|
+
readonly axes: RangeAxisMemory;
|
|
279
|
+
publish(category: string, slugs: readonly string[]): void;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const RangeAxesContext = createContext<RangeAxisRegistry | null>(null);
|
|
283
|
+
|
|
284
|
+
const EMPTY_RANGE_AXES: RangeAxisMemory = {};
|
|
285
|
+
|
|
286
|
+
/** The memory key for a search: its category path, or `""` for none. */
|
|
287
|
+
function axisKey(category: string | undefined): string {
|
|
288
|
+
return category ?? "";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function sameAxes(a: readonly string[] | undefined, b: readonly string[]): boolean {
|
|
292
|
+
return (
|
|
293
|
+
a !== undefined && a.length === b.length && a.every((slug, i) => slug === b[i])
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Publish the axes an answer MEASURED for the category it answered about.
|
|
299
|
+
*
|
|
300
|
+
* `slugs` is `undefined` when the server said nothing — it predates 0.14.7,
|
|
301
|
+
* or its engine listed `facet_ranges` in `degraded[]`. Nothing is written
|
|
302
|
+
* then, and nothing is forgotten: an empty list from a degraded answer would
|
|
303
|
+
* teach the rail that this category has no numeric axes at all.
|
|
304
|
+
*/
|
|
305
|
+
export function usePublishRangeAxes(
|
|
306
|
+
category: string | undefined,
|
|
307
|
+
slugs: readonly string[] | undefined
|
|
308
|
+
): void {
|
|
309
|
+
const registry = useContext(RangeAxesContext);
|
|
310
|
+
const key = axisKey(category);
|
|
311
|
+
const joined = slugs === undefined ? undefined : slugs.join("");
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
if (registry === null || joined === undefined) return;
|
|
314
|
+
registry.publish(key, joined === "" ? [] : joined.split(""));
|
|
315
|
+
}, [registry, key, joined]);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The axes an answer has already reported for this category, or `undefined`
|
|
320
|
+
* when none ever has.
|
|
321
|
+
*/
|
|
322
|
+
export function useRememberedRangeAxes(
|
|
323
|
+
category: string | undefined
|
|
324
|
+
): readonly string[] | undefined {
|
|
325
|
+
return useContext(RangeAxesContext)?.axes[axisKey(category)];
|
|
326
|
+
}
|
|
327
|
+
|
|
259
328
|
export interface SearchStateProviderProps extends ParseSearchStateOptions {
|
|
260
329
|
readonly adapter: SearchParamsAdapter;
|
|
261
330
|
/**
|
|
@@ -334,6 +403,20 @@ export function SearchStateProvider(
|
|
|
334
403
|
[facetKeys]
|
|
335
404
|
);
|
|
336
405
|
|
|
406
|
+
// The measured axis lists, per category — see `RangeAxesContext`.
|
|
407
|
+
const [rangeAxes, setRangeAxes] = useState<RangeAxisMemory>(EMPTY_RANGE_AXES);
|
|
408
|
+
const axisRegistry = useMemo<RangeAxisRegistry>(
|
|
409
|
+
() => ({
|
|
410
|
+
axes: rangeAxes,
|
|
411
|
+
publish: (category, slugs) => {
|
|
412
|
+
setRangeAxes((was) =>
|
|
413
|
+
sameAxes(was[category], slugs) ? was : { ...was, [category]: slugs }
|
|
414
|
+
);
|
|
415
|
+
},
|
|
416
|
+
}),
|
|
417
|
+
[rangeAxes]
|
|
418
|
+
);
|
|
419
|
+
|
|
337
420
|
const parsed = useMemo(
|
|
338
421
|
() =>
|
|
339
422
|
parseSearchState(new URLSearchParams(search), {
|
|
@@ -447,7 +530,9 @@ export function SearchStateProvider(
|
|
|
447
530
|
|
|
448
531
|
return (
|
|
449
532
|
<FacetKeysContext.Provider value={registry}>
|
|
450
|
-
<
|
|
533
|
+
<RangeAxesContext.Provider value={axisRegistry}>
|
|
534
|
+
<StateContext.Provider value={bag}>{children}</StateContext.Provider>
|
|
535
|
+
</RangeAxesContext.Provider>
|
|
451
536
|
</FacetKeysContext.Provider>
|
|
452
537
|
);
|
|
453
538
|
}
|