@stapel/search-react 0.31.0 → 0.32.0

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 (89) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/api/generated/schema.d.ts +69 -6
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/types.d.ts +118 -33
  5. package/dist/api/types.d.ts.map +1 -1
  6. package/dist/api/types.js +13 -0
  7. package/dist/api/types.js.map +1 -1
  8. package/dist/default/FacetGroupControl.d.ts +18 -1
  9. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  10. package/dist/default/FacetGroupControl.js +25 -0
  11. package/dist/default/FacetGroupControl.js.map +1 -1
  12. package/dist/default/FacetPanelPane.d.ts +16 -0
  13. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  14. package/dist/default/FacetPanelPane.js +94 -19
  15. package/dist/default/FacetPanelPane.js.map +1 -1
  16. package/dist/default/FilterChips.d.ts +22 -0
  17. package/dist/default/FilterChips.d.ts.map +1 -1
  18. package/dist/default/FilterChips.js +31 -2
  19. package/dist/default/FilterChips.js.map +1 -1
  20. package/dist/default/PopularValues.d.ts +49 -2
  21. package/dist/default/PopularValues.d.ts.map +1 -1
  22. package/dist/default/PopularValues.js +57 -3
  23. package/dist/default/PopularValues.js.map +1 -1
  24. package/dist/default/RangeFilterRow.d.ts +25 -0
  25. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  26. package/dist/default/RangeFilterRow.js +77 -5
  27. package/dist/default/RangeFilterRow.js.map +1 -1
  28. package/dist/default/SearchPage.d.ts +28 -1
  29. package/dist/default/SearchPage.d.ts.map +1 -1
  30. package/dist/default/SearchPage.js +13 -6
  31. package/dist/default/SearchPage.js.map +1 -1
  32. package/dist/default/SearchResultsPane.d.ts +10 -0
  33. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  34. package/dist/default/SearchResultsPane.js +1 -1
  35. package/dist/default/SearchResultsPane.js.map +1 -1
  36. package/dist/default/SortSelect.d.ts +10 -10
  37. package/dist/default/SortSelect.d.ts.map +1 -1
  38. package/dist/default/SortSelect.js +19 -8
  39. package/dist/default/SortSelect.js.map +1 -1
  40. package/dist/default/index.d.ts +4 -4
  41. package/dist/default/index.d.ts.map +1 -1
  42. package/dist/default/index.js +3 -3
  43. package/dist/default/index.js.map +1 -1
  44. package/dist/headless/FacetPanel.d.ts +7 -0
  45. package/dist/headless/FacetPanel.d.ts.map +1 -1
  46. package/dist/headless/FacetPanel.js +2 -1
  47. package/dist/headless/FacetPanel.js.map +1 -1
  48. package/dist/headless/SearchStateProvider.d.ts +15 -0
  49. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  50. package/dist/headless/SearchStateProvider.js +0 -0
  51. package/dist/headless/SearchStateProvider.js.map +1 -1
  52. package/dist/index.d.ts +4 -2
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +2 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/state/facets.d.ts +13 -0
  57. package/dist/state/facets.d.ts.map +1 -1
  58. package/dist/state/facets.js +13 -0
  59. package/dist/state/facets.js.map +1 -1
  60. package/dist/state/panel.d.ts +79 -0
  61. package/dist/state/panel.d.ts.map +1 -0
  62. package/dist/state/panel.js +75 -0
  63. package/dist/state/panel.js.map +1 -0
  64. package/dist/state/ranges.d.ts +55 -3
  65. package/dist/state/ranges.d.ts.map +1 -1
  66. package/dist/state/ranges.js +78 -20
  67. package/dist/state/ranges.js.map +1 -1
  68. package/llms.txt +4 -4
  69. package/manifest.json +12 -2
  70. package/nav-manifest.json +1 -1
  71. package/package.json +9 -9
  72. package/src/analytics/generated/events.json +1 -1
  73. package/src/api/generated/schema.ts +69 -6
  74. package/src/api/types.ts +132 -32
  75. package/src/default/FacetGroupControl.tsx +29 -1
  76. package/src/default/FacetPanelPane.tsx +240 -124
  77. package/src/default/FilterChips.tsx +39 -1
  78. package/src/default/PopularValues.tsx +88 -4
  79. package/src/default/RangeFilterRow.tsx +117 -20
  80. package/src/default/SearchPage.tsx +46 -4
  81. package/src/default/SearchResultsPane.tsx +13 -0
  82. package/src/default/SortSelect.tsx +29 -19
  83. package/src/default/index.ts +8 -1
  84. package/src/headless/FacetPanel.tsx +17 -2
  85. package/src/headless/SearchStateProvider.tsx +23 -0
  86. package/src/index.ts +8 -0
  87. package/src/state/facets.ts +27 -0
  88. package/src/state/panel.ts +138 -0
  89. package/src/state/ranges.ts +158 -47
