@stapel/search-react 0.30.1 → 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.
- package/CHANGELOG.md +97 -0
- package/dist/api/generated/schema.d.ts +71 -6
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +118 -33
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js +13 -0
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +18 -1
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +25 -0
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +16 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +94 -19
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +22 -0
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +31 -2
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/PopularValues.d.ts +49 -2
- package/dist/default/PopularValues.d.ts.map +1 -1
- package/dist/default/PopularValues.js +57 -3
- package/dist/default/PopularValues.js.map +1 -1
- package/dist/default/RangeFilterRow.d.ts +25 -0
- package/dist/default/RangeFilterRow.d.ts.map +1 -1
- package/dist/default/RangeFilterRow.js +77 -5
- package/dist/default/RangeFilterRow.js.map +1 -1
- package/dist/default/SearchPage.d.ts +28 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +13 -6
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +10 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +1 -1
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/SortSelect.d.ts +10 -10
- package/dist/default/SortSelect.d.ts.map +1 -1
- package/dist/default/SortSelect.js +19 -8
- package/dist/default/SortSelect.js.map +1 -1
- package/dist/default/index.d.ts +4 -4
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +3 -3
- package/dist/default/index.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +7 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +2 -1
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +15 -0
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +0 -0
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/state/facets.d.ts +13 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +13 -0
- package/dist/state/facets.js.map +1 -1
- package/dist/state/panel.d.ts +79 -0
- package/dist/state/panel.d.ts.map +1 -0
- package/dist/state/panel.js +75 -0
- package/dist/state/panel.js.map +1 -0
- package/dist/state/ranges.d.ts +55 -3
- package/dist/state/ranges.d.ts.map +1 -1
- package/dist/state/ranges.js +78 -20
- package/dist/state/ranges.js.map +1 -1
- package/llms.txt +4 -4
- package/manifest.json +12 -2
- package/nav-manifest.json +1 -1
- package/package.json +9 -9
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +71 -6
- package/src/api/types.ts +132 -32
- package/src/default/FacetGroupControl.tsx +29 -1
- package/src/default/FacetPanelPane.tsx +240 -124
- package/src/default/FilterChips.tsx +39 -1
- package/src/default/PopularValues.tsx +88 -4
- package/src/default/RangeFilterRow.tsx +117 -20
- package/src/default/SearchPage.tsx +46 -4
- package/src/default/SearchResultsPane.tsx +13 -0
- package/src/default/SortSelect.tsx +29 -19
- package/src/default/index.ts +8 -1
- package/src/headless/FacetPanel.tsx +17 -2
- package/src/headless/SearchStateProvider.tsx +23 -0
- package/src/index.ts +8 -0
- package/src/state/facets.ts +27 -0
- package/src/state/panel.ts +138 -0
- package/src/state/ranges.ts +158 -47
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stapel/search-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
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": "
|
|
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/
|
|
118
|
+
"@stapel/attributes-react": "^0.16.1",
|
|
119
|
+
"@stapel/core": "^0.25.2",
|
|
119
120
|
"@stapel/image": "^0.4.2",
|
|
120
121
|
"@stapel/showcase": "^0.3.0",
|
|
121
|
-
"@stapel/tokens": "^0.
|
|
122
|
-
"@stapel/tokens-antd": "^0.17.
|
|
123
|
-
"@stapel/attributes-react": "^0.16.0"
|
|
122
|
+
"@stapel/tokens": "^0.8.0",
|
|
123
|
+
"@stapel/tokens-antd": "^0.17.1"
|
|
124
124
|
},
|
|
125
125
|
"engines": {
|
|
126
126
|
"node": ">=22"
|
|
@@ -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}}` —
|
|
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]:
|
|
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
|
-
/**
|
|
256
|
-
|
|
257
|
-
|
|
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. */
|
|
@@ -351,6 +392,8 @@ export interface components {
|
|
|
351
392
|
score: number;
|
|
352
393
|
/** @description Whether this result is promoted. Present on EVERY item under EVERY sort, including when false — a mandatory marking (DSA Art. 26), not an optional field. */
|
|
353
394
|
promoted: boolean;
|
|
395
|
+
/** @description The seller this row belongs to, as the source named them — the same opaque key `owner=` filters on. Present on every item so a result page can draw a seller panel per card and read the profiles in one batched call by id; `""` when the source indexed no owner, never a missing key. It is not a facet and not a ranking input: this module stores the id and nothing else about the seller. */
|
|
396
|
+
owner_key: string;
|
|
354
397
|
/**
|
|
355
398
|
* Format: double
|
|
356
399
|
* @description Great-circle distance from the searched centre, in km. For an ANONYMOUS reader it is measured from the same ~1.1km grid point the card publishes and floored to that grid's quantum (the cell's diagonal, ~1.574km): a distance finer than the position it came from is the position, three requests away. The listing's own owner, staff and the service transport get the exact number. Coarse is enough for what it drives — a card saying «12 км» does not need metres — and it never overstates proximity, being floored rather than rounded.
|
|
@@ -416,6 +459,28 @@ export interface components {
|
|
|
416
459
|
degraded: string[];
|
|
417
460
|
backend: string;
|
|
418
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
|
+
};
|
|
419
484
|
};
|
|
420
485
|
responses: never;
|
|
421
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
|
|
114
|
-
*
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
*
|
|
138
|
-
* with the range filters removed
|
|
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
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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:
|
|
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
|
|
155
|
-
* a different fact from a bound of zero
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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,
|
|
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 `
|
|
168
|
-
* `
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|