@stapel/search-react 0.3.1 → 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.
- package/CHANGELOG.md +180 -0
- package/MODULE.md +43 -4
- package/README.md +1 -1
- package/dist/api/generated/schema.d.ts +5 -1
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/DegradationNotice.d.ts +34 -0
- package/dist/default/DegradationNotice.d.ts.map +1 -1
- package/dist/default/DegradationNotice.js +28 -6
- package/dist/default/DegradationNotice.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +54 -3
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +119 -19
- package/dist/default/FacetPanelPane.js.map +1 -1
- 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 +58 -16
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +155 -6
- 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 +54 -3
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +45 -25
- 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/index.d.ts +33 -16
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +26 -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/SearchResults.d.ts +20 -3
- package/dist/headless/SearchResults.d.ts.map +1 -1
- package/dist/headless/SearchResults.js +3 -1
- package/dist/headless/SearchResults.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 +32 -4
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +46 -2
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +86 -6
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +38 -4
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- 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/degradations.d.ts +41 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +41 -0
- package/dist/state/degradations.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 -5
- package/manifest.json +245 -22
- package/nav-manifest.json +1 -1
- package/package.json +15 -10
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +5 -1
- package/src/default/DegradationNotice.tsx +84 -14
- package/src/default/FacetPanelPane.tsx +343 -64
- 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 +356 -41
- package/src/default/SearchResultCard.tsx +231 -24
- package/src/default/SearchResultsPane.tsx +147 -76
- package/src/default/SortSelect.tsx +50 -37
- package/src/default/UrlIssueNotice.tsx +21 -3
- package/src/default/index.ts +42 -16
- package/src/default/sortLabels.ts +34 -0
- package/src/headless/FacetPanel.tsx +30 -2
- package/src/headless/SearchResults.tsx +32 -4
- 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 +39 -4
- package/src/i18n/keys.ts +97 -6
- package/src/i18n/ru.ts +45 -4
- package/src/index.ts +30 -3
- package/src/model/queries.ts +51 -1
- package/src/state/degradations.ts +60 -0
- 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,23 +18,88 @@
|
|
|
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
|
+
Checkbox,
|
|
41
|
+
Divider,
|
|
42
|
+
Flex,
|
|
43
|
+
InputNumber,
|
|
44
|
+
Typography,
|
|
45
|
+
} from "antd";
|
|
46
|
+
import { SlotPlaceholder, isDevBuild, useT } from "@stapel/core";
|
|
47
|
+
import {
|
|
48
|
+
EmptyState,
|
|
49
|
+
ErrorAlert,
|
|
50
|
+
LoadList,
|
|
51
|
+
SkinTheme,
|
|
52
|
+
} from "@stapel/tokens-antd/skin";
|
|
53
|
+
import { spacing } from "@stapel/tokens";
|
|
54
|
+
import { featureName } from "@stapel/attributes-react";
|
|
19
55
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
56
|
+
import type { SearchGeo } from "../api/types.js";
|
|
20
57
|
import { FacetPanel } from "../headless/FacetPanel.js";
|
|
58
|
+
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
21
59
|
import type { FacetGroup, FacetOption } from "../state/facets.js";
|
|
60
|
+
import { buildRangeGroups } from "../state/ranges.js";
|
|
22
61
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
62
|
+
import { LanguageSelect } from "./LanguageSelect.js";
|
|
63
|
+
import { RangeFilterRow } from "./RangeFilterRow.js";
|
|
25
64
|
import type { ThemeModeProp } from "./types.js";
|
|
26
65
|
|
|
66
|
+
/** What a host's category control is handed. */
|
|
67
|
+
export interface CategoryFilterSlotProps {
|
|
68
|
+
/** The `root/leaf` path the search is narrowed to, if any. */
|
|
69
|
+
readonly value: string | undefined;
|
|
70
|
+
/** `null` widens the search back to the whole catalogue. */
|
|
71
|
+
readonly onChange: (path: string | null) => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What a host's location control is handed. */
|
|
75
|
+
export interface GeoFilterSlotProps {
|
|
76
|
+
readonly value: SearchGeo | undefined;
|
|
77
|
+
/** `null` clears the location constraint. */
|
|
78
|
+
readonly onChange: (geo: SearchGeo | null) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
27
81
|
export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
28
|
-
/** The category's feature schema — the source of option LABELS
|
|
82
|
+
/** The category's feature schema — the source of option LABELS and of which
|
|
83
|
+
* slugs get a numeric range row. */
|
|
29
84
|
readonly categoryFeatures?: readonly FeatureDef[];
|
|
30
85
|
readonly locale?: string;
|
|
31
86
|
readonly enabled?: boolean;
|
|
87
|
+
/** The catalogue picker (`categories-react`'s `CategoryPickerField`, bound
|
|
88
|
+
* to a path). Unfilled, an active category still gets a "clear" control. */
|
|
89
|
+
readonly renderCategoryFilter?: (slot: CategoryFilterSlotProps) => ReactNode;
|
|
90
|
+
/** The location control (`geo-react`). Unfilled, a location that arrived in
|
|
91
|
+
* the URL still gets its radius and a "clear" control. */
|
|
92
|
+
readonly renderGeoFilter?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
93
|
+
/** BCP-47 tags this deployment indexes — see {@link LanguageSelect}. */
|
|
94
|
+
readonly languages?: readonly string[];
|
|
95
|
+
/**
|
|
96
|
+
* What the panel calls itself. `null` draws no title at all — for a surface
|
|
97
|
+
* that has ALREADY named it, which the phone sheet has: its dialog title and
|
|
98
|
+
* the panel's own heading both said "Filters", one under the other, in every
|
|
99
|
+
* shot of the open sheet. The row itself stays either way, because the
|
|
100
|
+
* "Clear all" control lives in it.
|
|
101
|
+
*/
|
|
102
|
+
readonly heading?: ReactNode;
|
|
32
103
|
}
|
|
33
104
|
|
|
34
105
|
function OptionRow(props: {
|
|
@@ -39,7 +110,7 @@ function OptionRow(props: {
|
|
|
39
110
|
const t = useT();
|
|
40
111
|
const { option, group } = props;
|
|
41
112
|
return (
|
|
42
|
-
<Flex justify="space-between" align="center" gap={
|
|
113
|
+
<Flex justify="space-between" align="center" gap={spacing[2]}>
|
|
43
114
|
<Checkbox
|
|
44
115
|
checked={option.selected}
|
|
45
116
|
data-testid={`facet-option-${group.slug}-${option.value}`}
|
|
@@ -52,9 +123,12 @@ function OptionRow(props: {
|
|
|
52
123
|
{option.label}
|
|
53
124
|
</Checkbox>
|
|
54
125
|
{option.count === null ? (
|
|
55
|
-
<
|
|
126
|
+
<Typography.Text
|
|
127
|
+
type="secondary"
|
|
128
|
+
data-testid={`facet-count-${group.slug}-${option.value}`}
|
|
129
|
+
>
|
|
56
130
|
{t(SEARCH_I18N_KEYS.facetsNotCounted)}
|
|
57
|
-
</
|
|
131
|
+
</Typography.Text>
|
|
58
132
|
) : (
|
|
59
133
|
<Typography.Text
|
|
60
134
|
type="secondary"
|
|
@@ -67,12 +141,182 @@ function OptionRow(props: {
|
|
|
67
141
|
);
|
|
68
142
|
}
|
|
69
143
|
|
|
144
|
+
/** The category constraint: the host's control, or the door out of it. */
|
|
145
|
+
function CategoryFilter(props: {
|
|
146
|
+
render?: (slot: CategoryFilterSlotProps) => ReactNode;
|
|
147
|
+
}): ReactElement | null {
|
|
148
|
+
const t = useT();
|
|
149
|
+
const { state, setCategory } = useSearchState();
|
|
150
|
+
const value = state.category;
|
|
151
|
+
|
|
152
|
+
if (props.render !== undefined) {
|
|
153
|
+
return (
|
|
154
|
+
<Flex vertical gap={spacing[1]} data-testid="search-category">
|
|
155
|
+
<Typography.Text strong>{t(SEARCH_I18N_KEYS.categoryTitle)}</Typography.Text>
|
|
156
|
+
{props.render({
|
|
157
|
+
value,
|
|
158
|
+
onChange: (path) => {
|
|
159
|
+
setCategory(path);
|
|
160
|
+
},
|
|
161
|
+
})}
|
|
162
|
+
</Flex>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (value === undefined) {
|
|
167
|
+
return <SlotPlaceholder name="renderCategoryFilter" data-testid="search-category-slot" />;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<Flex vertical gap={spacing[1]} data-testid="search-category">
|
|
172
|
+
<Typography.Text strong>{t(SEARCH_I18N_KEYS.categoryTitle)}</Typography.Text>
|
|
173
|
+
<Typography.Text type="secondary">
|
|
174
|
+
{t(SEARCH_I18N_KEYS.categoryCurrent, { path: value })}
|
|
175
|
+
</Typography.Text>
|
|
176
|
+
<Button
|
|
177
|
+
style={{ alignSelf: "flex-start" }}
|
|
178
|
+
data-testid="search-category-clear"
|
|
179
|
+
data-analytics="none"
|
|
180
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
181
|
+
onClick={() => {
|
|
182
|
+
setCategory(null);
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
{t(SEARCH_I18N_KEYS.categoryClear)}
|
|
186
|
+
</Button>
|
|
187
|
+
</Flex>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The location constraint.
|
|
193
|
+
*
|
|
194
|
+
* SETTING a centre needs a geocoder, which is the deployment's and
|
|
195
|
+
* `geo-react`'s. ADJUSTING one that already exists does not: the radius is a
|
|
196
|
+
* number in the URL, and a link shared with `lat/lon/radius_km` is a link this
|
|
197
|
+
* panel can widen, tighten and clear without knowing what a map is. That is the
|
|
198
|
+
* difference between a slot and a hole.
|
|
199
|
+
*/
|
|
200
|
+
function GeoFilter(props: {
|
|
201
|
+
render?: (slot: GeoFilterSlotProps) => ReactNode;
|
|
202
|
+
}): ReactElement | null {
|
|
203
|
+
const t = useT();
|
|
204
|
+
const { state, setGeo } = useSearchState();
|
|
205
|
+
const geo = state.geo;
|
|
206
|
+
|
|
207
|
+
const slot =
|
|
208
|
+
props.render !== undefined
|
|
209
|
+
? props.render({
|
|
210
|
+
value: geo,
|
|
211
|
+
onChange: (next) => {
|
|
212
|
+
setGeo(next);
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
: geo === undefined
|
|
216
|
+
? <SlotPlaceholder name="renderGeoFilter" data-testid="search-geo-slot" />
|
|
217
|
+
: null;
|
|
218
|
+
|
|
219
|
+
if (geo === undefined) {
|
|
220
|
+
// An unfilled slot is a NAMED hole in development and nothing at all in a
|
|
221
|
+
// production build — so the heading has to follow the placeholder rather
|
|
222
|
+
// than outlive it. It did not, and the live desktop panel printed
|
|
223
|
+
// "Location" over empty space with no location control under it
|
|
224
|
+
// (class NC-ORPHANFIELD): a label is a promise that a control follows.
|
|
225
|
+
if (props.render === undefined && !isDevBuild()) return null;
|
|
226
|
+
return slot === null ? null : (
|
|
227
|
+
<Flex vertical gap={spacing[1]} data-testid="search-geo">
|
|
228
|
+
<Typography.Text strong>{t(SEARCH_I18N_KEYS.geoTitle)}</Typography.Text>
|
|
229
|
+
{slot}
|
|
230
|
+
</Flex>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<Flex vertical gap={spacing[1]} data-testid="search-geo">
|
|
236
|
+
<Typography.Text strong>{t(SEARCH_I18N_KEYS.geoTitle)}</Typography.Text>
|
|
237
|
+
{slot}
|
|
238
|
+
<Typography.Text type="secondary" data-testid="search-geo-summary">
|
|
239
|
+
{geo.kind === "bbox"
|
|
240
|
+
? t(SEARCH_I18N_KEYS.geoBox)
|
|
241
|
+
: t(SEARCH_I18N_KEYS.geoCenter, {
|
|
242
|
+
lat: geo.lat.toFixed(3),
|
|
243
|
+
lon: geo.lon.toFixed(3),
|
|
244
|
+
})}
|
|
245
|
+
</Typography.Text>
|
|
246
|
+
{geo.kind === "center" && (
|
|
247
|
+
<Flex gap={spacing[2]} align="center" wrap>
|
|
248
|
+
<Typography.Text type="secondary" aria-hidden="true">
|
|
249
|
+
{t(SEARCH_I18N_KEYS.geoRadiusLabel)}
|
|
250
|
+
</Typography.Text>
|
|
251
|
+
<InputNumber
|
|
252
|
+
min={1}
|
|
253
|
+
value={geo.radiusKm ?? null}
|
|
254
|
+
aria-label={t(SEARCH_I18N_KEYS.geoRadiusLabel)}
|
|
255
|
+
data-testid="search-geo-radius"
|
|
256
|
+
onChange={(value) => {
|
|
257
|
+
setGeo({
|
|
258
|
+
kind: "center",
|
|
259
|
+
lat: geo.lat,
|
|
260
|
+
lon: geo.lon,
|
|
261
|
+
...(typeof value === "number" ? { radiusKm: value } : {}),
|
|
262
|
+
});
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
</Flex>
|
|
266
|
+
)}
|
|
267
|
+
<Button
|
|
268
|
+
style={{ alignSelf: "flex-start" }}
|
|
269
|
+
data-testid="search-geo-clear"
|
|
270
|
+
data-analytics="none"
|
|
271
|
+
data-analytics-reason="a filter is a read, not a flow step"
|
|
272
|
+
onClick={() => {
|
|
273
|
+
setGeo(null);
|
|
274
|
+
}}
|
|
275
|
+
>
|
|
276
|
+
{t(SEARCH_I18N_KEYS.geoClear)}
|
|
277
|
+
</Button>
|
|
278
|
+
</Flex>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The slugs the server skipped, named the way the panel names everything else.
|
|
284
|
+
*
|
|
285
|
+
* `facet_meta.skipped` is a list of index slugs (`power_w`); the sentence that
|
|
286
|
+
* reports them was printing exactly that, so a shopper read "These filters
|
|
287
|
+
* were not counted for this search: power_w". The category schema is already
|
|
288
|
+
* in this component for the option labels — it names these too. A slug the
|
|
289
|
+
* schema does not know stays as it is, because a made-up name would be worse
|
|
290
|
+
* than an honest identifier.
|
|
291
|
+
*/
|
|
292
|
+
function skippedNames(
|
|
293
|
+
slugs: readonly string[],
|
|
294
|
+
features: readonly FeatureDef[] | undefined,
|
|
295
|
+
t: (key: string) => string
|
|
296
|
+
): string {
|
|
297
|
+
return slugs
|
|
298
|
+
.map((slug) => {
|
|
299
|
+
const feature = features?.find((candidate) => candidate.slug === slug);
|
|
300
|
+
if (feature === undefined) return slug;
|
|
301
|
+
const name = t(featureName(feature));
|
|
302
|
+
return name.length > 0 ? name : slug;
|
|
303
|
+
})
|
|
304
|
+
.join(", ");
|
|
305
|
+
}
|
|
306
|
+
|
|
70
307
|
export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
71
308
|
const t = useT();
|
|
72
|
-
const
|
|
309
|
+
const { state } = useSearchState();
|
|
310
|
+
const ranges = buildRangeGroups({
|
|
311
|
+
state,
|
|
312
|
+
...(props.categoryFeatures !== undefined
|
|
313
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
314
|
+
: {}),
|
|
315
|
+
t,
|
|
316
|
+
});
|
|
73
317
|
|
|
74
318
|
return (
|
|
75
|
-
<
|
|
319
|
+
<SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
76
320
|
<FacetPanel
|
|
77
321
|
{...(props.categoryFeatures !== undefined
|
|
78
322
|
? { categoryFeatures: props.categoryFeatures }
|
|
@@ -81,14 +325,17 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
81
325
|
{...(props.enabled !== undefined ? { enabled: props.enabled } : {})}
|
|
82
326
|
>
|
|
83
327
|
{(bag) => (
|
|
84
|
-
<Flex vertical gap={
|
|
85
|
-
<Flex justify="space-between" align="center" gap={
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
328
|
+
<Flex vertical gap={spacing[3]} data-testid="search-facets">
|
|
329
|
+
<Flex justify="space-between" align="center" gap={spacing[2]}>
|
|
330
|
+
{props.heading === null ? (
|
|
331
|
+
<span />
|
|
332
|
+
) : (
|
|
333
|
+
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
334
|
+
{props.heading ?? t(SEARCH_I18N_KEYS.facetsTitle)}
|
|
335
|
+
</Typography.Title>
|
|
336
|
+
)}
|
|
89
337
|
{bag.activeFilters > 0 && (
|
|
90
338
|
<Button
|
|
91
|
-
size="small"
|
|
92
339
|
onClick={bag.clearAll}
|
|
93
340
|
data-analytics="none"
|
|
94
341
|
data-analytics-reason="a filter is a read, not a flow step"
|
|
@@ -99,12 +346,30 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
99
346
|
)}
|
|
100
347
|
</Flex>
|
|
101
348
|
|
|
349
|
+
<CategoryFilter
|
|
350
|
+
{...(props.renderCategoryFilter !== undefined
|
|
351
|
+
? { render: props.renderCategoryFilter }
|
|
352
|
+
: {})}
|
|
353
|
+
/>
|
|
354
|
+
<LanguageSelect
|
|
355
|
+
{...(props.languages !== undefined ? { languages: props.languages } : {})}
|
|
356
|
+
/>
|
|
357
|
+
<GeoFilter
|
|
358
|
+
{...(props.renderGeoFilter !== undefined
|
|
359
|
+
? { render: props.renderGeoFilter }
|
|
360
|
+
: {})}
|
|
361
|
+
/>
|
|
362
|
+
|
|
363
|
+
{/* Honesty flags, not failures: the counts ARE approximate and
|
|
364
|
+
those slugs WERE skipped, and a red box would teach a person
|
|
365
|
+
the page is broken. `ErrorAlert` is for a read that did not
|
|
366
|
+
happen; this is a read that happened with a caveat. */}
|
|
102
367
|
{bag.approximate && (
|
|
103
368
|
<Alert
|
|
104
369
|
type="info"
|
|
105
370
|
showIcon
|
|
106
371
|
data-testid="facets-approximate"
|
|
107
|
-
|
|
372
|
+
title={t(SEARCH_I18N_KEYS.facetsApproximate)}
|
|
108
373
|
/>
|
|
109
374
|
)}
|
|
110
375
|
{bag.skipped.length > 0 && (
|
|
@@ -112,63 +377,77 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
112
377
|
type="warning"
|
|
113
378
|
showIcon
|
|
114
379
|
data-testid="facets-skipped"
|
|
115
|
-
|
|
116
|
-
slugs: bag.skipped.
|
|
380
|
+
title={t(SEARCH_I18N_KEYS.facetsSkipped, {
|
|
381
|
+
slugs: skippedNames(bag.skipped, props.categoryFeatures, t),
|
|
117
382
|
})}
|
|
118
383
|
/>
|
|
119
384
|
)}
|
|
120
385
|
|
|
121
|
-
{
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
<
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
386
|
+
{ranges.length > 0 && (
|
|
387
|
+
<Flex vertical gap={spacing[3]} data-testid="search-ranges">
|
|
388
|
+
{ranges.map((group) => (
|
|
389
|
+
<RangeFilterRow
|
|
390
|
+
key={group.slug}
|
|
391
|
+
group={group}
|
|
392
|
+
onApply={bag.setRange}
|
|
393
|
+
/>
|
|
394
|
+
))}
|
|
395
|
+
</Flex>
|
|
396
|
+
)}
|
|
397
|
+
|
|
398
|
+
{ranges.length > 0 && <Divider style={{ margin: 0 }} />}
|
|
399
|
+
|
|
400
|
+
<LoadList
|
|
401
|
+
state={bag.state}
|
|
402
|
+
testId="facets"
|
|
403
|
+
skeletonRows={4}
|
|
404
|
+
empty={<EmptyState compact title={t(SEARCH_I18N_KEYS.facetsEmpty)} testId="facets-empty" />}
|
|
405
|
+
failed={(error) => (
|
|
128
406
|
<ErrorAlert
|
|
129
407
|
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)}
|
|
408
|
+
thrown={error}
|
|
409
|
+
message={t(SEARCH_I18N_KEYS.facetsLoadFailed)}
|
|
140
410
|
/>
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
411
|
+
)}
|
|
412
|
+
>
|
|
413
|
+
{(groups) => (
|
|
414
|
+
<Flex vertical gap={spacing[4]}>
|
|
415
|
+
{/* A group with no options is a heading with nothing under
|
|
416
|
+
it. `power_w` arrives in `skipped` and in no facet map,
|
|
417
|
+
so it produced exactly that — "Power" printed twice on
|
|
418
|
+
the desktop panel, once as the range row and once as a
|
|
419
|
+
label over air. The skipped Alert above already names it;
|
|
420
|
+
a heading with no control under it names nothing. */}
|
|
421
|
+
{groups
|
|
422
|
+
.filter((group) => group.options.length > 0)
|
|
423
|
+
.map((group) => (
|
|
424
|
+
<Flex
|
|
425
|
+
vertical
|
|
426
|
+
gap={spacing[1]}
|
|
427
|
+
key={group.slug}
|
|
428
|
+
data-testid={`facet-group-${group.slug}`}
|
|
429
|
+
data-counted={group.counted ? "true" : "false"}
|
|
430
|
+
>
|
|
431
|
+
<Typography.Text strong>{group.label}</Typography.Text>
|
|
432
|
+
{group.options.map((option) => (
|
|
433
|
+
<OptionRow
|
|
434
|
+
key={option.value}
|
|
435
|
+
group={group}
|
|
436
|
+
option={option}
|
|
437
|
+
onToggle={bag.toggle}
|
|
438
|
+
/>
|
|
439
|
+
))}
|
|
440
|
+
</Flex>
|
|
441
|
+
))}
|
|
442
|
+
<Typography.Text type="secondary">
|
|
164
443
|
{t(SEARCH_I18N_KEYS.facetsDrillDownHint)}
|
|
165
444
|
</Typography.Text>
|
|
166
445
|
</Flex>
|
|
167
|
-
)
|
|
168
|
-
|
|
446
|
+
)}
|
|
447
|
+
</LoadList>
|
|
169
448
|
</Flex>
|
|
170
449
|
)}
|
|
171
450
|
</FacetPanel>
|
|
172
|
-
</
|
|
451
|
+
</SkinTheme>
|
|
173
452
|
);
|
|
174
453
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The query's LANGUAGE — `lang`, and the reason it is a filter rather than a
|
|
3
|
+
* display preference.
|
|
4
|
+
*
|
|
5
|
+
* `lang` does two things server-side (`query.py`): it picks the ANALYZER (so a
|
|
6
|
+
* Russian word stems as Russian rather than as an unknown token) and it
|
|
7
|
+
* NARROWS the corpus to documents indexed in that language. Getting it wrong therefore changes
|
|
8
|
+
* which results exist, not how they are written — which is why the control
|
|
9
|
+
* lives in the filter panel beside the facets and not in a settings menu.
|
|
10
|
+
*
|
|
11
|
+
* ── Which languages a deployment has is not this pair's fact ───────────────
|
|
12
|
+
*
|
|
13
|
+
* The index's languages come from what has been indexed; there is no endpoint
|
|
14
|
+
* that lists them. So the host names them (`languages`), and the labels come
|
|
15
|
+
* from `Intl.DisplayNames` in the reader's own locale — each language named
|
|
16
|
+
* the way THIS reader would name it — rather than from a translation table this
|
|
17
|
+
* pair would have to carry for every language a deployment might index.
|
|
18
|
+
*
|
|
19
|
+
* With no `languages` prop the control renders only when the URL already
|
|
20
|
+
* carries one, and then only to REMOVE it: a constraint that arrived in a
|
|
21
|
+
* shared link must always have a control that widens it again.
|
|
22
|
+
*/
|
|
23
|
+
import { useMemo } from "react";
|
|
24
|
+
import type { ReactElement } from "react";
|
|
25
|
+
import { Flex, Select, Typography } from "antd";
|
|
26
|
+
import { useOptionalI18n, useT } from "@stapel/core";
|
|
27
|
+
import { spacing } from "@stapel/tokens";
|
|
28
|
+
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
29
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
30
|
+
|
|
31
|
+
/** Width of the language select — a language name plus room for a long one. */
|
|
32
|
+
export const LANGUAGE_SELECT_MIN_WIDTH = 176;
|
|
33
|
+
|
|
34
|
+
export interface LanguageSelectProps {
|
|
35
|
+
/** BCP-47 tags the deployment indexes (e.g. `["ru", "en"]`). */
|
|
36
|
+
readonly languages?: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A language tag as a person reads it, in THEIR locale. Falls back to the tag
|
|
41
|
+
* itself where the runtime has no name for it — a tag is at least honest,
|
|
42
|
+
* where a blank option is unclickable.
|
|
43
|
+
*/
|
|
44
|
+
function languageName(tag: string, locale: string): string {
|
|
45
|
+
try {
|
|
46
|
+
const names = new Intl.DisplayNames([locale], { type: "language" });
|
|
47
|
+
return names.of(tag) ?? tag;
|
|
48
|
+
} catch {
|
|
49
|
+
return tag;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function LanguageSelect(props: LanguageSelectProps): ReactElement | null {
|
|
54
|
+
const t = useT();
|
|
55
|
+
// The reader's locale names the languages. Optional on purpose: every other
|
|
56
|
+
// control in this skin renders outside a provider (English floor) rather
|
|
57
|
+
// than throwing, and a language list is not the place to break that.
|
|
58
|
+
const locale = useOptionalI18n()?.locale ?? "en";
|
|
59
|
+
const { state, setLanguage } = useSearchState();
|
|
60
|
+
const active = state.lang;
|
|
61
|
+
const languages = props.languages;
|
|
62
|
+
|
|
63
|
+
const options = useMemo(() => {
|
|
64
|
+
const tags = [...(languages ?? [])];
|
|
65
|
+
if (active !== undefined && !tags.includes(active)) tags.push(active);
|
|
66
|
+
return [
|
|
67
|
+
{ value: "", label: t(SEARCH_I18N_KEYS.languageAny) },
|
|
68
|
+
...tags.map((tag) => ({ value: tag, label: languageName(tag, locale) })),
|
|
69
|
+
];
|
|
70
|
+
}, [languages, active, locale, t]);
|
|
71
|
+
|
|
72
|
+
// Nothing offered and nothing applied: a select with one option ("any") is a
|
|
73
|
+
// control that cannot do anything, which §83 calls a dead control.
|
|
74
|
+
if ((languages ?? []).length === 0 && active === undefined) return null;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<Flex gap={spacing[2]} align="center" wrap>
|
|
78
|
+
<Typography.Text type="secondary" aria-hidden="true">
|
|
79
|
+
{t(SEARCH_I18N_KEYS.languageLabel)}
|
|
80
|
+
</Typography.Text>
|
|
81
|
+
<Select<string>
|
|
82
|
+
data-testid="search-language"
|
|
83
|
+
aria-label={t(SEARCH_I18N_KEYS.languageLabel)}
|
|
84
|
+
style={{ minWidth: LANGUAGE_SELECT_MIN_WIDTH }}
|
|
85
|
+
value={active ?? ""}
|
|
86
|
+
onChange={(next) => {
|
|
87
|
+
setLanguage(next === "" ? null : next);
|
|
88
|
+
}}
|
|
89
|
+
options={options}
|
|
90
|
+
/>
|
|
91
|
+
</Flex>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many results a page carries — `limit`, the fifth of the nine state
|
|
3
|
+
* setters that had no control.
|
|
4
|
+
*
|
|
5
|
+
* The offered sizes are the pair's, not the schema's: `docs/schema.json`
|
|
6
|
+
* declares `limit` as a bare integer, and the backend clamps it to
|
|
7
|
+
* `1..MAX_PAGE_SIZE` (100) with `DEFAULT_PAGE_SIZE` 24 when it is absent. So
|
|
8
|
+
* the select offers a short ladder around that default and never sends
|
|
9
|
+
* something the server would have to clamp — a person who lands on a link
|
|
10
|
+
* carrying `limit=37` keeps it (it is offered as its own option) rather than
|
|
11
|
+
* being silently moved to 24, which would change what a shared link shows.
|
|
12
|
+
*
|
|
13
|
+
* A page size is a PREFERENCE, not a step through the results: `setLimit`
|
|
14
|
+
* replaces the history entry rather than pushing one, so Back still undoes the
|
|
15
|
+
* last filter and not the last time somebody widened the page.
|
|
16
|
+
*/
|
|
17
|
+
import type { ReactElement } from "react";
|
|
18
|
+
import { Flex, Select, Typography } from "antd";
|
|
19
|
+
import { useT } from "@stapel/core";
|
|
20
|
+
import { fontSize, spacing } from "@stapel/tokens";
|
|
21
|
+
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
22
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
23
|
+
|
|
24
|
+
/** The shipped ladder, around stapel-search's own `DEFAULT_PAGE_SIZE` (24). */
|
|
25
|
+
export const SEARCH_PAGE_SIZES: readonly number[] = [12, 24, 48, 96];
|
|
26
|
+
|
|
27
|
+
/** Width of the size select — the widest option plus its caption. */
|
|
28
|
+
export const PAGE_SIZE_SELECT_MIN_WIDTH = 148;
|
|
29
|
+
|
|
30
|
+
export interface PageSizeSelectProps {
|
|
31
|
+
/** Override the offered ladder (a deployment with its own `MAX_PAGE_SIZE`). */
|
|
32
|
+
readonly sizes?: readonly number[];
|
|
33
|
+
/** What the server uses when the URL names none. Default 24. */
|
|
34
|
+
readonly defaultSize?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function PageSizeSelect(props: PageSizeSelectProps): ReactElement {
|
|
38
|
+
const t = useT();
|
|
39
|
+
const { state, setLimit } = useSearchState();
|
|
40
|
+
const ladder = props.sizes ?? SEARCH_PAGE_SIZES;
|
|
41
|
+
const active = state.limit ?? props.defaultSize ?? 24;
|
|
42
|
+
const offLadder = !ladder.includes(active);
|
|
43
|
+
const values = offLadder ? [...ladder, active].sort((a, b) => a - b) : ladder;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Flex vertical gap={spacing[1]} data-off-ladder={offLadder ? "true" : "false"}>
|
|
47
|
+
<Flex gap={spacing[2]} align="center">
|
|
48
|
+
<Typography.Text type="secondary" aria-hidden="true">
|
|
49
|
+
{t(SEARCH_I18N_KEYS.limitLabel)}
|
|
50
|
+
</Typography.Text>
|
|
51
|
+
<Select<number>
|
|
52
|
+
data-testid="search-limit"
|
|
53
|
+
aria-label={t(SEARCH_I18N_KEYS.limitLabel)}
|
|
54
|
+
style={{ minWidth: PAGE_SIZE_SELECT_MIN_WIDTH }}
|
|
55
|
+
value={active}
|
|
56
|
+
onChange={(next) => {
|
|
57
|
+
setLimit(next);
|
|
58
|
+
}}
|
|
59
|
+
options={values.map((size) => ({
|
|
60
|
+
value: size,
|
|
61
|
+
label: t(SEARCH_I18N_KEYS.limitOption, { count: size }),
|
|
62
|
+
}))}
|
|
63
|
+
/>
|
|
64
|
+
</Flex>
|
|
65
|
+
{/* Keeping a link's own page size is a DECISION, and a decision nobody
|
|
66
|
+
is told about is indistinguishable from a default. "37 per page" in
|
|
67
|
+
the box looks like the deployment's ladder until this line says
|
|
68
|
+
where it came from. */}
|
|
69
|
+
{offLadder && (
|
|
70
|
+
<Typography.Text
|
|
71
|
+
type="secondary"
|
|
72
|
+
style={{ fontSize: fontSize.xs.fontSize }}
|
|
73
|
+
data-testid="search-limit-from-link"
|
|
74
|
+
>
|
|
75
|
+
{t(SEARCH_I18N_KEYS.limitFromLink)}
|
|
76
|
+
</Typography.Text>
|
|
77
|
+
)}
|
|
78
|
+
</Flex>
|
|
79
|
+
);
|
|
80
|
+
}
|