@stapel/listings-react 0.13.0 → 0.14.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.
@@ -34,6 +34,17 @@
34
34
  * covers the four things that identify the listing — price, title, specs,
35
35
  * badges — so the card opens from everything a person reads.
36
36
  *
37
+ * ── One column on a phone, one ROW on a desktop ───────────────────────────
38
+ *
39
+ * The card is still one per row; what changes with width is where the photo
40
+ * sits. Full-bleed above the text is right at 390px and wrong at 974px, where
41
+ * it makes the picture the whole screen and the offer a footnote — measured on
42
+ * the live desktop SERP as one card per scroll. Above
43
+ * {@link LISTING_CARD_ROW_MIN} the strip moves BESIDE the text at a fixed
44
+ * width, from the card's own `@container` query rather than a viewport
45
+ * breakpoint: this card knows how wide it is and has no business knowing how
46
+ * wide the window is.
47
+ *
37
48
  * ── The two slots, and what they are honestly for ─────────────────────────
38
49
  *
39
50
  * `sellerSlot` is the seller's name and rating. It is a SLOT because a rating
@@ -64,7 +75,7 @@
64
75
  */
65
76
  import type { CSSProperties, ReactElement, ReactNode } from "react";
66
77
  import { Card, Flex, Typography, theme as antdTheme } from "antd";
67
- import { SkinCarousel, SkinTheme } from "@stapel/tokens-antd/skin";
78
+ import { SkinTheme } from "@stapel/tokens-antd/skin";
68
79
  import { useT } from "@stapel/core";
69
80
  import { fontSize, spacing } from "@stapel/tokens";
70
81
  import { FeatureBadges } from "@stapel/attributes-react/default";
@@ -80,12 +91,16 @@ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
80
91
  import { PriceTrendIcon } from "./icons.js";
81
92
  import { FavoriteHeart } from "./favorite.js";
82
93
  import {
94
+ CARD_FRAME_CLASS,
95
+ CARD_MAIN_CLASS,
96
+ CARD_MEDIA_CLASS,
97
+ CARD_QUERY_CLASS,
83
98
  CARD_TARGET_STYLE_HREF,
84
99
  CardTarget,
85
100
  cardTargetCss,
86
101
  } from "./ListingCard.js";
87
102
  import type { ListingCardOpenProps } from "./ListingCard.js";
88
- import { LISTING_PHOTO_ASPECT, ListingPhoto } from "./ListingPhoto.js";
103
+ import { ListingPhotoStrip } from "./ListingPhoto.js";
89
104
  import { ListingPrice } from "./ListingPrice.js";
90
105
  import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
91
106
 
@@ -177,12 +192,6 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
177
192
  const currency =
178
193
  listing.currency !== undefined ? { currency: listing.currency } : {};
179
194
 
180
- // A one-photo strip gets neither a peek nor dots: the sliver of a next slide
181
- // is an affordance for something that is there, and on a single photo it is
182
- // just a strip of dead space at the trailing edge (`SkinCarousel` says the
183
- // same thing from its own side).
184
- const many = photos.length > 1;
185
-
186
195
  const rail =
