@stapel/listings-react 0.20.0 → 0.22.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 +75 -0
- package/dist/api/generated/schema.d.ts +125 -2
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/listingsApi.d.ts +20 -1
- package/dist/api/listingsApi.d.ts.map +1 -1
- package/dist/api/listingsApi.js +1 -0
- package/dist/api/listingsApi.js.map +1 -1
- package/dist/api/types.d.ts +32 -2
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js +7 -0
- package/dist/api/types.js.map +1 -1
- package/dist/default/ListingCard.d.ts +11 -0
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +86 -7
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts +40 -0
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +53 -6
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +14 -4
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/MyListingsPane.d.ts +19 -1
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +99 -12
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/headless/ListingActions.d.ts +64 -13
- package/dist/headless/ListingActions.d.ts.map +1 -1
- package/dist/headless/ListingActions.js +60 -5
- package/dist/headless/ListingActions.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +8 -2
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +20 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +52 -1
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +12 -2
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/model/mine.d.ts +20 -0
- package/dist/model/mine.d.ts.map +1 -1
- package/dist/model/mine.js +22 -0
- package/dist/model/mine.js.map +1 -1
- package/dist/model/mutations.d.ts +24 -1
- package/dist/model/mutations.d.ts.map +1 -1
- package/dist/model/mutations.js +28 -0
- package/dist/model/mutations.js.map +1 -1
- package/dist/model/status.d.ts.map +1 -1
- package/dist/model/status.js +26 -9
- package/dist/model/status.js.map +1 -1
- package/dist/model/transitions.d.ts +38 -1
- package/dist/model/transitions.d.ts.map +1 -1
- package/dist/model/transitions.js +73 -1
- package/dist/model/transitions.js.map +1 -1
- package/llms.txt +4 -2
- package/manifest.json +26 -2
- package/nav-manifest.json +1 -1
- package/package.json +5 -5
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +125 -2
- package/src/api/listingsApi.ts +27 -0
- package/src/api/types.ts +47 -2
- package/src/default/ListingCard.tsx +115 -12
- package/src/default/ListingPhoto.tsx +102 -18
- package/src/default/ListingSerpCard.tsx +17 -4
- package/src/default/MyListingsPane.tsx +205 -39
- package/src/headless/ListingActions.tsx +137 -16
- package/src/i18n/es.ts +8 -2
- package/src/i18n/keys.ts +52 -1
- package/src/i18n/ru.ts +12 -2
- package/src/index.ts +19 -3
- package/src/model/mine.ts +23 -0
- package/src/model/mutations.ts +41 -0
- package/src/model/status.ts +27 -9
- package/src/model/transitions.ts +80 -1
|
@@ -107,7 +107,12 @@
|
|
|
107
107
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
108
108
|
import { Card, Flex, Typography, theme as antdTheme } from "antd";
|
|
109
109
|
import { SkinButton as Button } from "@stapel/tokens-antd/skin";
|
|
110
|
-
import {
|
|
110
|
+
import {
|
|
111
|
+
ErrorAlert,
|
|
112
|
+
GatedControl,
|
|
113
|
+
SKIN_CAROUSEL_SLIDE_CLASS,
|
|
114
|
+
SkinTheme,
|
|
115
|
+
} from "@stapel/tokens-antd/skin";
|
|
111
116
|
import { useActionGate, useT } from "@stapel/core";
|
|
112
117
|
import type { LinkComponent, SignInCtaProp } from "@stapel/core";
|
|
113
118
|
import { spacing } from "@stapel/tokens";
|
|
@@ -123,7 +128,7 @@ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
|
123
128
|
import { GateReasonPopover } from "./GateReasonPopover.js";
|
|
124
129
|
import { HeartIcon } from "./icons.js";
|
|
125
130
|
import { SignInLink } from "./SignInLink.js";
|
|
126
|
-
import { ListingPhotoStrip } from "./ListingPhoto.js";
|
|
131
|
+
import { PHOTO_LINK_CLASS, ListingPhotoStrip } from "./ListingPhoto.js";
|
|
127
132
|
import { ListingPrice } from "./ListingPrice.js";
|
|
128
133
|
import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
|
|
129
134
|
|
|
@@ -230,6 +235,18 @@ export const CARD_BLEED_CLASS = "stapel-listing-card-bleed";
|
|
|
230
235
|
*/
|
|
231
236
|
export const CARD_VIEWED_CLASS = "stapel-listing-card-seen";
|
|
232
237
|
|
|
238
|
+
/**
|
|
239
|
+
* The class that makes a card LOOK like a target.
|
|
240
|
+
*
|
|
241
|
+
* Measured on a live 1440px grid before this pack: `box-shadow: none`,
|
|
242
|
+
* `transform: none`, the border unchanged and `cursor: auto` — a card that
|
|
243
|
+
* opens a listing and gives a person no reason to believe it does. antd's own
|
|
244
|
+
* `hoverable` was not used: it hard-codes `boxShadowCard` and nothing else, so
|
|
245
|
+
* it cannot state a border and cannot be told to stand still for somebody who
|
|
246
|
+
* asked their system for less motion.
|
|
247
|
+
*/
|
|
248
|
+
export const CARD_HOVER_CLASS = "stapel-listing-card-hoverable";
|
|
249
|
+
|
|
233
250
|
/**
|
|
234
251
|
* How far a seen card is dimmed.
|
|
235
252
|
*
|
|
@@ -289,14 +306,41 @@ export function cardTargetCss(): string {
|
|
|
289
306
|
const media = `.${CARD_MEDIA_CLASS}`;
|
|
290
307
|
const main = `.${CARD_MAIN_CLASS}`;
|
|
291
308
|
const bleed = `.${CARD_BLEED_CLASS}`;
|
|
309
|
+
const hover = CARD_HOVER_CLASS;
|
|
292
310
|
return [
|
|
293
311
|
`.${CARD_TARGET_CLASS}{display:block;color:inherit;text-decoration:none}`,
|
|
294
312
|
`.${CARD_TARGET_CLASS}:focus-visible{outline:2px solid var(--listing-card-focus);outline-offset:2px}`,
|
|
313
|
+
// A PRESS, answered where the press lands (D176). The rule is on the
|
|
314
|
+
// TARGET rather than on the card's frame because that is the one element
|
|
315
|
+
// all three surfaces share: the feed tile is a bare div with this anchor
|
|
316
|
+
// over it and no card chrome at all, so a rule written for the frame
|
|
317
|
+
// reached the two surfaces that have one and missed the phone's home
|
|
318
|
+
// screen entirely. `matchMedia("(hover: hover)")` is false on that
|
|
319
|
+
// device, which makes this the ONLY feedback a finger ever gets between
|
|
320
|
+
// touching a tile and the next screen arriving.
|
|
321
|
+
`.${CARD_TARGET_CLASS}{transition:transform 90ms ease}`,
|
|
322
|
+
`.${CARD_TARGET_CLASS}:active{transform:scale(0.985)}`,
|
|
323
|
+
`@media (prefers-reduced-motion:reduce){.${CARD_TARGET_CLASS}{transition:none}` +
|
|
324
|
+
`.${CARD_TARGET_CLASS}:active{transform:none;opacity:0.9}}`,
|
|
295
325
|
// The card asks about its OWN width, not the window's — see
|
|
296
326
|
// `LISTING_CARD_ROW_MIN`.
|
|
297
327
|
`${q}{container-type:inline-size}`,
|
|
298
328
|
`${frame}{display:flex;flex-direction:column;min-inline-size:0}`,
|
|
299
|
-
|
|
329
|
+
// THE MEDIA WELL IS CLIPPED TO THE CARD'S OWN CORNER (D180).
|
|
330
|
+
//
|
|
331
|
+
// The photo strip runs edge to edge at the top of a stacked card, so the
|
|
332
|
+
// card's rounded corner is the corner of the PICTURE. It was not being
|
|
333
|
+
// cut to it: the well was a square box inside a rounded card, and each
|
|
334
|
+
// carousel slide carried a radius of its own — so a multi-photo tile drew
|
|
335
|
+
// a rounded sliver of the next photograph in the corner where the card's
|
|
336
|
+
// curve should be, and every row of tiles on the live feed read as a set
|
|
337
|
+
// of pictures with a torn right edge. One radius, stated by the card,
|
|
338
|
+
// applied to the box that holds the pictures; the slides inside it are
|
|
339
|
+
// square because the well is what has the shape.
|
|
340
|
+
`${media}{min-inline-size:0;overflow:hidden;` +
|
|
341
|
+
`border-start-start-radius:var(--listing-card-radius);` +
|
|
342
|
+
`border-start-end-radius:var(--listing-card-radius)}`,
|
|
343
|
+
`${media} .${SKIN_CAROUSEL_SLIDE_CLASS}{border-radius:0}`,
|
|
300
344
|
`${main}{display:flex;flex-direction:column;flex:1 1 auto;min-inline-size:0}`,
|
|
301
345
|
// The row arm. `align-items:flex-start` so a short text column does not
|
|
302
346
|
// stretch the photo, and a fixed media basis so the picture cannot grow
|
|
@@ -304,6 +348,9 @@ export function cardTargetCss(): string {
|
|
|
304
348
|
`@container (min-width:${String(LISTING_CARD_ROW_MIN)}px){` +
|
|
305
349
|
`${frame}{flex-direction:row;align-items:flex-start}` +
|
|
306
350
|
`${media}{flex:0 0 ${String(LISTING_CARD_ROW_MEDIA)}px;` +
|
|
351
|
+
// Inset on every side in the row arm, so the photo is a rounded block
|
|
352
|
+
// beside the text rather than two rounded corners and two square ones.
|
|
353
|
+
`border-radius:var(--listing-card-radius);` +
|
|
307
354
|
`max-inline-size:${String(LISTING_CARD_ROW_MEDIA)}px}` +
|
|
308
355
|
// A full-bleed stacked card has no padding of its own to give the row
|
|
309
356
|
// arm, so the row arm states its own; the reading column's padding is
|
|
@@ -311,6 +358,34 @@ export function cardTargetCss(): string {
|
|
|
311
358
|
`${bleed}{padding-block:var(--listing-card-inset);` +
|
|
312
359
|
`padding-inline-start:var(--listing-card-inset)}` +
|
|
313
360
|
`}`,
|
|
361
|
+
// A linked slide. `display:block` kills the inline baseline gap under the
|
|
362
|
+
// picture, which on a grid of tiles reads as cards of unequal height; the
|
|
363
|
+
// ring is for a browser that focuses an `aria-hidden` anchor anyway.
|
|
364
|
+
`.${PHOTO_LINK_CLASS}{display:block}`,
|
|
365
|
+
`.${PHOTO_LINK_CLASS}:focus-visible{outline:2px solid var(--listing-card-focus);outline-offset:2px}`,
|
|
366
|
+
// The card says it is a target. Two properties, both from the theme's own
|
|
367
|
+
// tokens, and a transition that a person who asked for less motion does
|
|
368
|
+
// not get.
|
|
369
|
+
`.${hover}{transition:box-shadow 160ms ease,border-color 160ms ease}`,
|
|
370
|
+
// HOVER IS FOR A POINTER THAT HAS ONE. Measured on the phone this loop
|
|
371
|
+
// walks: `matchMedia("(hover: hover)")` is false, and an unguarded
|
|
372
|
+
// `:hover` on a touch device is worse than absent — it latches after a
|
|
373
|
+
// tap and leaves one tile lit until something else is touched.
|
|
374
|
+
`@media (hover:hover){.${hover}:hover{` +
|
|
375
|
+
`box-shadow:var(--listing-card-hover-shadow);` +
|
|
376
|
+
`border-color:var(--listing-card-focus)}}`,
|
|
377
|
+
// PRESSED, and not the same thing as hovered. The card had a hover state
|
|
378
|
+
// and nothing at all on the way down, so a tap on a phone — where there
|
|
379
|
+
// is no hover to begin with — gave no feedback between the finger landing
|
|
380
|
+
// and the next screen arriving. The lift is TAKEN AWAY rather than
|
|
381
|
+
// increased: a pressed card is a card pushed into the page, which is the
|
|
382
|
+
// one direction a shadow can say without a second token.
|
|
383
|
+
`.${hover}:active{box-shadow:none;border-color:var(--listing-card-focus)}`,
|
|
384
|
+
// The photo is the largest target on the tile and is its own link, so it
|
|
385
|
+
// answers a press on its own — the card behind it may not be under the
|
|
386
|
+
// finger at all.
|
|
387
|
+
`.${PHOTO_LINK_CLASS}:active{opacity:0.88}`,
|
|
388
|
+
`@media (prefers-reduced-motion:reduce){.${hover}{transition:none}}`,
|
|
314
389
|
// Already seen. The photo and everything inside the card's anchor dim
|
|
315
390
|
// together; the heart is outside the anchor and stays as it was — see
|
|
316
391
|
// CARD_VIEWED_CLASS.
|
|
@@ -553,9 +628,23 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
553
628
|
/>
|
|
554
629
|
) : null}
|
|
555
630
|
|
|
631
|
+
{/* ONE line, and it truncates (D185). Measured on a live 1440px feed:
|
|
632
|
+
a two-part city-and-district name wrapped, the text block grew
|
|
633
|
+
104 -> 128px, and the two cards on either side of it stood 24px
|
|
634
|
+
shorter — a row of tiles with a ragged bottom edge and a heart
|
|
635
|
+
hanging below the line it belongs to. The place is a subtitle, not
|
|
636
|
+
prose: which city it is reads from the first words, and the tile's
|
|
637
|
+
height must be a property of the GRID rather than of how long this
|
|
638
|
+
particular neighbourhood is called. The full string stays in the
|
|
639
|
+
title attribute the way `ellipsis` puts it there, so nothing is
|
|
640
|
+
lost — it is one hover, or the listing's own page. */}
|
|
556
641
|
{listing.location_label !== undefined &&
|
|
557
642
|
listing.location_label.length > 0 ? (
|
|
558
|
-
<Typography.Text
|
|
643
|
+
<Typography.Text
|
|
644
|
+
type="secondary"
|
|
645
|
+
ellipsis={{ tooltip: listing.location_label }}
|
|
646
|
+
data-testid="listings-card-location"
|
|
647
|
+
>
|
|
559
648
|
{listing.location_label}
|
|
560
649
|
</Typography.Text>
|
|
561
650
|
) : null}
|
|
@@ -577,11 +666,12 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
577
666
|
{...(status !== undefined
|
|
578
667
|
? { "data-listing-status": status.status }
|
|
579
668
|
: {})}
|
|
580
|
-
//
|
|
581
|
-
// the card
|
|
582
|
-
{
|
|
583
|
-
? {
|
|
584
|
-
|
|
669
|
+
// `viewed` only when the server actually said so: absent and `null`
|
|
670
|
+
// leave the card's dimming exactly as it renders today.
|
|
671
|
+
className={
|
|
672
|
+
viewed ? `${CARD_HOVER_CLASS} ${CARD_VIEWED_CLASS}` : CARD_HOVER_CLASS
|
|
673
|
+
}
|
|
674
|
+
{...(viewed ? { "data-listing-viewed": "true" } : {})}
|
|
585
675
|
// The body's own padding is zero because the frame fills the card and
|
|
586
676
|
// the photo runs edge to edge when it is stacked: padding here would
|
|
587
677
|
// be a strip of card around a picture. The text block inside the
|
|
@@ -591,18 +681,31 @@ export function ListingCard(props: ListingCardProps): ReactElement {
|
|
|
591
681
|
style={{
|
|
592
682
|
["--listing-card-focus" as string]: token.colorPrimary,
|
|
593
683
|
["--listing-card-inset" as string]: `${String(token.paddingSM)}px`,
|
|
684
|
+
// The card's OWN corner, handed to the media well so the picture is
|
|
685
|
+
// cut to the same curve the card is drawn with — in whatever theme,
|
|
686
|
+
// at whatever radius a deployment retuned it to.
|
|
687
|
+
["--listing-card-radius" as string]: `${String(token.borderRadiusLG)}px`,
|
|
688
|
+
// The theme's own elevation, so the lift is the same one every
|
|
689
|
+
// raised surface in the skin uses and it is right in both modes.
|
|
690
|
+
["--listing-card-hover-shadow" as string]: token.boxShadowSecondary,
|
|
594
691
|
}}
|
|
595
692
|
>
|
|
596
693
|
<div className={CARD_QUERY_CLASS}>
|
|
597
694
|
<div className={`${CARD_FRAME_CLASS} ${CARD_BLEED_CLASS}`}>
|
|
598
|
-
{/* The
|
|
599
|
-
|
|
600
|
-
|
|
695
|
+
{/* The STRIP is outside the anchor — a swipeable scroller is a
|
|
696
|
+
control and a link may not contain one — but each SLIDE is
|
|
697
|
+
linked, so the 267x200 photograph is part of the card's target
|
|
698
|
+
instead of the one place on it where a click does nothing.
|
|
699
|
+
See `<ListingPhotoStrip>` for where that line is drawn. */}
|
|
601
700
|
<div className={CARD_MEDIA_CLASS}>
|
|
602
701
|
<ListingPhotoStrip
|
|
603
702
|
images={listing.images ?? []}
|
|
604
703
|
title={title.length > 0 ? title : String(listing.id)}
|
|
605
704
|
testId="listings-card-photos"
|
|
705
|
+
{...(props.href !== undefined ? { href: props.href } : {})}
|
|
706
|
+
{...(props.href !== undefined && props.linkComponent !== undefined
|
|
707
|
+
? { linkComponent: props.linkComponent }
|
|
708
|
+
: {})}
|
|
606
709
|
/>
|
|
607
710
|
</div>
|
|
608
711
|
|
|
@@ -27,10 +27,22 @@ import { Typography, theme as antdTheme } from "antd";
|
|
|
27
27
|
import { Image } from "@stapel/image";
|
|
28
28
|
import { SkinCarousel } from "@stapel/tokens-antd/skin";
|
|
29
29
|
import { useT } from "@stapel/core";
|
|
30
|
+
import type { LinkComponent } from "@stapel/core";
|
|
30
31
|
import { radii, spacing } from "@stapel/tokens";
|
|
31
32
|
import { useListingsRuntime } from "../model/context.js";
|
|
32
33
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
33
34
|
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The class a linked slide carries.
|
|
38
|
+
*
|
|
39
|
+
* `display:block` is the load-bearing half: an inline anchor around a block
|
|
40
|
+
* image leaves a baseline gap under the picture, which on a grid of tiles is a
|
|
41
|
+
* visible row of mismatched card heights. The rule itself lives in
|
|
42
|
+
* `cardTargetCss()`, beside the card's other one-rule-per-defect entries.
|
|
43
|
+
*/
|
|
44
|
+
export const PHOTO_LINK_CLASS = "stapel-listing-photo-link";
|
|
45
|
+
|
|
34
46
|
/** The default shape of a listing photo box everywhere in this skin. Declared
|
|
35
47
|
* once so a card, a detail hero and a dashboard thumbnail cannot drift into
|
|
36
48
|
* three different crops. */
|
|
@@ -151,6 +163,28 @@ export function ListingPhoto(props: ListingPhotoProps): ReactElement {
|
|
|
151
163
|
* SERP was measured correct on while the desktop card (a still `<img>` inside
|
|
152
164
|
* the anchor, one photo, no dots) was not.
|
|
153
165
|
*
|
|
166
|
+
* ── …and why each SLIDE may still be one, when a card asks (`href`) ────────
|
|
167
|
+
*
|
|
168
|
+
* The rule above is about the STRIP, and it was read as a rule about the
|
|
169
|
+
* picture. It is not, and the difference was measured: on a 1440px grid the
|
|
170
|
+
* photo is 267x200 — the largest, most obvious target on the card — and
|
|
171
|
+
* clicking it did nothing at all. `cursor: auto`, no hover, and the visitor
|
|
172
|
+
* stayed on the page they were already on. A person who clicks a picture of a
|
|
173
|
+
* phone is asking to see the phone; there is no reading of that click in which
|
|
174
|
+
* the right answer is silence.
|
|
175
|
+
*
|
|
176
|
+
* So a card may hand this strip its own `href`, and each slide becomes a link
|
|
177
|
+
* around its picture. What that does NOT do is make the strip a link: the
|
|
178
|
+
* scroller, its arrows and its dots stay controls, outside every anchor,
|
|
179
|
+
* because they are what the header's rule is actually protecting. And the
|
|
180
|
+
* slide links are `tabIndex={-1}` + `aria-hidden`, so a card is still ONE tab
|
|
181
|
+
* stop and one accessible name — the reading anchor's — rather than one per
|
|
182
|
+
* photograph.
|
|
183
|
+
*
|
|
184
|
+
* A surface that does not pass `href` is byte-identical to before. The phone
|
|
185
|
+
* SERP passes none: a horizontal swipe is a real gesture there, and it is the
|
|
186
|
+
* gesture the header's rule was written for.
|
|
187
|
+
*
|
|
154
188
|
* A listing with no photos still gets one slide, so a row's height does not
|
|
155
189
|
* depend on whether a seller uploaded anything, and a ONE-photo strip gets
|
|
156
190
|
* neither peek nor dots: the sliver of a next slide is an affordance for
|
|
@@ -164,10 +198,59 @@ export function ListingPhotoStrip(props: {
|
|
|
164
198
|
/** The surface's own test id, so a screen holding two kinds of card does
|
|
165
199
|
* not hand a test two elements under one name. */
|
|
166
200
|
readonly testId: string;
|
|
201
|
+
/**
|
|
202
|
+
* Where a click on the PICTURE goes — the card's own `href`, when the card
|
|
203
|
+
* wants its photograph to be part of its target. Omitted, every slide is
|
|
204
|
+
* inert exactly as it has always been.
|
|
205
|
+
*/
|
|
206
|
+
readonly href?: string;
|
|
207
|
+
/** The host's `<Link>`, so the click stays inside the SPA. */
|
|
208
|
+
readonly linkComponent?: LinkComponent;
|
|
167
209
|
}): ReactElement {
|
|
168
210
|
const t = useT();
|
|
169
211
|
const { images, title } = props;
|
|
170
212
|
const many = images.length > 1;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* One slide, linked or not.
|
|
216
|
+
*
|
|
217
|
+
* `aria-hidden` + `tabIndex={-1}`: this is a SECOND way to reach a
|
|
218
|
+
* destination the card already names, not a second destination. Without it a
|
|
219
|
+
* grid of twenty-four cards grows twenty-four extra tab stops, each
|
|
220
|
+
* announcing the same listing the card announced a moment ago.
|
|
221
|
+
*/
|
|
222
|
+
const slide = (photo: ReactElement, key: string): ReactElement => {
|
|
223
|
+
if (props.href === undefined) return photo;
|
|
224
|
+
const Link = props.linkComponent;
|
|
225
|
+
return Link !== undefined ? (
|
|
226
|
+
<Link
|
|
227
|
+
key={key}
|
|
228
|
+
href={props.href}
|
|
229
|
+
className={PHOTO_LINK_CLASS}
|
|
230
|
+
aria-hidden="true"
|
|
231
|
+
tabIndex={-1}
|
|
232
|
+
data-testid="listings-photo-link"
|
|
233
|
+
data-analytics="none"
|
|
234
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
235
|
+
>
|
|
236
|
+
{photo}
|
|
237
|
+
</Link>
|
|
238
|
+
) : (
|
|
239
|
+
<a
|
|
240
|
+
key={key}
|
|
241
|
+
href={props.href}
|
|
242
|
+
className={PHOTO_LINK_CLASS}
|
|
243
|
+
aria-hidden="true"
|
|
244
|
+
tabIndex={-1}
|
|
245
|
+
data-testid="listings-photo-link"
|
|
246
|
+
data-analytics="none"
|
|
247
|
+
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
248
|
+
>
|
|
249
|
+
{photo}
|
|
250
|
+
</a>
|
|
251
|
+
);
|
|
252
|
+
};
|
|
253
|
+
|
|
171
254
|
return (
|
|
172
255
|
<SkinCarousel
|
|
173
256
|
label={t(LISTINGS_I18N_KEYS.cardPhotos)}
|
|
@@ -176,24 +259,25 @@ export function ListingPhotoStrip(props: {
|
|
|
176
259
|
dots={many}
|
|
177
260
|
data-testid={props.testId}
|
|
178
261
|
>
|
|
179
|
-
{images.length === 0
|
|
180
|
-
<ListingPhoto imageRef={undefined} alt={title}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
262
|
+
{images.length === 0
|
|
263
|
+
? slide(<ListingPhoto imageRef={undefined} alt={title} />, "empty")
|
|
264
|
+
: images.map((reference, index) =>
|
|
265
|
+
slide(
|
|
266
|
+
<ListingPhoto
|
|
267
|
+
key={reference}
|
|
268
|
+
imageRef={reference}
|
|
269
|
+
alt={
|
|
270
|
+
many
|
|
271
|
+
? t(LISTINGS_I18N_KEYS.detailPhotoAlt, {
|
|
272
|
+
index: index + 1,
|
|
273
|
+
total: images.length,
|
|
274
|
+
})
|
|
275
|
+
: title
|
|
276
|
+
}
|
|
277
|
+
/>,
|
|
278
|
+
reference
|
|
279
|
+
)
|
|
280
|
+
)}
|
|
197
281
|
</SkinCarousel>
|
|
198
282
|
);
|
|
199
283
|
}
|
|
@@ -99,6 +99,7 @@ import {
|
|
|
99
99
|
CARD_MEDIA_CLASS,
|
|
100
100
|
CARD_QUERY_CLASS,
|
|
101
101
|
CARD_TARGET_STYLE_HREF,
|
|
102
|
+
CARD_HOVER_CLASS,
|
|
102
103
|
CARD_VIEWED_CLASS,
|
|
103
104
|
CardTarget,
|
|
104
105
|
cardTargetCss,
|
|
@@ -247,11 +248,23 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
247
248
|
{...(status !== undefined
|
|
248
249
|
? { "data-listing-status": status.status }
|
|
249
250
|
: {})}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
251
|
+
// THE PHONE CARD SAYS IT IS A TARGET, TOO (D176). It carried no
|
|
252
|
+
// hover and no pressed state at all, and the device this card exists
|
|
253
|
+
// for answers `matchMedia("(hover: hover)")` with false — so a tap
|
|
254
|
+
// gave no feedback whatever until the next screen arrived. The shared
|
|
255
|
+
// class is one rule for a pointer that hovers and one for a finger
|
|
256
|
+
// that presses; `cardTargetCss` states both.
|
|
257
|
+
className={
|
|
258
|
+
viewed ? `${CARD_HOVER_CLASS} ${CARD_VIEWED_CLASS}` : CARD_HOVER_CLASS
|
|
259
|
+
}
|
|
260
|
+
{...(viewed ? { "data-listing-viewed": "true" } : {})}
|
|
253
261
|
styles={{ body: { minWidth: 0, padding: token.paddingSM } }}
|
|
254
|
-
style={{
|
|
262
|
+
style={{
|
|
263
|
+
["--listing-card-focus" as string]: token.colorPrimary,
|
|
264
|
+
// The corner the media well is cut to — see `cardTargetCss` (D180).
|
|
265
|
+
["--listing-card-radius" as string]: `${String(token.borderRadiusLG)}px`,
|
|
266
|
+
["--listing-card-hover-shadow" as string]: token.boxShadowSecondary,
|
|
267
|
+
}}
|
|
255
268
|
>
|
|
256
269
|
<div className={CARD_QUERY_CLASS}>
|
|
257
270
|
<div className={CARD_FRAME_CLASS} style={{ gap: spacing[3] }}>
|