@stapel/search-react 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/MODULE.md +21 -9
  3. package/README.md +71 -13
  4. package/dist/api/generated/schema.d.ts +18 -3
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/api/types.d.ts +39 -4
  7. package/dist/api/types.d.ts.map +1 -1
  8. package/dist/api/types.js.map +1 -1
  9. package/dist/default/FacetGroupControl.d.ts +20 -13
  10. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  11. package/dist/default/FacetGroupControl.js +29 -21
  12. package/dist/default/FacetGroupControl.js.map +1 -1
  13. package/dist/default/FacetPanelPane.d.ts +15 -4
  14. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  15. package/dist/default/FacetPanelPane.js +10 -5
  16. package/dist/default/FacetPanelPane.js.map +1 -1
  17. package/dist/default/SearchPage.d.ts +16 -0
  18. package/dist/default/SearchPage.d.ts.map +1 -1
  19. package/dist/default/SearchPage.js +7 -3
  20. package/dist/default/SearchPage.js.map +1 -1
  21. package/dist/headless/SearchStateProvider.d.ts +12 -2
  22. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  23. package/dist/headless/SearchStateProvider.js +58 -6
  24. package/dist/headless/SearchStateProvider.js.map +1 -1
  25. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
  26. package/dist/i18n/generated/errors.es.gen.js +1 -0
  27. package/dist/i18n/generated/errors.es.gen.js.map +1 -1
  28. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  29. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  30. package/dist/i18n/generated/errors.gen.js +3 -0
  31. package/dist/i18n/generated/errors.gen.js.map +1 -1
  32. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  33. package/dist/i18n/generated/errors.ru.gen.js +1 -0
  34. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  35. package/dist/index.d.ts +4 -4
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +3 -3
  38. package/dist/index.js.map +1 -1
  39. package/dist/model/queries.d.ts.map +1 -1
  40. package/dist/model/queries.js +9 -1
  41. package/dist/model/queries.js.map +1 -1
  42. package/dist/state/facets.d.ts +62 -11
  43. package/dist/state/facets.d.ts.map +1 -1
  44. package/dist/state/facets.js +141 -29
  45. package/dist/state/facets.js.map +1 -1
  46. package/dist/state/urlState.d.ts +77 -2
  47. package/dist/state/urlState.d.ts.map +1 -1
  48. package/dist/state/urlState.js +80 -6
  49. package/dist/state/urlState.js.map +1 -1
  50. package/llms.txt +3 -2
  51. package/manifest.json +18 -1
  52. package/nav-manifest.json +1 -1
  53. package/package.json +8 -8
  54. package/src/analytics/generated/events.json +1 -1
  55. package/src/api/generated/schema.ts +18 -3
  56. package/src/api/types.ts +42 -4
  57. package/src/default/FacetGroupControl.tsx +27 -23
  58. package/src/default/FacetPanelPane.tsx +30 -8
  59. package/src/default/SearchPage.tsx +25 -1
  60. package/src/headless/SearchStateProvider.tsx +94 -4
  61. package/src/i18n/generated/errors.es.gen.ts +1 -0
  62. package/src/i18n/generated/errors.gen.ts +3 -0
  63. package/src/i18n/generated/errors.json +9 -0
  64. package/src/i18n/generated/errors.ru.gen.ts +1 -0
  65. package/src/index.ts +13 -1
  66. package/src/model/queries.ts +11 -1
  67. package/src/state/facets.ts +173 -30
  68. package/src/state/urlState.ts +137 -5
package/src/api/types.ts CHANGED
@@ -24,11 +24,17 @@ export type Schemas = components["schemas"];
24
24
  * slug either; ABSENT is a server too old to send one, and both read the same
25
25
  * way here (fall through to the schema).
26
26
  *
27
- * WHAT THE GENERATOR LOST: the field is newer than this pair's pinned
28
- * `schema.json`, so it is declared here as optional-and-nullable rather than
29
- * regenerated into a shape a deployed older server does not send.
27
+ * WHY IT IS NOT THE GENERATED SHAPE: stapel-search 0.14.5's `schema.json`
28
+ * declares this field REQUIRED, but the contract this pair announces is
29
+ * `>=0.14 <0.15`, and a 0.14.0 server inside that range sends no `label` at
30
+ * all. The generated member is therefore `Omit`ted and re-declared here as
31
+ * optional-and-nullable — a type must not promise a field a server the pair
32
+ * says it supports does not send.
30
33
  */
