@stapel/search-react 0.25.0 → 0.27.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 (81) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/MODULE.md +21 -9
  3. package/README.md +79 -18
  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 +28 -11
  14. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  15. package/dist/default/FacetPanelPane.js +53 -24
  16. package/dist/default/FacetPanelPane.js.map +1 -1
  17. package/dist/default/SearchPage.d.ts +27 -0
  18. package/dist/default/SearchPage.d.ts.map +1 -1
  19. package/dist/default/SearchPage.js +21 -8
  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/es.d.ts.map +1 -1
  26. package/dist/i18n/es.js +0 -2
  27. package/dist/i18n/es.js.map +1 -1
  28. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
  29. package/dist/i18n/generated/errors.es.gen.js +1 -0
  30. package/dist/i18n/generated/errors.es.gen.js.map +1 -1
  31. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  32. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  33. package/dist/i18n/generated/errors.gen.js +3 -0
  34. package/dist/i18n/generated/errors.gen.js.map +1 -1
  35. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  36. package/dist/i18n/generated/errors.ru.gen.js +1 -0
  37. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  38. package/dist/i18n/keys.d.ts +13 -11
  39. package/dist/i18n/keys.d.ts.map +1 -1
  40. package/dist/i18n/keys.js +13 -14
  41. package/dist/i18n/keys.js.map +1 -1
  42. package/dist/i18n/ru.d.ts.map +1 -1
  43. package/dist/i18n/ru.js +0 -4
  44. package/dist/i18n/ru.js.map +1 -1
  45. package/dist/index.d.ts +4 -4
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +3 -3
  48. package/dist/index.js.map +1 -1
  49. package/dist/model/queries.d.ts.map +1 -1
  50. package/dist/model/queries.js +9 -1
  51. package/dist/model/queries.js.map +1 -1
  52. package/dist/state/facets.d.ts +62 -11
  53. package/dist/state/facets.d.ts.map +1 -1
  54. package/dist/state/facets.js +141 -29
  55. package/dist/state/facets.js.map +1 -1
  56. package/dist/state/urlState.d.ts +77 -2
  57. package/dist/state/urlState.d.ts.map +1 -1
  58. package/dist/state/urlState.js +80 -6
  59. package/dist/state/urlState.js.map +1 -1
  60. package/llms.txt +3 -2
  61. package/manifest.json +18 -4
  62. package/nav-manifest.json +1 -1
  63. package/package.json +9 -9
  64. package/src/analytics/generated/events.json +1 -1
  65. package/src/api/generated/schema.ts +18 -3
  66. package/src/api/types.ts +42 -4
  67. package/src/default/FacetGroupControl.tsx +27 -23
  68. package/src/default/FacetPanelPane.tsx +81 -33
  69. package/src/default/SearchPage.tsx +55 -6
  70. package/src/headless/SearchStateProvider.tsx +94 -4
  71. package/src/i18n/es.ts +0 -4
  72. package/src/i18n/generated/errors.es.gen.ts +1 -0
  73. package/src/i18n/generated/errors.gen.ts +3 -0
  74. package/src/i18n/generated/errors.json +9 -0
  75. package/src/i18n/generated/errors.ru.gen.ts +1 -0
  76. package/src/i18n/keys.ts +13 -16
  77. package/src/i18n/ru.ts +0 -8
  78. package/src/index.ts +13 -1
  79. package/src/model/queries.ts +11 -1
  80. package/src/state/facets.ts +173 -30
  81. package/src/state/urlState.ts +137 -5
@@ -181,15 +181,21 @@ export const FACET_OPEN_GROUPS = 5;
181
181
  export const FACET_SEARCH_THRESHOLD = 6;
182
182
 
