@stapel/search-react 0.4.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +151 -0
- package/README.md +1 -1
- package/dist/default/DegradationNotice.d.ts +7 -0
- package/dist/default/DegradationNotice.d.ts.map +1 -1
- package/dist/default/DegradationNotice.js +17 -6
- package/dist/default/DegradationNotice.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +54 -3
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +119 -19
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/LanguageSelect.d.ts +9 -0
- package/dist/default/LanguageSelect.d.ts.map +1 -0
- package/dist/default/LanguageSelect.js +72 -0
- package/dist/default/LanguageSelect.js.map +1 -0
- package/dist/default/PageSizeSelect.d.ts +29 -0
- package/dist/default/PageSizeSelect.d.ts.map +1 -0
- package/dist/default/PageSizeSelect.js +25 -0
- package/dist/default/PageSizeSelect.js.map +1 -0
- package/dist/default/RangeFilterRow.d.ts +12 -0
- package/dist/default/RangeFilterRow.d.ts.map +1 -0
- package/dist/default/RangeFilterRow.js +75 -0
- package/dist/default/RangeFilterRow.js.map +1 -0
- package/dist/default/RankingDisclosurePane.d.ts +5 -0
- package/dist/default/RankingDisclosurePane.d.ts.map +1 -1
- package/dist/default/RankingDisclosurePane.js +15 -15
- package/dist/default/RankingDisclosurePane.js.map +1 -1
- package/dist/default/SearchBox.d.ts +13 -0
- package/dist/default/SearchBox.d.ts.map +1 -0
- package/dist/default/SearchBox.js +67 -0
- package/dist/default/SearchBox.js.map +1 -0
- package/dist/default/SearchPage.d.ts +54 -16
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +155 -8
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultCard.d.ts +19 -18
- package/dist/default/SearchResultCard.d.ts.map +1 -1
- package/dist/default/SearchResultCard.js +178 -5
- package/dist/default/SearchResultCard.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +42 -3
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +38 -21
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/SortSelect.d.ts +10 -2
- package/dist/default/SortSelect.d.ts.map +1 -1
- package/dist/default/SortSelect.js +27 -24
- package/dist/default/SortSelect.js.map +1 -1
- package/dist/default/UrlIssueNotice.d.ts.map +1 -1
- package/dist/default/UrlIssueNotice.js +19 -1
- package/dist/default/UrlIssueNotice.js.map +1 -1
- package/dist/default/index.d.ts +32 -15
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +26 -12
- package/dist/default/index.js.map +1 -1
- package/dist/default/sortLabels.d.ts +5 -0
- package/dist/default/sortLabels.d.ts.map +1 -0
- package/dist/default/sortLabels.js +32 -0
- package/dist/default/sortLabels.js.map +1 -0
- package/dist/headless/FacetPanel.d.ts +17 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +23 -2
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/useAppliedCount.d.ts +8 -0
- package/dist/headless/useAppliedCount.d.ts.map +1 -0
- package/dist/headless/useAppliedCount.js +30 -0
- package/dist/headless/useAppliedCount.js.map +1 -0
- package/dist/headless/useScorerNames.d.ts +4 -0
- package/dist/headless/useScorerNames.d.ts.map +1 -0
- package/dist/headless/useScorerNames.js +34 -0
- package/dist/headless/useScorerNames.js.map +1 -0
- package/dist/headless/useSearchBox.d.ts +44 -0
- package/dist/headless/useSearchBox.d.ts.map +1 -0
- package/dist/headless/useSearchBox.js +116 -0
- package/dist/headless/useSearchBox.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +30 -4
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +35 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +72 -4
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +34 -4
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/model/queries.d.ts +26 -2
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +38 -1
- package/dist/model/queries.js.map +1 -1
- package/dist/state/limits.d.ts +44 -0
- package/dist/state/limits.d.ts.map +1 -0
- package/dist/state/limits.js +44 -0
- package/dist/state/limits.js.map +1 -0
- package/dist/state/ranges.d.ts +52 -0
- package/dist/state/ranges.d.ts.map +1 -0
- package/dist/state/ranges.js +108 -0
- package/dist/state/ranges.js.map +1 -0
- package/llms.txt +15 -4
- package/manifest.json +238 -21
- package/nav-manifest.json +1 -1
- package/package.json +15 -10
- package/src/analytics/generated/events.json +1 -1
- package/src/default/DegradationNotice.tsx +34 -12
- package/src/default/FacetPanelPane.tsx +343 -64
- package/src/default/LanguageSelect.tsx +93 -0
- package/src/default/PageSizeSelect.tsx +80 -0
- package/src/default/RangeFilterRow.tsx +148 -0
- package/src/default/RankingDisclosurePane.tsx +67 -35
- package/src/default/SearchBox.tsx +130 -0
- package/src/default/SearchPage.tsx +351 -44
- package/src/default/SearchResultCard.tsx +231 -24
- package/src/default/SearchResultsPane.tsx +120 -70
- package/src/default/SortSelect.tsx +50 -37
- package/src/default/UrlIssueNotice.tsx +21 -3
- package/src/default/index.ts +38 -15
- package/src/default/sortLabels.ts +34 -0
- package/src/headless/FacetPanel.tsx +30 -2
- package/src/headless/useAppliedCount.ts +41 -0
- package/src/headless/useScorerNames.ts +36 -0
- package/src/headless/useSearchBox.ts +189 -0
- package/src/i18n/es.ts +37 -4
- package/src/i18n/keys.ts +83 -4
- package/src/i18n/ru.ts +41 -4
- package/src/index.ts +22 -2
- package/src/model/queries.ts +51 -1
- package/src/state/limits.ts +50 -0
- package/src/state/ranges.ts +144 -0
- package/dist/default/ErrorAlert.d.ts +0 -9
- package/dist/default/ErrorAlert.d.ts.map +0 -1
- package/dist/default/ErrorAlert.js +0 -27
- package/dist/default/ErrorAlert.js.map +0 -1
- package/dist/default/theme.d.ts +0 -24
- package/dist/default/theme.d.ts.map +0 -1
- package/dist/default/theme.js +0 -39
- package/dist/default/theme.js.map +0 -1
- package/src/default/ErrorAlert.tsx +0 -46
- package/src/default/theme.tsx +0 -66
|
@@ -15,10 +15,42 @@
|
|
|
15
15
|
* every sort, and a storefront where some lists show it and some do not is
|
|
16
16
|
* legally worse than one that never showed it. `renderCard` receives the whole
|
|
17
17
|
* item so the marking is always reachable, and this default renders it.
|
|
18
|
+
*
|
|
19
|
+
* ── Two things this card used to get wrong ────────────────────────────────
|
|
20
|
+
*
|
|
21
|
+
* 1. **It declared `image_url` and drew nothing.** A text-only card in a
|
|
22
|
+
* classifieds search is not a card; the field was in `GENERIC_CARD_FIELDS`
|
|
23
|
+
* and in no render path. It is drawn now, through `@stapel/image`, so the
|
|
24
|
+
* aspect box lands before the network does and a dead URL renders a named
|
|
25
|
+
* placeholder instead of the browser's torn-page icon.
|
|
26
|
+
* 2. **The marking's EXPLANATION was in a `Tooltip`.** Touch has no hover, so
|
|
27
|
+
* on the device most of this traffic arrives from, the sentence the law is
|
|
28
|
+
* actually about was unreachable — the tag said "Promoted" and nothing on
|
|
29
|
+
* screen said what that meant. It is ordinary text under the tag now
|
|
30
|
+
* (`stapel/no-tooltip-in-skin`), and the tag itself is a `--stapel-*` role
|
|
31
|
+
* rather than an antd preset, because the one legally-mandated marking in
|
|
32
|
+
* the pair has to look the same in every skin a deployment builds.
|
|
33
|
+
* 3. **The tag was white on cream.** `warning-on` is the text colour for the
|
|
34
|
+
* SOLID warning fill; over `warning-bg` the readable role is `warning`
|
|
35
|
+
* itself. The visual pass measured the one legally-mandated string in the
|
|
36
|
+
* package at roughly 1.2:1 — a disclosure nobody can read is the same as
|
|
37
|
+
* no disclosure, in both themes.
|
|
38
|
+
* 4. **The price was a raw amount and an ISO code.** "3200 RUB" is a wire
|
|
39
|
+
* value printed as prose: no grouping, no symbol, and the code in the
|
|
40
|
+
* reader's face. It goes through core's `useFormat().number` with
|
|
41
|
+
* `style: "currency"`, which is the same `Intl` path `@stapel/currencies-
|
|
42
|
+
* react`'s `formatMoney` takes — this pair does not depend on that package
|
|
43
|
+
* (a search index is not a price book) but it must not invent a second
|
|
44
|
+
* way to write money either.
|
|
18
45
|
*/
|
|
19
|
-
import
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
46
|
+
import { useMemo } from "react";
|
|
47
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
48
|
+
import { Card, Flex, Typography } from "antd";
|
|
49
|
+
import { Image } from "@stapel/image";
|
|
50
|
+
import type { StapelImage } from "@stapel/image";
|
|
51
|
+
import { useFormat, useT } from "@stapel/core";
|
|
52
|
+
import type { Format } from "@stapel/core";
|
|
53
|
+
import { cssVar, fontSize, fontWeight, radii, spacing } from "@stapel/tokens";
|
|
22
54
|
import type { SearchItem } from "../api/types.js";
|
|
23
55
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
24
56
|
|
|
@@ -47,14 +79,138 @@ export const GENERIC_CARD_FIELDS: readonly string[] = [
|
|
|
47
79
|
"currency",
|
|
48
80
|
"location",
|
|
49
81
|
"image_url",
|
|
82
|
+
"url",
|
|
50
83
|
];
|
|
51
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The card's price, written the way money is written.
|
|
87
|
+
*
|
|
88
|
+
* `card.price` arrives as a string because the index stores it as one, and
|
|
89
|
+
* `card.currency` as an ISO 4217 code because that is what a document carries.
|
|
90
|
+
* Rendered verbatim they read "3200 RUB": no grouping, the code where the
|
|
91
|
+
* symbol belongs, and the reader doing the arithmetic of where the thousands
|
|
92
|
+
* are. `Intl` knows all of that per locale, so the code goes in and the
|
|
93
|
+
* locale's own rendering comes out.
|
|
94
|
+
*
|
|
95
|
+
* Two ways this refuses to guess. A `price` that is not a finite number is
|
|
96
|
+
* passed through UNCHANGED — a doc type may store "on request", and turning
|
|
97
|
+
* that into `NaN` or into nothing loses what the seller wrote. A `currency`
|
|
98
|
+
* that is not a three-letter code (or one this runtime rejects) falls back to
|
|
99
|
+
* the plain grouped number plus the code, which is still better than the raw
|
|
100
|
+
* pair and never throws inside a render.
|
|
101
|
+
*/
|
|
102
|
+
export function formatCardPrice(
|
|
103
|
+
format: Format,
|
|
104
|
+
price: string | undefined,
|
|
105
|
+
currency: string | undefined
|
|
106
|
+
): string | undefined {
|
|
107
|
+
if (price === undefined) return undefined;
|
|
108
|
+
const amount = Number(price);
|
|
109
|
+
if (!Number.isFinite(amount)) return price;
|
|
110
|
+
if (currency !== undefined && /^[A-Za-z]{3}$/.test(currency)) {
|
|
111
|
+
try {
|
|
112
|
+
const money = format.number(amount, {
|
|
113
|
+
style: "currency",
|
|
114
|
+
currency: currency.toUpperCase(),
|
|
115
|
+
currencyDisplay: "narrowSymbol",
|
|
116
|
+
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2,
|
|
117
|
+
});
|
|
118
|
+
if (money !== null) return money;
|
|
119
|
+
} catch {
|
|
120
|
+
// An unsupported currency code: fall through to the grouped number.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const grouped = format.number(amount);
|
|
124
|
+
if (grouped === null) return price;
|
|
125
|
+
return currency === undefined ? grouped : `${grouped} ${currency}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The DSA Art. 26 marking, as a token role.
|
|
130
|
+
*
|
|
131
|
+
* `warning` is the §68 dictionary's "look at this, it is not an error" role —
|
|
132
|
+
* the same one every skin in the fleet resolves from its own theme JSON. The
|
|
133
|
+
* previous `<Tag color="gold">` was an antd PRESET: it survived a retheme
|
|
134
|
+
* unchanged, so a deployment with its own palette had one component still
|
|
135
|
+
* painted in Ant Design's gold, and the one marking that must be recognisable
|
|
136
|
+
* everywhere was the one thing the design system did not own.
|
|
137
|
+
*/
|
|
138
|
+
const PROMOTED_TAG: CSSProperties = {
|
|
139
|
+
alignSelf: "flex-start",
|
|
140
|
+
background: cssVar("warning-bg"),
|
|
141
|
+
// `warning`, not `warning-on`: the latter is the text colour for the SOLID
|
|
142
|
+
// warning fill (white on light), and over `warning-bg` it is cream on cream.
|
|
143
|
+
color: cssVar("warning"),
|
|
144
|
+
fontWeight: fontWeight.medium,
|
|
145
|
+
border: `1px solid ${cssVar("warning-border")}`,
|
|
146
|
+
borderRadius: radii.sm,
|
|
147
|
+
padding: `0 ${String(spacing[2])}px`,
|
|
148
|
+
fontSize: fontSize.xs.fontSize,
|
|
149
|
+
lineHeight: `${String(fontSize.md.lineHeight)}px`,
|
|
150
|
+
whiteSpace: "nowrap",
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** The photo's box: a 4:3 well the image fills, drawn before it loads. */
|
|
154
|
+
const PHOTO: CSSProperties = {
|
|
155
|
+
width: "100%",
|
|
156
|
+
aspectRatio: "4 / 3",
|
|
157
|
+
overflow: "hidden",
|
|
158
|
+
borderRadius: radii.md,
|
|
159
|
+
background: cssVar("surface-sunken"),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A plain `image_url` as the descriptor `<Image>` consumes.
|
|
164
|
+
*
|
|
165
|
+
* A doc type that stores a bare URL has no variant ladder and no dimensions —
|
|
166
|
+
* `source: "link"` is exactly that case, and `<Image>` degrades to the single
|
|
167
|
+
* URL rather than pretending to shop a tier. A doc type that stores the whole
|
|
168
|
+
* `StapelImage` snapshot (`card.image`) gets the ladder, blur-up and all.
|
|
169
|
+
*/
|
|
170
|
+
function cardImage(card: Readonly<Record<string, unknown>>): StapelImage | undefined {
|
|
171
|
+
const rich = card["image"];
|
|
172
|
+
if (rich !== null && typeof rich === "object" && "url" in rich) {
|
|
173
|
+
return rich as StapelImage;
|
|
174
|
+
}
|
|
175
|
+
const url = text(card["image_url"]);
|
|
176
|
+
if (url === undefined) return undefined;
|
|
177
|
+
return {
|
|
178
|
+
source: "link",
|
|
179
|
+
url,
|
|
180
|
+
mime: null,
|
|
181
|
+
width: null,
|
|
182
|
+
height: null,
|
|
183
|
+
aspect: null,
|
|
184
|
+
square: false,
|
|
185
|
+
preview_b64: null,
|
|
186
|
+
variants: [],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The whole card as ONE tap target.
|
|
192
|
+
*
|
|
193
|
+
* A catalogue row is a link — the person taps the picture, the title or the
|
|
194
|
+
* price and expects the same thing to happen. The card used to have no
|
|
195
|
+
* clickable anything at all: the entire result page of a storefront that had
|
|
196
|
+
* not passed `renderCard` was a wall of text with the tap target missing
|
|
197
|
+
* (class C-NOPRIMARY). `card.url` is the conventional field a doc type stores
|
|
198
|
+
* it in; without one the card stays exactly what it was, because inventing a
|
|
199
|
+
* destination is worse than not having one.
|
|
200
|
+
*/
|
|
201
|
+
const CARD_LINK: CSSProperties = {
|
|
202
|
+
color: "inherit",
|
|
203
|
+
display: "block",
|
|
204
|
+
textDecoration: "none",
|
|
205
|
+
};
|
|
206
|
+
|
|
52
207
|
export function SearchResultCard(props: SearchCardProps): ReactElement {
|
|
53
208
|
const t = useT();
|
|
209
|
+
const format = useFormat();
|
|
54
210
|
const card = props.item.card;
|
|
55
211
|
const title = text(card["title"]) ?? t(SEARCH_I18N_KEYS.resultsUntitled);
|
|
56
|
-
const price = text(card["price"]);
|
|
57
|
-
const
|
|
212
|
+
const price = formatCardPrice(format, text(card["price"]), text(card["currency"]));
|
|
213
|
+
const href = text(card["url"]);
|
|
58
214
|
const location = text(card["location"]);
|
|
59
215
|
const distance =
|
|
60
216
|
props.item.distance_km !== null
|
|
@@ -62,27 +218,40 @@ export function SearchResultCard(props: SearchCardProps): ReactElement {
|
|
|
62
218
|
km: props.item.distance_km.toFixed(1),
|
|
63
219
|
})
|
|
64
220
|
: undefined;
|
|
221
|
+
// Memoised on the two fields it reads: a fresh `meta` identity every render
|
|
222
|
+
// is a load `<Image>` has to decide is or is not the same one.
|
|
223
|
+
const image = useMemo(() => cardImage(card), [card]);
|
|
65
224
|
|
|
66
|
-
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
</
|
|
225
|
+
const body = (
|
|
226
|
+
<Flex vertical gap={spacing[2]}>
|
|
227
|
+
{image !== undefined && (
|
|
228
|
+
<div style={PHOTO} data-testid="search-result-photo">
|
|
229
|
+
{/* The WELL owns the shape (4:3, drawn before the network answers),
|
|
230
|
+
so the image is told to fill it. Without an explicit box the
|
|
231
|
+
image's own container reserves height only from the snapshot's
|
|
232
|
+
`aspect` — which a doc type storing a bare `image_url` does not
|
|
233
|
+
have — and a `cover` image inside a zero-height parent is a
|
|
234
|
+
photo that loaded and was never seen. */}
|
|
235
|
+
<Image
|
|
236
|
+
meta={image}
|
|
237
|
+
alt={t(SEARCH_I18N_KEYS.resultsImageAlt, { title })}
|
|
238
|
+
fit="cover"
|
|
239
|
+
style={{ width: "100%", height: "100%" }}
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
<Flex vertical gap={spacing[1]}>
|
|
244
|
+
<Typography.Text strong>{title}</Typography.Text>
|
|
245
|
+
{/* The price is the strongest line on a catalogue card: it is what
|
|
246
|
+
the eye scans a grid for. It used to share a type step with the
|
|
247
|
+
location and sit under a disclosure three times its size. */}
|
|
83
248
|
{price !== undefined && (
|
|
84
|
-
<Typography.Text
|
|
85
|
-
|
|
249
|
+
<Typography.Text
|
|
250
|
+
strong
|
|
251
|
+
style={{ fontSize: fontSize.lg.fontSize }}
|
|
252
|
+
data-testid="search-result-price"
|
|
253
|
+
>
|
|
254
|
+
{price}
|
|
86
255
|
</Typography.Text>
|
|
87
256
|
)}
|
|
88
257
|
{(location !== undefined || distance !== undefined) && (
|
|
@@ -91,6 +260,44 @@ export function SearchResultCard(props: SearchCardProps): ReactElement {
|
|
|
91
260
|
</Typography.Text>
|
|
92
261
|
)}
|
|
93
262
|
</Flex>
|
|
263
|
+
</Flex>
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<Card
|
|
268
|
+
size="small"
|
|
269
|
+
data-testid="search-result-card"
|
|
270
|
+
data-promoted={props.item.promoted ? "true" : "false"}
|
|
271
|
+
{...(href !== undefined ? { hoverable: true } : {})}
|
|
272
|
+
styles={{ body: { padding: spacing[3] } }}
|
|
273
|
+
>
|
|
274
|
+
<Flex vertical gap={spacing[2]}>
|
|
275
|
+
{href === undefined ? (
|
|
276
|
+
body
|
|
277
|
+
) : (
|
|
278
|
+
<a href={href} style={CARD_LINK} data-testid="search-result-link">
|
|
279
|
+
{body}
|
|
280
|
+
</a>
|
|
281
|
+
)}
|
|
282
|
+
{props.item.promoted && (
|
|
283
|
+
<Flex vertical gap={spacing[1]} data-testid="search-result-promotion">
|
|
284
|
+
<span style={PROMOTED_TAG} data-testid="search-result-promoted">
|
|
285
|
+
{t(SEARCH_I18N_KEYS.resultsPromoted)}
|
|
286
|
+
</span>
|
|
287
|
+
{/* The explanation the marking exists FOR. Visible, on every
|
|
288
|
+
device, because a legal disclosure that needs a mouse is a
|
|
289
|
+
disclosure a phone never receives — as a caption, so it marks
|
|
290
|
+
the card rather than outweighing what is for sale on it. */}
|
|
291
|
+
<Typography.Text
|
|
292
|
+
type="secondary"
|
|
293
|
+
style={{ fontSize: fontSize.xs.fontSize }}
|
|
294
|
+
data-testid="search-result-promoted-hint"
|
|
295
|
+
>
|
|
296
|
+
{t(SEARCH_I18N_KEYS.resultsPromotedHint)}
|
|
297
|
+
</Typography.Text>
|
|
298
|
+
</Flex>
|
|
299
|
+
)}
|
|
300
|
+
</Flex>
|
|
94
301
|
</Card>
|
|
95
302
|
);
|
|
96
303
|
}
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* `<SearchResultsPane>` — the antd result page: the count, the degradation
|
|
3
3
|
* banner, the cards, and the keyset controls.
|
|
4
4
|
*
|
|
5
|
-
* Rendered through
|
|
6
|
-
* "Nothing matches this search" is reachable only from a search that
|
|
7
|
-
* ran; a 5xx renders "we could not run this search" plus a retry. The
|
|
5
|
+
* Rendered through the shared substrate's `LoadList`, whose FOUR arms are the
|
|
6
|
+
* point. "Nothing matches this search" is reachable only from a search that
|
|
7
|
+
* actually ran; a 5xx renders "we could not run this search" plus a retry. The
|
|
8
8
|
* substitution of one for the other is what cost the 2026-08-09 incident, and
|
|
9
9
|
* the spec's §7.4 negative leg exercises this exact pane against a forced 5xx.
|
|
10
10
|
*
|
|
@@ -12,28 +12,39 @@
|
|
|
12
12
|
* is "narrow the search", not "there is nothing here" — it arrives as a 400
|
|
13
13
|
* with an empty body, which is precisely how it would otherwise render as an
|
|
14
14
|
* empty page.
|
|
15
|
+
*
|
|
16
|
+
* ── Two things the pager used to get wrong ────────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* 1. **Its reasons were in `title=`.** A disabled antd Button receives no
|
|
19
|
+
* pointer events, so the browser tooltip never fires — on any device — and
|
|
20
|
+
* a phone has no hover to begin with. Both buttons are `GatedButton` now:
|
|
21
|
+
* the block's reason is ordinary text beside the control and the button's
|
|
22
|
+
* `aria-describedby` points at it.
|
|
23
|
+
* 2. **It rendered when there was nothing to page.** Two dead buttons under an
|
|
24
|
+
* empty state is the fleet's C-DEADPAGER defect; the pager now appears only
|
|
25
|
+
* when the answer actually has another page in some direction.
|
|
15
26
|
*/
|
|
16
27
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
17
|
-
import {
|
|
28
|
+
import { Flex, Typography } from "antd";
|
|
29
|
+
import { errorCode, useT, useTPlural } from "@stapel/core";
|
|
18
30
|
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
} from "@stapel/
|
|
31
|
+
EmptyState,
|
|
32
|
+
ErrorAlert,
|
|
33
|
+
GatedButton,
|
|
34
|
+
LoadList,
|
|
35
|
+
SkinTheme,
|
|
36
|
+
} from "@stapel/tokens-antd/skin";
|
|
37
|
+
import { spacing } from "@stapel/tokens";
|
|
26
38
|
import type { SearchItem } from "../api/types.js";
|
|
27
39
|
import { SearchResults } from "../headless/SearchResults.js";
|
|
28
40
|
import type { SearchResultsBag } from "../headless/SearchResults.js";
|
|
41
|
+
import { useScorerNames } from "../headless/useScorerNames.js";
|
|
29
42
|
import { SEARCH_WINDOW_EXCEEDED } from "../i18n/errorsMap.js";
|
|
30
43
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
31
44
|
import { DegradationNotice } from "./DegradationNotice.js";
|
|
32
45
|
import type { DegradationNoticeVariant } from "./DegradationNotice.js";
|
|
33
|
-
import { ErrorAlert } from "./ErrorAlert.js";
|
|
34
46
|
import { SearchResultCard } from "./SearchResultCard.js";
|
|
35
47
|
import type { SearchCardRenderer } from "./SearchResultCard.js";
|
|
36
|
-
import { SearchSkinTheme } from "./theme.js";
|
|
37
48
|
import type { ThemeModeProp } from "./types.js";
|
|
38
49
|
|
|
39
50
|
/**
|
|
@@ -47,6 +58,17 @@ export type SearchResultsRenderer = (
|
|
|
47
58
|
items: readonly SearchItem[]
|
|
48
59
|
) => ReactNode;
|
|
49
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The widest a column of results is allowed to get.
|
|
63
|
+
*
|
|
64
|
+
* Off the spacing scale and named for it: it is a MEASURE, the reading-width
|
|
65
|
+
* decision every long page has to take. Without it the pane spread the full
|
|
66
|
+
* width of whatever it was dropped into — the visual pass measured a 2560px
|
|
67
|
+
* pane with four cards floating at the top of it and a status row 1350px from
|
|
68
|
+
* the buttons that act on it (class C-NOMAXW).
|
|
69
|
+
*/
|
|
70
|
+
export const RESULTS_MAX_WIDTH = 1120;
|
|
71
|
+
|
|
50
72
|
/**
|
|
51
73
|
* The results grid. `auto-fill` + `minmax(280px, 1fr)`: as many columns as fit,
|
|
52
74
|
* each at least a readable card and never wider than its share — a catalogue
|
|
@@ -58,7 +80,7 @@ export type SearchResultsRenderer = (
|
|
|
58
80
|
const RESULTS_GRID: CSSProperties = {
|
|
59
81
|
display: "grid",
|
|
60
82
|
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
|
61
|
-
gap:
|
|
83
|
+
gap: spacing[3],
|
|
62
84
|
alignItems: "stretch",
|
|
63
85
|
};
|
|
64
86
|
|
|
@@ -81,6 +103,21 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
|
|
|
81
103
|
* toolbar in here instead of printing a second caption above the pane.
|
|
82
104
|
*/
|
|
83
105
|
readonly toolbar?: ReactNode;
|
|
106
|
+
/**
|
|
107
|
+
* What this list is CALLED on this surface. Default: "Results".
|
|
108
|
+
*
|
|
109
|
+
* The pane owning the heading row is what stops a screen printing two
|
|
110
|
+
* captions — but "Results" is only the right word when the person performed
|
|
111
|
+
* a search. A landing's newest-first strip is "Fresh listings"; a seller's
|
|
112
|
+
* page is "This seller's listings"; a category page's is the category. Every
|
|
113
|
+
* one of those surfaces had written its own `<Title>` above the pane and got
|
|
114
|
+
* the pane's "Results" underneath it a moment later — two headings, one
|
|
115
|
+
* list, on the three busiest screens of the storefront.
|
|
116
|
+
*
|
|
117
|
+
* So the name comes IN, to the row that already exists, instead of being
|
|
118
|
+
* printed a second time above it. Still exactly one heading either way.
|
|
119
|
+
*/
|
|
120
|
+
readonly heading?: ReactNode;
|
|
84
121
|
/** Rendered under the pager — where the container puts the ranking link. */
|
|
85
122
|
readonly footer?: ReactNode;
|
|
86
123
|
/**
|
|
@@ -95,6 +132,9 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
|
|
|
95
132
|
*/
|
|
96
133
|
readonly degradationNotice?: DegradationNoticeVariant;
|
|
97
134
|
readonly enabled?: boolean;
|
|
135
|
+
/** Widest the column of results may grow (default {@link RESULTS_MAX_WIDTH});
|
|
136
|
+
* `null` lets the container decide. */
|
|
137
|
+
readonly maxWidth?: number | null;
|
|
98
138
|
}
|
|
99
139
|
|
|
100
140
|
function Count(props: { bag: SearchResultsBag }): ReactElement | null {
|
|
@@ -121,50 +161,69 @@ function Count(props: { bag: SearchResultsBag }): ReactElement | null {
|
|
|
121
161
|
);
|
|
122
162
|
}
|
|
123
163
|
|
|
124
|
-
|
|
164
|
+
/**
|
|
165
|
+
* The keyset controls — rendered only when there IS another page.
|
|
166
|
+
*
|
|
167
|
+
* Keyset paging has no page numbers to jump to: there is a next, a previous,
|
|
168
|
+
* and a server-side depth cap. When neither direction exists the pager is not
|
|
169
|
+
* "disabled", it is absent — a control that can never do anything is not a
|
|
170
|
+
* control that needs explaining.
|
|
171
|
+
*/
|
|
172
|
+
function Pager(props: { bag: SearchResultsBag }): ReactElement | null {
|
|
125
173
|
const t = useT();
|
|
126
|
-
const { prev, next, goPrev, goNext } = props.bag;
|
|
174
|
+
const { page, prev, next, goPrev, goNext } = props.bag;
|
|
175
|
+
if (page === null || (!page.hasNext && !page.hasPrev)) return null;
|
|
127
176
|
return (
|
|
128
|
-
<Flex gap={
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
title={prev.available ? undefined : t(prev.block.code, prev.block.params)}
|
|
177
|
+
<Flex gap={spacing[2]} justify="center" wrap data-testid="search-pager">
|
|
178
|
+
<GatedButton
|
|
179
|
+
gate={prev}
|
|
132
180
|
onClick={goPrev}
|
|
181
|
+
testId="search-prev"
|
|
133
182
|
data-analytics="none"
|
|
134
183
|
data-analytics-reason="keyset paging is a read, not a flow step"
|
|
135
|
-
data-testid="search-prev"
|
|
136
184
|
>
|
|
137
185
|
{t(SEARCH_I18N_KEYS.resultsPrev)}
|
|
138
|
-
</
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
title={next.available ? undefined : t(next.block.code, next.block.params)}
|
|
186
|
+
</GatedButton>
|
|
187
|
+
<GatedButton
|
|
188
|
+
gate={next}
|
|
142
189
|
onClick={goNext}
|
|
190
|
+
testId="search-next"
|
|
143
191
|
data-analytics="none"
|
|
144
192
|
data-analytics-reason="keyset paging is a read, not a flow step"
|
|
145
|
-
data-testid="search-next"
|
|
146
193
|
>
|
|
147
194
|
{t(SEARCH_I18N_KEYS.resultsNext)}
|
|
148
|
-
</
|
|
195
|
+
</GatedButton>
|
|
149
196
|
</Flex>
|
|
150
197
|
);
|
|
151
198
|
}
|
|
152
199
|
|
|
153
200
|
export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
154
201
|
const t = useT();
|
|
155
|
-
const describe = useDescribeFlowError();
|
|
156
202
|
const { renderCard, renderResults } = props;
|
|
203
|
+
const scorerName = useScorerNames();
|
|
204
|
+
const maxWidth = props.maxWidth === undefined ? RESULTS_MAX_WIDTH : props.maxWidth;
|
|
157
205
|
|
|
158
206
|
return (
|
|
159
|
-
<
|
|
207
|
+
<SkinTheme
|
|
208
|
+
surface="base"
|
|
209
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
210
|
+
style={{
|
|
211
|
+
width: "100%",
|
|
212
|
+
...(maxWidth !== null ? { maxWidth } : {}),
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
160
215
|
<SearchResults {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}>
|
|
161
216
|
{(bag) => (
|
|
162
|
-
<Flex vertical gap={
|
|
163
|
-
<Flex justify="space-between" align="center" wrap gap={
|
|
164
|
-
<Typography.Title
|
|
165
|
-
{
|
|
217
|
+
<Flex vertical gap={spacing[4]}>
|
|
218
|
+
<Flex justify="space-between" align="center" wrap gap={spacing[2]}>
|
|
219
|
+
<Typography.Title
|
|
220
|
+
level={4}
|
|
221
|
+
style={{ margin: 0 }}
|
|
222
|
+
data-testid="search-results-heading"
|
|
223
|
+
>
|
|
224
|
+
{props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
|
|
166
225
|
</Typography.Title>
|
|
167
|
-
<Flex align="center" wrap gap={
|
|
226
|
+
<Flex align="center" wrap gap={spacing[3]}>
|
|
168
227
|
<Count bag={bag} />
|
|
169
228
|
{props.toolbar}
|
|
170
229
|
</Flex>
|
|
@@ -173,49 +232,40 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
173
232
|
<DegradationNotice
|
|
174
233
|
degradations={bag.degradations}
|
|
175
234
|
variant={props.degradationNotice ?? "banner"}
|
|
235
|
+
scorerName={scorerName}
|
|
176
236
|
/>
|
|
177
237
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
238
|
+
<LoadList
|
|
239
|
+
state={bag.state}
|
|
240
|
+
testId="search"
|
|
241
|
+
skeletonRows={6}
|
|
242
|
+
onRetry={bag.refetch}
|
|
243
|
+
empty={
|
|
244
|
+
<EmptyState
|
|
245
|
+
title={t(SEARCH_I18N_KEYS.resultsEmpty)}
|
|
246
|
+
testId="search-empty"
|
|
247
|
+
/>
|
|
248
|
+
}
|
|
249
|
+
failed={(error) => {
|
|
185
250
|
// The window refusal is a DIFFERENT sentence from a failed
|
|
186
251
|
// search, and both are different from "nothing found".
|
|
187
252
|
const isWindow = errorCode(error) === SEARCH_WINDOW_EXCEEDED;
|
|
188
253
|
return (
|
|
189
254
|
<ErrorAlert
|
|
190
255
|
testId={isWindow ? "search-window-exceeded" : "search-failed"}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
action={
|
|
200
|
-
<Button
|
|
201
|
-
size="small"
|
|
202
|
-
onClick={bag.refetch}
|
|
203
|
-
data-analytics="none"
|
|
204
|
-
data-analytics-reason="retry of a failed read; no flow to step"
|
|
205
|
-
>
|
|
206
|
-
{t(SEARCH_I18N_KEYS.resultsRetry)}
|
|
207
|
-
</Button>
|
|
208
|
-
}
|
|
256
|
+
thrown={error}
|
|
257
|
+
message={t(
|
|
258
|
+
isWindow
|
|
259
|
+
? SEARCH_I18N_KEYS.resultsWindowExceeded
|
|
260
|
+
: SEARCH_I18N_KEYS.resultsLoadFailed
|
|
261
|
+
)}
|
|
262
|
+
retryLabel={t(SEARCH_I18N_KEYS.resultsRetry)}
|
|
263
|
+
onRetry={bag.refetch}
|
|
209
264
|
/>
|
|
210
265
|
);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
data-testid="search-empty"
|
|
215
|
-
description={t(SEARCH_I18N_KEYS.resultsEmpty)}
|
|
216
|
-
/>
|
|
217
|
-
),
|
|
218
|
-
ready: (items) => (
|
|
266
|
+
}}
|
|
267
|
+
>
|
|
268
|
+
{(items) => (
|
|
219
269
|
<div data-testid="search-results">
|
|
220
270
|
{renderResults !== undefined ? (
|
|
221
271
|
renderResults(items)
|
|
@@ -233,14 +283,14 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
233
283
|
</div>
|
|
234
284
|
)}
|
|
235
285
|
</div>
|
|
236
|
-
)
|
|
237
|
-
|
|
286
|
+
)}
|
|
287
|
+
</LoadList>
|
|
238
288
|
|
|
239
289
|
<Pager bag={bag} />
|
|
240
290
|
{props.footer}
|
|
241
291
|
</Flex>
|
|
242
292
|
)}
|
|
243
293
|
</SearchResults>
|
|
244
|
-
</
|
|
294
|
+
</SkinTheme>
|
|
245
295
|
);
|
|
246
296
|
}
|