@stapel/search-react 0.24.0 → 0.26.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 +73 -0
- package/MODULE.md +21 -9
- package/README.md +109 -15
- package/dist/api/generated/schema.d.ts +18 -3
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +39 -4
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +20 -13
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +29 -21
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +15 -4
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +10 -5
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/OtherCategoriesLine.d.ts +49 -0
- package/dist/default/OtherCategoriesLine.d.ts.map +1 -0
- package/dist/default/OtherCategoriesLine.js +127 -0
- package/dist/default/OtherCategoriesLine.js.map +1 -0
- package/dist/default/SearchPage.d.ts +36 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +12 -4
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +15 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +4 -1
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/index.d.ts +2 -0
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -0
- package/dist/default/index.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +12 -2
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +58 -6
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/headless/useOtherCategories.d.ts +52 -0
- package/dist/headless/useOtherCategories.d.ts.map +1 -0
- package/dist/headless/useOtherCategories.js +92 -0
- package/dist/headless/useOtherCategories.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +3 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
- package/dist/i18n/keys.d.ts +25 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +29 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +4 -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.map +1 -1
- package/dist/model/queries.js +9 -1
- package/dist/model/queries.js.map +1 -1
- package/dist/state/facets.d.ts +62 -11
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +141 -29
- package/dist/state/facets.js.map +1 -1
- package/dist/state/urlState.d.ts +77 -2
- package/dist/state/urlState.d.ts.map +1 -1
- package/dist/state/urlState.js +80 -6
- package/dist/state/urlState.js.map +1 -1
- package/llms.txt +4 -2
- package/manifest.json +45 -1
- package/nav-manifest.json +1 -1
- package/package.json +10 -10
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +18 -3
- package/src/api/types.ts +42 -4
- package/src/default/FacetGroupControl.tsx +27 -23
- package/src/default/FacetPanelPane.tsx +30 -8
- package/src/default/OtherCategoriesLine.tsx +238 -0
- package/src/default/SearchPage.tsx +57 -1
- package/src/default/SearchResultsPane.tsx +28 -0
- package/src/default/index.ts +13 -0
- package/src/headless/SearchStateProvider.tsx +94 -4
- package/src/headless/useOtherCategories.ts +193 -0
- package/src/i18n/es.ts +4 -0
- package/src/i18n/generated/errors.es.gen.ts +1 -0
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +9 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -0
- package/src/i18n/keys.ts +32 -0
- package/src/i18n/ru.ts +5 -0
- package/src/index.ts +23 -1
- package/src/model/queries.ts +11 -1
- package/src/state/facets.ts +173 -30
- package/src/state/urlState.ts +137 -5
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<OtherCategoriesLine>` — "Search in other categories: Cars 12 · Buses 3 ·
|
|
3
|
+
* Motorhomes 1 · …", on ONE line, drawn from the answer that drew the cards.
|
|
4
|
+
*
|
|
5
|
+
* ## What it replaces
|
|
6
|
+
*
|
|
7
|
+
* A full-width block under the results with one row per category, fetched
|
|
8
|
+
* separately and arriving after the page had settled. Two defects in one
|
|
9
|
+
* control:
|
|
10
|
+
*
|
|
11
|
+
* - **it was tall.** Fourteen sections became fourteen rows — a screen and a
|
|
12
|
+
* half of navigation under a list of listings, for information that fits
|
|
13
|
+
* in a sentence.
|
|
14
|
+
* - **it was late.** A second request meant the block appeared a beat after
|
|
15
|
+
* the cards and PUSHED them, on the one screen where a person has already
|
|
16
|
+
* started reading. The information was not even new: `/query` had already
|
|
17
|
+
* answered with `facet_meta.categories`, and the type-ahead had shown the
|
|
18
|
+
* same sections a keystroke earlier.
|
|
19
|
+
*
|
|
20
|
+
* This line renders in the SAME frame as the results, out of the same
|
|
21
|
+
* response ({@link useOtherCategories}), so there is nothing to arrive late.
|
|
22
|
+
* The single case that still needs a request — an empty result set, whose
|
|
23
|
+
* candidate list is empty by definition — draws into a slot whose height is
|
|
24
|
+
* reserved from the first frame, so the answer lands without moving anything.
|
|
25
|
+
*
|
|
26
|
+
* ## Pressing an entry narrows the search; it does not leave it
|
|
27
|
+
*
|
|
28
|
+
* The count beside a name is the count for THIS QUERY in that section — the
|
|
29
|
+
* server's `facet_meta.categories`. A link to the bare category feed would
|
|
30
|
+
* show a different, larger number, so the caption would be a lie one click
|
|
31
|
+
* later. Each entry therefore writes the `category` parameter of the search
|
|
32
|
+
* already on screen, keeping the query: press "Cars 12" and twelve results
|
|
33
|
+
* follow. That is a state change, so each entry is a real `<button>` and not
|
|
34
|
+
* an anchor.
|
|
35
|
+
*
|
|
36
|
+
* ## Two rows on a phone, at most
|
|
37
|
+
*
|
|
38
|
+
* The cap is halved on the sheet surface ({@link OTHER_CATEGORIES_PHONE_LIMIT}),
|
|
39
|
+
* and the collapsed line is clamped to two rows besides — a cap counts
|
|
40
|
+
* entries, and it is name LENGTH that turns a line into a paragraph. Expanding
|
|
41
|
+
* is the person's own press, and an expanded line is allowed to be as tall as
|
|
42
|
+
* what they asked for.
|
|
43
|
+
*/
|
|
44
|
+
import { Fragment, useState } from "react";
|
|
45
|
+
import type { CSSProperties, ReactElement } from "react";
|
|
46
|
+
import { Button, Typography } from "antd";
|
|
47
|
+
import { useDialogSurface } from "@stapel/tokens-antd/skin";
|
|
48
|
+
import { useT } from "@stapel/core";
|
|
49
|
+
import { cssVar } from "@stapel/tokens";
|
|
50
|
+
import { useSearchState } from "../headless/SearchStateProvider.js";
|
|
51
|
+
import {
|
|
52
|
+
OTHER_CATEGORIES_LIMIT,
|
|
53
|
+
OTHER_CATEGORIES_PHONE_LIMIT,
|
|
54
|
+
otherCategoryLeaf,
|
|
55
|
+
useOtherCategories,
|
|
56
|
+
} from "../headless/useOtherCategories.js";
|
|
57
|
+
import type { OtherCategoryRow } from "../headless/useOtherCategories.js";
|
|
58
|
+
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The height the empty-result slot holds before its answer arrives.
|
|
62
|
+
*
|
|
63
|
+
* One text line. Reserving it is the whole difference between "the sections
|
|
64
|
+
* appeared" and "the sections pushed the page", and it is reserved whether the
|
|
65
|
+
* request ends with rows or with none.
|
|
66
|
+
*/
|
|
67
|
+
export const OTHER_CATEGORIES_SLOT_MIN_HEIGHT = 24;
|
|
68
|
+
|
|
69
|
+
/** Names an id path the pair cannot name on its own. Returning `undefined`
|
|
70
|
+
* drops the row rather than printing a number at a person. */
|
|
71
|
+
export type OtherCategoryNamer = (category: string) => string | undefined;
|
|
72
|
+
|
|
73
|
+
export interface OtherCategoriesLineProps {
|
|
74
|
+
/** How many entries before the fold (default {@link OTHER_CATEGORIES_LIMIT}). */
|
|
75
|
+
readonly limit?: number;
|
|
76
|
+
/** The same, on the phone surface (default
|
|
77
|
+
* {@link OTHER_CATEGORIES_PHONE_LIMIT}). */
|
|
78
|
+
readonly phoneLimit?: number;
|
|
79
|
+
/**
|
|
80
|
+
* What a category id path is CALLED.
|
|
81
|
+
*
|
|
82
|
+
* The pair holds `"140/145"` and has no catalogue: naming it is the host's,
|
|
83
|
+
* exactly as `categoryLabel` is for the chip. Without this the line still
|
|
84
|
+
* draws every row the server named (a `/suggest` answer already in the
|
|
85
|
+
* cache, or the empty-result path) and every path whose last segment is a
|
|
86
|
+
* slug — and drops the rest, because "163 · 149" is not a sentence.
|
|
87
|
+
*/
|
|
88
|
+
readonly categoryName?: OtherCategoryNamer;
|
|
89
|
+
/** Skip the read entirely — mirrors `<SearchResultsPane enabled>`. */
|
|
90
|
+
readonly enabled?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const ENTRY: CSSProperties = {
|
|
94
|
+
padding: 0,
|
|
95
|
+
height: "auto",
|
|
96
|
+
// The line is text: an entry has to sit ON the baseline of the words around
|
|
97
|
+
// it rather than in a button-shaped box of its own.
|
|
98
|
+
verticalAlign: "baseline",
|
|
99
|
+
fontSize: "inherit",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const COUNT: CSSProperties = { color: cssVar("text-subtle") };
|
|
103
|
+
|
|
104
|
+
/** How many rows the collapsed phone line may occupy. */
|
|
105
|
+
export const OTHER_CATEGORIES_PHONE_ROWS = 2;
|
|
106
|
+
|
|
107
|
+
/** The class the clamp is hung on. */
|
|
108
|
+
export const OTHER_CATEGORIES_CLASS = "stapel-search-other-categories";
|
|
109
|
+
|
|
110
|
+
/** The `href` the hoisted sheet is deduplicated by. */
|
|
111
|
+
export const OTHER_CATEGORIES_STYLE_HREF = "stapel-search-other-categories";
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The clamp — two rows, collapsed, on the phone.
|
|
115
|
+
*
|
|
116
|
+
* A SHEET rather than an inline style, for the same reason the rail's
|
|
117
|
+
* scrollbar is one: `-webkit-line-clamp` needs `display:-webkit-box` and
|
|
118
|
+
* `-webkit-box-orient` together, and a vendor property set through the DOM
|
|
119
|
+
* style object is dropped by anything that does not already know it — which
|
|
120
|
+
* is how a clamp silently stops clamping.
|
|
121
|
+
*/
|
|
122
|
+
export function otherCategoriesCss(): string {
|
|
123
|
+
const clamped = `.${OTHER_CATEGORIES_CLASS}--clamped`;
|
|
124
|
+
return [
|
|
125
|
+
`${clamped}{display:-webkit-box;-webkit-box-orient:vertical;`,
|
|
126
|
+
`-webkit-line-clamp:${String(OTHER_CATEGORIES_PHONE_ROWS)};overflow:hidden}`,
|
|
127
|
+
].join("");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface Entry {
|
|
131
|
+
readonly row: OtherCategoryRow;
|
|
132
|
+
readonly name: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function OtherCategoriesLine(
|
|
136
|
+
props: OtherCategoriesLineProps
|
|
137
|
+
): ReactElement | null {
|
|
138
|
+
const t = useT();
|
|
139
|
+
const { setCategory } = useSearchState();
|
|
140
|
+
const bag = useOtherCategories(
|
|
141
|
+
props.enabled !== undefined ? { enabled: props.enabled } : {}
|
|
142
|
+
);
|
|
143
|
+
const surface = useDialogSurface();
|
|
144
|
+
const phone = surface === "sheet";
|
|
145
|
+
const [expanded, setExpanded] = useState(false);
|
|
146
|
+
|
|
147
|
+
const limit = phone
|
|
148
|
+
? (props.phoneLimit ?? OTHER_CATEGORIES_PHONE_LIMIT)
|
|
149
|
+
: (props.limit ?? OTHER_CATEGORIES_LIMIT);
|
|
150
|
+
|
|
151
|
+
const entries: Entry[] = [];
|
|
152
|
+
for (const row of bag.rows) {
|
|
153
|
+
const name =
|
|
154
|
+
props.categoryName?.(row.category) ?? row.name ?? otherCategoryLeaf(row.category);
|
|
155
|
+
if (name !== undefined) entries.push({ row, name });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const shown = expanded ? entries : entries.slice(0, limit);
|
|
159
|
+
const hidden = entries.length - shown.length;
|
|
160
|
+
|
|
161
|
+
if (entries.length === 0) {
|
|
162
|
+
// Nothing to say, and nothing coming: say nothing. A reserved band under a
|
|
163
|
+
// page that will never fill it is the same hole an empty filter column was.
|
|
164
|
+
if (!bag.reserving) return null;
|
|
165
|
+
return (
|
|
166
|
+
<div
|
|
167
|
+
data-testid="search-other-categories"
|
|
168
|
+
data-reserved="on"
|
|
169
|
+
data-source={bag.source}
|
|
170
|
+
style={{ minBlockSize: OTHER_CATEGORIES_SLOT_MIN_HEIGHT }}
|
|
171
|
+
/>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<Typography.Text
|
|
177
|
+
type="secondary"
|
|
178
|
+
data-testid="search-other-categories"
|
|
179
|
+
data-source={bag.source}
|
|
180
|
+
data-shown={shown.length}
|
|
181
|
+
{...(bag.reserving ? { "data-reserved": "on" } : {})}
|
|
182
|
+
className={
|
|
183
|
+
phone && !expanded
|
|
184
|
+
? `${OTHER_CATEGORIES_CLASS} ${OTHER_CATEGORIES_CLASS}--clamped`
|
|
185
|
+
: OTHER_CATEGORIES_CLASS
|
|
186
|
+
}
|
|
187
|
+
style={
|
|
188
|
+
bag.reserving ? { minBlockSize: OTHER_CATEGORIES_SLOT_MIN_HEIGHT } : {}
|
|
189
|
+
}
|
|
190
|
+
>
|
|
191
|
+
<style href={OTHER_CATEGORIES_STYLE_HREF} precedence="default">
|
|
192
|
+
{otherCategoriesCss()}
|
|
193
|
+
</style>
|
|
194
|
+
{t(SEARCH_I18N_KEYS.otherCategoriesLabel)}{" "}
|
|
195
|
+
{shown.map((entry, index) => (
|
|
196
|
+
<Fragment key={entry.row.category}>
|
|
197
|
+
{index > 0 && <span aria-hidden="true"> · </span>}
|
|
198
|
+
<Button
|
|
199
|
+
type="link"
|
|
200
|
+
size="small"
|
|
201
|
+
style={ENTRY}
|
|
202
|
+
data-testid="search-other-category"
|
|
203
|
+
data-category={entry.row.category}
|
|
204
|
+
data-analytics="none"
|
|
205
|
+
data-analytics-reason="narrowing a search is a read, not a flow step"
|
|
206
|
+
aria-label={t(SEARCH_I18N_KEYS.otherCategoriesNarrow, {
|
|
207
|
+
name: entry.name,
|
|
208
|
+
})}
|
|
209
|
+
onClick={() => {
|
|
210
|
+
setCategory(entry.row.category);
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
{entry.name} <span style={COUNT}>{entry.row.count}</span>
|
|
214
|
+
</Button>
|
|
215
|
+
</Fragment>
|
|
216
|
+
))}
|
|
217
|
+
{hidden > 0 && (
|
|
218
|
+
<>
|
|
219
|
+
<span aria-hidden="true"> · </span>
|
|
220
|
+
<Button
|
|
221
|
+
type="link"
|
|
222
|
+
size="small"
|
|
223
|
+
style={ENTRY}
|
|
224
|
+
data-testid="search-other-categories-more"
|
|
225
|
+
data-analytics="none"
|
|
226
|
+
data-analytics-reason="unfolding a line is a read, not a flow step"
|
|
227
|
+
aria-expanded={false}
|
|
228
|
+
onClick={() => {
|
|
229
|
+
setExpanded(true);
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
{t(SEARCH_I18N_KEYS.otherCategoriesMore, { count: hidden })}
|
|
233
|
+
</Button>
|
|
234
|
+
</>
|
|
235
|
+
)}
|
|
236
|
+
</Typography.Text>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -86,6 +86,7 @@ import { FilterChips } from "./FilterChips.js";
|
|
|
86
86
|
import { LocationSummaryLine } from "./LocationSummaryLine.js";
|
|
87
87
|
import { PageSizeSelect } from "./PageSizeSelect.js";
|
|
88
88
|
import { SearchBox } from "./SearchBox.js";
|
|
89
|
+
import type { OtherCategoryNamer } from "./OtherCategoriesLine.js";
|
|
89
90
|
import { SearchResultsPane } from "./SearchResultsPane.js";
|
|
90
91
|
import type { SearchResultsWrapper } from "./SearchResultsPane.js";
|
|
91
92
|
import { SortSelect } from "./SortSelect.js";
|
|
@@ -260,6 +261,22 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
260
261
|
/** Facet slugs pinned above every other group — see
|
|
261
262
|
* {@link FacetPanelPaneProps.pinnedFacets}. */
|
|
262
263
|
readonly pinnedFacets?: readonly string[];
|
|
264
|
+
/**
|
|
265
|
+
* How a DICTIONARY group is drawn — see
|
|
266
|
+
* {@link FacetPanelPaneProps.dictionaryMode}.
|
|
267
|
+
*
|
|
268
|
+
* Defaulted PER LAYOUT rather than left to the panel's own default, because
|
|
269
|
+
* the two frames want opposite shapes and only this component knows which
|
|
270
|
+
* one it is drawing: the desktop rail gets `"field"` (a select-style «Any»
|
|
271
|
+
* that opens the searchable list — a 418-value vocabulary held open in a
|
|
272
|
+
* 280px column is the whole column), the phone sheet gets `"inline"`
|
|
273
|
+
* (the sheet is already the disclosure). Set it to override both.
|
|
274
|
+
*
|
|
275
|
+
* It was unreachable through this component until now: the panel had the
|
|
276
|
+
* prop, `<SearchPage>` forwarded nothing, and a storefront that mounts the
|
|
277
|
+
* page rather than the pane could not get the field at all.
|
|
278
|
+
*/
|
|
279
|
+
readonly dictionaryMode?: "field" | "inline";
|
|
263
280
|
/** Print the engine's list of uncounted facet slugs in the filter panel.
|
|
264
281
|
* Default `false` — see {@link FacetPanelPaneProps.skippedNotice}. */
|
|
265
282
|
readonly skippedNotice?: boolean;
|
|
@@ -320,6 +337,25 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
320
337
|
* ask.
|
|
321
338
|
*/
|
|
322
339
|
readonly appliedChips?: boolean | "desktop";
|
|
340
|
+
/**
|
|
341
|
+
* Draw "Search in other categories: Cars 12 · Buses 3 · …" above the
|
|
342
|
+
* results — one line, from the SAME response the cards came from.
|
|
343
|
+
*
|
|
344
|
+
* It replaces the shape a storefront had built by hand: a full-width block
|
|
345
|
+
* of one row per category, fetched from `/suggest` after the page had
|
|
346
|
+
* settled and pushing everything below it when it landed. Here the rows are
|
|
347
|
+
* `facet_meta.categories`, which the answer already carried, so with results
|
|
348
|
+
* on screen the line costs no request and cannot arrive late. Only an EMPTY
|
|
349
|
+
* result set asks `/suggest`, into a slot whose height is reserved from the
|
|
350
|
+
* first frame.
|
|
351
|
+
*
|
|
352
|
+
* Opt-in, and `categoryName` is what makes it useful: the pair holds id
|
|
353
|
+
* paths and no catalogue — see {@link OtherCategoriesLineProps.categoryName}.
|
|
354
|
+
*/
|
|
355
|
+
readonly otherCategories?: boolean;
|
|
356
|
+
/** What a category id path is CALLED, for the line above. The same question
|
|
357
|
+
* `categoryLabel` answers for the chip, asked once per row. */
|
|
358
|
+
readonly categoryName?: OtherCategoryNamer;
|
|
323
359
|
/** What this surface calls its result list. See
|
|
324
360
|
* {@link SearchResultsPaneProps.heading}. */
|
|
325
361
|
readonly resultsHeading?: ReactNode;
|
|
@@ -402,6 +438,7 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
402
438
|
|
|
403
439
|
interface SearchPageBodyProps {
|
|
404
440
|
readonly renderCard?: SearchCardRenderer;
|
|
441
|
+
readonly dictionaryMode?: "field" | "inline";
|
|
405
442
|
readonly categoryFeatures?: readonly FeatureDef[];
|
|
406
443
|
readonly renderEmptyExits?: () => ReactNode;
|
|
407
444
|
readonly locale?: string;
|
|
@@ -417,6 +454,8 @@ interface SearchPageBodyProps {
|
|
|
417
454
|
readonly filtersHeader?: ReactNode;
|
|
418
455
|
readonly resultsHeader?: ReactNode;
|
|
419
456
|
readonly appliedChips?: boolean | "desktop";
|
|
457
|
+
readonly otherCategories?: boolean;
|
|
458
|
+
readonly categoryName?: OtherCategoryNamer;
|
|
420
459
|
readonly resultsHeading?: ReactNode;
|
|
421
460
|
readonly degradationNotice?: DegradationNoticeVariant;
|
|
422
461
|
readonly filtersLayout?: SearchFiltersLayout;
|
|
@@ -523,7 +562,12 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
523
562
|
result count scrolled out of sight above the fold. */}
|
|
524
563
|
{filtersEmpty ? null : (
|
|
525
564
|
<FacetPanelPane
|
|
526
|
-
{...(layout === "sheet"
|
|
565
|
+
{...(layout === "sheet"
|
|
566
|
+
? { heading: null }
|
|
567
|
+
: // STATIC, not sticky: the rail scrolls with the page, and a bar
|
|
568
|
+
// pinned to the port's floor sat on top of the last groups.
|
|
569
|
+
{ footerBar: "static" as const })}
|
|
570
|
+
dictionaryMode={props.dictionaryMode ?? (layout === "sheet" ? "inline" : "field")}
|
|
527
571
|
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
528
572
|
{...(props.renderEmptyExits !== undefined
|
|
529
573
|
? { renderEmptyExits: props.renderEmptyExits }
|
|
@@ -586,6 +630,12 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
586
630
|
{...(view.layout !== undefined ? { layout: view.layout } : {})}
|
|
587
631
|
{...(props.renderCard !== undefined ? { renderCard: props.renderCard } : {})}
|
|
588
632
|
{...(props.footer !== undefined ? { footer: props.footer } : {})}
|
|
633
|
+
{...(props.otherCategories !== undefined
|
|
634
|
+
? { otherCategories: props.otherCategories }
|
|
635
|
+
: {})}
|
|
636
|
+
{...(props.categoryName !== undefined
|
|
637
|
+
? { categoryName: props.categoryName }
|
|
638
|
+
: {})}
|
|
589
639
|
{...(props.resultsHeading !== undefined
|
|
590
640
|
? { heading: props.resultsHeading }
|
|
591
641
|
: {})}
|
|
@@ -763,6 +813,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
763
813
|
filtersHeader,
|
|
764
814
|
resultsHeader,
|
|
765
815
|
appliedChips,
|
|
816
|
+
otherCategories,
|
|
817
|
+
categoryName,
|
|
766
818
|
resultsHeading,
|
|
767
819
|
degradationNotice,
|
|
768
820
|
filtersLayout,
|
|
@@ -775,6 +827,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
775
827
|
onViewChange,
|
|
776
828
|
resultsAction,
|
|
777
829
|
resultsHeadingLevel,
|
|
830
|
+
dictionaryMode,
|
|
778
831
|
mode,
|
|
779
832
|
...parseOptions
|
|
780
833
|
} = props;
|
|
@@ -784,6 +837,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
784
837
|
<SearchStateProvider adapter={adapter} geoOffer={geoOffer} {...parseOptions}>
|
|
785
838
|
<SearchPageBody
|
|
786
839
|
{...(renderCard !== undefined ? { renderCard } : {})}
|
|
840
|
+
{...(dictionaryMode !== undefined ? { dictionaryMode } : {})}
|
|
787
841
|
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
788
842
|
{...(locale !== undefined ? { locale } : {})}
|
|
789
843
|
{...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
|
|
@@ -798,6 +852,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
798
852
|
{...(filtersHeader !== undefined ? { filtersHeader } : {})}
|
|
799
853
|
{...(resultsHeader !== undefined ? { resultsHeader } : {})}
|
|
800
854
|
{...(appliedChips !== undefined ? { appliedChips } : {})}
|
|
855
|
+
{...(otherCategories !== undefined ? { otherCategories } : {})}
|
|
856
|
+
{...(categoryName !== undefined ? { categoryName } : {})}
|
|
801
857
|
{...(resultsHeading !== undefined ? { resultsHeading } : {})}
|
|
802
858
|
{...(degradationNotice !== undefined ? { degradationNotice } : {})}
|
|
803
859
|
{...(filtersLayout !== undefined ? { filtersLayout } : {})}
|
|
@@ -46,6 +46,8 @@ import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
|
46
46
|
import { DegradationNotice } from "./DegradationNotice.js";
|
|
47
47
|
import { EmptyExits } from "./EmptyExits.js";
|
|
48
48
|
import type { DegradationNoticeVariant } from "./DegradationNotice.js";
|
|
49
|
+
import { OtherCategoriesLine } from "./OtherCategoriesLine.js";
|
|
50
|
+
import type { OtherCategoryNamer } from "./OtherCategoriesLine.js";
|
|
49
51
|
import { SearchResultCard } from "./SearchResultCard.js";
|
|
50
52
|
import type { SearchCardRenderer } from "./SearchResultCard.js";
|
|
51
53
|
import type { SearchResultsLayout } from "./ViewSwitch.js";
|
|
@@ -261,6 +263,20 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
|
|
|
261
263
|
* this pair offers the slot and never the tree.
|
|
262
264
|
*/
|
|
263
265
|
readonly renderEmptyExits?: () => ReactNode;
|
|
266
|
+
/**
|
|
267
|
+
* Draw "Search in other categories: Cars 12 · Buses 3 · …" above the
|
|
268
|
+
* results — the sections THIS answer is made of, one line, from the same
|
|
269
|
+
* response (`<OtherCategoriesLine>`).
|
|
270
|
+
*
|
|
271
|
+
* Opt-in, because the rows are id PATHS and naming them is the host's
|
|
272
|
+
* (`categoryName`): a deployment that passes neither gets the line only for
|
|
273
|
+
* the paths the server itself named, which on a slug-less catalogue is
|
|
274
|
+
* none. Where it is on, it costs no request while there are results.
|
|
275
|
+
*/
|
|
276
|
+
readonly otherCategories?: boolean;
|
|
277
|
+
/** What a category id path is CALLED, for the line above — see
|
|
278
|
+
* {@link OtherCategoriesLineProps.categoryName}. */
|
|
279
|
+
readonly categoryName?: OtherCategoryNamer;
|
|
264
280
|
}
|
|
265
281
|
|
|
266
282
|
function Count(props: { bag: SearchResultsBag }): ReactElement | null {
|
|
@@ -375,6 +391,18 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
375
391
|
scorerName={scorerName}
|
|
376
392
|
/>
|
|
377
393
|
|
|
394
|
+
{/* ABOVE the results and in the SAME frame as them. Under them
|
|
395
|
+
and asynchronously is where it was, and both halves of that
|
|
396
|
+
pushed a page a person had started reading. */}
|
|
397
|
+
{props.otherCategories === true && (
|
|
398
|
+
<OtherCategoriesLine
|
|
399
|
+
{...(props.enabled !== undefined ? { enabled: props.enabled } : {})}
|
|
400
|
+
{...(props.categoryName !== undefined
|
|
401
|
+
? { categoryName: props.categoryName }
|
|
402
|
+
: {})}
|
|
403
|
+
/>
|
|
404
|
+
)}
|
|
405
|
+
|
|
378
406
|
<LoadList
|
|
379
407
|
state={bag.state}
|
|
380
408
|
testId="search"
|
package/src/default/index.ts
CHANGED
|
@@ -48,6 +48,19 @@ export {
|
|
|
48
48
|
} from "./SearchPage.js";
|
|
49
49
|
export type { SearchPageProps, SearchFiltersLayout } from "./SearchPage.js";
|
|
50
50
|
|
|
51
|
+
export {
|
|
52
|
+
OtherCategoriesLine,
|
|
53
|
+
OTHER_CATEGORIES_CLASS,
|
|
54
|
+
OTHER_CATEGORIES_PHONE_ROWS,
|
|
55
|
+
OTHER_CATEGORIES_SLOT_MIN_HEIGHT,
|
|
56
|
+
OTHER_CATEGORIES_STYLE_HREF,
|
|
57
|
+
otherCategoriesCss,
|
|
58
|
+
} from "./OtherCategoriesLine.js";
|
|
59
|
+
export type {
|
|
60
|
+
OtherCategoriesLineProps,
|
|
61
|
+
OtherCategoryNamer,
|
|
62
|
+
} from "./OtherCategoriesLine.js";
|
|
63
|
+
|
|
51
64
|
export { SearchResultsPane, RESULTS_MAX_WIDTH } from "./SearchResultsPane.js";
|
|
52
65
|
export type {
|
|
53
66
|
SearchResultsPaneProps,
|
|
@@ -2,17 +2,22 @@ import {
|
|
|
2
2
|
createContext,
|
|
3
3
|
useCallback,
|
|
4
4
|
useContext,
|
|
5
|
+
useEffect,
|
|
5
6
|
useMemo,
|
|
7
|
+
useState,
|
|
6
8
|
} from "react";
|
|
7
9
|
import type { ReactElement, ReactNode } from "react";
|
|
8
10
|
import type {
|
|
11
|
+
FacetLabelsMap,
|
|
9
12
|
SearchGeo,
|
|
10
13
|
SearchQueryState,
|
|
11
14
|
SearchRange,
|
|
12
15
|
} from "../api/types.js";
|
|
13
16
|
import {
|
|
17
|
+
EMPTY_FACET_KEYS,
|
|
14
18
|
activeFilterCount,
|
|
15
19
|
clearFilters,
|
|
20
|
+
facetKeyMapFromLabels,
|
|
16
21
|
parseSearchState,
|
|
17
22
|
patchSearchState,
|
|
18
23
|
setFilterValues,
|
|
@@ -21,6 +26,7 @@ import {
|
|
|
21
26
|
writeSearchState,
|
|
22
27
|
} from "../state/urlState.js";
|
|
23
28
|
import type {
|
|
29
|
+
FacetKeyMap,
|
|
24
30
|
ParseSearchStateOptions,
|
|
25
31
|
SearchStateIssue,
|
|
26
32
|
SearchStatePatch,
|
|
@@ -132,6 +138,56 @@ function sameCenter(
|
|
|
132
138
|
|
|
133
139
|
const StateContext = createContext<SearchStateBag | null>(null);
|
|
134
140
|
|
|
141
|
+
/**
|
|
142
|
+
* WHERE THE SHORT KEYS COME FROM.
|
|
143
|
+
*
|
|
144
|
+
* `f.make` is a fact of the ANSWER (`facet_labels[slug].url_key`, resolved by
|
|
145
|
+
* the server inside the queried category's scope), and the codec that writes
|
|
146
|
+
* the address runs above the query that produces it. So the map is published
|
|
147
|
+
* upwards: whoever holds an answer hands it to this provider, which re-parses
|
|
148
|
+
* the URL with it and writes every subsequent address through it.
|
|
149
|
+
*
|
|
150
|
+
* Late by construction and correct at every moment in between: before the
|
|
151
|
+
* first answer the state holds whatever key the link carried, the request
|
|
152
|
+
* carries the same key, and the server resolves both forms. Nothing waits and
|
|
153
|
+
* nothing is rewritten behind the reader.
|
|
154
|
+
*/
|
|
155
|
+
interface FacetKeyRegistry {
|
|
156
|
+
readonly keys: FacetKeyMap;
|
|
157
|
+
publish(next: FacetKeyMap): void;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const FacetKeysContext = createContext<FacetKeyRegistry | null>(null);
|
|
161
|
+
|
|
162
|
+
/** Two maps are the same map when they write the same keys — the read side is
|
|
163
|
+
* derived from the write side, so comparing one compares both. */
|
|
164
|
+
function sameKeys(a: FacetKeyMap, b: FacetKeyMap): boolean {
|
|
165
|
+
const left = Object.keys(a.write);
|
|
166
|
+
const right = Object.keys(b.write);
|
|
167
|
+
if (left.length !== right.length) return false;
|
|
168
|
+
return left.every((slug) => a.write[slug] === b.write[slug]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Publish an answer's short keys to the state provider above.
|
|
173
|
+
*
|
|
174
|
+
* A no-op outside `<SearchStateProvider>` and a no-op on a server that sends
|
|
175
|
+
* no `url_key`: both leave the address spelled in slugs, which is what it was
|
|
176
|
+
* spelled in before this existed.
|
|
177
|
+
*/
|
|
178
|
+
export function usePublishFacetKeys(labels: FacetLabelsMap | undefined): void {
|
|
179
|
+
const registry = useContext(FacetKeysContext);
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (registry === null || labels === undefined) return;
|
|
182
|
+
registry.publish(facetKeyMapFromLabels(labels));
|
|
183
|
+
}, [registry, labels]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The short-key map this search is currently writing its address with. */
|
|
187
|
+
export function useFacetKeys(): FacetKeyMap {
|
|
188
|
+
return useContext(FacetKeysContext)?.keys ?? EMPTY_FACET_KEYS;
|
|
189
|
+
}
|
|
190
|
+
|
|
135
191
|
export interface SearchStateProviderProps extends ParseSearchStateOptions {
|
|
136
192
|
readonly adapter: SearchParamsAdapter;
|
|
137
193
|
/**
|
|
@@ -193,9 +249,27 @@ export function SearchStateProvider(
|
|
|
193
249
|
|
|
194
250
|
const search = params.toString();
|
|
195
251
|
|
|
252
|
+
// The answer's short keys (`f.make`), published from below — see
|
|
253
|
+
// `FacetKeysContext`. Held here because this is where both directions of
|
|
254
|
+
// the codec run.
|
|
255
|
+
const [facetKeys, setFacetKeys] = useState<FacetKeyMap>(EMPTY_FACET_KEYS);
|
|
256
|
+
const registry = useMemo<FacetKeyRegistry>(
|
|
257
|
+
() => ({
|
|
258
|
+
keys: facetKeys,
|
|
259
|
+
publish: (next) => {
|
|
260
|
+
// Idempotent on purpose: this is called from an effect under a query
|
|
261
|
+
// whose data identity is stable, and a setState that always produced
|
|
262
|
+
// a new object would re-render the whole page per answer.
|
|
263
|
+
setFacetKeys((was) => (sameKeys(was, next) ? was : next));
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
[facetKeys]
|
|
267
|
+
);
|
|
268
|
+
|
|
196
269
|
const parsed = useMemo(
|
|
197
270
|
() =>
|
|
198
271
|
parseSearchState(new URLSearchParams(search), {
|
|
272
|
+
facetKeys,
|
|
199
273
|
defaultType,
|
|
200
274
|
...(defaultQ !== undefined ? { defaultQ } : {}),
|
|
201
275
|
...(defaultSort !== undefined ? { defaultSort } : {}),
|
|
@@ -203,14 +277,26 @@ export function SearchStateProvider(
|
|
|
203
277
|
...(defaultCategory !== undefined ? { defaultCategory } : {}),
|
|
204
278
|
...(defaultLang !== undefined ? { defaultLang } : {}),
|
|
205
279
|
}),
|
|
206
|
-
[
|
|
280
|
+
[
|
|
281
|
+
search,
|
|
282
|
+
facetKeys,
|
|
283
|
+
defaultType,
|
|
284
|
+
defaultQ,
|
|
285
|
+
defaultSort,
|
|
286
|
+
defaultLimit,
|
|
287
|
+
defaultCategory,
|
|
288
|
+
defaultLang,
|
|
289
|
+
]
|
|
207
290
|
);
|
|
208
291
|
|
|
209
292
|
const commit = useCallback(
|
|
210
293
|
(next: SearchQueryState, options?: { readonly replace?: boolean }): void => {
|
|
211
|
-
setParams(
|
|
294
|
+
setParams(
|
|
295
|
+
writeSearchState(next, new URLSearchParams(search), facetKeys),
|
|
296
|
+
options
|
|
297
|
+
);
|
|
212
298
|
},
|
|
213
|
-
[setParams, search]
|
|
299
|
+
[setParams, search, facetKeys]
|
|
214
300
|
);
|
|
215
301
|
|
|
216
302
|
|
|
@@ -280,7 +366,11 @@ export function SearchStateProvider(
|
|
|
280
366
|
}, [parsed, commit, geoOffer]);
|
|
281
367
|
|
|
282
368
|
|
|
283
|
-
return
|
|
369
|
+
return (
|
|
370
|
+
<FacetKeysContext.Provider value={registry}>
|
|
371
|
+
<StateContext.Provider value={bag}>{children}</StateContext.Provider>
|
|
372
|
+
</FacetKeysContext.Provider>
|
|
373
|
+
);
|
|
284
374
|
}
|
|
285
375
|
|
|
286
376
|
/**
|