@stapel/search-react 0.24.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 (101) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/MODULE.md +21 -9
  3. package/README.md +109 -15
  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/OtherCategoriesLine.d.ts +49 -0
  18. package/dist/default/OtherCategoriesLine.d.ts.map +1 -0
  19. package/dist/default/OtherCategoriesLine.js +127 -0
  20. package/dist/default/OtherCategoriesLine.js.map +1 -0
  21. package/dist/default/SearchPage.d.ts +36 -0
  22. package/dist/default/SearchPage.d.ts.map +1 -1
  23. package/dist/default/SearchPage.js +12 -4
  24. package/dist/default/SearchPage.js.map +1 -1
  25. package/dist/default/SearchResultsPane.d.ts +15 -0
  26. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  27. package/dist/default/SearchResultsPane.js +4 -1
  28. package/dist/default/SearchResultsPane.js.map +1 -1
  29. package/dist/default/index.d.ts +2 -0
  30. package/dist/default/index.d.ts.map +1 -1
  31. package/dist/default/index.js +1 -0
  32. package/dist/default/index.js.map +1 -1
  33. package/dist/headless/SearchStateProvider.d.ts +12 -2
  34. package/dist/headless/SearchStateProvider.d.ts.map +1 -1
  35. package/dist/headless/SearchStateProvider.js +58 -6
  36. package/dist/headless/SearchStateProvider.js.map +1 -1
  37. package/dist/headless/useOtherCategories.d.ts +52 -0
  38. package/dist/headless/useOtherCategories.d.ts.map +1 -0
  39. package/dist/headless/useOtherCategories.js +92 -0
  40. package/dist/headless/useOtherCategories.js.map +1 -0
  41. package/dist/i18n/es.d.ts.map +1 -1
  42. package/dist/i18n/es.js +3 -0
  43. package/dist/i18n/es.js.map +1 -1
  44. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
  45. package/dist/i18n/generated/errors.es.gen.js +1 -0
  46. package/dist/i18n/generated/errors.es.gen.js.map +1 -1
  47. package/dist/i18n/generated/errors.gen.d.ts +6 -0
  48. package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
  49. package/dist/i18n/generated/errors.gen.js +3 -0
  50. package/dist/i18n/generated/errors.gen.js.map +1 -1
  51. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
  52. package/dist/i18n/generated/errors.ru.gen.js +1 -0
  53. package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
  54. package/dist/i18n/keys.d.ts +25 -0
  55. package/dist/i18n/keys.d.ts.map +1 -1
  56. package/dist/i18n/keys.js +29 -0
  57. package/dist/i18n/keys.js.map +1 -1
  58. package/dist/i18n/ru.d.ts.map +1 -1
  59. package/dist/i18n/ru.js +4 -0
  60. package/dist/i18n/ru.js.map +1 -1
  61. package/dist/index.d.ts +6 -4
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +4 -3
  64. package/dist/index.js.map +1 -1
  65. package/dist/model/queries.d.ts.map +1 -1
  66. package/dist/model/queries.js +9 -1
  67. package/dist/model/queries.js.map +1 -1
  68. package/dist/state/facets.d.ts +62 -11
  69. package/dist/state/facets.d.ts.map +1 -1
  70. package/dist/state/facets.js +141 -29
  71. package/dist/state/facets.js.map +1 -1
  72. package/dist/state/urlState.d.ts +77 -2
  73. package/dist/state/urlState.d.ts.map +1 -1
  74. package/dist/state/urlState.js +80 -6
  75. package/dist/state/urlState.js.map +1 -1
  76. package/llms.txt +4 -2
  77. package/manifest.json +45 -1
  78. package/nav-manifest.json +1 -1
  79. package/package.json +10 -10
  80. package/src/analytics/generated/events.json +1 -1
  81. package/src/api/generated/schema.ts +18 -3
  82. package/src/api/types.ts +42 -4
  83. package/src/default/FacetGroupControl.tsx +27 -23
  84. package/src/default/FacetPanelPane.tsx +30 -8
  85. package/src/default/OtherCategoriesLine.tsx +238 -0
  86. package/src/default/SearchPage.tsx +57 -1
  87. package/src/default/SearchResultsPane.tsx +28 -0
  88. package/src/default/index.ts +13 -0
  89. package/src/headless/SearchStateProvider.tsx +94 -4
  90. package/src/headless/useOtherCategories.ts +193 -0
  91. package/src/i18n/es.ts +4 -0
  92. package/src/i18n/generated/errors.es.gen.ts +1 -0
  93. package/src/i18n/generated/errors.gen.ts +3 -0
  94. package/src/i18n/generated/errors.json +9 -0
  95. package/src/i18n/generated/errors.ru.gen.ts +1 -0
  96. package/src/i18n/keys.ts +32 -0
  97. package/src/i18n/ru.ts +5 -0
  98. package/src/index.ts +23 -1
  99. package/src/model/queries.ts +11 -1
  100. package/src/state/facets.ts +173 -30
  101. package/src/state/urlState.ts +137 -5
