@stapel/search-react 0.4.0 → 0.7.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 (139) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +1 -1
  3. package/dist/default/DegradationNotice.d.ts +7 -0
  4. package/dist/default/DegradationNotice.d.ts.map +1 -1
  5. package/dist/default/DegradationNotice.js +17 -6
  6. package/dist/default/DegradationNotice.js.map +1 -1
  7. package/dist/default/FacetPanelPane.d.ts +54 -3
  8. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  9. package/dist/default/FacetPanelPane.js +119 -19
  10. package/dist/default/FacetPanelPane.js.map +1 -1
  11. package/dist/default/LanguageSelect.d.ts +9 -0
  12. package/dist/default/LanguageSelect.d.ts.map +1 -0
  13. package/dist/default/LanguageSelect.js +72 -0
  14. package/dist/default/LanguageSelect.js.map +1 -0
  15. package/dist/default/PageSizeSelect.d.ts +29 -0
  16. package/dist/default/PageSizeSelect.d.ts.map +1 -0
  17. package/dist/default/PageSizeSelect.js +25 -0
  18. package/dist/default/PageSizeSelect.js.map +1 -0
  19. package/dist/default/RangeFilterRow.d.ts +12 -0
  20. package/dist/default/RangeFilterRow.d.ts.map +1 -0
  21. package/dist/default/RangeFilterRow.js +75 -0
  22. package/dist/default/RangeFilterRow.js.map +1 -0
  23. package/dist/default/RankingDisclosurePane.d.ts +5 -0
  24. package/dist/default/RankingDisclosurePane.d.ts.map +1 -1
  25. package/dist/default/RankingDisclosurePane.js +15 -15
  26. package/dist/default/RankingDisclosurePane.js.map +1 -1
  27. package/dist/default/SearchBox.d.ts +13 -0
  28. package/dist/default/SearchBox.d.ts.map +1 -0
  29. package/dist/default/SearchBox.js +67 -0
  30. package/dist/default/SearchBox.js.map +1 -0
  31. package/dist/default/SearchPage.d.ts +54 -16
  32. package/dist/default/SearchPage.d.ts.map +1 -1
  33. package/dist/default/SearchPage.js +155 -8
  34. package/dist/default/SearchPage.js.map +1 -1
  35. package/dist/default/SearchResultCard.d.ts +19 -18
  36. package/dist/default/SearchResultCard.d.ts.map +1 -1
  37. package/dist/default/SearchResultCard.js +178 -5
  38. package/dist/default/SearchResultCard.js.map +1 -1
  39. package/dist/default/SearchResultsPane.d.ts +42 -3
  40. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  41. package/dist/default/SearchResultsPane.js +38 -21
  42. package/dist/default/SearchResultsPane.js.map +1 -1
  43. package/dist/default/SortSelect.d.ts +10 -2
  44. package/dist/default/SortSelect.d.ts.map +1 -1
  45. package/dist/default/SortSelect.js +27 -24
  46. package/dist/default/SortSelect.js.map +1 -1
  47. package/dist/default/UrlIssueNotice.d.ts.map +1 -1
  48. package/dist/default/UrlIssueNotice.js +19 -1
  49. package/dist/default/UrlIssueNotice.js.map +1 -1
  50. package/dist/default/index.d.ts +32 -15
  51. package/dist/default/index.d.ts.map +1 -1
  52. package/dist/default/index.js +26 -12
  53. package/dist/default/index.js.map +1 -1
  54. package/dist/default/sortLabels.d.ts +5 -0
  55. package/dist/default/sortLabels.d.ts.map +1 -0
  56. package/dist/default/sortLabels.js +32 -0
  57. package/dist/default/sortLabels.js.map +1 -0
  58. package/dist/headless/FacetPanel.d.ts +17 -0
  59. package/dist/headless/FacetPanel.d.ts.map +1 -1
  60. package/dist/headless/FacetPanel.js +23 -2
  61. package/dist/headless/FacetPanel.js.map +1 -1
  62. package/dist/headless/useAppliedCount.d.ts +8 -0
  63. package/dist/headless/useAppliedCount.d.ts.map +1 -0
  64. package/dist/headless/useAppliedCount.js +30 -0
  65. package/dist/headless/useAppliedCount.js.map +1 -0
  66. package/dist/headless/useScorerNames.d.ts +4 -0
  67. package/dist/headless/useScorerNames.d.ts.map +1 -0
  68. package/dist/headless/useScorerNames.js +34 -0
  69. package/dist/headless/useScorerNames.js.map +1 -0
  70. package/dist/headless/useSearchBox.d.ts +44 -0
  71. package/dist/headless/useSearchBox.d.ts.map +1 -0
  72. package/dist/headless/useSearchBox.js +116 -0
  73. package/dist/headless/useSearchBox.js.map +1 -0
  74. package/dist/i18n/es.d.ts.map +1 -1
  75. package/dist/i18n/es.js +30 -4
  76. package/dist/i18n/es.js.map +1 -1
  77. package/dist/i18n/keys.d.ts +35 -0
  78. package/dist/i18n/keys.d.ts.map +1 -1
  79. package/dist/i18n/keys.js +72 -4
  80. package/dist/i18n/keys.js.map +1 -1
  81. package/dist/i18n/ru.d.ts.map +1 -1
  82. package/dist/i18n/ru.js +34 -4
  83. package/dist/i18n/ru.js.map +1 -1
  84. package/dist/index.d.ts +7 -2
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +5 -2
  87. package/dist/index.js.map +1 -1
  88. package/dist/model/queries.d.ts +26 -2
  89. package/dist/model/queries.d.ts.map +1 -1
  90. package/dist/model/queries.js +38 -1
  91. package/dist/model/queries.js.map +1 -1
  92. package/dist/state/limits.d.ts +44 -0
  93. package/dist/state/limits.d.ts.map +1 -0
  94. package/dist/state/limits.js +44 -0
  95. package/dist/state/limits.js.map +1 -0
  96. package/dist/state/ranges.d.ts +52 -0
  97. package/dist/state/ranges.d.ts.map +1 -0
  98. package/dist/state/ranges.js +108 -0
  99. package/dist/state/ranges.js.map +1 -0
  100. package/llms.txt +15 -4
  101. package/manifest.json +238 -21
  102. package/nav-manifest.json +1 -1
  103. package/package.json +15 -10
  104. package/src/analytics/generated/events.json +1 -1
  105. package/src/default/DegradationNotice.tsx +34 -12
  106. package/src/default/FacetPanelPane.tsx +343 -64
  107. package/src/default/LanguageSelect.tsx +93 -0
  108. package/src/default/PageSizeSelect.tsx +80 -0
  109. package/src/default/RangeFilterRow.tsx +148 -0
  110. package/src/default/RankingDisclosurePane.tsx +67 -35
  111. package/src/default/SearchBox.tsx +130 -0
  112. package/src/default/SearchPage.tsx +351 -44
  113. package/src/default/SearchResultCard.tsx +231 -24
  114. package/src/default/SearchResultsPane.tsx +120 -70
  115. package/src/default/SortSelect.tsx +50 -37
  116. package/src/default/UrlIssueNotice.tsx +21 -3
  117. package/src/default/index.ts +38 -15
  118. package/src/default/sortLabels.ts +34 -0
  119. package/src/headless/FacetPanel.tsx +30 -2
  120. package/src/headless/useAppliedCount.ts +41 -0
  121. package/src/headless/useScorerNames.ts +36 -0
  122. package/src/headless/useSearchBox.ts +189 -0
  123. package/src/i18n/es.ts +37 -4
  124. package/src/i18n/keys.ts +83 -4
  125. package/src/i18n/ru.ts +41 -4
  126. package/src/index.ts +22 -2
  127. package/src/model/queries.ts +51 -1
  128. package/src/state/limits.ts +50 -0
  129. package/src/state/ranges.ts +144 -0
  130. package/dist/default/ErrorAlert.d.ts +0 -9
  131. package/dist/default/ErrorAlert.d.ts.map +0 -1
  132. package/dist/default/ErrorAlert.js +0 -27
  133. package/dist/default/ErrorAlert.js.map +0 -1
  134. package/dist/default/theme.d.ts +0 -24
  135. package/dist/default/theme.d.ts.map +0 -1
  136. package/dist/default/theme.js +0 -39
  137. package/dist/default/theme.js.map +0 -1
  138. package/src/default/ErrorAlert.tsx +0 -46
  139. package/src/default/theme.tsx +0 -66