@@ -0,0 +1,138 @@
1
+ /**
2
+ * ONE panel, not two halves of one — the order a filter rail draws its facet
3
+ * groups and its numeric axes in.
4
+ *
5
+ * ── The defect this closes ────────────────────────────────────────────────
6
+ *
7
+ * The rail drew three blocks in a fixed sequence: the CORE ranges (price),
8
+ * then every facet group, then every attribute range. That sequence is a
9
+ * client's opinion about a catalogue it has never read, and on a live cars
10
+ * leaf it put "Year" — a measurement the category authors second,
11
+ * right after the make — below forty checkbox groups, while "Price" sat above
12
+ * a make picker the schema puts first. A host asking for
13
+ * `partition → make → price → year` could pin the two GROUPS and had no way
14
+ * to say where the two RANGES went, because the two halves were ordered by
15
+ * different code and never compared.
16
+ *
17
+ * stapel-search 0.16.0 ends the argument by numbering both halves in ONE
18
+ * sequence: `facet_labels[<slug>].order` and `facet_meta.ranges[<slug>].order`
19
+ * are the same integer scale, assigned by the plan — core ranges first (they
20
+ * address a column every document in every corpus has), then the category's
21
+ * own schema order, mandatory first. Sorting both halves by that one key is
22
+ * all this module does.
23
+ *
24
+ * ── What happens when nobody numbered anything ────────────────────────────
25
+ *
26
+ * An older server states no `order` at all, and a row built from the CATEGORY
27
+ * SCHEMA alone never has one however new the server is. Those items keep the
28
+ * band order this pair has always used — core ranges, then the groups in the
29
+ * order the caller handed them (which is `orderFacetGroupsBySchema`'s), then
30
+ * the remaining measurements — and sit AFTER everything the plan did number,
31
+ * because a stated position is evidence and an assumed one is not.
32
+ *
33
+ * `pinned` outranks both. It is the host saying "this page is about make",
34
+ * and a page that has decided what it is about is not overruled by the
35
+ * category's own idea of a reading order.
36
+ */
37
+ import type { FacetGroup } from "./facets.js";
38
+ import type { RangeGroup } from "./ranges.js";
39
+
40
+ /** One row of the panel: a bucket list, or a from/to picker. */
41
+ export type PanelItem =
42
+ | { readonly kind: "group"; readonly slug: string; readonly group: FacetGroup }
43
+ | { readonly kind: "range"; readonly slug: string; readonly range: RangeGroup };
44
+
45
+ export interface OrderPanelItemsInput {
46
+ /**
47
+ * The facet groups, already filtered to what is drawable and ordered the
48
+ * way the rail wants them — `orderFacetGroupsBySchema`'s output. Their
49
+ * relative order is preserved for every group the plan did not number.
50
+ */
51
+ readonly groups: readonly FacetGroup[];
52
+ /** The range rows, `buildRangeGroups`' output. */
53
+ readonly ranges: readonly RangeGroup[];
54
+ /**
55
+ * Slugs pinned above everything, in the order given — the axis a page has
56
+ * already decided is its subject. A slug matches whichever half carries it,
57
+ * so `["make", "price", "year"]` is one sequence over both.
58
+ */
59
+ readonly pinned?: readonly string[];
60
+ }
61
+
62
+ /**
63
+ * The panel's rows, in the one order they are read in.
64
+ *
65
+ * Three bands, and every one of them is stable — equal-ranked items keep the
66
+ * order they arrived in, so nothing reshuffles under a click:
67
+ *
68
+ * 1. `pinned`, in the order given.
69
+ * 2. everything the ANSWER numbered (`order`), ascending. Both halves share
70
+ * the scale, so a group and a range can interleave inside it.
71
+ * 3. everything nobody numbered: core ranges, then groups, then attribute
72
+ * ranges — the band order the rail shipped with, kept for exactly the case
73
+ * it was invented for (a server or a row with no stated position).
74
+ */
75
+ export function orderPanelItems(
76
+ input: OrderPanelItemsInput
77
+ ): readonly PanelItem[] {
78
+ const items: PanelItem[] = [
79
+ ...input.groups.map(
80
+ (group): PanelItem => ({ kind: "group", slug: group.slug, group })
81
+ ),
82
+ ...input.ranges.map(
83
+ (range): PanelItem => ({ kind: "range", slug: range.slug, range })
84
+ ),
85
+ ];
86
+
87
+ const pinnedIndex = new Map<string, number>();
88
+ (input.pinned ?? []).forEach((slug, index) => {
89
+ if (!pinnedIndex.has(slug)) pinnedIndex.set(slug, index);
90
+ });
91
+
92
+ const stated = (item: PanelItem): number | undefined =>
93
+ item.kind === "range" ? item.range.order : orderOf(item.group);
94
+
95
+ // The fallback band's own three tiers. A core axis first for the reason it
96
+ // has always been first: on a phones leaf every attribute range is parcel
97
+ // logistics and the one number a buyer narrows by is the price.
98
+ const fallbackTier = (item: PanelItem): number => {
99
+ if (item.kind === "range") return item.range.core ? 0 : 2;
100
+ return 1;
101
+ };
102
+
103
+ const decorated = items.map((item, index) => ({ item, index }));
104
+ decorated.sort((a, b) => {
105
+ const pinA = pinnedIndex.get(a.item.slug);
106
+ const pinB = pinnedIndex.get(b.item.slug);
107
+ if (pinA !== undefined || pinB !== undefined) {
108
+ if (pinA === undefined) return 1;
109
+ if (pinB === undefined) return -1;
110
+ if (pinA !== pinB) return pinA - pinB;
111
+ return a.index - b.index;
112
+ }
113
+ const orderA = stated(a.item);
114
+ const orderB = stated(b.item);
115
+ if (orderA !== undefined || orderB !== undefined) {
116
+ // A stated position beats an assumed one, so the unnumbered tail follows
117
+ // the plan rather than being interleaved into it by a guess.
118
+ if (orderA === undefined) return 1;
119
+ if (orderB === undefined) return -1;
120
+ if (orderA !== orderB) return orderA - orderB;
121
+ return a.index - b.index;
122
+ }
123
+ const tier = fallbackTier(a.item) - fallbackTier(b.item);
124
+ if (tier !== 0) return tier;
125
+ return a.index - b.index;
126
+ });
127
+ return decorated.map((entry) => entry.item);
128
+ }
129
+
130
+ /**
131
+ * A group's stated position, or `undefined`.
132
+ *
133
+ * `null` is the server's own "the plan has no place for this group" and reads
134
+ * exactly like an absent field — both mean "sort me with the rest".
135
+ */
136
+ function orderOf(group: FacetGroup): number | undefined {
137
+ return typeof group.order === "number" ? group.order : undefined;
138
+ }
@@ -12,14 +12,19 @@
12
12
  * values; a range is not enumerable and no bucket is ever sent for one. So
