@stapel/listings-react 0.12.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.
@@ -50,21 +50,44 @@
50
50
  * do not import each other; the container is the seam. A library that picked
51
51
  * one would pick it for every host.
52
52
  *
53
- * ── On a narrow form the CHARACTERISTICS come before the photos ────────────
54
- *
55
- * The details of the chosen category are the questions only that category
56
- * asks, and they are the reason a category is chosen at all. On a wide form
57
- * they can sit after the photos, because the whole form is one screen. On a
58
- * 390px one they cannot: measured on a live classified deployment, choosing
59
- * Mobile phones grew the page to 7292px and put the first attribute control at
60
- * y=1596 nearly two viewports below the fold, behind a ~700px photo dropzone
61
- * while the footer said "10 required details not filled in" with none of
62
- * them on screen. So on a narrow form the section moves directly under the
63
- * category that produced it, and the photo dropzone follows it.
64
- *
65
- * The measurement is the FORM's own width (`useElementWidth`, the fleet's one
66
- * observer), not the viewport's: a composer is not a viewport, and a viewport
67
- * query would call a 360px pane on a desktop "wide".
53
+ * ── The SECTION ORDER is this component's, and it is not a prop ────────────
54
+ *
55
+ * One order, at every width:
56
+ *
57
+ * category title description price currency where photos
58
+ * the category's characteristics the listing's own options
59
+ *
60
+ * The category comes first because everything after it depends on the choice.
61
+ * Then the five questions a seller came to answer what it is, what it says,
62
+ * what it costs, where it is, what it looks like and only then the questions
63
+ * the CATEGORY asks.
64
+ *
65
+ * This used to be two orders chosen by the form's width, and the narrow one
66
+ * put the characteristics directly under the category. The reason was real:
67
+ * measured on a live classified deployment, Mobile phones put the first
68
+ * attribute control at y=1596, two viewports below the fold, while the footer
69
+ * said "10 required details not filled in" with none of them on screen. The
70
+ * fix was not: the same leaf carries 32 imported fields, so on the next
71
+ * measurement of the same form the seller was asked for the parcel's weight,
72
+ * its length and "what the goods are measured in" BEFORE the title — title
73
+ * y=5575, price y=5871, photos y=6245, of a 7308px form. A composer that asks
74
+ * about postage before it asks what is being sold has no funnel left.
75
+ *
76
+ * So the discoverability problem is solved where it actually lives, and by two
77
+ * things that do not move the questions around:
78
+ *
79
+ * - the count of unfilled details is a CONTROL that goes to the first of them
80
+ * (`listings-composer-goto-missing`, below), so "10 required details" is
81
+ * never a dead end wherever the region sits;
82
+ * - the region itself is short, because `groupCollapse="auto"` opens the
83
+ * groups that ask something required and leaves the plumbing (delivery
84
+ * dimensions, wholesale terms) closed under its own heading.
85
+ *
86
+ * `detailsPlacement` is therefore gone as a decision — `data-placement` stays
87
+ * on the region as a constant, because an e2e that measured the regression
88
+ * needs something to read that is not a pixel count. A host that wants a
89
+ * different order does not want this page; it wants `<FeatureFields>` and its
90
+ * own form, which is exactly why that component is exported separately.
68
91
  *
69
92
  * ── Every blocked control says which of six reasons it is ──────────────────
70
93
  *
@@ -76,7 +99,6 @@
76
99
  * would be told, and `<GatedButton>` renders the reason beside the button —
77
100
  * never a grey rectangle, never a hover.
78
101
  */
79
- import { useRef } from "react";
80
102
  import type { ComponentType, ReactElement, ReactNode } from "react";
