@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/MODULE.md +3 -1
  3. package/README.md +46 -1
  4. package/dist/api/generated/schema.d.ts +20 -10
  5. package/dist/api/generated/schema.d.ts.map +1 -1
  6. package/dist/default/ListingDetailPane.d.ts +96 -8
  7. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  8. package/dist/default/ListingDetailPane.js +127 -17
  9. package/dist/default/ListingDetailPane.js.map +1 -1
  10. package/dist/default/MyListingsPane.d.ts.map +1 -1
  11. package/dist/default/MyListingsPane.js +10 -6
  12. package/dist/default/MyListingsPane.js.map +1 -1
  13. package/dist/default/detailGallery.d.ts +77 -0
  14. package/dist/default/detailGallery.d.ts.map +1 -0
  15. package/dist/default/detailGallery.js +91 -0
  16. package/dist/default/detailGallery.js.map +1 -0
  17. package/dist/default/index.d.ts +3 -1
  18. package/dist/default/index.d.ts.map +1 -1
  19. package/dist/default/index.js +4 -0
  20. package/dist/default/index.js.map +1 -1
  21. package/dist/default/movableCluster.d.ts +18 -0
  22. package/dist/default/movableCluster.d.ts.map +1 -0
  23. package/dist/default/movableCluster.js +110 -0
  24. package/dist/default/movableCluster.js.map +1 -0
  25. package/dist/headless/MyListings.d.ts +31 -10
  26. package/dist/headless/MyListings.d.ts.map +1 -1
  27. package/dist/headless/MyListings.js +19 -2
  28. package/dist/headless/MyListings.js.map +1 -1
  29. package/dist/model/status.d.ts +31 -16
  30. package/dist/model/status.d.ts.map +1 -1
  31. package/dist/model/status.js +30 -15
  32. package/dist/model/status.js.map +1 -1
  33. package/llms.txt +1 -1
  34. package/manifest.json +1 -1
  35. package/nav-manifest.json +1 -1
  36. package/package.json +3 -3
  37. package/src/analytics/generated/events.json +1 -1
  38. package/src/api/generated/schema.ts +20 -10
  39. package/src/default/ListingDetailPane.tsx +259 -15
  40. package/src/default/MyListingsPane.tsx +10 -6
  41. package/src/default/detailGallery.ts +97 -0
  42. package/src/default/index.ts +14 -1
  43. package/src/default/movableCluster.tsx +137 -0
  44. package/src/headless/MyListings.tsx +52 -12
  45. package/src/model/status.ts +31 -16
@@ -439,15 +439,18 @@ export interface paths {
439
439
  cookie?: never;
440
440
  };
441
441
  /**
442
- * @description Listings CRUD plus owner lifecycle actions and favorites.
442
+ * @description One integer per cabinet tab, over the caller's own listings.
443
443
  *
444
- * Read and write live in one class, so the guest wall
445
- * (:func:`anonymous_write_refusal`, the ``ALLOW_ANONYMOUS_WRITES`` switch)
446
- * is applied per ACTION and never as a class permission: an anonymous
447
- * session must keep browsing and keep its favorites, and only the
448
- * authorship actions ``create``, ``update``/``partial_update``,
449
- * ``save-draft``, ``publish`` are the ones that turn a caller into a
450
- * seller.
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 three numbers. ``list`` is
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
- /** @description Listing counts by tab for the current user. */
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
- /** The narrowest a gallery tile may get before the grid drops a column. A
117
- * measure rather than a pixel: the tiles then fill whatever the ELEMENT is,
118
- * which is §83's geometry rule — one photo per row on a phone, three on a
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 actionsPlacement?: "header" | "gallery" | "buy-box";
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 ?? "header";
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" ? readerActions : null}
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" ? readerActions : null}
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" ? readerActions : null}
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, counted from their own read — see
27
- * `model/status.ts` for why a fourth tab and not the archive. The block
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 tab
575
- // has no server counter at all (D407 a moderator-rejected
576
- // listing sat in Drafts under a `0`, and a taken-down one
577
- // under no number whatever).
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
+ }
@@ -73,7 +73,20 @@ export {
73
73
  DETAIL_PHOTO_MIN,
74
74
  DETAIL_GALLERY_GUTTER,
75
75
  } from "./ListingDetailPane.js";
76
- export type { ListingDetailPaneProps } from "./ListingDetailPane.js";
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,