@stapel/search-react 0.29.1 → 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.
Files changed (60) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +56 -0
  3. package/dist/api/generated/schema.d.ts +9 -1
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/api/types.d.ts +46 -3
  6. package/dist/api/types.d.ts.map +1 -1
  7. package/dist/api/types.js.map +1 -1
  8. package/dist/default/FacetGroupControl.d.ts +5 -0
  9. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  10. package/dist/default/FacetGroupControl.js +63 -1
  11. package/dist/default/FacetGroupControl.js.map +1 -1
  12. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  13. package/dist/default/FacetPanelPane.js +10 -2
  14. package/dist/default/FacetPanelPane.js.map +1 -1
  15. package/dist/default/FilterChips.d.ts.map +1 -1
  16. package/dist/default/FilterChips.js +2 -0
  17. package/dist/default/FilterChips.js.map +1 -1
  18. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  19. package/dist/default/RangeFilterRow.js +18 -4
  20. package/dist/default/RangeFilterRow.js.map +1 -1
  21. package/dist/default/SearchPage.d.ts.map +1 -1
  22. package/dist/default/SearchPage.js +4 -0
  23. package/dist/default/SearchPage.js.map +1 -1
  24. package/dist/headless/FacetPanel.d.ts +24 -1
  25. package/dist/headless/FacetPanel.d.ts.map +1 -1
  26. package/dist/headless/FacetPanel.js +19 -3
  27. package/dist/headless/FacetPanel.js.map +1 -1
  28. package/dist/headless/SearchStateProvider.d.ts +31 -0
  29. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  30. package/dist/headless/SearchStateProvider.js +0 -0
  31. package/dist/headless/SearchStateProvider.js.map +1 -1
  32. package/dist/index.d.ts +4 -4
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/state/degradations.d.ts +12 -0
  37. package/dist/state/degradations.d.ts.map +1 -1
  38. package/dist/state/degradations.js +12 -0
  39. package/dist/state/degradations.js.map +1 -1
  40. package/dist/state/ranges.d.ts +41 -4
  41. package/dist/state/ranges.d.ts.map +1 -1
  42. package/dist/state/ranges.js +62 -11
  43. package/dist/state/ranges.js.map +1 -1
  44. package/llms.txt +1 -1
  45. package/manifest.json +7 -1
  46. package/nav-manifest.json +1 -1
  47. package/package.json +6 -6
  48. package/src/analytics/generated/events.json +1 -1
  49. package/src/api/generated/schema.ts +9 -1
  50. package/src/api/types.ts +48 -3
  51. package/src/default/FacetGroupControl.tsx +64 -1
  52. package/src/default/FacetPanelPane.tsx +22 -4
  53. package/src/default/FilterChips.tsx +2 -0
  54. package/src/default/RangeFilterRow.tsx +17 -2
  55. package/src/default/SearchPage.tsx +4 -0
  56. package/src/headless/FacetPanel.tsx +53 -3
  57. package/src/headless/SearchStateProvider.tsx +86 -1
  58. package/src/index.ts +6 -0
  59. package/src/state/degradations.ts +13 -0
  60. package/src/state/ranges.ts +112 -14
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; nothing else about `FacetMeta` is hand-written.
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
- return singleChoice(feature) ? "segmented" : "checkbox";
273
+ if (singleChoice(feature) || looksSingleChoiceByEvidence(group)) return "segmented";
274
+ return "checkbox";
212
275
  }
213
276
 
