@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
@@ -10,10 +10,20 @@
10
10
  *
11
11
  * A facet answer (`facets: {slug: {value: count}}`) enumerates DISCRETE
12
12
  * values; a range is not enumerable and no bucket is ever sent for one. So
13
- * the rows come from two places:
13
+ * the rows come from three places:
14
14
  *
15
+ * - the ANSWER's `facet_meta.ranges` (stapel-search 0.14.7+) — `{slug:
16
+ * {min, max}}` for every axis this page has NUMBERS behind, measured with
17
+ * the range filters removed and uncapped by `MAX_FACET_FIELDS`. When the
18
+ * answer reports it, it is the authority on which attribute axes exist and
19
+ * where their ends are: the schema says an axis COULD be numeric, and this
20
+ * says it IS, on these documents. It reaches axes the schema's own type
21
+ * cannot — a vocabulary-backed `year`, a `floor`, a `doors` are CHOICES in
22
+ * the catalogue and from/to's to a buyer, and both are served.
15
23
  * - the CATEGORY SCHEMA — the same `categoryFeatures` slot that gives the
16
- * checkboxes their labels — filtered to the numeric value types;
24
+ * checkboxes their labels — filtered to the numeric value types. It is the
25
+ * FALLBACK, and the only source against a server that reports no `ranges`
26
+ * (older, or an engine that listed `facet_ranges` in `degraded[]`).
17
27
  * - the ANSWER's `facet_meta.core_ranges` (stapel-search 0.4.0+), which
18
28
  * names the range slugs addressing a COLUMN of the document rather than
19
29
  * an attribute. `price` is the shipped one, and it is why this module
@@ -37,7 +47,11 @@
37
47
  */
38
48
  import { featureConfig, featureName, featureType } from "@stapel/attributes-react";
39
49
  import type { FeatureDef } from "@stapel/attributes-react";
40
- import type { SearchQueryState, SearchRange } from "../api/types.js";
50
+ import type {
51
+ FacetRangesMap,
52
+ SearchQueryState,
53
+ SearchRange,
54
+ } from "../api/types.js";
41
55
 
42
56
  /**
43
57
  * Value types a numeric range row is drawn for (`config.type`, the
@@ -60,9 +74,19 @@ export interface RangeGroup {
60
74
  * never promised a number, and re-formatting one would rewrite the link). */
61
75
  readonly from: string | undefined;
62
76
  readonly to: string | undefined;
63
- /** Bounds the schema declares, for the input's own limits. */
77
+ /** The axis's ends: the ANSWER's measured bounds when it reported any for
78
+ * this slug, the schema's declared ones otherwise. */
64
79
  readonly min: number | undefined;
65
80
  readonly max: number | undefined;
81
+ /**
82
+ * `true` when {@link min}/{@link max} were MEASURED over this answer
83
+ * (`facet_meta.ranges`) rather than declared by the schema.
84
+ *
85
+ * The difference is what a picker may be drawn from: a catalogue's
86
+ * `1900..2027` is what a year could be, and `2015..2020` is what this page
87
+ * has. Never set on a core axis — see {@link BuildRangeGroupsInput.ranges}.
88
+ */
89
+ readonly measured: boolean;
66
90
  /** Unit suffix the schema declares (`postfix`, or a convertible unit). */
67
91
  readonly unit: string | undefined;
68
92
  /** `1` for an integer feature — a whole-number input for a whole number. */
@@ -81,8 +105,10 @@ export interface RangeGroup {
81
105
  * to pick from — `undefined` for every other axis, including price.
82
106
  *
83
107
  * A year is not a number a person types, it is one of a hundred-odd values,
84
- * and the reference classified draws it as two pickers. `min`/`max` from
85
- * the schema (`year: 1900..2027` on a live cars leaf) bound the list;
108
+ * and the reference classified draws it as two pickers. {@link min}/{@link
109
+ * max} bound the list the answer's measured ends when it reported any
110
+ * (`year: 1990..2024` over these documents), the schema's declared ones
111
+ * otherwise (`year: 1900..2027` on a live cars leaf);
86
112
  * {@link RANGE_PICKER_MAX_VALUES} is where a picker stops being one and
87
113
  * becomes a scroll with a search box in it, at which point two typed fields
88
114
  * are the better control. Descending, because the busy end of a bounded
@@ -124,6 +150,31 @@ export interface BuildRangeGroupsInput {
124
150
  * can actually serve, and this list is empty against an older one.
125
151
  */
126
152
  readonly coreRanges?: readonly string[];
153
+ /**
154
+ * `facet_meta.ranges` — the axes this ANSWER measured, and their ends
155
+ * (stapel-search 0.14.7+).
156
+ *
157
+ * Two jobs, and the second is the one the schema cannot do:
158
+ * - BOUNDS. `min`/`max` come from here when the axis is in it, and from
159
+ * the schema's `config` otherwise. A catalogue's `1900..2027` is what a
160
+ * year COULD be; `2015..2020` is what this page has.
161
+ * - EXISTENCE. An axis the schema types as a CHOICE — a vocabulary-backed
162
+ * `year`, a `floor`, a `doors` — is a from/to to a buyer, and the server
163
+ * now says so by measuring it. Reported axes get a row whatever the
164
+ * schema calls them.
165
+ *
166
+ * `undefined` means the server said nothing: it predates 0.14.7, or its
167
+ * engine has no `ranges` verb and listed `facet_ranges` in `degraded[]`.
168
+ * The schema's bounds are the fallback then — never an empty rail, which
169
+ * would read as "this category has no numbers".
170
+ *
171
+ * A CORE axis is left alone by it. The price field is not clamped to the
172
+ * corpus's current ends: those ends move with every other filter, and an
173
+ * input that refuses the number a person meant to type is worse than an
174
+ * unbounded one. The measured price is still on the envelope for a host
175
+ * that wants to draw a histogram over it.
176
+ */
177
+ readonly ranges?: FacetRangesMap;
127
178
  /** ISO 4217 code for the money axes, when the surface knows one. */
128
179
  readonly currency?: string;
129
180
  /** Translator for label keys (the schema's `name` is often one). */
@@ -154,6 +205,34 @@ function translate(t: ((key: string) => string) | undefined, key: string): strin
154
205
  */
155
206
  export const RANGE_PICKER_MAX_VALUES = 300;
156
207
 
208
+ /**
209
+ * Is this axis a WHOLE-NUMBER one — the question both the picker and the
210
+ * input's step ask?
211
+ *
212
+ * The schema answers it for a typed feature (`int`, and never `float` or a
213
+ * convertible unit, whose values are 1.4 litres). For an axis the schema
214
+ * types as a CHOICE, or does not describe at all, the ANSWER answers it: the
215
+ * server measured two ends over the real documents, and whole ends over a
216
+ * vocabulary of numeric codes is what a `year` or a `floor` is.
217
+ */
218
+ function isIntegerAxis(
219
+ feature: FeatureDef | undefined,
220
+ min: number | undefined,
221
+ max: number | undefined,
222
+ measured: boolean
223
+ ): boolean {
224
+ const type = feature === undefined ? undefined : featureType(feature);
225
+ if (type === "int") return true;
226
+ if (type === "float" || type === "convertible_unit") return false;
227
+ return (
228
+ measured &&
229
+ min !== undefined &&
230
+ max !== undefined &&
231
+ Number.isInteger(min) &&
232
+ Number.isInteger(max)
233
+ );
234
+ }
235
+
157
236
  /**
158
237
  * The value list for a bounded integer axis, or `undefined`.
159
238
  *
@@ -163,10 +242,10 @@ export const RANGE_PICKER_MAX_VALUES = 300;
163
242
  function pickerValues(
164
243
  feature: FeatureDef | undefined,
165
244
  min: number | undefined,
166
- max: number | undefined
245
+ max: number | undefined,
246
+ measured: boolean
167
247
  ): readonly number[] | undefined {
168
- if (feature === undefined) return undefined;
169
- if (featureType(feature) !== "int") return undefined;
248
+ if (!isIntegerAxis(feature, min, max, measured)) return undefined;
170
249
  if (min === undefined || max === undefined) return undefined;
171
250
  if (!Number.isInteger(min) || !Number.isInteger(max)) return undefined;
172
251
  const span = max - min + 1;
@@ -198,12 +277,24 @@ export function buildRangeGroups(
198
277
  for (const feature of input.categoryFeatures ?? []) bySlug.set(feature.slug, feature);
199
278
 
200
279
  const core = new Set(input.coreRanges ?? []);
280
+ const bounds = input.ranges;
281
+ const reported = new Set(Object.keys(bounds ?? {}));
201
282
  const slugs: string[] = [...core];
202
283
  for (const feature of input.categoryFeatures ?? []) {
203
284
  // A core slug shadows a same-named attribute — which is exactly what the
204
285
  // server does with it (`index_schema.CORE_RANGE_FIELDS` reserves the
205
286
  // slug), so drawing both would put two controls over one filter.
206
- if (isRangeFeature(feature) && !core.has(feature.slug)) slugs.push(feature.slug);
287
+ if (core.has(feature.slug)) continue;
288
+ // The schema's own numeric types, PLUS anything this answer measured: a
289
+ // vocabulary-backed `year` is a choice in the catalogue and a from/to on
290
+ // the page, and the server measuring it is the fact that settles it.
291
+ // Schema ORDER either way, so an axis does not move when it is measured.
292
+ if (isRangeFeature(feature) || reported.has(feature.slug)) slugs.push(feature.slug);
293
+ }
294
+ // Measured axes the schema never mentioned — a host that hands in no
295
+ // `categoryFeatures` still gets its rows, labelled by slug.
296
+ for (const slug of reported) {
297
+ if (!slugs.includes(slug)) slugs.push(slug);
207
298
  }
208
299
  for (const slug of Object.keys(input.state.ranges)) {
209
300
  if (!slugs.includes(slug)) slugs.push(slug);
@@ -214,6 +305,12 @@ export function buildRangeGroups(
214
305
  const feature = isCore ? undefined : bySlug.get(slug);
215
306
  const config = feature === undefined ? {} : featureConfig(feature);
216
307
  const applied: SearchRange | undefined = input.state.ranges[slug];
308
+ // Measured ends win over declared ones, on an attribute axis only — see
309
+ // `BuildRangeGroupsInput.ranges` for why the price input keeps its own.
310
+ const axis = isCore ? undefined : bounds?.[slug];
311
+ const measured = axis !== undefined;
312
+ const min = measured ? axis.min : num(config["min"]);
313
+ const max = measured ? axis.max : num(config["max"]);
217
314
  return {
218
315
  slug,
219
316
  label: isCore
@@ -224,16 +321,17 @@ export function buildRangeGroups(
224
321
  feature,
225
322
  from: applied?.from,
226
323
  to: applied?.to,
227
- min: num(config["min"]),
228
- max: num(config["max"]),
324
+ min,
325
+ max,
326
+ measured,
229
327
  // A core money axis carries a CURRENCY, not a unit suffix: "₽" is
230
328
  // formatted from the code for the reader's locale, a unit suffix is a literal
231
329
  // the category author typed.
232
330
  unit: isCore
233
331
  ? undefined
234
332
  : (str(config["postfix"]) ?? str(config["unit_m"]) ?? str(config["unit_i"])),
235
- step: feature !== undefined && featureType(feature) === "int" ? 1 : undefined,
236
- picker: isCore ? undefined : pickerValues(feature, num(config["min"]), num(config["max"])),
333
+ step: !isCore && isIntegerAxis(feature, min, max, measured) ? 1 : undefined,
334
+ picker: isCore ? undefined : pickerValues(feature, min, max, measured),
237
335
  active: applied !== undefined,
238
336
  core: isCore,
239
337
  currency: isCore ? str(input.currency) : undefined,