@stapel/search-react 0.10.0 → 0.12.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 (130) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/MODULE.md +111 -0
  3. package/README.md +42 -0
  4. package/dist/api/generated/schema.d.ts +76 -6
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/api/searchApi.d.ts +13 -6
  7. package/dist/api/searchApi.d.ts.map +1 -1
  8. package/dist/api/searchApi.js.map +1 -1
  9. package/dist/api/types.d.ts +107 -1
  10. package/dist/api/types.d.ts.map +1 -1
  11. package/dist/api/types.js +29 -0
  12. package/dist/api/types.js.map +1 -1
  13. package/dist/default/DegradationNotice.d.ts +22 -13
  14. package/dist/default/DegradationNotice.d.ts.map +1 -1
  15. package/dist/default/DegradationNotice.js +7 -8
  16. package/dist/default/DegradationNotice.js.map +1 -1
  17. package/dist/default/FacetPanelPane.d.ts +13 -2
  18. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  19. package/dist/default/FacetPanelPane.js +29 -16
  20. package/dist/default/FacetPanelPane.js.map +1 -1
  21. package/dist/default/FilterChips.d.ts +88 -3
  22. package/dist/default/FilterChips.d.ts.map +1 -1
  23. package/dist/default/FilterChips.js +166 -13
  24. package/dist/default/FilterChips.js.map +1 -1
  25. package/dist/default/LocationSummaryLine.d.ts.map +1 -1
  26. package/dist/default/LocationSummaryLine.js +1 -1
  27. package/dist/default/LocationSummaryLine.js.map +1 -1
  28. package/dist/default/RangeFilterRow.d.ts.map +1 -1
  29. package/dist/default/RangeFilterRow.js +49 -5
  30. package/dist/default/RangeFilterRow.js.map +1 -1
  31. package/dist/default/SearchBox.d.ts.map +1 -1
  32. package/dist/default/SearchBox.js +74 -4
  33. package/dist/default/SearchBox.js.map +1 -1
  34. package/dist/default/SearchPage.d.ts +29 -1
  35. package/dist/default/SearchPage.d.ts.map +1 -1
  36. package/dist/default/SearchPage.js +25 -10
  37. package/dist/default/SearchPage.js.map +1 -1
  38. package/dist/default/SearchResultsPane.d.ts +22 -0
  39. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  40. package/dist/default/SearchResultsPane.js +2 -2
  41. package/dist/default/SearchResultsPane.js.map +1 -1
  42. package/dist/default/SortSelect.d.ts +23 -0
  43. package/dist/default/SortSelect.d.ts.map +1 -1
  44. package/dist/default/SortSelect.js +29 -9
  45. package/dist/default/SortSelect.js.map +1 -1
  46. package/dist/default/index.d.ts +2 -2
  47. package/dist/default/index.d.ts.map +1 -1
  48. package/dist/default/index.js +1 -1
  49. package/dist/default/index.js.map +1 -1
  50. package/dist/headless/FacetPanel.d.ts +26 -0
  51. package/dist/headless/FacetPanel.d.ts.map +1 -1
  52. package/dist/headless/FacetPanel.js +16 -1
  53. package/dist/headless/FacetPanel.js.map +1 -1
  54. package/dist/headless/useFacetLabels.d.ts +39 -0
  55. package/dist/headless/useFacetLabels.d.ts.map +1 -0
  56. package/dist/headless/useFacetLabels.js +129 -0
  57. package/dist/headless/useFacetLabels.js.map +1 -0
  58. package/dist/headless/useSearchBox.d.ts +71 -0
  59. package/dist/headless/useSearchBox.d.ts.map +1 -1
  60. package/dist/headless/useSearchBox.js +67 -2
  61. package/dist/headless/useSearchBox.js.map +1 -1
  62. package/dist/i18n/es.d.ts.map +1 -1
  63. package/dist/i18n/es.js +8 -0
  64. package/dist/i18n/es.js.map +1 -1
  65. package/dist/i18n/keys.d.ts +48 -0
  66. package/dist/i18n/keys.d.ts.map +1 -1
  67. package/dist/i18n/keys.js +58 -0
  68. package/dist/i18n/keys.js.map +1 -1
  69. package/dist/i18n/ru.d.ts.map +1 -1
  70. package/dist/i18n/ru.js +10 -0
  71. package/dist/i18n/ru.js.map +1 -1
  72. package/dist/index.d.ts +9 -7
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +6 -5
  75. package/dist/index.js.map +1 -1
  76. package/dist/model/queries.d.ts +6 -5
  77. package/dist/model/queries.d.ts.map +1 -1
  78. package/dist/model/queries.js +4 -3
  79. package/dist/model/queries.js.map +1 -1
  80. package/dist/model/queryKeys.d.ts +12 -0
  81. package/dist/model/queryKeys.d.ts.map +1 -1
  82. package/dist/model/queryKeys.js +7 -0
  83. package/dist/model/queryKeys.js.map +1 -1
  84. package/dist/nav/manifest.d.ts +21 -0
  85. package/dist/nav/manifest.d.ts.map +1 -1
  86. package/dist/nav/manifest.js +3 -2
  87. package/dist/nav/manifest.js.map +1 -1
  88. package/dist/state/degradations.d.ts +32 -0
  89. package/dist/state/degradations.d.ts.map +1 -1
  90. package/dist/state/degradations.js +33 -0
  91. package/dist/state/degradations.js.map +1 -1
  92. package/dist/state/facets.d.ts +64 -0
  93. package/dist/state/facets.d.ts.map +1 -1
  94. package/dist/state/facets.js +134 -9
  95. package/dist/state/facets.js.map +1 -1
  96. package/dist/state/ranges.d.ts +45 -3
  97. package/dist/state/ranges.d.ts.map +1 -1
  98. package/dist/state/ranges.js +55 -14
  99. package/dist/state/ranges.js.map +1 -1
  100. package/llms.txt +2 -2
  101. package/manifest.json +27 -2
  102. package/nav-manifest.json +4 -3
  103. package/package.json +10 -10
  104. package/src/analytics/generated/events.json +1 -1
  105. package/src/api/generated/schema.ts +76 -6
  106. package/src/api/searchApi.ts +13 -6
  107. package/src/api/types.ts +115 -1
  108. package/src/default/DegradationNotice.tsx +30 -20
  109. package/src/default/FacetPanelPane.tsx +55 -25
  110. package/src/default/FilterChips.tsx +290 -38
  111. package/src/default/LocationSummaryLine.tsx +6 -1
  112. package/src/default/RangeFilterRow.tsx +61 -3
  113. package/src/default/SearchBox.tsx +134 -6
  114. package/src/default/SearchPage.tsx +79 -5
  115. package/src/default/SearchResultsPane.tsx +48 -11
  116. package/src/default/SortSelect.tsx +70 -9
  117. package/src/default/index.ts +10 -2
  118. package/src/headless/FacetPanel.tsx +44 -1
  119. package/src/headless/useFacetLabels.ts +172 -0
  120. package/src/headless/useSearchBox.ts +132 -5
  121. package/src/i18n/es.ts +9 -0
  122. package/src/i18n/keys.ts +60 -0
  123. package/src/i18n/ru.ts +11 -0
  124. package/src/index.ts +28 -4
  125. package/src/model/queries.ts +6 -5
  126. package/src/model/queryKeys.ts +23 -0
  127. package/src/nav/manifest.ts +24 -2
  128. package/src/state/degradations.ts +56 -0
  129. package/src/state/facets.ts +181 -9
  130. package/src/state/ranges.ts +85 -15