81
103
  import {
82
104
  Alert,
@@ -94,7 +116,6 @@ import {
94
116
  GatedButton,
95
117
  PaneGate,
96
118
  SkinTheme,
97
- useElementWidth,
98
119
  } from "@stapel/tokens-antd/skin";
99
120
  import {
100
121
  SlotPlaceholder,
@@ -104,7 +125,7 @@ import {
104
125
  useI18n,
105
126
  useT,
106
127
  } from "@stapel/core";
107
- import { breakpoints, spacing } from "@stapel/tokens";
128
+ import { spacing } from "@stapel/tokens";
108
129
  import type { FeatureDef } from "@stapel/attributes-react";
109
130
  import {
110
131
  BUILTIN_VALUE_EDITOR_TYPES,
@@ -135,16 +156,14 @@ import type { ThemeModeProp } from "./types.js";
135
156
  export const COMPOSER_MEASURE = "44rem";
136
157
 
137
158
  /**
138
- * Below this FORM width the composer is a one-thumb column, and the order of
139
- * its sections has to change — see {@link ListingComposerPage}'s header on why
140
- * the characteristics move up.
141
- *
142
- * The `tablet` breakpoint, which is what "narrow" means everywhere else in the
143
- * skin, measured against the form's OWN width and never the viewport's (§83).
144
- * A composer drawn in a 360px settings pane on a 1440px desktop is narrow; a
145
- * viewport query would call it wide and bury its questions.
159
+ * What `data-placement` says on the characteristics region.
160
+ *
161
+ * A constant, and deliberately so: the order is the composer's and does not
162
+ * depend on the width any more (see the header). The attribute stays because
163
+ * an e2e suite measured the regression through it and needs a reading that is
164
+ * not a pixel count.
146
165
  */
147
- export const COMPOSER_STACKED_BELOW: number = breakpoints.tablet;
166
+ export const COMPOSER_DETAILS_PLACEMENT = "after-core-fields";
148
167
 
149
168
  /**
150
169
  * The DOM id of the control that answers one of the composer's own fields —
@@ -167,11 +186,18 @@ const FOCUSABLE =
167
186
  "input,select,textarea,button,[href],[tabindex]:not([tabindex='-1'])";
168
187
 
169
188
  /**
170
- * Put the person in front of one field: bring it into view, and focus what
171
- * they are meant to answer.
189
+ * Put the person in front of one field: open whatever it is folded inside,
190
+ * bring it into view, and focus what they are meant to answer.
191
+ *
192
+ * The disclosure step is the one that is easy to forget. A characteristic
193
+ * lives in a `<details>` section that may be closed (`groupCollapse="auto"`),
194
+ * and scrolling to a control inside a closed disclosure scrolls to nothing —
195
+ * the "take me to the first empty field" button would report success and move
196
+ * the page nowhere. Any depth of nesting is opened, because the answer to
197
+ * "where is it" must not depend on how the section was drawn.
172
198
  *
173
- * Both halves are guarded rather than assumed. `scrollIntoView` does not exist
174
- * in every environment this renders in (jsdom, older embedded engines), and a
199
+ * The rest is guarded rather than assumed. `scrollIntoView` does not exist in
200
+ * every environment this renders in (jsdom, older embedded engines), and a
175
201
  * field whose control came from a slot may have nothing focusable in it at
176
202
  * all — in which case scrolling to it is still the whole of the help that can
177
203
  * honestly be given.
@@ -180,6 +206,13 @@ function revealField(id: string): void {
180
206
  if (typeof document === "undefined") return;
181
207
  const anchor = document.getElementById(id);
182
208
  if (anchor === null) return;
209
+ for (
210
+ let folded = anchor.closest("details");
211
+ folded !== null;
212
+ folded = folded.parentElement?.closest("details") ?? null
213
+ ) {
214
+ folded.open = true;
215
+ }
183
216
  if (typeof anchor.scrollIntoView === "function") {
184
217
  anchor.scrollIntoView({ block: "center", behavior: "smooth" });
185
218
  }
@@ -483,15 +516,6 @@ export function ListingComposerPage(
483
516
  );
484
517
 
485
518
  const LocationPicker = props.locationPicker;
486
- // The FORM's width, which is what decides the section order — see the
487
- // header. `?? false` because an unmeasured box is not a narrow one (the rule
488
- // `useElementWidth` states for every caller): the wide order is the one that
489
- // reflows gracefully, so it is what the first frame draws.
490
- const form = useRef<HTMLDivElement>(null);
491
- const { below } = useElementWidth(form, {
492
- thresholds: { stacked: COMPOSER_STACKED_BELOW },
493
- });
494
- const stacked = below.stacked ?? false;
495
519
 
496
520
  /**
497
521
  * The characteristics of the chosen category — built once and rendered in
@@ -506,7 +530,7 @@ export function ListingComposerPage(
506
530
  const details = (
507
531
  <div
508
532
  data-testid="listings-composer-details"
509
- data-placement={stacked ? "after-category" : "after-photos"}
533
+ data-placement={COMPOSER_DETAILS_PLACEMENT}
510
534
  >
511
535
  <Divider />
512
536
  <Typography.Title level={5}>
@@ -539,6 +563,11 @@ export function ListingComposerPage(
539
563
  errors={bag.fieldErrors}
540
564
  disabled={bag.publishing}
541
565
  onChange={bag.setFeature}
566
+ // An imported leaf is mostly plumbing: 32 fields across seven
567
+ // headings, four of them parcel dimensions. Open what is required or
568
+ // already answered, leave the rest one tap away under its own
569
+ // heading — see `FeatureGroupCollapse`.
570
+ groupCollapse="auto"
542
571
  />
543
572
  )}
544
573
  </div>
@@ -603,11 +632,6 @@ export function ListingComposerPage(
603
632
  />
604
633
  ) : null}
605
634
 
606
- {/* The measured box is the FORM, so a host that renders this page in
607
- a narrow pane gets the narrow order without the viewport agreeing.
608
- A plain block wrapper: antd's `Form` forwards its ref to the form
609
- INSTANCE, not to a node. */}
610
- <div ref={form}>
611
635
  <Form layout="vertical" data-testid="listings-composer-form">
612
636
  <SlotField
613
637
  label={t(LISTINGS_I18N_KEYS.composeCategory)}
@@ -634,10 +658,6 @@ export function ListingComposerPage(
634
658
  />
635
659
  ) : null}
636
660
 
637
- {/* Narrow: the category's own questions, directly under the choice
638
- that produced them and above the photo dropzone. */}
639
- {stacked ? details : null}
640
-
641
661
  <Form.Item
642
662
  label={t(LISTINGS_I18N_KEYS.composeTitleLabel)}
643
663
  {...errorOf(TITLE_FIELD)}
@@ -788,9 +808,9 @@ export function ListingComposerPage(
788
808
  </>
789
809
  ) : null}
