@stapel/listings-react 0.9.1 → 0.10.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 +24 -0
- package/dist/default/FeedGrid.d.ts +42 -0
- package/dist/default/FeedGrid.d.ts.map +1 -0
- package/dist/default/FeedGrid.js +23 -0
- package/dist/default/FeedGrid.js.map +1 -0
- package/dist/default/ListingCard.d.ts +31 -0
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +12 -4
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingFeedCard.d.ts +77 -0
- package/dist/default/ListingFeedCard.d.ts.map +1 -0
- package/dist/default/ListingFeedCard.js +75 -0
- package/dist/default/ListingFeedCard.js.map +1 -0
- package/dist/default/ListingSerpCard.d.ts +114 -0
- package/dist/default/ListingSerpCard.d.ts.map +1 -0
- package/dist/default/ListingSerpCard.js +72 -0
- package/dist/default/ListingSerpCard.js.map +1 -0
- package/dist/default/favorite.d.ts +49 -0
- package/dist/default/favorite.d.ts.map +1 -0
- package/dist/default/favorite.js +25 -0
- package/dist/default/favorite.js.map +1 -0
- package/dist/default/icons.d.ts +17 -3
- package/dist/default/icons.d.ts.map +1 -1
- package/dist/default/icons.js +13 -1
- package/dist/default/icons.js.map +1 -1
- package/dist/default/index.d.ts +17 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +14 -1
- package/dist/default/index.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +4 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +11 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +15 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +4 -0
- package/dist/i18n/ru.js.map +1 -1
- package/llms.txt +3 -1
- package/manifest.json +45 -1
- package/nav-manifest.json +1 -1
- package/package.json +7 -7
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FeedGrid.tsx +70 -0
- package/src/default/ListingCard.tsx +21 -5
- package/src/default/ListingFeedCard.tsx +232 -0
- package/src/default/ListingSerpCard.tsx +374 -0
- package/src/default/favorite.tsx +91 -0
- package/src/default/icons.tsx +37 -3
- package/src/default/index.ts +24 -1
- package/src/i18n/es.ts +4 -0
- package/src/i18n/keys.ts +15 -0
- package/src/i18n/ru.ts +4 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<FeedGrid>` — the two-column wall a phone home feed is laid out on.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this is not `<SearchResultsPane>`'s grid ──────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The results grid is `repeat(auto-fill, minmax(280px, 1fr))`: as many columns
|
|
7
|
+
* as FIT, each at least a readable card. That is right for a catalogue and
|
|
8
|
+
* wrong for a feed — 280px is wider than half of a 390px phone, so a feed laid
|
|
9
|
+
* out that way collapses to one column exactly where the ref calls for two.
|
|
10
|
+
* A feed's column count is a DESIGN decision ("two, side by side, small"), not
|
|
11
|
+
* a consequence of a minimum card width, and the two rules cannot be spelled
|
|
12
|
+
* with one declaration.
|
|
13
|
+
*
|
|
14
|
+
* ── No masonry ────────────────────────────────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* The refs' feed looks staggered because the photos have different heights.
|
|
17
|
+
* They do not here: `<ListingFeedCard>` draws every photo in the same 4:3 well
|
|
18
|
+
* (`LISTING_PHOTO_ASPECT`), so the tiles line up in rows on their own and the
|
|
19
|
+
* grid stays a grid. Nothing in this package will pull in a masonry polyfill
|
|
20
|
+
* to reproduce a raggedness that is a property of unconstrained images —
|
|
21
|
+
* `columns: 2` (the CSS multi-column route) was the other candidate and was
|
|
22
|
+
* rejected because it breaks reading order: a screen reader and a keyboard
|
|
23
|
+
* would walk the whole left column before reaching the top of the right one.
|
|
24
|
+
*
|
|
25
|
+
* ── Desktop is not this wave's consumer, and is not broken either ─────────
|
|
26
|
+
*
|
|
27
|
+
* `columns` defaults to 2 because a phone feed is the surface this exists for.
|
|
28
|
+
* A wider surface passes its own number; the declaration is the same one, so
|
|
29
|
+
* there is no second layout to keep in step and no breakpoint in this file.
|
|
30
|
+
*/
|
|
31
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
32
|
+
import { spacing } from "@stapel/tokens";
|
|
33
|
+
|
|
34
|
+
/** The ref's phone feed: two tiles across. */
|
|
35
|
+
export const FEED_GRID_COLUMNS = 2;
|
|
36
|
+
|
|
37
|
+
export interface FeedGridProps {
|
|
38
|
+
/** How many tiles across. Default {@link FEED_GRID_COLUMNS}. */
|
|
39
|
+
readonly columns?: number;
|
|
40
|
+
/** The tiles — `<ListingFeedCard>`s, conventionally. */
|
|
41
|
+
readonly children: ReactNode;
|
|
42
|
+
readonly style?: CSSProperties;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function FeedGrid(props: FeedGridProps): ReactElement {
|
|
46
|
+
const columns = props.columns ?? FEED_GRID_COLUMNS;
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
data-testid="listings-feed-grid"
|
|
50
|
+
data-columns={String(columns)}
|
|
51
|
+
style={{
|
|
52
|
+
display: "grid",
|
|
53
|
+
// `minmax(0, 1fr)` rather than `1fr`: a bare `1fr` is `minmax(auto,
|
|
54
|
+
// 1fr)`, and `auto` refuses to shrink below its content — one long
|
|
55
|
+
// unbroken word in a title then widens the whole column and pushes the
|
|
56
|
+
// grid past the screen.
|
|
57
|
+
gridTemplateColumns: `repeat(${String(columns)}, minmax(0, 1fr))`,
|
|
58
|
+
columnGap: spacing[3],
|
|
59
|
+
// Rows breathe more than columns do: the tiles carry no border, so the
|
|
60
|
+
// gap between two rows is the only thing saying where one card's
|
|
61
|
+
// location line ends and the next card's photo begins.
|
|
62
|
+
rowGap: spacing[5],
|
|
63
|
+
alignItems: "start",
|
|
64
|
+
...props.style,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{props.children}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -221,15 +221,29 @@ export type ListingCardProps = ListingCardBaseProps & ListingCardOpenProps;
|
|
|
221
221
|
* same content in a `<button>` reset to look like nothing, rather than drawing
|
|
222
222
|
* a separate captioned control: a card that is a target on one deployment and
|
|
223
223
|
* a card-plus-a-button on another would be two different products.
|
|
224
|
+
*
|
|
225
|
+
* Exported for this pair's OTHER card surfaces (`<ListingSerpCard>`,
|
|
226
|
+
* `<ListingFeedCard>`) and for no one else — it is deliberately absent from
|
|
227
|
+
* `src/default/index.ts`. Three cards each re-deriving "which of the three
|
|
228
|
+
* arms is this" is three places for the double-navigation defect to come back;
|
|
229
|
+
* one function is one place. The card surfaces differ in what they PUT inside
|
|
230
|
+
* the target, never in how the target is made.
|
|
224
231
|
*/
|
|
225
|
-
function CardTarget(
|
|
232
|
+
export function CardTarget(
|
|
226
233
|
props: ListingCardOpenProps & {
|
|
227
234
|
readonly listingId: number;
|
|
228
235
|
readonly label: string;
|
|
236
|
+
/** The target's own test id. Each card surface names its own, so a screen
|
|
237
|
+
* holding two kinds of card does not hand a test two elements under one
|
|
238
|
+
* name. Default: the original card's. */
|
|
239
|
+
readonly testId?: string;
|
|
240
|
+
/** The body's test id on the arm where nothing opens. */
|
|
241
|
+
readonly bodyTestId?: string;
|
|
229
242
|
readonly children: ReactNode;
|
|
230
243
|
}
|
|
231
244
|
): ReactElement {
|
|
232
245
|
const { label, children } = props;
|
|
246
|
+
const testId = props.testId ?? "listings-card-open";
|
|
233
247
|
|
|
234
248
|
if (props.href !== undefined) {
|
|
235
249
|
const Link = props.linkComponent;
|
|
@@ -241,7 +255,7 @@ function CardTarget(
|
|
|
241
255
|
href={props.href}
|
|
242
256
|
aria-label={label}
|
|
243
257
|
className={CARD_TARGET_CLASS}
|
|
244
|
-
data-testid=
|
|
258
|
+
data-testid={testId}
|
|
245
259
|
data-analytics="none"
|
|
246
260
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
247
261
|
>
|
|
@@ -253,7 +267,7 @@ function CardTarget(
|
|
|
253
267
|
aria-label={label}
|
|
254
268
|
className={CARD_TARGET_CLASS}
|
|
255
269
|
style={TARGET_STYLE}
|
|
256
|
-
data-testid=
|
|
270
|
+
data-testid={testId}
|
|
257
271
|
data-analytics="none"
|
|
258
272
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
259
273
|
>
|
|
@@ -270,7 +284,7 @@ function CardTarget(
|
|
|
270
284
|
aria-label={label}
|
|
271
285
|
className={CARD_TARGET_CLASS}
|
|
272
286
|
style={BUTTON_TARGET_STYLE}
|
|
273
|
-
data-testid=
|
|
287
|
+
data-testid={testId}
|
|
274
288
|
data-analytics="none"
|
|
275
289
|
data-analytics-reason="business action — host app wraps with its own tracked()"
|
|
276
290
|
onClick={() => {
|
|
@@ -283,7 +297,9 @@ function CardTarget(
|
|
|
283
297
|
}
|
|
284
298
|
|
|
285
299
|
// No open control at all — a card inside a screen that IS the listing.
|
|
286
|
-
return
|
|
300
|
+
return (
|
|
301
|
+
<div data-testid={props.bodyTestId ?? "listings-card-body"}>{children}</div>
|
|
302
|
+
);
|
|
287
303
|
}
|
|
288
304
|
|
|
289
305
|
export function ListingCard(props: ListingCardProps): ReactElement {
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<ListingFeedCard>` — the borderless card of a phone home feed (refs §1).
|
|
3
|
+
*
|
|
4
|
+
* ── What "borderless" is actually doing ───────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* `<ListingCard>` and `<ListingSerpCard>` are antd `Card`s: a painted surface
|
|
7
|
+
* with a border, which is what makes one result read as one object next to
|
|
8
|
+
* another. A two-column feed is a different picture. Twenty bordered boxes on
|
|
9
|
+
* a 390px screen is twenty frames and forty vertical lines, and the photos —
|
|
10
|
+
* the only thing anyone is actually looking at — end up as small pictures
|
|
11
|
+
* inside chrome. So this card has no surface of its own: the photo is the
|
|
12
|
+
* card, the three lines under it sit on the page's own ground, and the RHYTHM
|
|
13
|
+
* of the grid is what separates one from the next. That is the ref, and it is
|
|
14
|
+
* also why this is a third component rather than a `bordered={false}` on the
|
|
15
|
+
* first: nothing else about the layout survives the change either.
|
|
16
|
+
*
|
|
17
|
+
* ── The heart is over the photo here, and only here ───────────────────────
|
|
18
|
+
*
|
|
19
|
+
* `<ListingCard>` argues at length that the heart belongs in a row UNDER the
|
|
20
|
+
* card rather than floating on the photograph, because a blocked favourite
|
|
21
|
+
* states its reason as text and there is nowhere to put a sentence on top of a
|
|
22
|
+
* picture. That argument is correct and this card does not repeat its
|
|
23
|
+
* conclusion, for a reason it states rather than hides: a 2-column feed tile
|
|
24
|
+
* has no line to spare. A full row of "Sign in to save this" under every one
|
|
25
|
+
* of twenty tiles is not twenty pieces of help — it is the feed.
|
|
26
|
+
*
|
|
27
|
+
* The fleet already has the mechanism for exactly this and it is not "hide the
|
|
28
|
+
* reason": `GateReasonScopeContext` / `<PaneGate>` pool identical reasons and
|
|
29
|
+
* render each ONCE for everything inside the scope, with every control's
|
|
30
|
+
* `aria-describedby` still pointing at that single copy. **A container drawing
|
|
31
|
+
* a feed should wrap `<FeedGrid>` in a `<PaneGate>`.** Unscoped, the reason
|
|
32
|
+
* still renders — over the photo, visible, never behind a hover — because a
|
|
33
|
+
* reason a person cannot read is the one outcome the doctrine forbids.
|
|
34
|
+
*
|
|
35
|
+
* ── Two lines of title, and then it stops ─────────────────────────────────
|
|
36
|
+
*
|
|
37
|
+
* A feed tile is roughly 170px wide. A three-line title pushes the price below
|
|
38
|
+
* the fold of the row and makes the grid ragged; a one-line ellipsis throws
|
|
39
|
+
* away the half of a listing's name that distinguishes it from the one beside
|
|
40
|
+
* it. Two lines, clamped, is the ref's answer and the only one that keeps the
|
|
41
|
+
* price on the same y as its neighbour's.
|
|
42
|
+
*/
|
|
43
|
+
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
44
|
+
import { Flex, Typography } from "antd";
|
|
45
|
+
import { SkinTheme } from "@stapel/tokens-antd/skin";
|
|
46
|
+
import { useT } from "@stapel/core";
|
|
47
|
+
import { radii, spacing } from "@stapel/tokens";
|
|
48
|
+
import type { ListingCard as ListingCardData } from "../api/types.js";
|
|
49
|
+
import { lifecycleCaption } from "../model/status.js";
|
|
50
|
+
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
51
|
+
import { FavoriteHeart } from "./favorite.js";
|
|
52
|
+
import {
|
|
53
|
+
CARD_TARGET_STYLE_HREF,
|
|
54
|
+
CardTarget,
|
|
55
|
+
cardTargetCss,
|
|
56
|
+
} from "./ListingCard.js";
|
|
57
|
+
import type { ListingCardOpenProps } from "./ListingCard.js";
|
|
58
|
+
import { ListingPhoto } from "./ListingPhoto.js";
|
|
59
|
+
import { ListingPrice } from "./ListingPrice.js";
|
|
60
|
+
import type { ThemeModeProp } from "./types.js";
|
|
61
|
+
|
|
62
|
+
/** Lines of title a tile draws before it clips. See the file header. */
|
|
63
|
+
const TITLE_LINES = 2;
|
|
64
|
+
|
|
65
|
+
/** The class the clamped title carries, for {@link feedCardCss}. */
|
|
66
|
+
export const FEED_TITLE_CLASS = "stapel-listing-feed-title";
|
|
67
|
+
|
|
68
|
+
/** The `href` the hoisted feed stylesheet is deduplicated by. */
|
|
69
|
+
export const FEED_CARD_STYLE_HREF = "stapel-listings-feed-card";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The clamp, as a real CSS rule rather than an inline style.
|
|
73
|
+
*
|
|
74
|
+
* `-webkit-line-clamp` and `-webkit-box-orient` are the two declarations that
|
|
75
|
+
* do not survive the trip through an inline style object: React's serializer
|
|
76
|
+
* and every DOM implementation that is not a browser drop them silently, so a
|
|
77
|
+
* tile written that way clamps in Chrome, does not clamp in a test, and
|
|
78
|
+
* nothing anywhere says which. A hoisted sheet keeps one copy for the document
|
|
79
|
+
* and makes the rule something a test can read.
|
|
80
|
+
*/
|
|
81
|
+
export function feedCardCss(): string {
|
|
82
|
+
return (
|
|
83
|
+
`.${FEED_TITLE_CLASS}{display:-webkit-box;-webkit-box-orient:vertical;` +
|
|
84
|
+
`-webkit-line-clamp:${String(TITLE_LINES)};overflow:hidden}`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The tile. `position: relative` is what the heart and the badge overlay are
|
|
89
|
+
* pinned to; `minWidth: 0` keeps a long word inside its grid track. */
|
|
90
|
+
const TILE: CSSProperties = { position: "relative", minWidth: 0 };
|
|
91
|
+
|
|
92
|
+
/** Pinned to the photo's leading corner: the container's own marking. */
|
|
93
|
+
const BADGE: CSSProperties = {
|
|
94
|
+
position: "absolute",
|
|
95
|
+
insetBlockStart: spacing[2],
|
|
96
|
+
insetInlineStart: spacing[2],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Pinned to the photo's trailing corner. `alignItems: flex-end` so the
|
|
100
|
+
* pooled-or-not reason, when there is one, stacks under the heart against the
|
|
101
|
+
* same edge rather than pushing it inwards. */
|
|
102
|
+
const HEART: CSSProperties = {
|
|
103
|
+
position: "absolute",
|
|
104
|
+
insetBlockStart: spacing[2],
|
|
105
|
+
insetInlineEnd: spacing[2],
|
|
106
|
+
alignItems: "flex-end",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export interface ListingFeedCardBaseProps extends ThemeModeProp {
|
|
110
|
+
readonly listing: ListingCardData;
|
|
111
|
+
/**
|
|
112
|
+
* Drawn over the photo's leading corner — "New", "In stock", a promotion
|
|
113
|
+
* marking. A SLOT rather than a string: what earns an overlay on a feed is a
|
|
114
|
+
* deployment's decision, and DSA Art. 26 marking belongs to whichever pair
|
|
115
|
+
* received the fact.
|
|
116
|
+
*/
|
|
117
|
+
readonly badgeOverlay?: ReactNode;
|
|
118
|
+
/** Hide the favourite entirely — for a surface where it makes no sense.
|
|
119
|
+
* NOT a way to hide it from visitors. */
|
|
120
|
+
readonly showFavorite?: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type ListingFeedCardProps = ListingFeedCardBaseProps & ListingCardOpenProps;
|
|
124
|
+
|
|
125
|
+
export function ListingFeedCard(props: ListingFeedCardProps): ReactElement {
|
|
126
|
+
const t = useT();
|
|
127
|
+
const { listing } = props;
|
|
128
|
+
const status =
|
|
129
|
+
listing.status === undefined ? undefined : lifecycleCaption(listing.status);
|
|
130
|
+
const title = listing.title ?? "";
|
|
131
|
+
const targetLabel =
|
|
132
|
+
title.length > 0 ? title : t(LISTINGS_I18N_KEYS.cardUntitled);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<SkinTheme
|
|
136
|
+
surface="bare"
|
|
137
|
+
{...(props.mode !== undefined ? { mode: props.mode } : {})}
|
|
138
|
+
>
|
|
139
|
+
<style href={CARD_TARGET_STYLE_HREF} precedence="default">
|
|
140
|
+
{cardTargetCss()}
|
|
141
|
+
</style>
|
|
142
|
+
<style href={FEED_CARD_STYLE_HREF} precedence="default">
|
|
143
|
+
{feedCardCss()}
|
|
144
|
+
</style>
|
|
145
|
+
<div
|
|
146
|
+
style={TILE}
|
|
147
|
+
data-testid="listings-feed-card"
|
|
148
|
+
data-listing-id={listing.id}
|
|
149
|
+
{...(status !== undefined
|
|
150
|
+
? { "data-listing-status": status.status }
|
|
151
|
+
: {})}
|
|
152
|
+
>
|
|
153
|
+
{/* One anchor over the whole tile. The photo is a still `<img>`, so
|
|
154
|
+
unlike the SERP card's swipeable strip it is safe inside a link —
|
|
155
|
+
and on a feed the picture IS the click. */}
|
|
156
|
+
<CardTarget
|
|
157
|
+
{...openProps(props)}
|
|
158
|
+
listingId={listing.id}
|
|
159
|
+
label={targetLabel}
|
|
160
|
+
testId="listings-feed-open"
|
|
161
|
+
bodyTestId="listings-feed-body"
|
|
162
|
+
>
|
|
163
|
+
<Flex vertical gap={spacing[1]}>
|
|
164
|
+
<ListingPhoto
|
|
165
|
+
imageRef={listing.images?.[0]}
|
|
166
|
+
alt={title.length > 0 ? title : String(listing.id)}
|
|
167
|
+
style={{ borderRadius: radii.lg }}
|
|
168
|
+
/>
|
|
169
|
+
|
|
170
|
+
{/* Title before price: on a feed a person is browsing, not
|
|
171
|
+
comparing — the ref's order, and the reverse of the SERP's. */}
|
|
172
|
+
<Typography.Text
|
|
173
|
+
className={FEED_TITLE_CLASS}
|
|
174
|
+
data-testid="listings-feed-title"
|
|
175
|
+
>
|
|
176
|
+
{title}
|
|
177
|
+
</Typography.Text>
|
|
178
|
+
|
|
179
|
+
<Typography.Text strong data-testid="listings-feed-price">
|
|
180
|
+
<ListingPrice
|
|
181
|
+
amount={listing.price}
|
|
182
|
+
{...(listing.currency !== undefined
|
|
183
|
+
? { currency: listing.currency }
|
|
184
|
+
: {})}
|
|
185
|
+
/>
|
|
186
|
+
</Typography.Text>
|
|
187
|
+
|
|
188
|
+
{listing.location_label !== undefined &&
|
|
189
|
+
listing.location_label.length > 0 ? (
|
|
190
|
+
<Typography.Text
|
|
191
|
+
type="secondary"
|
|
192
|
+
ellipsis
|
|
193
|
+
data-testid="listings-feed-location"
|
|
194
|
+
>
|
|
195
|
+
{listing.location_label}
|
|
196
|
+
</Typography.Text>
|
|
197
|
+
) : null}
|
|
198
|
+
</Flex>
|
|
199
|
+
</CardTarget>
|
|
200
|
+
|
|
201
|
+
{props.badgeOverlay !== undefined && (
|
|
202
|
+
<div style={BADGE} data-testid="listings-feed-badge">
|
|
203
|
+
{props.badgeOverlay}
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{/* Outside the anchor — a button inside a link is neither valid HTML
|
|
208
|
+
nor operable — and pinned rather than stacked. See the header. */}
|
|
209
|
+
{props.showFavorite === false ? null : (
|
|
210
|
+
<FavoriteHeart
|
|
211
|
+
listingId={listing.id}
|
|
212
|
+
favorited={listing.is_favorited}
|
|
213
|
+
testId="listings-feed-favorite"
|
|
214
|
+
style={HEART}
|
|
215
|
+
/>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
</SkinTheme>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The three-armed open union, narrowed out of this card's own props — see
|
|
223
|
+
* `<ListingSerpCard>`'s copy for why it is picked rather than spread. */
|
|
224
|
+
function openProps(props: ListingFeedCardProps): ListingCardOpenProps {
|
|
225
|
+
if (props.href !== undefined) {
|
|
226
|
+
return props.linkComponent !== undefined
|
|
227
|
+
? { href: props.href, linkComponent: props.linkComponent }
|
|
228
|
+
: { href: props.href };
|
|
229
|
+
}
|
|
230
|
+
if (props.onOpen !== undefined) return { onOpen: props.onOpen };
|
|
231
|
+
return {};
|
|
232
|
+
}
|