@@ -16,6 +16,16 @@
16
16
  * the catalogue — never a query log, because stapel-search keeps none — so
17
17
  * every suggestion is a search that has results. The endpoint was typed and
18
18
  * unreachable for three releases (S-8).
19
+ * - **Reaches the CATALOGUE, not only the titles.** stapel-search 0.7.0
20
+ * answers with CATEGORIES too, and they render as their own group above the
21
+ * terms: on a live classified deployment, typing a word that names a
22
+ * section answered listing titles and nothing else, so the search field
23
+ * could not reach a category at all. Each row prints the ancestor path
24
+ * (three catalogues have a "Shorts"; only the path tells them apart) and
25
+ * the live listing count, and follows the server's own `category` string.
26
+ * See
27
+ * `useSearchBox` for why the group is absent rather than empty when the
28
+ * server says it had no provider.
19
29
  * - **Never grows a "no results" dropdown.** With nothing to suggest the menu
20
30
  * stays shut: an empty popover under a half-typed word says "there is
21
31
  * nothing" about a search that has not run.
@@ -28,16 +38,43 @@
28
38
  * as everything else; a header outside one renders `<SearchPage>`'s copy.
29
39
  */
30
40
  import { useState } from "react";
31
- import type { ReactElement } from "react";
32
- import { AutoComplete, Button, Flex, Input } from "antd";
33
- import { useT } from "@stapel/core";
41
+ import type { ReactElement, ReactNode } from "react";
42
+ import { AutoComplete, Button, Flex, Input, Typography } from "antd";
43
+ import { useT, useTPlural } from "@stapel/core";
34
44
  import { SkinTheme } from "@stapel/tokens-antd/skin";
35
45
  import { spacing } from "@stapel/tokens";
36
46
  import { useSearchBox } from "../headless/useSearchBox.js";
37
47
  import type { UseSearchBoxOptions } from "../headless/useSearchBox.js";
48
+ import type { SuggestCategory } from "../api/types.js";
38
49
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
39
50
  import type { ThemeModeProp } from "./types.js";
40
51
 
