@stapel/listings-react 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +129 -0
- package/MODULE.md +3 -1
- package/README.md +102 -8
- package/dist/api/generated/schema.d.ts +20 -10
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/ListingActions.d.ts +8 -1
- package/dist/default/ListingActions.d.ts.map +1 -1
- package/dist/default/ListingActions.js +1 -1
- package/dist/default/ListingActions.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +148 -9
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +131 -19
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +10 -6
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/ShareAction.d.ts +16 -1
- package/dist/default/ShareAction.d.ts.map +1 -1
- package/dist/default/ShareAction.js +16 -9
- package/dist/default/ShareAction.js.map +1 -1
- package/dist/default/actionRow.d.ts +24 -0
- package/dist/default/actionRow.d.ts.map +1 -1
- package/dist/default/actionRow.js +50 -7
- package/dist/default/actionRow.js.map +1 -1
- package/dist/default/detailGallery.d.ts +77 -0
- package/dist/default/detailGallery.d.ts.map +1 -0
- package/dist/default/detailGallery.js +91 -0
- package/dist/default/detailGallery.js.map +1 -0
- package/dist/default/index.d.ts +4 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +5 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/movableCluster.d.ts +18 -0
- package/dist/default/movableCluster.d.ts.map +1 -0
- package/dist/default/movableCluster.js +110 -0
- package/dist/default/movableCluster.js.map +1 -0
- package/dist/headless/MyListings.d.ts +31 -10
- package/dist/headless/MyListings.d.ts.map +1 -1
- package/dist/headless/MyListings.js +19 -2
- package/dist/headless/MyListings.js.map +1 -1
- package/dist/headless/Share.d.ts +60 -2
- package/dist/headless/Share.d.ts.map +1 -1
- package/dist/headless/Share.js +55 -3
- package/dist/headless/Share.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/model/cardBadges.d.ts +25 -0
- package/dist/model/cardBadges.d.ts.map +1 -1
- package/dist/model/cardBadges.js +41 -5
- package/dist/model/cardBadges.js.map +1 -1
- package/dist/model/draft.d.ts +42 -1
- package/dist/model/draft.d.ts.map +1 -1
- package/dist/model/draft.js +40 -3
- package/dist/model/draft.js.map +1 -1
- package/dist/model/status.d.ts +31 -16
- package/dist/model/status.d.ts.map +1 -1
- package/dist/model/status.js +30 -15
- package/dist/model/status.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +8 -1
- package/nav-manifest.json +1 -1
- package/package.json +3 -3
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +20 -10
- package/src/default/ListingActions.tsx +9 -1
- package/src/default/ListingDetailPane.tsx +316 -18
- package/src/default/MyListingsPane.tsx +10 -6
- package/src/default/ShareAction.tsx +36 -10
- package/src/default/actionRow.ts +55 -7
- package/src/default/detailGallery.ts +97 -0
- package/src/default/index.ts +15 -1
- package/src/default/movableCluster.tsx +137 -0
- package/src/headless/MyListings.tsx +52 -12
- package/src/headless/Share.tsx +98 -5
- package/src/index.ts +7 -0
- package/src/model/cardBadges.ts +42 -5
- package/src/model/draft.ts +61 -3
- package/src/model/status.ts +31 -16
|
@@ -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,
|
|
@@ -142,6 +155,7 @@ export {
|
|
|
142
155
|
LISTING_ACTION_CLASS,
|
|
143
156
|
LISTING_ACTION_HIT,
|
|
144
157
|
LISTING_ACTION_LABEL_CLASS,
|
|
158
|
+
LISTING_ACTION_SPECIFICITY,
|
|
145
159
|
LISTING_CARD_ACTION_CLASS,
|
|
146
160
|
LISTING_CARD_ACTION_HIT,
|
|
147
161
|
actionRowCss,
|
|
@@ -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/headless/Share.tsx
CHANGED
|
@@ -17,6 +17,15 @@
|
|
|
17
17
|
* menu with "copy the link" and the three networks a Russian-speaking
|
|
18
18
|
* marketplace actually gets traffic from.
|
|
19
19
|
*
|
|
20
|
+
* ── …AND "HAS A SHEET" IS NOT "SHOULD USE THE SHEET" (§25) ───────────────
|
|
21
|
+
*
|
|
22
|
+
* `navigator.share` is true on desktop Chrome on macOS. Measured on the stand:
|
|
23
|
+
* every share on the storefront opened the OS sheet and the copy-link menu —
|
|
24
|
+
* three networks and a clipboard row, built for exactly that platform — was
|
|
25
|
+
* unreachable there. So the decision takes a second reading, the primary
|
|
26
|
+
* POINTER, and {@link UseShareOptions.prefer} lets a surface state the answer
|
|
27
|
+
* outright. See {@link SharePreference}.
|
|
28
|
+
*
|
|
20
29
|
* `native` is resolved in an EFFECT rather than during render, for the reason
|
|
21
30
|
* `cardGallery.ts`'s `useFinePointer` gives at length: a server render has no
|
|
22
31
|
* `navigator`, and a first client render that disagreed with it is a
|
|
@@ -134,6 +143,70 @@ export function hasNativeShare(): boolean {
|
|
|
134
143
|
return typeof navigator.share === "function";
|
|
135
144
|
}
|
|
136
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The media query that asks "is this a thumb" — the second half of the arm
|
|
148
|
+
* decision (§25).
|
|
149
|
+
*
|
|
150
|
+
* `(pointer: coarse)` describes the PRIMARY input device, which is exactly the
|
|
151
|
+
* question: a phone and a tablet match, a mouse and a trackpad do not, and a
|
|
152
|
+
* touchscreen laptop being driven with its trackpad reports the trackpad.
|
|
153
|
+
*/
|
|
154
|
+
export const SHARE_COARSE_MEDIA = "(pointer: coarse)";
|
|
155
|
+
|
|
156
|
+
/** Does the primary pointer look like a finger? `false` wherever the question
|
|
157
|
+
* cannot be asked — a server, an engine without `matchMedia` — because the
|
|
158
|
+
* fallback arm is the one that draws something. */
|
|
159
|
+
export function hasCoarsePointer(): boolean {
|
|
160
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
return window.matchMedia(SHARE_COARSE_MEDIA).matches;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* WHICH ARM A SURFACE WANTS, and why `navigator.share` alone was the wrong
|
|
172
|
+
* question.
|
|
173
|
+
*
|
|
174
|
+
* The capability probe is true on desktop Chrome on macOS — measured on the
|
|
175
|
+
* stand (§25), where every share on the storefront opened the OS sheet and the
|
|
176
|
+
* copy-link menu was therefore unreachable on the platform it was BUILT for.
|
|
177
|
+
* "Has a share sheet" and "is a device whose share sheet is the better answer"
|
|
178
|
+
* turned out to be two questions, and the pair was only asking the first.
|
|
179
|
+
*
|
|
180
|
+
* `"auto"` (default) the platform sheet only where the primary pointer is
|
|
181
|
+
* COARSE and the API exists; a mouse gets the menu, with its
|
|
182
|
+
* copy-link row and its three networks.
|
|
183
|
+
* `"menu"` always this pair's menu, whatever the device offers. For a
|
|
184
|
+
* host whose desktop and mobile web are one build and which
|
|
185
|
+
* wants one answer.
|
|
186
|
+
* `"native"` the platform sheet wherever the API exists, pointer ignored —
|
|
187
|
+
* the behaviour every version before this one had, kept
|
|
188
|
+
* reachable by name rather than deleted.
|
|
189
|
+
*
|
|
190
|
+
* In all three, a missing `navigator.share` is the menu: an arm that cannot
|
|
191
|
+
* open is not an arm.
|
|
192
|
+
*/
|
|
193
|
+
export type SharePreference = "auto" | "menu" | "native";
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Resolve the arm from the preference and what the device actually answered.
|
|
197
|
+
*
|
|
198
|
+
* Pure, and separate from the hook, so the decision is a thing a test reads
|
|
199
|
+
* rather than a thing a rendered `data-share-mode` implies.
|
|
200
|
+
*/
|
|
201
|
+
export function preferNativeShare(
|
|
202
|
+
prefer: SharePreference,
|
|
203
|
+
capability: { readonly native: boolean; readonly coarse: boolean }
|
|
204
|
+
): boolean {
|
|
205
|
+
if (prefer === "menu") return false;
|
|
206
|
+
if (!capability.native) return false;
|
|
207
|
+
return prefer === "native" || capability.coarse;
|
|
208
|
+
}
|
|
209
|
+
|
|
137
210
|
export interface UseShareOptions {
|
|
138
211
|
/** The canonical address, absolute or a path. Absent: the address bar,
|
|
139
212
|
* which is the honest answer only for a host with no route seam. */
|
|
@@ -146,13 +219,24 @@ export interface UseShareOptions {
|
|
|
146
219
|
* through; `"native"` never says which app, because the sheet does not
|
|
147
220
|
* tell the page. */
|
|
148
221
|
readonly onShared?: ((channel: ShareChannel) => void) | undefined;
|
|
222
|
+
/**
|
|
223
|
+
* Which arm this surface wants — see {@link SharePreference}. Default
|
|
224
|
+
* `"auto"`: the platform sheet on a coarse pointer, this pair's menu on a
|
|
225
|
+
* mouse, the menu wherever `navigator.share` is missing.
|
|
226
|
+
*/
|
|
227
|
+
readonly prefer?: SharePreference | undefined;
|
|
149
228
|
}
|
|
150
229
|
|
|
151
230
|
export interface ShareBag {
|
|
152
231
|
/** The absolute address every arm shares. `undefined` only on a server. */
|
|
153
232
|
readonly url: string | undefined;
|
|
154
|
-
/**
|
|
155
|
-
*
|
|
233
|
+
/**
|
|
234
|
+
* IS THE PLATFORM SHEET THE ARM ON SCREEN — the resolved answer, not the
|
|
235
|
+
* raw capability. `prefer` and the primary pointer are both in it (see
|
|
236
|
+
* {@link SharePreference}); `hasNativeShare()` is the capability alone, for
|
|
237
|
+
* a caller that wants to ask that question itself. Settles in an effect —
|
|
238
|
+
* see the file header.
|
|
239
|
+
*/
|
|
156
240
|
readonly native: boolean;
|
|
157
241
|
/** The three networks' links, already encoded. */
|
|
158
242
|
readonly links: readonly ShareLink[];
|
|
@@ -182,15 +266,24 @@ export const SHARE_COPIED_MS = 2400;
|
|
|
182
266
|
|
|
183
267
|
export function useShare(options: UseShareOptions = {}): ShareBag {
|
|
184
268
|
const { url: given, title, text, onShared } = options;
|
|
269
|
+
const prefer = options.prefer ?? "auto";
|
|
185
270
|
const [native, setNative] = useState(false);
|
|
186
271
|
const [copied, setCopied] = useState(false);
|
|
187
272
|
const [copyFailed, setCopyFailed] = useState(false);
|
|
188
273
|
|
|
189
274
|
// See the header: resolved in an effect so a server render and the
|
|
190
|
-
// hydration pass that must agree with it draw the same arm.
|
|
275
|
+
// hydration pass that must agree with it draw the same arm. Both halves of
|
|
276
|
+
// the question are asked in the SAME effect — the capability and the
|
|
277
|
+
// pointer — so there is never a frame in which one has landed and the
|
|
278
|
+
// other has not and the button changes arm twice.
|
|
191
279
|
useEffect(() => {
|
|
192
|
-
setNative(
|
|
193
|
-
|
|
280
|
+
setNative(
|
|
281
|
+
preferNativeShare(prefer, {
|
|
282
|
+
native: hasNativeShare(),
|
|
283
|
+
coarse: hasCoarsePointer(),
|
|
284
|
+
})
|
|
285
|
+
);
|
|
286
|
+
}, [prefer]);
|
|
194
287
|
|
|
195
288
|
const url = useMemo(() => resolveShareUrl(given), [given]);
|
|
196
289
|
const links = useMemo(
|
package/src/index.ts
CHANGED
|
@@ -163,7 +163,9 @@ export {
|
|
|
163
163
|
retainKnownFeatureValues,
|
|
164
164
|
} from "./model/draft.js";
|
|
165
165
|
export type {
|
|
166
|
+
DraftPatchOptions,
|
|
166
167
|
EmptyDraftOptions,
|
|
168
|
+
ListingDraftField,
|
|
167
169
|
ListingDraftValues,
|
|
168
170
|
ListingLocation,
|
|
169
171
|
} from "./model/draft.js";
|
|
@@ -184,6 +186,7 @@ export { featureUnit, formatSpecValue } from "./model/featureText.js";
|
|
|
184
186
|
export {
|
|
185
187
|
badgePresentation,
|
|
186
188
|
badgeValueText,
|
|
189
|
+
captionName,
|
|
187
190
|
cardBadgeText,
|
|
188
191
|
cardBadgeTexts,
|
|
189
192
|
hasCardBadgeContract,
|
|
@@ -316,10 +319,13 @@ export type {
|
|
|
316
319
|
UseFavoritesOptions,
|
|
317
320
|
} from "./headless/Favorites.js";
|
|
318
321
|
export {
|
|
322
|
+
SHARE_COARSE_MEDIA,
|
|
319
323
|
SHARE_COPIED_MS,
|
|
320
324
|
SHARE_NETWORKS,
|
|
321
325
|
Share,
|
|
326
|
+
hasCoarsePointer,
|
|
322
327
|
hasNativeShare,
|
|
328
|
+
preferNativeShare,
|
|
323
329
|
resolveShareUrl,
|
|
324
330
|
shareLinks,
|
|
325
331
|
useShare,
|
|
@@ -329,6 +335,7 @@ export type {
|
|
|
329
335
|
ShareChannel,
|
|
330
336
|
ShareLink,
|
|
331
337
|
ShareNetwork,
|
|
338
|
+
SharePreference,
|
|
332
339
|
ShareTarget,
|
|
333
340
|
UseShareOptions,
|
|
334
341
|
} from "./headless/Share.js";
|
package/src/model/cardBadges.ts
CHANGED
|
@@ -227,12 +227,41 @@ function isTrue(raw: unknown): boolean {
|
|
|
227
227
|
*/
|
|
228
228
|
export type CardBadgeStyle = "badge" | "line";
|
|
229
229
|
|
|
230
|
+
/**
|
|
231
|
+
* A CAPTION AS THIS PAIR WILL PUNCTUATE IT — the catalogue's own trailing
|
|
232
|
+
* colon stripped (D455).
|
|
233
|
+
*
|
|
234
|
+
* Measured on a live feed, translated: one card in twenty-four read
|
|
235
|
+
* "HONOR · **Model:: 90** · 256 GB". The catalogue row for that leaf spells
|
|
236
|
+
* the feature's name "Model:" — with the colon IN the name — and presents it
|
|
237
|
+
* `name_value`, while the neighbouring listing's row for the same slug spells
|
|
238
|
+
* it "Model" and presents it `value`. So the content is inconsistent and only
|
|
239
|
+
* one half of that is ours; what is ours is that {@link caption} then adds a
|
|
240
|
+
* second colon to a name that already ended in one.
|
|
241
|
+
*
|
|
242
|
+
* Punctuation between a caption and its answer is the SURFACE's decision (see
|
|
243
|
+
* the module header) — which means it is not the catalogue's, and a name that
|
|
244
|
+
* arrives carrying its own is a name with a separator baked into it. It is
|
|
245
|
+
* taken off here, once, so both styles are unaffected by which of the two
|
|
246
|
+
* spellings a row happens to use: the chip draws "Model 90" and the line
|
|
247
|
+
* "Model: 90" either way.
|
|
248
|
+
*
|
|
249
|
+
* Only a TRAILING colon, and only the colon: a name is otherwise printed
|
|
250
|
+
* exactly as the catalogue wrote it. "Model: year:" is not a shape anybody
|
|
251
|
+
* sends, and a rule that chewed punctuation off the end of every caption
|
|
252
|
+
* would eventually eat a name that meant it.
|
|
253
|
+
*/
|
|
254
|
+
export function captionName(name: string): string {
|
|
255
|
+
return name.replace(/\s*:+$/u, "");
|
|
256
|
+
}
|
|
257
|
+
|
|
230
258
|
/** A caption and its answer, joined the way this surface separates them. */
|
|
231
259
|
function caption(name: string, body: string, style: CardBadgeStyle): string {
|
|
232
260
|
// A SPACE in a chip and a COLON in a line. "Floor 3" is a caption inside a
|
|
233
261
|
// border; "Floor: 3" is what the same pair has to become when the border is
|
|
234
262
|
// gone and the neighbours are a dot away.
|
|
235
|
-
|
|
263
|
+
const head = captionName(name);
|
|
264
|
+
return style === "line" ? `${head}: ${body}` : `${head} ${body}`;
|
|
236
265
|
}
|
|
237
266
|
|
|
238
267
|
/**
|
|
@@ -250,11 +279,16 @@ export function cardBadgeText(
|
|
|
250
279
|
style: CardBadgeStyle = "badge"
|
|
251
280
|
): string | undefined {
|
|
252
281
|
const presentation = badgePresentation(row);
|
|
253
|
-
|
|
282
|
+
// Normalised ONCE, here (D455): every arm below asks "is there a name to
|
|
283
|
+
// print", and the answer has to be about the name this pair will actually
|
|
284
|
+
// draw — a row whose whole name is ":" has none.
|
|
285
|
+
const name = captionName(text(row.name));
|
|
254
286
|
const unit = text(row.unit);
|
|
255
287
|
|
|
256
288
|
if (presentation === "name") {
|
|
257
|
-
// The name IS the badge, and only while the answer is yes.
|
|
289
|
+
// The name IS the badge, and only while the answer is yes. Normalised by
|
|
290
|
+
// the same rule as a caption: a lone "Brick:" is a colon with nothing
|
|
291
|
+
// after it, which is the defect in its plainest form.
|
|
258
292
|
return isTrue(row.value) && name.length > 0 ? name : undefined;
|
|
259
293
|
}
|
|
260
294
|
|
|
@@ -288,7 +322,7 @@ export function cardBadgeText(
|
|
|
288
322
|
function alreadyCaptioned(row: CardBadgeRow): boolean {
|
|
289
323
|
const presentation = badgePresentation(row);
|
|
290
324
|
if (presentation === "name") return true;
|
|
291
|
-
return presentation === "name_value" && text(row.name).length > 0;
|
|
325
|
+
return presentation === "name_value" && captionName(text(row.name)).length > 0;
|
|
292
326
|
}
|
|
293
327
|
|
|
294
328
|
/**
|
|
@@ -331,7 +365,10 @@ export function cardBadgeTexts(
|
|
|
331
365
|
|
|
332
366
|
for (const positions of groups.values()) {
|
|
333
367
|
if (positions.length < 2) continue;
|
|
334
|
-
|
|
368
|
+
// The names as they will be DRAWN (D455) — so a catalogue row spelling
|
|
369
|
+
// one axis "Floor:" and the other "Floor" is two spellings of one word
|
|
370
|
+
// here rather than two distinct captions that tell a reader nothing apart.
|
|
371
|
+
const names = positions.map((at) => captionName(text(printedRows[at]?.name)));
|
|
335
372
|
// Nothing to caption with, or one word for both axes: leave the line as
|
|
336
373
|
// the server wrote it rather than adding a caption that tells a reader
|
|
337
374
|
// nothing they did not already have.
|