@@ -1,34 +1,106 @@
1
1
  /**
2
- * `<SearchPage>` — the composed screen the nav manifest points at: filters on
3
- * the left, sort and results on the right, the ranking link in the footer.
2
+ * `<SearchPage>` — the composed screen the nav manifest points at: the query
3
+ * box, the filters, the sort and the results.
4
4
  *
5
- * It composes the two panes and owns NO state of its own. The URL is the
6
- * state, and it arrives through the `adapter` prop — which is where a host
7
- * hands in `useRouterSearchParams()` from `@stapel/search-react/router`, or
8
- * its own binding. The page does not reach for a router, so the same
9
- * component renders under react-router, under a Next.js app router, and in a
10
- * test with a plain `URLSearchParams`.
5
+ * It composes the panes and owns NO state of its own. The URL is the state,
6
+ * and it arrives through the `adapter` prop — which is where a host hands in
7
+ * `useRouterSearchParams()` from `@stapel/search-react/router`, or its own
8
+ * binding. The page does not reach for a router, so the same component renders
9
+ * under react-router, under a Next.js app router, and in a test with a plain
10
+ * `URLSearchParams`.
11
11
  *
12
- * The three seams a storefront fills (spec §6.2 items 1–3): `renderCard`
13
- * (a `<ListingCard>`), `categoryFeatures` (from categories-react, for facet
14
- * labels), and `footer` (the container's own chrome). None of them is
15
- * optional behaviour in disguise every one has a working default.
12
+ * The seams a storefront fills: `renderCard` (a `<ListingCard>`),
13
+ * `categoryFeatures` (from categories-react, for facet labels and range rows),
14
+ * `renderCategoryFilter` / `renderGeoFilter` (controls other pairs own),
15
+ * `footer` (the container's own chrome), `filtersHeader` and `resultsHeading`.
16
+ * None of them is optional behaviour in disguise — every one has a working
17
+ * default or a visible placeholder.
18
+ *
19
+ * ── The screen could not start a search ───────────────────────────────────
20
+ *
21
+ * Until this release the page rendered filters, sort and results and NO query
22
+ * box: `q` reached the state only from the address bar, and `setText` had zero
23
+ * callers in the repository. A scaffolded app therefore had a search route and
24
+ * no way to search (audit S-1). `<SearchBox>` is now the first thing on the
25
+ * page — and it is exported, so a container that puts the real box in its
26
+ * header can pass `searchBox={false}` and keep exactly one.
27
+ *
28
+ * ── On a phone the filters are a sheet ────────────────────────────────────
29
+ *
30
+ * The filter column was `xs={24}`: on a 390px screen the entire panel stacked
31
+ * ABOVE the results, so a person scrolled past every facet to reach the first
32
+ * card (§83(b), audit S-6). Below the tablet breakpoint the panel now lives
33
+ * behind a "Filters (N)" button and opens as a bottom sheet through the shared
34
+ * `SkinDialog`, with the count of what is applied on the button itself.
35
+ *
36
+ * ── The filter column is laid out only when there is something in it ──────
37
+ *
38
+ * `Col md={7}` was unconditional, and on a deployment whose search plan
39
+ * declares no facets that spent a quarter of every results page — `/s`, every
40
+ * category page, every seller page — on an empty-state illustration saying
41
+ * "no filters for this search". Saying it once is honest; reserving a column
42
+ * for it on every screen is not a message, it is a hole. The page asks the
43
+ * facet bag what it has (`useFacetPanel`) and gives the results the whole
44
+ * width when the answer is nothing AND no other filter control has anything
45
+ * to show. Note which way the test runs: the column stays for `loading` and
46
+ * for `failed`, because a panel that has not answered yet is not a panel with
47
+ * nothing in it, and a layout that reflowed underneath a person mid-load would
48
+ * be worse than the hole.
16
49
  */