@@ -0,0 +1,238 @@
1
+ /**
2
+ * `<OtherCategoriesLine>` — "Search in other categories: Cars 12 · Buses 3 ·
3
+ * Motorhomes 1 · …", on ONE line, drawn from the answer that drew the cards.
4
+ *
5
+ * ## What it replaces
6
+ *
7
+ * A full-width block under the results with one row per category, fetched
8
+ * separately and arriving after the page had settled. Two defects in one
9
+ * control:
10
+ *
11
+ * - **it was tall.** Fourteen sections became fourteen rows — a screen and a
12
+ * half of navigation under a list of listings, for information that fits
13
+ * in a sentence.
14
+ * - **it was late.** A second request meant the block appeared a beat after
15
+ * the cards and PUSHED them, on the one screen where a person has already
16
+ * started reading. The information was not even new: `/query` had already
17
+ * answered with `facet_meta.categories`, and the type-ahead had shown the
18
+ * same sections a keystroke earlier.
19
+ *
20
+ * This line renders in the SAME frame as the results, out of the same
21
+ * response ({@link useOtherCategories}), so there is nothing to arrive late.
22
+ * The single case that still needs a request — an empty result set, whose
23
+ * candidate list is empty by definition — draws into a slot whose height is
24
+ * reserved from the first frame, so the answer lands without moving anything.
25
+ *
26
+ * ## Pressing an entry narrows the search; it does not leave it
27
+ *
28
+ * The count beside a name is the count for THIS QUERY in that section — the
29
+ * server's `facet_meta.categories`. A link to the bare category feed would
30
+ * show a different, larger number, so the caption would be a lie one click
31
+ * later. Each entry therefore writes the `category` parameter of the search
32
+ * already on screen, keeping the query: press "Cars 12" and twelve results
33
+ * follow. That is a state change, so each entry is a real `<button>` and not
34
+ * an anchor.
35
+ *
36
+ * ## Two rows on a phone, at most
37
+ *
38
+ * The cap is halved on the sheet surface ({@link OTHER_CATEGORIES_PHONE_LIMIT}),
39
+ * and the collapsed line is clamped to two rows besides — a cap counts
40
+ * entries, and it is name LENGTH that turns a line into a paragraph. Expanding
41
+ * is the person's own press, and an expanded line is allowed to be as tall as
42
+ * what they asked for.
43
+ */
44
+ import { Fragment, useState } from "react";
45
+ import type { CSSProperties, ReactElement } from "react";
46
+ import { Button, Typography } from "antd";
47
+ import { useDialogSurface } from "@stapel/tokens-antd/skin";
48
+ import { useT } from "@stapel/core";
49
+ import { cssVar } from "@stapel/tokens";
50
+ import { useSearchState } from "../headless/SearchStateProvider.js";
51
+ import {
52
+ OTHER_CATEGORIES_LIMIT,
53
+ OTHER_CATEGORIES_PHONE_LIMIT,
54
+ otherCategoryLeaf,
55
+ useOtherCategories,
56
+ } from "../headless/useOtherCategories.js";
57
+ import type { OtherCategoryRow } from "../headless/useOtherCategories.js";
58
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
59
+
60
+ /**
61
+ * The height the empty-result slot holds before its answer arrives.
62
+ *
63
+ * One text line. Reserving it is the whole difference between "the sections
64
+ * appeared" and "the sections pushed the page", and it is reserved whether the
65
+ * request ends with rows or with none.
66
+ */
67
+ export const OTHER_CATEGORIES_SLOT_MIN_HEIGHT = 24;
68
+
69
+ /** Names an id path the pair cannot name on its own. Returning `undefined`
70
+ * drops the row rather than printing a number at a person. */
71
+ export type OtherCategoryNamer = (category: string) => string | undefined;
72
+
73
+ export interface OtherCategoriesLineProps {
74
+ /** How many entries before the fold (default {@link OTHER_CATEGORIES_LIMIT}). */
75
+ readonly limit?: number;
76
+ /** The same, on the phone surface (default
77
+ * {@link OTHER_CATEGORIES_PHONE_LIMIT}). */
78
+ readonly phoneLimit?: number;
79
+ /**
80
+ * What a category id path is CALLED.
81
+ *
82
+ * The pair holds `"140/145"` and has no catalogue: naming it is the host's,
83
+ * exactly as `categoryLabel` is for the chip. Without this the line still
84
+ * draws every row the server named (a `/suggest` answer already in the
85
+ * cache, or the empty-result path) and every path whose last segment is a
86
+ * slug — and drops the rest, because "163 · 149" is not a sentence.
87
+ */
88
+ readonly categoryName?: OtherCategoryNamer;
89
+ /** Skip the read entirely — mirrors `<SearchResultsPane enabled>`. */
90
+ readonly enabled?: boolean;
91
+ }
92
+
93
+ const ENTRY: CSSProperties = {
94
+ padding: 0,
95
+ height: "auto",
96
+ // The line is text: an entry has to sit ON the baseline of the words around
97
+ // it rather than in a button-shaped box of its own.
98
+ verticalAlign: "baseline",
99
+ fontSize: "inherit",
100
+ };
101
+
102
+ const COUNT: CSSProperties = { color: cssVar("text-subtle") };
103
+
104
+ /** How many rows the collapsed phone line may occupy. */
105
+ export const OTHER_CATEGORIES_PHONE_ROWS = 2;
106
+
107
+ /** The class the clamp is hung on. */
108
+ export const OTHER_CATEGORIES_CLASS = "stapel-search-other-categories";
109
+
110
+ /** The `href` the hoisted sheet is deduplicated by. */
111
+ export const OTHER_CATEGORIES_STYLE_HREF = "stapel-search-other-categories";
112
+
113
+ /**
114
+ * The clamp — two rows, collapsed, on the phone.
115
+ *
116
+ * A SHEET rather than an inline style, for the same reason the rail's
117
+ * scrollbar is one: `-webkit-line-clamp` needs `display:-webkit-box` and
118
+ * `-webkit-box-orient` together, and a vendor property set through the DOM
119
+ * style object is dropped by anything that does not already know it — which
120
+ * is how a clamp silently stops clamping.
121
+ */
122
+ export function otherCategoriesCss(): string {
123
+ const clamped = `.${OTHER_CATEGORIES_CLASS}--clamped`;
124
+ return [
125
+ `${clamped}{display:-webkit-box;-webkit-box-orient:vertical;`,
126
+ `-webkit-line-clamp:${String(OTHER_CATEGORIES_PHONE_ROWS)};overflow:hidden}`,
127
+ ].join("");
128
+ }
129
+
130
+ interface Entry {
131
+ readonly row: OtherCategoryRow;
132
+ readonly name: string;
133
+ }
134
+
135
+ export function OtherCategoriesLine(
136
+ props: OtherCategoriesLineProps
137
+ ): ReactElement | null {
138
+ const t = useT();
139
+ const { setCategory } = useSearchState();
140
+ const bag = useOtherCategories(
141
+ props.enabled !== undefined ? { enabled: props.enabled } : {}
142
+ );
143
+ const surface = useDialogSurface();
144
+ const phone = surface === "sheet";
145
+ const [expanded, setExpanded] = useState(false);
146
+
147
+ const limit = phone
148
+ ? (props.phoneLimit ?? OTHER_CATEGORIES_PHONE_LIMIT)
149
+ : (props.limit ?? OTHER_CATEGORIES_LIMIT);
150
+
151
+ const entries: Entry[] = [];
152
+ for (const row of bag.rows) {
153
+ const name =
154
+ props.categoryName?.(row.category) ?? row.name ?? otherCategoryLeaf(row.category);
155
+ if (name !== undefined) entries.push({ row, name });
156
+ }
157
+
158
+ const shown = expanded ? entries : entries.slice(0, limit);
159
+ const hidden = entries.length - shown.length;
160
+
161
+ if (entries.length === 0) {
162
+ // Nothing to say, and nothing coming: say nothing. A reserved band under a
163
+ // page that will never fill it is the same hole an empty filter column was.
164
+ if (!bag.reserving) return null;
165
+ return (
166
+ <div
167
+ data-testid="search-other-categories"
168
+ data-reserved="on"
169
+ data-source={bag.source}
170
+ style={{ minBlockSize: OTHER_CATEGORIES_SLOT_MIN_HEIGHT }}
171
+ />
172
+ );
173
+ }
174
+
175
+ return (
176
+ <Typography.Text
177
+ type="secondary"
178
+ data-testid="search-other-categories"
179
+ data-source={bag.source}
180
+ data-shown={shown.length}
181
+ {...(bag.reserving ? { "data-reserved": "on" } : {})}
182
+ className={
183
+ phone && !expanded
184
+ ? `${OTHER_CATEGORIES_CLASS} ${OTHER_CATEGORIES_CLASS}--clamped`
185
+ : OTHER_CATEGORIES_CLASS
186
+ }
187
+ style={
188
+ bag.reserving ? { minBlockSize: OTHER_CATEGORIES_SLOT_MIN_HEIGHT } : {}
189
+ }
190
+ >
191
+ <style href={OTHER_CATEGORIES_STYLE_HREF} precedence="default">
192
+ {otherCategoriesCss()}
193
+ </style>
194
+ {t(SEARCH_I18N_KEYS.otherCategoriesLabel)}{" "}
195
+ {shown.map((entry, index) => (
196
+ <Fragment key={entry.row.category}>
197
+ {index > 0 && <span aria-hidden="true"> · </span>}
198
+ <Button
199
+ type="link"
200
+ size="small"
201
+ style={ENTRY}
202
+ data-testid="search-other-category"
203
+ data-category={entry.row.category}
204
+ data-analytics="none"
205
+ data-analytics-reason="narrowing a search is a read, not a flow step"
206
+ aria-label={t(SEARCH_I18N_KEYS.otherCategoriesNarrow, {
207
+ name: entry.name,
208
+ })}
209
+ onClick={() => {
210
+ setCategory(entry.row.category);
211
+ }}
212
+ >
213
+ {entry.name} <span style={COUNT}>{entry.row.count}</span>
214
+ </Button>
215
+ </Fragment>
216
+ ))}
217
+ {hidden > 0 && (
218
+ <>
219
+ <span aria-hidden="true"> · </span>
220
+ <Button
221
+ type="link"
222
+ size="small"
223
+ style={ENTRY}
224
+ data-testid="search-other-categories-more"
225
+ data-analytics="none"
226
+ data-analytics-reason="unfolding a line is a read, not a flow step"
227
+ aria-expanded={false}
228
+ onClick={() => {
229
+ setExpanded(true);
230
+ }}
231
+ >
232
+ {t(SEARCH_I18N_KEYS.otherCategoriesMore, { count: hidden })}
233
+ </Button>
234
+ </>
235
+ )}
236
+ </Typography.Text>
237
+ );
238
+ }
@@ -86,6 +86,7 @@ import { FilterChips } from "./FilterChips.js";
86
86
  import { LocationSummaryLine } from "./LocationSummaryLine.js";
