@stapel/search-react 0.4.0 → 0.8.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.
Files changed (154) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +1 -1
  3. package/dist/default/DegradationNotice.d.ts +7 -0
  4. package/dist/default/DegradationNotice.d.ts.map +1 -1
  5. package/dist/default/DegradationNotice.js +17 -6
  6. package/dist/default/DegradationNotice.js.map +1 -1
  7. package/dist/default/FacetGroupControl.d.ts +40 -0
  8. package/dist/default/FacetGroupControl.d.ts.map +1 -0
  9. package/dist/default/FacetGroupControl.js +176 -0
  10. package/dist/default/FacetGroupControl.js.map +1 -0
  11. package/dist/default/FacetPanelPane.d.ts +54 -3
  12. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  13. package/dist/default/FacetPanelPane.js +116 -22
  14. package/dist/default/FacetPanelPane.js.map +1 -1
  15. package/dist/default/FilterChips.d.ts +28 -0
  16. package/dist/default/FilterChips.d.ts.map +1 -0
  17. package/dist/default/FilterChips.js +204 -0
  18. package/dist/default/FilterChips.js.map +1 -0
  19. package/dist/default/LanguageSelect.d.ts +9 -0
  20. package/dist/default/LanguageSelect.d.ts.map +1 -0
  21. package/dist/default/LanguageSelect.js +72 -0
  22. package/dist/default/LanguageSelect.js.map +1 -0
  23. package/dist/default/PageSizeSelect.d.ts +29 -0
  24. package/dist/default/PageSizeSelect.d.ts.map +1 -0
  25. package/dist/default/PageSizeSelect.js +25 -0
  26. package/dist/default/PageSizeSelect.js.map +1 -0
  27. package/dist/default/RangeFilterRow.d.ts +12 -0
  28. package/dist/default/RangeFilterRow.d.ts.map +1 -0
  29. package/dist/default/RangeFilterRow.js +75 -0
  30. package/dist/default/RangeFilterRow.js.map +1 -0
  31. package/dist/default/RankingDisclosurePane.d.ts +5 -0
  32. package/dist/default/RankingDisclosurePane.d.ts.map +1 -1
  33. package/dist/default/RankingDisclosurePane.js +15 -15
  34. package/dist/default/RankingDisclosurePane.js.map +1 -1
  35. package/dist/default/SearchBox.d.ts +13 -0
  36. package/dist/default/SearchBox.d.ts.map +1 -0
  37. package/dist/default/SearchBox.js +67 -0
  38. package/dist/default/SearchBox.js.map +1 -0
  39. package/dist/default/SearchPage.d.ts +94 -16
  40. package/dist/default/SearchPage.d.ts.map +1 -1
  41. package/dist/default/SearchPage.js +191 -8
  42. package/dist/default/SearchPage.js.map +1 -1
  43. package/dist/default/SearchResultCard.d.ts +19 -18
  44. package/dist/default/SearchResultCard.d.ts.map +1 -1
  45. package/dist/default/SearchResultCard.js +178 -5
  46. package/dist/default/SearchResultCard.js.map +1 -1
  47. package/dist/default/SearchResultsPane.d.ts +61 -3
  48. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  49. package/dist/default/SearchResultsPane.js +51 -21
  50. package/dist/default/SearchResultsPane.js.map +1 -1
  51. package/dist/default/SortSelect.d.ts +10 -2
  52. package/dist/default/SortSelect.d.ts.map +1 -1
  53. package/dist/default/SortSelect.js +27 -24
  54. package/dist/default/SortSelect.js.map +1 -1
  55. package/dist/default/UrlIssueNotice.d.ts.map +1 -1
  56. package/dist/default/UrlIssueNotice.js +19 -1
  57. package/dist/default/UrlIssueNotice.js.map +1 -1
  58. package/dist/default/ViewSwitch.d.ts +66 -0
  59. package/dist/default/ViewSwitch.d.ts.map +1 -0
  60. package/dist/default/ViewSwitch.js +49 -0
  61. package/dist/default/ViewSwitch.js.map +1 -0
  62. package/dist/default/index.d.ts +38 -15
  63. package/dist/default/index.d.ts.map +1 -1
  64. package/dist/default/index.js +29 -12
  65. package/dist/default/index.js.map +1 -1
  66. package/dist/default/sortLabels.d.ts +5 -0
  67. package/dist/default/sortLabels.d.ts.map +1 -0
  68. package/dist/default/sortLabels.js +32 -0
  69. package/dist/default/sortLabels.js.map +1 -0
  70. package/dist/headless/FacetPanel.d.ts +17 -0
  71. package/dist/headless/FacetPanel.d.ts.map +1 -1
  72. package/dist/headless/FacetPanel.js +23 -2
  73. package/dist/headless/FacetPanel.js.map +1 -1
  74. package/dist/headless/useAppliedCount.d.ts +8 -0
  75. package/dist/headless/useAppliedCount.d.ts.map +1 -0
  76. package/dist/headless/useAppliedCount.js +30 -0
  77. package/dist/headless/useAppliedCount.js.map +1 -0
  78. package/dist/headless/useScorerNames.d.ts +4 -0
  79. package/dist/headless/useScorerNames.d.ts.map +1 -0
  80. package/dist/headless/useScorerNames.js +34 -0
  81. package/dist/headless/useScorerNames.js.map +1 -0
  82. package/dist/headless/useSearchBox.d.ts +44 -0
  83. package/dist/headless/useSearchBox.d.ts.map +1 -0
  84. package/dist/headless/useSearchBox.js +116 -0
  85. package/dist/headless/useSearchBox.js.map +1 -0
  86. package/dist/i18n/es.d.ts.map +1 -1
  87. package/dist/i18n/es.js +38 -4
  88. package/dist/i18n/es.js.map +1 -1
  89. package/dist/i18n/keys.d.ts +55 -0
  90. package/dist/i18n/keys.d.ts.map +1 -1
  91. package/dist/i18n/keys.js +101 -4
  92. package/dist/i18n/keys.js.map +1 -1
  93. package/dist/i18n/ru.d.ts.map +1 -1
  94. package/dist/i18n/ru.js +42 -4
  95. package/dist/i18n/ru.js.map +1 -1
  96. package/dist/index.d.ts +7 -2
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +5 -2
  99. package/dist/index.js.map +1 -1
  100. package/dist/model/queries.d.ts +26 -2
  101. package/dist/model/queries.d.ts.map +1 -1
  102. package/dist/model/queries.js +38 -1
  103. package/dist/model/queries.js.map +1 -1
  104. package/dist/state/limits.d.ts +44 -0
  105. package/dist/state/limits.d.ts.map +1 -0
  106. package/dist/state/limits.js +44 -0
  107. package/dist/state/limits.js.map +1 -0
  108. package/dist/state/ranges.d.ts +52 -0
  109. package/dist/state/ranges.d.ts.map +1 -0
  110. package/dist/state/ranges.js +108 -0
  111. package/dist/state/ranges.js.map +1 -0
  112. package/llms.txt +16 -4
  113. package/manifest.json +268 -22
  114. package/nav-manifest.json +1 -1
  115. package/package.json +15 -10
  116. package/src/analytics/generated/events.json +1 -1
  117. package/src/default/DegradationNotice.tsx +34 -12
  118. package/src/default/FacetGroupControl.tsx +312 -0
  119. package/src/default/FacetPanelPane.tsx +320 -87
  120. package/src/default/FilterChips.tsx +415 -0
  121. package/src/default/LanguageSelect.tsx +93 -0
  122. package/src/default/PageSizeSelect.tsx +80 -0
  123. package/src/default/RangeFilterRow.tsx +148 -0
  124. package/src/default/RankingDisclosurePane.tsx +67 -35
  125. package/src/default/SearchBox.tsx +130 -0
  126. package/src/default/SearchPage.tsx +454 -44
  127. package/src/default/SearchResultCard.tsx +231 -24
  128. package/src/default/SearchResultsPane.tsx +158 -71
  129. package/src/default/SortSelect.tsx +50 -37
  130. package/src/default/UrlIssueNotice.tsx +21 -3
  131. package/src/default/ViewSwitch.tsx +147 -0
  132. package/src/default/index.ts +55 -15
  133. package/src/default/sortLabels.ts +34 -0
  134. package/src/headless/FacetPanel.tsx +30 -2
  135. package/src/headless/useAppliedCount.ts +41 -0
  136. package/src/headless/useScorerNames.ts +36 -0
  137. package/src/headless/useSearchBox.ts +189 -0
  138. package/src/i18n/es.ts +46 -4
  139. package/src/i18n/keys.ts +114 -4
  140. package/src/i18n/ru.ts +50 -4
  141. package/src/index.ts +22 -2
  142. package/src/model/queries.ts +51 -1
  143. package/src/state/limits.ts +50 -0
  144. package/src/state/ranges.ts +144 -0
  145. package/dist/default/ErrorAlert.d.ts +0 -9
  146. package/dist/default/ErrorAlert.d.ts.map +0 -1
  147. package/dist/default/ErrorAlert.js +0 -27
  148. package/dist/default/ErrorAlert.js.map +0 -1
  149. package/dist/default/theme.d.ts +0 -24
  150. package/dist/default/theme.d.ts.map +0 -1
  151. package/dist/default/theme.js +0 -39
  152. package/dist/default/theme.js.map +0 -1
  153. package/src/default/ErrorAlert.tsx +0 -46
  154. 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 type { ReactElement, ReactNode } from "react";
