@stapel/listings-react 0.27.0 → 0.28.1
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 +123 -0
- package/README.md +56 -7
- 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 +52 -1
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +4 -2
- package/dist/default/ListingDetailPane.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/index.d.ts +1 -1
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/notice.d.ts.map +1 -1
- package/dist/default/notice.js +46 -4
- package/dist/default/notice.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/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/default/ListingActions.tsx +9 -1
- package/src/default/ListingDetailPane.tsx +57 -3
- package/src/default/ShareAction.tsx +36 -10
- package/src/default/actionRow.ts +55 -7
- package/src/default/index.ts +1 -0
- package/src/default/notice.ts +56 -4
- 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/default/actionRow.ts
CHANGED
|
@@ -86,13 +86,62 @@ export const LISTING_ACTION_HIT: number = controls["height-phone"];
|
|
|
86
86
|
*/
|
|
87
87
|
export const LISTING_CARD_ACTION_HIT = 36;
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* HOW MANY TIMES THE HIT-TARGET CLASS IS REPEATED IN ITS OWN SELECTOR — and
|
|
91
|
+
* why a repeat rather than a number typed once (D450).
|
|
92
|
+
*
|
|
93
|
+
* Measured on the live listing page: the heart and the share glyph were
|
|
94
|
+
* **32 × 44**, not 44 × 44. The block axis survived and the inline one did
|
|
95
|
+
* not, because antd's circle shape ships
|
|
96
|
+
*
|
|
97
|
+
* `:where(…).ant-btn.ant-btn-circle.ant-btn{min-width:var(--ant-control-height)}`
|
|
98
|
+
*
|
|
99
|
+
* — three classes, specificity (0,3,0), against this sheet's single class
|
|
100
|
+
* (0,1,0). `:where()` adds nothing, and a media query adds nothing either, so
|
|
101
|
+
* the only thing that decides is the class count: antd's 32 won and there was
|
|
102
|
+
* no viewport at which it did not.
|
|
103
|
+
*
|
|
104
|
+
* A repeated class is the one way to outrank it without `!important`. Four
|
|
105
|
+
* repeats — (0,4,0) — clear antd's three with one to spare, and the selector
|
|
106
|
+
* still matches exactly the same element, so nothing about WHAT the rule
|
|
107
|
+
* applies to changes. `!important` was refused deliberately: a host that
|
|
108
|
+
* genuinely wants a different target must be able to say so with a selector,
|
|
109
|
+
* and this sheet's whole argument (see `movableCluster.tsx`) is that a pair's
|
|
110
|
+
* geometry should never force one on somebody else.
|
|
111
|
+
*/
|
|
112
|
+
export const LISTING_ACTION_SPECIFICITY = 4;
|
|
113
|
+
|
|
114
|
+
/** A class name repeated {@link LISTING_ACTION_SPECIFICITY} times — the
|
|
115
|
+
* selector that beats antd's circle. */
|
|
116
|
+
function outranking(className: string): string {
|
|
117
|
+
return `.${className}`.repeat(LISTING_ACTION_SPECIFICITY);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The floor, in BOTH spellings of the same axis.
|
|
122
|
+
*
|
|
123
|
+
* A browser cascades `min-inline-size` and `min-width` together and keeps the
|
|
124
|
+
* winner, so in a browser the logical pair alone would be enough once the
|
|
125
|
+
* selector outranks antd's. The physical pair is written beside it because
|
|
126
|
+
* the declaration being beaten is spelled physically, and an engine that does
|
|
127
|
+
* NOT merge the two names (jsdom, where this rule is asserted) would leave
|
|
128
|
+
* antd's `min-width:32px` standing beside our `min-inline-size:44px` and call
|
|
129
|
+
* that a pass. Two spellings of one number, and the number has one source.
|
|
130
|
+
*/
|
|
131
|
+
function floor(size: number): string {
|
|
132
|
+
const px = `${String(size)}px`;
|
|
133
|
+
return (
|
|
134
|
+
`min-inline-size:${px};min-block-size:${px};` +
|
|
135
|
+
`min-width:${px};min-height:${px}`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
89
139
|
export function actionRowCss(): string {
|
|
90
|
-
const action =
|
|
91
|
-
const cardAction =
|
|
140
|
+
const action = outranking(LISTING_ACTION_CLASS);
|
|
141
|
+
const cardAction = outranking(LISTING_CARD_ACTION_CLASS);
|
|
92
142
|
const row = `.${LISTING_ACTIONS_CLASS}`;
|
|
93
143
|
const over = `.${LISTING_ACTIONS_OVERLAY_CLASS}`;
|
|
94
144
|
const label = `.${LISTING_ACTION_LABEL_CLASS}`;
|
|
95
|
-
const hit = String(LISTING_ACTION_HIT);
|
|
96
145
|
const phone = `(max-width:${String(breakpoints.tablet - 1)}px)`;
|
|
97
146
|
return [
|
|
98
147
|
// The floor. `min-*` rather than `width`/`height`: a share button with a
|
|
@@ -100,14 +149,13 @@ export function actionRowCss(): string {
|
|
|
100
149
|
// `controlHeight` already reaches the height on a phone — this is the
|
|
101
150
|
// guarantee for every OTHER viewport and for a host-registered button
|
|
102
151
|
// that never read the antd token at all.
|
|
103
|
-
`${action}{
|
|
152
|
+
`${action}{${floor(LISTING_ACTION_HIT)};` +
|
|
104
153
|
`display:inline-flex;align-items:center;justify-content:center}`,
|
|
105
154
|
// A card's control: one tier smaller where there is a cursor, the same
|
|
106
155
|
// 44px where there is a thumb. See LISTING_CARD_ACTION_HIT.
|
|
107
|
-
`${cardAction}{
|
|
108
|
-
`min-block-size:${String(LISTING_CARD_ACTION_HIT)}px;` +
|
|
156
|
+
`${cardAction}{${floor(LISTING_CARD_ACTION_HIT)};` +
|
|
109
157
|
`display:inline-flex;align-items:center;justify-content:center}`,
|
|
110
|
-
`@media ${phone}{${cardAction}{
|
|
158
|
+
`@media ${phone}{${cardAction}{${floor(LISTING_ACTION_HIT)}}}`,
|
|
111
159
|
// The cluster. `align-items:flex-end` so a blocked heart's reason — the
|
|
112
160
|
// one thing here that can be two lines — stacks against the same edge
|
|
113
161
|
// instead of pushing the controls inwards (the arrangement
|
package/src/default/index.ts
CHANGED
package/src/default/notice.ts
CHANGED
|
@@ -30,13 +30,43 @@
|
|
|
30
30
|
* the heart's state is on the heart. This is the AMPLIFIER, never the record.
|
|
31
31
|
* A future `SkinNotice` in the substrate replaces the body of this function
|
|
32
32
|
* and nothing else; the callers already speak in resolved sentences.
|
|
33
|
+
*
|
|
34
|
+
* ── An amplifier may not outlive what it amplifies ────────────────────────
|
|
35
|
+
*
|
|
36
|
+
* Both arms hand the notice to a holder MOUNTED OUTSIDE this component tree —
|
|
37
|
+
* antd's `<App>` holder in the first arm, and in the second a React root antd
|
|
38
|
+
* renders into the document the first time the static entry is called. Neither
|
|
39
|
+
* is unmounted by unmounting the surface that spoke, and a standing notice is
|
|
40
|
+
* not an idle DOM node: `@rc-component/notification` counts its two seconds
|
|
41
|
+
* down with a `requestAnimationFrame` LOOP (`useNoticeTimer`), which is live
|
|
42
|
+
* work driven from a root nothing in this package owns.
|
|
43
|
+
*
|
|
44
|
+
* So the notices raised here are RETIRED when the surface that raised them
|
|
45
|
+
* goes. Two things follow, and both are the intent:
|
|
46
|
+
*
|
|
47
|
+
* - a person who presses the heart and immediately navigates away does not
|
|
48
|
+
* get "Saved" floating over the next page, about a listing they left;
|
|
49
|
+
* - nothing this pair started keeps running after the tree it started in is
|
|
50
|
+
* gone. Measured as CI flake: the detail page's heart toast kept stepping
|
|
51
|
+
* its rAF loop past the end of the test file that raised it, and the frame
|
|
52
|
+
* that landed after the environment was torn down threw
|
|
53
|
+
* `ReferenceError: window is not defined` out of react-dom — from a suite
|
|
54
|
+
* in which every test had passed.
|
|
33
55
|
*/
|
|
34
|
-
import { useCallback } from "react";
|
|
56
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
35
57
|
import { App, message as staticMessage } from "antd";
|
|
36
58
|
|
|
37
59
|
/** Say one short sentence. Resolved copy — this is the skin, not a bag. */
|
|
38
60
|
export type Notice = (text: string) => void;
|
|
39
61
|
|
|
62
|
+
/**
|
|
63
|
+
* What antd hands back for a raised notice: CALL it to close the notice early,
|
|
64
|
+
* `then` it to learn that it closed on its own. Typed structurally rather than
|
|
65
|
+
* imported (`antd/es/message/interface`) so this file keeps to antd's public
|
|
66
|
+
* entry — the shape is antd's documented `MessageType` either way.
|
|
67
|
+
*/
|
|
68
|
+
type RaisedNotice = (() => void) & PromiseLike<unknown>;
|
|
69
|
+
|
|
40
70
|
/**
|
|
41
71
|
* How long a confirmation stands, in seconds.
|
|
42
72
|
*
|
|
@@ -51,18 +81,40 @@ export const NOTICE_SECONDS = 2;
|
|
|
51
81
|
export function useNotice(): Notice {
|
|
52
82
|
const app = App.useApp();
|
|
53
83
|
const contextual = app.message.success;
|
|
84
|
+
// The notices this surface has raised and that have not closed themselves
|
|
85
|
+
// yet — see the header. A notice retires itself the moment it closes, so
|
|
86
|
+
// this holds at most the handful still on screen.
|
|
87
|
+
const standing = useRef<Set<RaisedNotice>>(new Set());
|
|
88
|
+
useEffect(
|
|
89
|
+
() => () => {
|
|
90
|
+
const open = standing.current;
|
|
91
|
+
standing.current = new Set();
|
|
92
|
+
for (const close of open) close();
|
|
93
|
+
},
|
|
94
|
+
[]
|
|
95
|
+
);
|
|
96
|
+
const hold = useCallback((raised: RaisedNotice): void => {
|
|
97
|
+
standing.current.add(raised);
|
|
98
|
+
const retire = (): void => {
|
|
99
|
+
standing.current.delete(raised);
|
|
100
|
+
};
|
|
101
|
+
// Closed by its own timer, by our unmount, or by a host calling
|
|
102
|
+
// `message.destroy()` — every ending resolves this, and a rejection is an
|
|
103
|
+
// ending too. Nothing is awaited: the notice is already on screen.
|
|
104
|
+
raised.then(retire, retire);
|
|
105
|
+
}, []);
|
|
54
106
|
return useCallback(
|
|
55
107
|
(text: string): void => {
|
|
56
108
|
if (typeof contextual === "function") {
|
|
57
|
-
contextual(text, NOTICE_SECONDS);
|
|
109
|
+
hold(contextual(text, NOTICE_SECONDS) as RaisedNotice);
|
|
58
110
|
return;
|
|
59
111
|
}
|
|
60
112
|
// No `<App>` above us. antd's static entry renders its own holder into
|
|
61
113
|
// the document, which is exactly right for a host that never opted in,
|
|
62
114
|
// and is a no-op on a server where there is no document to render into.
|
|
63
115
|
if (typeof document === "undefined") return;
|
|
64
|
-
staticMessage.success(text, NOTICE_SECONDS);
|
|
116
|
+
hold(staticMessage.success(text, NOTICE_SECONDS) as RaisedNotice);
|
|
65
117
|
},
|
|
66
|
-
[contextual]
|
|
118
|
+
[contextual, hold]
|
|
67
119
|
);
|
|
68
120
|
}
|
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.
|
package/src/model/draft.ts
CHANGED
|
@@ -224,6 +224,28 @@ function toWireFeatures(
|
|
|
224
224
|
return toFeaturesDto(features, values) as unknown as WireFeaturesDraft;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
/** One field of the `save-draft` body, by the name the wire uses. */
|
|
228
|
+
export type ListingDraftField = keyof ListingDraftPatch;
|
|
229
|
+
|
|
230
|
+
/** Options for {@link draftPatchFromValues}. */
|
|
231
|
+
export interface DraftPatchOptions {
|
|
232
|
+
/**
|
|
233
|
+
* NAME THE FIELDS THIS SAVE IS WRITING, and the body carries no others.
|
|
234
|
+
*
|
|
235
|
+
* `save-draft` REPLACES every field in the body — it does not merge — so a
|
|
236
|
+
* body is not "the values I have", it is "the fields I am claiming". A save
|
|
237
|
+
* fired by one control (a photo settling, a blurred title) previously
|
|
238
|
+
* claimed all fourteen, which is only harmless while every one of them is
|
|
239
|
+
* loaded and true.
|
|
240
|
+
*
|
|
241
|
+
* Given, only these keys are spelled; the omission rules below still apply
|
|
242
|
+
* inside the selection, so naming `features_draft` without a schema still
|
|
243
|
+
* writes nothing. Absent, the whole body is sent, which is right for the
|
|
244
|
+
* composer's own save — it holds every value on the form.
|
|
245
|
+
*/
|
|
246
|
+
readonly fields?: readonly ListingDraftField[];
|
|
247
|
+
}
|
|
248
|
+
|
|
227
249
|
/**
|
|
228
250
|
* The composer's values → the `save-draft` body.
|
|
229
251
|
*
|
|
@@ -234,12 +256,34 @@ function toWireFeatures(
|
|
|
234
256
|
* (`error.400.listing_feature_not_allowed`), which is why
|
|
235
257
|
* {@link retainKnownFeatureValues} prunes on the way in rather than letting
|
|
236
258
|
* the server explain it.
|
|
259
|
+
*
|
|
260
|
+
* ── NO SCHEMA IS NOT AN EMPTY ANSWER SHEET ───────────────────────────────
|
|
261
|
+
*
|
|
262
|
+
* An absent or empty `features` used to produce `features_draft: {}`, and
|
|
263
|
+
* `save-draft` REPLACES that map rather than merging into it — so a save that
|
|
264
|
+
* left before the category's schema arrived DELETED every characteristic the
|
|
265
|
+
* row was holding, while the form on screen still showed them. Measured by a
|
|
266
|
+
* live container over two cold loads of one draft: a reopen's own
|
|
267
|
+
* settled-photo save fires in the first commit, before the row's category has
|
|
268
|
+
* even been adopted, and the row alternated between the draft's answers and
|
|
269
|
+
* none of them (a client storefront's README, "Named gaps").
|
|
270
|
+
*
|
|
271
|
+
* `{}` is a claim — "this listing has no characteristics" — and a caller with
|
|
272
|
+
* no schema is in no position to make it. So the key is OMITTED entirely
|
|
273
|
+
* whenever there is no schema to tag values with, and `save-draft` then
|
|
274
|
+
* leaves the stored map exactly as it was. A category that genuinely declares
|
|
275
|
+
* no features writes nothing either, which is the same answer arrived at
|
|
276
|
+
* honestly: there is nothing to say and the row already says it.
|
|
277
|
+
*
|
|
278
|
+
* {@link DraftPatchOptions.fields} is the general form of the same rule — a
|
|
279
|
+
* save that names what it is writing cannot erase what it is not.
|
|
237
280
|
*/
|
|
238
281
|
export function draftPatchFromValues(
|
|
239
282
|
values: ListingDraftValues,
|
|
240
|
-
features: readonly FeatureDef[]
|
|
283
|
+
features: readonly FeatureDef[] | undefined,
|
|
284
|
+
options: DraftPatchOptions = {}
|
|
241
285
|
): ListingDraftPatch {
|
|
242
|
-
|
|
286
|
+
const full: ListingDraftPatch = {
|
|
243
287
|
// Omitted while unchosen rather than sent as `""`: a draft is allowed to
|
|
244
288
|
// have no category (0.21.4), and `""` is not "no category" on the wire —
|
|
245
289
|
// it is an empty id the serializer refuses. The category is written by
|
|
@@ -258,7 +302,11 @@ export function draftPatchFromValues(
|
|
|
258
302
|
// sent here is discarded. Sending one would be a claim the wire ignores.
|
|
259
303
|
lat_draft: values.location.lat,
|
|
260
304
|
lon_draft: values.location.lon,
|
|
261
|
-
|
|
305
|
+
// Omitted, not emptied, when there is no schema to tag with — see the
|
|
306
|
+
// doc above. `{}` is a claim about the listing; silence is not.
|
|
307
|
+
...(features !== undefined && features.length > 0
|
|
308
|
+
? { features_draft: toWireFeatures(features, values.features) }
|
|
309
|
+
: {}),
|
|
262
310
|
countable: values.countable,
|
|
263
311
|
// The pair mirrors the model's cross-field rule rather than sending a
|
|
264
312
|
// contradiction: a service carries no quantity, and `validate_countable
|
|
@@ -266,6 +314,16 @@ export function draftPatchFromValues(
|
|
|
266
314
|
stock_quantity: values.countable ? values.stockQuantity : null,
|
|
267
315
|
auto_republish: values.autoRepublish,
|
|
268
316
|
};
|
|
317
|
+
const named = options.fields;
|
|
318
|
+
if (named === undefined) return full;
|
|
319
|
+
// A selection, not a second body: the field's VALUE is still whatever the
|
|
320
|
+
// rules above produced, so a named field the rules omit stays omitted.
|
|
321
|
+
const wanted = new Set<string>(named);
|
|
322
|
+
const out: Record<string, unknown> = {};
|
|
323
|
+
for (const [key, value] of Object.entries(full)) {
|
|
324
|
+
if (wanted.has(key)) out[key] = value;
|
|
325
|
+
}
|
|
326
|
+
return out as ListingDraftPatch;
|
|
269
327
|
}
|
|
270
328
|
|
|
271
329
|
/**
|