@stapel/listings-react 0.26.0 → 0.27.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 +51 -0
- package/MODULE.md +3 -1
- package/README.md +46 -1
- package/dist/api/generated/schema.d.ts +20 -10
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +96 -8
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +127 -17
- 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/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 +3 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +4 -0
- 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/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 +1 -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/ListingDetailPane.tsx +259 -15
- package/src/default/MyListingsPane.tsx +10 -6
- package/src/default/detailGallery.ts +97 -0
- package/src/default/index.ts +14 -1
- package/src/default/movableCluster.tsx +137 -0
- package/src/headless/MyListings.tsx +52 -12
- package/src/model/status.ts +31 -16
|
@@ -439,15 +439,18 @@ export interface paths {
|
|
|
439
439
|
cookie?: never;
|
|
440
440
|
};
|
|
441
441
|
/**
|
|
442
|
-
* @description
|
|
442
|
+
* @description One integer per cabinet tab, over the caller's own listings.
|
|
443
443
|
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
444
|
+
* The tab groupings are the SERVER's — ``my/listings?status=`` takes
|
|
445
|
+
* the same sets — so a cabinet never has to re-derive which statuses
|
|
446
|
+
* make up "active". Every lifecycle status is in exactly one group:
|
|
447
|
+
* ``blocked`` (a moderation takedown) is its own count rather than
|
|
448
|
+
* being folded into ``archived``, because the two tell the seller
|
|
449
|
+
* different things and only one of them is theirs to undo.
|
|
450
|
+
*
|
|
451
|
+
* Owner-scoped at the queryset via ``owned_by``, soft-deleted rows
|
|
452
|
+
* excluded by the default manager — the same scope as
|
|
453
|
+
* ``my/listings``, so a tab's rows and its count always agree.
|
|
451
454
|
*
|
|
452
455
|
* **Permissions:** `IsAuthenticated`
|
|
453
456
|
*/
|
|
@@ -500,7 +503,7 @@ export interface paths {
|
|
|
500
503
|
* @description The caller's OWN listings, in every status.
|
|
501
504
|
*
|
|
502
505
|
* The counterpart of ``my/counters``: the same owner scope and the same
|
|
503
|
-
* status grouping, but the rows behind the
|
|
506
|
+
* status grouping, but the rows behind the four numbers. ``list`` is
|
|
504
507
|
* the shop window (``published()``, narrowable to nobody), so this is
|
|
505
508
|
* the only route by which a person can be shown their own drafts.
|
|
506
509
|
*
|
|
@@ -1129,11 +1132,18 @@ export interface components {
|
|
|
1129
1132
|
* @enum {string}
|
|
1130
1133
|
*/
|
|
1131
1134
|
ModerationStatusEnum: "not_submitted" | "pending" | "approved" | "rejected" | "needs_review";
|
|
1132
|
-
/**
|
|
1135
|
+
/**
|
|
1136
|
+
* @description Listing counts by tab for the current user.
|
|
1137
|
+
*
|
|
1138
|
+
* One integer per cabinet tab, and every lifecycle status belongs to
|
|
1139
|
+
* exactly one of them — ``blocked`` included, so a listing a moderator
|
|
1140
|
+
* took down is counted somewhere instead of vanishing from the totals.
|
|
1141
|
+
*/
|
|
1133
1142
|
MyCountersResponse: {
|
|
1134
1143
|
active: number;
|
|
1135
1144
|
archived: number;
|
|
1136
1145
|
drafts: number;
|
|
1146
|
+
blocked: number;
|
|
1137
1147
|
};
|
|
1138
1148
|
/**
|
|
1139
1149
|
* @description The owner's own card — the public card plus what only an owner sees.
|
|
@@ -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";
|
|
@@ -81,6 +107,13 @@ 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).
|
|
@@ -262,9 +292,96 @@ export interface ListingDetailPaneProps
|
|
|
262
292
|
* photo counter owns the bottom trailing corner;
|
|
263
293
|
* - `"buy-box"` — inside `listings-detail-actions`, beside "message the
|
|
264
294
|
* seller", which is where the favourite alone used to live. The escape
|
|
265
|
-
* hatch for a host whose page was laid out around it
|
|
295
|
+
* hatch for a host whose page was laid out around it;
|
|
296
|
+
* - `"bar"` — the condensed top bar the host draws through
|
|
297
|
+
* {@link renderActionsBar}. Only meaningful in a LIST beside one of the
|
|
298
|
+
* three above, and only with that render prop: it names a second place
|
|
299
|
+
* the one cluster may travel to, never a home of its own.
|
|
300
|
+
*
|
|
301
|
+
* A LIST is how a host says "both": `["header", "bar"]` keeps the cluster
|
|
302
|
+
* beside the title and lends it to the bar for as long as the bar is on
|
|
303
|
+
* screen. Exactly one home placement is honoured — the first non-`"bar"`
|
|
304
|
+
* entry — because two homes would need two instances, which is the defect
|
|
305
|
+
* this closes rather than the feature it adds.
|
|
306
|
+
*/
|
|
307
|
+
readonly actionsPlacement?:
|
|
308
|
+
| ListingActionsPlacement
|
|
309
|
+
| readonly ListingActionsPlacement[];
|
|
310
|
+
/**
|
|
311
|
+
* THE SAME CLUSTER, IN A SECOND PLACE — a condensed bar, typically.
|
|
312
|
+
*
|
|
313
|
+
* Called with the bar's MOUNT POINT, not with a copy of the cluster: return
|
|
314
|
+
* it wrapped in whatever chrome the bar is (`position: fixed`, a back arrow,
|
|
315
|
+
* the title), and the pane moves its one `<ListingActions>` into it. Return
|
|
316
|
+
* `null` while the bar is not on screen and the cluster goes back where it
|
|
317
|
+
* came from — the same DOM node, the same hooks, an optimistic favourite
|
|
318
|
+
* still in flight uninterrupted.
|
|
319
|
+
*
|
|
320
|
+
* Requires `"bar"` in {@link actionsPlacement}. Pair it with
|
|
321
|
+
* {@link onTitleVisible} for the usual rule: the bar appears when the title
|
|
322
|
+
* leaves the fold.
|
|
323
|
+
*
|
|
324
|
+
* ```tsx
|
|
325
|
+
* <ListingDetailPane
|
|
326
|
+
* actionsPlacement={["header", "bar"]}
|
|
327
|
+
* onTitleVisible={(visible) => { setBarShown(!visible); }}
|
|
328
|
+
* renderActionsBar={(cluster) =>
|
|
329
|
+
* barShown ? <div className="topbar">{back}{title}{cluster}</div> : null
|
|
330
|
+
* }
|
|
331
|
+
* />
|
|
332
|
+
* ```
|
|
333
|
+
*
|
|
334
|
+
* A container that mounted its own second `<ListingActions>` for this can
|
|
335
|
+
* delete it: two `useFavoriteToggle` instances on one page, two hearts that
|
|
336
|
+
* agree only after a refetch, and a second set of test ids to keep the
|
|
337
|
+
* pane's own single are all what this prop exists to end.
|
|
338
|
+
*/
|
|
339
|
+
readonly renderActionsBar?: (cluster: ReactNode) => ReactNode;
|
|
340
|
+
/**
|
|
341
|
+
* IS THE TITLE STILL IN THE FOLD?
|
|
342
|
+
*
|
|
343
|
+
* An `IntersectionObserver` on the pane's own `<h1>` — never a `scroll`
|
|
344
|
+
* listener, which asks the question on every frame of a page whose job is
|
|
345
|
+
* scrolling photographs and answers it no better. Called on each crossing
|
|
346
|
+
* and not on every scroll: `false` when the title leaves, `true` when it
|
|
347
|
+
* comes back.
|
|
348
|
+
*
|
|
349
|
+
* It exists because the chrome a host hangs on this had no way to ask. A
|
|
350
|
+
* container drawing a condensed bar found the title by the pane's published
|
|
351
|
+
* `data-testid` and waited for it with a `MutationObserver`, because the
|
|
352
|
+
* title lands with the listing and not with the first frame — a private
|
|
353
|
+
* selector and a subscription, for a boolean the pane already knows.
|
|
354
|
+
*
|
|
355
|
+
* NOT called at all where the environment has no `IntersectionObserver`:
|
|
356
|
+
* the honest answer there is "this page cannot tell", and a fabricated
|
|
357
|
+
* `true` would leave a host's bar wedged open on the arm that has no
|
|
358
|
+
* scrolling anyway.
|
|
359
|
+
*
|
|
360
|
+
* Pass a STABLE function (a `useState` setter, a `useCallback`). The
|
|
361
|
+
* observer is created once for the title node and reads the latest callback
|
|
362
|
+
* through a ref, so an inline arrow works and does not re-observe.
|
|
363
|
+
*/
|
|
364
|
+
readonly onTitleVisible?: (visible: boolean) => void;
|
|
365
|
+
/**
|
|
366
|
+
* WHAT SHAPE THE PHOTOGRAPHS ARE IN.
|
|
367
|
+
*
|
|
368
|
+
* - `"grid"` (default) — the element-width grid this pane has always drawn,
|
|
369
|
+
* `repeat(auto-fit, minmax(14rem, 1fr))`: three tiles across a desktop
|
|
370
|
+
* pane, one across a phone;
|
|
371
|
+
* - `"strip"` — a snap-scrolling horizontal strip, one photograph visible
|
|
372
|
+
* with the next peeking. On a 390px phone the grid resolves to one
|
|
373
|
+
* column, so a listing with three pictures pushes its own title and price
|
|
374
|
+
* nearly three screens down — the first thing a person sees after tapping
|
|
375
|
+
* a search result is a photograph with nothing beside it.
|
|
376
|
+
*
|
|
377
|
+
* The HOST names it, the same rule as {@link layout} and for the same
|
|
378
|
+
* reason: the side that knows the viewport it granted decides, and no media
|
|
379
|
+
* query is guessed in a leaf. A live storefront was carrying
|
|
380
|
+
* `display: flex !important` against this pane's inline `display: grid` to
|
|
381
|
+
* say exactly this; that declaration is a class now, so even a host wanting
|
|
382
|
+
* a third shape needs a selector rather than an `!important`.
|
|
266
383
|
*/
|
|
267
|
-
readonly
|
|
384
|
+
readonly galleryLayout?: ListingGalleryLayout;
|
|
268
385
|
/**
|
|
269
386
|
* The container's sign-in door, rendered beside the favourite's refusal —
|
|
270
387
|
* the same `SignInCta` seam the three card skins already take. The pane was
|
|
@@ -298,6 +415,82 @@ export interface ListingDetailPaneProps
|
|
|
298
415
|
readonly footer?: ReactNode;
|
|
299
416
|
}
|
|
300
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Where the reader's cluster may sit. Three homes and one loan — see
|
|
420
|
+
* {@link ListingDetailPaneProps.actionsPlacement}.
|
|
421
|
+
*/
|
|
422
|
+
export type ListingActionsPlacement =
|
|
423
|
+
| "header"
|
|
424
|
+
| "gallery"
|
|
425
|
+
| "buy-box"
|
|
426
|
+
| "bar";
|
|
427
|
+
|
|
428
|
+
/** The cluster's HOME: the first entry that is not the borrowed bar. */
|
|
429
|
+
function homePlacement(
|
|
430
|
+
placement: ListingDetailPaneProps["actionsPlacement"]
|
|
431
|
+
): Exclude<ListingActionsPlacement, "bar"> {
|
|
432
|
+
if (placement === undefined) return "header";
|
|
433
|
+
if (typeof placement === "string") {
|
|
434
|
+
// `"bar"` alone names no home — the cluster still has to live somewhere
|
|
435
|
+
// while the bar is off screen, and that somewhere is the default.
|
|
436
|
+
return placement === "bar" ? "header" : placement;
|
|
437
|
+
}
|
|
438
|
+
for (const one of placement) {
|
|
439
|
+
if (one !== "bar") return one;
|
|
440
|
+
}
|
|
441
|
+
return "header";
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Did the host ask for the borrowed placement at all? */
|
|
445
|
+
function wantsBar(
|
|
446
|
+
placement: ListingDetailPaneProps["actionsPlacement"]
|
|
447
|
+
): boolean {
|
|
448
|
+
if (placement === undefined) return false;
|
|
449
|
+
if (typeof placement === "string") return placement === "bar";
|
|
450
|
+
return placement.includes("bar");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Priorities for the two mount points: the bar wins while it is on screen. */
|
|
454
|
+
const CLUSTER_HOME = 0;
|
|
455
|
+
const CLUSTER_BAR = 1;
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The pane's own title, watched — see
|
|
459
|
+
* {@link ListingDetailPaneProps.onTitleVisible}.
|
|
460
|
+
*
|
|
461
|
+
* Returns a callback ref for the heading element. The observer is created once
|
|
462
|
+
* per node and disconnected by React 19's ref cleanup; the host's callback is
|
|
463
|
+
* read through a ref at call time, so an inline arrow does not re-observe on
|
|
464
|
+
* every render of a page that re-renders on every query update.
|
|
465
|
+
*/
|
|
466
|
+
function useTitleVisibility(
|
|
467
|
+
onTitleVisible: ((visible: boolean) => void) | undefined
|
|
468
|
+
): (node: HTMLElement | null) => (() => void) | undefined {
|
|
469
|
+
const latest = useRef(onTitleVisible);
|
|
470
|
+
useEffect(() => {
|
|
471
|
+
latest.current = onTitleVisible;
|
|
472
|
+
});
|
|
473
|
+
const wanted = onTitleVisible !== undefined;
|
|
474
|
+
return useCallback(
|
|
475
|
+
(node: HTMLElement | null): (() => void) | undefined => {
|
|
476
|
+
if (node === null || !wanted) return undefined;
|
|
477
|
+
// No observer, no answer. A fabricated `true` would wedge a host's bar
|
|
478
|
+
// open on an arm that has no scrolling to close it with.
|
|
479
|
+
if (typeof IntersectionObserver === "undefined") return undefined;
|
|
480
|
+
const observer = new IntersectionObserver((entries) => {
|
|
481
|
+
const entry = entries[entries.length - 1];
|
|
482
|
+
if (entry === undefined) return;
|
|
483
|
+
latest.current?.(entry.isIntersecting);
|
|
484
|
+
});
|
|
485
|
+
observer.observe(node);
|
|
486
|
+
return () => {
|
|
487
|
+
observer.disconnect();
|
|
488
|
+
};
|
|
489
|
+
},
|
|
490
|
+
[wanted]
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
301
494
|
/**
|
|
302
495
|
* Which arm of `actions` this is.
|
|
303
496
|
*
|
|
@@ -335,7 +528,18 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
335
528
|
// leaves rendering the reason to `<GatedControl>`, which computes its own.
|
|
336
529
|
const favoriteView = useActionGate(bag.favoriteGate);
|
|
337
530
|
const split = props.layout === "split";
|
|
338
|
-
const placement = props.actionsPlacement
|
|
531
|
+
const placement = homePlacement(props.actionsPlacement);
|
|
532
|
+
/* THE SECOND PLACEMENT IS A LOAN, NOT A COPY. Both halves have to be asked
|
|
533
|
+
for: `"bar"` in the placement list says the cluster may travel, and
|
|
534
|
+
`renderActionsBar` is the only thing that can put it anywhere. With
|
|
535
|
+
neither — every existing mount — nothing below changes: one cluster,
|
|
536
|
+
rendered inline where it always was, no portal and no slot divs. */
|
|
537
|
+
const galleryLayout: ListingGalleryLayout = props.galleryLayout ?? "grid";
|
|
538
|
+
const renderBar = props.renderActionsBar;
|
|
539
|
+
const barred = wantsBar(props.actionsPlacement) && renderBar !== undefined;
|
|
540
|
+
const movable = useMovableCluster(barred);
|
|
541
|
+
const moving = barred && movable.portable;
|
|
542
|
+
const titleRef = useTitleVisibility(props.onTitleVisible);
|
|
339
543
|
// The two arms of `actions` — see `isActionsConfig`.
|
|
340
544
|
const actionsConfig: ListingActionsConfig | undefined = isActionsConfig(
|
|
341
545
|
props.actions
|
|
@@ -570,14 +774,48 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
570
774
|
/>
|
|
571
775
|
);
|
|
572
776
|
|
|
777
|
+
/* What the PAGE draws where the cluster lives. With the bar in
|
|
778
|
+
play that is a slot and not the cluster itself: the cluster is
|
|
779
|
+
rendered once into `clusterLayer` below and travels between the
|
|
780
|
+
two slots as a DOM node, so it mounts once, holds one
|
|
781
|
+
`useFavoriteToggle`, and keeps its element identity across the
|
|
782
|
+
move. Without it, the cluster is drawn inline exactly as it has
|
|
783
|
+
always been. */
|
|
784
|
+
const homeActions = moving
|
|
785
|
+
? movable.slot(CLUSTER_HOME, placement)
|
|
786
|
+
: readerActions;
|
|
787
|
+
|
|
788
|
+
/* The one instance, plus the host's bar around the slot that may
|
|
789
|
+
borrow it. Rendered at the end of the page's own flow: the
|
|
790
|
+
portal has no position of its own (its content is wherever the
|
|
791
|
+
winning slot is), and a bar is `position: fixed` chrome whose
|
|
792
|
+
place in the document order is not its place on the screen. */
|
|
793
|
+
const clusterLayer =
|
|
794
|
+
!moving || renderBar === undefined ? null : (
|
|
795
|
+
<>
|
|
796
|
+
{movable.render(readerActions)}
|
|
797
|
+
{/* NOT a slot with a silent absence: `renderBar` is the only
|
|
798
|
+
thing that makes `moving` true, so this arm is
|
|
799
|
+
unreachable without one and the host's own `null` (the
|
|
800
|
+
bar off screen) is the answer that sends the cluster
|
|
801
|
+
home. There is no hole to place a `<SlotPlaceholder>` in
|
|
802
|
+
— the cluster is at its primary placement instead. */}
|
|
803
|
+
{renderBar(movable.slot(CLUSTER_BAR, "bar"))}
|
|
804
|
+
</>
|
|
805
|
+
);
|
|
806
|
+
|
|
573
807
|
/* Element-width tiles: the grid decides how many fit, the
|
|
574
808
|
photos fill them. */
|
|
575
809
|
const gallery = (
|
|
576
810
|
<div
|
|
577
811
|
data-testid="listings-detail-gallery"
|
|
812
|
+
className={LISTINGS_GALLERY_CLASS}
|
|
813
|
+
// The layout is a CLASS and an attribute, not an inline
|
|
814
|
+
// `display`: a host with a shape neither arm offers can then
|
|
815
|
+
// write CSS for it at its own breakpoints without `!important`
|
|
816
|
+
// over a pair's own geometry. See `detailGallery.ts`.
|
|
817
|
+
data-gallery-layout={galleryLayout}
|
|
578
818
|
style={{
|
|
579
|
-
display: "grid",
|
|
580
|
-
gridTemplateColumns: `repeat(auto-fit, minmax(${DETAIL_PHOTO_MIN}, 1fr))`,
|
|
581
819
|
// The page's own edge, per breakpoint — see
|
|
582
820
|
// `DETAIL_GALLERY_GUTTER` (D418).
|
|
583
821
|
gap: DETAIL_GALLERY_GUTTER,
|
|
@@ -587,6 +825,9 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
587
825
|
position: "relative",
|
|
588
826
|
}}
|
|
589
827
|
>
|
|
828
|
+
<style href={LISTINGS_GALLERY_STYLE_HREF} precedence="default">
|
|
829
|
+
{detailGalleryCss()}
|
|
830
|
+
</style>
|
|
590
831
|
{bag.images.length === 0 ? (
|
|
591
832
|
<ListingPhoto
|
|
592
833
|
imageRef={undefined}
|
|
@@ -604,7 +845,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
604
845
|
/>
|
|
605
846
|
))
|
|
606
847
|
)}
|
|
607
|
-
{placement === "gallery" ?
|
|
848
|
+
{placement === "gallery" ? homeActions : null}
|
|
608
849
|
</div>
|
|
609
850
|
);
|
|
610
851
|
|
|
@@ -621,12 +862,13 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
621
862
|
<Flex align="flex-start" justify="space-between" gap={spacing[3]}>
|
|
622
863
|
<Typography.Title
|
|
623
864
|
level={props.headingLevel ?? 3}
|
|
865
|
+
ref={titleRef}
|
|
624
866
|
data-testid="listings-detail-title"
|
|
625
867
|
style={{ minWidth: 0, flex: "1 1 auto" }}
|
|
626
868
|
>
|
|
627
869
|
{listing.title ?? ""}
|
|
628
870
|
</Typography.Title>
|
|
629
|
-
{placement === "header" ?
|
|
871
|
+
{placement === "header" ? homeActions : null}
|
|
630
872
|
</Flex>
|
|
631
873
|
|
|
632
874
|
{/* The `show_at_title` projection, formatted from the stored
|
|
@@ -741,7 +983,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
741
983
|
{/* The reader's two actions live in the cluster now (see
|
|
742
984
|
`actionsPlacement`); the buy box keeps them only when a
|
|
743
985
|
host asks for the layout this page used to have. */}
|
|
744
|
-
{placement === "buy-box" ?
|
|
986
|
+
{placement === "buy-box" ? homeActions : null}
|
|
745
987
|
|
|
746
988
|
{actionsNode}
|
|
747
989
|
</Flex>
|
|
@@ -888,6 +1130,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
888
1130
|
{meta}
|
|
889
1131
|
{asideAfterActions ? null : aside}
|
|
890
1132
|
{props.footer}
|
|
1133
|
+
{clusterLayer}
|
|
891
1134
|
</>
|
|
892
1135
|
);
|
|
893
1136
|
}
|
|
@@ -938,6 +1181,7 @@ export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
|
938
1181
|
{aside}
|
|
939
1182
|
</Flex>
|
|
940
1183
|
</div>
|
|
1184
|
+
{clusterLayer}
|
|
941
1185
|
</>
|
|
942
1186
|
);
|
|
943
1187
|
},
|
|
@@ -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"}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOW THE LISTING PAGE LAYS OUT ITS PHOTOGRAPHS — and why the answer is a
|
|
3
|
+
* class and a prop rather than an inline `display`.
|
|
4
|
+
*
|
|
5
|
+
* ── The defect a container was carrying ───────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* The gallery is an element-width grid (`repeat(auto-fit, minmax(14rem,
|
|
8
|
+
* 1fr))`), which on a 390px phone resolves to ONE column: a listing with three
|
|
9
|
+
* pictures pushes its own title and price nearly three screens down, and the
|
|
10
|
+
* first thing a person sees after tapping a search result is a photograph with
|
|
11
|
+
* nothing beside it. A phone lays photographs out as a snap-scrolling STRIP —
|
|
12
|
+
* one visible with the next peeking, title and price directly under it.
|
|
13
|
+
*
|
|
14
|
+
* That is layout, and layout is the container's to decide. But the pane wrote
|
|
15
|
+
* `display: grid` INLINE, and an inline declaration is beaten by nothing that
|
|
16
|
+
* is not `!important` — so a live storefront carried
|
|
17
|
+
* `[data-testid="listings-detail-gallery"] { display: flex !important }`
|
|
18
|
+
* against a pair's own geometry, named by a test id, to say a thing the pair
|
|
19
|
+
* offered no way to say (a client storefront's own stylesheet, §2).
|
|
20
|
+
*
|
|
21
|
+
* ── The seam ──────────────────────────────────────────────────────────────
|
|
22
|
+
*
|
|
23
|
+
* Two halves, and both matter:
|
|
24
|
+
*
|
|
25
|
+
* 1. `<ListingDetailPane galleryLayout>` — the pane ships BOTH layouts and
|
|
26
|
+
* the host names one, exactly as it already names `layout="split"` and
|
|
27
|
+
* `gutter`. The host is the side that knows the viewport it granted;
|
|
28
|
+
* a media query guessed in a leaf is the thing this package does not do.
|
|
29
|
+
* 2. `display` and the track are no longer inline. They live on this
|
|
30
|
+
* stylesheet, at one class plus one attribute, so a host that wants
|
|
31
|
+
* something neither arm offers can still write CSS for it at its own
|
|
32
|
+
* breakpoints — and needs no `!important` to be heard, only a selector of
|
|
33
|
+
* its own (`[data-testid="listings-detail-gallery"][data-gallery-layout]`
|
|
34
|
+
* ties; add any third condition and it wins).
|
|
35
|
+
*
|
|
36
|
+
* What stays inline is what nobody overrides and what a stylesheet would make
|
|
37
|
+
* worse: the `gap` (the page's own responsive gutter token, D418 — a var, so
|
|
38
|
+
* a resize reflows it) and `position: relative`, which is the containing block
|
|
39
|
+
* the `actionsPlacement="gallery"` overlay is pinned to.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* The narrowest a gallery tile may get before the grid drops a column. A
|
|
43
|
+
* measure rather than a pixel: the tiles then fill whatever the ELEMENT is,
|
|
44
|
+
* which is §83's geometry rule — one photo per row on a phone, three on a
|
|
45
|
+
* desktop pane, and no `width: 320` that is near-full-bleed on one and a
|
|
46
|
+
* postage stamp on the other.
|
|
47
|
+
*
|
|
48
|
+
* Declared here rather than in `<ListingDetailPane>` because this is the file
|
|
49
|
+
* that writes the track it feeds; the pane re-exports it, so the public name
|
|
50
|
+
* is unchanged.
|
|
51
|
+
*/
|
|
52
|
+
export const DETAIL_PHOTO_MIN = "14rem";
|
|
53
|
+
|
|
54
|
+
/** Which shape the photographs take. See the file header. */
|
|
55
|
+
export type ListingGalleryLayout = "grid" | "strip";
|
|
56
|
+
|
|
57
|
+
/** The class the gallery box carries. */
|
|
58
|
+
export const LISTINGS_GALLERY_CLASS = "stapel-listings-detail-gallery";
|
|
59
|
+
|
|
60
|
+
/** The `href` the hoisted gallery stylesheet is deduplicated by. */
|
|
61
|
+
export const LISTINGS_GALLERY_STYLE_HREF = "stapel-listings-detail-gallery";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How much of the strip's width ONE photograph takes.
|
|
65
|
+
*
|
|
66
|
+
* Not 100: the remaining sliver of the next picture is the only thing on a
|
|
67
|
+
* phone that says the strip scrolls at all. Exported so a host laying out
|
|
68
|
+
* beside it measures against the same number instead of guessing it back out
|
|
69
|
+
* of a screenshot.
|
|
70
|
+
*/
|
|
71
|
+
export const LISTINGS_GALLERY_STRIP_BASIS = "86%";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The gallery's layout rules, for the hoisted `<style>`.
|
|
75
|
+
*
|
|
76
|
+
* The strip's child rule (`> *`) is the reason this is a stylesheet and not
|
|
77
|
+
* two more inline properties: an inline style cannot reach a child, and the
|
|
78
|
+
* flex basis is what makes the strip a strip rather than a row of squeezed
|
|
79
|
+
* photographs.
|
|
80
|
+
*/
|
|
81
|
+
export function detailGalleryCss(): string {
|
|
82
|
+
return `
|
|
83
|
+
.${LISTINGS_GALLERY_CLASS}[data-gallery-layout="grid"] {
|
|
84
|
+
display: grid;
|
|
85
|
+
grid-template-columns: repeat(auto-fit, minmax(${DETAIL_PHOTO_MIN}, 1fr));
|
|
86
|
+
}
|
|
87
|
+
.${LISTINGS_GALLERY_CLASS}[data-gallery-layout="strip"] {
|
|
88
|
+
display: flex;
|
|
89
|
+
overflow-x: auto;
|
|
90
|
+
scroll-snap-type: x mandatory;
|
|
91
|
+
}
|
|
92
|
+
.${LISTINGS_GALLERY_CLASS}[data-gallery-layout="strip"] > * {
|
|
93
|
+
flex: 0 0 ${LISTINGS_GALLERY_STRIP_BASIS};
|
|
94
|
+
scroll-snap-align: start;
|
|
95
|
+
}
|
|
96
|
+
`.trim();
|
|
97
|
+
}
|
package/src/default/index.ts
CHANGED
|
@@ -73,7 +73,20 @@ export {
|
|
|
73
73
|
DETAIL_PHOTO_MIN,
|
|
74
74
|
DETAIL_GALLERY_GUTTER,
|
|
75
75
|
} from "./ListingDetailPane.js";
|
|
76
|
-
export type {
|
|
76
|
+
export type {
|
|
77
|
+
ListingDetailPaneProps,
|
|
78
|
+
ListingActionsPlacement,
|
|
79
|
+
} from "./ListingDetailPane.js";
|
|
80
|
+
// The gallery's two shapes, its class and the stylesheet behind them — a host
|
|
81
|
+
// that wants a third writes CSS against these rather than `!important` over an
|
|
82
|
+
// inline `display`.
|
|
83
|
+
export {
|
|
84
|
+
LISTINGS_GALLERY_CLASS,
|
|
85
|
+
LISTINGS_GALLERY_STYLE_HREF,
|
|
86
|
+
LISTINGS_GALLERY_STRIP_BASIS,
|
|
87
|
+
detailGalleryCss,
|
|
88
|
+
} from "./detailGallery.js";
|
|
89
|
+
export type { ListingGalleryLayout } from "./detailGallery.js";
|
|
77
90
|
export {
|
|
78
91
|
ListingComposerPage,
|
|
79
92
|
COMPOSER_DETAILS_PLACEMENT,
|