@stapel/search-react 0.4.0 → 0.8.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.
- package/CHANGELOG.md +191 -0
- package/README.md +1 -1
- package/dist/default/DegradationNotice.d.ts +7 -0
- package/dist/default/DegradationNotice.d.ts.map +1 -1
- package/dist/default/DegradationNotice.js +17 -6
- package/dist/default/DegradationNotice.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +40 -0
- package/dist/default/FacetGroupControl.d.ts.map +1 -0
- package/dist/default/FacetGroupControl.js +176 -0
- package/dist/default/FacetGroupControl.js.map +1 -0
- package/dist/default/FacetPanelPane.d.ts +54 -3
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +116 -22
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts +28 -0
- package/dist/default/FilterChips.d.ts.map +1 -0
- package/dist/default/FilterChips.js +204 -0
- package/dist/default/FilterChips.js.map +1 -0
- package/dist/default/LanguageSelect.d.ts +9 -0
- package/dist/default/LanguageSelect.d.ts.map +1 -0
- package/dist/default/LanguageSelect.js +72 -0
- package/dist/default/LanguageSelect.js.map +1 -0
- package/dist/default/PageSizeSelect.d.ts +29 -0
- package/dist/default/PageSizeSelect.d.ts.map +1 -0
- package/dist/default/PageSizeSelect.js +25 -0
- package/dist/default/PageSizeSelect.js.map +1 -0
- package/dist/default/RangeFilterRow.d.ts +12 -0
- package/dist/default/RangeFilterRow.d.ts.map +1 -0
- package/dist/default/RangeFilterRow.js +75 -0
- package/dist/default/RangeFilterRow.js.map +1 -0
- package/dist/default/RankingDisclosurePane.d.ts +5 -0
- package/dist/default/RankingDisclosurePane.d.ts.map +1 -1
- package/dist/default/RankingDisclosurePane.js +15 -15
- package/dist/default/RankingDisclosurePane.js.map +1 -1
- package/dist/default/SearchBox.d.ts +13 -0
- package/dist/default/SearchBox.d.ts.map +1 -0
- package/dist/default/SearchBox.js +67 -0
- package/dist/default/SearchBox.js.map +1 -0
- package/dist/default/SearchPage.d.ts +94 -16
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +191 -8
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultCard.d.ts +19 -18
- package/dist/default/SearchResultCard.d.ts.map +1 -1
- package/dist/default/SearchResultCard.js +178 -5
- package/dist/default/SearchResultCard.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +61 -3
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +51 -21
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/SortSelect.d.ts +10 -2
- package/dist/default/SortSelect.d.ts.map +1 -1
- package/dist/default/SortSelect.js +27 -24
- package/dist/default/SortSelect.js.map +1 -1
- package/dist/default/UrlIssueNotice.d.ts.map +1 -1
- package/dist/default/UrlIssueNotice.js +19 -1
- package/dist/default/UrlIssueNotice.js.map +1 -1
- package/dist/default/ViewSwitch.d.ts +66 -0
- package/dist/default/ViewSwitch.d.ts.map +1 -0
- package/dist/default/ViewSwitch.js +49 -0
- package/dist/default/ViewSwitch.js.map +1 -0
- package/dist/default/index.d.ts +38 -15
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +29 -12
- package/dist/default/index.js.map +1 -1
- package/dist/default/sortLabels.d.ts +5 -0
- package/dist/default/sortLabels.d.ts.map +1 -0
- package/dist/default/sortLabels.js +32 -0
- package/dist/default/sortLabels.js.map +1 -0
- package/dist/headless/FacetPanel.d.ts +17 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +23 -2
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/useAppliedCount.d.ts +8 -0
- package/dist/headless/useAppliedCount.d.ts.map +1 -0
- package/dist/headless/useAppliedCount.js +30 -0
- package/dist/headless/useAppliedCount.js.map +1 -0
- package/dist/headless/useScorerNames.d.ts +4 -0
- package/dist/headless/useScorerNames.d.ts.map +1 -0
- package/dist/headless/useScorerNames.js +34 -0
- package/dist/headless/useScorerNames.js.map +1 -0
- package/dist/headless/useSearchBox.d.ts +44 -0
- package/dist/headless/useSearchBox.d.ts.map +1 -0
- package/dist/headless/useSearchBox.js +116 -0
- package/dist/headless/useSearchBox.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +38 -4
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +55 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +101 -4
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +42 -4
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/model/queries.d.ts +26 -2
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +38 -1
- package/dist/model/queries.js.map +1 -1
- package/dist/state/limits.d.ts +44 -0
- package/dist/state/limits.d.ts.map +1 -0
- package/dist/state/limits.js +44 -0
- package/dist/state/limits.js.map +1 -0
- package/dist/state/ranges.d.ts +52 -0
- package/dist/state/ranges.d.ts.map +1 -0
- package/dist/state/ranges.js +108 -0
- package/dist/state/ranges.js.map +1 -0
- package/llms.txt +16 -4
- package/manifest.json +268 -22
- package/nav-manifest.json +1 -1
- package/package.json +15 -10
- package/src/analytics/generated/events.json +1 -1
- package/src/default/DegradationNotice.tsx +34 -12
- package/src/default/FacetGroupControl.tsx +312 -0
- package/src/default/FacetPanelPane.tsx +320 -87
- package/src/default/FilterChips.tsx +415 -0
- package/src/default/LanguageSelect.tsx +93 -0
- package/src/default/PageSizeSelect.tsx +80 -0
- package/src/default/RangeFilterRow.tsx +148 -0
- package/src/default/RankingDisclosurePane.tsx +67 -35
- package/src/default/SearchBox.tsx +130 -0
- package/src/default/SearchPage.tsx +454 -44
- package/src/default/SearchResultCard.tsx +231 -24
- package/src/default/SearchResultsPane.tsx +158 -71
- package/src/default/SortSelect.tsx +50 -37
- package/src/default/UrlIssueNotice.tsx +21 -3
- package/src/default/ViewSwitch.tsx +147 -0
- package/src/default/index.ts +55 -15
- package/src/default/sortLabels.ts +34 -0
- package/src/headless/FacetPanel.tsx +30 -2
- package/src/headless/useAppliedCount.ts +41 -0
- package/src/headless/useScorerNames.ts +36 -0
- package/src/headless/useSearchBox.ts +189 -0
- package/src/i18n/es.ts +46 -4
- package/src/i18n/keys.ts +114 -4
- package/src/i18n/ru.ts +50 -4
- package/src/index.ts +22 -2
- package/src/model/queries.ts +51 -1
- package/src/state/limits.ts +50 -0
- package/src/state/ranges.ts +144 -0
- package/dist/default/ErrorAlert.d.ts +0 -9
- package/dist/default/ErrorAlert.d.ts.map +0 -1
- package/dist/default/ErrorAlert.js +0 -27
- package/dist/default/ErrorAlert.js.map +0 -1
- package/dist/default/theme.d.ts +0 -24
- package/dist/default/theme.d.ts.map +0 -1
- package/dist/default/theme.js +0 -39
- package/dist/default/theme.js.map +0 -1
- package/src/default/ErrorAlert.tsx +0 -46
- package/src/default/theme.tsx +0 -66
|
@@ -1,34 +1,130 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `<SearchPage>` — the composed screen the nav manifest points at:
|
|
3
|
-
*
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
18
|
-
import {
|
|
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 { FilterChips } from "./FilterChips.js";
|
|
70
|
+
import { PageSizeSelect } from "./PageSizeSelect.js";
|
|
71
|
+
import { SearchBox } from "./SearchBox.js";
|
|
24
72
|
import { SearchResultsPane } from "./SearchResultsPane.js";
|
|
25
73
|
import { SortSelect } from "./SortSelect.js";
|
|
74
|
+
import { SEARCH_BUILTIN_VIEWS, ViewSwitch, resolveView } from "./ViewSwitch.js";
|
|
75
|
+
import type { SearchView } from "./ViewSwitch.js";
|
|
26
76
|
import type { DegradationNoticeVariant } from "./DegradationNotice.js";
|
|
27
77
|
import type { SearchCardRenderer } from "./SearchResultCard.js";
|
|
28
|
-
import { SearchSkinTheme } from "./theme.js";
|
|
29
78
|
import { UrlIssueNotice } from "./UrlIssueNotice.js";
|
|
30
79
|
import type { ThemeModeProp } from "./types.js";
|
|
31
80
|
|
|
81
|
+
/** Where the filters live: beside the results, or behind a button in a sheet. */
|
|
82
|
+
export type SearchFiltersLayout = "column" | "sheet";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The desktop filter rail's width.
|
|
86
|
+
*
|
|
87
|
+
* A rail, not a half-page. `Col md={7}` gave the panel a SHARE of the page, so
|
|
88
|
+
* on a wide screen the filters grew with it — the visual pass measured 570 of
|
|
89
|
+
* 1280px (45%) spent on the controls that narrow a list, next to the list they
|
|
90
|
+
* narrow. Filters are a fixed instrument: a checkbox column needs the width of
|
|
91
|
+
* its longest label and nothing more, and every pixel past that comes out of
|
|
92
|
+
* the results. 280px is that width at the default type step, and it is the
|
|
93
|
+
* same panel the phone sheet draws — one component, two frames.
|
|
94
|
+
*/
|
|
95
|
+
export const FILTERS_RAIL_WIDTH = 280;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The rail: fixed width, never squeezed, never grown — and STICKY.
|
|
99
|
+
*
|
|
100
|
+
* A catalogue page is thirty cards long and the filters are at the top of it,
|
|
101
|
+
* so by the fourth row of results the controls that narrow the list are a
|
|
102
|
+
* screenful above the list they narrow: the only way to change a filter after
|
|
103
|
+
* scrolling was to scroll back. The rail now stays put while the results move
|
|
104
|
+
* under it, and scrolls INTERNALLY when its own content is taller than the
|
|
105
|
+
* window (`overflowY: auto` + a viewport-height cap), which is the one place a
|
|
106
|
+
* viewport measure is right — the sticky box's height IS the window's.
|
|
107
|
+
*
|
|
108
|
+
* `alignSelf: flex-start` is load-bearing: a flex child stretches to the row's
|
|
109
|
+
* height by default, and a stretched box has nothing to stick to.
|
|
110
|
+
*/
|
|
111
|
+
const RAIL: CSSProperties = {
|
|
112
|
+
flex: `0 0 ${String(FILTERS_RAIL_WIDTH)}px`,
|
|
113
|
+
maxWidth: FILTERS_RAIL_WIDTH,
|
|
114
|
+
position: "sticky",
|
|
115
|
+
top: 0,
|
|
116
|
+
alignSelf: "flex-start",
|
|
117
|
+
maxHeight: "100dvh",
|
|
118
|
+
overflowY: "auto",
|
|
119
|
+
overscrollBehavior: "contain",
|
|
120
|
+
// Room for the focus ring of the last control against the scroll edge.
|
|
121
|
+
paddingBlockEnd: spacing[2],
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** The results take what is left. `minWidth: 0` so a long word inside a card
|
|
125
|
+
* cannot push the grid wider than its column. */
|
|
126
|
+
const RESULTS_COLUMN: CSSProperties = { flex: "1 1 auto", minWidth: 0 };
|
|
127
|
+
|
|
32
128
|
export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions {
|
|
33
129
|
/** The URL binding. `useRouterSearchParams()` from `./router` is the
|
|
34
130
|
* react-router one. */
|
|
@@ -36,11 +132,314 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
36
132
|
readonly renderCard?: SearchCardRenderer;
|
|
37
133
|
readonly categoryFeatures?: readonly FeatureDef[];
|
|
38
134
|
readonly locale?: string;
|
|
135
|
+
/** Render the query box at the top. `false` for a container whose HEADER
|
|
136
|
+
* already mounts `<SearchBox>` — one box per screen, not two. */
|
|
137
|
+
readonly searchBox?: boolean;
|
|
138
|
+
/** BCP-47 tags this deployment indexes, for the language filter. */
|
|
139
|
+
readonly languages?: readonly string[];
|
|
140
|
+
/** The catalogue picker slot — see {@link FacetPanelPaneProps}. */
|
|
141
|
+
readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
|
|
142
|
+
/** The location control slot (`geo-react`). */
|
|
143
|
+
readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
39
144
|
/** Container chrome under the results — e.g. the ranking-disclosure link. */
|
|
40
145
|
readonly footer?: ReactNode;
|
|
146
|
+
/**
|
|
147
|
+
* Rendered at the TOP of the filter column, above everything else.
|
|
148
|
+
*
|
|
149
|
+
* The slot exists for a filter this pair cannot ship and has no named seam
|
|
150
|
+
* for. Whatever a host renders here reads and writes the same URL state as
|
|
151
|
+
* the facets beside it (`useSearchState()`), so it is a filter in every
|
|
152
|
+
* sense that matters and not a decoration bolted on top.
|
|
153
|
+
*/
|
|
154
|
+
readonly filtersHeader?: ReactNode;
|
|
155
|
+
/** What this surface calls its result list. See
|
|
156
|
+
* {@link SearchResultsPaneProps.heading}. */
|
|
157
|
+
readonly resultsHeading?: ReactNode;
|
|
41
158
|
/** How the results surface says what the engine could not do (default
|
|
42
159
|
* `"banner"`). Handed straight to `<SearchResultsPane>`. */
|
|
43
160
|
readonly degradationNotice?: DegradationNoticeVariant;
|
|
161
|
+
/**
|
|
162
|
+
* Force the filter surface instead of reading the viewport. For tests and
|
|
163
|
+
* for a host that renders the page inside a phone-width container that is
|
|
164
|
+
* not the viewport — not an escape hatch for "I prefer a column on phones".
|
|
165
|
+
*/
|
|
166
|
+
readonly filtersLayout?: SearchFiltersLayout;
|
|
167
|
+
/**
|
|
168
|
+
* Open the phone filter sheet on mount.
|
|
169
|
+
*
|
|
170
|
+
* For a container that deep-links INTO the filters ("Refine this search"
|
|
171
|
+
* from a category page), and for the story that photographs the sheet —
|
|
172
|
+
* a state reached only by a tap is a state nothing outside a browser has
|
|
173
|
+
* ever seen. The person still closes it; this is the initial value, not a
|
|
174
|
+
* controlled one.
|
|
175
|
+
*/
|
|
176
|
+
readonly defaultFiltersOpen?: boolean;
|
|
177
|
+
/** Offer a page-size control beside the sort. Default `true`. */
|
|
178
|
+
readonly pageSize?: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* The trail above the heading — "Home / Cars / Sedans".
|
|
181
|
+
*
|
|
182
|
+
* A SLOT, because a breadcrumb is a walk up the CATEGORY tree and the tree
|
|
183
|
+
* belongs to `categories-react`; a search package knows the `category`
|
|
184
|
+
* parameter's value and nothing about its ancestors. Rendered above the
|
|
185
|
+
* results heading, inside the results column, so it sits over the list it
|
|
186
|
+
* describes rather than over the whole two-column page.
|
|
187
|
+
*/
|
|
188
|
+
readonly breadcrumb?: ReactNode;
|
|
189
|
+
/**
|
|
190
|
+
* The arrangements the view switch offers. Default: the pair's own list and
|
|
191
|
+
* grid. A deployment adds its own — `{ id: "map", labelKey, icon, render }`
|
|
192
|
+
* — and the switch treats it like the two that ship; see {@link SearchView}.
|
|
193
|
+
*
|
|
194
|
+
* A single view draws no switch at all.
|
|
195
|
+
*/
|
|
196
|
+
readonly views?: readonly SearchView[];
|
|
197
|
+
/** Which arrangement the page opens in. Default: the first of `views`. */
|
|
198
|
+
readonly defaultView?: string;
|
|
199
|
+
/** Told when the arrangement changes, for a host that remembers it. The
|
|
200
|
+
* view is NOT url state — see `<ViewSwitch>` for why. */
|
|
201
|
+
readonly onViewChange?: (id: string) => void;
|
|
202
|
+
/**
|
|
203
|
+
* The action at the trailing end of the results toolbar — conventionally
|
|
204
|
+
* "notify me about new ones".
|
|
205
|
+
*
|
|
206
|
+
* A SLOT, and it cannot be anything else: saving a search and mailing its
|
|
207
|
+
* new hits is a subscription with an owner, a schedule and a consent record,
|
|
208
|
+
* none of which this pair has. What the pair CAN state is where such a
|
|
209
|
+
* control belongs and that the page keeps room for it.
|
|
210
|
+
*/
|
|
211
|
+
readonly resultsAction?: ReactNode;
|
|
212
|
+
/**
|
|
213
|
+
* Heading level for the results caption. Default `1` — on a results SCREEN
|
|
214
|
+
* the list's name is the page's heading. A container that already prints its
|
|
215
|
+
* own `<h1>` above this page passes a lower level.
|
|
216
|
+
*/
|
|
217
|
+
readonly resultsHeadingLevel?: 1 | 2 | 3 | 4 | 5;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
interface SearchPageBodyProps {
|
|
221
|
+
readonly renderCard?: SearchCardRenderer;
|
|
222
|
+
readonly categoryFeatures?: readonly FeatureDef[];
|
|
223
|
+
readonly locale?: string;
|
|
224
|
+
readonly searchBox?: boolean;
|
|
225
|
+
readonly languages?: readonly string[];
|
|
226
|
+
readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
|
|
227
|
+
readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
228
|
+
readonly footer?: ReactNode;
|
|
229
|
+
readonly filtersHeader?: ReactNode;
|
|
230
|
+
readonly resultsHeading?: ReactNode;
|
|
231
|
+
readonly degradationNotice?: DegradationNoticeVariant;
|
|
232
|
+
readonly filtersLayout?: SearchFiltersLayout;
|
|
233
|
+
readonly defaultFiltersOpen?: boolean;
|
|
234
|
+
readonly pageSize?: boolean;
|
|
235
|
+
readonly breadcrumb?: ReactNode;
|
|
236
|
+
readonly views?: readonly SearchView[];
|
|
237
|
+
readonly defaultView?: string;
|
|
238
|
+
readonly onViewChange?: (id: string) => void;
|
|
239
|
+
readonly resultsAction?: ReactNode;
|
|
240
|
+
readonly resultsHeadingLevel?: 1 | 2 | 3 | 4 | 5;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The two columns, inside the state provider — which is where they have to be,
|
|
245
|
+
* because the layout decision reads the same search the panes read.
|
|
246
|
+
*/
|
|
247
|
+
function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
248
|
+
const t = useT();
|
|
249
|
+
const tPlural = useTPlural();
|
|
250
|
+
const { categoryFeatures, locale, filtersHeader } = props;
|
|
251
|
+
const { state } = useSearchState();
|
|
252
|
+
const facets = useFacetPanel({
|
|
253
|
+
...(categoryFeatures !== undefined ? { categoryFeatures } : {}),
|
|
254
|
+
...(locale !== undefined ? { locale } : {}),
|
|
255
|
+
});
|
|
256
|
+
const applied = useAppliedCount();
|
|
257
|
+
const surface = useDialogSurface();
|
|
258
|
+
const layout: SearchFiltersLayout =
|
|
259
|
+
props.filtersLayout ?? (surface === "sheet" ? "sheet" : "column");
|
|
260
|
+
const [sheetOpen, setSheetOpen] = useState(props.defaultFiltersOpen === true);
|
|
261
|
+
|
|
262
|
+
// How the results are ARRANGED. Component state, not URL state: it changes
|
|
263
|
+
// how the same answer is drawn, never what the answer is, so it must not
|
|
264
|
+
// rewrite the meaning of a link somebody shared (`<ViewSwitch>` §the view is
|
|
265
|
+
// not URL state).
|
|
266
|
+
const views = props.views ?? SEARCH_BUILTIN_VIEWS;
|
|
267
|
+
const [viewId, setViewId] = useState<string | undefined>(props.defaultView);
|
|
268
|
+
const view = resolveView(views, viewId) ?? { id: "", labelKey: "" };
|
|
269
|
+
const changeView = (next: string): void => {
|
|
270
|
+
setViewId(next);
|
|
271
|
+
props.onViewChange?.(next);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* "Show 25 results", not "Show results".
|
|
276
|
+
*
|
|
277
|
+
* The sheet's own button is the only place a person learns what the filters
|
|
278
|
+
* they just ticked did — the results are behind it. When the engine cannot
|
|
279
|
+
* say how many there are (`countKind: "unknown"`) the button says so by
|
|
280
|
+
* saying nothing: a fabricated number on the one control that commits the
|
|
281
|
+
* change is worse than a generic verb.
|
|
282
|
+
*/
|
|
283
|
+
const applyLabel =
|
|
284
|
+
applied.count === null || applied.kind === "unknown"
|
|
285
|
+
? t(SEARCH_I18N_KEYS.filtersApply)
|
|
286
|
+
: tPlural(
|
|
287
|
+
applied.kind === "at_least"
|
|
288
|
+
? SEARCH_I18N_KEYS.filtersShowCountAtLeast
|
|
289
|
+
: SEARCH_I18N_KEYS.filtersShowCount,
|
|
290
|
+
{ count: applied.count }
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
// "Nothing to filter by" is a LOADED answer of zero facet groups AND no
|
|
294
|
+
// other control with anything to say — see the header. Every clause is a
|
|
295
|
+
// control that would otherwise be hidden behind an empty panel: a numeric
|
|
296
|
+
// range the schema declares, a category or a language the URL carries, a
|
|
297
|
+
// host slot that is filled.
|
|
298
|
+
const ranges = buildRangeGroups({
|
|
299
|
+
state,
|
|
300
|
+
...(categoryFeatures !== undefined ? { categoryFeatures } : {}),
|
|
301
|
+
});
|
|
302
|
+
const filtersEmpty =
|
|
303
|
+
facets.state.status === "ready" &&
|
|
304
|
+
facets.state.data.length === 0 &&
|
|
305
|
+
facets.activeFilters === 0 &&
|
|
306
|
+
ranges.length === 0 &&
|
|
307
|
+
state.category === undefined &&
|
|
308
|
+
state.lang === undefined &&
|
|
309
|
+
props.renderCategoryFilter === undefined &&
|
|
310
|
+
props.renderGeoFilter === undefined &&
|
|
311
|
+
(props.languages ?? []).length === 0;
|
|
312
|
+
const showFilters = filtersHeader !== undefined || !filtersEmpty;
|
|
313
|
+
|
|
314
|
+
const panel = (
|
|
315
|
+
<Flex vertical gap={spacing[4]}>
|
|
316
|
+
{filtersHeader}
|
|
317
|
+
{/* The facet panel is skipped entirely when the only thing it would draw
|
|
318
|
+
is its own empty state and the column is open for the host's control
|
|
319
|
+
alone — one empty-state illustration under a working filter is still
|
|
320
|
+
a hole, just a smaller one. */}
|
|
321
|
+
{/* In the sheet the dialog's own title already says "Filters"; the panel
|
|
322
|
+
repeating it printed the word twice, one line apart. */}
|
|
323
|
+
{filtersEmpty ? null : (
|
|
324
|
+
<FacetPanelPane
|
|
325
|
+
{...(layout === "sheet" ? { heading: null } : {})}
|
|
326
|
+
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
327
|
+
{...(locale !== undefined ? { locale } : {})}
|
|
328
|
+
{...(props.languages !== undefined ? { languages: props.languages } : {})}
|
|
329
|
+
{...(props.renderCategoryFilter !== undefined
|
|
330
|
+
? { renderCategoryFilter: props.renderCategoryFilter }
|
|
331
|
+
: {})}
|
|
332
|
+
{...(props.renderGeoFilter !== undefined
|
|
333
|
+
? { renderGeoFilter: props.renderGeoFilter }
|
|
334
|
+
: {})}
|
|
335
|
+
/>
|
|
336
|
+
)}
|
|
337
|
+
</Flex>
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
// The toolbar over the results: how they are ARRANGED, how they are ORDERED,
|
|
341
|
+
// how many per page — and the surface's own action at the trailing end.
|
|
342
|
+
const toolbar = (
|
|
343
|
+
<Flex align="center" wrap gap={spacing[3]}>
|
|
344
|
+
<ViewSwitch views={views} value={view.id} onChange={changeView} />
|
|
345
|
+
<SortSelect />
|
|
346
|
+
{props.pageSize !== false && <PageSizeSelect />}
|
|
347
|
+
{props.resultsAction}
|
|
348
|
+
</Flex>
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const results = (
|
|
352
|
+
<SearchResultsPane
|
|
353
|
+
toolbar={toolbar}
|
|
354
|
+
headingLevel={props.resultsHeadingLevel ?? 1}
|
|
355
|
+
{...(view.render !== undefined ? { renderResults: view.render } : {})}
|
|
356
|
+
{...(view.layout !== undefined ? { layout: view.layout } : {})}
|
|
357
|
+
{...(props.renderCard !== undefined ? { renderCard: props.renderCard } : {})}
|
|
358
|
+
{...(props.footer !== undefined ? { footer: props.footer } : {})}
|
|
359
|
+
{...(props.resultsHeading !== undefined
|
|
360
|
+
? { heading: props.resultsHeading }
|
|
361
|
+
: {})}
|
|
362
|
+
{...(props.degradationNotice !== undefined
|
|
363
|
+
? { degradationNotice: props.degradationNotice }
|
|
364
|
+
: {})}
|
|
365
|
+
/>
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<Flex
|
|
370
|
+
vertical
|
|
371
|
+
gap={spacing[4]}
|
|
372
|
+
data-testid="search-page"
|
|
373
|
+
data-filters={showFilters ? "on" : "off"}
|
|
374
|
+
data-filters-layout={layout}
|
|
375
|
+
>
|
|
376
|
+
{props.searchBox !== false && <SearchBox />}
|
|
377
|
+
{props.breadcrumb !== undefined && (
|
|
378
|
+
<div data-testid="search-breadcrumb">{props.breadcrumb}</div>
|
|
379
|
+
)}
|
|
380
|
+
<UrlIssueNotice />
|
|
381
|
+
|
|
382
|
+
{showFilters && layout === "sheet" ? (
|
|
383
|
+
<>
|
|
384
|
+
{/* The phone's filter row. It REPLACES the full-width "Filters (3)"
|
|
385
|
+
button that used to stand here: that button said how many
|
|
386
|
+
constraints were applied and not one word about WHICH, and put
|
|
387
|
+
every filter behind one tap onto a sheet you then had to scroll.
|
|
388
|
+
The chips state the filters on the page — and the leading chip is
|
|
389
|
+
still the whole panel, for the person who wants all of it. */}
|
|
390
|
+
<FilterChips
|
|
391
|
+
onOpenAll={() => {
|
|
392
|
+
setSheetOpen(true);
|
|
393
|
+
}}
|
|
394
|
+
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
395
|
+
{...(locale !== undefined ? { locale } : {})}
|
|
396
|
+
{...(props.renderGeoFilter !== undefined
|
|
397
|
+
? { renderGeoFilter: props.renderGeoFilter }
|
|
398
|
+
: {})}
|
|
399
|
+
/>
|
|
400
|
+
<SkinDialog
|
|
401
|
+
open={sheetOpen}
|
|
402
|
+
onClose={() => {
|
|
403
|
+
setSheetOpen(false);
|
|
404
|
+
}}
|
|
405
|
+
title={t(SEARCH_I18N_KEYS.facetsTitle)}
|
|
406
|
+
dismissLabel={t(SEARCH_I18N_KEYS.filtersDismiss)}
|
|
407
|
+
data-testid="search-filters-sheet"
|
|
408
|
+
footer={
|
|
409
|
+
<Button
|
|
410
|
+
block
|
|
411
|
+
type="primary"
|
|
412
|
+
data-testid="search-filters-apply"
|
|
413
|
+
data-analytics="none"
|
|
414
|
+
data-analytics-reason="the filters are already applied; this closes the sheet"
|
|
415
|
+
onClick={() => {
|
|
416
|
+
setSheetOpen(false);
|
|
417
|
+
}}
|
|
418
|
+
>
|
|
419
|
+
{applyLabel}
|
|
420
|
+
</Button>
|
|
421
|
+
}
|
|
422
|
+
>
|
|
423
|
+
{panel}
|
|
424
|
+
</SkinDialog>
|
|
425
|
+
{results}
|
|
426
|
+
</>
|
|
427
|
+
) : showFilters ? (
|
|
428
|
+
<Flex align="flex-start" gap={spacing[5]} data-testid="search-page-columns">
|
|
429
|
+
<div style={RAIL}>{panel}</div>
|
|
430
|
+
{/* ONE heading and ONE sort control. The page used to caption
|
|
431
|
+
the toolbar "Results" and then mount a pane whose own heading
|
|
432
|
+
says "Results" again — the live /s page printed both, one
|
|
433
|
+
above the other. The pane owns the heading row; the page puts
|
|
434
|
+
the sort control INTO it, and `resultsHeading` puts this
|
|
435
|
+
surface's own word there rather than above it. */}
|
|
436
|
+
<div style={RESULTS_COLUMN}>{results}</div>
|
|
437
|
+
</Flex>
|
|
438
|
+
) : (
|
|
439
|
+
results
|
|
440
|
+
)}
|
|
441
|
+
</Flex>
|
|
442
|
+
);
|
|
44
443
|
}
|
|
45
444
|
|
|
46
445
|
export function SearchPage(props: SearchPageProps): ReactElement {
|
|
@@ -49,42 +448,53 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
49
448
|
renderCard,
|
|
50
449
|
categoryFeatures,
|
|
51
450
|
locale,
|
|
451
|
+
searchBox,
|
|
452
|
+
languages,
|
|
453
|
+
renderCategoryFilter,
|
|
454
|
+
renderGeoFilter,
|
|
52
455
|
footer,
|
|
456
|
+
filtersHeader,
|
|
457
|
+
resultsHeading,
|
|
53
458
|
degradationNotice,
|
|
459
|
+
filtersLayout,
|
|
460
|
+
defaultFiltersOpen,
|
|
461
|
+
pageSize,
|
|
462
|
+
breadcrumb,
|
|
463
|
+
views,
|
|
464
|
+
defaultView,
|
|
465
|
+
onViewChange,
|
|
466
|
+
resultsAction,
|
|
467
|
+
resultsHeadingLevel,
|
|
54
468
|
mode,
|
|
55
469
|
...parseOptions
|
|
56
470
|
} = props;
|
|
57
471
|
|
|
58
472
|
return (
|
|
59
|
-
<
|
|
473
|
+
<SkinTheme surface="base" {...(mode !== undefined ? { mode } : {})}>
|
|
60
474
|
<SearchStateProvider adapter={adapter} {...parseOptions}>
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
/>
|
|
84
|
-
</Col>
|
|
85
|
-
</Row>
|
|
86
|
-
</Flex>
|
|
475
|
+
<SearchPageBody
|
|
476
|
+
{...(renderCard !== undefined ? { renderCard } : {})}
|
|
477
|
+
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
478
|
+
{...(locale !== undefined ? { locale } : {})}
|
|
479
|
+
{...(searchBox !== undefined ? { searchBox } : {})}
|
|
480
|
+
{...(languages !== undefined ? { languages } : {})}
|
|
481
|
+
{...(renderCategoryFilter !== undefined ? { renderCategoryFilter } : {})}
|
|
482
|
+
{...(renderGeoFilter !== undefined ? { renderGeoFilter } : {})}
|
|
483
|
+
{...(footer !== undefined ? { footer } : {})}
|
|
484
|
+
{...(filtersHeader !== undefined ? { filtersHeader } : {})}
|
|
485
|
+
{...(resultsHeading !== undefined ? { resultsHeading } : {})}
|
|
486
|
+
{...(degradationNotice !== undefined ? { degradationNotice } : {})}
|
|
487
|
+
{...(filtersLayout !== undefined ? { filtersLayout } : {})}
|
|
488
|
+
{...(defaultFiltersOpen !== undefined ? { defaultFiltersOpen } : {})}
|
|
489
|
+
{...(pageSize !== undefined ? { pageSize } : {})}
|
|
490
|
+
{...(breadcrumb !== undefined ? { breadcrumb } : {})}
|
|
491
|
+
{...(views !== undefined ? { views } : {})}
|
|
492
|
+
{...(defaultView !== undefined ? { defaultView } : {})}
|
|
493
|
+
{...(onViewChange !== undefined ? { onViewChange } : {})}
|
|
494
|
+
{...(resultsAction !== undefined ? { resultsAction } : {})}
|
|
495
|
+
{...(resultsHeadingLevel !== undefined ? { resultsHeadingLevel } : {})}
|
|
496
|
+
/>
|
|
87
497
|
</SearchStateProvider>
|
|
88
|
-
</
|
|
498
|
+
</SkinTheme>
|
|
89
499
|
);
|
|
90
500
|
}
|