@stapel/search-react 0.31.0 → 0.32.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 (89) hide show
  1. package/CHANGELOG.md +103 -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 +27 -0
  13. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  14. package/dist/default/FacetPanelPane.js +100 -19
  15. package/dist/default/FacetPanelPane.js.map +1 -1
  16. package/dist/default/FilterChips.d.ts +30 -0
  17. package/dist/default/FilterChips.d.ts.map +1 -1
  18. package/dist/default/FilterChips.js +35 -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 +88 -3
  65. package/dist/state/ranges.d.ts.map +1 -1
  66. package/dist/state/ranges.js +96 -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 +257 -124
  77. package/src/default/FilterChips.tsx +51 -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 +209 -47
@@ -168,6 +168,21 @@ export interface SearchStateBag {
168
168
  toggleFilter(slug: string, value: string): void;
169
169
  setFilter(slug: string, values: readonly string[]): void;
170
170
  setRange(slug: string, range: SearchRange | null): void;
171
+ /**
172
+ * Write SEVERAL ranges at once — the bulk half of {@link setRange}, and the
173
+ * same relationship `setFilter` has to `toggleFilter`.
174
+ *
175
+ * Every setter here closes over the state it edits, so two of them in one
176
+ * tick both fold their change into the SAME starting value and the second
177
+ * one wins. That is invisible while one control writes one axis; it is the
178
+ * whole story for a filter panel with one "Apply" over a price row and
179
+ * a mileage row, where two calls would have applied the mileage and quietly
180
+ * dropped the price. This folds them all into one state and commits once —
181
+ * one request, one history entry.
182
+ *
183
+ * `null` for a slug clears it, exactly as it does on {@link setRange}.
184
+ */
185
+ setRanges(ranges: Readonly<Record<string, SearchRange | null>>): void;
171
186
  setGeo(geo: SearchGeo | null): void;
172
187
  setLimit(limit: number | null): void;
173
188
  clearAll(): void;
@@ -506,6 +521,14 @@ export function SearchStateProvider(
506
521
  apply(setFilterValues(state, slug, values), historyOptions("filter")),
507
522
  setRange: (slug, range) =>
508
523
  apply(setRangeValue(state, slug, range), historyOptions("range")),
524
+ setRanges: (ranges) =>
525
+ apply(
526
+ Object.entries(ranges).reduce(
527
+ (next, [slug, range]) => setRangeValue(next, slug, range),
528
+ state
529
+ ),
530
+ historyOptions("range")
531
+ ),
509
532
  setGeo: (geo) => {
510
533
  apply(patchSearchState(state, { geo }), historyOptions("geo"));
511
534
  },
package/src/index.ts CHANGED
@@ -49,16 +49,21 @@ export {
49
49
  SUGGEST_DEGRADED_CATEGORIES,
50
50
  SUGGEST_DEGRADED_ROLLUP,
51
51
  suggestTerms,
52
+ withheldSlugs,
52
53
  } from "./api/types.js";
53
54
  export type {
55
+ FacetAxisKind,
54
56
  FacetCategoryCount,
55
57
  FacetLabels,
56
58
  FacetLabelsMap,
57
59
  FacetMeta,
60
+ FacetRangeAxis,
58
61
  FacetRangeBounds,
59
62
  FacetRangesMap,
60
63
  FacetSelection,
64
+ FacetWithheldAxis,
61
65
  FacetWithheldGroup,
66
+ FacetWithheldReason,
62
67
  RankingResponse,
63
68
  Scorer,
64
69
  SearchDegradation,
@@ -156,6 +161,9 @@ export {
156
161
  } from "./state/ranges.js";
157
162
  export type { BuildRangeGroupsInput, RangeGroup } from "./state/ranges.js";
158
163
 
164
+ export { orderPanelItems } from "./state/panel.js";
165
+ export type { OrderPanelItemsInput, PanelItem } from "./state/panel.js";
166
+
159
167
  export {
160
168
  SEARCH_BOX_DEBOUNCE_MS,
161
169
  SEARCH_BOX_SUGGEST_DEBOUNCE_MS,
@@ -239,6 +239,19 @@ export interface FacetGroup {
239
239
  * hold. `undefined` means nobody said, not "inline".
240
240
  */
241
241
  readonly vocabulary?: string;
242
+ /**
243
+ * Where this group sits in the ONE sequence the panel draws — the answer's
244
+ * `facet_labels[<slug>].order`, numbered together with the numeric axes'
245
+ * `facet_meta.ranges[<slug>].order` (stapel-search 0.16.0+).
246
+ *
247
+ * The point of the field is that a group and a range are two ways of
248
+ * narrowing one authored feature, so they share a scale: drawn sorted by it,
249
+ * "Price" and "Year" land among the makes and models instead of above and
250
+ * below all of them. `undefined` (an older server, or a group the plan has
251
+ * no place for, which the wire says as `null`) means "no stated position",
252
+ * and `orderPanelItems` sorts those after everything that has one.
253
+ */
254
+ readonly order?: number | undefined;
242
255
  /** The group's heading: the answer's own `label`, else the feature's
243
256
  * display name, else — with a dev warning — the raw slug. */
244
257
  readonly label: string;
@@ -606,6 +619,19 @@ function optionalVocabulary(
606
619
  return vocabulary === undefined ? {} : { vocabulary };
607
620
  }
608
621
 
622
+ /**
623
+ * The group's stated place in the panel, when the answer states one.
624
+ *
625
+ * `null` on the wire is the server's own "the plan has no position for this
626
+ * group", which reads exactly like a server too old to state one: both mean
627
+ * "sort me after everything that does have a place". So both spread to
628
+ * nothing, and `FacetGroup.order` is a number or absent — never a `null` a
629
+ * comparator would have to remember to special-case.
630
+ */
631
+ function optionalOrder(order: number | null | undefined): { order?: number } {
632
+ return typeof order === "number" ? { order } : {};
633
+ }
634
+
609
635
  function resolveVocabulary(
610
636
  input: BuildFacetGroupsInput,
611
637
  feature: FeatureDef | undefined,
@@ -813,6 +839,7 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
813
839
  return {
814
840
  slug,
815
841
  urlKey: keys.write[slug] ?? slug,
842
+ ...optionalOrder(input.facetLabels?.[slug]?.order),
816
843
  ...optionalVocabulary(resolveVocabulary(input, feature, slug)),
817
844
  ...resolveGroupLabel(input, feature, slug),
818
845
  feature,
@@ -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`
@@ -34,6 +39,15 @@
34
39
  *
35
40
  * Plus every slug the URL already carries a range for.
36
41
  *
42
+ * ── One axis, one control ─────────────────────────────────────────────────
43
+ *
44
+ * A slug the answer COUNTED already has a bucket list on the rail, and a
45
+ * from/to picker over the same field is a second control writing the same
46
+ * filter — ticking 2019 and typing 2015..2020 reach the same URL by different
47
+ * routes and neither shows what the other did. The counted half wins because
48
+ * it is the one with evidence in it (a number per value, not two ends). See
49
+ * {@link BuildRangeGroupsInput.countedFacets} and its `bothAxes` opt-out.
50
+ *
37
51
  * That last clause is the same rule `buildFacetGroups` follows for a filtered
38
52
  * slug that fell out of the plan: a constraint that is ACTIVE must always have
39
53
  * a control that removes it, even when the schema no longer explains it.
@@ -49,9 +63,11 @@ import { featureConfig, featureName, featureType } from "@stapel/attributes-reac
49
63
  import type { FeatureDef } from "@stapel/attributes-react";
50
64
  import type {
51
65
  FacetRangesMap,
66
+ FacetWithheldAxis,
52
67
  SearchQueryState,
53
68
  SearchRange,
54
69
  } from "../api/types.js";
70
+ import { withheldSlugs } from "../api/types.js";
55
71
 
56
72
  /**
57
73
  * Value types a numeric range row is drawn for (`config.type`, the
@@ -66,7 +82,13 @@ export const RANGE_FEATURE_TYPES: readonly string[] = [
66
82
  /** One `r.<slug>` row of the filter panel. */
67
83
  export interface RangeGroup {
68
84
  readonly slug: string;
69
- /** The feature's display name (translated when it is a key), else the slug. */
85
+ /**
86
+ * What to write above the picker: the ANSWER's own caption first (0.16.0
87
+ * resolves it from the same definition a group heading comes from), the
88
+ * schema's display name next, this package's key for a core axis, and — only
89
+ * for a slug the URL constrains that none of them explain — the slug itself.
90
+ * {@link named} says which of those two worlds the row is in.
91
+ */
70
92
  readonly label: string;
71
93
  /** The schema entry behind the slug, when the host supplied one. */
72
94
  readonly feature: FeatureDef | undefined;
@@ -87,7 +109,10 @@ export interface RangeGroup {
87
109
  * has. Never set on a core axis — see {@link BuildRangeGroupsInput.ranges}.
88
110
  */
89
111
  readonly measured: boolean;
90
- /** Unit suffix the schema declares (`postfix`, or a convertible unit). */
112
+ /** What the numbers are measured in: the ANSWER's `unit` (the definition's
113
+ * `postfix`, or the BASE unit of a convertible family) when it states one,
114
+ * the schema's own affixes otherwise. Absent, never `""`, on an axis nobody
115
+ * gave a unit — a price among them. */
91
116
  readonly unit: string | undefined;
92
117
  /** `1` for an integer feature — a whole-number input for a whole number. */
93
118
  readonly step: number | undefined;
@@ -121,6 +146,31 @@ export interface RangeGroup {
121
146
  * carries a `postfix`, not a currency.
122
147
  */
123
148
  readonly currency: string | undefined;
149
+ /**
150
+ * Where this axis sits in the ONE sequence the panel draws — the answer's
151
+ * `facet_meta.ranges[<slug>].order`, numbered together with the groups'
152
+ * `facet_labels[<slug>].order` (stapel-search 0.16.0+).
153
+ *
154
+ * `undefined` means the answer stated no position (an older server, an axis
155
+ * the plan has no place for, or a row that came from the schema alone), and
156
+ * a panel then falls back to the band order it has always used: core axes
157
+ * first, then the groups, then the rest of the measurements. See
158
+ * `orderPanelItems`.
159
+ */
160
+ readonly order: number | undefined;
161
+ /**
162
+ * `true` when {@link label} is a name SOMEBODY GAVE the axis — the answer's
163
+ * own caption, the schema's `FeatureDef.name`, or this package's key for a
164
+ * core axis — and `false` when it is the storage slug standing in for a name
165
+ * nobody has.
166
+ *
167
+ * A row is only ever unlabelled when the URL already constrains the slug: a
168
+ * constraint must keep the control that removes it, even one captioned
169
+ * `power_w`. Every other unnamed axis is dropped before it reaches a
170
+ * surface, which is the client half of the rule stapel-search 0.16.0 states
171
+ * on the wire by WITHHOLDING an axis it could not caption.
172
+ */
173
+ readonly named: boolean;
124
174
  }
125
175
 
126
176
  /**
@@ -175,6 +225,57 @@ export interface BuildRangeGroupsInput {
175
225
  * that wants to draw a histogram over it.
176
226
  */
177
227
  readonly ranges?: FacetRangesMap;
228
+ /**
229
+ * `facet_meta.withheld` — the axes the server planned and then did NOT
230
+ * offer (stapel-search 0.16.0+).
231
+ *
232
+ * Only the rows whose `axis` is `"range"` are read here, and reading them is
233
+ * not optional: an axis withheld for `coverage` describes three of fifty-two
234
+ * documents and an axis withheld as `unlabelled` has no caption anyone could
235
+ * print, and the server leaving it out of `ranges` is only half the
236
+ * statement — the CATEGORY SCHEMA still names the same slug, so a rail that
237
+ * ignored this list would draw from the schema exactly the row the answer
238
+ * just declined to offer.
239
+ *
240
+ * The one exemption is a slug the URL constrains. The server promises never
241
+ * to withhold one (that would leave a filter applied with no control to undo
242
+ * it) and this module keeps the row anyway if it ever does, for the same
243
+ * reason it keeps a row the schema cannot explain.
244
+ */
245
+ readonly withheld?: readonly FacetWithheldAxis[];
246
+ /**
247
+ * The slugs this answer COUNTED (`FacetPanelBag.counted`) — the axes that
248
+ * already have a bucket list on the rail.
249
+ *
250
+ * ONE AXIS, ONE CONTROL. A slug can be a choice and a measurement at once —
251
+ * an imported `year` is typed as a vocabulary in the catalogue and measured
252
+ * by the plan — and until now the panel drew BOTH: a checkbox group of
253
+ * years and, further down the same rail, a from/to picker over the same
254
+ * field. Measured on a live storefront, which worked around it by removing
255
+ * the range row itself from the rendered list. Two controls over one filter
256
+ * is not a choice a reader can make sense of: ticking 2019 and typing
257
+ * 2015..2020 write the same URL by two different routes, and neither
258
+ * control shows what the other did.
259
+ *
260
+ * The GROUP wins, because it is the half backed by evidence: it carries a
261
+ * count per value, so a reader sees what is actually there, where a picker
262
+ * only offers ends. A host that genuinely wants both says so with
263
+ * {@link bothAxes}.
264
+ *
265
+ * A CONSTRAINT still keeps its control: a slug the URL carries a range for
266
+ * is never dropped, whatever else is on the rail — the same rule the
267
+ * withheld list is exempt from.
268
+ */
269
+ readonly countedFacets?: readonly string[];
270
+ /**
271
+ * Draw a range row for a slug that already has a counted facet group
272
+ * ({@link countedFacets}) instead of leaving it to the group.
273
+ *
274
+ * The opt-out, for a surface where the two really are different questions —
275
+ * a year picker beside a decade grouping, say. Off by default: the shipped
276
+ * defect was the panel doing this without being asked.
277
+ */
278
+ readonly bothAxes?: boolean;
178
279
  /** ISO 4217 code for the money axes, when the surface knows one. */
179
280
  readonly currency?: string;
180
281
  /** Translator for label keys (the schema's `name` is often one). */
@@ -279,6 +380,13 @@ export function buildRangeGroups(
279
380
  const core = new Set(input.coreRanges ?? []);
280
381
  const bounds = input.ranges;
281
382
  const reported = new Set(Object.keys(bounds ?? {}));
383
+ // The axes this answer planned and declined to offer — sparse, or with no
384
+ // caption anyone could print. Dropped unless the URL constrains them.
385
+ const withheld = new Set(withheldSlugs(input.withheld, "range"));
386
+ // The axes that already have a bucket list. See `countedFacets`: one axis
387
+ // gets one control, and the counted half is the one with evidence in it.
388
+ const counted =
389
+ input.bothAxes === true ? new Set<string>() : new Set(input.countedFacets ?? []);
282
390
  const slugs: string[] = [...core];
283
391
  for (const feature of input.categoryFeatures ?? []) {
284
392
  // A core slug shadows a same-named attribute — which is exactly what the
@@ -300,43 +408,97 @@ export function buildRangeGroups(
300
408
  if (!slugs.includes(slug)) slugs.push(slug);
301
409
  }
302
410
 
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
- });
411
+ return slugs
412
+ .map((slug): RangeGroup => {
413
+ const isCore = core.has(slug);
414
+ const feature = isCore ? undefined : bySlug.get(slug);
415
+ const config = feature === undefined ? {} : featureConfig(feature);
416
+ const applied: SearchRange | undefined = input.state.ranges[slug];
417
+ // The ANSWER's row for the slug. Read for its caption on every axis
418
+ // including a core one the server names `price` out of its own
419
+ // `CORE_RANGE_LABELS` and for its BOUNDS on an attribute axis only:
420
+ // see `BuildRangeGroupsInput.ranges` for why the price input keeps its
421
+ // own ends.
422
+ const axis = bounds?.[slug];
423
+ const measured = !isCore && axis !== undefined;
424
+ const min = measured ? axis.min : num(config["min"]);
425
+ const max = measured ? axis.max : num(config["max"]);
426
+ // The caption, in the order of who is entitled to give one:
427
+ // 1. the ANSWER. `label` is resolved server-side from the same
428
+ // definition a group heading comes from, it is present for a core
429
+ // axis the client has no definition for, and it is the only source
430
+ // a host that threaded no schema has at all.
431
+ // 2. the SCHEMA's own `name`, for a server too old to send one.
432
+ // 3. this package's key for a core axis, same reason as (2).
433
+ // and never the slug, which is storage. An axis none of the three name
434
+ // is dropped below.
435
+ const answered = str(axis?.label);
436
+ const named =
437
+ answered !== undefined || isCore || feature !== undefined;
438
+ const label =
439
+ answered !== undefined
440
+ ? // `label_translatable: false` is literal text the catalogue wrote
441
+ // and must not be looked up; absent means the server did not say,
442
+ // and a lookup that misses returns the string unchanged.
443
+ axis?.label_translatable === false
444
+ ? answered
445
+ : translate(input.t, answered)
446
+ : isCore
447
+ ? translate(input.t, coreRangeLabelKey(slug))
448
+ : feature === undefined
449
+ ? slug
450
+ : translate(input.t, featureName(feature));
451
+ return {
452
+ slug,
453
+ label,
454
+ named,
455
+ feature,
456
+ from: applied?.from,
457
+ to: applied?.to,
458
+ min,
459
+ max,
460
+ measured,
461
+ // The unit, same precedence and same reason: the answer resolved it
462
+ // off the definition (a `convertible_unit`'s BASE unit, which the
463
+ // client cannot work out from `unit_m`/`unit_i` alone), the schema's
464
+ // own affixes second. Translated, because the server states plainly
465
+ // that it never translates one and the string may be a key.
466
+ //
467
+ // A core money axis carries a CURRENCY, not a unit suffix: "₽" is
468
+ // formatted from the code for the reader's locale, a unit suffix is a
469
+ // literal the category author typed.
470
+ unit: isCore
471
+ ? undefined
472
+ : str(axis?.unit) !== undefined
473
+ ? translate(input.t, str(axis?.unit) ?? "")
474
+ : (str(config["postfix"]) ?? str(config["unit_m"]) ?? str(config["unit_i"])),
475
+ step: !isCore && isIntegerAxis(feature, min, max, measured) ? 1 : undefined,
476
+ picker: isCore ? undefined : pickerValues(feature, min, max, measured),
477
+ active: applied !== undefined,
478
+ core: isCore,
479
+ currency: isCore ? str(input.currency) : undefined,
480
+ order: typeof axis?.order === "number" ? axis.order : undefined,
481
+ };
482
+ })
483
+ .filter((group) => {
484
+ // A CONSTRAINT ALWAYS KEEPS ITS CONTROL. Everything below removes a row
485
+ // the reader has not used; a row they have is never one of them.
486
+ if (group.active) return true;
487
+ // The server planned this axis and declined to offer it — sparse, or
488
+ // unnameable. The schema still declares the same slug, so without this
489
+ // the rail would draw exactly the row the answer withheld.
490
+ if (withheld.has(group.slug)) return false;
491
+ // The same axis is already a bucket list on this rail. A CORE column is
492
+ // exempt: it is not part of the category plan (the server reserves the
493
+ // slug), so a same-named group is a different question and the price
494
+ // input must not disappear because one arrived.
495
+ if (!group.core && counted.has(group.slug)) return false;
496
+ // Nobody named it. A from/to picker captioned `kilometrage` is a control
497
+ // whose meaning a reader has to guess out of the numbers inside it,
498
+ // which is not a filter — the same judgement stapel-search 0.16.0 makes
499
+ // on the wire, made again here for the rows that come from the schema.
500
+ return group.named;
501
+ });
340
502
  }
341
503
 
342
504
  /**