@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
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
- # @stapel/search-react 0.31.0
1
+ # @stapel/search-react 0.32.1
2
2
 
3
- Headless React flow pair for stapel-search (contract >=0.15 <0.16) — business + state, zero visual opinion.
3
+ Headless React flow pair for stapel-search (contract >=0.16 <0.17) — business + state, zero visual opinion.
4
4
  Built on @stapel/core: typed client + StapelApiError envelope, auth token refresh,
5
5
  verification-403 interception, i18n engine, analytics facade, TanStack Query layer.
6
6
 
@@ -85,10 +85,10 @@ const { tracked } = useTracked();
85
85
  - search.language-select → <LanguageSelect> [offered|from-a-link] demo/LanguageSelect.demo.tsx
86
86
  - search.location-summary → <LocationSummaryLine> [everywhere|placed|narrowed|long-place|wide] demo/LocationSummaryLine.demo.tsx
87
87
  - search.other-categories-line → <OtherCategoriesLine> [line|narrow|empty] demo/OtherCategoriesLine.demo.tsx
88
- - search.page → <SearchPage> [desktop|phone|unreadable-link] demo/SearchPage.demo.tsx
88
+ - search.page → <SearchPage> [desktop|phone|catalogue-leaf|unreadable-link] demo/SearchPage.demo.tsx
89
89
  - search.page-size-select → <PageSizeSelect> [ladder|off-ladder] demo/PageSizeSelect.demo.tsx
90
90
  - search.partition-chips → <PartitionChips> [parent|child] demo/PartitionChips.demo.tsx
91
- - search.popular-values → <PopularValues> [desktop|narrow-column] demo/PopularValues.demo.tsx
91
+ - search.popular-values → <PopularValues> [desktop|responsive|narrow-column] demo/PopularValues.demo.tsx
92
92
  - search.range-filter-row → <RangeFilterRow> [untouched|applied] demo/RangeFilterRow.demo.tsx
93
93
  - search.ranking-pane → <RankingDisclosurePane> [desktop|phone] demo/RankingDisclosurePane.demo.tsx
94
94
  - search.result-card → <SearchResultCard> [gallery|promoted|unresolved|plain] demo/SearchResultCard.demo.tsx
package/manifest.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-manifest.mjs — do not edit; drift-gated (pnpm gen:manifest:check)",
3
3
  "package": "@stapel/search-react",
4
- "version": "0.31.0",
4
+ "version": "0.32.1",
5
5
  "backend": {
6
6
  "module": "stapel-search",
7
- "contract": ">=0.15 <0.16"
7
+ "contract": ">=0.16 <0.17"
8
8
  },
9
9
  "layers": [
10
10
  "api",
@@ -580,6 +580,7 @@
580
580
  "variants": [
581
581
  "desktop",
582
582
  "phone",
583
+ "catalogue-leaf",
583
584
  "unreadable-link"
584
585
  ],
585
586
  "source": "demo/SearchPage.demo.tsx"
@@ -619,6 +620,7 @@
619
620
  ],
620
621
  "variants": [
621
622
  "desktop",
623
+ "responsive",
622
624
  "narrow-column"
623
625
  ],
624
626
  "source": "demo/PopularValues.demo.tsx"
@@ -1009,6 +1011,7 @@
1009
1011
  "navEntries",
1010
1012
  "offerableCategories",
1011
1013
  "orderFacetGroupsBySchema",
1014
+ "orderPanelItems",
1012
1015
  "otherCategoryLeaf",
1013
1016
  "ownsParam",
1014
1017
  "parseDegradations",
@@ -1043,12 +1046,14 @@
1043
1046
  "useSearchRuntime",
1044
1047
  "useSearchState",
1045
1048
  "useSuggest",
1049
+ "withheldSlugs",
1046
1050
  "writeSearchState"
1047
1051
  ],