31
- export type FacetLabels = Schemas["FacetLabels"] & {
34
+ export type FacetLabels = Omit<
35
+ Schemas["FacetLabels"],
36
+ "label" | "label_translatable" | "url_key"
37
+ > & {
32
38
  readonly label?: string | null;
33
39
  /**
34
40
  * Whether {@link label} is a translation KEY rather than a caption, the way
@@ -43,6 +49,38 @@ export type FacetLabels = Schemas["FacetLabels"] & {
43
49
  * fixture captured from the wire is not a type error.
44
50
  */
45
51
  readonly label_translatable?: boolean;
52
+ /**
53
+ * What this group is called IN THE ADDRESS: `f.<url_key>`, `r.<url_key>`.
54
+ *
55
+ * The slug minus the type suffix an importer mints (`_select`,
56
+ * `_ref_select`, `_int`, `_bool`, `_string`) where dropping it stays
57
+ * unambiguous among the features of the category in scope, and the slug
58
+ * itself otherwise — and always when the query names no category
59
+ * (stapel-search 0.14.4+, `facets.url_keys`). Derived per request, never
60
+ * stored: the slug remains the feature's identity and both forms are
61
+ * accepted inside the scope, which is what lets a client write the short
62
+ * one without a migration behind it.
63
+ *
64
+ * WHY IT IS NOT THE GENERATED SHAPE: 0.14.5 declares it REQUIRED, and the
65
+ * announced contract is `>=0.14 <0.15` — a 0.14.0 server in that range
66
+ * states no `url_key`, and the codec's whole point is that such a link
67
+ * still works. `Omit`ted from the generated member and re-declared
68
+ * optional here. Measured on a live answer 2026-09-04:
69
+ * `make_ref_select` → `make`, `fuel_type_ref_select` → `fuel_type`,
70
+ * `model` → `model`.
71
+ */
72
+ readonly url_key?: string | null;
73
+ /**
74
+ * The vocabulary this group's values are drawn from, when the server names
75
+ * one.
76
+ *
77
+ * The one fact that decides whether an axis is a DICTIONARY rather than a
78
+ * list, and the client's second-best source for it: the first is the
79
+ * category schema's own `optionsRef`, which a host that threaded no schema
80
+ * does not have. Absent on every server that does not state it — absence
81
+ * is not "inline", it is "unsaid", and the schema is asked next.
82
+ */
83
+ readonly vocabulary?: string | null;
46
84
  };
47
85
 
48
86
  /** `facet_labels` as a whole: `{slug: {label, translatable, values}}`. */
@@ -67,12 +67,9 @@ import type { CSSProperties, ReactElement } from "react";
67
67
  import { Button, Checkbox, Flex, Input, Typography } from "antd";
68
68
  import { useT } from "@stapel/core";
69
69
  import { controls, cssVar, radii, spacing } from "@stapel/tokens";
70
- import {
71
- VOCABULARY_BACKED_TYPES,
72
- featureConfig,
73
- featureType,
74
- } from "@stapel/attributes-react";
70
+ import { featureConfig, featureType } from "@stapel/attributes-react";
75
71
  import type { FeatureDef } from "@stapel/attributes-react";
72
+ import { facetGroupIsVocabularyBacked } from "../state/facets.js";
76
73
  import type { FacetGroup, FacetOption } from "../state/facets.js";
77
74
  import { translitPrefixMatch } from "../state/translit.js";
78
75
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
@@ -147,14 +144,21 @@ function singleChoice(feature: FeatureDef | undefined): boolean {
147
144
  }
148
145
 
149
146
  /**
150
- * Is this group a DICTIONARY — a vocabulary's level, too long to scroll?
147
+ * Is this group a DICTIONARY — an axis whose values live in a vocabulary?
151
148
  *
152
- * Two ways to be one, because the schema is an optional slot and the live
153
- * case is the one where it is empty:
149
+ * Two ways to be one, and only the second one counts anything:
154
150
  *
155
- * - the def types the slug `ref_select`/`ref_hierarchical_select`, i.e. its
156
- * config is a POINTER into a vocabulary and there was never an option
157
- * table to draw;
151
+ * - the axis is VOCABULARY-BACKED (`facetGroupIsVocabularyBacked`): the def
152
+ * types it `ref_select`/`ref_hierarchical_select`, so its config is a
153
+ * POINTER and there was never an option table to draw — or there is no def
154
+ * and the answer named the vocabulary itself. **However many buckets came
155
+ * back.** This threshold used to be counted against the answer's evidence,
156
+ * and on a stand holding three cars the make axis has three buckets, drew
157
+ * three checkboxes, and hid the other four hundred makes behind nothing at
158
+ * all: the founder's "what if there are hundreds of options" is a question about the DICTIONARY,
159
+ * and the dictionary is large whether or not this leaf has stock. The
160
+ * field is also the only control that can search values the answer never
161
+ * enumerated, which is exactly the case a thin stand produces;
158
162
  * - there is NO def at all and the answer came back with more values than a
159
163
  * fold. At a live classified's cars branch the storefront passed an empty
160
164
  * feature list, so the 418 makes arrived as an unnamed, untyped group of
@@ -162,20 +166,20 @@ function singleChoice(feature: FeatureDef | undefined): boolean {
162
166
  * answers that, and refusing to draw one because the schema is missing
163
167
  * punishes the buyer for the wiring.
164
168
  *
165
- * Either way it takes more than {@link FACET_DICTIONARY_THRESHOLD} EVIDENCE
166
- * buckets values the answer actually counted. A zero-filled option table or
167
- * a schema-only tail is a list a person can already read, and a box over it
168
- * would search for values no document carries.
169
+ * An INLINE option set is never one, whatever its length: a `select` carries
170
+ * its own table, that table is the whole of the axis, and a small one
171
+ * (≤ {@link FACET_DICTIONARY_THRESHOLD} options) is a list a person reads at
172
+ * a glance rather than types into.
169
173
  */
170
174
  export function isDictionaryFacet(group: FacetGroup): boolean {
175
+ if (facetGroupIsVocabularyBacked(group)) return true;
176
+ // A typed def that is not vocabulary-backed carries its options inline:
177
+ // checkboxes or pills, however many there are.
178
+ if (group.feature !== undefined) return false;
171
179
  const buckets = group.options.filter(
172
180
  (option) => option.count !== null && option.count > 0
173
181
  ).length;
174
- if (buckets <= FACET_DICTIONARY_THRESHOLD) return false;
175
- const feature = group.feature;
176
- if (feature === undefined) return true;
177
- const type = featureType(feature);
178
- return type !== undefined && VOCABULARY_BACKED_TYPES.includes(type);
182
+ return buckets > FACET_DICTIONARY_THRESHOLD;
179
183
  }
180
184
 
181
185
  /**
@@ -189,9 +193,9 @@ export function isDictionaryFacet(group: FacetGroup): boolean {
189
193
  * `maxSelected: 1` over a 418-value vocabulary, so "pick one" won and the
190
194
  * control it produced was four hundred pills in a 280px rail — a wall
191
195
  * with a different border radius. Above the fold the shape a person needs
192
- * is a search box, whether or not they may tick two; below it,
193
- * single-choice still means pills, because `isDictionaryFacet` requires
194
- * more than {@link FACET_DICTIONARY_THRESHOLD} counted buckets;
196
+ * is a search box, whether or not they may tick two. A single-choice axis
197
+ * with an INLINE option table still means pills, because a dictionary is
198
+ * now about where the values live and not about how many came back;
195
199
  * - and a dictionary is a dictionary before it is a checkbox list, because
196
200
  * the checkbox list is the shape it was drawn as when nobody could pick a
197
201
  * make.
@@ -260,17 +260,28 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
260
260
  */
261
261
  readonly skippedNotice?: boolean;
262
262
  /**
263
- * Draw the sticky footer inside the panel: the live result count as the
264
- * bar's strong text, and the clear-all control (which then moves out of the
265
- * heading row — one control, not two) beside it. Default `false`.
263
+ * Draw the footer inside the panel: the live result count as the bar's
264
+ * strong text, and the clear-all control (which then moves out of the
265
+ * heading row — one control, not two) beside it. Default: no bar.
266
266
  *
267
267
  * `<SearchPage>` turns it on for the desktop RAIL only. Desktop filters
268
268
  * apply instantly, so the bar is FEEDBACK plus the way out, not an apply
269
269
  * button — which is exactly why the phone sheet must not get it: the sheet
270
270
  * already closes through its own "Show N results" footer, and a second
271
271
  * count-bearing bar above that one would be the same sentence twice.
272
+ *
273
+ * ── Where it sits, and why that is a choice ───────────────────────────────
274
+ *
275
+ * `"sticky"` (and `true`, which is what it has always meant) pins the bar to
276
+ * the bottom of the panel's own scroll port. That is right in a SHEET, whose
277
+ * port is the sheet and whose bar is the way out of it. In the desktop
278
+ * COLUMN the rail scrolls with the page, so an opaque bar pinned to the
279
+ * bottom of the viewport parks itself over the last two facet groups and
280
+ * they cannot be reached at all — a storefront was reaching for `!important`
281
+ * to lift it off. `"static"` puts the bar after the groups, where it stops
282
+ * covering anything, and the column layout of `<SearchPage>` passes it.
272
283
  */
273
- readonly footerBar?: boolean;
284
+ readonly footerBar?: boolean | "sticky" | "static";
274
285
  /**
275
286
  * The partition control, drawn at the TOP of the panel — above the price,
276
287
  * above every facet.
@@ -319,6 +330,9 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
319
330
  function RailFooterBar(props: {
320
331
  readonly activeFilters: number;
321
332
  readonly clearAll: () => void;
333
+ /** `"sticky"` pins it to the scroll port's floor; `"static"` lets it sit
334
+ * after the last group. See {@link FacetPanelPaneProps.footerBar}. */
335
+ readonly position: "sticky" | "static";
322
336
  }): ReactElement | null {
323
337
  const t = useT();
324
338
  const tPlural = useTPlural();
@@ -340,9 +354,9 @@ function RailFooterBar(props: {
340
354
  return (
341
355
  <div
342
356
  data-testid="facets-footer-bar"
357
+ data-position={props.position}
343
358
  style={{
344
- position: "sticky",
345
- bottom: 0,
359
+ ...(props.position === "sticky" ? { position: "sticky", bottom: 0 } : {}),
346
360
  // Opaque, or the options scrolling under the bar read THROUGH it.
347
361
  background: token.colorBgContainer,
348
362
  borderBlockStart: `1px solid ${token.colorSplit}`,
@@ -513,6 +527,13 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
513
527
  // panel's own search box: the URL is the search, and how much of the rail a
514
528
  // person has unfolded is not part of it.
515
529
  const [tailOpen, setTailOpen] = useState(false);
530
+ // `true` is the shape the prop shipped with and keeps meaning: pinned.
531
+ const footerBar: "sticky" | "static" | "none" =
532
+ props.footerBar === true
533
+ ? "sticky"
534
+ : props.footerBar === false || props.footerBar === undefined
535
+ ? "none"
536
+ : props.footerBar;
516
537
 
517
538
  return (
518
539
  <SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
@@ -577,7 +598,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
577
598
  {/* With the footer bar on, clear-all lives THERE — beside the
578
599
  count it acts on — and drawing it here too would be two
579
600
  identical exits one panel apart. */}
580
- {bag.activeFilters > 0 && props.footerBar !== true && (
601
+ {bag.activeFilters > 0 && footerBar === "none" && (
581
602
  <Button
582
603
  style={FACET_CLEAR}
583
604
  onClick={bag.clearAll}
@@ -845,10 +866,11 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
845
866
  </>
846
867
  )}
847
868
 
848
- {props.footerBar === true && (
869
+ {footerBar !== "none" && (
849
870
  <RailFooterBar
850
871
  activeFilters={bag.activeFilters}
851
872
  clearAll={bag.clearAll}
873
+ position={footerBar}
852
874
  />
853
875
  )}
854
876
  </Flex>
@@ -261,6 +261,22 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
261
261
  /** Facet slugs pinned above every other group — see
262
262
  * {@link FacetPanelPaneProps.pinnedFacets}. */
263
263
  readonly pinnedFacets?: readonly string[];
264
+ /**
265
+ * How a DICTIONARY group is drawn — see
266
+ * {@link FacetPanelPaneProps.dictionaryMode}.
267
+ *
268
+ * Defaulted PER LAYOUT rather than left to the panel's own default, because
269
+ * the two frames want opposite shapes and only this component knows which
270
+ * one it is drawing: the desktop rail gets `"field"` (a select-style «Any»
271
+ * that opens the searchable list — a 418-value vocabulary held open in a
272
+ * 280px column is the whole column), the phone sheet gets `"inline"`
273
+ * (the sheet is already the disclosure). Set it to override both.
274
+ *
275
+ * It was unreachable through this component until now: the panel had the
276
+ * prop, `<SearchPage>` forwarded nothing, and a storefront that mounts the
277
+ * page rather than the pane could not get the field at all.
278
+ */
279
+ readonly dictionaryMode?: "field" | "inline";
264
280
  /** Print the engine's list of uncounted facet slugs in the filter panel.
265
281
  * Default `false` — see {@link FacetPanelPaneProps.skippedNotice}. */
266
282
  readonly skippedNotice?: boolean;
@@ -422,6 +438,7 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
422
438
 
423
439
  interface SearchPageBodyProps {
424
440
  readonly renderCard?: SearchCardRenderer;
441
+ readonly dictionaryMode?: "field" | "inline";
425
442
  readonly categoryFeatures?: readonly FeatureDef[];
426
443
  readonly renderEmptyExits?: () => ReactNode;
427
444
  readonly locale?: string;
@@ -545,7 +562,12 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
545
562
  result count scrolled out of sight above the fold. */}
546
563
  {filtersEmpty ? null : (
547
564
  <FacetPanelPane
548
- {...(layout === "sheet" ? { heading: null } : { footerBar: true })}
565
+ {...(layout === "sheet"
566
+ ? { heading: null }
567
+ : // STATIC, not sticky: the rail scrolls with the page, and a bar
568
+ // pinned to the port's floor sat on top of the last groups.
569
+ { footerBar: "static" as const })}
570
+ dictionaryMode={props.dictionaryMode ?? (layout === "sheet" ? "inline" : "field")}
549
571
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
550
572
  {...(props.renderEmptyExits !== undefined
551
573
  ? { renderEmptyExits: props.renderEmptyExits }
@@ -805,6 +827,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
805
827
  onViewChange,
806
828
  resultsAction,
807
829
  resultsHeadingLevel,
830
+ dictionaryMode,
808
831
  mode,
809
832
  ...parseOptions
810
833
  } = props;
@@ -814,6 +837,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
814
837
  <SearchStateProvider adapter={adapter} geoOffer={geoOffer} {...parseOptions}>
815
838
  <SearchPageBody
816
839
  {...(renderCard !== undefined ? { renderCard } : {})}
840
+ {...(dictionaryMode !== undefined ? { dictionaryMode } : {})}
817
841
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
818
842
  {...(locale !== undefined ? { locale } : {})}
819
843
  {...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
@@ -2,17 +2,22 @@ import {
2
2
  createContext,
3
3
  useCallback,
4
4
  useContext,
5
+ useEffect,
5
6
  useMemo,
7
+ useState,
6
8
  } from "react";
7
9
  import type { ReactElement, ReactNode } from "react";
8
10
  import type {
11
+ FacetLabelsMap,
9
12
  SearchGeo,
10
13
  SearchQueryState,
11
14
  SearchRange,
12
15
  } from "../api/types.js";
13
16
  import {
17
+ EMPTY_FACET_KEYS,
14
18
  activeFilterCount,
15
19
  clearFilters,
20
+ facetKeyMapFromLabels,
16
21
  parseSearchState,
17
22
  patchSearchState,
18
23
  setFilterValues,
@@ -21,6 +26,7 @@ import {
21
26
  writeSearchState,
22
27
  } from "../state/urlState.js";
23
28
  import type {
29
+ FacetKeyMap,
24
30
  ParseSearchStateOptions,
25
31
  SearchStateIssue,
26
32
  SearchStatePatch,
@@ -132,6 +138,56 @@ function sameCenter(
132
138
 
133
139
  const StateContext = createContext<SearchStateBag | null>(null);
134
140
 
141
+ /**
142
+ * WHERE THE SHORT KEYS COME FROM.
143
+ *
144
+ * `f.make` is a fact of the ANSWER (`facet_labels[slug].url_key`, resolved by
145
+ * the server inside the queried category's scope), and the codec that writes
146
+ * the address runs above the query that produces it. So the map is published
147
+ * upwards: whoever holds an answer hands it to this provider, which re-parses
148
+ * the URL with it and writes every subsequent address through it.
149
+ *
150
+ * Late by construction and correct at every moment in between: before the
151
+ * first answer the state holds whatever key the link carried, the request
152
+ * carries the same key, and the server resolves both forms. Nothing waits and
153
+ * nothing is rewritten behind the reader.
154
+ */
155
+ interface FacetKeyRegistry {
156
+ readonly keys: FacetKeyMap;
157
+ publish(next: FacetKeyMap): void;
158
+ }
159
+
160
+ const FacetKeysContext = createContext<FacetKeyRegistry | null>(null);
161
+
162
+ /** Two maps are the same map when they write the same keys — the read side is
163
+ * derived from the write side, so comparing one compares both. */
164
+ function sameKeys(a: FacetKeyMap, b: FacetKeyMap): boolean {
165
+ const left = Object.keys(a.write);
166
+ const right = Object.keys(b.write);
167
+ if (left.length !== right.length) return false;
168
+ return left.every((slug) => a.write[slug] === b.write[slug]);
169
+ }
170
+
171
+ /**
172
+ * Publish an answer's short keys to the state provider above.
173
+ *
174
+ * A no-op outside `<SearchStateProvider>` and a no-op on a server that sends
175
+ * no `url_key`: both leave the address spelled in slugs, which is what it was
176
+ * spelled in before this existed.
177
+ */
178
+ export function usePublishFacetKeys(labels: FacetLabelsMap | undefined): void {
179
+ const registry = useContext(FacetKeysContext);
180
+ useEffect(() => {
181
+ if (registry === null || labels === undefined) return;
182
+ registry.publish(facetKeyMapFromLabels(labels));
183
+ }, [registry, labels]);
184
+ }
185
+
186
+ /** The short-key map this search is currently writing its address with. */
187
+ export function useFacetKeys(): FacetKeyMap {
188
+ return useContext(FacetKeysContext)?.keys ?? EMPTY_FACET_KEYS;
189
+ }
190
+
135
191
  export interface SearchStateProviderProps extends ParseSearchStateOptions {
136
192
  readonly adapter: SearchParamsAdapter;
137
193
  /**
@@ -193,9 +249,27 @@ export function SearchStateProvider(
193
249
 
194
250
  const search = params.toString();
195
251
 
252
+ // The answer's short keys (`f.make`), published from below — see
253
+ // `FacetKeysContext`. Held here because this is where both directions of
254
+ // the codec run.
255
+ const [facetKeys, setFacetKeys] = useState<FacetKeyMap>(EMPTY_FACET_KEYS);
256
+ const registry = useMemo<FacetKeyRegistry>(
257
+ () => ({
258
+ keys: facetKeys,
259
+ publish: (next) => {
260
+ // Idempotent on purpose: this is called from an effect under a query
261
+ // whose data identity is stable, and a setState that always produced
262
+ // a new object would re-render the whole page per answer.
263
+ setFacetKeys((was) => (sameKeys(was, next) ? was : next));
264
+ },
265
+ }),
266
+ [facetKeys]
267
+ );
268
+
196
269
  const parsed = useMemo(
197
270
  () =>
198
271
  parseSearchState(new URLSearchParams(search), {
272
+ facetKeys,
199
273
  defaultType,
200
274
  ...(defaultQ !== undefined ? { defaultQ } : {}),
201
275
  ...(defaultSort !== undefined ? { defaultSort } : {}),
@@ -203,14 +277,26 @@ export function SearchStateProvider(
203
277
  ...(defaultCategory !== undefined ? { defaultCategory } : {}),
204
278
  ...(defaultLang !== undefined ? { defaultLang } : {}),
205
279
  }),
206
- [search, defaultType, defaultQ, defaultSort, defaultLimit, defaultCategory, defaultLang]
280
+ [
281
+ search,
282
+ facetKeys,
283
+ defaultType,
284
+ defaultQ,
285
+ defaultSort,
286
+ defaultLimit,
287
+ defaultCategory,
288
+ defaultLang,
289
+ ]
207
290
  );
208
291
 
209
292
  const commit = useCallback(
210
293
  (next: SearchQueryState, options?: { readonly replace?: boolean }): void => {
211
- setParams(writeSearchState(next, new URLSearchParams(search)), options);
294
+ setParams(
295
+ writeSearchState(next, new URLSearchParams(search), facetKeys),
296
+ options
297
+ );
212
298
  },
213
- [setParams, search]
299
+ [setParams, search, facetKeys]
214
300
  );
215
301
 
216
302
 
@@ -280,7 +366,11 @@ export function SearchStateProvider(
280
366
  }, [parsed, commit, geoOffer]);
281
367
 
282
368
 
283
- return <StateContext.Provider value={bag}>{children}</StateContext.Provider>;
369
+ return (
370
+ <FacetKeysContext.Provider value={registry}>
371
+ <StateContext.Provider value={bag}>{children}</StateContext.Provider>
372
+ </FacetKeysContext.Provider>
373
+ );
284
374
  }
285
375
 
286
376
  /**
@@ -35,6 +35,7 @@ export const searchErrorBundleEs: Record<SearchErrorCode, string> = {
35
35
  "error.400.search_sort_needs_center": "Ordenar por distancia requiere lat y lon",
36
36
  "error.400.search_too_many_facets": "Demasiados filtros de faceta (límite {limit})",
37
37
  "error.400.search_too_many_ranges": "Demasiados filtros de rango (límite {limit})",
38
+ "error.400.search_unknown_category": "No existe la categoría «{category}». `category` admite el id del nodo o su ruta raíz/hoja.",
38
39
  "error.400.search_unknown_doc_type": "Tipo de búsqueda desconocido «{doc_type}»",
39
40
  "error.400.search_unknown_sort": "Orden desconocido «{sort}»",
40
41
  "error.400.search_window_exceeded": "Esta página de resultados supera el límite de {window}. Acote la búsqueda en lugar de avanzar más páginas.",
@@ -58,6 +58,7 @@ export const SEARCH_ERRORS = {
58
58
  "error.400.search_sort_needs_center": { status: 400, params: [], remediation: "fix_input", en: "Sorting by distance needs lat and lon" },
59
59
  "error.400.search_too_many_facets": { status: 400, params: ["limit"], remediation: "fix_input", en: "Too many facet filters (limit {limit})" },
60
60
  "error.400.search_too_many_ranges": { status: 400, params: ["limit"], remediation: "fix_input", en: "Too many range filters (limit {limit})" },
61
+ "error.400.search_unknown_category": { status: 400, params: ["category"], remediation: "fix_input", en: "No category '{category}' exists. `category` takes the node id or its root/leaf path." },
61
62
  "error.400.search_unknown_doc_type": { status: 400, params: ["doc_type"], remediation: "fix_input", en: "Unknown search type '{doc_type}'" },
62
63
  "error.400.search_unknown_sort": { status: 400, params: ["sort"], remediation: "fix_input", en: "Unknown sort '{sort}'" },
63
64
  "error.400.search_window_exceeded": { status: 400, params: ["window"], remediation: "fix_input", en: "This result page is beyond the maximum window of {window}. Narrow the search instead of paging deeper." },
@@ -118,6 +119,7 @@ export const SEARCH_ERROR_CODES: readonly SearchErrorCode[] = [
118
119
  "error.400.search_sort_needs_center",
119
120
  "error.400.search_too_many_facets",
120
121
  "error.400.search_too_many_ranges",
122
+ "error.400.search_unknown_category",
121
123
  "error.400.search_unknown_doc_type",
122
124
  "error.400.search_unknown_sort",
123
125
  "error.400.search_window_exceeded",
@@ -180,6 +182,7 @@ export const searchErrorBundleEn: Record<SearchErrorCode, string> = {
180
182
  "error.400.search_sort_needs_center": "Sorting by distance needs lat and lon",
181
183
  "error.400.search_too_many_facets": "Too many facet filters (limit {limit})",
182
184
  "error.400.search_too_many_ranges": "Too many range filters (limit {limit})",
185
+ "error.400.search_unknown_category": "No category '{category}' exists. `category` takes the node id or its root/leaf path.",
183
186
  "error.400.search_unknown_doc_type": "Unknown search type '{doc_type}'",
184
187
  "error.400.search_unknown_sort": "Unknown sort '{sort}'",
185
188
  "error.400.search_window_exceeded": "This result page is beyond the maximum window of {window}. Narrow the search instead of paging deeper.",
@@ -195,6 +195,15 @@
195
195
  "remediation": "fix_input",
196
196
  "en": "Too many range filters (limit {limit})"
197
197
  },
198
+ {
199
+ "code": "error.400.search_unknown_category",
200
+ "status": 400,
201
+ "params": [
202
+ "category"
203
+ ],
204
+ "remediation": "fix_input",
205
+ "en": "No category '{category}' exists. `category` takes the node id or its root/leaf path."
206
+ },
198
207
  {
199
208
  "code": "error.400.search_unknown_doc_type",
200
209
  "status": 400,
@@ -35,6 +35,7 @@ export const searchErrorBundleRu: Record<SearchErrorCode, string> = {
35
35
  "error.400.search_sort_needs_center": "Сортировка по расстоянию требует координат (lat и lon)",
36
36
  "error.400.search_too_many_facets": "Слишком много фильтров-фасетов (предел {limit})",
37
37
  "error.400.search_too_many_ranges": "Слишком много диапазонных фильтров (предел {limit})",
38
+ "error.400.search_unknown_category": "Категории «{category}» не существует. В `category` передаётся id узла или путь корень/лист.",
38
39
  "error.400.search_unknown_doc_type": "Неизвестный тип поиска «{doc_type}»",
39
40
  "error.400.search_unknown_sort": "Неизвестная сортировка «{sort}»",
40
41
  "error.400.search_window_exceeded": "Эта страница выдачи выходит за предел в {window} результатов. Уточните запрос вместо перелистывания вглубь.",
package/src/index.ts CHANGED
@@ -77,11 +77,16 @@ export type {
77
77
 
78
78
  // ── state (pure: no React, no router) ────────────────────────────────────────
79
79
  export {
80
+ EMPTY_FACET_KEYS,
80
81
  FILTER_PREFIX,
81
82
  RANGE_PREFIX,
82
83
  SEARCH_PARAM,
83
84
  activeFilterCount,
85
+ buildFacetKeyMap,
84
86
  clearFilters,
87
+ facetKeyForSlug,
88
+ facetKeyMapFromLabels,
89
+ facetSlugForKey,
85
90
  ownsParam,
86
91
  parseSearchState,
87
92
  patchSearchState,
@@ -91,6 +96,7 @@ export {
91
96
  writeSearchState,
92
97
  } from "./state/urlState.js";
93
98
  export type {
99
+ FacetKeyMap,
94
100
  ParseSearchStateOptions,
95
101
  ParsedSearchState,
96
102
  SearchStateIssue,
@@ -118,6 +124,7 @@ export {
118
124
  buildFacetGroups,
119
125
  facetGroupHasEvidence,
120
126
  facetGroupIsDrawable,
127
+ facetGroupIsVocabularyBacked,
121
128
  facetOptionLabel,
122
129
  isFacetableFeature,
123
130
  orderFacetGroupsBySchema,
@@ -177,7 +184,12 @@ export { useRankingDisclosure, useSearchQuery, useSuggest } from "./model/querie
177
184
 
178
185
  // ── headless (renderless components) ─────────────────────────────────────────
179
186
  export { SearchProvider } from "./headless/SearchProvider.js";
180
- export { SearchStateProvider, useSearchState } from "./headless/SearchStateProvider.js";
187
+ export {
188
+ SearchStateProvider,
189
+ useFacetKeys,
190
+ usePublishFacetKeys,
191
+ useSearchState,
192
+ } from "./headless/SearchStateProvider.js";
181
193
  export type {
182
194
  SearchParamsAdapter,
183
195
  SearchStateBag,
@@ -9,6 +9,7 @@ import type {
9
9
  SuggestAnswer,
10
10
  } from "../api/types.js";
11
11
  import { SUGGEST_MAX_LIMIT, SUGGEST_MIN_CHARS } from "../state/limits.js";
12
+ import { usePublishFacetKeys } from "../headless/SearchStateProvider.js";
12
13
  import { useSearchApi } from "./context.js";
13
14
  import { searchQueryKeys } from "./queryKeys.js";
14
15
 
@@ -53,13 +54,22 @@ export function useSearchQuery(
53
54
  ): UseQueryResult<SearchResponse, StapelApiError> {
54
55
  const api = useSearchApi();
55
56
  const params = searchQueryParams(state);
56
- return useQuery({
57
+ const query = useQuery<SearchResponse, StapelApiError>({
57
58
  queryKey: searchQueryKeys.query(params),
58
59
  queryFn: ({ signal }) => api.query(state, { signal }),
59
60
  enabled: (options?.enabled ?? true) && state.type.length > 0,
60
61
  placeholderData: keepPreviousData,
61
62
  retry: false,
62
63
  });
64
+ // The answer states what each axis is called IN THE ADDRESS
65
+ // (`facet_labels[slug].url_key`); the codec that writes the address sits
66
+ // above this hook, so the map is handed up. Every surface reads through
67
+ // this one hook, so there is exactly one place the address learns its own
68
+ // spelling — and outside a `<SearchStateProvider>` it is a no-op.
69
+ usePublishFacetKeys(
70
+ query.data === undefined ? undefined : query.data.facet_labels
71
+ );
72
+ return query;
63
73
  }
64
74
 
65
75
  /**