13
13
  * the rows come from three places:
14
14
  *
15
- * - the ANSWER's `facet_meta.ranges` (stapel-search 0.14.7+) `{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
+ * - the ANSWER's `facet_meta.ranges` (stapel-search 0.14.7+, a whole axis
16
+ * rather than two numbers since 0.16.0) `{slug: {min, max, label,
17
+ * label_translatable, unit?, order}}` for every axis this page has NUMBERS
18
+ * behind, measured with the range filters removed. When the answer reports
19
+ * it, it is the authority on which attribute axes exist, where their ends
20
+ * are, WHAT THEY ARE CALLED and where in the panel they sit: the schema
21
+ * says an axis COULD be numeric, and this says it IS, on these documents,
22
+ * under this name. It reaches axes the schema's own type cannot a
23
+ * vocabulary-backed `year`, a `floor`, a `doors` are CHOICES in the
24
+ * catalogue and from/to's to a buyer, and both are served.
25
+ * - the ANSWER's `facet_meta.withheld` (0.16.0), which is the same authority
26
+ * used in the negative: an axis it names as a withheld `range` is one this
27
+ * answer planned and declined to offer, and no schema row resurrects it.
23
28
  * - the CATEGORY SCHEMA — the same `categoryFeatures` slot that gives the
24
29
  * checkboxes their labels — filtered to the numeric value types. It is the
