@stapel/search-react 0.24.0 → 0.25.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 +55 -0
- package/README.md +38 -2
- 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 +20 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +7 -3
- 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/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/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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/llms.txt +2 -1
- package/manifest.json +28 -1
- package/nav-manifest.json +1 -1
- package/package.json +9 -9
- package/src/analytics/generated/events.json +1 -1
- package/src/default/OtherCategoriesLine.tsx +238 -0
- package/src/default/SearchPage.tsx +32 -0
- package/src/default/SearchResultsPane.tsx +28 -0
- package/src/default/index.ts +13 -0
- package/src/headless/useOtherCategories.ts +193 -0
- package/src/i18n/es.ts +4 -0
- package/src/i18n/keys.ts +32 -0
- package/src/i18n/ru.ts +5 -0
- package/src/index.ts +10 -0
|
@@ -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";
|
|
@@ -320,6 +321,25 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
320
321
|
* ask.
|
|
321
322
|
*/
|
|
322
323
|
readonly appliedChips?: boolean | "desktop";
|
|
324
|
+
/**
|
|
325
|
+
* Draw "Search in other categories: Cars 12 · Buses 3 · …" above the
|
|
326
|
+
* results — one line, from the SAME response the cards came from.
|
|
327
|
+
*
|
|
328
|
+
* It replaces the shape a storefront had built by hand: a full-width block
|
|
329
|
+
* of one row per category, fetched from `/suggest` after the page had
|
|
330
|
+
* settled and pushing everything below it when it landed. Here the rows are
|
|
331
|
+
* `facet_meta.categories`, which the answer already carried, so with results
|
|
332
|
+
* on screen the line costs no request and cannot arrive late. Only an EMPTY
|
|
333
|
+
* result set asks `/suggest`, into a slot whose height is reserved from the
|
|
334
|
+
* first frame.
|
|
335
|
+
*
|
|
336
|
+
* Opt-in, and `categoryName` is what makes it useful: the pair holds id
|
|
337
|
+
* paths and no catalogue — see {@link OtherCategoriesLineProps.categoryName}.
|
|
338
|
+
*/
|
|
339
|
+
readonly otherCategories?: boolean;
|
|
340
|
+
/** What a category id path is CALLED, for the line above. The same question
|
|
341
|
+
* `categoryLabel` answers for the chip, asked once per row. */
|
|
342
|
+
readonly categoryName?: OtherCategoryNamer;
|
|
323
343
|
/** What this surface calls its result list. See
|
|
324
344
|
* {@link SearchResultsPaneProps.heading}. */
|
|
325
345
|
readonly resultsHeading?: ReactNode;
|
|
@@ -417,6 +437,8 @@ interface SearchPageBodyProps {
|
|
|
417
437
|
readonly filtersHeader?: ReactNode;
|
|
418
438
|
readonly resultsHeader?: ReactNode;
|
|
419
439
|
readonly appliedChips?: boolean | "desktop";
|
|
440
|
+
readonly otherCategories?: boolean;
|
|
441
|
+
readonly categoryName?: OtherCategoryNamer;
|
|
420
442
|
readonly resultsHeading?: ReactNode;
|
|
421
443
|
readonly degradationNotice?: DegradationNoticeVariant;
|
|
422
444
|
readonly filtersLayout?: SearchFiltersLayout;
|
|
@@ -586,6 +608,12 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
586
608
|
{...(view.layout !== undefined ? { layout: view.layout } : {})}
|
|
587
609
|
{...(props.renderCard !== undefined ? { renderCard: props.renderCard } : {})}
|
|
588
610
|
{...(props.footer !== undefined ? { footer: props.footer } : {})}
|
|
611
|
+
{...(props.otherCategories !== undefined
|
|
612
|
+
? { otherCategories: props.otherCategories }
|
|
613
|
+
: {})}
|
|
614
|
+
{...(props.categoryName !== undefined
|
|
615
|
+
? { categoryName: props.categoryName }
|
|
616
|
+
: {})}
|
|
589
617
|
{...(props.resultsHeading !== undefined
|
|
590
618
|
? { heading: props.resultsHeading }
|
|
591
619
|
: {})}
|
|
@@ -763,6 +791,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
763
791
|
filtersHeader,
|
|
764
792
|
resultsHeader,
|
|
765
793
|
appliedChips,
|
|
794
|
+
otherCategories,
|
|
795
|
+
categoryName,
|
|
766
796
|
resultsHeading,
|
|
767
797
|
degradationNotice,
|
|
768
798
|
filtersLayout,
|
|
@@ -798,6 +828,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
798
828
|
{...(filtersHeader !== undefined ? { filtersHeader } : {})}
|
|
799
829
|
{...(resultsHeader !== undefined ? { resultsHeader } : {})}
|
|
800
830
|
{...(appliedChips !== undefined ? { appliedChips } : {})}
|
|
831
|
+
{...(otherCategories !== undefined ? { otherCategories } : {})}
|
|
832
|
+
{...(categoryName !== undefined ? { categoryName } : {})}
|
|
801
833
|
{...(resultsHeading !== undefined ? { resultsHeading } : {})}
|
|
802
834
|
{...(degradationNotice !== undefined ? { degradationNotice } : {})}
|
|
803
835
|
{...(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,
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "The same query, in another section" — the categories THIS answer is made
|
|
3
|
+
* of, read out of the answer that drew the cards.
|
|
4
|
+
*
|
|
5
|
+
* ## What it replaces
|
|
6
|
+
*
|
|
7
|
+
* A storefront drew this as a full-width block under the results: one row per
|
|
8
|
+
* category, fetched from `/suggest` AFTER the page had settled, so a tall
|
|
9
|
+
* panel appeared a beat late and pushed everything a person was already
|
|
10
|
+
* reading. Both halves were avoidable. The block was tall where one line
|
|
11
|
+
* says the same thing, and the request was a second ask for something the
|
|
12
|
+
* page already had: `/query` answers with `facet_meta.categories` — `{path,
|
|
13
|
+
* count}` for every category the candidate set contains — and that is
|
|
14
|
+
* exactly the list the block was printing.
|
|
15
|
+
*
|
|
16
|
+
* So the rows come from the SEARCH response. No request of this hook's own
|
|
17
|
+
* while there are results, and therefore nothing to arrive late.
|
|
18
|
+
*
|
|
19
|
+
* ## The one case that DOES earn a request
|
|
20
|
+
*
|
|
21
|
+
* An empty result set has no candidates, so `facet_meta.categories` is empty
|
|
22
|
+
* too — and that is precisely the screen where "this word exists in these
|
|
23
|
+
* sections" is worth the most. There, and only there, `/suggest` is asked.
|
|
24
|
+
* The bag says so through {@link OtherCategoriesBag.reserving}, so a surface
|
|
25
|
+
* can hold the row's height from the first frame instead of letting the
|
|
26
|
+
* answer push the page a second time.
|
|
27
|
+
*
|
|
28
|
+
* ## Names come from the cache, not from a request
|
|
29
|
+
*
|
|
30
|
+
* `facet_meta.categories` carries id PATHS and counts, never names — the
|
|
31
|
+
* catalogue belongs to `categories-react`. Three sources are tried, in order:
|
|
32
|
+
*
|
|
33
|
+
* 1. the host's resolver (a surface prop), which is the only one that can
|
|
34
|
+
* name an id path with certainty;
|
|
35
|
+
* 2. the `/suggest` answer ALREADY IN THE QUERY CACHE — the type-ahead asked
|
|
36
|
+
* about this same word a keystroke earlier, and its rows carry the
|
|
37
|
+
* server's own names. `useSuggest` with `enabled: false` hands back a
|
|
38
|
+
* cached answer and fires nothing, so this costs no request;
|
|
39
|
+
* 3. the path's last segment, when it is a slug rather than a number.
|
|
40
|
+
*
|
|
41
|
+
* A row none of the three can name is DROPPED rather than printed as "163" —
|
|
42
|
+
* the same rule `categoryLeaf` states for the category chip.
|
|
43
|
+
*/
|
|
44
|
+
import type { SearchResponse, SuggestCategory } from "../api/types.js";
|
|
45
|
+
import { useSearchQuery, useSuggest } from "../model/queries.js";
|
|
46
|
+
import { offerableCategories } from "./useSearchBox.js";
|
|
47
|
+
import { useSearchState } from "./SearchStateProvider.js";
|
|
48
|
+
|
|
49
|
+
/** How many entries the line prints before it folds the rest. */
|
|
50
|
+
export const OTHER_CATEGORIES_LIMIT = 8;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The phone's cap.
|
|
54
|
+
*
|
|
55
|
+
* Half the desktop's, because the requirement is a LINE and at 390px eight
|
|
56
|
+
* entries are not one — they are the block this replaces, wearing a comma.
|
|
57
|
+
* Four short section names plus the fold marker are the two rows a phone can
|
|
58
|
+
* spend on a navigation aid above a list of cards.
|
|
59
|
+
*/
|
|
60
|
+
export const OTHER_CATEGORIES_PHONE_LIMIT = 4;
|
|
61
|
+
|
|
62
|
+
/** One category the current answer reached, with the count that answer gives it. */
|
|
63
|
+
export interface OtherCategoryRow {
|
|
64
|
+
/** The slash-joined id path — the same string the `category` filter takes,
|
|
65
|
+
* so narrowing to it needs no translation and no tree. */
|
|
66
|
+
readonly category: string;
|
|
67
|
+
/** Documents matching THIS query that live there. Scoped to the query, which
|
|
68
|
+
* is why narrowing the search is the honest destination and the bare
|
|
69
|
+
* category feed is not: the feed would show a different number. */
|
|
70
|
+
readonly count: number;
|
|
71
|
+
/** The server's own name, when this row came from `/suggest` or when a
|
|
72
|
+
* cached suggest answer named the same path. */
|
|
73
|
+
readonly name?: string;
|
|
74
|
+
/** Display names root→leaf, when the server sent them. */
|
|
75
|
+
readonly path?: readonly string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface OtherCategoriesBag {
|
|
79
|
+
/** Every row, uncapped and in the order the line should print them. Capping
|
|
80
|
+
* is a decision about the SURFACE (a phone takes fewer), so it is not made
|
|
81
|
+
* here. */
|
|
82
|
+
readonly rows: readonly OtherCategoryRow[];
|
|
83
|
+
/**
|
|
84
|
+
* Where the rows came from. `"results"` — the search response itself, drawn
|
|
85
|
+
* in the same frame as the cards. `"suggest"` — the empty-result path, the
|
|
86
|
+
* only one with a request behind it. `"none"` — nothing to offer.
|
|
87
|
+
*/
|
|
88
|
+
readonly source: "results" | "suggest" | "none";
|
|
89
|
+
/**
|
|
90
|
+
* The empty-result path is active, so a row is coming from a request that
|
|
91
|
+
* has not landed (or landed empty). A surface keeps the height either way:
|
|
92
|
+
* space reserved from the first frame is space nothing can push.
|
|
93
|
+
*/
|
|
94
|
+
readonly reserving: boolean;
|
|
95
|
+
/** That request is in flight right now. */
|
|
96
|
+
readonly pending: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const NO_ROWS: readonly OtherCategoryRow[] = [];
|
|
100
|
+
|
|
101
|
+
/** The path's last segment, when it names something — never a bare id. */
|
|
102
|
+
export function otherCategoryLeaf(category: string): string | undefined {
|
|
103
|
+
const parts = category.split("/").filter((part) => part.length > 0);
|
|
104
|
+
const leaf = parts[parts.length - 1];
|
|
105
|
+
if (leaf === undefined || /^\d+$/.test(leaf)) return undefined;
|
|
106
|
+
return leaf;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The rows the ANSWER carries, busiest first and without the section the
|
|
110
|
+
* search is already in. */
|
|
111
|
+
function rowsFromResponse(
|
|
112
|
+
answer: SearchResponse | undefined,
|
|
113
|
+
applied: string | undefined,
|
|
114
|
+
named: ReadonlyMap<string, SuggestCategory>
|
|
115
|
+
): readonly OtherCategoryRow[] {
|
|
116
|
+
const meta = answer?.facet_meta.categories ?? [];
|
|
117
|
+
const rows = meta
|
|
118
|
+
// "Other" than the one the search is in. Descendants stay: narrowing from
|
|
119
|
+
// a branch to one of its leaves is a different section and the move this
|
|
120
|
+
// line exists for.
|
|
121
|
+
.filter((row) => row.category !== applied)
|
|
122
|
+
.map((row) => {
|
|
123
|
+
const match = named.get(row.category);
|
|
124
|
+
return {
|
|
125
|
+
category: row.category,
|
|
126
|
+
count: row.count,
|
|
127
|
+
...(match !== undefined ? { name: match.name, path: match.path } : {}),
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
// The server documents "busiest first" and the cap has to take the busiest
|
|
131
|
+
// eight; asserting the order here is cheaper than trusting it and being
|
|
132
|
+
// wrong about which eight a person sees.
|
|
133
|
+
return [...rows].sort((a, b) =>
|
|
134
|
+
b.count === a.count ? a.category.localeCompare(b.category) : b.count - a.count
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function useOtherCategories(
|
|
139
|
+
options: { readonly enabled?: boolean } = {}
|
|
140
|
+
): OtherCategoriesBag {
|
|
141
|
+
const { state } = useSearchState();
|
|
142
|
+
const enabled = options.enabled ?? true;
|
|
143
|
+
|
|
144
|
+
// The SAME query the results pane runs: identical key, identical cache
|
|
145
|
+
// entry, no second request. This hook can therefore be mounted anywhere
|
|
146
|
+
// under the provider without the envelope having to be threaded to it.
|
|
147
|
+
const query = useSearchQuery(state, { enabled });
|
|
148
|
+
const answer = query.data;
|
|
149
|
+
|
|
150
|
+
// Ready AND empty. `answer === undefined` is "still loading", which is not
|
|
151
|
+
// an empty result and must not spend a request.
|
|
152
|
+
const resultsEmpty = enabled && answer !== undefined && answer.items.length === 0;
|
|
153
|
+
|
|
154
|
+
// `enabled: false` still returns whatever the box already put in the cache
|
|
155
|
+
// and asks nothing — which is how a page WITH results gets the server's
|
|
156
|
+
// names for free. A page WITHOUT results turns the same hook into the one
|
|
157
|
+
// request this feature ever makes.
|
|
158
|
+
const suggest = useSuggest({ type: state.type, q: state.q, enabled: resultsEmpty });
|
|
159
|
+
// Through the pair's own reader, which is where "this server sent no
|
|
160
|
+
// categories key at all" is already distinguished from "nothing matched".
|
|
161
|
+
const suggested = offerableCategories(suggest.data);
|
|
162
|
+
const named = new Map(suggested.map((row) => [row.category, row]));
|
|
163
|
+
|
|
164
|
+
const fromResults = rowsFromResponse(answer, state.category, named);
|
|
165
|
+
|
|
166
|
+
const pending = resultsEmpty && suggest.isLoading && suggest.fetchStatus === "fetching";
|
|
167
|
+
|
|
168
|
+
if (fromResults.length > 0) {
|
|
169
|
+
return { rows: fromResults, source: "results", reserving: false, pending: false };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (resultsEmpty) {
|
|
173
|
+
// The server's rank order is kept as sent: `/suggest` ranks by match grade
|
|
174
|
+
// and stock before count, and re-sorting by count here would throw that
|
|
175
|
+
// away for a number that is not what ranked them.
|
|
176
|
+
const rows = suggested
|
|
177
|
+
.filter((row) => row.category !== state.category)
|
|
178
|
+
.map((row) => ({
|
|
179
|
+
category: row.category,
|
|
180
|
+
count: row.count,
|
|
181
|
+
name: row.name,
|
|
182
|
+
path: row.path,
|
|
183
|
+
}));
|
|
184
|
+
return {
|
|
185
|
+
rows,
|
|
186
|
+
source: rows.length > 0 ? "suggest" : "none",
|
|
187
|
+
reserving: true,
|
|
188
|
+
pending,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { rows: NO_ROWS, source: "none", reserving: false, pending: false };
|
|
193
|
+
}
|
package/src/i18n/es.ts
CHANGED
|
@@ -131,6 +131,10 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
131
131
|
"search.empty.widen_radius": "Buscar en un radio de {km} km",
|
|
132
132
|
"search.empty.anywhere": "Buscar en todas partes",
|
|
133
133
|
"search.empty.drop_filter": "Sin «{name}»",
|
|
134
|
+
|
|
135
|
+
"search.other_categories.label": "Buscar en otras categorías:",
|
|
136
|
+
"search.other_categories.narrow": "Buscar en {name}",
|
|
137
|
+
"search.other_categories.more": "{count} más",
|
|
134
138
|
"search.category.title": "Categoría",
|
|
135
139
|
"search.category.clear": "Buscar en todo el catálogo",
|
|
136
140
|
"search.category.current": "Buscando dentro de {path}",
|
package/src/i18n/keys.ts
CHANGED
|
@@ -282,6 +282,33 @@ export const SEARCH_I18N_KEYS = {
|
|
|
282
282
|
/** "Without {name}" — one applied filter, named as its own chip names it. */
|
|
283
283
|
emptyDropFilter: "search.empty.drop_filter",
|
|
284
284
|
|
|
285
|
+
// ── the same query, in another section (one line, from the answer) ───────
|
|
286
|
+
/**
|
|
287
|
+
* The line's lead-in — "Search in other categories:". It ends in a colon
|
|
288
|
+
* because what follows is a list on the same line, and a translator who is
|
|
289
|
+
* shown the string without one has no way to know that.
|
|
290
|
+
*
|
|
291
|
+
* Not `search.box.categories` ("Sections", the type-ahead's group heading):
|
|
292
|
+
* that one titles a menu of destinations while a person types, this one
|
|
293
|
+
* introduces a row of narrowings beside a result list. Same noun, two
|
|
294
|
+
* surfaces, and the languages that decline it need both.
|
|
295
|
+
*/
|
|
296
|
+
otherCategoriesLabel: "search.other_categories.label",
|
|
297
|
+
/**
|
|
298
|
+
* The accessible name of one entry. The visible caption is "Cars 12" — a
|
|
299
|
+
* name and a number, which says nothing about what pressing it DOES. This
|
|
300
|
+
* does: it narrows the search that is already on screen, and it keeps the
|
|
301
|
+
* query, which is why the number beside it stays true afterwards.
|
|
302
|
+
*/
|
|
303
|
+
otherCategoriesNarrow: "search.other_categories.narrow",
|
|
304
|
+
/**
|
|
305
|
+
* The fold at the end of the line: how many sections it did not print.
|
|
306
|
+
* `{count}` is a bare number after a word, not a counted noun in a
|
|
307
|
+
* sentence, so this is not a plural family — the same shape as
|
|
308
|
+
* `facetsAllFilters` and `filtersChipOverflow`.
|
|
309
|
+
*/
|
|
310
|
+
otherCategoriesMore: "search.other_categories.more",
|
|
311
|
+
|
|
285
312
|
// ── category (a host slot, plus the control that removes it) ─────────────
|
|
286
313
|
categoryTitle: "search.category.title",
|
|
287
314
|
categoryClear: "search.category.clear",
|
|
@@ -573,6 +600,11 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
573
600
|
"search.empty.widen_radius": "Search within {km} km",
|
|
574
601
|
"search.empty.anywhere": "Search anywhere",
|
|
575
602
|
"search.empty.drop_filter": "Without {name}",
|
|
603
|
+
|
|
604
|
+
"search.other_categories.label": "Search in other categories:",
|
|
605
|
+
"search.other_categories.narrow": "Search in {name}",
|
|
606
|
+
"search.other_categories.more": "{count} more",
|
|
607
|
+
|
|
576
608
|
"search.category.title": "Category",
|
|
577
609
|
"search.category.clear": "Search the whole catalogue",
|
|
578
610
|
"search.category.current": "Searching inside {path}",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -166,6 +166,11 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
166
166
|
"search.empty.widen_radius": "Искать в радиусе {km} км",
|
|
167
167
|
"search.empty.anywhere": "Искать везде",
|
|
168
168
|
"search.empty.drop_filter": "Без «{name}»",
|
|
169
|
+
|
|
170
|
+
// One line, out of the same answer the cards came from: section and count.
|
|
171
|
+
"search.other_categories.label": "Поиск в других категориях:",
|
|
172
|
+
"search.other_categories.narrow": "Искать в разделе «{name}»",
|
|
173
|
+
"search.other_categories.more": "ещё {count}",
|
|
169
174
|
"search.category.title": "Категория",
|
|
170
175
|
"search.category.clear": "Искать по всему каталогу",
|
|
171
176
|
"search.category.current": "Ищем внутри {path}",
|
package/src/index.ts
CHANGED
|
@@ -194,6 +194,16 @@ export type {
|
|
|
194
194
|
} from "./headless/useFacetLabels.js";
|
|
195
195
|
export { useAppliedSort } from "./headless/useAppliedSort.js";
|
|
196
196
|
export { offerableCategories, useSearchBox } from "./headless/useSearchBox.js";
|
|
197
|
+
export {
|
|
198
|
+
OTHER_CATEGORIES_LIMIT,
|
|
199
|
+
OTHER_CATEGORIES_PHONE_LIMIT,
|
|
200
|
+
otherCategoryLeaf,
|
|
201
|
+
useOtherCategories,
|
|
202
|
+
} from "./headless/useOtherCategories.js";
|
|
203
|
+
export type {
|
|
204
|
+
OtherCategoriesBag,
|
|
205
|
+
OtherCategoryRow,
|
|
206
|
+
} from "./headless/useOtherCategories.js";
|
|
197
207
|
export type { SearchBoxBag, UseSearchBoxOptions } from "./headless/useSearchBox.js";
|
|
198
208
|
export { countQueryState, useSearchCount } from "./headless/useSearchCount.js";
|
|
199
209
|
export type {
|