183
183
  /**
184
- * How many groups the rail draws before the rest go behind one control.
184
+ * How many groups the rail draws before the rest go behind one control. This
185
+ * is `<FacetPanelPane>`'s own default, for the desktop COLUMN — `<SearchPage>`
186
+ * passes 8 for the phone sheet instead (a surface reached through a modal
187
+ * already costs a tap; folding its tail behind a second one is not the same
188
+ * saving as folding a column that sits on screen the whole time).
185
189
  *
186
- * The reference classified shows a make, a price, a year and a handful of
187
- * body axes, and then the word "all filters" — eight is the count that fills
188
- * a 900px rail once the partition row and the price have taken their share,
189
- * and it is the point past which a person is scanning rather than reading.
190
- * The tail is not hidden: `facetsAllFilters` names how many are in it.
190
+ * The reference classified inlines roughly two dozen groups in the desktop
191
+ * rail before anything folds — eight, this pair's number for three releases,
192
+ * turned a make, a price and a year into the whole visible rail with a
193
+ * "all filters" button under them. Sixteen is not a copy of the reference's
194
+ * count; it is the point past which the tail is genuinely a scan rather than
195
+ * a read, on the rail widths this pair is measured against. The tail is
196
+ * never hidden: `facetsAllFilters` names how many are in it.
191
197
  */
192
- export const FACET_VISIBLE_GROUPS = 8;
198
+ export const FACET_VISIBLE_GROUPS = 16;
193
199
 
194
200
  /** What a host's category control is handed. */