87
87
  import { PageSizeSelect } from "./PageSizeSelect.js";
88
88
  import { SearchBox } from "./SearchBox.js";
89
+ import type { OtherCategoryNamer } from "./OtherCategoriesLine.js";
89
90
  import { SearchResultsPane } from "./SearchResultsPane.js";
90
91
  import type { SearchResultsWrapper } from "./SearchResultsPane.js";
91
92
  import { SortSelect } from "./SortSelect.js";
@@ -260,6 +261,22 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
260
261
  /** Facet slugs pinned above every other group — see
261
262
  * {@link FacetPanelPaneProps.pinnedFacets}. */
262
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";
263
280
  /** Print the engine's list of uncounted facet slugs in the filter panel.
264
281
  * Default `false` — see {@link FacetPanelPaneProps.skippedNotice}. */
265
282
  readonly skippedNotice?: boolean;
@@ -320,6 +337,25 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
320
337
  * ask.
321
338
  */
322
339
  readonly appliedChips?: boolean | "desktop";
340
+ /**
341
+ * Draw "Search in other categories: Cars 12 · Buses 3 · …" above the
342
+ * results — one line, from the SAME response the cards came from.
343
+ *
344
+ * It replaces the shape a storefront had built by hand: a full-width block
345
+ * of one row per category, fetched from `/suggest` after the page had
346
+ * settled and pushing everything below it when it landed. Here the rows are
347
+ * `facet_meta.categories`, which the answer already carried, so with results
348
+ * on screen the line costs no request and cannot arrive late. Only an EMPTY
349
+ * result set asks `/suggest`, into a slot whose height is reserved from the
350
+ * first frame.
351
+ *
352
+ * Opt-in, and `categoryName` is what makes it useful: the pair holds id
353
+ * paths and no catalogue — see {@link OtherCategoriesLineProps.categoryName}.
354
+ */
355
+ readonly otherCategories?: boolean;
356
+ /** What a category id path is CALLED, for the line above. The same question
357
+ * `categoryLabel` answers for the chip, asked once per row. */
358
+ readonly categoryName?: OtherCategoryNamer;
323
359
  /** What this surface calls its result list. See
324
360
  * {@link SearchResultsPaneProps.heading}. */
