@stapel/listings-react 0.27.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +56 -7
  3. package/dist/default/ListingActions.d.ts +8 -1
  4. package/dist/default/ListingActions.d.ts.map +1 -1
  5. package/dist/default/ListingActions.js +1 -1
  6. package/dist/default/ListingActions.js.map +1 -1
  7. package/dist/default/ListingDetailPane.d.ts +52 -1
  8. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  9. package/dist/default/ListingDetailPane.js +4 -2
  10. package/dist/default/ListingDetailPane.js.map +1 -1
  11. package/dist/default/ShareAction.d.ts +16 -1
  12. package/dist/default/ShareAction.d.ts.map +1 -1
  13. package/dist/default/ShareAction.js +16 -9
  14. package/dist/default/ShareAction.js.map +1 -1
  15. package/dist/default/actionRow.d.ts +24 -0
  16. package/dist/default/actionRow.d.ts.map +1 -1
  17. package/dist/default/actionRow.js +50 -7
  18. package/dist/default/actionRow.js.map +1 -1
  19. package/dist/default/index.d.ts +1 -1
  20. package/dist/default/index.d.ts.map +1 -1
  21. package/dist/default/index.js +1 -1
  22. package/dist/default/index.js.map +1 -1
  23. package/dist/headless/Share.d.ts +60 -2
  24. package/dist/headless/Share.d.ts.map +1 -1
  25. package/dist/headless/Share.js +55 -3
  26. package/dist/headless/Share.js.map +1 -1
  27. package/dist/index.d.ts +4 -4
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/model/cardBadges.d.ts +25 -0
  32. package/dist/model/cardBadges.d.ts.map +1 -1
  33. package/dist/model/cardBadges.js +41 -5
  34. package/dist/model/cardBadges.js.map +1 -1
  35. package/dist/model/draft.d.ts +42 -1
  36. package/dist/model/draft.d.ts.map +1 -1
  37. package/dist/model/draft.js +40 -3
  38. package/dist/model/draft.js.map +1 -1
  39. package/llms.txt +1 -1
  40. package/manifest.json +8 -1
  41. package/nav-manifest.json +1 -1
  42. package/package.json +1 -1
  43. package/src/analytics/generated/events.json +1 -1
  44. package/src/default/ListingActions.tsx +9 -1
  45. package/src/default/ListingDetailPane.tsx +57 -3
  46. package/src/default/ShareAction.tsx +36 -10
  47. package/src/default/actionRow.ts +55 -7
  48. package/src/default/index.ts +1 -0
  49. package/src/headless/Share.tsx +98 -5
  50. package/src/index.ts +7 -0
  51. package/src/model/cardBadges.ts +42 -5
  52. package/src/model/draft.ts +61 -3
@@ -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
- /** Does this device have the platform's own sheet? Settles in an effect —
155
- * see the file header. */
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(hasNativeShare());
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";
@@ -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
- return style === "line" ? `${name}: ${body}` : `${name} ${body}`;
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
- const name = text(row.name);
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
- const names = positions.map((at) => text(printedRows[at]?.name));
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.
@@ -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
- return {
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
- features_draft: toWireFeatures(features, values.features),
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
  /**