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