214
277
  /**
@@ -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
@@ -894,7 +903,16 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
894
903
  schema, so the rail draws that many skeleton rows, each
895
904
  `RANGE_ROW_MIN_HEIGHT` tall like the real one it will
896
905
  become. Same count in both arms, so the swap from
897
- skeleton to `<RangeFilterRow>` costs no further height. */}
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. */}
898
916
  {props.categoryFeatures === undefined ? (
899
917
  <>
900
918
  <Divider style={{ margin: 0 }} />
@@ -905,7 +923,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
905
923
  />
906
924
  </>
907
925
  ) : (
908
- attributeRanges.length > 0 && (
926
+ (attributeRanges.length > 0 || reservedAxes > 0) && (
909
927
  <>
910
928
  <Divider style={{ margin: 0 }} />
911
929
  <Flex
@@ -921,8 +939,8 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
921
939
  onApply={bag.setRange}
922
940
  />
923
941
  ))
924
- : attributeRanges.map((group) => (
925
- <RangeRowSkeleton key={group.slug} />
942
+ : Array.from({ length: reservedAxes }, (_, index) => (
943
+ <RangeRowSkeleton key={index} />
926
944
  ))}
927
945
  </Flex>
928
946
  </>
@@ -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,8 +12,12 @@
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 (or Enter),
16
- * which is also what makes "from > to" refusable instead of merely empty.
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";
@@ -218,8 +222,14 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
218
222
  // results are no longer about.
219
223
  const applied = useRef<string>(`${toDraft(group.from)}..${toDraft(group.to)}`);
220
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);
221
230
  if (applied.current !== current) {
222
231
  applied.current = current;
232
+ lastSent.current = current;
223
233
  if (from !== toDraft(group.from)) setFrom(toDraft(group.from));
224
234
  if (to !== toDraft(group.to)) setTo(toDraft(group.to));
225
235
  }
@@ -236,6 +246,9 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
236
246
 
237
247
  const commit = (): void => {
238
248
  if (!usable) return;
249
+ const draftKey = `${from}..${to}`;
250
+ if (draftKey === lastSent.current) return;
251
+ lastSent.current = draftKey;
239
252
  props.onApply(group.slug, empty ? null : draft);
240
253
  };
241
254
 
@@ -310,6 +323,7 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
310
323
  setFrom(value === null || value === undefined ? "" : String(value));
311
324
  }}
312
325
  onPressEnter={commit}
326
+ onBlur={commit}
313
327
  />
314
328
  <InputNumber
315
329
  value={to === "" ? null : Number(to)}
@@ -327,6 +341,7 @@ export function RangeFilterRow(props: RangeFilterRowProps): ReactElement {
327
341
  setTo(value === null || value === undefined ? "" : String(value));
328
342
  }}
329
343
  onPressEnter={commit}
344
+ onBlur={commit}
330
345
  />
331
346
  </>
332
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 { useSearchState } from "./SearchStateProvider.js";
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: meta.core_ranges ?? [],
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
- <StateContext.Provider value={bag}>{children}</StateContext.Provider>
533
+ <RangeAxesContext.Provider value={axisRegistry}>
534
+ <StateContext.Provider value={bag}>{children}</StateContext.Provider>
535
+ </RangeAxesContext.Provider>
451
536
  </FacetKeysContext.Provider>
452
537
  );
453
538
  }
package/src/index.ts CHANGED
@@ -55,6 +55,8 @@ export type {
55
55
  FacetLabels,
56
56
  FacetLabelsMap,
57
57
  FacetMeta,
58
+ FacetRangeBounds,
59
+ FacetRangesMap,
58
60
  FacetSelection,
59
61
  FacetWithheldGroup,
60
62
  RankingResponse,
@@ -107,6 +109,7 @@ export type {
107
109
 
108
110
  export {
109
111
  FACET_PLAN_EVIDENCE,
112
+ FACET_RANGES,
110
113
  countIsEstimate,
111
114
  countKind,
112
115
  degradationAudience,
@@ -190,10 +193,13 @@ export {
190
193
  SearchStateProvider,
191
194
  useFacetKeys,
192
195
  usePublishFacetKeys,
196
+ usePublishRangeAxes,
197
+ useRememberedRangeAxes,
193
198
  useSearchState,
194
199
  } from "./headless/SearchStateProvider.js";
195
200
  export type {
196
201
  HistoryMode,
202
+ RangeAxisMemory,
197
203
  SearchHistoryKind,
198
204
  SearchParamsAdapter,
199
205
  SearchStateBag,
@@ -27,6 +27,19 @@ const SCORER_PREFIX = "scorer:";
27
27
  */
28
28
  export const FACET_PLAN_EVIDENCE = "facet_plan_evidence";
29
29
 
30
+ /**
31
+ * "This engine has no `ranges` verb, so no axis was measured."
32
+ *
33
+ * Read raw for the same reason as {@link FACET_PLAN_EVIDENCE}: an empty
34
+ * `facet_meta.ranges` from a degraded answer is not "this category has no
35
+ * numbers", so the rail falls back to the SCHEMA's bounds instead of drawing
36
+ * a control with no ends — and, crucially, does not REMEMBER the empty list
37
+ * as the category's axis count. Kept out of the `KNOWN` table deliberately: it is
38
+ * an operator's engine choice, and the generic literal-beside-the-sentence
39
+ * fallback is the honest way to show one this build has no wording for.
40
+ */
41
+ export const FACET_RANGES = "facet_ranges";
42
+
30
43
  const KNOWN: Readonly<Record<string, SearchDegradationKind>> = {
31
44
  typo_tolerance: "typo_tolerance",
32
45
  phrase_synonyms: "phrase_synonyms",