17
- import type { ReactElement, ReactNode } from "react";
18
- import { Col, Flex, Row } from "antd";
50
+ import { useState } from "react";
51
+ import type { CSSProperties, ReactElement, ReactNode } from "react";
52
+ import { Button, Flex } from "antd";
53
+ import { SkinDialog, SkinTheme, useDialogSurface } from "@stapel/tokens-antd/skin";
54
+ import { useT, useTPlural } from "@stapel/core";
55
+ import { spacing } from "@stapel/tokens";
19
56
  import type { FeatureDef } from "@stapel/attributes-react";
20
- import { SearchStateProvider } from "../headless/SearchStateProvider.js";
57
+ import { SearchStateProvider, useSearchState } from "../headless/SearchStateProvider.js";
21
58
  import type { SearchParamsAdapter } from "../headless/SearchStateProvider.js";
59
+ import { useFacetPanel } from "../headless/FacetPanel.js";
60
+ import { useAppliedCount } from "../headless/useAppliedCount.js";
22
61
  import type { ParseSearchStateOptions } from "../state/urlState.js";
62
+ import { buildRangeGroups } from "../state/ranges.js";
63
+ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
23
64
  import { FacetPanelPane } from "./FacetPanelPane.js";
65
+ import type {
66
+ CategoryFilterSlotProps,
67
+ GeoFilterSlotProps,
68
+ } from "./FacetPanelPane.js";
69
+ import { PageSizeSelect } from "./PageSizeSelect.js";
70
+ import { SearchBox } from "./SearchBox.js";
24
71
  import { SearchResultsPane } from "./SearchResultsPane.js";
