@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
|
@@ -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
|
|
53
|
-
* three integers and no fourth, so the removed tab
|
|
54
|
-
* holds —
|
|
55
|
-
* whichever tab is open
|
|
56
|
-
* seller with more takedowns than one page of `my/listings`
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
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`
|
|
93
|
-
* read
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/model/status.ts
CHANGED
|
@@ -285,8 +285,8 @@ export function listingStatusView(
|
|
|
285
285
|
}
|
|
286
286
|
|
|
287
287
|
/**
|
|
288
|
-
* The three tabs
|
|
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 `
|
|
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
|
|
312
|
-
* one
|
|
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
|
|
324
|
-
*
|
|
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
|
|
328
|
-
*
|
|
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
|
|
354
|
-
* archive tab holding takedowns would read the
|
|
355
|
-
* `0` — until the tab was opened and its rows
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
* moment D407 was
|
|
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
|