325
361
  readonly resultsHeading?: ReactNode;
@@ -402,6 +438,7 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
402
438
 
403
439
  interface SearchPageBodyProps {
404
440
  readonly renderCard?: SearchCardRenderer;
441
+ readonly dictionaryMode?: "field" | "inline";
405
442
  readonly categoryFeatures?: readonly FeatureDef[];
406
443
  readonly renderEmptyExits?: () => ReactNode;
407
444
  readonly locale?: string;
@@ -417,6 +454,8 @@ interface SearchPageBodyProps {
417
454
  readonly filtersHeader?: ReactNode;
418
455
  readonly resultsHeader?: ReactNode;
419
456
  readonly appliedChips?: boolean | "desktop";
457
+ readonly otherCategories?: boolean;
458
+ readonly categoryName?: OtherCategoryNamer;
420
459
  readonly resultsHeading?: ReactNode;
421
460
  readonly degradationNotice?: DegradationNoticeVariant;
422
461
  readonly filtersLayout?: SearchFiltersLayout;
@@ -523,7 +562,12 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
523
562
  result count scrolled out of sight above the fold. */}
524
563
  {filtersEmpty ? null : (
525
564
  <FacetPanelPane
526
- {...(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")}
527
571
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
528
572
  {...(props.renderEmptyExits !== undefined
529
573
  ? { renderEmptyExits: props.renderEmptyExits }
@@ -586,6 +630,12 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
586
630
  {...(view.layout !== undefined ? { layout: view.layout } : {})}
587
631
  {...(props.renderCard !== undefined ? { renderCard: props.renderCard } : {})}
588
632
  {...(props.footer !== undefined ? { footer: props.footer } : {})}
633
+ {...(props.otherCategories !== undefined
634
+ ? { otherCategories: props.otherCategories }
635
+ : {})}
636
+ {...(props.categoryName !== undefined
637
+ ? { categoryName: props.categoryName }
638
+ : {})}
589
639
  {...(props.resultsHeading !== undefined
590
640
  ? { heading: props.resultsHeading }
591
641
  : {})}
@@ -763,6 +813,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
763
813
  filtersHeader,
764
814
  resultsHeader,
765
815
  appliedChips,
816
+ otherCategories,
817
+ categoryName,
766
818
  resultsHeading,
767
819
  degradationNotice,
768
820
  filtersLayout,
@@ -775,6 +827,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
775
827
  onViewChange,
776
828
  resultsAction,
777
829
  resultsHeadingLevel,
830
+ dictionaryMode,
778
831
  mode,
779
832
  ...parseOptions
780
833
  } = props;
@@ -784,6 +837,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
784
837
  <SearchStateProvider adapter={adapter} geoOffer={geoOffer} {...parseOptions}>
785
838
  <SearchPageBody
786
839
  {...(renderCard !== undefined ? { renderCard } : {})}
840
+ {...(dictionaryMode !== undefined ? { dictionaryMode } : {})}
787
841
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
788
842
  {...(locale !== undefined ? { locale } : {})}
789
843
  {...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
@@ -798,6 +852,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
798
852
  {...(filtersHeader !== undefined ? { filtersHeader } : {})}
799
853
  {...(resultsHeader !== undefined ? { resultsHeader } : {})}
800
854
  {...(appliedChips !== undefined ? { appliedChips } : {})}
855
+ {...(otherCategories !== undefined ? { otherCategories } : {})}
856
+ {...(categoryName !== undefined ? { categoryName } : {})}
801
857
  {...(resultsHeading !== undefined ? { resultsHeading } : {})}
802
858
  {...(degradationNotice !== undefined ? { degradationNotice } : {})}
803
859
  {...(filtersLayout !== undefined ? { filtersLayout } : {})}
@@ -46,6 +46,8 @@ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
46
46
  import { DegradationNotice } from "./DegradationNotice.js";
47
47
  import { EmptyExits } from "./EmptyExits.js";
48
48
  import type { DegradationNoticeVariant } from "./DegradationNotice.js";
49
+ import { OtherCategoriesLine } from "./OtherCategoriesLine.js";
50
+ import type { OtherCategoryNamer } from "./OtherCategoriesLine.js";
49
51
  import { SearchResultCard } from "./SearchResultCard.js";
50
52
  import type { SearchCardRenderer } from "./SearchResultCard.js";
51
53
  import type { SearchResultsLayout } from "./ViewSwitch.js";
@@ -261,6 +263,20 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
261
263
  * this pair offers the slot and never the tree.
262
264
  */
263
265
  readonly renderEmptyExits?: () => ReactNode;
266
+ /**
267
+ * Draw "Search in other categories: Cars 12 · Buses 3 · …" above the
268
+ * results — the sections THIS answer is made of, one line, from the same
269
+ * response (`<OtherCategoriesLine>`).
270
+ *
271
+ * Opt-in, because the rows are id PATHS and naming them is the host's
272
+ * (`categoryName`): a deployment that passes neither gets the line only for
273
+ * the paths the server itself named, which on a slug-less catalogue is
274
+ * none. Where it is on, it costs no request while there are results.
275
+ */
276
+ readonly otherCategories?: boolean;
277
+ /** What a category id path is CALLED, for the line above — see
278
+ * {@link OtherCategoriesLineProps.categoryName}. */
279
+ readonly categoryName?: OtherCategoryNamer;
264
280
  }
265
281
 
266
282
  function Count(props: { bag: SearchResultsBag }): ReactElement | null {
@@ -375,6 +391,18 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
375
391
  scorerName={scorerName}
376
392
  />
377
393
 
394
+ {/* ABOVE the results and in the SAME frame as them. Under them
395
+ and asynchronously is where it was, and both halves of that
396
+ pushed a page a person had started reading. */}
397
+ {props.otherCategories === true && (
398
+ <OtherCategoriesLine
399
+ {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}
400
+ {...(props.categoryName !== undefined
401
+ ? { categoryName: props.categoryName }
402
+ : {})}
403
+ />
404
+ )}
405
+
378
406
  <LoadList
379
407
  state={bag.state}
380
408
  testId="search"
@@ -48,6 +48,19 @@ export {
48
48
  } from "./SearchPage.js";
49
49
  export type { SearchPageProps, SearchFiltersLayout } from "./SearchPage.js";
50
50
 
51
+ export {
52
+ OtherCategoriesLine,
53
+ OTHER_CATEGORIES_CLASS,
54
+ OTHER_CATEGORIES_PHONE_ROWS,
55
+ OTHER_CATEGORIES_SLOT_MIN_HEIGHT,
56
+ OTHER_CATEGORIES_STYLE_HREF,
57
+ otherCategoriesCss,
58
+ } from "./OtherCategoriesLine.js";
59
+ export type {
60
+ OtherCategoriesLineProps,
61
+ OtherCategoryNamer,
62
+ } from "./OtherCategoriesLine.js";
63
+
51
64
  export { SearchResultsPane, RESULTS_MAX_WIDTH } from "./SearchResultsPane.js";
52
65
  export type {
53
66
  SearchResultsPaneProps,
@@ -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
  /**