187
196
  props.actionsRail !== undefined || props.showFavorite !== false ? (
188
197
  <Flex
@@ -221,144 +230,130 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
221
230
  styles={{ body: { minWidth: 0, padding: token.paddingSM } }}
222
231
  style={{ ["--listing-card-focus" as string]: token.colorPrimary }}
223
232
  >
224
- <Flex vertical gap={spacing[3]}>
225
- {/* The strip is a SIBLING of the anchor, never a child — see the
226
- file header. A listing with no photos still gets one slide, so
227
- the card's height does not depend on whether a seller uploaded
228
- anything. */}
229
- <SkinCarousel
230
- label={t(LISTINGS_I18N_KEYS.cardPhotos)}
231
- aspectRatio={LISTING_PHOTO_ASPECT}
232
- peek={many}
233
- dots={many}
234
- data-testid="listings-serp-photos"
235
- >
236
- {photos.length === 0 ? (
237
- <ListingPhoto
238
- imageRef={undefined}
239
- alt={title.length > 0 ? title : String(listing.id)}
233
+ <div className={CARD_QUERY_CLASS}>
234
+ <div className={CARD_FRAME_CLASS} style={{ gap: spacing[3] }}>
235
+ {/* The strip is a SIBLING of the anchor, never a child — see the
236
+ file header. A listing with no photos still gets one slide, so
237
+ the card's height does not depend on whether a seller uploaded
238
+ anything. */}
239
+ <div className={CARD_MEDIA_CLASS}>
240
+ <ListingPhotoStrip
241
+ images={photos}
242
+ title={title.length > 0 ? title : String(listing.id)}
243
+ testId="listings-serp-photos"
240
244
  />
241
- ) : (
242
- photos.map((reference, index) => (
243
- <ListingPhoto
244
- key={reference}
245
- imageRef={reference}
246
- alt={t(LISTINGS_I18N_KEYS.detailPhotoAlt, {
247
- index: index + 1,
248
- total: photos.length,
249
- })}
250
- />
251
- ))
252
- )}
253
- </SkinCarousel>
254
-
255
- <Flex gap={spacing[3]} align="flex-start">
256
- <Flex vertical gap={spacing[1]} style={BODY}>
257
- <CardTarget
258
- {...openProps(props)}
259
- listingId={listing.id}
260
- label={targetLabel}
261
- testId="listings-serp-open"
262
- bodyTestId="listings-serp-body"
263
- >
264
- {props.badge}
245
+ </div>
265
246
 
266
- {/* PRICE FIRST, and loud. `fontSize.xl` rather than an antd
267
- heading: this is a price, not a section title, and it must
268
- not enter the document outline of a page holding twenty of
269
- them. */}
270
- <Flex align="center" gap={spacing[2]} wrap>
271
- <Typography.Text
272
- strong
273
- style={{ fontSize: fontSize.xl.fontSize }}
274
- data-testid="listings-serp-price"
247
+ <div className={CARD_MAIN_CLASS}>
248
+ <Flex gap={spacing[3]} align="flex-start">
249
+ <Flex vertical gap={spacing[1]} style={BODY}>
250
+ <CardTarget
251
+ {...openProps(props)}
252
+ listingId={listing.id}
253
+ label={targetLabel}
254
+ testId="listings-serp-open"
255
+ bodyTestId="listings-serp-body"
275
256
  >
276
- <ListingPrice amount={listing.price} {...currency} />
277
- </Typography.Text>
278
- {priceTrend !== undefined && (
279
- <PriceTrendIcon
280
- direction={priceTrend.direction}
281
- label={t(
282
- priceTrend.direction === "down"
283
- ? LISTINGS_I18N_KEYS.cardPriceDropped
284
- : LISTINGS_I18N_KEYS.cardPriceRaised
257
+ {props.badge}
258
+
259
+ {/* PRICE FIRST, and loud. `fontSize.xl` rather than an antd
260
+ heading: this is a price, not a section title, and it must
261
+ not enter the document outline of a page holding twenty of
262
+ them. */}
263
+ <Flex align="center" gap={spacing[2]} wrap>
264
+ <Typography.Text
265
+ strong
266
+ style={{ fontSize: fontSize.xl.fontSize }}
267
+ data-testid="listings-serp-price"
268
+ >
269
+ <ListingPrice amount={listing.price} {...currency} />
270
+ </Typography.Text>
271
+ {priceTrend !== undefined && (
272
+ <PriceTrendIcon
273
+ direction={priceTrend.direction}
274
+ label={t(
275
+ priceTrend.direction === "down"
276
+ ? LISTINGS_I18N_KEYS.cardPriceDropped
277
+ : LISTINGS_I18N_KEYS.cardPriceRaised
278
+ )}
279
+ />
285
280
  )}
286
- />
287
- )}
288
- </Flex>
281
+ </Flex>
289
282
 
290
- {priceTrend !== undefined && (
291
- <Flex
292
- align="baseline"
293
- gap={spacing[1]}
294
- data-testid="listings-serp-old-price"
295
- >
296
- {/* The strike-through is what a sighted reader sees and
297
- nothing a screen reader announces, so the word is on
298
- the line too rather than left to the styling. */}
299
- <Typography.Text type="secondary">
300
- {t(LISTINGS_I18N_KEYS.cardPriceWas)}
301
- </Typography.Text>
302
- <Typography.Text type="secondary" delete>
303
- <ListingPrice amount={priceTrend.oldPrice} {...currency} />
283
+ {priceTrend !== undefined && (
284
+ <Flex
285
+ align="baseline"
286
+ gap={spacing[1]}
287
+ data-testid="listings-serp-old-price"
288
+ >
289
+ {/* The strike-through is what a sighted reader sees and
290
+ nothing a screen reader announces, so the word is on
291
+ the line too rather than left to the styling. */}
292
+ <Typography.Text type="secondary">
293
+ {t(LISTINGS_I18N_KEYS.cardPriceWas)}
294
+ </Typography.Text>
295
+ <Typography.Text type="secondary" delete>
296
+ <ListingPrice amount={priceTrend.oldPrice} {...currency} />
297
+ </Typography.Text>
298
+ </Flex>
299
+ )}
300
+
301
+ <Typography.Text data-testid="listings-serp-title">
302
+ {title}
304
303
  </Typography.Text>
305
- </Flex>
306
- )}
307
304
 
308
- <Typography.Text data-testid="listings-serp-title">
309
- {title}
310
- </Typography.Text>
305
+ {props.specsLine !== undefined && props.specsLine.length > 0 ? (
306
+ <Typography.Text
307
+ type="secondary"
308
+ ellipsis
309
+ data-testid="listings-serp-specs"
310
+ >
311
+ {props.specsLine}
312
+ </Typography.Text>
313
+ ) : titleDaos.length > 0 ? (
314
+ <Typography.Text
315
+ type="secondary"
316
+ ellipsis
317
+ data-testid="listings-serp-specs"
318
+ >
319
+ <FeatureBadges
320
+ features={featuresFromDaoList(titleDaos, copy).map(
321
+ (view) => view.feature,
322
+ )}
323
+ values={featuresDtoFromDaoList(titleDaos)}
324
+ />
325
+ </Typography.Text>
326
+ ) : null}
311
327
 
312
- {props.specsLine !== undefined && props.specsLine.length > 0 ? (
313
- <Typography.Text
314
- type="secondary"
315
- ellipsis
316
- data-testid="listings-serp-specs"
317
- >
318
- {props.specsLine}
319
- </Typography.Text>
320
- ) : titleDaos.length > 0 ? (
321
- <Typography.Text
322
- type="secondary"
323
- ellipsis
324
- data-testid="listings-serp-specs"
325
- >
326
- <FeatureBadges
327
- features={featuresFromDaoList(titleDaos, copy).map(
328
- (view) => view.feature,
329
- )}
330
- values={featuresDtoFromDaoList(titleDaos)}
331
- />
332
- </Typography.Text>
333
- ) : null}
328
+ {badgeFeatures.length > 0 ? (
329
+ <FeatureBadges
330
+ features={badgeFeatures.map((view) => view.feature)}
331
+ values={badgeValues}
332
+ />
333
+ ) : null}
334
+ </CardTarget>
334
335
 
335
- {badgeFeatures.length > 0 ? (
336
- <FeatureBadges
337
- features={badgeFeatures.map((view) => view.feature)}
338
- values={badgeValues}
339
- />
340
- ) : null}
341
- </CardTarget>
336
+ {/* Outside the anchor, both of them: a seller line usually holds
337
+ a link to the seller, and the place is the last thing read
338
+ rather than part of what the card is called. */}
339
+ {props.sellerSlot ?? null}
342
340
 
343
- {/* Outside the anchor, both of them: a seller line usually holds
344
- a link to the seller, and the place is the last thing read
345
- rather than part of what the card is called. */}
346
- {props.sellerSlot ?? null}
347
-
348
- {listing.location_label !== undefined &&
349
- listing.location_label.length > 0 ? (
350
- <Typography.Text
351
- type="secondary"
352
- data-testid="listings-serp-location"
353
- >
354
- {listing.location_label}
355
- </Typography.Text>
356
- ) : null}
357
- </Flex>
341
+ {listing.location_label !== undefined &&
342
+ listing.location_label.length > 0 ? (
343
+ <Typography.Text
344
+ type="secondary"
345
+ data-testid="listings-serp-location"
346
+ >
347
+ {listing.location_label}
348
+ </Typography.Text>
349
+ ) : null}
350
+ </Flex>
358
351
 
359
- {rail}
360
- </Flex>
361
- </Flex>
352
+ {rail}
353
+ </Flex>
354
+ </div>
355
+ </div>
356
+ </div>
362
357
  </Card>
363
358
  </SkinTheme>
364
359
  );
@@ -33,7 +33,13 @@
33
33
  * that wrapped its own composition in `<ListingsSkinTheme>` imports
34
34
  * `<SkinTheme>` from the substrate instead — same props, plus a `surface`.
35
35
  */
36
- export { ListingCard } from "./ListingCard.js";
36
+ export {
37
+ ListingCard,
38
+ // The card's row arm, for a container that wants to lay out against the same
39
+ // threshold (and for a test that measures it). See `<ListingCard>`'s header.
40
+ LISTING_CARD_ROW_MIN,
41
+ LISTING_CARD_ROW_MEDIA,
42
+ } from "./ListingCard.js";
37
43
  export type {
38
44
  ListingCardProps,
39
45
  ListingCardBaseProps,
@@ -79,7 +85,11 @@ export type {
79
85
 
80
86
  export { LifecycleTag, ListingStatusBlock, ModerationNote } from "./StatusTags.js";
81
87
  export type { ListingStatusProps } from "./StatusTags.js";
82
- export { ListingPhoto, LISTING_PHOTO_ASPECT } from "./ListingPhoto.js";
88
+ export {
89
+ ListingPhoto,
90
+ ListingPhotoStrip,
91
+ LISTING_PHOTO_ASPECT,
92
+ } from "./ListingPhoto.js";
83
93
  export type { ListingPhotoProps } from "./ListingPhoto.js";
84
94
  export { SignInLink } from "./SignInLink.js";
85
95
  export type { SignInLinkProps } from "./SignInLink.js";