790
810
 
791
- {/* Wide: the whole form is one screen, so the details keep their
792
- place after the photos. */}
793
- {stacked ? null : details}
811
+ {/* The category's own questions, after the five the seller came to
812
+ answer. See the header on why this is one order and not two. */}
813
+ {details}
794
814
 
795
815
  <Divider />
796
816
 
@@ -832,7 +852,6 @@ export function ListingComposerPage(
832
852
  </Checkbox>
833
853
  </Form.Item>
834
854
  </Form>
835
- </div>
836
855
 
837
856
  {bag.refusal?.kind === "invalid_draft" ? (
838
857
  <ErrorAlert
@@ -25,6 +25,7 @@ import type { CSSProperties, ReactElement } from "react";
25
25
  import { useMemo } from "react";
26
26
  import { Typography, theme as antdTheme } from "antd";
27
27
  import { Image } from "@stapel/image";
28
+ import { SkinCarousel } from "@stapel/tokens-antd/skin";
28
29
  import { useT } from "@stapel/core";
29
30
  import { radii, spacing } from "@stapel/tokens";
30
31
  import { useListingsRuntime } from "../model/context.js";
@@ -133,3 +134,66 @@ export function ListingPhoto(props: ListingPhotoProps): ReactElement {
133
134
  />
134
135
  );
135
136
  }
137
+
138
+ /**
139
+ * A listing's photos as ONE swipeable strip — the shape every card surface in
140
+ * this pair uses, so there is one gallery and not three.
141
+ *
142
+ * ── Why the strip is never inside a card's anchor ──────────────────────────
143
+ *
144
+ * A `<SkinCarousel>` is a scroll container with its own tab stop, and a
145
+ * horizontal swipe that ends inside an `<a>` is a swipe the browser may
146
+ * deliver as a click: every attempt to look at photo two would open the
147
+ * listing. A link may not contain a control, and a swipeable strip is a
148
+ * control. So a card renders this as a SIBLING of its anchor and keeps the
149
+ * anchor around everything a person READS — which is the arrangement
150
+ * `<ListingSerpCard>` has shipped since it existed, and the one the live phone
151
+ * SERP was measured correct on while the desktop card (a still `<img>` inside
152
+ * the anchor, one photo, no dots) was not.
153
+ *
154
+ * A listing with no photos still gets one slide, so a row's height does not
155
+ * depend on whether a seller uploaded anything, and a ONE-photo strip gets
156
+ * neither peek nor dots: the sliver of a next slide is an affordance for
157
+ * something that is there.
158
+ */
159
+ export function ListingPhotoStrip(props: {
160
+ /** The stored references, in the seller's order. */
161
+ readonly images: readonly string[];
162
+ /** The listing's title — the fallback alt for a single photo. */
163
+ readonly title: string;
164
+ /** The surface's own test id, so a screen holding two kinds of card does
165
+ * not hand a test two elements under one name. */
166
+ readonly testId: string;
167
+ }): ReactElement {
168
+ const t = useT();
169
+ const { images, title } = props;
170
+ const many = images.length > 1;
171
+ return (
172
+ <SkinCarousel
173
+ label={t(LISTINGS_I18N_KEYS.cardPhotos)}
174
+ aspectRatio={LISTING_PHOTO_ASPECT}
175
+ peek={many}
176
+ dots={many}
177
+ data-testid={props.testId}
178
+ >
179
+ {images.length === 0 ? (
180
+ <ListingPhoto imageRef={undefined} alt={title} />
181
+ ) : (
182
+ images.map((reference, index) => (
183
+ <ListingPhoto
184
+ key={reference}
185
+ imageRef={reference}
186
+ alt={
187
+ many
188
+ ? t(LISTINGS_I18N_KEYS.detailPhotoAlt, {
189
+ index: index + 1,
190
+ total: images.length,
191
+ })
192
+ : title
193
+ }
194
+ />
195
+ ))
196
+ )}
197
+ </SkinCarousel>
198
+ );
199
+ }
@@ -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,
@@ -57,8 +63,8 @@ export { ListingDetailPane, DETAIL_MEASURE, DETAIL_PHOTO_MIN } from "./ListingDe
57
63
  export type { ListingDetailPaneProps } from "./ListingDetailPane.js";
58
64
  export {
59
65
  ListingComposerPage,
66
+ COMPOSER_DETAILS_PLACEMENT,
60
67
  COMPOSER_MEASURE,
61
- COMPOSER_STACKED_BELOW,
62
68
  composerFieldId,
63
69
  } from "./ListingComposerPage.js";
64
70
  export type {
@@ -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";