20
- import { Card, Flex, Tag, Tooltip, Typography } from "antd";
21
- import { useT } from "@stapel/core";
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 currency = text(card["currency"]);
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
- return (
67
- <Card
68
- size="small"
69
- data-testid="search-result-card"
70
- data-promoted={props.item.promoted ? "true" : "false"}
71
- >
72
- <Flex vertical gap={4}>
73
- <Flex justify="space-between" align="start" gap={8}>
74
- <Typography.Text strong>{title}</Typography.Text>
75
- {props.item.promoted && (
76
- <Tooltip title={t(SEARCH_I18N_KEYS.resultsPromotedHint)}>
77
- <Tag color="gold" data-testid="search-result-promoted">
78
- {t(SEARCH_I18N_KEYS.resultsPromoted)}
79
- </Tag>
80
- </Tooltip>
81
- )}
82
- </Flex>
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
- {currency !== undefined ? `${price} ${currency}` : price}
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 core's `matchList`, whose FOUR required arms are the point.
6
- * "Nothing matches this search" is reachable only from a search that actually
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,40 @@
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 { Button, Empty, Flex, Spin, Typography } from "antd";
28
+ import { Flex, Typography } from "antd";
29
+ import { errorCode, useT, useTPlural } from "@stapel/core";
18
30
  import {
19
- errorCode,
20
- matchList,
21
- toFlowError,
22
- useDescribeFlowError,
23
- useT,
24
- useTPlural,
25
- } from "@stapel/core";
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";
48
+ import type { SearchResultsLayout } from "./ViewSwitch.js";
37
49
  import type { ThemeModeProp } from "./types.js";
38
50
 
39
51
  /**
@@ -47,6 +59,17 @@ export type SearchResultsRenderer = (
47
59
  items: readonly SearchItem[]
48
60
  ) => ReactNode;
49
61
 
62
+ /**
63
+ * The widest a column of results is allowed to get.
64
+ *
65
+ * Off the spacing scale and named for it: it is a MEASURE, the reading-width
66
+ * decision every long page has to take. Without it the pane spread the full
67
+ * width of whatever it was dropped into — the visual pass measured a 2560px
68
+ * pane with four cards floating at the top of it and a status row 1350px from
69
+ * the buttons that act on it (class C-NOMAXW).
70
+ */
71
+ export const RESULTS_MAX_WIDTH = 1120;
72
+
50
73
  /**
51
74
  * The results grid. `auto-fill` + `minmax(280px, 1fr)`: as many columns as fit,
52
75
  * each at least a readable card and never wider than its share — a catalogue
@@ -58,10 +81,24 @@ export type SearchResultsRenderer = (
58
81
  const RESULTS_GRID: CSSProperties = {
59
82
  display: "grid",
60
83
  gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
61
- gap: 12,
84
+ gap: spacing[3],
62
85
  alignItems: "stretch",
63
86
  };
64
87
 
88
+ /**
89
+ * The LIST arrangement: one wide row per result.
90
+ *
91
+ * The same grid with one track, deliberately — not a second layout mechanism.
92
+ * A list is a grid whose column count is one, and expressing it that way is
93
+ * what keeps the card slot, the gap and the stretch identical between the two
94
+ * arrangements, so switching views cannot change anything except the number of
95
+ * columns.
96
+ */
97
+ const RESULTS_LIST: CSSProperties = {
98
+ ...RESULTS_GRID,
99
+ gridTemplateColumns: "1fr",
100
+ };
101
+
65
102
  export interface SearchResultsPaneProps extends ThemeModeProp {
66
103
  /**
67
104
  * The card slot (spec §6.2 item 1). A storefront passes
@@ -81,6 +118,21 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
81
118
  * toolbar in here instead of printing a second caption above the pane.
82
119
  */
83
120
  readonly toolbar?: ReactNode;
121
+ /**
122
+ * What this list is CALLED on this surface. Default: "Results".
123
+ *
124
+ * The pane owning the heading row is what stops a screen printing two
125
+ * captions — but "Results" is only the right word when the person performed
126
+ * a search. A landing's newest-first strip is "Fresh listings"; a seller's
127
+ * page is "This seller's listings"; a category page's is the category. Every
128
+ * one of those surfaces had written its own `<Title>` above the pane and got
129
+ * the pane's "Results" underneath it a moment later — two headings, one
130
+ * list, on the three busiest screens of the storefront.
131
+ *
132
+ * So the name comes IN, to the row that already exists, instead of being
133
+ * printed a second time above it. Still exactly one heading either way.
134
+ */
135
+ readonly heading?: ReactNode;
84
136
  /** Rendered under the pager — where the container puts the ranking link. */
85
137
  readonly footer?: ReactNode;
86
138
  /**
@@ -95,6 +147,27 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
95
147
  */
96
148
  readonly degradationNotice?: DegradationNoticeVariant;
97
149
  readonly enabled?: boolean;
150
+ /** Widest the column of results may grow (default {@link RESULTS_MAX_WIDTH});
151
+ * `null` lets the container decide. */
152
+ readonly maxWidth?: number | null;
153
+ /**
154
+ * How the loaded rows are arranged: as many card columns as fit
155
+ * (`"grid"`, the default) or one wide row each (`"list"`). The view SWITCH
156
+ * that flips this lives in `<ViewSwitch>`; the pane only draws.
157
+ *
158
+ * Ignored when `renderResults` replaces the arrangement entirely.
159
+ */
160
+ readonly layout?: SearchResultsLayout;
161
+ /**
162
+ * Heading level for the results caption. Default `4`, which is what every
163
+ * surface that EMBEDS this pane under its own title needs.
164
+ *
165
+ * `<SearchPage>` passes `1`: on a results SCREEN the list's name is the
166
+ * page's heading, and a page whose only heading is an `<h4>` has a document
167
+ * outline that starts three levels down — the thing a screen reader's
168
+ * heading list is for.
169
+ */
170
+ readonly headingLevel?: 1 | 2 | 3 | 4 | 5;
98
171
  }
99
172
 
100
173
  function Count(props: { bag: SearchResultsBag }): ReactElement | null {
@@ -121,50 +194,69 @@ function Count(props: { bag: SearchResultsBag }): ReactElement | null {
121
194
  );
122
195
  }
123
196
 
124
- function Pager(props: { bag: SearchResultsBag }): ReactElement {
197
+ /**
198
+ * The keyset controls — rendered only when there IS another page.
199
+ *
200
+ * Keyset paging has no page numbers to jump to: there is a next, a previous,
201
+ * and a server-side depth cap. When neither direction exists the pager is not
202
+ * "disabled", it is absent — a control that can never do anything is not a
203
+ * control that needs explaining.
204
+ */
205
+ function Pager(props: { bag: SearchResultsBag }): ReactElement | null {
125
206
  const t = useT();
126
- const { prev, next, goPrev, goNext } = props.bag;
207
+ const { page, prev, next, goPrev, goNext } = props.bag;
208
+ if (page === null || (!page.hasNext && !page.hasPrev)) return null;
127
209
  return (
128
- <Flex gap={8} justify="center" data-testid="search-pager">
129
- <Button
130
- disabled={!prev.available}
131
- title={prev.available ? undefined : t(prev.block.code, prev.block.params)}
210
+ <Flex gap={spacing[2]} justify="center" wrap data-testid="search-pager">
211
+ <GatedButton
212
+ gate={prev}
132
213
  onClick={goPrev}
214
+ testId="search-prev"
133
215
  data-analytics="none"
134
216
  data-analytics-reason="keyset paging is a read, not a flow step"
135
- data-testid="search-prev"
136
217
  >
137
218
  {t(SEARCH_I18N_KEYS.resultsPrev)}
138
- </Button>
139
- <Button
140
- disabled={!next.available}
141
- title={next.available ? undefined : t(next.block.code, next.block.params)}
219
+ </GatedButton>
220
+ <GatedButton
221
+ gate={next}
142
222
  onClick={goNext}
223
+ testId="search-next"
143
224
  data-analytics="none"
144
225
  data-analytics-reason="keyset paging is a read, not a flow step"
145
- data-testid="search-next"
146
226
  >
147
227
  {t(SEARCH_I18N_KEYS.resultsNext)}
148
- </Button>
228
+ </GatedButton>
149
229
  </Flex>
150
230
  );
151
231
  }
152
232
 
153
233
  export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
154
234
  const t = useT();
155
- const describe = useDescribeFlowError();
156
235
  const { renderCard, renderResults } = props;
236
+ const scorerName = useScorerNames();
237
+ const maxWidth = props.maxWidth === undefined ? RESULTS_MAX_WIDTH : props.maxWidth;
157
238
 
158
239
  return (
159
- <SearchSkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
240
+ <SkinTheme
241
+ surface="base"
242
+ {...(props.mode !== undefined ? { mode: props.mode } : {})}
243
+ style={{
244
+ width: "100%",
245
+ ...(maxWidth !== null ? { maxWidth } : {}),
246
+ }}
247
+ >
160
248
  <SearchResults {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}>
161
249
  {(bag) => (
162
- <Flex vertical gap={16}>
163
- <Flex justify="space-between" align="center" wrap gap={8}>
164
- <Typography.Title level={4} style={{ margin: 0 }}>
165
- {t(SEARCH_I18N_KEYS.resultsTitle)}
250
+ <Flex vertical gap={spacing[4]}>
251
+ <Flex justify="space-between" align="center" wrap gap={spacing[2]}>
252
+ <Typography.Title
253
+ level={props.headingLevel ?? 4}
254
+ style={{ margin: 0 }}
255
+ data-testid="search-results-heading"
256
+ >
257
+ {props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
166
258
  </Typography.Title>
167
- <Flex align="center" wrap gap={12}>
259
+ <Flex align="center" wrap gap={spacing[3]}>
168
260
  <Count bag={bag} />
169
261
  {props.toolbar}
170
262
  </Flex>
@@ -173,54 +265,49 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
173
265
  <DegradationNotice
174
266
  degradations={bag.degradations}
175
267
  variant={props.degradationNotice ?? "banner"}
268
+ scorerName={scorerName}
176
269
  />
177
270
 
178
- {matchList(bag.state, {
179
- loading: () => (
180
- <Flex justify="center" style={{ padding: 24 }}>
181
- <Spin data-testid="search-loading" />
182
- </Flex>
183
- ),
184
- failed: (error) => {
271
+ <LoadList
272
+ state={bag.state}
273
+ testId="search"
274
+ skeletonRows={6}
275
+ onRetry={bag.refetch}
276
+ empty={
277
+ <EmptyState
278
+ title={t(SEARCH_I18N_KEYS.resultsEmpty)}
279
+ testId="search-empty"
280
+ />
281
+ }
282
+ failed={(error) => {
185
283
  // The window refusal is a DIFFERENT sentence from a failed
186
284
  // search, and both are different from "nothing found".
187
285
  const isWindow = errorCode(error) === SEARCH_WINDOW_EXCEEDED;
188
286
  return (
189
287
  <ErrorAlert
190
288
  testId={isWindow ? "search-window-exceeded" : "search-failed"}
191
- error={{
192
- ...describe(toFlowError(error)),
193
- message: t(
194
- isWindow
195
- ? SEARCH_I18N_KEYS.resultsWindowExceeded
196
- : SEARCH_I18N_KEYS.resultsLoadFailed
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
- }
289
+ thrown={error}
290
+ message={t(
291
+ isWindow
292
+ ? SEARCH_I18N_KEYS.resultsWindowExceeded
293
+ : SEARCH_I18N_KEYS.resultsLoadFailed
294
+ )}
295
+ retryLabel={t(SEARCH_I18N_KEYS.resultsRetry)}
296
+ onRetry={bag.refetch}
209
297
  />
210
298
  );
211
- },
212
- empty: () => (
213
- <Empty
214
- data-testid="search-empty"
215
- description={t(SEARCH_I18N_KEYS.resultsEmpty)}
216
- />
217
- ),
218
- ready: (items) => (
299
+ }}
300
+ >
301
+ {(items) => (
219
302
  <div data-testid="search-results">
220
303
  {renderResults !== undefined ? (
221
304
  renderResults(items)
222
305
  ) : (
223
- <div style={RESULTS_GRID} data-testid="search-results-grid">
306
+ <div
307
+ style={props.layout === "list" ? RESULTS_LIST : RESULTS_GRID}
308
+ data-testid="search-results-grid"
309
+ data-layout={props.layout ?? "grid"}
310
+ >
224
311
  {items.map((item: SearchItem) => (
225
312
  <div key={item.key}>
226
313
  {renderCard !== undefined ? (
@@ -233,14 +320,14 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
233
320
  </div>
234
321
  )}
235
322
  </div>
236
- ),
237
- })}
323
+ )}
324
+ </LoadList>
238
325
 
239
326
  <Pager bag={bag} />
240
327
  {props.footer}
241
328
  </Flex>
242
329
  )}
243
330
  </SearchResults>
244
- </SearchSkinTheme>
331
+ </SkinTheme>
245
332
  );
246
333
  }