25
72
  import { SortSelect } from "./SortSelect.js";
26
73
  import type { DegradationNoticeVariant } from "./DegradationNotice.js";
27
74
  import type { SearchCardRenderer } from "./SearchResultCard.js";
28
- import { SearchSkinTheme } from "./theme.js";
29
75
  import { UrlIssueNotice } from "./UrlIssueNotice.js";
30
76
  import type { ThemeModeProp } from "./types.js";
31
77
 
78
+ /** Where the filters live: beside the results, or behind a button in a sheet. */
79
+ export type SearchFiltersLayout = "column" | "sheet";
80
+
81
+ /**
82
+ * The desktop filter rail's width.
83
+ *
84
+ * A rail, not a half-page. `Col md={7}` gave the panel a SHARE of the page, so
85
+ * on a wide screen the filters grew with it — the visual pass measured 570 of
86
+ * 1280px (45%) spent on the controls that narrow a list, next to the list they
87
+ * narrow. Filters are a fixed instrument: a checkbox column needs the width of
88
+ * its longest label and nothing more, and every pixel past that comes out of
89
+ * the results. 280px is that width at the default type step, and it is the
90
+ * same panel the phone sheet draws — one component, two frames.
91
+ */
92
+ export const FILTERS_RAIL_WIDTH = 280;
93
+
94
+ /** The rail: fixed, never squeezed, never grown. */
95
+ const RAIL: CSSProperties = {
96
+ flex: `0 0 ${String(FILTERS_RAIL_WIDTH)}px`,
97
+ maxWidth: FILTERS_RAIL_WIDTH,
98
+ };
99
+
100
+ /** The results take what is left. `minWidth: 0` so a long word inside a card
101
+ * cannot push the grid wider than its column. */
102
+ const RESULTS_COLUMN: CSSProperties = { flex: "1 1 auto", minWidth: 0 };
103
+
32
104
  export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions {
33
105
  /** The URL binding. `useRouterSearchParams()` from `./router` is the
34
106
  * react-router one. */
@@ -36,11 +108,247 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
36
108
  readonly renderCard?: SearchCardRenderer;
37
109
  readonly categoryFeatures?: readonly FeatureDef[];
38
110
  readonly locale?: string;
111
+ /** Render the query box at the top. `false` for a container whose HEADER
112
+ * already mounts `<SearchBox>` — one box per screen, not two. */
113
+ readonly searchBox?: boolean;
114
+ /** BCP-47 tags this deployment indexes, for the language filter. */
115
+ readonly languages?: readonly string[];
116
+ /** The catalogue picker slot — see {@link FacetPanelPaneProps}. */
117
+ readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
118
+ /** The location control slot (`geo-react`). */
119
+ readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
39
120
  /** Container chrome under the results — e.g. the ranking-disclosure link. */
40
121
  readonly footer?: ReactNode;
122
+ /**
123
+ * Rendered at the TOP of the filter column, above everything else.
124
+ *
125
+ * The slot exists for a filter this pair cannot ship and has no named seam
126
+ * for. Whatever a host renders here reads and writes the same URL state as
127
+ * the facets beside it (`useSearchState()`), so it is a filter in every
128
+ * sense that matters and not a decoration bolted on top.
129
+ */
130
+ readonly filtersHeader?: ReactNode;
131
+ /** What this surface calls its result list. See
132
+ * {@link SearchResultsPaneProps.heading}. */
133
+ readonly resultsHeading?: ReactNode;
41
134
  /** How the results surface says what the engine could not do (default
42
135
  * `"banner"`). Handed straight to `<SearchResultsPane>`. */
43
136
  readonly degradationNotice?: DegradationNoticeVariant;
137
+ /**
138
+ * Force the filter surface instead of reading the viewport. For tests and
139
+ * for a host that renders the page inside a phone-width container that is
140
+ * not the viewport — not an escape hatch for "I prefer a column on phones".
141
+ */
142
+ readonly filtersLayout?: SearchFiltersLayout;
143
+ /**
144
+ * Open the phone filter sheet on mount.
145
+ *
146
+ * For a container that deep-links INTO the filters ("Refine this search"
147
+ * from a category page), and for the story that photographs the sheet —
148
+ * a state reached only by a tap is a state nothing outside a browser has
149
+ * ever seen. The person still closes it; this is the initial value, not a
150
+ * controlled one.
151
+ */
152
+ readonly defaultFiltersOpen?: boolean;
153
+ /** Offer a page-size control beside the sort. Default `true`. */
154
+ readonly pageSize?: boolean;
155
+ }
156
+
157
+ interface SearchPageBodyProps {
158
+ readonly renderCard?: SearchCardRenderer;
159
+ readonly categoryFeatures?: readonly FeatureDef[];
160
+ readonly locale?: string;
161
+ readonly searchBox?: boolean;
162
+ readonly languages?: readonly string[];
163
+ readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
164
+ readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
165
+ readonly footer?: ReactNode;
166
+ readonly filtersHeader?: ReactNode;
167
+ readonly resultsHeading?: ReactNode;
168
+ readonly degradationNotice?: DegradationNoticeVariant;
169
+ readonly filtersLayout?: SearchFiltersLayout;
170
+ readonly defaultFiltersOpen?: boolean;
171
+ readonly pageSize?: boolean;
172
+ }
173
+
174
+ /**
175
+ * The two columns, inside the state provider — which is where they have to be,
176
+ * because the layout decision reads the same search the panes read.
177
+ */
178
+ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
179
+ const t = useT();
180
+ const tPlural = useTPlural();
181
+ const { categoryFeatures, locale, filtersHeader } = props;
182
+ const { state } = useSearchState();
183
+ const facets = useFacetPanel({
184
+ ...(categoryFeatures !== undefined ? { categoryFeatures } : {}),
185
+ ...(locale !== undefined ? { locale } : {}),
186
+ });
187
+ const applied = useAppliedCount();
188
+ const surface = useDialogSurface();
189
+ const layout: SearchFiltersLayout =
190
+ props.filtersLayout ?? (surface === "sheet" ? "sheet" : "column");
191
+ const [sheetOpen, setSheetOpen] = useState(props.defaultFiltersOpen === true);
192
+
193
+ /**
194
+ * "Show 25 results", not "Show results".
195
+ *
196
+ * The sheet's own button is the only place a person learns what the filters
197
+ * they just ticked did — the results are behind it. When the engine cannot
198
+ * say how many there are (`countKind: "unknown"`) the button says so by
199
+ * saying nothing: a fabricated number on the one control that commits the
200
+ * change is worse than a generic verb.
201
+ */
202
+ const applyLabel =
203
+ applied.count === null || applied.kind === "unknown"
204
+ ? t(SEARCH_I18N_KEYS.filtersApply)
205
+ : tPlural(
206
+ applied.kind === "at_least"
207
+ ? SEARCH_I18N_KEYS.filtersShowCountAtLeast
208
+ : SEARCH_I18N_KEYS.filtersShowCount,
209
+ { count: applied.count }
210
+ );
211
+
212
+ // "Nothing to filter by" is a LOADED answer of zero facet groups AND no
213
+ // other control with anything to say — see the header. Every clause is a
214
+ // control that would otherwise be hidden behind an empty panel: a numeric
215
+ // range the schema declares, a category or a language the URL carries, a
216
+ // host slot that is filled.
217
+ const ranges = buildRangeGroups({
218
+ state,
219
+ ...(categoryFeatures !== undefined ? { categoryFeatures } : {}),
220
+ });
221
+ const filtersEmpty =
222
+ facets.state.status === "ready" &&
223
+ facets.state.data.length === 0 &&
224
+ facets.activeFilters === 0 &&
225
+ ranges.length === 0 &&
226
+ state.category === undefined &&
227
+ state.lang === undefined &&
228
+ props.renderCategoryFilter === undefined &&
229
+ props.renderGeoFilter === undefined &&
230
+ (props.languages ?? []).length === 0;
231
+ const showFilters = filtersHeader !== undefined || !filtersEmpty;
232
+
233
+ const panel = (
234
+ <Flex vertical gap={spacing[4]}>
235
+ {filtersHeader}
236
+ {/* The facet panel is skipped entirely when the only thing it would draw
237
+ is its own empty state and the column is open for the host's control
238
+ alone — one empty-state illustration under a working filter is still
239
+ a hole, just a smaller one. */}
240
+ {/* In the sheet the dialog's own title already says "Filters"; the panel
241
+ repeating it printed the word twice, one line apart. */}
242
+ {filtersEmpty ? null : (
243
+ <FacetPanelPane
244
+ {...(layout === "sheet" ? { heading: null } : {})}
245
+ {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
246
+ {...(locale !== undefined ? { locale } : {})}
247
+ {...(props.languages !== undefined ? { languages: props.languages } : {})}
248
+ {...(props.renderCategoryFilter !== undefined
249
+ ? { renderCategoryFilter: props.renderCategoryFilter }
250
+ : {})}
251
+ {...(props.renderGeoFilter !== undefined
252
+ ? { renderGeoFilter: props.renderGeoFilter }
253
+ : {})}
254
+ />
255
+ )}
256
+ </Flex>
257
+ );
258
+
259
+ const toolbar = (
260
+ <Flex align="center" wrap gap={spacing[3]}>
261
+ <SortSelect />
262
+ {props.pageSize !== false && <PageSizeSelect />}
263
+ </Flex>
264
+ );
265
+
266
+ const results = (
267
+ <SearchResultsPane
268
+ toolbar={toolbar}
269
+ {...(props.renderCard !== undefined ? { renderCard: props.renderCard } : {})}
270
+ {...(props.footer !== undefined ? { footer: props.footer } : {})}
271
+ {...(props.resultsHeading !== undefined
272
+ ? { heading: props.resultsHeading }
273
+ : {})}
274
+ {...(props.degradationNotice !== undefined
275
+ ? { degradationNotice: props.degradationNotice }
276
+ : {})}
277
+ />
278
+ );
279
+
280
+ return (
281
+ <Flex
282
+ vertical
283
+ gap={spacing[4]}
284
+ data-testid="search-page"
285
+ data-filters={showFilters ? "on" : "off"}
286
+ data-filters-layout={layout}
287
+ >
288
+ {props.searchBox !== false && <SearchBox />}
289
+ <UrlIssueNotice />
290
+
291
+ {showFilters && layout === "sheet" ? (
292
+ <>
293
+ <Button
294
+ block
295
+ data-testid="search-filters-open"
296
+ data-analytics="none"
297
+ data-analytics-reason="opening the filter sheet is a read, not a flow step"
298
+ onClick={() => {
299
+ setSheetOpen(true);
300
+ }}
301
+ >
302
+ {/* "Filters (0)" is a count of nothing printed on the control that
303
+ opens the thing that would produce one. The count appears when
304
+ there is a count. */}
305
+ {facets.activeFilters === 0
306
+ ? t(SEARCH_I18N_KEYS.facetsTitle)
307
+ : t(SEARCH_I18N_KEYS.filtersOpen, { count: facets.activeFilters })}
308
+ </Button>
309
+ <SkinDialog
310
+ open={sheetOpen}
311
+ onClose={() => {
312
+ setSheetOpen(false);
313
+ }}
314
+ title={t(SEARCH_I18N_KEYS.facetsTitle)}
315
+ dismissLabel={t(SEARCH_I18N_KEYS.filtersDismiss)}
316
+ data-testid="search-filters-sheet"
317
+ footer={
318
+ <Button
319
+ block
320
+ type="primary"
321
+ data-testid="search-filters-apply"
322
+ data-analytics="none"
323
+ data-analytics-reason="the filters are already applied; this closes the sheet"
324
+ onClick={() => {
325
+ setSheetOpen(false);
326
+ }}
327
+ >
328
+ {applyLabel}
329
+ </Button>
330
+ }
331
+ >
332
+ {panel}
333
+ </SkinDialog>
334
+ {results}
335
+ </>
336
+ ) : showFilters ? (
337
+ <Flex align="flex-start" gap={spacing[5]} data-testid="search-page-columns">
338
+ <div style={RAIL}>{panel}</div>
339
+ {/* ONE heading and ONE sort control. The page used to caption
340
+ the toolbar "Results" and then mount a pane whose own heading
341
+ says "Results" again — the live /s page printed both, one
342
+ above the other. The pane owns the heading row; the page puts
343
+ the sort control INTO it, and `resultsHeading` puts this
344
+ surface's own word there rather than above it. */}
345
+ <div style={RESULTS_COLUMN}>{results}</div>
346
+ </Flex>
347
+ ) : (
348
+ results
349
+ )}
350
+ </Flex>
351
+ );
44
352
  }