1048
1052
  "types": [
1049
1053
  "BuildFacetGroupsInput",
1050
1054
  "BuildRangeGroupsInput",
1051
1055
  "CreateSearchRuntimeOptions",
1056
+ "FacetAxisKind",
1052
1057
  "FacetCategoryCount",
1053
1058
  "FacetGroup",
1054
1059
  "FacetKeyMap",
@@ -1060,13 +1065,18 @@
1060
1065
  "FacetMeta",
1061
1066
  "FacetOption",
1062
1067
  "FacetPanelBag",
1068
+ "FacetRangeAxis",
1063
1069
  "FacetRangeBounds",
1064
1070
  "FacetRangesMap",
1065
1071
  "FacetSelection",
1072
+ "FacetWithheldAxis",
1066
1073
  "FacetWithheldGroup",
1074
+ "FacetWithheldReason",
1067
1075
  "HistoryMode",
1076
+ "OrderPanelItemsInput",
1068
1077
  "OtherCategoriesBag",
1069
1078
  "OtherCategoryRow",
1079
+ "PanelItem",
1070
1080
  "ParseSearchStateOptions",
1071
1081
  "ParsedSearchState",
1072
1082
  "RangeAxisMemory",
package/nav-manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@stapel/search-react",
3
- "version": "0.31.0",
3
+ "version": "0.32.1",
4
4
  "entries": [
5
5
  {
6
6
  "id": "search.results",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stapel/search-react",
3
- "version": "0.31.0",
3
+ "version": "0.32.1",
4
4
  "description": "Headless React pair for stapel-search: a typed query client, TanStack Query hooks, and a URL-first state codec that makes a search shareable by construction (filters, ranges, geo, sort and the keyset cursor all live in the query string). Drill-down facets rendered with their remaining counts and with the server's own honesty flags — approximate, skipped, degraded — never swallowed; keyset pagination with the window refusal named; DSA Art. 26 `promoted` marking carried into every card slot and the P2B Art. 5 ranking disclosure exposed as data. Zero visual opinion in the main entry; an opt-in /default subpath ships the antd skin, and /router binds the codec to react-router's useSearchParams.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -52,14 +52,14 @@
52
52
  ],
53
53
  "size-limit": [
54
54
  {
55
- "name": "index — the headless pair (client + hooks + URL codec + facet model incl. the facetability rule, the label-source order, the across-alphabets prefix matcher, the SCHEMA-order comparator that puts a category's required axes first and the bounded-integer picker list): no antd, no react-router. Raised 12 → 12.5 KB for the rail-order comparator, the shared drawability predicate (one rule for the rail and the chip row) and the picker bounds. Raised 12.5 → 13 KB for `useOtherCategories`, which reads the sections an answer is made of out of `facet_meta.categories` — the read that replaced a storefront's second request, then 13 → 13.5 KB for the SHORT FEATURE KEYS: the two-direction `url_key` map (built with the server's own collision rules, so an ambiguous short form keeps the slug on both sides), its publication seam from the answer up to the state provider, and the vocabulary-backed predicate the drawability rule and the dictionary shape now share, then 13.5 → 13.75 KB for the documented history policy (`DEFAULT_HISTORY_MODE`, one push/replace table every mutator follows instead of an ad hoc boolean per call) and the codec omitting `type`/`sort`/`limit` from the address when they equal the default a host declared, then 13.75 → 14 KB for the MEASURED range axes: `facet_meta.ranges` on the envelope, the bounds/picker rule that prefers what an answer measured over what a catalogue declares (and draws a row for a vocabulary-backed numeric axis the schema types as a choice), and the per-category axis memory the rail reserves its box from",
55
+ "name": "index — the headless pair (client + hooks + URL codec + facet model incl. the facetability rule, the label-source order, the across-alphabets prefix matcher, the SCHEMA-order comparator that puts a category's required axes first and the bounded-integer picker list): no antd, no react-router. Raised 12 → 12.5 KB for the rail-order comparator, the shared drawability predicate (one rule for the rail and the chip row) and the picker bounds. Raised 12.5 → 13 KB for `useOtherCategories`, which reads the sections an answer is made of out of `facet_meta.categories` — the read that replaced a storefront's second request, then 13 → 13.5 KB for the SHORT FEATURE KEYS: the two-direction `url_key` map (built with the server's own collision rules, so an ambiguous short form keeps the slug on both sides), its publication seam from the answer up to the state provider, and the vocabulary-backed predicate the drawability rule and the dictionary shape now share, then 13.5 → 13.75 KB for the documented history policy (`DEFAULT_HISTORY_MODE`, one push/replace table every mutator follows instead of an ad hoc boolean per call) and the codec omitting `type`/`sort`/`limit` from the address when they equal the default a host declared, then 13.75 → 14 KB for the MEASURED range axes: `facet_meta.ranges` on the envelope, the bounds/picker rule that prefers what an answer measured over what a catalogue declares (and draws a row for a vocabulary-backed numeric axis the schema types as a choice), and the per-category axis memory the rail reserves its box from, then 14 → 14.5 KB for ONE PANEL and the named axis: a RangeAxis carrying its own label/unit/order instead of two bare numbers, the withheld rows that say which axis was dropped and why, `orderPanelItems` merging groups and ranges into a single sequence, and `setRanges` committing a whole panel in one state change. Measured with dependencies held constant, this package's src at the commit before that work and then at it: 13.95 -> 14.28 KB. 330 B is what it costs to stop drawing `kilometrage` as a heading over a picker — an axis nobody named is now dropped rather than captioned from its slug, and the model has to carry the name to know that",
56
56
  "path": "dist/index.js",
57
- "limit": "14 KB"
57
+ "limit": "14.5 KB"
58
58
  },
59
59
  {
60
- "name": "default — the antd skin (query box + a typeahead that offers CATEGORY destinations with their live counts, filters incl. ranges/geo/category slots + the phone chip row with its leading category chip and the location summary row + the rail's evidence-ranked disclosure groups, panel search and sticky count/clear footer, results incl. the view switch, the card photo GALLERY as a SkinCarousel strip, the empty state's derived exits, ranking, the dictionary control for a vocabulary facet, the popular-values block, the partition row in both its chip and segmented variants, the select-style dictionary FIELD the desktop rail opens, the from/to pickers a bounded integer axis draws and the rail's own scrollbar sheet) must stay out of the main bundle. Raised 24.5 → 25.5 KB for those four controls, then 25.5 → 26.25 KB for the chip row's APPLIED mode — one chip per applied value and per numeric bound, each removing that one constraint beside a rail that is already on screen (a storefront was carrying its own copy of it), then 26.25 → 27 KB for the \"search in other categories\" LINE — which is a net deletion on the page that mounts it, replacing a full-width block of one row per section that arrived after the results and pushed them, then 27 → 27.75 KB for the two frames the page now tells apart: the dictionary FIELD reaching `<SearchPage>` at all (per-layout default, field in the rail and inline in the sheet) and the footer bar being static in a column and sticky in a sheet, where it used to be pinned over the last two groups everywhere, then 27.75 → 28.5 KB for `categoryHref` on the other-categories line — a real `<a href>` per row with a modifier-click left to the browser, instead of a `<button>` with no address a person could open in a new tab, then 28.5 → 29.25 KB for the phone's dictionary SHEET — a trigger row over the shared `SkinPickerSheet` with a recommended band, an alphabetical rest paged fifty at a time and a draft committed in one write, which is the control the composer's vocabulary picker already was while the buyer got a wall of checkboxes",
60
+ "name": "default — the antd skin (query box + a typeahead that offers CATEGORY destinations with their live counts, filters incl. ranges/geo/category slots + the phone chip row with its leading category chip and the location summary row + the rail's evidence-ranked disclosure groups, panel search and sticky count/clear footer, results incl. the view switch, the card photo GALLERY as a SkinCarousel strip, the empty state's derived exits, ranking, the dictionary control for a vocabulary facet, the popular-values block, the partition row in both its chip and segmented variants, the select-style dictionary FIELD the desktop rail opens, the from/to pickers a bounded integer axis draws and the rail's own scrollbar sheet) must stay out of the main bundle. Raised 24.5 → 25.5 KB for those four controls, then 25.5 → 26.25 KB for the chip row's APPLIED mode — one chip per applied value and per numeric bound, each removing that one constraint beside a rail that is already on screen (a storefront was carrying its own copy of it), then 26.25 → 27 KB for the \"search in other categories\" LINE — which is a net deletion on the page that mounts it, replacing a full-width block of one row per section that arrived after the results and pushed them, then 27 → 27.75 KB for the two frames the page now tells apart: the dictionary FIELD reaching `<SearchPage>` at all (per-layout default, field in the rail and inline in the sheet) and the footer bar being static in a column and sticky in a sheet, where it used to be pinned over the last two groups everywhere, then 27.75 → 28.5 KB for `categoryHref` on the other-categories line — a real `<a href>` per row with a modifier-click left to the browser, instead of a `<button>` with no address a person could open in a new tab, then 28.5 → 29.25 KB for the phone's dictionary SHEET — a trigger row over the shared `SkinPickerSheet` with a recommended band, an alphabetical rest paged fifty at a time and a draft committed in one write, which is the control the composer's vocabulary picker already was while the buyer got a wall of checkboxes, then 29.25 → 30.5 KB for the panel that draws all of it: groups and ranges as one ordered sequence with one «Apply» for the panel rather than one per row, the empty-group heading rule, `categoryFilter={false}` and the `resultsLead` slot for a page reached by walking the catalogue, `PopularValues columns=\\\"responsive\\\"` on a container query, `SortSelect` annotating a blocked option at every width, a loading arm that covers the whole pane (0.34 CLS on a live host), and the chip row reserving its own box (a further 0.045). Measured with dependencies held constant, this package's src at the commit before that work and then at it: 29.05 -> 30.16 KB — 1.11 KB, and every part of it is a shift or a mislabelled control a host was living with",
61
61
  "path": "dist/default/index.js",
62
- "limit": "29.25 KB"
62
+ "limit": "30.5 KB"
63
63
  },
64
64
  {
65
65
  "name": "router — the react-router binding is opt-in; the main entry must never pull a router",
@@ -115,12 +115,12 @@
115
115
  "size-limit": "^11.2.0",
116
116
  "typescript": "^5.8.3",
117
117
  "vitest": "^3.2.4",
118
- "@stapel/core": "^0.25.1",
118
+ "@stapel/attributes-react": "^0.16.1",
119
+ "@stapel/core": "^0.25.4",
119
120
  "@stapel/image": "^0.4.2",
120
121
  "@stapel/showcase": "^0.3.0",
121
- "@stapel/tokens": "^0.7.0",
122
- "@stapel/tokens-antd": "^0.17.0",
123
- "@stapel/attributes-react": "^0.16.0"
122
+ "@stapel/tokens": "^0.8.0",
123
+ "@stapel/tokens-antd": "^0.17.2"
124
124
  },
125
125
  "engines": {
126
126
  "node": ">=22"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-events.mjs — do not edit; drift-gated (pnpm gen:events:check)",
3
3
  "package": "@stapel/search-react",
4
- "version": "0.31.0",
4
+ "version": "0.32.1",
5
5
  "defined": [],
6
6
  "flows": []
7
7
  }
@@ -135,6 +135,12 @@ export interface paths {
135
135
  export type webhooks = Record<string, never>;
136
136
  export interface components {
137
137
  schemas: {
138
+ /**
139
+ * @description * `group` - group
140
+ * * `range` - range
141
+ * @enum {string}
142
+ */
143
+ AxisEnum: "group" | "range";
138
144
  /** @description One partition of the answer — a heading, never a filter over it. */
139
145
  BandSummary: {
140
146
  id: components["schemas"]["IdEnum"];
@@ -233,6 +239,8 @@ export interface components {
233
239
  vocabulary: string | null;
234
240
  /** @description The vocabulary level `vocabulary` resolves against. Present only alongside a non-null `vocabulary`. */
235
241
  level?: string;
242
+ /** @description Where this group sits in ONE panel, numbered together with the numeric axes in `facet_meta.ranges` — draw both halves sorted by it and price and year land where the category's schema puts them, among the makes and models rather than below all of them. Core ranges take the first positions (they exist for every document in every category); the rest follow the plan's own order, which for a category's own schema is mandatory first and then as authored. `null` when the plan has no position for the group, which a client sorts last. */
243
+ order: number | null;
236
244
  };
237
245
  FacetMeta: {
238
246
  /** @description True when counts came from a sample because the candidate set exceeded FACET_CANDIDATE_CAP. */
@@ -246,16 +254,22 @@ export interface components {
246
254
  dropped_filters: string[];
247
255
  /** @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. */
248
256
  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. */
257
+ /** @description `{slug: {min, max, label, label_translatable, unit?, order}}` — every numeric axis that HAS a number on this page, core columns (`price`) and attributes (`year`, `mileage`, `engine_volume`) alike, with everything needed to DRAW it. What a from/to picker is made of: 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 either has no numbers behind it in this candidate set — a different fact from a bound of zero — or was WITHHELD, and then it is named in `withheld` with the reason. Capped by MAX_FACET_FIELDS since 0.16.0: the budget is how wide a panel may be, and a from/to picker is as wide as a bucket list. Empty, with `facet_ranges` in `degraded[]`, on an engine that does not implement the optional `ranges` verb. */
250
258
  ranges: {
251
- [key: string]: unknown;
259
+ [key: string]: components["schemas"]["RangeAxis"];
252
260
  };
253
261
  /** @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). */
254
262
  plan: string;
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. */
256
- withheld: {
257
- [key: string]: unknown;
258
- }[];
263
+ /**
264
+ * @description Axes this answer planned and then did not offer, each naming which half of the panel it is about (`axis`) and why (`reason`). 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.
265
+ *
266
+ * `coverage` — counted, then withheld for describing too little of the result set (FACET_MIN_COVERAGE), with the two numbers. For a GROUP only slugs the evidence plan borrowed from another category are ever measured, because a group the queried category authored answers with its zeros on purpose; for a RANGE every non-core axis is, because a range has no zeros to answer with and a slider over three of fifty-two documents narrows nothing whoever authored it.
267
+ *
268
+ * `unlabelled` (0.16.0, ranges) — no caption could be resolved from any source, so a client could only print the storage slug above the picker. `doors` and `kilometrage` in a chip row is an axis that was there and unreadable.
269
+ *
270
+ * An axis the reader has already filtered on is never withheld for any reason: that would leave the filter applied with no control to undo it.
271
+ */
272
+ withheld: components["schemas"]["WithheldAxis"][];
259
273
  /** @description `{category, count}` — the categories this answer's candidate set is made of, busiest first, `category` being the same slash-joined id path the `category` filter takes. The evidence the plan was drawn from, and what a panel needs to offer the CATEGORY itself as the first filter on a text search. Empty when the plan is the queried category's own. */
260
274
  categories: {
261
275
  [key: string]: unknown;
@@ -307,6 +321,27 @@ export interface components {
307
321
  residual: string;
308
322
  degraded: string[];
309
323
  };
324
+ /** @description One numeric axis a from/to picker is drawn from, with its caption. */
325
+ RangeAxis: {
326
+ /**
327
+ * Format: double
328
+ * @description The low end of the axis over this candidate set, measured with the range filters removed. A number, not a string: a slider end is arithmetic the client does immediately. Integral values render integral, so a year is `2015` and an engine volume is `1.4`.
329
+ */
330
+ min: number;
331
+ /**
332
+ * Format: double
333
+ * @description The high end, same rule as `min`.
334
+ */
335
+ max: number;
336
+ /** @description What to write above the picker. Resolved from the SAME source a facet group's heading comes from — the category's own feature definition — because a group and a range are two ways of narrowing one authored feature. A core range (`price`) has no definition to read and takes this library's own key. Never null: an axis with no resolvable caption is withheld instead (`withheld`, reason `unlabelled`), because a chip row printing `doors` and `kilometrage` at a reader is an axis that was there and unreadable. */
337
+ label: string;
338
+ /** @description True when `label` is a translation KEY, false when it is literal text the catalogue wrote. The reader cannot tell by looking. */
339
+ label_translatable: boolean;
340
+ /** @description What the numbers are measured in — `postfix` off the feature definition, or the family's BASE unit for a `convertible_unit` (the stored value is in the base unit, so naming the input unit would label metres as kilometres). A translation key or literal text, exactly as the catalogue wrote it; this module never translates. ABSENT, not empty, for an axis whose definition names no unit — `price` among them, because a price's unit is the corpus's base currency and a property of each document rather than of the axis. */
341
+ unit?: string;
342
+ /** @description Where this axis sits in ONE panel, numbered together with the groups in `facet_labels`. Same field, same sequence: draw both halves sorted by it and the picker lands where the schema put it. */
343
+ order: number | null;
344
+ };
310
345
  /** @description The P2B Art. 5 disclosure, generated from the scorer registry. */
311
346
  RankingResponse: {
312
347
  doc_type: string;
@@ -314,6 +349,12 @@ export interface components {
314
349
  scorers: components["schemas"]["Scorer"][];
315
350
  notes: string[];
316
351
  };
352
+ /**
353
+ * @description * `coverage` - coverage
354
+ * * `unlabelled` - unlabelled
355
+ * @enum {string}
356
+ */
357
+ ReasonEnum: "coverage" | "unlabelled";
317
358
  ReindexRequest: {
318
359
  doc_type: string;
319
360
  /** @description Re-pull exactly these keys; omit to rebuild the whole type. */
@@ -418,6 +459,28 @@ export interface components {
418
459
  degraded: string[];
419
460
  backend: string;
420
461
  };
462
+ /** @description One axis that was planned and then not offered, and why. */
463
+ WithheldAxis: {
464
+ slug: string;
465
+ /**
466
+ * @description Which half of the panel this row is about — the bucket list or the from/to picker. One slug can be both (an imported `year` is a choice AND a measurement) and the two are decided by different quantities over the same page, so a withheld `group` row does not mean the slider is gone, or the other way round.
467
+ *
468
+ * * `group` - group
469
+ * * `range` - range
470
+ */
471
+ axis: components["schemas"]["AxisEnum"];
472
+ /**
473
+ * @description `coverage` — it describes too little of this result set (FACET_MIN_COVERAGE). `unlabelled` — no caption could be resolved, so a client could only print the storage slug at a reader. A closed set: an unknown value means the client is older than the server.
474
+ *
475
+ * * `coverage` - coverage
476
+ * * `unlabelled` - unlabelled
477
+ */
478
+ reason: components["schemas"]["ReasonEnum"];
479
+ /** @description How many documents the axis describes. Present on `coverage` only — it is the measurement that decided. */
480
+ coverage?: number;
481
+ /** @description What it was a share OF. Present on `coverage` only. */
482
+ candidates?: number;
483
+ };
421
484
  };
422
485
  responses: never;
423
486
  parameters: never;
package/src/api/types.ts CHANGED
@@ -33,8 +33,25 @@ export type Schemas = components["schemas"];
33
33
  */
34
34
  export type FacetLabels = Omit<
35
35
  Schemas["FacetLabels"],
36
- "label" | "label_translatable" | "url_key" | "vocabulary"
36
+ "label" | "label_translatable" | "url_key" | "vocabulary" | "order"
37
37
  > & {
38
+ /**
39
+ * Where this group sits in ONE panel, numbered together with the numeric
40
+ * axes of `facet_meta.ranges` (stapel-search 0.16.0+).
41
+ *
42
+ * The whole point of the field is that it is the SAME sequence: a group and
43
+ * a range are two ways of narrowing one authored feature, and sorting both
44
+ * halves by this key puts "Price" and "Year" where the category's schema put
45
+ * them instead of stacking every choice above every measurement. `null` is
46
+ * the server saying the plan has no position for this group, which sorts
47
+ * last.
48
+ *
49
+ * WHY IT IS NOT THE GENERATED SHAPE: 0.16.0 declares it REQUIRED, and a
50
+ * fixture captured from a 0.15 answer — or a group a host builds by hand —
51
+ * carries no `order` at all. Optional here, and the panel reads absence and
52
+ * `null` the same way: no position, sort last.
53
+ */
54
+ readonly order?: number | null;
38
55
  readonly label?: string | null;
39
56
  /**
40
57
  * Whether {@link label} is a translation KEY rather than a caption, the way
@@ -110,18 +127,61 @@ export type SearchResponse = Omit<
110
127
  export type SearchItem = Schemas["SearchItem"];
111
128
 
112
129
  /**
113
- * One group the counter COUNTED and then held back, because its buckets
114
- * describe too little of the result set (`FACET_MIN_COVERAGE`).
130
+ * One axis the server PLANNED and then did not offer, and why
131
+ * (stapel-search 0.16.0's `WithheldAxis`).
115
132
  *
116
133
  * The existence of this list is what makes "this search offers no filters"
117
- * a false sentence whenever it is not empty (D175).
134
+ * a false sentence whenever it is not empty (D175). Two things 0.16.0 added,
135
+ * and both matter to a panel:
136
+ *
137
+ * - `axis` says WHICH HALF the row is about. One slug can be a choice and a
138
+ * measurement at once — an imported `year` is both — and the two are
139
+ * decided by different quantities over the same page, so a withheld
140
+ * `group` row does not mean the slider is gone. A surface that counts the
141
+ * list filters by `axis`; a surface that hides an axis reads only the rows
142
+ * for its own half.
143
+ * - `reason` is a CLOSED set. `coverage` is the old case (it describes too
144
+ * little of the result set, and then the two numbers are present).
145
+ * `unlabelled` is the new one: no caption could be resolved anywhere, so
146
+ * the only thing a client could draw above the picker is the storage slug
147
+ * — `doors`, `kilometrage`, printed at a buyer. That axis is withheld
148
+ * rather than shipped bare, and this row is how the panel knows it existed.
149
+ *
150
+ * `coverage` and `candidates` are therefore OPTIONAL: they are the
151
+ * measurement that decided a `coverage` row and mean nothing on an
152
+ * `unlabelled` one.
118
153
  */
119
- export interface FacetWithheldGroup {
120
- readonly slug: string;
121
- /** Sum of that group's bucket counts — how much of the set it describes. */
122
- readonly coverage: number;
123
- /** Size of the candidate set `coverage` is a fraction of. */
124
- readonly candidates: number;
154
+ export type FacetWithheldAxis = Schemas["WithheldAxis"];
155
+
156
+ /**
157
+ * @deprecated The 0.15-era name, kept for one minor so a host that imported
158
+ * it still compiles. It now denotes {@link FacetWithheldAxis}, which is the
159
+ * same row plus `axis` and `reason`.
160
+ */
161
+ export type FacetWithheldGroup = FacetWithheldAxis;
162
+
163
+ /** Which half of the panel a {@link FacetWithheldAxis} row is about. */
164
+ export type FacetAxisKind = Schemas["AxisEnum"];
165
+
166
+ /** Why an axis was withheld — a closed set; an unknown value means the client
167
+ * is older than the server. */
168
+ export type FacetWithheldReason = Schemas["ReasonEnum"];
169
+
170
+ /**
171
+ * The slugs one half of the panel must NOT draw, out of the answer's
172
+ * `withheld` list.
173
+ *
174
+ * Filtering by `axis` is the whole reason the field exists: a `year` withheld
175
+ * as a GROUP is still a slider, and a `year` withheld as a RANGE is still a
176
+ * bucket list. Reading the list without the discriminator hides both.
177
+ */
178
+ export function withheldSlugs(
179
+ withheld: readonly FacetWithheldAxis[] | undefined,
180
+ axis: FacetAxisKind
181
+ ): readonly string[] {
182
+ return (withheld ?? [])
183
+ .filter((row) => row.axis === axis)
184
+ .map((row) => row.slug);
125
185
  }
126
186
 
127
187
  /** One category the candidate set is made of. */
@@ -134,41 +194,81 @@ export interface FacetCategoryCount {
134
194
  }
135
195
 
136
196
  /**
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+).
197
+ * One numeric axis a from/to picker is drawn from its two ENDS, measured
198
+ * over this answer's candidate set with the range filters removed, AND its
199
+ * caption (stapel-search 0.16.0's `RangeAxis`).
200
+ *
201
+ * `min`/`max` are numbers, not strings: a slider end is arithmetic a client
202
+ * does immediately, and a price re-parsed from a formatted string is a price
203
+ * that has already been rounded once.
139
204
  *
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.
205
+ * The other three are 0.16.0's, and they are why a client stopped needing the
206
+ * category schema to write a heading:
207
+ *
208
+ * - `label` comes from the same source a facet group's heading does — the
209
+ * category's own `FeatureDef.name`, or this library's own key for a core
210
+ * axis — and is never null, because an axis nobody could name is WITHHELD
211
+ * (`facet_meta.withheld`, reason `unlabelled`) rather than shipped as a
212
+ * bare `doors`. `label_translatable` says whether it is a key or literal
213
+ * text the catalogue wrote, exactly as it does for a group.
214
+ * - `unit` is the definition's `postfix`, or the family's BASE unit for a
215
+ * `convertible_unit` — the stored value is in the base unit, so naming the
216
+ * input unit would label metres as kilometres. ABSENT, never `""`, for an
217
+ * axis whose definition names none (`price` among them: a price's unit is
218
+ * the corpus's currency, a property of each document rather than of the
219
+ * axis).
220
+ * - `order` numbers this axis in the SAME sequence as `facet_labels`, so a
221
+ * panel that sorts both halves by it lands the picker where the category
222
+ * authored it.
223
+ *
224
+ * `label`, `label_translatable` and `order` are OPTIONAL here while the
225
+ * generated shape declares them required: a fixture captured off a 0.15
226
+ * server, and a map a host or a demo builds by hand, carry two numbers and
227
+ * nothing else. A type that promised the caption would compile while reading
228
+ * `undefined` from a field the compiler swore was there — and the label is
229
+ * exactly the field a panel must not guess at.
143
230
  */
144
- export interface FacetRangeBounds {
145
- readonly min: number;
146
- readonly max: number;
147
- }
231
+ export type FacetRangeAxis = Omit<
232
+ Schemas["RangeAxis"],
233
+ "label" | "label_translatable" | "order"
234
+ > & {
235
+ readonly label?: string;
236
+ readonly label_translatable?: boolean;
237
+ readonly order?: number | null;
238
+ };
239
+
240
+ /**
241
+ * @deprecated The 0.14-era name for the two bounds alone. It now denotes
242
+ * {@link FacetRangeAxis}, which is the same two numbers plus the caption.
243
+ */
244
+ export type FacetRangeBounds = FacetRangeAxis;
148
245
 
149
246
  /**
150
- * `facet_meta.ranges` — `{slug: {min, max}}` for every axis this answer has
247
+ * `facet_meta.ranges` — `{slug: RangeAxis}` for every axis this answer has
151
248
  * numbers behind, core columns and attribute axes in ONE report because one
152
249
  * rail draws both.
153
250
  *
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`.
251
+ * An axis ABSENT from the map either has no numbers behind it on this page
252
+ * a different fact from a bound of zero or was WITHHELD, and then it is
253
+ * named in `facet_meta.withheld` with the reason. The map itself absent is a
254
+ * different fact again: the server predates 0.14.7, or its engine has no
255
+ * `ranges` verb and said so as `facet_ranges` in `degraded[]`. The panel tells
256
+ * them apart — see `state/ranges.ts`.
159
257
  */
160
- export type FacetRangesMap = Readonly<Record<string, FacetRangeBounds>>;
258
+ export type FacetRangesMap = Readonly<Record<string, FacetRangeAxis>>;
161
259
 
162
260
  /**
163
261
  * The honesty block beside the counts: `approximate`, `candidates`,
164
262
  * `counted`, `skipped`, and (stapel-search 0.12.0+) where the facet plan came
165
263
  * from. Rendered, never swallowed (spec §4.2).
166
264
  *
167
- * WHAT THE GENERATOR LOST: drf-spectacular describes `withheld` and
168
- * `categories` as bare `object` arrays, so the generated members are
169
- * `{[key: string]: unknown}[]` the two fields a panel has to read
170
- * field-by-field are the two it cannot. Both are corrected here to the
171
- * documented row shapes.
265
+ * WHAT THE GENERATOR LOST: drf-spectacular describes `categories` as a bare
266
+ * `object` array, so the generated member is `{[key: string]: unknown}[]` — a
267
+ * field a panel has to read row by row arrives with no rows in the type. It is
268
+ * corrected here to the documented row shape. `withheld` was in the same state
269
+ * until stapel-search 0.16.0 gave it a named `WithheldAxis`; it is now
270
+ * generated, and is re-declared only to keep the `readonly` array this pair
271
+ * hands around.
172
272
  *
173
273
  * `ranges` (stapel-search 0.14.7) is now GENERATED, and is corrected here for
174
274
  * both of the reasons the two fields above are. The generator lost the row
@@ -184,7 +284,7 @@ export type FacetMeta = Omit<
184
284
  Schemas["FacetMeta"],
185
285
  "withheld" | "categories" | "ranges"
186
286
  > & {
187
- readonly withheld: readonly FacetWithheldGroup[];
287
+ readonly withheld: readonly FacetWithheldAxis[];
188
288
  readonly categories: readonly FacetCategoryCount[];
189
289
  readonly ranges?: FacetRangesMap;
190
290
  };
@@ -984,7 +984,29 @@ export interface FacetGroupControlProps {
984
984
  readonly onSetValues?: (slug: string, values: readonly string[]) => void;
985
985
  }
986
986
 
987
- export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
987
+ /**
988
+ * Is this group a HEADING WITH NOTHING UNDER IT?
989
+ *
990
+ * A bucket list with no buckets draws a caption, a chevron, and an empty box
991
+ * — a control that cannot narrow anything, taking a row of a 280px rail and a
992
+ * stop in a screen reader's tour to say so. On a live laptops leaf that was
993
+ * six of six groups (D249), and `facetGroupIsDrawable` now keeps most of them
994
+ * off the rail; this is the last mile, for the group that reaches a surface
995
+ * anyway — a host's own list, a fixture, a slug the URL constrains whose
996
+ * options the answer never enumerated.
997
+ *
998
+ * A DICTIONARY group is the exemption and the only one. Its control is a
999
+ * FIELD over a vocabulary the answer never enumerated, so it works with no
1000
+ * buckets at all — that is the whole reason the shape exists, and a make
1001
+ * picker on a leaf holding three cars must not vanish for having three.
1002
+ */
1003
+ export function facetGroupIsEmptyHeading(group: FacetGroup): boolean {
1004
+ return group.options.length === 0 && facetGroupShape(group) !== "dictionary";
1005
+ }
1006
+
1007
+ export function FacetGroupControl(
1008
+ props: FacetGroupControlProps
1009
+ ): ReactElement | null {
988
1010
  const t = useT();
989
1011
  const { group } = props;
990
1012
  const [expanded, setExpanded] = useState(false);
@@ -995,6 +1017,12 @@ export function FacetGroupControl(props: FacetGroupControlProps): ReactElement {
995
1017
  const disclosure = props.collapsible === true && props.heading !== false;
996
1018
  const open = !disclosure || openState;
997
1019
 
1020
+ // Nothing to narrow by: no heading either. See `facetGroupIsEmptyHeading`.
1021
+ // AFTER the hooks, never before one — the group can gain buckets on the next
1022
+ // answer, and a component that stopped calling `useState` on the way there
1023
+ // would be a different component to React.
1024
+ if (facetGroupIsEmptyHeading(group)) return null;
1025
+
998
1026
  const limit =
999
1027
  props.visibleOptions === null
1000
1028
  ? null