@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +72 -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 +12 -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 +16 -0
  19. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  20. package/dist/default/RangeFilterRow.js +40 -5
  21. package/dist/default/RangeFilterRow.js.map +1 -1
  22. package/dist/default/SearchPage.d.ts.map +1 -1
  23. package/dist/default/SearchPage.js +4 -0
  24. package/dist/default/SearchPage.js.map +1 -1
  25. package/dist/headless/FacetPanel.d.ts +24 -1
  26. package/dist/headless/FacetPanel.d.ts.map +1 -1
  27. package/dist/headless/FacetPanel.js +19 -3
  28. package/dist/headless/FacetPanel.js.map +1 -1
  29. package/dist/headless/SearchStateProvider.d.ts +31 -0
  30. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  31. package/dist/headless/SearchStateProvider.js +0 -0
  32. package/dist/headless/SearchStateProvider.js.map +1 -1
  33. package/dist/index.d.ts +4 -4
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/state/degradations.d.ts +12 -0
  38. package/dist/state/degradations.d.ts.map +1 -1
  39. package/dist/state/degradations.js +12 -0
  40. package/dist/state/degradations.js.map +1 -1
  41. package/dist/state/ranges.d.ts +41 -4
  42. package/dist/state/ranges.d.ts.map +1 -1
  43. package/dist/state/ranges.js +62 -11
  44. package/dist/state/ranges.js.map +1 -1
  45. package/llms.txt +1 -1
  46. package/manifest.json +7 -1
  47. package/nav-manifest.json +1 -1
  48. package/package.json +5 -5
  49. package/src/analytics/generated/events.json +1 -1
  50. package/src/api/generated/schema.ts +9 -1
  51. package/src/api/types.ts +48 -3
  52. package/src/default/FacetGroupControl.tsx +64 -1
  53. package/src/default/FacetPanelPane.tsx +67 -15
  54. package/src/default/FilterChips.tsx +2 -0
  55. package/src/default/RangeFilterRow.tsx +61 -3
  56. package/src/default/SearchPage.tsx +4 -0
  57. package/src/headless/FacetPanel.tsx +53 -3
  58. package/src/headless/SearchStateProvider.tsx +86 -1
  59. package/src/index.ts +6 -0
  60. package/src/state/degradations.ts +13 -0
  61. 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; 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
  /**
@@ -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
- {attributeRanges.length > 0 && (
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
- <Flex
883
- vertical
884
- gap={spacing[3]}
885
- data-testid="search-ranges-attributes"
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 (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";
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 { 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
  }