@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
@@ -0,0 +1,137 @@
1
+ /**
2
+ * ONE CLUSTER, TWO PLACES ON THE PAGE — the same instance, moved.
3
+ *
4
+ * ── The ask this answers, and why the obvious shape does not ──────────────
5
+ *
6
+ * A listing page wants the reader's two verbs beside the title AND in the
7
+ * condensed bar that arrives when the title scrolls away (reference §24). A
8
+ * host cannot do that today: `<ListingDetailPane>` mounts its cluster at
9
+ * exactly one `actionsPlacement`, so a container that wants the second one
10
+ * mounts a `<ListingActions>` of its own — which is a SECOND
11
+ * `useFavoriteToggle` on one page, two hearts that agree only after a refetch,
12
+ * and two copies of a control whose geometry this pair exists to rule once.
13
+ *
14
+ * The shapes that do NOT close it, stated because each looks like it would:
15
+ *
16
+ * a render prop handed the cluster ELEMENT
17
+ * A React element is a description, not an instance. Rendered in two
18
+ * places it mounts twice — exactly the defect, now with the pair's name
19
+ * on it.
20
+ *
21
+ * the host moving one element between two parents
22
+ * React unmounts and remounts across a parent change: the hook state
23
+ * goes, the DOM node is a different node, and an optimistic favourite
24
+ * in flight is lost mid-write.
25
+ *
26
+ * two mounts kept in step by lifting the state
27
+ * Two controls, one state — and then the page has two `aria-pressed`
28
+ * hearts, two focus targets and two things for a probe to count.
29
+ *
30
+ * ── What this does ────────────────────────────────────────────────────────
31
+ *
32
+ * The cluster is rendered ONCE, through a portal, into a `<div>` this hook
33
+ * owns. The page draws SLOTS — empty `display: contents` divs — wherever the
34
+ * cluster may sit, and the container element is `appendChild`-ed into whichever
35
+ * slot is currently on screen with the highest priority. Moving a DOM node
36
+ * between parents is not a React tree change, so:
37
+ *
38
+ * - `<ListingActions>` (and everything under it) mounts exactly once;
39
+ * - the favourite button is literally the SAME `HTMLElement` in both
40
+ * placements — `test/detailActionsPlacements.test.tsx` holds the node
41
+ * across the move and asserts identity, which is the only assertion that
42
+ * can tell this apart from a well-behaved remount;
43
+ * - a slot that unmounts hands the cluster back to the next one down,
44
+ * rather than taking it off the page.
45
+ *
46
+ * `display: contents` on both the slot and the container is load-bearing: the
47
+ * cluster must remain a direct flex item of the heading row (it is laid out by
48
+ * `justify="space-between"`) and must stay absolutely positionable against the
49
+ * gallery's containing block in the overlay arm. A wrapper that generated a
50
+ * box would change both.
51
+ */
52
+ import { useCallback, useEffect, useRef } from "react";
53
+ import type { CSSProperties, ReactElement, ReactNode } from "react";
54
+ import { createPortal } from "react-dom";
55
+
56
+ /** Neither slot nor container may generate a box — see the file header. */
57
+ const CONTENTS: CSSProperties = { display: "contents" };
58
+
59
+ export interface MovableCluster {
60
+ /**
61
+ * True when there is a document to portal into. `false` under SSR, where
62
+ * the caller renders the cluster inline at its primary placement instead —
63
+ * a server render has no scrolling and therefore no second placement.
64
+ */
65
+ readonly portable: boolean;
66
+ /** Render the cluster ONCE, anywhere in the tree. Returns the portal. */
67
+ render(cluster: ReactNode): ReactNode;
68
+ /**
69
+ * A mount point. The highest `priority` currently on screen holds the
70
+ * cluster; when it goes, the next one down gets it back.
71
+ */
72
+ slot(priority: number, name: string): ReactElement;
73
+ }
74
+
75
+ export function useMovableCluster(enabled: boolean): MovableCluster {
76
+ const container = useRef<HTMLElement | null>(null);
77
+ if (enabled && container.current === null && typeof document !== "undefined") {
78
+ const host = document.createElement("div");
79
+ host.style.display = "contents";
80
+ host.setAttribute("data-listings-cluster-portal", "");
81
+ container.current = host;
82
+ }
83
+ const slots = useRef(new Map<number, HTMLElement>());
84
+ const refs = useRef(new Map<number, (node: HTMLElement | null) => void>());
85
+
86
+ /** Put the container in the highest-priority slot that is on the page. */
87
+ const settle = useCallback((): void => {
88
+ const host = container.current;
89
+ if (host === null) return;
90
+ let best: HTMLElement | undefined;
91
+ let bestPriority = -1;
92
+ for (const [priority, node] of slots.current) {
93
+ // `isConnected` matters during a commit that removes a slot: React may
94
+ // hand back a node it is about to drop, and appending into it would take
95
+ // the cluster off the page with it.
96
+ if (node.isConnected && priority > bestPriority) {
97
+ bestPriority = priority;
98
+ best = node;
99
+ }
100
+ }
101
+ if (best !== undefined && host.parentNode !== best) best.appendChild(host);
102
+ }, []);
103
+
104
+ // The belt. A slot's ref cleanup is what normally moves the cluster, but a
105
+ // subtree removed around it would leave the container detached with nothing
106
+ // to notice; this runs after every commit and repairs that in one comparison.
107
+ useEffect(settle);
108
+
109
+ const refFor = useCallback(
110
+ (priority: number): ((node: HTMLElement | null) => void) => {
111
+ let ref = refs.current.get(priority);
112
+ if (ref === undefined) {
113
+ ref = (node: HTMLElement | null): void => {
114
+ if (node === null) slots.current.delete(priority);
115
+ else slots.current.set(priority, node);
116
+ settle();
117
+ };
118
+ refs.current.set(priority, ref);
119
+ }
120
+ return ref;
121
+ },
122
+ [settle]
123
+ );
124
+
125
+ return {
126
+ portable: container.current !== null,
127
+ render: (cluster: ReactNode): ReactNode =>
128
+ container.current === null ? null : createPortal(cluster, container.current),
129
+ slot: (priority: number, name: string): ReactElement => (
130
+ <div
131
+ ref={refFor(priority)}
132
+ style={CONTENTS}
133
+ data-listings-cluster-slot={name}
134
+ />
135
+ ),
136
+ };
137
+ }
@@ -49,13 +49,23 @@ import { useMandateGate } from "./useMandateGate.js";
49
49
  * `blockedRows` is fetched beside them, off the same route, narrowed to