52
+ /**
53
+ * The separator between the ancestor names of a category row.
54
+ *
55
+ * The same one the vocabulary and hierarchical formatters use fleet-wide, so a
56
+ * path reads identically in the box, on a card and in the composer.
57
+ */
58
+ const PATH_SEPARATOR = " / ";
59
+
60
+ /** Marks a menu row as a DESTINATION rather than a search term.
61
+ *
62
+ * The kind travels on the option OBJECT and never inside its `value`: a
63
+ * category's `category` string and a title prefix are both strings, and
64
+ * telling them apart by sniffing the text is how a term that happens to look
65
+ * like a path silently navigates somewhere. */
66
+ interface BoxOption {
67
+ readonly value: string;
68
+ readonly label?: ReactNode;
69
+ readonly stapelCategory?: SuggestCategory;
70
+ }
71
+
72
+ /** A labelled group of rows — antd renders the label as a group heading. */
73
+ interface BoxOptionGroup {
74
+ readonly label: ReactNode;
75
+ readonly options: BoxOption[];
76
+ }
77
+
41
78
  export interface SearchBoxProps extends ThemeModeProp, UseSearchBoxOptions {
42
79
  /** Override the placeholder — a category page says what it searches. */
43
80
  readonly placeholder?: string;
@@ -47,6 +84,33 @@ export interface SearchBoxProps extends ThemeModeProp, UseSearchBoxOptions {
47
84
  readonly autoFocus?: boolean;
48
85
  }
49
86
 
87
+ /** One destination row: the ancestor path, and how many live listings are
88
+ * behind it. */
89
+ function CategoryRow(props: {
90
+ readonly category: SuggestCategory;
91
+ readonly showCount: boolean;
92
+ }): ReactElement {
93
+ const tPlural = useTPlural();
94
+ const { category } = props;
95
+ return (
96
+ <Flex
97
+ justify="space-between"
98
+ align="center"
99
+ gap={spacing[2]}
100
+ data-testid={`search-box-category-${category.category}`}
101
+ >
102
+ {/* The whole path, not the leaf: three catalogues have a "Shorts", and
103
+ the path is the only thing that says which one this is. */}
104
+ <span>{category.path.join(PATH_SEPARATOR)}</span>
105
+ {props.showCount && (
106
+ <Typography.Text type="secondary">
107
+ {tPlural(SEARCH_I18N_KEYS.boxCategoryCount, { count: category.count })}
108
+ </Typography.Text>
109
+ )}
110
+ </Flex>
111
+ );
112
+ }
113
+
50
114
  export function SearchBox(props: SearchBoxProps): ReactElement {
51
115
  const t = useT();
52
116
  const {
@@ -59,7 +123,62 @@ export function SearchBox(props: SearchBoxProps): ReactElement {
59
123
  const box = useSearchBox(boxOptions);
60
124
  const [open, setOpen] = useState(false);
61
125
 
62
- const options = box.suggestions.map((value) => ({ value }));
126
+ const categoryOptions: BoxOption[] = box.categories.map(
127
+ (category) => ({
128
+ // The server's own string, verbatim — it is unique per row and it is
129
+ // exactly what the SERP's `category` parameter takes.
130
+ value: category.category,
131
+ label: (
132
+ <CategoryRow
133
+ category={category}
134
+ showCount={!box.categoryCountsUnknown}
135
+ />
136
+ ),
137
+ stapelCategory: category,
138
+ })
139
+ );
140
+ const termOptions: BoxOption[] = box.suggestions.map((value) => ({ value }));
141
+
142
+ /*
143
+ * Destinations FIRST, then terms.
144
+ *
145
+ * A classified's box is a navigation control before it is a text filter:
146
+ * the person who typed a section's name wants the section, and the titles
147
+ * that happen to contain the word are the fallback, not the answer. The
148
+ * server ranks the destinations by live listing count and this list keeps
149
+ * that order.
150
+ *
151
+ * The group is absent — not empty — when the server had no category
152
+ * provider, which falls out of `box.categories` being empty: a heading over
153
+ * nothing would be the box claiming the catalogue has no such section, a
154
+ * claim the answer never made. See `useSearchBox`.
155
+ */
156
+ const options: (BoxOption | BoxOptionGroup)[] =
157
+ categoryOptions.length > 0
158
+ ? [
159
+ {
160
+ label: (
161
+ <span data-testid="search-box-categories-heading">
162
+ {t(SEARCH_I18N_KEYS.boxCategories)}
163
+ </span>
164
+ ),
165
+ options: categoryOptions,
166
+ },
167
+ ...(termOptions.length > 0
168
+ ? [
169
+ {
170
+ label: (
171
+ <span data-testid="search-box-terms-heading">
172
+ {t(SEARCH_I18N_KEYS.boxSuggestions)}
173
+ </span>
174
+ ),
175
+ options: termOptions,
176
+ },
177
+ ]
178
+ : []),
179
+ ]
180
+ : termOptions;
181
+ const hasOptions = categoryOptions.length > 0 || termOptions.length > 0;
63
182
 
64
183
  return (
65
184
  <SkinTheme
@@ -75,10 +194,19 @@ export function SearchBox(props: SearchBoxProps): ReactElement {
75
194
  // open state is the skin's, not antd's: the prop that reports the
76
195
  // menu opening was renamed between antd 5 and 6 and this package
77
196
  // supports both, so nothing here asks antd when to open.
78
- open={open && options.length > 0}
197
+ open={open && hasOptions}
79
198
  style={{ flex: 1, minWidth: 0 }}
80
- onSelect={(value: string) => {
199
+ onSelect={(value: string, option: BoxOption | BoxOptionGroup) => {
81
200
  setOpen(false);
201
+ // A destination navigates; a term searches. The kind is read off
202
+ // the option OBJECT, never sniffed out of the text — see
203
+ // `BoxOption`.
204
+ const category =
205
+ "stapelCategory" in option ? option.stapelCategory : undefined;
206
+ if (category !== undefined) {
207
+ box.chooseCategory(category);
208
+ return;
209
+ }
82
210
  box.submit(value);
83
211
  }}
84
212
  onChange={(value: string) => {
@@ -71,6 +71,7 @@ import type { FeatureDef } from "@stapel/attributes-react";
71
71
  import { SearchStateProvider, useSearchState } from "../headless/SearchStateProvider.js";
72
72
  import type { SearchParamsAdapter } from "../headless/SearchStateProvider.js";
73
73
  import { useFacetPanel } from "../headless/FacetPanel.js";
74
+ import type { FacetLabelResolver } from "../headless/useFacetLabels.js";
74
75
  import { useAppliedCount } from "../headless/useAppliedCount.js";
75
76
  import type { ParseSearchStateOptions } from "../state/urlState.js";
76
77
  import type { SearchGeo } from "../api/types.js";
@@ -147,13 +148,40 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
147
148
  readonly renderCard?: SearchCardRenderer;
148
149
  readonly categoryFeatures?: readonly FeatureDef[];
149
150
  readonly locale?: string;
151
+ /**
152
+ * Name the facet values neither the answer nor the category schema names —
153
+ * see {@link FacetLabelResolver}.
154
+ *
155
+ * Set once here and it reaches BOTH filter surfaces, the desktop panel and
156
+ * the phone chip row, the same way `geoLabel` does. It is the seam a
157
+ * `ref_select` facet needs: its config carries a pointer to a vocabulary and
158
+ * no option table, the vocabulary is a service this pair does not talk to,
159
+ * and a server older than stapel-search 0.4.0 sends no `facet_labels` to
160
+ * cover for it — so without this the chips print `apple` and `128-gb`.
161
+ */
162
+ readonly resolveFacetLabels?: FacetLabelResolver;
150
163
  /** Render the query box at the top. `false` for a container whose HEADER
151
164
  * already mounts `<SearchBox>` — one box per screen, not two. */
152
165
  readonly searchBox?: boolean;
153
166
  /** BCP-47 tags this deployment indexes, for the language filter. */
154
167
  readonly languages?: readonly string[];
155
- /** The catalogue picker slot — see {@link FacetPanelPaneProps}. */
168
+ /**
169
+ * The catalogue picker slot — see {@link FacetPanelPaneProps}.
170
+ *
171
+ * It reaches TWO surfaces: the filter panel's category row, and — on the
172
+ * phone — the LEADING chip of the filter row, which opens the same control
173
+ * in the same kind of sheet as every other chip. The owner's navigation
174
+ * model chooses levels 1-2 from tiles and everything deeper as a
175
+ * characteristic, and on a result list that is what a chip is.
176
+ */
156
177
  readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
178
+ /**
179
+ * What the current category is CALLED — the chip's own text. The pair holds
180
+ * a path of slugs and no way to turn one into a catalogue name; absent, the
181
+ * chip states the path's last segment. See
182
+ * {@link FilterChipsProps.categoryLabel}.
183
+ */
184
+ readonly categoryLabel?: ReactNode;
157
185
  /** The location control slot (`geo-react`). */
158
186
  readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
159
187
  /**
@@ -273,9 +301,11 @@ interface SearchPageBodyProps {
273
301
  readonly renderCard?: SearchCardRenderer;
274
302
  readonly categoryFeatures?: readonly FeatureDef[];
275
303
  readonly locale?: string;
304
+ readonly resolveFacetLabels?: FacetLabelResolver;
276
305
  readonly searchBox?: boolean;
277
306
  readonly languages?: readonly string[];
278
307
  readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
308
+ readonly categoryLabel?: ReactNode;
279
309
  readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
280
310
  readonly geoLabel?: ReactNode;
281
311
  readonly footer?: ReactNode;
@@ -301,11 +331,12 @@ interface SearchPageBodyProps {
301
331
  function SearchPageBody(props: SearchPageBodyProps): ReactElement {
302
332
  const t = useT();
303
333
  const tPlural = useTPlural();
304
- const { categoryFeatures, locale, filtersHeader } = props;
334
+ const { categoryFeatures, locale, resolveFacetLabels, filtersHeader } = props;
305
335
  const { state } = useSearchState();
306
336
  const facets = useFacetPanel({
307
337
  ...(categoryFeatures !== undefined ? { categoryFeatures } : {}),
308
338
  ...(locale !== undefined ? { locale } : {}),
339
+ ...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {}),
309
340
  });
310
341
  const applied = useAppliedCount();
311
342
  const surface = useDialogSurface();
@@ -379,6 +410,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
379
410
  {...(layout === "sheet" ? { heading: null } : {})}
380
411
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
381
412
  {...(locale !== undefined ? { locale } : {})}
413
+ {...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
382
414
  {...(props.languages !== undefined ? { languages: props.languages } : {})}
383
415
  {...(props.renderCategoryFilter !== undefined
384
416
  ? { renderCategoryFilter: props.renderCategoryFilter }
@@ -392,9 +424,32 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
392
424
  </Flex>
393
425
  );
394
426
 
395
- // The toolbar over the results: how they are ARRANGED, how they are ORDERED,
396
- // how many per page and the surface's own action at the trailing end.
397
- const toolbar = (
427
+ /*
428
+ * The toolbar over the results: how they are ARRANGED, how they are ORDERED,
429
+ * how many per page — and the surface's own action at the trailing end.
430
+ *
431
+ * TWO shapes, because at 390px the desktop shape is not a smaller version of
432
+ * itself, it is four stacked rows. The phone form is the reference's own
433
+ * sort row: the ordering at one end, the surface's action at the other, one
434
+ * line, nothing else. `pageSize` is already the surface's call; the view
435
+ * switch draws nothing for a single view and stays in the row for the
436
+ * surfaces that offer two.
437
+ */
438
+ const phoneToolbar = layout === "sheet";
439
+ const toolbar = phoneToolbar ? (
440
+ <Flex
441
+ align="center"
442
+ justify="space-between"
443
+ gap={spacing[2]}
444
+ style={{ width: "100%" }}
445
+ >
446
+ <Flex align="center" gap={spacing[2]} style={{ minWidth: 0 }}>
447
+ <ViewSwitch views={views} value={view.id} onChange={changeView} />
448
+ <SortSelect compact />
449
+ </Flex>
450
+ {props.resultsAction}
451
+ </Flex>
452
+ ) : (
398
453
  <Flex align="center" wrap gap={spacing[3]}>
399
454
  <ViewSwitch views={views} value={view.id} onChange={changeView} />
400
455
  <SortSelect />
@@ -406,6 +461,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
406
461
  const results = (
407
462
  <SearchResultsPane
408
463
  toolbar={toolbar}
464
+ {...(phoneToolbar ? { header: "compact" as const } : {})}
409
465
  headingLevel={props.resultsHeadingLevel ?? 1}
410
466
  {...(view.render !== undefined ? { renderResults: view.render } : {})}
411
467
  {...(view.layout !== undefined ? { layout: view.layout } : {})}
@@ -456,10 +512,24 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
456
512
  }}
457
513
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
458
514
  {...(locale !== undefined ? { locale } : {})}
515
+ {...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
516
+ /* The catalogue picker becomes the row's leading chip. The panel
517
+ behind the circle keeps its own copy of the control; both write
518
+ the same `category` parameter, so they cannot disagree. */
519
+ {...(props.renderCategoryFilter !== undefined
520
+ ? { renderCategoryFilter: props.renderCategoryFilter }
521
+ : {})}
522
+ {...(props.categoryLabel !== undefined
523
+ ? { categoryLabel: props.categoryLabel }
524
+ : {})}
459
525
  {...(props.renderGeoFilter !== undefined
460
526
  ? { renderGeoFilter: props.renderGeoFilter }
461
527
  : {})}
462
528
  {...(props.geoLabel !== undefined ? { geoLabel: props.geoLabel } : {})}
529
+ /* The row above already states the location and opens its own
530
+ sheet, so the chip would be the second control over one filter
531
+ — see `FilterChipsProps.geoChip`. */
532
+ {...(props.resultsHeader !== undefined ? { geoChip: false } : {})}
463
533
  />
464
534
  <SkinDialog
465
535
  open={sheetOpen}
@@ -512,9 +582,11 @@ export function SearchPage(props: SearchPageProps): ReactElement {
512
582
  renderCard,
513
583
  categoryFeatures,
514
584
  locale,
585
+ resolveFacetLabels,
515
586
  searchBox,
516
587
  languages,
517
588
  renderCategoryFilter,
589
+ categoryLabel,
518
590
  renderGeoFilter,
519
591
  geoLabel,
520
592
  defaultGeo,
@@ -543,9 +615,11 @@ export function SearchPage(props: SearchPageProps): ReactElement {
543
615
  {...(renderCard !== undefined ? { renderCard } : {})}
544
616
  {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
545
617
  {...(locale !== undefined ? { locale } : {})}
618
+ {...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
546
619
  {...(searchBox !== undefined ? { searchBox } : {})}
547
620
  {...(languages !== undefined ? { languages } : {})}
548
621
  {...(renderCategoryFilter !== undefined ? { renderCategoryFilter } : {})}
622
+ {...(categoryLabel !== undefined ? { categoryLabel } : {})}
549
623
  {...(renderGeoFilter !== undefined ? { renderGeoFilter } : {})}
550
624
  {...(geoLabel !== undefined ? { geoLabel } : {})}
551
625
  {...(footer !== undefined ? { footer } : {})}
@@ -33,6 +33,7 @@ import {
33
33
  GatedButton,
34
34
  LoadList,
35
35
  SkinTheme,
36
+ visuallyHidden,
36
37
  } from "@stapel/tokens-antd/skin";
37
38
  import { spacing } from "@stapel/tokens";
38
39
  import type { SearchItem } from "../api/types.js";
@@ -168,6 +169,28 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
168
169
  * heading list is for.
169
170
  */
170
171
  readonly headingLevel?: 1 | 2 | 3 | 4 | 5;
172
+ /**
173
+ * How the row above the results is arranged. Default `"banner"`.
174
+ *
175
+ * `"banner"` — the heading on the left, the count and the toolbar on the
176
+ * right of one line. A desktop results page, where that line is wide enough
177
+ * to hold all three.
178
+ *
179
+ * `"compact"` — the phone form, and it is a different SHAPE rather than the
180
+ * same shape at a smaller size. At 390px the banner's one line wraps to
181
+ * four: a display-size "Results", the count under it, the sort control under
182
+ * that, and the surface's action under that — a whole viewport of chrome
183
+ * above the first card, which is what a live phone SERP printed. Compact
184
+ * gives the toolbar its own row (the surface arranges it: sort at one end,
185
+ * its action at the other) and puts the COUNT immediately above the cards,
186
+ * where it is a caption for the list rather than a fourth heading.
187
+ *
188
+ * The heading does not disappear — a results screen whose only heading is
189
+ * gone has a document outline that starts at the footer. It becomes
190
+ * visually hidden, so a screen reader's heading list is unchanged and the
191
+ * viewport is not spent saying "Results" over a list of results.
192
+ */
193
+ readonly header?: "banner" | "compact";
171
194
  }
172
195
 
173
196
  function Count(props: { bag: SearchResultsBag }): ReactElement | null {
@@ -248,19 +271,33 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
248
271
  <SearchResults {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}>
249
272
  {(bag) => (
250
273
  <Flex vertical gap={spacing[4]}>
251
- <Flex justify="space-between" align="center" wrap gap={spacing[2]}>
252
- <Typography.Title
253
- level={props.headingLevel ?? 4}
254
- style={{ margin: 0 }}
255
- data-testid="search-results-heading"
256
- >
257
- {props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
258
- </Typography.Title>
259
- <Flex align="center" wrap gap={spacing[3]}>
260
- <Count bag={bag} />
274
+ {props.header === "compact" ? (
275
+ <Flex vertical gap={spacing[2]} data-testid="search-results-header-compact">
276
+ <Typography.Title
277
+ level={props.headingLevel ?? 4}
278
+ style={visuallyHidden}
279
+ data-testid="search-results-heading"
280
+ >
281
+ {props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
282
+ </Typography.Title>
261
283
  {props.toolbar}
284
+ <Count bag={bag} />
285
+ </Flex>
286
+ ) : (
287
+ <Flex justify="space-between" align="center" wrap gap={spacing[2]}>
288
+ <Typography.Title
289
+ level={props.headingLevel ?? 4}
290
+ style={{ margin: 0 }}
291
+ data-testid="search-results-heading"
292
+ >
293
+ {props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
294
+ </Typography.Title>
295
+ <Flex align="center" wrap gap={spacing[3]}>
296
+ <Count bag={bag} />
297
+ {props.toolbar}
298
+ </Flex>
262
299
  </Flex>
263
- </Flex>
300
+ )}
264
301
 
265
302
  <DegradationNotice
266
303
  degradations={bag.degradations}
@@ -34,10 +34,37 @@ import { sortLabelKey } from "./sortLabels.js";
34
34
  */
35
35
  export const SORT_SELECT_MIN_WIDTH = 200;
36
36
 
37
+ /** Why `sort=distance` is refused without a centre — the server's own code, so
38
+ * the control and the 400 it would have earned say the same sentence. */
39
+ const SORT_DISTANCE_BLOCKED = "error.400.search_sort_needs_center";
40
+
37
41
  export interface SortSelectProps {
38
42
  /** The sort the SERVER applied, shown when the URL names none. Omitted, it
39
43
  * is read from the page already in cache — see {@link useAppliedSort}. */
40
44
  readonly appliedSort?: string | undefined;
45
+ /**
46
+ * The one-line form, for a phone toolbar. Default `false`.
47
+ *
48
+ * Three things change, and the third is the interesting one:
49
+ *
50
+ * - the "Sort" caption goes (the select already shows a sort by name; the
51
+ * accessible name keeps the word);
52
+ * - the {@link SORT_SELECT_MIN_WIDTH} floor goes, so the control shares one
53
+ * row with whatever the surface puts beside it instead of pushing it to
54
+ * the next line;
55
+ * - the blocked option's REASON moves from a line under the control into
56
+ * the option's own label.
57
+ *
58
+ * That last move is not the reason being dropped. This file exists because
59
+ * the reason used to live in a `title=` a phone can never surface, and a
60
+ * phone is exactly where "sort by distance" is greyed out most often. On a
61
+ * 390px toolbar the reason as a separate row costs a whole band of the
62
+ * viewport above the first result — so it goes where the person actually
63
+ * meets the refusal: on the disabled row of the open list, which a screen
64
+ * reader reads out with the option and a thumb reads at the moment of the
65
+ * tap. Nothing is hidden; it is closer to the thing it explains.
66
+ */
67
+ readonly compact?: boolean;
41
68
  }
42
69
 
43
70
  export function SortSelect(props: SortSelectProps): ReactElement {
@@ -65,7 +92,48 @@ export function SortSelect(props: SortSelectProps): ReactElement {
65
92
  // four sorts work perfectly well without a location.
66
93
  const distance: ActionAvailability = hasCentre
67
94
  ? actionAvailable()
68
- : actionBlocked("error.400.search_sort_needs_center");
95
+ : actionBlocked(SORT_DISTANCE_BLOCKED);
96
+
97
+ const optionsFor = (describedBy?: string): {
98
+ readonly value: string;
99
+ readonly label: string;
100
+ readonly disabled: boolean;
101
+ }[] =>
102
+ values.map((value) => {
103
+ const key = sortLabelKey(value);
104
+ const label = key !== undefined ? t(key) : value;
105
+ const blocked = value === "distance" && !hasCentre;
106
+ return {
107
+ value,
108
+ // In the compact form the option carries its own reason — see
109
+ // `SortSelectProps.compact`. Elsewhere `GatedControl` renders it once,
110
+ // beside the control, and repeating it here would say it twice.
111
+ label:
112
+ blocked && props.compact === true && describedBy === undefined
113
+ ? `${label} — ${t(SORT_DISTANCE_BLOCKED)}`
114
+ : label,
115
+ disabled: blocked,
116
+ };
117
+ });
118
+
119
+ if (props.compact === true) {
120
+ return (
121
+ <Select<string>
122
+ data-testid="search-sort"
123
+ data-stapel-gated={hasCentre ? "available" : "blocked"}
124
+ aria-label={t(SEARCH_I18N_KEYS.sortLabel)}
125
+ // `minWidth: 0` and not the floor: a control that refuses to be
126
+ // narrower than 200px is a control that wraps a two-item toolbar onto
127
+ // two rows at 390px.
128
+ style={{ minWidth: 0, flex: "0 1 auto" }}
129
+ value={active ?? null}
130
+ onChange={(next) => {
131
+ setSort(next);
132
+ }}
133
+ options={optionsFor()}
134
+ />
135
+ );
136
+ }
69
137
 
70
138
  return (
71
139
  <GatedControl gate={distance} testId="search-sort-gate">
@@ -83,14 +151,7 @@ export function SortSelect(props: SortSelectProps): ReactElement {
83
151
  onChange={(next) => {
84
152
  setSort(next);
85
153
  }}
86
- options={values.map((value) => {
87
- const key = sortLabelKey(value);
88
- return {
89
- value,
90
- label: key !== undefined ? t(key) : value,
91
- disabled: value === "distance" && !hasCentre,
92
- };
93
- })}
154
+ options={optionsFor(bind["aria-describedby"] ?? "")}
94
155
  />
95
156
  </Flex>
96
157
  )}
@@ -48,8 +48,16 @@ export type {
48
48
  SearchResultsRenderer,
49
49
  } from "./SearchResultsPane.js";
50
50
 
51
- export { FilterChips, CHIP_ROW_CLASS, CHIP_ROW_STYLE_HREF, chipRowCss } from "./FilterChips.js";
52
- export type { FilterChipsProps } from "./FilterChips.js";
51
+ export {
52
+ FilterChips,
53
+ CHIP_BAND_ORDER,
54
+ CHIP_ROW_CLASS,
55
+ CHIP_ROW_STYLE_HREF,
56
+ categoryLeaf,
57
+ chipRowCss,
58
+ orderChipFilters,
59
+ } from "./FilterChips.js";
60
+ export type { ChipBand, ChipSpec, FilterChipsProps } from "./FilterChips.js";
53
61
 
54
62
  export { LocationSummaryLine } from "./LocationSummaryLine.js";
55
63
  export type { LocationSummaryLineProps } from "./LocationSummaryLine.js";
@@ -6,6 +6,8 @@ import type { FacetMeta, SearchRange } from "../api/types.js";
6
6
  import { useSearchQuery } from "../model/queries.js";
7
7
  import { buildFacetGroups } from "../state/facets.js";
8
8
  import type { FacetGroup } from "../state/facets.js";
9
+ import { useHostFacetLabels } from "./useFacetLabels.js";
10
+ import type { FacetLabelResolver } from "./useFacetLabels.js";
9
11
  import { useSearchState } from "./SearchStateProvider.js";
10
12
 
11
13
  /** The bag `<FacetPanel>` hands its render prop. */
@@ -31,6 +33,22 @@ export interface FacetPanelBag {
31
33
  readonly skipped: readonly string[];
32
34
  /** Slugs that WERE counted. */
33
35
  readonly counted: readonly string[];
36
+ /**
37
+ * Range slugs the server declared as CORE document columns
38
+ * (`facet_meta.core_ranges`, stapel-search 0.4.0+) — `price` on a
39
+ * classified board. They are not part of the category plan and are not
40
+ * counted; they are axes the panel may offer unconditionally, and they
41
+ * come from the answer so that a panel never draws a control over a
42
+ * filter the deployed server would answer zero for.
43
+ */
44
+ readonly coreRanges: readonly string[];
45
+ /**
46
+ * ISO 4217 code of the corpus, read off the first card of the answer, so
47
+ * a money range reads as money without the host wiring anything. The
48
+ * cards already carry it — `SearchResultCard` formats prices from the
49
+ * same field.
50
+ */
51
+ readonly currency: string | undefined;
34
52
  /** Size of the largest counted set — the number `approximate` is about. */
35
53
  readonly candidates: number;
36
54
  /** Facet values + ranges + geo currently applied. */
@@ -47,6 +65,7 @@ const EMPTY_META: FacetMeta = {
47
65
  candidates: 0,
48
66
  counted: [],
49
67
  skipped: [],
68
+ core_ranges: [],
50
69
  };
51
70
 
52
71
  /**
@@ -70,6 +89,8 @@ export function FacetPanel(props: {
70
89
  /** BCP-47 tag for `date`-typed option labels. Defaults to the runtime's. */
71
90
  locale?: string;
72
91
  enabled?: boolean;
92
+ /** The host's vocabulary lookup — see {@link FacetLabelResolver}. */
93
+ resolveFacetLabels?: FacetLabelResolver;
73
94
  children: (bag: FacetPanelBag) => ReactNode;
74
95
  }): ReactNode {
75
96
  return props.children(
@@ -79,6 +100,9 @@ export function FacetPanel(props: {
79
100
  : {}),
80
101
  ...(props.locale !== undefined ? { locale: props.locale } : {}),
81
102
  ...(props.enabled !== undefined ? { enabled: props.enabled } : {}),
103
+ ...(props.resolveFacetLabels !== undefined
104
+ ? { resolveFacetLabels: props.resolveFacetLabels }
105
+ : {}),
82
106
  })
83
107
  );
84
108
  }
@@ -99,6 +123,13 @@ export function useFacetPanel(props: {
99
123
  categoryFeatures?: readonly FeatureDef[];
100
124
  locale?: string;
101
125
  enabled?: boolean;
126
+ /**
127
+ * The host's lookup for values neither the answer nor the schema names —
128
+ * see {@link FacetLabelResolver}. Threaded here rather than into each skin
129
+ * so the chip row and the filter panel, which both call this hook, cannot
130
+ * end up printing two different words for one value.
131
+ */
132
+ resolveFacetLabels?: FacetLabelResolver;
102
133
  } = {}): FacetPanelBag {
103
134
  const { state: searchState, setFilter, setRange, clearAll, toggleFilter, activeFilters } =
104
135
  useSearchState();
@@ -115,6 +146,7 @@ export function useFacetPanel(props: {
115
146
  buildFacetGroups({
116
147
  facets: data.facets,
117
148
  meta: data.facet_meta,
149
+ facetLabels: data.facet_labels,
118
150
  state: searchState,
119
151
  ...(props.categoryFeatures !== undefined
120
152
  ? { categoryFeatures: props.categoryFeatures }
@@ -124,11 +156,22 @@ export function useFacetPanel(props: {
124
156
  })
125
157
  );
126
158
 
159
+ // The host seam runs AFTER `buildFacetGroups`, on what it could not name:
160
+ // the precedence is server captions, then the schema's own option table,
161
+ // then this. See `useFacetLabels.ts`.
162
+ const labelled = useHostFacetLabels(groups, props.resolveFacetLabels, props.locale);
163
+
127
164
  return {
128
- state: groups,
165
+ state: labelled,
129
166
  approximate: meta.approximate,
130
167
  skipped: meta.skipped,
131
168
  counted: meta.counted,
169
+ coreRanges: meta.core_ranges ?? [],
170
+ currency:
171
+ envelope.status === "ready"
172
+ ? envelope.data.items.find((item) => typeof item.card?.["currency"] === "string")
173
+ ?.card?.["currency"] as string | undefined
174
+ : undefined,
132
175
  candidates: meta.candidates,
133
176
  activeFilters,
134
177
  toggle: toggleFilter,