@stapel/listings-react 0.26.0 → 0.28.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 +129 -0
- package/MODULE.md +3 -1
- package/README.md +102 -8
- package/dist/api/generated/schema.d.ts +20 -10
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/ListingActions.d.ts +8 -1
- package/dist/default/ListingActions.d.ts.map +1 -1
- package/dist/default/ListingActions.js +1 -1
- package/dist/default/ListingActions.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +148 -9
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +131 -19
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +10 -6
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/ShareAction.d.ts +16 -1
- package/dist/default/ShareAction.d.ts.map +1 -1
- package/dist/default/ShareAction.js +16 -9
- package/dist/default/ShareAction.js.map +1 -1
- package/dist/default/actionRow.d.ts +24 -0
- package/dist/default/actionRow.d.ts.map +1 -1
- package/dist/default/actionRow.js +50 -7
- package/dist/default/actionRow.js.map +1 -1
- package/dist/default/detailGallery.d.ts +77 -0
- package/dist/default/detailGallery.d.ts.map +1 -0
- package/dist/default/detailGallery.js +91 -0
- package/dist/default/detailGallery.js.map +1 -0
- package/dist/default/index.d.ts +4 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +5 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/movableCluster.d.ts +18 -0
- package/dist/default/movableCluster.d.ts.map +1 -0
- package/dist/default/movableCluster.js +110 -0
- package/dist/default/movableCluster.js.map +1 -0
- package/dist/headless/MyListings.d.ts +31 -10
- package/dist/headless/MyListings.d.ts.map +1 -1
- package/dist/headless/MyListings.js +19 -2
- package/dist/headless/MyListings.js.map +1 -1
- package/dist/headless/Share.d.ts +60 -2
- package/dist/headless/Share.d.ts.map +1 -1
- package/dist/headless/Share.js +55 -3
- package/dist/headless/Share.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/model/cardBadges.d.ts +25 -0
- package/dist/model/cardBadges.d.ts.map +1 -1
- package/dist/model/cardBadges.js +41 -5
- package/dist/model/cardBadges.js.map +1 -1
- package/dist/model/draft.d.ts +42 -1
- package/dist/model/draft.d.ts.map +1 -1
- package/dist/model/draft.js +40 -3
- package/dist/model/draft.js.map +1 -1
- package/dist/model/status.d.ts +31 -16
- package/dist/model/status.d.ts.map +1 -1
- package/dist/model/status.js +30 -15
- package/dist/model/status.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +8 -1
- package/nav-manifest.json +1 -1
- package/package.json +3 -3
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +20 -10
- package/src/default/ListingActions.tsx +9 -1
- package/src/default/ListingDetailPane.tsx +316 -18
- package/src/default/MyListingsPane.tsx +10 -6
- package/src/default/ShareAction.tsx +36 -10
- package/src/default/actionRow.ts +55 -7
- package/src/default/detailGallery.ts +97 -0
- package/src/default/index.ts +15 -1
- package/src/default/movableCluster.tsx +137 -0
- package/src/headless/MyListings.tsx +52 -12
- package/src/headless/Share.tsx +98 -5
- package/src/index.ts +7 -0
- package/src/model/cardBadges.ts +42 -5
- package/src/model/draft.ts +61 -3
- package/src/model/status.ts +31 -16
|
@@ -49,8 +49,34 @@
|
|
|
49
49
|
* as CategoryPage's `subcategories`: a decision taken once by the component
|
|
50
50
|
* that knows the viewport it granted, never a media query guessed in a leaf —
|
|
51
51
|
* and the default `"column"` renders exactly what existing hosts already get.
|
|
52
|
+
*
|
|
53
|
+
* ── The reader's cluster can be in TWO places, and is ONE thing ────────────
|
|
54
|
+
*
|
|
55
|
+
* A phone reads this page over four screens. Past the first, the reference
|
|
56
|
+
* classified draws a condensed bar — back, the title, and the two verbs — and
|
|
57
|
+
* a container building one had to mount a `<ListingActions>` of its own,
|
|
58
|
+
* because `actionsPlacement` took a single value and the pane exposed no
|
|
59
|
+
* target for the cluster it builds. That second mount is a second
|
|
60
|
+
* `useFavoriteToggle` on one page: two hearts that agree only after a refetch,
|
|
61
|
+
* two `aria-pressed` controls, and a second set of test ids kept in step by
|
|
62
|
+
* hand so the pane's own stayed single.
|
|
63
|
+
*
|
|
64
|
+
* `actionsPlacement={["header", "bar"]}` + `renderActionsBar` is the answer,
|
|
65
|
+
* and the shape is deliberate: the render prop is handed a MOUNT POINT, not
|
|
66
|
+
* the cluster. `<ListingActions>` is rendered once through a portal and the
|
|
67
|
+
* portal's container is moved between the two slots as a DOM node, so the
|
|
68
|
+
* component mounts once, holds one hook, and is literally the same element in
|
|
69
|
+
* both places (`movableCluster.tsx` has the argument; the test holds the
|
|
70
|
+
* favourite across the move and compares identity). A render prop handed the
|
|
71
|
+
* cluster's ELEMENT would have read the same at a call site and mounted twice,
|
|
72
|
+
* which is the defect with the pair's name on it.
|
|
73
|
+
*
|
|
74
|
+
* `onTitleVisible` is the other half: the same container watched the pane's
|
|
75
|
+
* `<h1>` through its published test id and a `MutationObserver`, for a
|
|
76
|
+
* boolean the pane already knows. It is an `IntersectionObserver` on the
|
|
77
|
+
* title, never a scroll listener.
|
|
52
78
|
*/
|
|
53
|
-
import { isValidElement } from "react";
|
|
79
|
+
import { isValidElement, useCallback, useEffect, useRef } from "react";
|
|
54
80
|
import type { ReactElement, ReactNode } from "react";
|
|
55
81
|
import { Descriptions, Divider, Flex, Typography, theme as antdTheme } from "antd";
|
|
56
82
|
import { SkinButton as Button } from "@stapel/tokens-antd/skin";
|
|
@@ -76,11 +102,18 @@ import { useListingActions } from "../headless/ListingActions.js";
|
|
|
76
102
|
import { asFeatureDaoList, featureValuesForDisplay } from "../model/features.js";
|
|
77
103
|
import { formatSpecValue } from "../model/featureText.js";
|
|
78
104
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
79
|
-
import type { ShareChannel } from "../headless/Share.js";
|
|
105
|
+
import type { ShareChannel, SharePreference } from "../headless/Share.js";
|
|
80
106
|
import { GateReasonPopover } from "./GateReasonPopover.js";
|
|
81
107
|
import { ListingActions } from "./ListingActions.js";
|
|
82
108
|
import type { ListingActionsConfig } from "./ListingActions.js";
|
|
83
109
|
import { LISTING_ACTION_CLASS } from "./actionRow.js";
|
|
110
|
+
import {
|
|
111
|
+
LISTINGS_GALLERY_CLASS,
|
|
112
|
+
LISTINGS_GALLERY_STYLE_HREF,
|
|
113
|
+
detailGalleryCss,
|
|
114
|
+
} from "./detailGallery.js";
|
|
115
|
+
import type { ListingGalleryLayout } from "./detailGallery.js";
|
|
116
|
+
import { useMovableCluster } from "./movableCluster.js";
|
|
84
117
|
import { useNotice } from "./notice.js";
|
|
85
118
|
import { ListingSpecColumns, ListingSpecList } from "./ListingSpecList.js";
|
|
86
119
|
import { SignInLink } from "./SignInLink.js";
|
|
@@ -113,12 +146,9 @@ export const DETAIL_SPLIT_MEASURE = "75rem";
|
|
|
113
146
|
*/
|
|
114
147
|
export const DETAIL_SPLIT_ASIDE = "380px";
|
|
115
148
|
|
|
116
|
-
/**
|
|
117
|
-
*
|
|
118
|
-
|
|
119
|
-
* desktop pane, and no `width: 320` that is near-full-bleed on one and a
|
|
120
|
-
* postage stamp on the other. */
|
|
121
|
-
export const DETAIL_PHOTO_MIN = "14rem";
|
|
149
|
+
/** Re-exported: the tile floor is declared beside the track it feeds, in
|
|
150
|
+
* `detailGallery.ts`. */
|
|
151
|
+
export { DETAIL_PHOTO_MIN } from "./detailGallery.js";
|
|
122
152
|
|
|
123
153
|
/**
|
|
124
154
|
* THE GUTTER BETWEEN TWO PHOTOGRAPHS, and it is the page's own (D418).
|
|
@@ -241,6 +271,14 @@ export interface ListingDetailPaneProps
|
|
|
241
271
|
* somebody sends to a friend. See `useShare`.
|
|
242
272
|
*/
|
|
243
273
|
readonly shareUrl?: string;
|
|
274
|
+
/**
|
|
275
|
+
* Which arm the share control uses — handed to `<ShareAction prefer>`
|
|
276
|
+
* through the cluster. Default `"auto"`: the platform sheet where the
|
|
277
|
+
* primary pointer is coarse, this pair's menu on a mouse. See
|
|
278
|
+
* `SharePreference` for the measurement that made the pointer part of the
|
|
279
|
+
* question (§25).
|
|
280
|
+
*/
|
|
281
|
+
readonly sharePrefer?: SharePreference;
|
|
244
282
|
/** Analytics: which channel a completed share went through. */
|
|
245
283
|
readonly onShared?: (channel: ShareChannel) => void;
|
|
246
284
|
/**
|
|
@@ -262,9 +300,96 @@ export interface ListingDetailPaneProps
|
|
|
262
300
|
* photo counter owns the bottom trailing corner;
|
|
263
301
|
* - `"buy-box"` — inside `listings-detail-actions`, beside "message the
|
|
264
302
|
* seller", which is where the favourite alone used to live. The escape
|
|
265
|
-
* hatch for a host whose page was laid out around it
|
|
303
|
+
* hatch for a host whose page was laid out around it;
|
|
304
|
+
* - `"bar"` — the condensed top bar the host draws through
|
|
305
|
+
* {@link renderActionsBar}. Only meaningful in a LIST beside one of the
|
|
306
|
+
* three above, and only with that render prop: it names a second place
|
|
307
|
+
* the one cluster may travel to, never a home of its own.
|
|
308
|
+
*
|
|
309
|
+
* A LIST is how a host says "both": `["header", "bar"]` keeps the cluster
|
|
310
|
+
* beside the title and lends it to the bar for as long as the bar is on
|
|
311
|
+
* screen. Exactly one home placement is honoured — the first non-`"bar"`
|
|
312
|
+
* entry — because two homes would need two instances, which is the defect
|
|
313
|
+
* this closes rather than the feature it adds.
|
|
314
|
+
*/
|
|
315
|
+
readonly actionsPlacement?:
|
|
316
|
+
| ListingActionsPlacement
|
|
317
|
+
| readonly ListingActionsPlacement[];
|
|
318
|
+
/**
|
|
319
|
+
* THE SAME CLUSTER, IN A SECOND PLACE — a condensed bar, typically.
|
|
320
|
+
*
|
|
321
|
+
* Called with the bar's MOUNT POINT, not with a copy of the cluster: return
|
|
322
|
+
* it wrapped in whatever chrome the bar is (`position: fixed`, a back arrow,
|
|
323
|
+
* the title), and the pane moves its one `<ListingActions>` into it. Return
|
|
324
|
+
* `null` while the bar is not on screen and the cluster goes back where it
|
|
325
|
+
* came from — the same DOM node, the same hooks, an optimistic favourite
|
|
326
|
+
* still in flight uninterrupted.
|
|
327
|
+
*
|
|
328
|
+
* Requires `"bar"` in {@link actionsPlacement}. Pair it with
|
|
329
|
+
* {@link onTitleVisible} for the usual rule: the bar appears when the title
|
|
330
|
+
* leaves the fold.
|
|
331
|
+
*
|
|
332
|
+
* ```tsx
|
|
333
|
+
* <ListingDetailPane
|
|
334
|
+
* actionsPlacement={["header", "bar"]}
|
|
335
|
+
* onTitleVisible={(visible) => { setBarShown(!visible); }}
|
|
336
|
+
* renderActionsBar={(cluster) =>
|
|
337
|
+
* barShown ? <div className="topbar">{back}{title}{cluster}</div> : null
|
|
338
|
+
* }
|
|
339
|
+
* />
|
|
340
|
+
* ```
|
|
341
|
+
*
|
|
342
|
+
* A container that mounted its own second `<ListingActions>` for this can
|
|
343
|
+
* delete it: two `useFavoriteToggle` instances on one page, two hearts that
|
|
344
|
+
* agree only after a refetch, and a second set of test ids to keep the
|
|
345
|
+
* pane's own single are all what this prop exists to end.
|
|
266
346
|
*/
|
|
267
|
-
readonly
|
|
347
|
+
readonly renderActionsBar?: (cluster: ReactNode) => ReactNode;
|
|
348
|
+
/**
|
|
349
|
+
* IS THE TITLE STILL IN THE FOLD?
|
|
350
|
+
*
|
|
351
|
+
* An `IntersectionObserver` on the pane's own `<h1>` — never a `scroll`
|
|
352
|
+
* listener, which asks the question on every frame of a page whose job is
|
|
353
|
+
* scrolling photographs and answers it no better. Called on each crossing
|
|
354
|
+
* and not on every scroll: `false` when the title leaves, `true` when it
|
|
355
|
+
* comes back.
|
|
356
|
+
*
|
|
357
|
+
* It exists because the chrome a host hangs on this had no way to ask. A
|
|
358
|
+
* container drawing a condensed bar found the title by the pane's published
|
|
359
|
+
* `data-testid` and waited for it with a `MutationObserver`, because the
|
|
360
|
+
* title lands with the listing and not with the first frame — a private
|
|
361
|
+
* selector and a subscription, for a boolean the pane already knows.
|
|
362
|
+
*
|
|
363
|
+
* NOT called at all where the environment has no `IntersectionObserver`:
|
|
364
|
+
* the honest answer there is "this page cannot tell", and a fabricated
|
|
365
|
+
* `true` would leave a host's bar wedged open on the arm that has no
|
|
366
|
+
* scrolling anyway.
|
|
367
|
+
*
|
|
368
|
+
* Pass a STABLE function (a `useState` setter, a `useCallback`). The
|
|
369
|
+
* observer is created once for the title node and reads the latest callback
|
|
370
|
+
* through a ref, so an inline arrow works and does not re-observe.
|
|
371
|
+
*/
|
|
372
|
+
readonly onTitleVisible?: (visible: boolean) => void;
|
|
373
|
+
/**
|
|
374
|
+
* WHAT SHAPE THE PHOTOGRAPHS ARE IN.
|
|
375
|
+
*
|
|
376
|
+
* - `"grid"` (default) — the element-width grid this pane has always drawn,
|
|
377
|
+
* `repeat(auto-fit, minmax(14rem, 1fr))`: three tiles across a desktop
|
|
378
|
+
* pane, one across a phone;
|
|
379
|
+
* - `"strip"` — a snap-scrolling horizontal strip, one photograph visible
|
|
380
|
+
* with the next peeking. On a 390px phone the grid resolves to one
|
|
381
|
+
* column, so a listing with three pictures pushes its own title and price
|
|
382
|
+
* nearly three screens down — the first thing a person sees after tapping
|
|
383
|
+
* a search result is a photograph with nothing beside it.
|
|
384
|
+
*
|
|
385
|
+
* The HOST names it, the same rule as {@link layout} and for the same
|
|
386
|
+
* reason: the side that knows the viewport it granted decides, and no media
|
|
387
|
+
* query is guessed in a leaf. A live storefront was carrying
|
|
388
|
+
* `display: flex !important` against this pane's inline `display: grid` to
|
|
389
|
+
* say exactly this; that declaration is a class now, so even a host wanting
|
|
390
|
+
* a third shape needs a selector rather than an `!important`.
|
|
391
|
+
*/
|
|
392
|
+
readonly galleryLayout?: ListingGalleryLayout;
|
|
268
393
|
/**
|
|
269
394
|
* The container's sign-in door, rendered beside the favourite's refusal —
|
|
270
395
|
* the same `SignInCta` seam the three card skins already take. The pane was
|
|
@@ -295,9 +420,128 @@ export interface ListingDetailPaneProps
|
|
|
295
420
|
* Default `3`, byte-compatible for every existing mount.
|
|
296
421
|
*/
|
|
297
422
|
readonly headingLevel?: 1 | 2 | 3;
|
|
423
|
+
/**
|
|
424
|
+
* WHERE THE BUY COLUMN'S STICKY TOP EDGE IS — the offset of whatever chrome
|
|
425
|
+
* is pinned above this page (`layout="split"` only; the one-column arm has
|
|
426
|
+
* no sticky column).
|
|
427
|
+
*
|
|
428
|
+
* ```tsx
|
|
429
|
+
* // the height <PublicShell> publishes, read rather than restated
|
|
430
|
+
* <ListingDetailPane layout="split" buyTop="var(--stapel-header-height)" />
|
|
431
|
+
* ```
|
|
432
|
+
*
|
|
433
|
+
* The column is `position: sticky; top: 16px` written INLINE, and an inline
|
|
434
|
+
* declaration is beaten by nothing short of `!important` — so a host with a
|
|
435
|
+
* pinned header had no way to say "start below it". Measured on the stand
|
|
436
|
+
* (D456): the storefront's header is sticky and 64px tall, and at any scroll
|
|
437
|
+
* depth the top of the buy column — the price's own first twenty pixels —
|
|
438
|
+
* sat UNDER it. Same seam and same argument as `<SearchPage railTop>`, which
|
|
439
|
+
* this prop is deliberately spelled after.
|
|
440
|
+
*
|
|
441
|
+
* A number is pixels; a string is taken as written (a `var()`, a `calc()`,
|
|
442
|
+
* `"4rem"`). Default `spacing[4]` — 16px, exactly where the column has
|
|
443
|
+
* always started — so an existing mount is byte-compatible.
|
|
444
|
+
*/
|
|
445
|
+
readonly buyTop?: number | string;
|
|
446
|
+
/**
|
|
447
|
+
* HOW WIDE THE PANE MAY GET — the `max-width` it writes on its own root.
|
|
448
|
+
*
|
|
449
|
+
* Default is the constant for the arm on screen: {@link DETAIL_MEASURE}
|
|
450
|
+
* (60rem) in `"column"`, {@link DETAIL_SPLIT_MEASURE} (75rem) in `"split"`,
|
|
451
|
+
* so no existing mount changes shape. Anything CSS `max-width` takes is
|
|
452
|
+
* accepted (`"80rem"`, `1280`, `"100%"`).
|
|
453
|
+
*
|
|
454
|
+
* `"none"` removes the cap, and it is the answer for a pane mounted inside
|
|
455
|
+
* a page frame that already decided the measure. That case is not
|
|
456
|
+
* hypothetical: measured on the stand at 1440 (D457), the listing page's
|
|
457
|
+
* content ended at x=1224 with 216px of empty gutter beside it while every
|
|
458
|
+
* other page of the same site ran to the frame's edge — a second, lower cap
|
|
459
|
+
* inside a container that already had one. The cap is written INLINE, so
|
|
460
|
+
* the container could not outrank it without `!important` and reached for
|
|
461
|
+
* `min-inline-size: 100%` instead (a minimum beats a maximum by the sizing
|
|
462
|
+
* rules); this prop is that workaround's replacement, and the same seam
|
|
463
|
+
* `<CategoryPage measure>` already offers.
|
|
464
|
+
*/
|
|
465
|
+
readonly measure?: number | string;
|
|
298
466
|
readonly footer?: ReactNode;
|
|
299
467
|
}
|
|
300
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Where the reader's cluster may sit. Three homes and one loan — see
|
|
471
|
+
* {@link ListingDetailPaneProps.actionsPlacement}.
|
|
472
|
+
*/
|
|
473
|
+
export type ListingActionsPlacement =
|
|
474
|
+
| "header"
|
|
475
|
+
| "gallery"
|
|
476
|
+
| "buy-box"
|
|
477
|
+
| "bar";
|
|
478
|
+
|
|
479
|
+
/** The cluster's HOME: the first entry that is not the borrowed bar. */
|
|
480
|
+
function homePlacement(
|
|
481
|
+
placement: ListingDetailPaneProps["actionsPlacement"]
|
|
482
|
+
): Exclude<ListingActionsPlacement, "bar"> {
|
|
483
|
+
if (placement === undefined) return "header";
|
|
484
|
+
if (typeof placement === "string") {
|
|
485
|
+
// `"bar"` alone names no home — the cluster still has to live somewhere
|
|
486
|
+
// while the bar is off screen, and that somewhere is the default.
|
|
487
|
+
return placement === "bar" ? "header" : placement;
|
|
488
|
+
}
|
|
489
|
+
for (const one of placement) {
|
|
490
|
+
if (one !== "bar") return one;
|
|
491
|
+
}
|
|
492
|
+
return "header";
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Did the host ask for the borrowed placement at all? */
|
|
496
|
+
function wantsBar(
|
|
497
|
+
placement: ListingDetailPaneProps["actionsPlacement"]
|
|
498
|
+
): boolean {
|
|
499
|
+
if (placement === undefined) return false;
|
|
500
|
+
if (typeof placement === "string") return placement === "bar";
|
|
501
|
+
return placement.includes("bar");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Priorities for the two mount points: the bar wins while it is on screen. */
|
|
505
|
+
const CLUSTER_HOME = 0;
|
|
506
|
+
const CLUSTER_BAR = 1;
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* The pane's own title, watched — see
|
|
510
|
+
* {@link ListingDetailPaneProps.onTitleVisible}.
|
|
511
|
+
*
|
|
512
|
+
* Returns a callback ref for the heading element. The observer is created once
|
|
513
|
+
* per node and disconnected by React 19's ref cleanup; the host's callback is
|
|
514
|
+
* read through a ref at call time, so an inline arrow does not re-observe on
|
|
515
|
+
* every render of a page that re-renders on every query update.
|
|
516
|
+
*/
|
|
517
|
+
function useTitleVisibility(
|
|
518
|
+
onTitleVisible: ((visible: boolean) => void) | undefined
|
|
519
|
+
): (node: HTMLElement | null) => (() => void) | undefined {
|
|
520
|
+
const latest = useRef(onTitleVisible);
|
|
521
|
+
useEffect(() => {
|
|
522
|
+
latest.current = onTitleVisible;
|
|
523
|
+
});
|
|
524
|
+
const wanted = onTitleVisible !== undefined;
|
|
525
|
+
return useCallback(
|
|
526
|
+
(node: HTMLElement | null): (() => void) | undefined => {
|
|
527
|
+
if (node === null || !wanted) return undefined;
|
|
528
|
+
// No observer, no answer. A fabricated `true` would wedge a host's bar
|
|
529
|
+
// open on an arm that has no scrolling to close it with.
|
|
530
|
+
if (typeof IntersectionObserver === "undefined") return undefined;
|
|
531
|
+
const observer = new IntersectionObserver((entries) => {
|
|
532
|
+
const entry = entries[entries.length - 1];
|
|
533
|
+
if (entry === undefined) return;
|
|
534
|
+
latest.current?.(entry.isIntersecting);
|
|
535
|
+
});
|
|
536
|
+
observer.observe(node);
|
|
537
|
+
return () => {
|
|
538
|
+
observer.disconnect();
|
|
539
|
+
};
|
|
540
|
+
},
|
|
541
|
+
[wanted]
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
301
545
|
/**
|
|
302
546
|
* Which arm of `actions` this is.
|
|
303
547
|
*
|
|
@@ -335,7 +579,18 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
335
579
|
// leaves rendering the reason to `<GatedControl>`, which computes its own.
|
|
336
580
|
const favoriteView = useActionGate(bag.favoriteGate);
|
|
337
581
|
const split = props.layout === "split";
|
|
338
|
-
const placement = props.actionsPlacement
|
|
582
|
+
const placement = homePlacement(props.actionsPlacement);
|
|
583
|
+
/* THE SECOND PLACEMENT IS A LOAN, NOT A COPY. Both halves have to be asked
|
|
584
|
+
for: `"bar"` in the placement list says the cluster may travel, and
|
|
585
|
+
`renderActionsBar` is the only thing that can put it anywhere. With
|
|
586
|
+
neither — every existing mount — nothing below changes: one cluster,
|
|
587
|
+
rendered inline where it always was, no portal and no slot divs. */
|
|
588
|
+
const galleryLayout: ListingGalleryLayout = props.galleryLayout ?? "grid";
|
|
589
|
+
const renderBar = props.renderActionsBar;
|
|
590
|
+
const barred = wantsBar(props.actionsPlacement) && renderBar !== undefined;
|
|
591
|
+
const movable = useMovableCluster(barred);
|
|
592
|
+
const moving = barred && movable.portable;
|
|
593
|
+
const titleRef = useTitleVisibility(props.onTitleVisible);
|
|
339
594
|
// The two arms of `actions` — see `isActionsConfig`.
|
|
340
595
|
const actionsConfig: ListingActionsConfig | undefined = isActionsConfig(
|
|
341
596
|
props.actions
|
|
@@ -388,7 +643,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
388
643
|
<SkinTheme
|
|
389
644
|
surface="base"
|
|
390
645
|
style={{
|
|
391
|
-
maxWidth: split ? DETAIL_SPLIT_MEASURE : DETAIL_MEASURE,
|
|
646
|
+
maxWidth: props.measure ?? (split ? DETAIL_SPLIT_MEASURE : DETAIL_MEASURE),
|
|
392
647
|
// See `gutter`: a frame that already placed the page edge does not get
|
|
393
648
|
// a second one stacked inside it.
|
|
394
649
|
padding: props.gutter === "shell" ? 0 : spacing[4],
|
|
@@ -566,18 +821,55 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
566
821
|
{...(listing.title !== undefined && listing.title !== null
|
|
567
822
|
? { shareTitle: listing.title }
|
|
568
823
|
: {})}
|
|
824
|
+
{...(props.sharePrefer !== undefined
|
|
825
|
+
? { sharePrefer: props.sharePrefer }
|
|
826
|
+
: {})}
|
|
569
827
|
{...(props.onShared !== undefined ? { onShared: props.onShared } : {})}
|
|
570
828
|
/>
|
|
571
829
|
);
|
|
572
830
|
|
|
831
|
+
/* What the PAGE draws where the cluster lives. With the bar in
|
|
832
|
+
play that is a slot and not the cluster itself: the cluster is
|
|
833
|
+
rendered once into `clusterLayer` below and travels between the
|
|
834
|
+
two slots as a DOM node, so it mounts once, holds one
|
|
835
|
+
`useFavoriteToggle`, and keeps its element identity across the
|
|
836
|
+
move. Without it, the cluster is drawn inline exactly as it has
|
|
837
|
+
always been. */
|
|
838
|
+
const homeActions = moving
|
|
839
|
+
? movable.slot(CLUSTER_HOME, placement)
|
|
840
|
+
: readerActions;
|
|
841
|
+
|
|
842
|
+
/* The one instance, plus the host's bar around the slot that may
|
|
843
|
+
borrow it. Rendered at the end of the page's own flow: the
|
|
844
|
+
portal has no position of its own (its content is wherever the
|
|
845
|
+
winning slot is), and a bar is `position: fixed` chrome whose
|
|
846
|
+
place in the document order is not its place on the screen. */
|
|
847
|
+
const clusterLayer =
|
|
848
|
+
!moving || renderBar === undefined ? null : (
|
|
849
|
+
<>
|
|
850
|
+
{movable.render(readerActions)}
|
|
851
|
+
{/* NOT a slot with a silent absence: `renderBar` is the only
|
|
852
|
+
thing that makes `moving` true, so this arm is
|
|
853
|
+
unreachable without one and the host's own `null` (the
|
|
854
|
+
bar off screen) is the answer that sends the cluster
|
|
855
|
+
home. There is no hole to place a `<SlotPlaceholder>` in
|
|
856
|
+
— the cluster is at its primary placement instead. */}
|
|
857
|
+
{renderBar(movable.slot(CLUSTER_BAR, "bar"))}
|
|
858
|
+
</>
|
|
859
|
+
);
|
|
860
|
+
|
|
573
861
|
/* Element-width tiles: the grid decides how many fit, the
|
|
574
862
|
photos fill them. */
|
|
575
863
|
const gallery = (
|
|
576
864
|
<div
|
|
577
865
|
data-testid="listings-detail-gallery"
|
|
866
|
+
className={LISTINGS_GALLERY_CLASS}
|
|
867
|
+
// The layout is a CLASS and an attribute, not an inline
|
|
868
|
+
// `display`: a host with a shape neither arm offers can then
|
|
869
|
+
// write CSS for it at its own breakpoints without `!important`
|
|
870
|
+
// over a pair's own geometry. See `detailGallery.ts`.
|
|
871
|
+
data-gallery-layout={galleryLayout}
|
|
578
872
|
style={{
|
|
579
|
-
display: "grid",
|
|
580
|
-
gridTemplateColumns: `repeat(auto-fit, minmax(${DETAIL_PHOTO_MIN}, 1fr))`,
|
|
581
873
|
// The page's own edge, per breakpoint — see
|
|
582
874
|
// `DETAIL_GALLERY_GUTTER` (D418).
|
|
583
875
|
gap: DETAIL_GALLERY_GUTTER,
|
|
@@ -587,6 +879,9 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
587
879
|
position: "relative",
|
|
588
880
|
}}
|
|
589
881
|
>
|
|
882
|
+
<style href={LISTINGS_GALLERY_STYLE_HREF} precedence="default">
|
|
883
|
+
{detailGalleryCss()}
|
|
884
|
+
</style>
|
|
590
885
|
{bag.images.length === 0 ? (
|
|
591
886
|
<ListingPhoto
|
|
592
887
|
imageRef={undefined}
|
|
@@ -604,7 +899,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
604
899
|
/>
|
|
605
900
|
))
|
|
606
901
|
)}
|
|
607
|
-
{placement === "gallery" ?
|
|
902
|
+
{placement === "gallery" ? homeActions : null}
|
|
608
903
|
</div>
|
|
609
904
|
);
|
|
610
905
|
|
|
@@ -621,12 +916,13 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
621
916
|
<Flex align="flex-start" justify="space-between" gap={spacing[3]}>
|
|
622
917
|
<Typography.Title
|
|
623
918
|
level={props.headingLevel ?? 3}
|
|
919
|
+
ref={titleRef}
|
|
624
920
|
data-testid="listings-detail-title"
|
|
625
921
|
style={{ minWidth: 0, flex: "1 1 auto" }}
|
|
626
922
|
>
|
|
627
923
|
{listing.title ?? ""}
|
|
628
924
|
</Typography.Title>
|
|
629
|
-
{placement === "header" ?
|
|
925
|
+
{placement === "header" ? homeActions : null}
|
|
630
926
|
</Flex>
|
|
631
927
|
|
|
632
928
|
{/* The `show_at_title` projection, formatted from the stored
|
|
@@ -741,7 +1037,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
741
1037
|
{/* The reader's two actions live in the cluster now (see
|
|
742
1038
|
`actionsPlacement`); the buy box keeps them only when a
|
|
743
1039
|
host asks for the layout this page used to have. */}
|
|
744
|
-
{placement === "buy-box" ?
|
|
1040
|
+
{placement === "buy-box" ? homeActions : null}
|
|
745
1041
|
|
|
746
1042
|
{actionsNode}
|
|
747
1043
|
</Flex>
|
|
@@ -888,6 +1184,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
888
1184
|
{meta}
|
|
889
1185
|
{asideAfterActions ? null : aside}
|
|
890
1186
|
{props.footer}
|
|
1187
|
+
{clusterLayer}
|
|
891
1188
|
</>
|
|
892
1189
|
);
|
|
893
1190
|
}
|
|
@@ -928,7 +1225,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
928
1225
|
data-testid="listings-detail-buy-column"
|
|
929
1226
|
style={{
|
|
930
1227
|
position: "sticky",
|
|
931
|
-
top: spacing[4],
|
|
1228
|
+
top: props.buyTop ?? spacing[4],
|
|
932
1229
|
alignSelf: "start",
|
|
933
1230
|
}}
|
|
934
1231
|
>
|
|
@@ -938,6 +1235,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
938
1235
|
{aside}
|
|
939
1236
|
</Flex>
|
|
940
1237
|
</div>
|
|
1238
|
+
{clusterLayer}
|
|
941
1239
|
</>
|
|
942
1240
|
);
|
|
943
1241
|
},
|
|
@@ -23,8 +23,11 @@
|
|
|
23
23
|
* row was on the page (in a block above the tabs) and in no tab and in no
|
|
24
24
|
* number, and a person reads the numbers.
|
|
25
25
|
*
|
|
26
|
-
* So the takedowns are the fourth tab
|
|
27
|
-
*
|
|
26
|
+
* So the takedowns are the fourth tab. It was counted from its own read
|
|
27
|
+
* while the counter had three integers; since stapel-listings 0.22.4 the
|
|
28
|
+
* counter has four and the badge is the SERVER's, so the tab appears and
|
|
29
|
+
* is right without the takedown page having landed — see `model/status.ts`
|
|
30
|
+
* for why a fourth tab and not the archive. The block
|
|
28
31
|
* above the tabs stays as one LINE, without the rows: a takedown must not
|
|
29
32
|
* need a click to be discovered, and printing the same row twice on one
|
|
30
33
|
* screen is not the way to say so.
|
|
@@ -571,10 +574,11 @@ export function MyListingsPane(props: MyListingsPaneProps): ReactElement {
|
|
|
571
574
|
// phone instead of collapsing into an overflow menu.
|
|
572
575
|
//
|
|
573
576
|
// `tabCounts`, not `counters`: the badge is never allowed to
|
|
574
|
-
// read lower than the rows underneath it, and the fourth
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
// under no number
|
|
577
|
+
// read lower than the rows underneath it, and the fourth
|
|
578
|
+
// tab's number degrades to its own rows on a server older
|
|
579
|
+
// than 0.22.4 (D407 — a moderator-rejected listing sat in
|
|
580
|
+
// Drafts under a `0`, and a taken-down one under no number
|
|
581
|
+
// whatever).
|
|
578
582
|
ready: (counts) => (
|
|
579
583
|
<Typography.Text
|
|
580
584
|
type={tab === "removed" ? "warning" : "secondary"}
|
|
@@ -10,16 +10,22 @@
|
|
|
10
10
|
*
|
|
11
11
|
* ── One control, two renderings, and the DEVICE picks ─────────────────────
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* On a device driven with a THUMB the press opens the PLATFORM's own sheet:
|
|
14
|
+
* the person's own apps, in their own order, including the ones we have never
|
|
15
|
+
* heard of. A library that drew its own list of four networks on a phone would
|
|
16
|
+
* be offering a worse version of something the operating system already does
|
|
17
|
+
* better.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* is
|
|
19
|
+
* Everywhere else the press opens a small menu: copy the link, and the three
|
|
20
|
+
* networks a Russian-speaking marketplace actually receives traffic from. This
|
|
21
|
+
* is the DESKTOP rendering, not a fallback for old browsers, which is why it
|
|
22
|
+
* is built rather than apologised for.
|
|
23
|
+
*
|
|
24
|
+
* The reading that decides is the primary POINTER and not `navigator.share`
|
|
25
|
+
* alone: desktop Chrome on macOS has the API, so the capability question sent
|
|
26
|
+
* every desktop share to the OS sheet and left this menu unreachable on the
|
|
27
|
+
* platform it was written for (§25). {@link ShareActionProps.prefer} is where
|
|
28
|
+
* a host overrules the reading in either direction.
|
|
23
29
|
*
|
|
24
30
|
* ── The menu is a Popover, and that is an exception with an argument ──────
|
|
25
31
|
*
|
|
@@ -60,7 +66,11 @@ import { SkinButton as Button, ErrorAlert } from "@stapel/tokens-antd/skin";
|
|
|
60
66
|
import { useT } from "@stapel/core";
|
|
61
67
|
import { spacing } from "@stapel/tokens";
|
|
62
68
|
import { useShare } from "../headless/Share.js";
|
|
63
|
-
import type {
|
|
69
|
+
import type {
|
|
70
|
+
ShareChannel,
|
|
71
|
+
ShareNetwork,
|
|
72
|
+
SharePreference,
|
|
73
|
+
} from "../headless/Share.js";
|
|
64
74
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
65
75
|
import {
|
|
66
76
|
LISTING_ACTIONS_STYLE_HREF,
|
|
@@ -104,6 +114,21 @@ export interface ShareActionProps {
|
|
|
104
114
|
* for it.
|
|
105
115
|
*/
|
|
106
116
|
readonly shape?: "default" | "circle";
|
|
117
|
+
/**
|
|
118
|
+
* WHICH ARM THIS SURFACE WANTS — see {@link SharePreference}.
|
|
119
|
+
*
|
|
120
|
+
* Default `"auto"`: the platform sheet where the primary pointer is coarse
|
|
121
|
+
* AND `navigator.share` exists, this pair's menu everywhere else. The
|
|
122
|
+
* default changed in this release, and it changed because of a measurement
|
|
123
|
+
* (§25): desktop Chrome on macOS reports `navigator.share`, so a pair that
|
|
124
|
+
* asked only the capability opened the OS sheet on the desktop and the
|
|
125
|
+
* copy-link menu — three networks and a clipboard row, built for exactly
|
|
126
|
+
* that platform — could not be reached there at all.
|
|
127
|
+
*
|
|
128
|
+
* `"native"` restores the capability-only behaviour by name; `"menu"` pins
|
|
129
|
+
* this pair's menu on every device.
|
|
130
|
+
*/
|
|
131
|
+
readonly prefer?: SharePreference;
|
|
107
132
|
readonly style?: CSSProperties;
|
|
108
133
|
}
|
|
109
134
|
|
|
@@ -115,6 +140,7 @@ export function ShareAction(props: ShareActionProps): ReactElement {
|
|
|
115
140
|
url: props.url,
|
|
116
141
|
title: props.title,
|
|
117
142
|
text: props.text,
|
|
143
|
+
...(props.prefer !== undefined ? { prefer: props.prefer } : {}),
|
|
118
144
|
...(props.onShared !== undefined ? { onShared: props.onShared } : {}),
|
|
119
145
|
});
|
|
120
146
|
const label = t(LISTINGS_I18N_KEYS.shareAction);
|
package/src/default/actionRow.ts
CHANGED
|
@@ -86,13 +86,62 @@ export const LISTING_ACTION_HIT: number = controls["height-phone"];
|
|
|
86
86
|
*/
|
|
87
87
|
export const LISTING_CARD_ACTION_HIT = 36;
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* HOW MANY TIMES THE HIT-TARGET CLASS IS REPEATED IN ITS OWN SELECTOR — and
|
|
91
|
+
* why a repeat rather than a number typed once (D450).
|
|
92
|
+
*
|
|
93
|
+
* Measured on the live listing page: the heart and the share glyph were
|
|
94
|
+
* **32 × 44**, not 44 × 44. The block axis survived and the inline one did
|
|
95
|
+
* not, because antd's circle shape ships
|
|
96
|
+
*
|
|
97
|
+
* `:where(…).ant-btn.ant-btn-circle.ant-btn{min-width:var(--ant-control-height)}`
|
|
98
|
+
*
|
|
99
|
+
* — three classes, specificity (0,3,0), against this sheet's single class
|
|
100
|
+
* (0,1,0). `:where()` adds nothing, and a media query adds nothing either, so
|
|
101
|
+
* the only thing that decides is the class count: antd's 32 won and there was
|
|
102
|
+
* no viewport at which it did not.
|
|
103
|
+
*
|
|
104
|
+
* A repeated class is the one way to outrank it without `!important`. Four
|
|
105
|
+
* repeats — (0,4,0) — clear antd's three with one to spare, and the selector
|
|
106
|
+
* still matches exactly the same element, so nothing about WHAT the rule
|
|
107
|
+
* applies to changes. `!important` was refused deliberately: a host that
|
|
108
|
+
* genuinely wants a different target must be able to say so with a selector,
|
|
109
|
+
* and this sheet's whole argument (see `movableCluster.tsx`) is that a pair's
|
|
110
|
+
* geometry should never force one on somebody else.
|
|
111
|
+
*/
|
|
112
|
+
export const LISTING_ACTION_SPECIFICITY = 4;
|
|
113
|
+
|
|
114
|
+
/** A class name repeated {@link LISTING_ACTION_SPECIFICITY} times — the
|
|
115
|
+
* selector that beats antd's circle. */
|
|
116
|
+
function outranking(className: string): string {
|
|
117
|
+
return `.${className}`.repeat(LISTING_ACTION_SPECIFICITY);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The floor, in BOTH spellings of the same axis.
|
|
122
|
+
*
|
|
123
|
+
* A browser cascades `min-inline-size` and `min-width` together and keeps the
|
|
124
|
+
* winner, so in a browser the logical pair alone would be enough once the
|
|
125
|
+
* selector outranks antd's. The physical pair is written beside it because
|
|
126
|
+
* the declaration being beaten is spelled physically, and an engine that does
|
|
127
|
+
* NOT merge the two names (jsdom, where this rule is asserted) would leave
|
|
128
|
+
* antd's `min-width:32px` standing beside our `min-inline-size:44px` and call
|
|
129
|
+
* that a pass. Two spellings of one number, and the number has one source.
|
|
130
|
+
*/
|
|
131
|
+
function floor(size: number): string {
|
|
132
|
+
const px = `${String(size)}px`;
|
|
133
|
+
return (
|
|
134
|
+
`min-inline-size:${px};min-block-size:${px};` +
|
|
135
|
+
`min-width:${px};min-height:${px}`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
89
139
|
export function actionRowCss(): string {
|
|
90
|
-
const action =
|
|
91
|
-
const cardAction =
|
|
140
|
+
const action = outranking(LISTING_ACTION_CLASS);
|
|
141
|
+
const cardAction = outranking(LISTING_CARD_ACTION_CLASS);
|
|
92
142
|
const row = `.${LISTING_ACTIONS_CLASS}`;
|
|
93
143
|
const over = `.${LISTING_ACTIONS_OVERLAY_CLASS}`;
|
|
94
144
|
const label = `.${LISTING_ACTION_LABEL_CLASS}`;
|
|
95
|
-
const hit = String(LISTING_ACTION_HIT);
|
|
96
145
|
const phone = `(max-width:${String(breakpoints.tablet - 1)}px)`;
|
|
97
146
|
return [
|
|
98
147
|
// The floor. `min-*` rather than `width`/`height`: a share button with a
|
|
@@ -100,14 +149,13 @@ export function actionRowCss(): string {
|
|
|
100
149
|
// `controlHeight` already reaches the height on a phone — this is the
|
|
101
150
|
// guarantee for every OTHER viewport and for a host-registered button
|
|
102
151
|
// that never read the antd token at all.
|
|
103
|
-
`${action}{
|
|
152
|
+
`${action}{${floor(LISTING_ACTION_HIT)};` +
|
|
104
153
|
`display:inline-flex;align-items:center;justify-content:center}`,
|
|
105
154
|
// A card's control: one tier smaller where there is a cursor, the same
|
|
106
155
|
// 44px where there is a thumb. See LISTING_CARD_ACTION_HIT.
|
|
107
|
-
`${cardAction}{
|
|
108
|
-
`min-block-size:${String(LISTING_CARD_ACTION_HIT)}px;` +
|
|
156
|
+
`${cardAction}{${floor(LISTING_CARD_ACTION_HIT)};` +
|
|
109
157
|
`display:inline-flex;align-items:center;justify-content:center}`,
|
|
110
|
-
`@media ${phone}{${cardAction}{
|
|
158
|
+
`@media ${phone}{${cardAction}{${floor(LISTING_ACTION_HIT)}}}`,
|
|
111
159
|
// The cluster. `align-items:flex-end` so a blocked heart's reason — the
|
|
112
160
|
// one thing here that can be two lines — stacks against the same edge
|
|
113
161
|
// instead of pushing the controls inwards (the arrangement
|