50
50
  * whatever `MY_LISTINGS_UNTABBED_STATUSES` derives.
51
51
  *
52
- * That second read is also the fourth tab's COUNT (D407). `my/counters` has
53
- * three integers and no fourth, so the removed tab is counted from the rows it
54
- * holds — which is honest exactly because that read is unpaged and runs
55
- * whichever tab is open. The one thing it cannot describe is a page cap: a
56
- * seller with more takedowns than one page of `my/listings` would see the page
57
- * count and not the total, and there is no counter on the wire to check it
58
- * against (see `MyListingsBag.tabCounts`).
52
+ * That second read used to be the fourth tab's COUNT as well (D407):
53
+ * `my/counters` had three integers and no fourth, so the removed tab was
54
+ * counted from the rows it holds — honest, because that read is unpaged and
55
+ * runs whichever tab is open, and blind to exactly one thing: a page cap. A
56
+ * seller with more takedowns than one page of `my/listings` saw the page count
57
+ * and not the total, and there was no counter on the wire to check it against.
58
+ *
59
+ * **stapel-listings 0.22.4 answers it**: `MyCountersResponse.blocked`, required
60
+ * beside the other three, over the same owner scope and the same grouping. So
61
+ * the tab's number is the SERVER's now and the unpaged read is what it always
62
+ * was underneath — the removed tab's ROWS. The two consequences are both
63
+ * visible: the tab appears from the counter (a beat earlier, and without the
64
+ * seller's whole takedown page having to land first), and a seller with more
65
+ * takedowns than fit on a page is told how many there are rather than how many
66
+ * arrived. A deployment on a server older than 0.22.4 sends no `blocked` at
67
+ * all, and then the rows are the count again — degraded to the previous
68
+ * answer, never to a `0` (see `MyListingsBag.tabCounts`).
59
69
  */
60
70
 
61
71
  export interface MyListingsBag {
@@ -66,10 +76,16 @@ export interface MyListingsBag {
66
76
  * The server's three always; the removed one only where there is something
67
77
  * in it or the person asked for it by address. An empty "Taken down" tab is
68
78
  * a scare, and the seller it would scare is the one it has nothing to tell.
79
+ *
80
+ * "Something in it" is `counters.blocked` (stapel-listings 0.22.4) and no
81
+ * longer the unpaged takedown page: the two reads settle independently, and
82
+ * the one that decides whether a tab strip has three tabs or four should be
83
+ * the one that answers in three integers rather than the one that answers in
84
+ * a page of listings.
69
85
  */
70
86
  readonly tabs: readonly MyListingsTab[];
71
87
  setTab(tab: MyListingsTab): void;
72
- /** The three real counts, as the server reports them. */
88
+ /** The four real counts, as the server reports them. */
73
89
  readonly counters: LoadState<MyCounters>;
74
90
  /**
75
91
  * The number to DRAW on each tab — the server's counter, raised to what is
@@ -89,8 +105,13 @@ export interface MyListingsBag {
89
105
  *
90
106
  * The second half: a takedown was in no tab and therefore in no number, so
91
107
  * a cabinet holding one read "Active 0 · Drafts 0 · Archived 0" over it.
92
- * `removed` is counted from `blockedRows` there is no server counter to
93
- * read, and the rows are the only evidence on the wire.
108
+ * `removed` was counted from `blockedRows`, because there was no server
109
+ * counter to read and the rows were the only evidence on the wire. There is
110
+ * one now — `MyCountersResponse.blocked`, stapel-listings 0.22.4 — and it is
111
+ * what the fourth tab draws, under the same floor as the other three: the
112
+ * open tab's number is never below the rows on screen. `blockedRows` stays
113
+ * the FALLBACK for a deployment whose server predates the field, which is a
114
+ * count that is right up to a page and never a `0` over a visible row.
94
115
  */
95
116
  readonly tabCounts: LoadState<Readonly<Record<MyListingsTab, number>>>;
96
117
  /** The rows for the current tab — {@link MyListingsBag.blockedRows} while
@@ -206,8 +227,17 @@ export function useMyListings(
206
227
  // The takedown count, when it is known. Not a `0` while the read is in
207
228
  // flight: the tab strip is drawn off this number and a tab that appeared a
208
229
  // beat after the page settled would move the three beside it.
209
- const blockedCount =
230
+ //
231
+ // The SERVER's integer first (stapel-listings 0.22.4), the unpaged page's
232
+ // length only where a deployment's backend predates it. `counters.data` is
233
+ // typed with `blocked` required, so the runtime check is the honest one and
234
+ // not a type-driven one: an older server answers 200 with three keys, and a
235
+ // `?? 0` there would print "Taken down 0" over a row the seller can see.
236
+ const rowsBlockedCount =
210
237
  blockedRows.status === "ready" ? blockedRows.data.length : undefined;
238
+ const serverBlockedCount =
239
+ typeof counters.data?.blocked === "number" ? counters.data.blocked : undefined;
240
+ const blockedCount = serverBlockedCount ?? rowsBlockedCount;
211
241
 
212
242
  // Paging belongs to the tab's own keyset read. The takedowns are fetched
213
243
  // unpaged on purpose, so the removed tab has nowhere to go and says so.
@@ -229,13 +259,23 @@ export function useMyListings(
229
259
  one === tab ? Math.max(server[one], visible) : server[one],
230
260
  ])
231
261
  ) as Readonly<Record<MyListingsCountedTab, number>>),
232
- [MY_LISTINGS_REMOVED_TAB]: blockedCount ?? 0,
262
+ // The fourth tab takes the same floor as the other three: the server's
263
+ // number, raised to what is on screen while this tab is the open one.
264
+ // The counter and the unpaged page are two reads of one set and either
265
+ // may be the staler — a badge under its own rows is not a count.
266
+ [MY_LISTINGS_REMOVED_TAB]:
267
+ tab === MY_LISTINGS_REMOVED_TAB
268
+ ? Math.max(blockedCount ?? 0, visible)
269
+ : (blockedCount ?? 0),
233
270
  });
234
271
  }, [counters.status, counters.error, counters.data, rows, tab, blockedCount]);
235
272
 
236
273
  // The removed tab is drawn where it has something to say — or where the
237
274
  // address named it, so `?tab=removed` opens a real (if empty) tab rather
238
275
  // than an activeKey pointing at nothing.
276
+ //
277
+ // "Something to say" is the COUNTER since 0.22.4, so the tab arrives with
278
+ // `my/counters` rather than waiting for a page of takedowns to come back.
239
279
  const tabs: readonly MyListingsTab[] =
240
280
  removed || (blockedCount ?? 0) > 0 ? MY_LISTINGS_TABS : MY_LISTINGS_COUNTED_TABS;
241
281
 
@@ -285,8 +285,8 @@ export function listingStatusView(
285
285
  }
286
286
 
287
287
  /**
288
- * The three tabs `my/counters` COUNTS, and the statuses each one folds
289
- * together.
288
+ * The three tabs whose ROWS come off a keyset page, and the statuses each one
289
+ * folds together.
290
290
  *
291
291
  * The grouping is the SERVER's (`views.my_counters`), copied here so a tab's
292
292
  * caption and its count cannot describe different sets: `active` includes
@@ -294,10 +294,17 @@ export function listingStatusView(
294
294
  * both because a listing in those states is something the owner is still
295
295
  * working on. A client that grouped them its own way would show "3 active"
296
296
  * over two rows.
297
+ *
298
+ * The name is about the SOURCE, not about the counter: `MyCountersResponse`
299
+ * carries four integers since stapel-listings 0.22.4 (`blocked` joined the
300
+ * three), and this is still the set a {@link MyListingsCountedTab} —
301
+ * `MyListingsSource`'s whole parameter type — may name. The removed tab reads
302
+ * its rows off `?status=blocked` directly and unpaged, so a host that
303
+ * implemented the seam before the fourth tab existed is never handed it.
297
304
  */
298
305
  export const MY_LISTINGS_COUNTED_TABS = ["active", "drafts", "archived"] as const;
299
306
 
300
- /** One of the three tabs `MyCountersResponse` carries a number for. */
307
+ /** One of the three tabs a `MyListingsSource` is asked for. */
301
308
  export type MyListingsCountedTab = (typeof MY_LISTINGS_COUNTED_TABS)[number];
302
309
 
303
310
  const COUNTED_TAB_STATUSES: Readonly<
@@ -308,8 +315,9 @@ const COUNTED_TAB_STATUSES: Readonly<
308
315
  archived: ["archived", "paused", "expired", "sold"],
309
316
  };
310
317
 
311
- /** Which of the three COUNTED tabs a status belongs to, or `undefined` for
312
- * one the server's own counter groups nowhere — `blocked`, today. */
318
+ /** Which of the three keyset-paged tabs a status belongs to, or `undefined`
319
+ * for one none of them folds in — `blocked`, today, which has a tab and a
320
+ * counter of its own and a different row source under it. */
313
321
  export function countedTabOf(
314
322
  status: ListingLifecycleStatus
315
323
  ): MyListingsCountedTab | undefined {
@@ -320,13 +328,16 @@ export function countedTabOf(
320
328
  }
321
329
 
322
330
  /**
323
- * The statuses the server's own counter groups nowhere — `blocked`, and
324
- * nothing else today.
331
+ * The statuses none of the three paged tabs folds in — `blocked`, and nothing
332
+ * else today.
325
333
  *
326
334
  * DERIVED, not written down a second time: a status added upstream and left
327
- * out of the counter groupings lands here automatically and gets shown, which
328
- * is the opposite of what a hardcoded `["blocked"]` would do the day it goes
329
- * stale.
335
+ * out of the tab groupings lands here automatically and gets shown, which is
336
+ * the opposite of what a hardcoded `["blocked"]` would do the day it goes
337
+ * stale. That the server now has a `blocked` COUNTER for the same set does not
338
+ * make the derivation redundant: the counter names one status and this names
339
+ * whatever the three groupings leave over, which is the set the fourth tab
340
+ * must ASK for.
330
341
  */
331
342
  export const MY_LISTINGS_UNTABBED_STATUSES: readonly ListingLifecycleStatus[] =
332
343
  LISTING_STATUSES.filter((status) => countedTabOf(status) === undefined);
@@ -350,12 +361,16 @@ export const MY_LISTINGS_UNTABBED_STATUSES: readonly ListingLifecycleStatus[] =
350
361
  * ── Why a fourth tab and not the archive ─────────────────────────────────
351
362
  *
352
363
  * Folding `blocked` into `archived` is the other shape this could take, and
353
- * it costs the count: `my/counters` has three integers and no fourth, so an
354
- * archive tab holding takedowns would read the server's `archived` number —
355
- * `0` — until the tab was opened and its rows could raise it. The fourth tab
356
- * is counted from its OWN read (`?status=blocked`, unpaged), so the number is
357
- * right while the seller is looking at a different tab, which is exactly the
358
- * moment D407 was measured at.
364
+ * it cost the count: when D407 was written `my/counters` had three integers
365
+ * and no fourth, so an archive tab holding takedowns would have read the
366
+ * server's `archived` number — `0` — until the tab was opened and its rows
367
+ * could raise it. The fourth tab was counted from its OWN read
368
+ * (`?status=blocked`, unpaged) so that the number was right while the seller
369
+ * was looking at a different tab, which is exactly the moment D407 was
370
+ * measured at. stapel-listings 0.22.4 puts `blocked` in the counter, so the
371
+ * number is the server's now and that read is only the tab's rows — but the
372
+ * property it was there to defend is the same one, and it is now defended by
373
+ * the wire instead of by a page.
359
374
  *
360
375
  * The archive keeps its meaning too, which is not nothing: "I put this away"
361
376
  * and "a moderator took this down" are not the same sentence and a tab that