45
353
 
46
354
  export function SearchPage(props: SearchPageProps): ReactElement {
@@ -49,42 +357,41 @@ export function SearchPage(props: SearchPageProps): ReactElement {
49
357
  renderCard,
50
358
  categoryFeatures,
51
359
  locale,
360
+ searchBox,
361
+ languages,
362
+ renderCategoryFilter,
363
+ renderGeoFilter,
52
364
  footer,
365
+ filtersHeader,
366
+ resultsHeading,
53
367
  degradationNotice,
368
+ filtersLayout,
369
+ defaultFiltersOpen,
370
+ pageSize,
54
371
  mode,
55
372
  ...parseOptions
56
373
  } = props;
57
374
 
58
375
  return (
59
- <SearchSkinTheme {...(mode !== undefined ? { mode } : {})}>
376
+ <SkinTheme surface="base" {...(mode !== undefined ? { mode } : {})}>
60
377
  <SearchStateProvider adapter={adapter} {...parseOptions}>
61
- <Flex vertical gap={16} data-testid="search-page">
62
- <UrlIssueNotice />
63
- <Row gutter={[16, 16]}>
64
- <Col xs={24} md={7}>
65
- <FacetPanelPane
66
- {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
67
- {...(locale !== undefined ? { locale } : {})}
68
- />
69
- </Col>
70
- <Col xs={24} md={17}>
71
- {/* ONE heading and ONE sort control. The page used to caption
72
- the toolbar "Results" and then mount a pane whose own heading
73
- says "Results" again the live /s page printed both, one
74
- above the other. The pane owns the heading row; the page puts
75
- the sort control INTO it. */}
76
- <SearchResultsPane
77
- toolbar={<SortSelect />}
78
- {...(renderCard !== undefined ? { renderCard } : {})}
79
- {...(footer !== undefined ? { footer } : {})}
80
- {...(degradationNotice !== undefined
81
- ? { degradationNotice }
82
- : {})}
83
- />
84
- </Col>
85
- </Row>
86
- </Flex>
378
+ <SearchPageBody
379
+ {...(renderCard !== undefined ? { renderCard } : {})}
380
+ {...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
381
+ {...(locale !== undefined ? { locale } : {})}
382
+ {...(searchBox !== undefined ? { searchBox } : {})}
383
+ {...(languages !== undefined ? { languages } : {})}
384
+ {...(renderCategoryFilter !== undefined ? { renderCategoryFilter } : {})}
385
+ {...(renderGeoFilter !== undefined ? { renderGeoFilter } : {})}
386
+ {...(footer !== undefined ? { footer } : {})}
387
+ {...(filtersHeader !== undefined ? { filtersHeader } : {})}
388
+ {...(resultsHeading !== undefined ? { resultsHeading } : {})}
389
+ {...(degradationNotice !== undefined ? { degradationNotice } : {})}
390
+ {...(filtersLayout !== undefined ? { filtersLayout } : {})}
391
+ {...(defaultFiltersOpen !== undefined ? { defaultFiltersOpen } : {})}
392
+ {...(pageSize !== undefined ? { pageSize } : {})}
393
+ />
87
394
  </SearchStateProvider>
88
- </SearchSkinTheme>
395
+ </SkinTheme>
89
396
  );
90
397
  }