@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,5 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `<FacetPanelPane>` — the antd
|
|
2
|
+
* `<FacetPanelPane>` — the antd FILTER panel: everything that narrows a
|
|
3
|
+
* search, in the order a person narrows it.
|
|
4
|
+
*
|
|
5
|
+
* Category → language → location → numeric ranges → facet checkboxes. The last
|
|
6
|
+
* of those was, for three releases, the only one that existed: six of the nine
|
|
7
|
+
* state setters had no control at all, so a price range or a location could
|
|
8
|
+
* only be applied by editing the address bar (audit S-2, S-7).
|
|
3
9
|
*
|
|
4
10
|
* Three things it is obliged to render, all of which a naive panel drops:
|
|
5
11
|
*
|
|
@@ -12,67 +18,265 @@
|
|
|
12
18
|
* - `skipped` — the slugs the server did not count at all. Their options show
|
|
13
19
|
* "not counted", never `0`. A silent zero there is the same defect class as
|
|
14
20
|
* `data ?? []`: a number that looks like an answer and is not one.
|
|
21
|
+
*
|
|
22
|
+
* ── Two slots, and why they are slots ─────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* `renderCategoryFilter` and `renderGeoFilter` are filled by OTHER pairs:
|
|
25
|
+
* choosing a category means walking the catalogue tree (`categories-react`),
|
|
26
|
+
* and turning an address into a coordinate needs a geocoder and a map
|
|
27
|
+
* (`geo-react`). Neither belongs in a search package, and importing either
|
|
28
|
+
* would tie a storefront's search to a catalogue it might not have.
|
|
29
|
+
*
|
|
30
|
+
* What this panel does NOT do is pretend the slot is optional. An unfilled
|
|
31
|
+
* slot renders `SlotPlaceholder` in development — a named, visible hole rather
|
|
32
|
+
* than a silent absence — and, in every build, any constraint the URL already
|
|
33
|
+
* carries gets a control that REMOVES it. A shared link that narrows to a
|
|
34
|
+
* category or a point must never leave a person with no way to widen it again.
|
|
15
35
|
*/
|
|
16
|
-
import type { ReactElement } from "react";
|
|
17
|
-
import {
|
|
18
|
-
|
|
36
|
+
import type { ReactElement, ReactNode } from "react";
|
|
37
|
+
import {
|
|
38
|
+
Alert,
|
|
39
|
+
Button,
|
|
40
|
+
Divider,
|
|
41
|
+
Flex,
|
|
42
|
+
InputNumber,
|
|
43
|
+
Typography,
|
|
44
|
+
} from "antd";
|
|
45
|
+
import { SlotPlaceholder, isDevBuild, useT } from "@stapel/core";
|
|
46
|
+
import {
|
|
47
|
+
EmptyState,
|
|
48
|
+
ErrorAlert,
|
|
49
|
+
LoadList,
|
|
50
|
+
SkinTheme,
|
|
51
|
+
} from "@stapel/tokens-antd/skin";
|
|
52
|
+
import { spacing } from "@stapel/tokens";
|
|
53
|
+
import { featureName } from "@stapel/attributes-react";
|
|
19
54
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
55
|
+
import type { SearchGeo } from "../api/types.js";
|
|
20
56
|
import { FacetPanel } from "../headless/FacetPanel.js";
|
|
21
|
-
import
|
|
57
|
+
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
58
|
+
import { FacetGroupControl } from "./FacetGroupControl.js";
|
|
59
|
+
import { buildRangeGroups } from "../state/ranges.js";
|
|
22
60
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
61
|
+
import { LanguageSelect } from "./LanguageSelect.js";
|
|
62
|
+
import { RangeFilterRow } from "./RangeFilterRow.js";
|
|
25
63
|
import type { ThemeModeProp } from "./types.js";
|
|
26
64
|
|
|
65
|
+
/** What a host's category control is handed. */
|
|
66
|
+
export interface CategoryFilterSlotProps {
|
|
67
|
+
/** The `root/leaf` path the search is narrowed to, if any. */
|
|
68
|
+
readonly value: string | undefined;
|
|
69
|
+
/** `null` widens the search back to the whole catalogue. */
|
|
70
|
+
readonly onChange: (path: string | null) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What a host's location control is handed. */
|
|
74
|
+
export interface GeoFilterSlotProps {
|
|
75
|
+
readonly value: SearchGeo | undefined;
|
|
76
|
+
/** `null` clears the location constraint. */
|
|
77
|
+
readonly onChange: (geo: SearchGeo | null) => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
27
80
|
export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
28
|
-
/** The category's feature schema — the source of option LABELS
|
|
81
|
+
/** The category's feature schema — the source of option LABELS and of which
|
|
82
|
+
* slugs get a numeric range row. */
|
|
29
83
|
readonly categoryFeatures?: readonly FeatureDef[];
|
|
30
84
|
readonly locale?: string;
|
|
31
85
|
readonly enabled?: boolean;
|
|
86
|
+
/** The catalogue picker (`categories-react`'s `CategoryPickerField`, bound
|
|
87
|
+
* to a path). Unfilled, an active category still gets a "clear" control. */
|
|
88
|
+
readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
|
|
89
|
+
/** The location control (`geo-react`). Unfilled, a location that arrived in
|
|
90
|
+
* the URL still gets its radius and a "clear" control. */
|
|
91
|
+
readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
92
|
+
/** BCP-47 tags this deployment indexes — see {@link LanguageSelect}. */
|
|
93
|
+
readonly languages?: readonly string[];
|
|
94
|
+
/**
|
|
95
|
+
* What the panel calls itself. `null` draws no title at all — for a surface
|
|
96
|
+
* that has ALREADY named it, which the phone sheet has: its dialog title and
|
|
97
|
+
* the panel's own heading both said "Filters", one under the other, in every
|
|
98
|
+
* shot of the open sheet. The row itself stays either way, because the
|
|
99
|
+
* "Clear all" control lives in it.
|
|
100
|
+
*/
|
|
101
|
+
readonly heading?: ReactNode;
|
|
32
102
|
}
|
|
33
103
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}): ReactElement {
|
|
104
|
+
/** The category constraint: the host's control, or the door out of it. */
|
|
105
|
+
function CategoryFilter(props: {
|
|
106
|
+
render?: (slot: CategoryFilterSlotProps) => ReactNode;
|
|
107
|
+
}): ReactElement | null {
|
|
39
108
|
const t = useT();
|
|
40
|
-
const {
|
|
109
|
+
const { state, setCategory } = useSearchState();
|
|
110
|
+
const value = state.category;
|
|
111
|
+
|
|
112
|
+
if (props.render !== undefined) {
|
|
113
|
+
return (
|
|
114
|
+
<Flex vertical gap={spacing[1]} data-testid="search-category">
|
|
115
|
+
<Typography.Text strong>{t(SEARCH_I18N_KEYS.categoryTitle)}</Typography.Text>
|
|
116
|
+
{props.render({
|
|
117
|
+
value,
|
|
118
|
+
onChange: (path) => {
|
|
119
|
+
setCategory(path);
|
|
120
|
+
},
|
|
121
|
+
})}
|
|
122
|
+
</Flex>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (value === undefined) {
|
|
127
|
+
return <SlotPlaceholder name="renderCategoryFilter" data-testid="search-category-slot" />;
|
|
128
|
+
}
|
|
129
|
+
|
|
41
130
|
return (
|
|
42
|
-
<Flex
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
131
|
+
<Flex vertical gap={spacing[1]} data-testid="search-category">
|
|
132
|
+
<Typography.Text strong>{t(SEARCH_I18N_KEYS.categoryTitle)}</Typography.Text>
|
|
133
|
+
<Typography.Text type="secondary">
|
|
134
|
+
{t(SEARCH_I18N_KEYS.categoryCurrent, { path: value })}
|
|
135
|
+
</Typography.Text>
|
|
136
|
+
<Button
|
|
137
|
+
style={{ alignSelf: "flex-start" }}
|
|
138
|
+
data-testid="search-category-clear"
|
|
46
139
|
data-analytics="none"
|
|
47
140
|
data-analytics-reason="a filter is a read, not a flow step"
|
|
48
|
-
|
|
49
|
-
|
|
141
|
+
onClick={() => {
|
|
142
|
+
setCategory(null);
|
|
50
143
|
}}
|
|
51
144
|
>
|
|
52
|
-
{
|
|
53
|
-
</
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
145
|
+
{t(SEARCH_I18N_KEYS.categoryClear)}
|
|
146
|
+
</Button>
|
|
147
|
+
</Flex>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The location constraint.
|
|
153
|
+
*
|
|
154
|
+
* SETTING a centre needs a geocoder, which is the deployment's and
|
|
155
|
+
* `geo-react`'s. ADJUSTING one that already exists does not: the radius is a
|
|
156
|
+
* number in the URL, and a link shared with `lat/lon/radius_km` is a link this
|
|
157
|
+
* panel can widen, tighten and clear without knowing what a map is. That is the
|
|
158
|
+
* difference between a slot and a hole.
|
|
159
|
+
*/
|
|
160
|
+
function GeoFilter(props: {
|
|
161
|
+
render?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
162
|
+
}): ReactElement | null {
|
|
163
|
+
const t = useT();
|
|
164
|
+
const { state, setGeo } = useSearchState();
|
|
165
|
+
const geo = state.geo;
|
|
166
|
+
|
|
167
|
+
const slot =
|
|
168
|
+
props.render !== undefined
|
|
169
|
+
? props.render({
|
|
170
|
+
value: geo,
|
|
171
|
+
onChange: (next) => {
|
|
172
|
+
setGeo(next);
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
: geo === undefined
|
|
176
|
+
? <SlotPlaceholder name="renderGeoFilter" data-testid="search-geo-slot" />
|
|
177
|
+
: null;
|
|
178
|
+
|
|
179
|
+
if (geo === undefined) {
|
|
180
|
+
// An unfilled slot is a NAMED hole in development and nothing at all in a
|
|
181
|
+
// production build — so the heading has to follow the placeholder rather
|
|
182
|
+
// than outlive it. It did not, and the live desktop panel printed
|
|
183
|
+
// "Location" over empty space with no location control under it
|
|
184
|
+
// (class NC-ORPHANFIELD): a label is a promise that a control follows.
|
|
185
|
+
if (props.render === undefined && !isDevBuild()) return null;
|
|
186
|
+
return slot === null ? null : (
|
|
187
|
+
<Flex vertical gap={spacing[1]} data-testid="search-geo">
|
|
188
|
+
<Typography.Text strong>{t(SEARCH_I18N_KEYS.geoTitle)}</Typography.Text>
|
|
189
|
+
{slot}
|
|
190
|
+
</Flex>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<Flex vertical gap={spacing[1]} data-testid="search-geo">
|
|
196
|
+
<Typography.Text strong>{t(SEARCH_I18N_KEYS.geoTitle)}</Typography.Text>
|
|
197
|
+
{slot}
|
|
198
|
+
<Typography.Text type="secondary" data-testid="search-geo-summary">
|
|
199
|
+
{geo.kind === "bbox"
|
|
200
|
+
? t(SEARCH_I18N_KEYS.geoBox)
|
|
201
|
+
: t(SEARCH_I18N_KEYS.geoCenter, {
|
|
202
|
+
lat: geo.lat.toFixed(3),
|
|
203
|
+
lon: geo.lon.toFixed(3),
|
|
204
|
+
})}
|
|
205
|
+
</Typography.Text>
|
|
206
|
+
{geo.kind === "center" && (
|
|
207
|
+
<Flex gap={spacing[2]} align="center" wrap>
|
|
208
|
+
<Typography.Text type="secondary" aria-hidden="true">
|
|
209
|
+
{t(SEARCH_I18N_KEYS.geoRadiusLabel)}
|
|
210
|
+
</Typography.Text>
|
|
211
|
+
<InputNumber
|
|
212
|
+
min={1}
|
|
213
|
+
value={geo.radiusKm ?? null}
|
|
214
|
+
aria-label={t(SEARCH_I18N_KEYS.geoRadiusLabel)}
|
|
215
|
+
data-testid="search-geo-radius"
|
|
216
|
+
onChange={(value) => {
|
|
217
|
+
setGeo({
|
|
218
|
+
kind: "center",
|
|
219
|
+
lat: geo.lat,
|
|
220
|
+
lon: geo.lon,
|
|
221
|
+
...(typeof value === "number" ? { radiusKm: value } : {}),
|
|
222
|
+
});
|
|
223
|
+
}}
|
|
224
|
+
/>
|
|
225
|
+
</Flex>
|
|
65
226
|
)}
|
|
227
|
+
<Button
|
|
228
|
+
style={{ alignSelf: "flex-start" }}
|
|
229
|
+
data-testid="search-geo-clear"
|
|
230
|
+
data-analytics="none"
|
|
231
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
232
|
+
onClick={() => {
|
|
233
|
+
setGeo(null);
|
|
234
|
+
}}
|
|
235
|
+
>
|
|
236
|
+
{t(SEARCH_I18N_KEYS.geoClear)}
|
|
237
|
+
</Button>
|
|
66
238
|
</Flex>
|
|
67
239
|
);
|
|
68
240
|
}
|
|
69
241
|
|
|
242
|
+
/**
|
|
243
|
+
* The slugs the server skipped, named the way the panel names everything else.
|
|
244
|
+
*
|
|
245
|
+
* `facet_meta.skipped` is a list of index slugs (`power_w`); the sentence that
|
|
246
|
+
* reports them was printing exactly that, so a shopper read "These filters
|
|
247
|
+
* were not counted for this search: power_w". The category schema is already
|
|
248
|
+
* in this component for the option labels — it names these too. A slug the
|
|
249
|
+
* schema does not know stays as it is, because a made-up name would be worse
|
|
250
|
+
* than an honest identifier.
|
|
251
|
+
*/
|
|
252
|
+
function skippedNames(
|
|
253
|
+
slugs: readonly string[],
|
|
254
|
+
features: readonly FeatureDef[] | undefined,
|
|
255
|
+
t: (key: string) => string
|
|
256
|
+
): string {
|
|
257
|
+
return slugs
|
|
258
|
+
.map((slug) => {
|
|
259
|
+
const feature = features?.find((candidate) => candidate.slug === slug);
|
|
260
|
+
if (feature === undefined) return slug;
|
|
261
|
+
const name = t(featureName(feature));
|
|
262
|
+
return name.length > 0 ? name : slug;
|
|
263
|
+
})
|
|
264
|
+
.join(", ");
|
|
265
|
+
}
|
|
266
|
+
|
|
70
267
|
export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
71
268
|
const t = useT();
|
|
72
|
-
const
|
|
269
|
+
const { state } = useSearchState();
|
|
270
|
+
const ranges = buildRangeGroups({
|
|
271
|
+
state,
|
|
272
|
+
...(props.categoryFeatures !== undefined
|
|
273
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
274
|
+
: {}),
|
|
275
|
+
t,
|
|
276
|
+
});
|
|
73
277
|
|
|
74
278
|
return (
|
|
75
|
-
<
|
|
279
|
+
<SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
76
280
|
<FacetPanel
|
|
77
281
|
{...(props.categoryFeatures !== undefined
|
|
78
282
|
? { categoryFeatures: props.categoryFeatures }
|
|
@@ -81,14 +285,17 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
81
285
|
{...(props.enabled !== undefined ? { enabled: props.enabled } : {})}
|
|
82
286
|
>
|
|
83
287
|
{(bag) => (
|
|
84
|
-
<Flex vertical gap={
|
|
85
|
-
<Flex justify="space-between" align="center" gap={
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
288
|
+
<Flex vertical gap={spacing[3]} data-testid="search-facets">
|
|
289
|
+
<Flex justify="space-between" align="center" gap={spacing[2]}>
|
|
290
|
+
{props.heading === null ? (
|
|
291
|
+
<span />
|
|
292
|
+
) : (
|
|
293
|
+
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
294
|
+
{props.heading ?? t(SEARCH_I18N_KEYS.facetsTitle)}
|
|
295
|
+
</Typography.Title>
|
|
296
|
+
)}
|
|
89
297
|
{bag.activeFilters > 0 && (
|
|
90
298
|
<Button
|
|
91
|
-
size="small"
|
|
92
299
|
onClick={bag.clearAll}
|
|
93
300
|
data-analytics="none"
|
|
94
301
|
data-analytics-reason="a filter is a read, not a flow step"
|
|
@@ -99,12 +306,30 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
99
306
|
)}
|
|
100
307
|
</Flex>
|
|
101
308
|
|
|
309
|
+
<CategoryFilter
|
|
310
|
+
{...(props.renderCategoryFilter !== undefined
|
|
311
|
+
? { render: props.renderCategoryFilter }
|
|
312
|
+
: {})}
|
|
313
|
+
/>
|
|
314
|
+
<LanguageSelect
|
|
315
|
+
{...(props.languages !== undefined ? { languages: props.languages } : {})}
|
|
316
|
+
/>
|
|
317
|
+
<GeoFilter
|
|
318
|
+
{...(props.renderGeoFilter !== undefined
|
|
319
|
+
? { render: props.renderGeoFilter }
|
|
320
|
+
: {})}
|
|
321
|
+
/>
|
|
322
|
+
|
|
323
|
+
{/* Honesty flags, not failures: the counts ARE approximate and
|
|
324
|
+
those slugs WERE skipped, and a red box would teach a person
|
|
325
|
+
the page is broken. `ErrorAlert` is for a read that did not
|
|
326
|
+
happen; this is a read that happened with a caveat. */}
|
|
102
327
|
{bag.approximate && (
|
|
103
328
|
<Alert
|
|
104
329
|
type="info"
|
|
105
330
|
showIcon
|
|
106
331
|
data-testid="facets-approximate"
|
|
107
|
-
|
|
332
|
+
title={t(SEARCH_I18N_KEYS.facetsApproximate)}
|
|
108
333
|
/>
|
|
109
334
|
)}
|
|
110
335
|
{bag.skipped.length > 0 && (
|
|
@@ -112,63 +337,71 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
112
337
|
type="warning"
|
|
113
338
|
showIcon
|
|
114
339
|
data-testid="facets-skipped"
|
|
115
|
-
|
|
116
|
-
slugs: bag.skipped.
|
|
340
|
+
title={t(SEARCH_I18N_KEYS.facetsSkipped, {
|
|
341
|
+
slugs: skippedNames(bag.skipped, props.categoryFeatures, t),
|
|
117
342
|
})}
|
|
118
343
|
/>
|
|
119
344
|
)}
|
|
120
345
|
|
|
121
|
-
{
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
<
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
346
|
+
{ranges.length > 0 && (
|
|
347
|
+
<Flex vertical gap={spacing[3]} data-testid="search-ranges">
|
|
348
|
+
{ranges.map((group) => (
|
|
349
|
+
<RangeFilterRow
|
|
350
|
+
key={group.slug}
|
|
351
|
+
group={group}
|
|
352
|
+
onApply={bag.setRange}
|
|
353
|
+
/>
|
|
354
|
+
))}
|
|
355
|
+
</Flex>
|
|
356
|
+
)}
|
|
357
|
+
|
|
358
|
+
{ranges.length > 0 && <Divider style={{ margin: 0 }} />}
|
|
359
|
+
|
|
360
|
+
<LoadList
|
|
361
|
+
state={bag.state}
|
|
362
|
+
testId="facets"
|
|
363
|
+
skeletonRows={4}
|
|
364
|
+
empty={<EmptyState compact title={t(SEARCH_I18N_KEYS.facetsEmpty)} testId="facets-empty" />}
|
|
365
|
+
failed={(error) => (
|
|
128
366
|
<ErrorAlert
|
|
129
367
|
testId="facets-failed"
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
message: t(SEARCH_I18N_KEYS.facetsLoadFailed),
|
|
133
|
-
}}
|
|
134
|
-
/>
|
|
135
|
-
),
|
|
136
|
-
empty: () => (
|
|
137
|
-
<Empty
|
|
138
|
-
data-testid="facets-empty"
|
|
139
|
-
description={t(SEARCH_I18N_KEYS.facetsEmpty)}
|
|
368
|
+
thrown={error}
|
|
369
|
+
message={t(SEARCH_I18N_KEYS.facetsLoadFailed)}
|
|
140
370
|
/>
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
371
|
+
)}
|
|
372
|
+
>
|
|
373
|
+
{(groups) => (
|
|
374
|
+
<Flex vertical gap={spacing[4]}>
|
|
375
|
+
{/* A group with no options is a heading with nothing under
|
|
376
|
+
it. `power_w` arrives in `skipped` and in no facet map,
|
|
377
|
+
so it produced exactly that — "Power" printed twice on
|
|
378
|
+
the desktop panel, once as the range row and once as a
|
|
379
|
+
label over air. The skipped Alert above already names it;
|
|
380
|
+
a heading with no control under it names nothing. */}
|
|
381
|
+
{/* Each group draws itself the way its own schema says:
|
|
382
|
+
pills for a single-choice facet, indented children for a
|
|
383
|
+
hierarchical one, a fold for a long one. The panel does
|
|
384
|
+
not decide — `facetGroupShape` reads the same config keys
|
|
385
|
+
the attributes editor reads, so a facet cannot look one
|
|
386
|
+
way here and another way in the composer. */}
|
|
387
|
+
{groups
|
|
388
|
+
.filter((group) => group.options.length > 0)
|
|
389
|
+
.map((group) => (
|
|
390
|
+
<FacetGroupControl
|
|
391
|
+
key={group.slug}
|
|
392
|
+
group={group}
|
|
393
|
+
onToggle={bag.toggle}
|
|
394
|
+
/>
|
|
395
|
+
))}
|
|
396
|
+
<Typography.Text type="secondary">
|
|
164
397
|
{t(SEARCH_I18N_KEYS.facetsDrillDownHint)}
|
|
165
398
|
</Typography.Text>
|
|
166
399
|
</Flex>
|
|
167
|
-
)
|
|
168
|
-
|
|
400
|
+
)}
|
|
401
|
+
</LoadList>
|
|
169
402
|
</Flex>
|
|
170
403
|
)}
|
|
171
404
|
</FacetPanel>
|
|
172
|
-
</
|
|
405
|
+
</SkinTheme>
|
|
173
406
|
);
|
|
174
407
|
}
|