195
201
  export interface CategoryFilterSlotProps {
@@ -260,17 +266,28 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
260
266
  */
261
267
  readonly skippedNotice?: boolean;
262
268
  /**
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`.
269
+ * Draw the footer inside the panel: the live result count as the bar's
270
+ * strong text, and the clear-all control (which then moves out of the
271
+ * heading row — one control, not two) beside it. Default: no bar.
266
272
  *
267
273
  * `<SearchPage>` turns it on for the desktop RAIL only. Desktop filters
268
274
  * apply instantly, so the bar is FEEDBACK plus the way out, not an apply
269
275
  * button — which is exactly why the phone sheet must not get it: the sheet
270
276
  * already closes through its own "Show N results" footer, and a second
271
277
  * count-bearing bar above that one would be the same sentence twice.
278
+ *
279
+ * ── Where it sits, and why that is a choice ───────────────────────────────
280
+ *
281
+ * `"sticky"` (and `true`, which is what it has always meant) pins the bar to
282
+ * the bottom of the panel's own scroll port. That is right in a SHEET, whose
283
+ * port is the sheet and whose bar is the way out of it. In the desktop
284
+ * COLUMN the rail scrolls with the page, so an opaque bar pinned to the
285
+ * bottom of the viewport parks itself over the last two facet groups and
286
+ * they cannot be reached at all — a storefront was reaching for `!important`
287
+ * to lift it off. `"static"` puts the bar after the groups, where it stops
288
+ * covering anything, and the column layout of `<SearchPage>` passes it.
272
289
  */
273
- readonly footerBar?: boolean;
290
+ readonly footerBar?: boolean | "sticky" | "static";
274
291
  /**
275
292
  * The partition control, drawn at the TOP of the panel — above the price,
276
293
  * above every facet.
@@ -319,6 +336,9 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
319
336
  function RailFooterBar(props: {
320
337
  readonly activeFilters: number;
321
338
  readonly clearAll: () => void;
339
+ /** `"sticky"` pins it to the scroll port's floor; `"static"` lets it sit
340
+ * after the last group. See {@link FacetPanelPaneProps.footerBar}. */
341
+ readonly position: "sticky" | "static";
322
342
  }): ReactElement | null {
323
343
  const t = useT();
324
344
  const tPlural = useTPlural();
@@ -340,9 +360,9 @@ function RailFooterBar(props: {
340
360
  return (
341
361
  <div
342
362
  data-testid="facets-footer-bar"
363
+ data-position={props.position}
343
364
  style={{
344
- position: "sticky",
345
- bottom: 0,
365
+ ...(props.position === "sticky" ? { position: "sticky", bottom: 0 } : {}),
346
366
  // Opaque, or the options scrolling under the bar read THROUGH it.
347
367
  background: token.colorBgContainer,
348
368
  borderBlockStart: `1px solid ${token.colorSplit}`,
@@ -470,33 +490,43 @@ function skippedNames(
470
490
 
471
491
  /**
472
492
  * The empty arm of the panel — the ONE place "this search offers no filters"
473
- * may be said, and the two answers that forbid it (D175).
493
+ * may be said, and the three things that forbid it (D175, amended).
474
494
  *
475
- * A group list of zero is not the same claim. `withheld` names groups the
476
- * counter counted and then held back for describing too little of the result
477
- * set: they exist, so the honest line is how many. `planUnavailable` means
478
- * the server could not work a plan out at all — the reader hears that from
479
- * `<DegradationNotice>`, and this arm's only job is to not contradict it.
495
+ * A group list of zero is not "nothing on the rail". `withheld` names groups
496
+ * the counter counted and then held back for describing too little of the
497
+ * result set: they exist, so this is not the search's own claim to make.
498
+ * `planUnavailable` means the server could not work a plan out at all — the
499
+ * reader hears that from `<DegradationNotice>`, and this arm's only job is to
500
+ * not contradict it. `hasOtherDrawable` means the rail is already drawing
501
+ * something beside the facet groups — a price row, a location constraint, a
502
+ * partition — and "no filters" would be false the moment any of those is on
503
+ * screen.
504
+ *
505
+ * This USED to be two sentences: `facetsEmpty` for the last case and
506
+ * `facetsWithheld` — "N filters apply to too few of these results" — for the
507
+ * first. A reference catalogue checked against the same case says NEITHER:
508
+ * it leaves the filters visible with low counts and explains nothing. So
509
+ * `withheld` (and, the same way, `skipped`) now suppress this arm instead of
510
+ * replacing its text — the arm says nothing, and `data-withheld` is a test
511
+ * hook a shopper never reads.
480
512
  *
481
513
  * A COMPONENT rather than a ternary inline in `empty=`, because `LoadList`
482
514
  * reads a nullish `empty` as "no arm given" and draws its own default, which
483
515
  * is the sentence again. An element that renders `null` says nothing; a
484
516
  * `null` prop says it louder.
485
517
  */
486
- function FacetsEmptyArm(props: { readonly bag: FacetPanelBag }): ReactElement | null {
518
+ function FacetsEmptyArm(props: {
519
+ readonly bag: FacetPanelBag;
520
+ /** Something else on the rail already makes "no filters" false. */
521
+ readonly hasOtherDrawable: boolean;
522
+ }): ReactElement | null {
487
523
  const t = useT();
488
- const tPlural = useTPlural();
489
524
  const withheld = props.bag.withheld.length;
490
- if (withheld > 0) {
491
- return (
492
- <EmptyState
493
- compact
494
- title={tPlural(SEARCH_I18N_KEYS.facetsWithheld, { count: withheld })}
495
- testId="facets-withheld"
496
- />
497
- );
525
+ if (withheld > 0 || props.bag.planUnavailable || props.hasOtherDrawable) {
526
+ return withheld > 0 ? (
527
+ <span hidden data-testid="facets-withheld" data-withheld={withheld} />
528
+ ) : null;
498
529
  }
499
- if (props.bag.planUnavailable) return null;
500
530
  return (
501
531
  <EmptyState compact title={t(SEARCH_I18N_KEYS.facetsEmpty)} testId="facets-empty" />
502
532
  );
@@ -513,6 +543,13 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
513
543
  // panel's own search box: the URL is the search, and how much of the rail a
514
544
  // person has unfolded is not part of it.
515
545
  const [tailOpen, setTailOpen] = useState(false);
546
+ // `true` is the shape the prop shipped with and keeps meaning: pinned.
547
+ const footerBar: "sticky" | "static" | "none" =
548
+ props.footerBar === true
549
+ ? "sticky"
550
+ : props.footerBar === false || props.footerBar === undefined
551
+ ? "none"
552
+ : props.footerBar;
516
553
 
517
554
  return (
518
555
  <SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
@@ -550,6 +587,16 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
550
587
  // classified catalogue means parcel weight and wholesale packing.
551
588
  const coreRanges = ranges.filter((group) => group.core);
552
589
  const attributeRanges = ranges.filter((group) => !group.core);
590
+ // Is there anything on this rail besides the facet groups? A price
591
+ // row, an applied location, or the partition slot all make "this
592
+ // search offers no filters" false even when the group list itself
593
+ // is empty. `state.geo` rather than a rendered control: the place
594
+ // and its radius are drawn by `<LocationSummaryLine>` beside this
595
+ // panel, not inside it, but the constraint is still on the rail.
596
+ const hasOtherDrawable =
597
+ props.partition !== undefined ||
598
+ coreRanges.length > 0 ||
599
+ state.geo !== undefined;
553
600
  return (
554
601
  <Flex vertical gap={spacing[3]} data-testid="search-facets">
555
602
  {/* In a 280px rail this row laid the word "Filters" out in a
@@ -577,7 +624,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
577
624
  {/* With the footer bar on, clear-all lives THERE — beside the
578
625
  count it acts on — and drawing it here too would be two
579
626
  identical exits one panel apart. */}
580
- {bag.activeFilters > 0 && props.footerBar !== true && (
627
+ {bag.activeFilters > 0 && footerBar === "none" && (
581
628
  <Button
582
629
  style={FACET_CLEAR}
583
630
  onClick={bag.clearAll}
@@ -657,7 +704,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
657
704
  state={bag.state}
658
705
  testId="facets"
659
706
  skeletonRows={4}
660
- empty={<FacetsEmptyArm bag={bag} />}
707
+ empty={<FacetsEmptyArm bag={bag} hasOtherDrawable={hasOtherDrawable} />}
661
708
  failed={(error) => (
662
709
  <ErrorAlert
663
710
  testId="facets-failed"
@@ -845,10 +892,11 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
845
892
  </>
846
893
  )}
847
894
 
848
- {props.footerBar === true && (
895
+ {footerBar !== "none" && (
849
896
  <RailFooterBar
850
897
  activeFilters={bag.activeFilters}
851
898
  clearAll={bag.clearAll}
899
+ position={footerBar}
852
900
  />
853
901
  )}
854
902
  </Flex>
@@ -261,6 +261,33 @@ 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";
280
+ /**
281
+ * How many groups the rail draws before the rest fold behind "All filters"
282
+ * — see {@link FacetPanelPaneProps.visibleGroups}.
283
+ *
284
+ * Defaulted PER LAYOUT, the same reasoning as `dictionaryMode`: 16 in the
285
+ * desktop COLUMN, where the rail sits on screen the whole time and the tail
286
+ * is a genuine scroll away; 8 in the phone SHEET, which is already a modal
287
+ * a person paid a tap to open — folding its tail behind a second control
288
+ * saves less than folding a column's does. Set it to override either.
289
+ */
290
+ readonly visibleGroups?: number | null;
264
291
  /** Print the engine's list of uncounted facet slugs in the filter panel.
265
292
  * Default `false` — see {@link FacetPanelPaneProps.skippedNotice}. */
266
293
  readonly skippedNotice?: boolean;
@@ -422,6 +449,8 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
422
449
 
423
450
  interface SearchPageBodyProps {
424
451
  readonly renderCard?: SearchCardRenderer;
452
+ readonly dictionaryMode?: "field" | "inline";
453
+ readonly visibleGroups?: number | null;
425
454
  readonly categoryFeatures?: readonly FeatureDef[];
426
455
  readonly renderEmptyExits?: () => ReactNode;
427
456
  readonly locale?: string;
@@ -516,11 +545,12 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
516
545
  const filtersEmpty =
517
546
  facets.state.status === "ready" &&
518
547
  facets.state.data.length === 0 &&
519
- // Zero groups is not zero filters: `withheld` names groups the server
520
- // counted and held back for covering too little of the result set, and
521
- // the panel's job is to say how many (D175). Skipping the column would
522
- // put that sentence nowhere.
523
- facets.withheld.length === 0 &&
548
+ // `withheld` (groups the server counted and held back for covering too
549
+ // little of the result set) used to keep the column open so the panel
550
+ // could name how many. It no longer prints that sentence at all (D175,
551
+ // amended) a reference catalogue says nothing in this case either — so
552
+ // zero groups plus zero of everything else below really is nothing to
553
+ // show.
524
554
  facets.activeFilters === 0 &&
525
555
  ranges.length === 0 &&
526
556
  state.category === undefined &&
@@ -545,7 +575,22 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
545
575
  result count scrolled out of sight above the fold. */}
546
576
  {filtersEmpty ? null : (
547
577
  <FacetPanelPane
548
- {...(layout === "sheet" ? { heading: null } : { footerBar: true })}
578
+ {...(layout === "sheet"
579
+ ? { heading: null }
580
+ : // STATIC, not sticky: the rail scrolls with the page, and a bar
581
+ // pinned to the port's floor sat on top of the last groups.
582
+ { footerBar: "static" as const })}
583
+ dictionaryMode={props.dictionaryMode ?? (layout === "sheet" ? "inline" : "field")}
584
+ // `??` would treat an explicit `null` ("never fold") the same as
585
+ // "not set": `visibleGroups` uses `null` as a real value, unlike
586
+ // `dictionaryMode` above, so only `undefined` falls through.
587
+ visibleGroups={
588
+ props.visibleGroups !== undefined
589
+ ? props.visibleGroups
590
+ : layout === "sheet"
591
+ ? 8
592
+ : 16
593
+ }
549
594
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
550
595
  {...(props.renderEmptyExits !== undefined
551
596
  ? { renderEmptyExits: props.renderEmptyExits }
@@ -805,6 +850,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
805
850
  onViewChange,
806
851
  resultsAction,
807
852
  resultsHeadingLevel,
853
+ dictionaryMode,
854
+ visibleGroups,
808
855
  mode,
809
856
  ...parseOptions
810
857
  } = props;
@@ -814,6 +861,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
814
861
  <SearchStateProvider adapter={adapter} geoOffer={geoOffer} {...parseOptions}>
815
862
  <SearchPageBody
816
863
  {...(renderCard !== undefined ? { renderCard } : {})}
864
+ {...(dictionaryMode !== undefined ? { dictionaryMode } : {})}
865
+ {...(visibleGroups !== undefined ? { visibleGroups } : {})}
817
866
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
818
867
  {...(locale !== undefined ? { locale } : {})}
819
868
  {...(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
  /**
package/src/i18n/es.ts CHANGED
@@ -75,10 +75,6 @@ export const searchI18nBundleEs: I18nDictionary = {
75
75
  "Los recuentos son aproximados: había demasiados candidatos para contarlos todos.",
76
76
  "search.facets.skipped":
77
77
  "Estos filtros no se contaron para esta búsqueda: {slugs}",
78
- "search.facets.withheld.one":
79
- "{count} filtro se aplica a muy pocos de estos resultados",
80
- "search.facets.withheld.other":
81
- "{count} filtros se aplican a muy pocos de estos resultados",
82
78
  "search.facets.not_counted": "sin contar",
83
79
  "search.facets.drill_down_hint":
84
80
  "Cada recuento es lo que obtendrías eligiendo ese valor en lugar del actual.",
@@ -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/i18n/keys.ts CHANGED
@@ -111,22 +111,24 @@ export const SEARCH_I18N_KEYS = {
111
111
  facetsTitle: "search.facets.title",
112
112
  facetsLoading: "search.facets.loading",
113
113
  facetsLoadFailed: "search.facets.load_failed",
114
+ /**
115
+ * "This search offers no filters" — the ONE sentence the empty arm may
116
+ * print, and only when the rail draws nothing else either (no price, no
117
+ * location, no partition — see `FacetsEmptyArm`'s `hasOtherDrawable`).
118
+ *
119
+ * D175, amended: this used to be one of TWO sentences, the other keyed on
120
+ * `facet_meta.withheld` ("N filters apply to too few of these results").
121
+ * A reference catalogue was checked for the same case and says neither —
122
+ * it leaves the filters visible with low counts and no explanation of why
123
+ * some are thin. `withheld` (and `skipped`) now suppress this sentence
124
+ * instead of replacing it; a test that needs the count reads
125
+ * `data-withheld` on the empty arm, which carries no text.
126
+ */
114
127
  facetsEmpty: "search.facets.empty",
115
128
  facetsClear: "search.facets.clear",
116
129
  facetsClearAll: "search.facets.clear_all",
117
130
  facetsApproximate: "search.facets.approximate",
118
131
  facetsSkipped: "search.facets.skipped",
119
- /**
120
- * "N filters apply to too few of these results" — the sentence that
121
- * REPLACES {@link facetsEmpty} whenever `facet_meta.withheld` is not
122
- * empty. A PLURAL FAMILY: it counts a noun in words.
123
- *
124
- * D175: a branch category holding 46 phones that all carry a manufacturer
125
- * printed "this search offers no filters". The groups were counted and
126
- * then withheld for covering too little of the set — so "there are none"
127
- * was false, and the honest sentence is this one, with the number in it.
128
- */
129
- facetsWithheld: "search.facets.withheld",
130
132
  /** Shown INSTEAD of a count for a slug the server did not count. */
131
133
  facetsNotCounted: "search.facets.not_counted",
132
134
  facetsDrillDownHint: "search.facets.drill_down_hint",
@@ -471,7 +473,6 @@ export const SEARCH_I18N_PLURAL_KEYS: readonly SearchI18nKey[] = [
471
473
  SEARCH_I18N_KEYS.filtersShowCountAtLeast,
472
474
  SEARCH_I18N_KEYS.boxCategoryCount,
473
475
  SEARCH_I18N_KEYS.facetsMatchCount,
474
- SEARCH_I18N_KEYS.facetsWithheld,
475
476
  ];
476
477
 
477
478
  /**
@@ -544,10 +545,6 @@ export const searchI18nBundleEn: Record<string, string> = {
544
545
  "Counts are approximate — there were too many candidates to count them all.",
545
546
  "search.facets.skipped":
546
547
  "These filters were not counted for this search: {slugs}",
547
- "search.facets.withheld.one":
548
- "{count} filter applies to too few of these results",
549
- "search.facets.withheld.other":
550
- "{count} filters apply to too few of these results",
551
548
  "search.facets.not_counted": "not counted",
552
549
  "search.facets.drill_down_hint":
553
550
  "Each count is what you would get by choosing that value instead of the one you have.",
package/src/i18n/ru.ts CHANGED
@@ -100,14 +100,6 @@ export const searchI18nBundleRu: I18nDictionary = {
100
100
  "Счётчики приблизительные — подходящих объявлений слишком много, чтобы пересчитать все.",
101
101
  "search.facets.skipped":
102
102
  "Эти фильтры для текущего поиска не посчитаны: {slugs}",
103
- "search.facets.withheld.one":
104
- "Ещё {count} фильтр подходит слишком малой части результатов",
105
- "search.facets.withheld.few":
106
- "Ещё {count} фильтра подходят слишком малой части результатов",
107
- "search.facets.withheld.many":
108
- "Ещё {count} фильтров подходят слишком малой части результатов",
109
- "search.facets.withheld.other":
110
- "Ещё {count} фильтра подходят слишком малой части результатов",
111
103
  "search.facets.not_counted": "не посчитано",
112
104
  "search.facets.drill_down_hint":
113
105
  "Рядом с каждым значением — сколько будет, если выбрать его вместо текущего.",
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,