25
30
  * FALLBACK, and the only source against a server that reports no `ranges`
@@ -49,9 +54,11 @@ import { featureConfig, featureName, featureType } from "@stapel/attributes-reac
49
54
  import type { FeatureDef } from "@stapel/attributes-react";
50
55
  import type {
51
56
  FacetRangesMap,
57
+ FacetWithheldAxis,
52
58
  SearchQueryState,
53
59
  SearchRange,
54
60
  } from "../api/types.js";
61
+ import { withheldSlugs } from "../api/types.js";
55
62
 
56
63
  /**
57
64
  * Value types a numeric range row is drawn for (`config.type`, the
@@ -66,7 +73,13 @@ export const RANGE_FEATURE_TYPES: readonly string[] = [
66
73
  /** One `r.<slug>` row of the filter panel. */
67
74
  export interface RangeGroup {
68
75
  readonly slug: string;
69
- /** The feature's display name (translated when it is a key), else the slug. */
76
+ /**
77
+ * What to write above the picker: the ANSWER's own caption first (0.16.0
78
+ * resolves it from the same definition a group heading comes from), the
79
+ * schema's display name next, this package's key for a core axis, and — only
80
+ * for a slug the URL constrains that none of them explain — the slug itself.
81
+ * {@link named} says which of those two worlds the row is in.
82
+ */
70
83
  readonly label: string;
71
84
  /** The schema entry behind the slug, when the host supplied one. */
72
85
  readonly feature: FeatureDef | undefined;
@@ -87,7 +100,10 @@ export interface RangeGroup {
87
100
  * has. Never set on a core axis — see {@link BuildRangeGroupsInput.ranges}.
88
101
  */
89
102
  readonly measured: boolean;
90
- /** Unit suffix the schema declares (`postfix`, or a convertible unit). */
103
+ /** What the numbers are measured in: the ANSWER's `unit` (the definition's
104
+ * `postfix`, or the BASE unit of a convertible family) when it states one,
105
+ * the schema's own affixes otherwise. Absent, never `""`, on an axis nobody
106
+ * gave a unit — a price among them. */
91
107
  readonly unit: string | undefined;
92
108
  /** `1` for an integer feature — a whole-number input for a whole number. */
93
109
  readonly step: number | undefined;
@@ -121,6 +137,31 @@ export interface RangeGroup {
121
137
  * carries a `postfix`, not a currency.
122
138
  */
123
139
  readonly currency: string | undefined;
140
+ /**
141
+ * Where this axis sits in the ONE sequence the panel draws — the answer's
142
+ * `facet_meta.ranges[<slug>].order`, numbered together with the groups'
143
+ * `facet_labels[<slug>].order` (stapel-search 0.16.0+).
144
+ *
145
+ * `undefined` means the answer stated no position (an older server, an axis
146
+ * the plan has no place for, or a row that came from the schema alone), and
147
+ * a panel then falls back to the band order it has always used: core axes
148
+ * first, then the groups, then the rest of the measurements. See
149
+ * `orderPanelItems`.
150
+ */
151
+ readonly order: number | undefined;
152
+ /**
153
+ * `true` when {@link label} is a name SOMEBODY GAVE the axis — the answer's
154
+ * own caption, the schema's `FeatureDef.name`, or this package's key for a
155
+ * core axis — and `false` when it is the storage slug standing in for a name
156
+ * nobody has.
157
+ *
158
+ * A row is only ever unlabelled when the URL already constrains the slug: a
159
+ * constraint must keep the control that removes it, even one captioned
160
+ * `power_w`. Every other unnamed axis is dropped before it reaches a
161
+ * surface, which is the client half of the rule stapel-search 0.16.0 states
162
+ * on the wire by WITHHOLDING an axis it could not caption.
163
+ */
164
+ readonly named: boolean;
124
165
  }
125
166
 
126
167
  /**
@@ -175,6 +216,24 @@ export interface BuildRangeGroupsInput {
175
216
  * that wants to draw a histogram over it.
176
217
  */
177
218
  readonly ranges?: FacetRangesMap;
219
+ /**
220
+ * `facet_meta.withheld` — the axes the server planned and then did NOT
221
+ * offer (stapel-search 0.16.0+).
222
+ *
223
+ * Only the rows whose `axis` is `"range"` are read here, and reading them is
224
+ * not optional: an axis withheld for `coverage` describes three of fifty-two
225
+ * documents and an axis withheld as `unlabelled` has no caption anyone could
226
+ * print, and the server leaving it out of `ranges` is only half the
227
+ * statement — the CATEGORY SCHEMA still names the same slug, so a rail that
228
+ * ignored this list would draw from the schema exactly the row the answer
229
+ * just declined to offer.
230
+ *
231
+ * The one exemption is a slug the URL constrains. The server promises never
232
+ * to withhold one (that would leave a filter applied with no control to undo
233
+ * it) and this module keeps the row anyway if it ever does, for the same
234
+ * reason it keeps a row the schema cannot explain.
235
+ */
236
+ readonly withheld?: readonly FacetWithheldAxis[];
178
237
  /** ISO 4217 code for the money axes, when the surface knows one. */
179
238
  readonly currency?: string;
180
239
  /** Translator for label keys (the schema's `name` is often one). */
@@ -279,6 +338,9 @@ export function buildRangeGroups(
279
338
  const core = new Set(input.coreRanges ?? []);
280
339
  const bounds = input.ranges;
281
340
  const reported = new Set(Object.keys(bounds ?? {}));
341
+ // The axes this answer planned and declined to offer — sparse, or with no
342
+ // caption anyone could print. Dropped unless the URL constrains them.
343
+ const withheld = new Set(withheldSlugs(input.withheld, "range"));
282
344
  const slugs: string[] = [...core];
283
345
  for (const feature of input.categoryFeatures ?? []) {
284
346
  // A core slug shadows a same-named attribute — which is exactly what the
@@ -300,43 +362,92 @@ export function buildRangeGroups(
300
362
  if (!slugs.includes(slug)) slugs.push(slug);
301
363
  }
302
364
 
303
- return slugs.map((slug) => {
304
- const isCore = core.has(slug);
305
- const feature = isCore ? undefined : bySlug.get(slug);
306
- const config = feature === undefined ? {} : featureConfig(feature);
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"]);
314
- return {
315
- slug,
316
- label: isCore
317
- ? translate(input.t, coreRangeLabelKey(slug))
318
- : feature === undefined
319
- ? slug
320
- : translate(input.t, featureName(feature)),
321
- feature,
322
- from: applied?.from,
323
- to: applied?.to,
324
- min,
325
- max,
326
- measured,
327
- // A core money axis carries a CURRENCY, not a unit suffix: "₽" is
328
- // formatted from the code for the reader's locale, a unit suffix is a literal
329
- // the category author typed.
330
- unit: isCore
331
- ? undefined
332
- : (str(config["postfix"]) ?? str(config["unit_m"]) ?? str(config["unit_i"])),
333
- step: !isCore && isIntegerAxis(feature, min, max, measured) ? 1 : undefined,
334
- picker: isCore ? undefined : pickerValues(feature, min, max, measured),
335
- active: applied !== undefined,
336
- core: isCore,
337
- currency: isCore ? str(input.currency) : undefined,
338
- };
339
- });
365
+ return slugs
366
+ .map((slug): RangeGroup => {
367
+ const isCore = core.has(slug);
368
+ const feature = isCore ? undefined : bySlug.get(slug);
369
+ const config = feature === undefined ? {} : featureConfig(feature);
370
+ const applied: SearchRange | undefined = input.state.ranges[slug];
371
+ // The ANSWER's row for the slug. Read for its caption on every axis
372
+ // including a core one the server names `price` out of its own
373
+ // `CORE_RANGE_LABELS` and for its BOUNDS on an attribute axis only:
374
+ // see `BuildRangeGroupsInput.ranges` for why the price input keeps its
375
+ // own ends.
376
+ const axis = bounds?.[slug];
377
+ const measured = !isCore && axis !== undefined;
378
+ const min = measured ? axis.min : num(config["min"]);
379
+ const max = measured ? axis.max : num(config["max"]);
380
+ // The caption, in the order of who is entitled to give one:
381
+ // 1. the ANSWER. `label` is resolved server-side from the same
382
+ // definition a group heading comes from, it is present for a core
383
+ // axis the client has no definition for, and it is the only source
384
+ // a host that threaded no schema has at all.
385
+ // 2. the SCHEMA's own `name`, for a server too old to send one.
386
+ // 3. this package's key for a core axis, same reason as (2).
387
+ // and never the slug, which is storage. An axis none of the three name
388
+ // is dropped below.
389
+ const answered = str(axis?.label);
390
+ const named =
391
+ answered !== undefined || isCore || feature !== undefined;
392
+ const label =
393
+ answered !== undefined
394
+ ? // `label_translatable: false` is literal text the catalogue wrote
395
+ // and must not be looked up; absent means the server did not say,
396
+ // and a lookup that misses returns the string unchanged.
397
+ axis?.label_translatable === false
398
+ ? answered
399
+ : translate(input.t, answered)
400
+ : isCore
401
+ ? translate(input.t, coreRangeLabelKey(slug))
402
+ : feature === undefined
403
+ ? slug
404
+ : translate(input.t, featureName(feature));
405
+ return {
406
+ slug,
407
+ label,
408
+ named,
409
+ feature,
410
+ from: applied?.from,
411
+ to: applied?.to,
412
+ min,
413
+ max,
414
+ measured,
415
+ // The unit, same precedence and same reason: the answer resolved it
416
+ // off the definition (a `convertible_unit`'s BASE unit, which the
417
+ // client cannot work out from `unit_m`/`unit_i` alone), the schema's
418
+ // own affixes second. Translated, because the server states plainly
419
+ // that it never translates one and the string may be a key.
420
+ //
421
+ // A core money axis carries a CURRENCY, not a unit suffix: "₽" is
422
+ // formatted from the code for the reader's locale, a unit suffix is a
423
+ // literal the category author typed.
424
+ unit: isCore
425
+ ? undefined
426
+ : str(axis?.unit) !== undefined
427
+ ? translate(input.t, str(axis?.unit) ?? "")
428
+ : (str(config["postfix"]) ?? str(config["unit_m"]) ?? str(config["unit_i"])),
429
+ step: !isCore && isIntegerAxis(feature, min, max, measured) ? 1 : undefined,
430
+ picker: isCore ? undefined : pickerValues(feature, min, max, measured),
431
+ active: applied !== undefined,
432
+ core: isCore,
433
+ currency: isCore ? str(input.currency) : undefined,
434
+ order: typeof axis?.order === "number" ? axis.order : undefined,
435
+ };
436
+ })
437
+ .filter((group) => {
438
+ // A CONSTRAINT ALWAYS KEEPS ITS CONTROL. Everything below removes a row
439
+ // the reader has not used; a row they have is never one of them.
440
+ if (group.active) return true;
441
+ // The server planned this axis and declined to offer it — sparse, or
442
+ // unnameable. The schema still declares the same slug, so without this
443
+ // the rail would draw exactly the row the answer withheld.
444
+ if (withheld.has(group.slug)) return false;
445
+ // Nobody named it. A from/to picker captioned `kilometrage` is a control
446
+ // whose meaning a reader has to guess out of the numbers inside it,
447
+ // which is not a filter — the same judgement stapel-search 0.16.0 makes
448
+ // on the wire, made again here for the rows that come from the schema.
449
+ return group.named;
450
+ });
340
451
  }
341
452
 
342
453
  /**