@stapel/listings-react 0.29.1 → 0.30.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 (64) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/dist/default/CardBadges.d.ts +32 -0
  3. package/dist/default/CardBadges.d.ts.map +1 -1
  4. package/dist/default/CardBadges.js +41 -3
  5. package/dist/default/CardBadges.js.map +1 -1
  6. package/dist/default/ListingDetailPane.d.ts +115 -8
  7. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  8. package/dist/default/ListingDetailPane.js +358 -177
  9. package/dist/default/ListingDetailPane.js.map +1 -1
  10. package/dist/default/ListingRelated.d.ts +88 -0
  11. package/dist/default/ListingRelated.d.ts.map +1 -0
  12. package/dist/default/ListingRelated.js +41 -0
  13. package/dist/default/ListingRelated.js.map +1 -0
  14. package/dist/default/ListingSpecList.d.ts +29 -34
  15. package/dist/default/ListingSpecList.d.ts.map +1 -1
  16. package/dist/default/ListingSpecList.js +67 -12
  17. package/dist/default/ListingSpecList.js.map +1 -1
  18. package/dist/default/condensedBar.d.ts +17 -0
  19. package/dist/default/condensedBar.d.ts.map +1 -0
  20. package/dist/default/condensedBar.js +73 -0
  21. package/dist/default/condensedBar.js.map +1 -0
  22. package/dist/default/detailGallery.d.ts +40 -40
  23. package/dist/default/detailGallery.d.ts.map +1 -1
  24. package/dist/default/detailGallery.js +129 -0
  25. package/dist/default/detailGallery.js.map +1 -1
  26. package/dist/default/detailRhythm.d.ts +15 -0
  27. package/dist/default/detailRhythm.d.ts.map +1 -0
  28. package/dist/default/detailRhythm.js +74 -0
  29. package/dist/default/detailRhythm.js.map +1 -0
  30. package/dist/default/icons.d.ts +13 -0
  31. package/dist/default/icons.d.ts.map +1 -1
  32. package/dist/default/icons.js +15 -0
  33. package/dist/default/icons.js.map +1 -1
  34. package/dist/default/index.d.ts +9 -5
  35. package/dist/default/index.d.ts.map +1 -1
  36. package/dist/default/index.js +13 -4
  37. package/dist/default/index.js.map +1 -1
  38. package/dist/i18n/es.d.ts.map +1 -1
  39. package/dist/i18n/es.js +9 -0
  40. package/dist/i18n/es.js.map +1 -1
  41. package/dist/i18n/keys.d.ts +23 -0
  42. package/dist/i18n/keys.d.ts.map +1 -1
  43. package/dist/i18n/keys.js +32 -0
  44. package/dist/i18n/keys.js.map +1 -1
  45. package/dist/i18n/ru.d.ts.map +1 -1
  46. package/dist/i18n/ru.js +9 -0
  47. package/dist/i18n/ru.js.map +1 -1
  48. package/llms.txt +3 -2
  49. package/manifest.json +28 -1
  50. package/nav-manifest.json +1 -1
  51. package/package.json +4 -4
  52. package/src/analytics/generated/events.json +1 -1
  53. package/src/default/CardBadges.tsx +65 -4
  54. package/src/default/ListingDetailPane.tsx +490 -52
  55. package/src/default/ListingRelated.tsx +154 -0
  56. package/src/default/ListingSpecList.tsx +64 -1
  57. package/src/default/condensedBar.ts +74 -0
  58. package/src/default/detailGallery.ts +141 -0
  59. package/src/default/detailRhythm.ts +78 -0
  60. package/src/default/icons.tsx +32 -0
  61. package/src/default/index.ts +45 -2
  62. package/src/i18n/es.ts +9 -0
  63. package/src/i18n/keys.ts +32 -0
  64. package/src/i18n/ru.ts +9 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * "FIND MORE" — the two strips a listing page ends with, and why they are two.
3
+ *
4
+ * The reference classified draws them as separate sections with separate
5
+ * promises (§16 comparison 4): "more options like this one", which is the
6
+ * category and the listing's main axes, and "other listings from this seller",
7
+ * which is one person's shelf. A buyer reading the first is still shopping;
8
+ * a buyer reading the second has decided who they are buying from. Collapsing
9
+ * them into one "related" rail answers neither question.
10
+ *
11
+ * ── WHERE THE ROWS COME FROM, and why not from here ───────────────────────
12
+ *
13
+ * Both lists are SEARCH results, and this pair does not read search: the query
14
+ * is `category=<slug>` plus the main axis filters for the first, and
15
+ * `owner=<uuid>` for the second, and building either means knowing a search
16
+ * module's parameter names and a category's slug — neither of which a listings
17
+ * pair has (the detail wire carries `category_id`, a number, and no slug).
18
+ *
19
+ * So the seam is two-sided and the host picks its side:
20
+ *
21
+ * - `similar` / `fromSeller` — the ROWS, already fetched. The pane draws
22
+ * this component with them, which is the whole of what a host needs on a
23
+ * storefront that already has a search client;
24
+ * - `renderSimilar` / `renderFromSeller` — the SLOT, handed everything the
25
+ * pane knows to build a query with ({@link ListingRelatedContext}). For a
26
+ * host whose "more like this" is not a strip of cards at all — the
27
+ * reference's own first mechanism is a set of catalogue LINKS, not
28
+ * listings.
29
+ *
30
+ * Neither is drawn empty. A heading over nothing is worse than no heading: it
31
+ * reads as a section that failed to load.
32
+ */
33
+ import type { ReactElement, ReactNode } from "react";
34
+ import { Flex, Typography } from "antd";
35
+ import { useT } from "@stapel/core";
36
+ import type { LinkComponent } from "@stapel/core";
37
+ import { spacing } from "@stapel/tokens";
38
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
39
+ import type {
40
+ ListingCard as ListingCardData,
41
+ ListingFeatureView,
42
+ } from "../api/types.js";
43
+ import { ListingFeedCard } from "./ListingFeedCard.js";
44
+
45
+ /** The class the horizontal strip carries. */
46
+ export const RELATED_STRIP_CLASS = "stapel-listings-related";
47
+ /** The `href` the hoisted strip stylesheet is deduplicated by. */
48
+ export const RELATED_STYLE_HREF = "stapel-listings-related";
49
+
50
+ /**
51
+ * How much of the page width ONE card takes in the strip.
52
+ *
53
+ * A `min()` rather than a percentage: on a 390px phone the reference shows
54
+ * roughly one and a half cards, so the next one peeks and the strip says it
55
+ * scrolls; on a desktop a card that kept scaling with the page would be a
56
+ * 700px tile of a 200px photograph.
57
+ */
58
+ export const RELATED_CARD_BASIS = "min(46%, 220px)";
59
+
60
+ /**
61
+ * What the pane knows about this listing, for a host building the queries.
62
+ *
63
+ * `categoryId` is the wire's own value and it is a STRING there — the detail
64
+ * serializer types it `string | null`, and this contract does not quietly
65
+ * reinterpret it.
66
+ */
67
+ export interface ListingRelatedContext {
68
+ readonly listingId: number;
69
+ /** The listing's category, as the wire carries it. */
70
+ readonly categoryId: string | undefined;
71
+ /** The seller — the `owner=` filter's value. */
72
+ readonly ownerKey: string | undefined;
73
+ /**
74
+ * The listing's MAIN AXES, as the category itself projected them: the
75
+ * `show_at_title` subset, which is the same set a card's title line prints
76
+ * and the nearest thing to "what makes two of these alike".
77
+ */
78
+ readonly axes: readonly ListingFeatureView[];
79
+ }
80
+
81
+ /** The strip's rule an inline style cannot reach: the cards' flex basis. */
82
+ export function relatedStripCss(): string {
83
+ return (
84
+ `.${RELATED_STRIP_CLASS}{display:flex;overflow-x:auto;` +
85
+ `gap:${String(spacing[3])}px;scroll-snap-type:x proximity}` +
86
+ `.${RELATED_STRIP_CLASS}>*{flex:0 0 ${RELATED_CARD_BASIS};scroll-snap-align:start}`
87
+ );
88
+ }
89
+
90
+ export interface ListingRelatedStripProps {
91
+ /** The section's caption. */
92
+ readonly heading: string;
93
+ readonly items: readonly ListingCardData[];
94
+ /** Where "show all" goes — the search this strip is a sample of. Absent,
95
+ * there is no link: a strip of three with nowhere to go is honest, and an
96
+ * inert "show all" is not. */
97
+ readonly showAllHref?: string;
98
+ /** Where one card goes. Absent, the cards are inert — which is what a
99
+ * host mounting this inside its own link wrapper wants. */
100
+ readonly listingHref?: (id: number) => string;
101
+ readonly linkComponent?: LinkComponent;
102
+ readonly testId: string;
103
+ }
104
+
105
+ /** One "find more" section: a caption, an optional link out, and a scrolling
106
+ * row of the pair's own feed tiles. */
107
+ export function ListingRelatedStrip(
108
+ props: ListingRelatedStripProps
109
+ ): ReactElement | null {
110
+ const t = useT();
111
+ const { items, listingHref } = props;
112
+ if (items.length === 0) return null;
113
+ const Link = props.linkComponent;
114
+ const showAll =
115
+ props.showAllHref === undefined ? null : Link !== undefined ? (
116
+ <Link href={props.showAllHref} data-testid={`${props.testId}-all`}>
117
+ {t(LISTINGS_I18N_KEYS.detailShowAll)}
118
+ </Link>
119
+ ) : (
120
+ <Typography.Link href={props.showAllHref} data-testid={`${props.testId}-all`}>
121
+ {t(LISTINGS_I18N_KEYS.detailShowAll)}
122
+ </Typography.Link>
123
+ );
124
+
125
+ return (
126
+ <Flex vertical gap={spacing[2]} data-testid={props.testId}>
127
+ <style href={RELATED_STYLE_HREF} precedence="default">
128
+ {relatedStripCss()}
129
+ </style>
130
+ <Flex align="baseline" justify="space-between" gap={spacing[3]}>
131
+ <Typography.Title level={5} style={{ margin: 0 }}>
132
+ {props.heading}
133
+ </Typography.Title>
134
+ {showAll}
135
+ </Flex>
136
+ <div className={RELATED_STRIP_CLASS}>
137
+ {items.map((row) => {
138
+ const href = listingHref?.(row.id);
139
+ const open: ReactNode =
140
+ href === undefined ? (
141
+ <ListingFeedCard listing={row} />
142
+ ) : (
143
+ <ListingFeedCard
144
+ listing={row}
145
+ href={href}
146
+ {...(Link !== undefined ? { linkComponent: Link } : {})}
147
+ />
148
+ );
149
+ return <div key={row.id}>{open}</div>;
150
+ })}
151
+ </div>
152
+ </Flex>
153
+ );
154
+ }
@@ -32,6 +32,7 @@
32
32
  * digits grouped by the reader's locale. `model/featureText.ts` says where
33
33
  * the unit comes from and why there is no `unit` key to read it from.
34
34
  */
35
+ import { useState } from "react";
35
36
  import type { CSSProperties, ReactElement } from "react";
36
37
  import { Typography, theme as antdTheme } from "antd";
37
38
  import { SkinTheme } from "@stapel/tokens-antd/skin";
@@ -46,6 +47,7 @@ import {
46
47
  isValueVerified,
47
48
  } from "@stapel/attributes-react";
48
49
  import type { FeatureDef, FeatureValueDto } from "@stapel/attributes-react";
50
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
49
51
  import { formatSpecValue } from "../model/featureText.js";
50
52
 
51
53
  /** The class one spec row carries. */
@@ -85,9 +87,34 @@ export interface ListingSpecListProps {
85
87
  /** The surface's own test id, so a split page holding two columns of these
86
88
  * does not hand a test two elements under one name. */
87
89
  readonly testId?: string;
90
+ /**
91
+ * HOW MANY ROWS STAND BEFORE THE FOLD. Default: all of them.
92
+ *
93
+ * This is a PRESENTATION limit and nothing else — it hides no field from
94
+ * anybody, because the control under the list opens the rest in place and
95
+ * every row is in the accessibility tree once it is open. The reference
96
+ * classified folds at about eighteen and calls the control "all
97
+ * characteristics"; the argument is the same one the description has, that
98
+ * a forty-row list under a photograph on a 390px phone is four screens
99
+ * between the price and the seller.
100
+ *
101
+ * It is a PROP and not a default, because the fold is a decision about a
102
+ * VIEWPORT and this package does not read viewports (see the pane's
103
+ * `layout` / `galleryLayout`): a desktop split column with two columns of
104
+ * rows wants none of it.
105
+ *
106
+ * The stated limit is honoured only when it actually saves something: a
107
+ * list of eleven with a limit of ten draws all eleven rather than a fold
108
+ * that hides one row behind a button as tall as the row.
109
+ */
110
+ readonly limit?: number;
88
111
  readonly style?: CSSProperties;
89
112
  }
90
113
 
114
+ /** How many rows a fold has to hide before it is worth one. See
115
+ * {@link ListingSpecListProps.limit}. */
116
+ export const SPEC_FOLD_MIN_HIDDEN = 2;
117
+
91
118
  /**
92
119
  * A withheld value's row: what the system OBSERVED, and nothing more — the
93
120
  * same three states `@stapel/attributes-react` prints, said with its own
@@ -104,10 +131,22 @@ export function ListingSpecList(props: ListingSpecListProps): ReactElement {
104
131
  const { locale } = useI18n();
105
132
  const { token } = antdTheme.useToken();
106
133
 
134
+ const [open, setOpen] = useState(false);
135
+
107
136
  // A `header` is a section caption in a FORM, not a value: it has none, and
108
137
  // a spec table that printed one would print "not specified" under it.
109
138
  const rows = props.features.filter((feature) => featureType(feature) !== "header");
110
139
 
140
+ /* THE FOLD, and it folds nothing it cannot save two rows by folding. The
141
+ limit is counted over the rows that are actually DRAWN, not over the
142
+ features handed in — a category whose declaration ends in three form
143
+ headers would otherwise fold a list that is already short. */
144
+ const limit = props.limit ?? Number.POSITIVE_INFINITY;
145
+ const folded =
146
+ !open && rows.length - limit >= SPEC_FOLD_MIN_HIDDEN
147
+ ? rows.slice(0, limit)
148
+ : rows;
149
+
111
150
  return (
112
151
  <SkinTheme surface="bare">
113
152
  <style href={SPEC_STYLE_HREF} precedence="default">
@@ -124,7 +163,7 @@ export function ListingSpecList(props: ListingSpecListProps): ReactElement {
124
163
  ...props.style,
125
164
  }}
126
165
  >
127
- {rows.map((feature) => {
166
+ {folded.map((feature) => {
128
167
  const dto = props.values[feature.slug];
129
168
  const redacted = isRedactedValue(dto);
130
169
  const text = redacted
@@ -152,6 +191,25 @@ export function ListingSpecList(props: ListingSpecListProps): ReactElement {
152
191
  </p>
153
192
  );
154
193
  })}
194
+ {/* `aria-expanded` rather than a second sentence: the control IS the
195
+ state, and a person using a screen reader is told the list opened
196
+ without the list announcing itself. It disappears once open —
197
+ there is no "show less", because a reader who opened forty rows
198
+ scrolls past them and does not scroll back up to close them. */}
199
+ {folded.length === rows.length ? null : (
200
+ <Typography.Link
201
+ role="button"
202
+ aria-expanded={false}
203
+ data-testid={`${props.testId ?? "listings-spec-list"}-show-all`}
204
+ data-analytics="none"
205
+ data-analytics-reason="a look, not an outcome — unfolding a list changes no record"
206
+ onClick={() => {
207
+ setOpen(true);
208
+ }}
209
+ >
210
+ {t(LISTINGS_I18N_KEYS.detailShowAll)}
211
+ </Typography.Link>
212
+ )}
155
213
  </div>
156
214
  </SkinTheme>
157
215
  );
@@ -165,6 +223,11 @@ export function ListingSpecList(props: ListingSpecListProps): ReactElement {
165
223
  * The cut is here rather than inside the list because a CSS `columns` rule
166
224
  * would break a wrapped paragraph across the column boundary, which is
167
225
  * exactly the defect the paragraph shape was adopted to avoid.
226
+ *
227
+ * `limit` is accepted (one props type) and deliberately NOT forwarded: a fold
228
+ * applied to each half would hide the same count twice and leave two "show
229
+ * all" controls that open different halves of one list. The two-column arm is
230
+ * the wide screen, which is the arm the fold exists to spare.
168
231
  */
169
232
  export function ListingSpecColumns(props: ListingSpecListProps): ReactElement {
170
233
  const half = Math.ceil(props.features.length / 2);
@@ -0,0 +1,74 @@
1
+ /**
2
+ * THE CONDENSED BAR — what a phone reads at the top of a listing page once
3
+ * the title has scrolled away.
4
+ *
5
+ * ── Why the pane draws it, when it already lends the cluster out ───────────
6
+ *
7
+ * `renderActionsBar` hands a host the MOUNT POINT and lets it build the bar.
8
+ * That is the right seam for a container with chrome of its own, and it is
9
+ * also four things every container then writes identically: a fixed strip at
10
+ * the top of the viewport, a back control, the listing's title on one line,
11
+ * and the rule that the bar is on screen exactly while the title is not.
12
+ * Measured against the reference classified (§24/§25/§29), which shows the
13
+ * same four from about 160px of scroll on every listing it has.
14
+ *
15
+ * So `actionsPlacement={["header", "condensed-top"]}` is the pane drawing it,
16
+ * with the SAME travelling cluster underneath — one `useFavoriteToggle`, one
17
+ * `aria-pressed` heart, one share control, moved as a DOM node into the bar's
18
+ * slot and moved back when the bar goes (`movableCluster.tsx` has the whole
19
+ * argument). A host that wants a different bar keeps `renderActionsBar`, and
20
+ * where both are asked for the host's own render prop wins: a pair's default
21
+ * never overrules a container that said something specific.
22
+ *
23
+ * ── The threshold is the TITLE, not a pixel count ─────────────────────────
24
+ *
25
+ * The reference's "~160px" is a fact about its own header height, not a
26
+ * number this pane could honour — the same 160px is mid-gallery on one
27
+ * storefront and past the price on another. The pane already watches its own
28
+ * `<h1>` through an `IntersectionObserver` for `onTitleVisible`; the bar
29
+ * appears when that says the title left the fold, which is what the reference
30
+ * number approximates and what a person actually reads: the bar carries the
31
+ * title precisely because the title is no longer on screen.
32
+ *
33
+ * ── The geometry is a stylesheet, for two reasons ─────────────────────────
34
+ *
35
+ * The ellipsis needs three declarations on the title element and the bar needs
36
+ * a `position: fixed` a host may want to move under its own header. Written as
37
+ * a class, a container retunes either with a selector instead of `!important`
38
+ * over an inline style — the rule `detailGallery.ts` was created to establish.
39
+ */
40
+ import { cssVar, fontSize, spacing } from "@stapel/tokens";
41
+
42
+ /** The class the fixed bar carries. */
43
+ export const CONDENSED_BAR_CLASS = "stapel-listings-condensed-bar";
44
+ /** The class the bar's one-line title carries. */
45
+ export const CONDENSED_TITLE_CLASS = "stapel-listings-condensed-title";
46
+ /** The `href` the hoisted bar stylesheet is deduplicated by. */
47
+ export const CONDENSED_BAR_STYLE_HREF = "stapel-listings-condensed-bar";
48
+
49
+ /**
50
+ * The bar's rules.
51
+ *
52
+ * The colours are token ROLES read as vars rather than resolved numbers: the
53
+ * bar is painted over whatever the page is scrolling, so it needs an opaque
54
+ * ground, and a ground picked in JS at render keeps the theme it was drawn
55
+ * with when the reader switches themes. `z-index: 20` is above a page's own
56
+ * content and deliberately below the range a modal layer uses.
57
+ */
58
+ export function condensedBarCss(): string {
59
+ return [
60
+ `.${CONDENSED_BAR_CLASS}{position:fixed;inset-block-start:0;inset-inline:0;` +
61
+ `z-index:20;display:flex;align-items:center;` +
62
+ `gap:${String(spacing[2])}px;` +
63
+ `padding:${String(spacing[2])}px ${String(spacing[3])}px;` +
64
+ `background:${cssVar("surface-raised")};` +
65
+ `border-block-end:1px solid ${cssVar("border")}}`,
66
+ // One line, and the end of a long title is the part a reader can spare.
67
+ // `min-inline-size:0` is what lets it shrink inside the flex row at all —
68
+ // without it a long title pushes the two verbs off the screen.
69
+ `.${CONDENSED_TITLE_CLASS}{flex:1 1 auto;min-inline-size:0;` +
70
+ `overflow:hidden;white-space:nowrap;text-overflow:ellipsis;` +
71
+ `font-size:${String(fontSize.sm.fontSize)}px;` +
72
+ `line-height:${String(fontSize.sm.lineHeight)}px}`,
73
+ ].join("");
74
+ }
@@ -37,7 +37,30 @@
37
37
  * worse: the `gap` (the page's own responsive gutter token, D418 — a var, so
38
38
  * a resize reflows it) and `position: relative`, which is the containing block
39
39
  * the `actionsPlacement="gallery"` overlay is pinned to.
40
+ *
41
+ * ── WHERE THE STRIP IS, and why a tap could not answer it ─────────────────
42
+ *
43
+ * The strip arm is a native scroll container: a finger moves it, a fling
44
+ * settles it on a snap point, and neither of those is an event this package
45
+ * fires. So the page carried a strip that scrolled correctly and a "1 of 7"
46
+ * that did not move — measured on the reference walk (§20b: `scrollLeft`
47
+ * driven a full slide, the indicator still reading the first photograph).
48
+ * Anything keyed off a TAP is the wrong instrument here, because the gesture
49
+ * that changes the photograph is not a tap.
50
+ *
51
+ * {@link useGalleryPosition} is the answer, and it is the strip's own
52
+ * `scroll` event throttled to one measurement per animation frame — the same
53
+ * shape `SkinCarousel` already uses for the card's dots, written here because
54
+ * the detail gallery is this package's own element and mounts no carousel.
55
+ * `scrollend` is deliberately NOT the trigger: Safari has no such event, and
56
+ * an indicator that only settles is an indicator that lies for the length of
57
+ * a fling. The index is read from live rectangles rather than from
58
+ * `scrollLeft / slideWidth`, for the reason `nearestSlideIndex` gives: the
59
+ * arithmetic version has to know the gap, the peek and the writing direction.
40
60
  */
61
+ import { useCallback, useEffect, useRef, useState } from "react";
62
+ import { fontSize, radii, spacing } from "@stapel/tokens";
63
+
41
64
  /**
42
65
  * The narrowest a gallery tile may get before the grid drops a column. A
43
66
  * measure rather than a pixel: the tiles then fill whatever the ELEMENT is,
@@ -60,6 +83,21 @@ export const LISTINGS_GALLERY_CLASS = "stapel-listings-detail-gallery";
60
83
  /** The `href` the hoisted gallery stylesheet is deduplicated by. */
61
84
  export const LISTINGS_GALLERY_STYLE_HREF = "stapel-listings-detail-gallery";
62
85
 
86
+ /** The class the strip's "3 of 16" pill carries. */
87
+ export const LISTINGS_GALLERY_COUNTER_CLASS = "stapel-listings-detail-count";
88
+
89
+ /**
90
+ * The class on the box that HOLDS the strip and its counter.
91
+ *
92
+ * A frame rather than the gallery itself, and the reason is mechanical: in the
93
+ * strip arm the gallery element IS the scroll container, and an absolutely
94
+ * positioned child of a scroller is laid out against its padding box — it
95
+ * scrolls away with the third photograph. It also cannot be a child of the
96
+ * strip at all, because `> *` makes every child a slide. So the counter is a
97
+ * sibling of the strip inside a box that does not scroll.
98
+ */
99
+ export const LISTINGS_GALLERY_FRAME_CLASS = "stapel-listings-detail-gallery-frame";
100
+
63
101
  /**
64
102
  * How much of the strip's width ONE photograph takes.
65
103
  *
@@ -70,6 +108,91 @@ export const LISTINGS_GALLERY_STYLE_HREF = "stapel-listings-detail-gallery";
70
108
  */
71
109
  export const LISTINGS_GALLERY_STRIP_BASIS = "86%";
72
110
 
111
+ /**
112
+ * The index of the child whose leading edge is nearest the box's.
113
+ *
114
+ * Exported because it is the whole claim of {@link useGalleryPosition} and a
115
+ * test can put rectangles in front of it without a layout engine.
116
+ */
117
+ export function nearestPhotoIndex(box: HTMLElement): number {
118
+ const origin = box.getBoundingClientRect().left;
119
+ let best = 0;
120
+ let bestDistance = Number.POSITIVE_INFINITY;
121
+ for (let i = 0; i < box.children.length; i += 1) {
122
+ const child = box.children.item(i);
123
+ if (child === null) continue;
124
+ const distance = Math.abs(child.getBoundingClientRect().left - origin);
125
+ if (distance < bestDistance) {
126
+ bestDistance = distance;
127
+ best = i;
128
+ }
129
+ }
130
+ return best;
131
+ }
132
+
133
+ /** What the gallery box is handed back — see {@link useGalleryPosition}. */
134
+ export interface GalleryPosition {
135
+ /** The photograph on screen, 0-based. */
136
+ readonly active: number;
137
+ /** Attach to the scrolling box. */
138
+ readonly ref: (node: HTMLElement | null) => (() => void) | undefined;
139
+ }
140
+
141
+ /**
142
+ * WHICH PHOTOGRAPH THE STRIP IS SHOWING — read off the strip itself.
143
+ *
144
+ * `enabled` is false for the grid arm and for a single photograph: a grid does
145
+ * not scroll horizontally and a lone picture has no position, so neither pays
146
+ * for a listener. The ref then attaches nothing at all.
147
+ *
148
+ * The measurement is throttled to one per animation frame, because a momentum
149
+ * scroll fires dozens of `scroll` events per frame and each measurement is a
150
+ * layout flush. The state write is guarded on a CHANGE, so a scroll across one
151
+ * photograph costs one render rather than sixty.
152
+ */
153
+ export function useGalleryPosition(enabled: boolean): GalleryPosition {
154
+ const [active, setActive] = useState(0);
155
+ const frame = useRef<number | null>(null);
156
+ // Reset when the arm goes away, so a host toggling `galleryLayout` does not
157
+ // keep a stale index for a grid nobody can scroll back.
158
+ useEffect(() => {
159
+ if (!enabled) setActive(0);
160
+ }, [enabled]);
161
+ const ref = useCallback(
162
+ (node: HTMLElement | null): (() => void) | undefined => {
163
+ if (node === null || !enabled) return undefined;
164
+ const read = (): void => {
165
+ frame.current = null;
166
+ setActive(nearestPhotoIndex(node));
167
+ };
168
+ const onScroll = (): void => {
169
+ if (frame.current !== null) return;
170
+ // No rAF (an old jsdom, a server-side shim): measure straight away.
171
+ // A throttle that cannot be scheduled is not a reason to stop
172
+ // reporting where the strip is.
173
+ if (typeof requestAnimationFrame !== "function") {
174
+ read();
175
+ return;
176
+ }
177
+ frame.current = requestAnimationFrame(read);
178
+ };
179
+ // A strip can mount already scrolled (a restored position), so the
180
+ // indicator opens truthful rather than at 1.
181
+ read();
182
+ node.addEventListener("scroll", onScroll, { passive: true });
183
+ return () => {
184
+ node.removeEventListener("scroll", onScroll);
185
+ if (frame.current !== null && typeof cancelAnimationFrame === "function") {
186
+ cancelAnimationFrame(frame.current);
187
+ }
188
+ frame.current = null;
189
+ };
190
+ },
191
+ [enabled]
192
+ );
193
+ return { active, ref };
194
+ }
195
+
73
196
  /**
74
197
  * The gallery's layout rules, for the hoisted `<style>`.
75
198
  *
@@ -93,5 +216,23 @@ export function detailGalleryCss(): string {
93
216
  flex: 0 0 ${LISTINGS_GALLERY_STRIP_BASIS};
94
217
  scroll-snap-align: start;
95
218
  }
219
+ .${LISTINGS_GALLERY_FRAME_CLASS} {
220
+ position: relative;
221
+ min-inline-size: 0;
222
+ }
223
+ .${LISTINGS_GALLERY_COUNTER_CLASS} {
224
+ position: absolute;
225
+ inset-block-end: ${String(spacing[2])}px;
226
+ inset-inline-end: ${String(spacing[2])}px;
227
+ z-index: 1;
228
+ pointer-events: none;
229
+ padding: ${String(spacing[1] / 2)}px ${String(spacing[2])}px;
230
+ border-radius: ${String(radii.full)}px;
231
+ background: rgba(0, 0, 0, 0.55);
232
+ color: #fff;
233
+ font-size: ${String(fontSize.xs.fontSize)}px;
234
+ line-height: ${String(fontSize.xs.lineHeight)}px;
235
+ font-variant-numeric: tabular-nums;
236
+ }
96
237
  `.trim();
97
238
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * ONE GAP BETWEEN THE LISTING PAGE'S BLOCKS, AND THE PANE IS THE ONE WHO SAYS
3
+ * IT.
4
+ *
5
+ * ── What was on screen ────────────────────────────────────────────────────
6
+ *
7
+ * `<ListingDetailPane>` stacks its blocks in a `<Flex vertical gap>` and
8
+ * declares 16px (12px in the buy column). Measured on the stand by the
9
+ * tidiness probe at 1440 and 390, the gaps INSIDE that flex were 111.05,
10
+ * 53.59, 42.39, 29 and 27 pixels. Five distances, none of them 16, none of
11
+ * them chosen by anybody.
12
+ *
13
+ * The cause is not the gap. It is that a flex `gap` governs only the space a
14
+ * container puts BETWEEN its children, and half the children here are antd
15
+ * components carrying outer margins of their own:
16
+ *
17
+ * `<Divider>` 24px above and 24px below, from antd's own sheet, so
18
+ * one horizontal rule inside a 16px column costs 40 +
19
+ * 40;
20
+ * `<Typography.Title>` a margin-block-start proportional to its level, which
21
+ * is right for prose in a document flow and is a second
22
+ * opinion inside a container that already spaces its
23
+ * children;
24
+ * `<Typography.Paragraph>` a bottom margin of about one line.
25
+ *
26
+ * A margin and a gap ADD. So the page's rhythm was the sum of two systems,
27
+ * and reading either one of them told you nothing about what was drawn.
28
+ *
29
+ * ── The rule ──────────────────────────────────────────────────────────────
30
+ *
31
+ * `margin-block: 0` on every direct child of the pane's block columns —
32
+ * the same three-line answer `@stapel/categories-react`'s `blockRhythm.ts`
33
+ * gives for a catalogue page, and deliberately spelled the same way: a block's
34
+ * own outer margin is a second opinion about a distance the container already
35
+ * decided.
36
+ *
37
+ * It is a SHEET and not an inline style for the reason every other rule in
38
+ * this package is: it addresses CHILDREN this component renders but does not
39
+ * own, and an inline style cannot reach one.
40
+ *
41
+ * ── The divider keeps a spacing, because it is not a block ────────────────
42
+ *
43
+ * A horizontal rule is a SECTION BREAK: it needs more air than the space
44
+ * between two blocks, or it reads as another row in the stack. So it is not
45
+ * zeroed with the rest — it gets {@link DETAIL_RULE_CLASS}, one token step on
46
+ * each side, which lands the break at `gap + step` and makes the number a
47
+ * decision instead of whatever antd's 24 happened to sum to. The class beats
48
+ * the reset on specificity (`.col > .rule` over `.col > *`) without an
49
+ * `!important`, and a host can out-specify it with one more condition.
50
+ */
51
+ import { spacing } from "@stapel/tokens";
52
+
53
+ /** The class the pane's block columns carry. */
54
+ export const DETAIL_RHYTHM_CLASS = "stapel-listings-detail-column";
55
+
56
+ /** The class the pane's horizontal rule carries. */
57
+ export const DETAIL_RULE_CLASS = "stapel-listings-detail-rule";
58
+
59
+ /** The `href` the hoisted rhythm sheet is deduplicated by. */
60
+ export const DETAIL_RHYTHM_STYLE_HREF = "stapel-listings-detail-rhythm";
61
+
62
+ /**
63
+ * ONE TOKEN STEP of extra air on each side of the section rule — see the file
64
+ * header. Exported so a test reads the decision rather than a literal, and so
65
+ * a container laying out beside the rule measures against the same number.
66
+ */
67
+ export const DETAIL_RULE_SPACE: number = spacing[2];
68
+
69
+ /** The rhythm's two rules. */
70
+ export function detailRhythmCss(): string {
71
+ const column = `.${DETAIL_RHYTHM_CLASS}`;
72
+ return [
73
+ // A block's own outer margin is a second opinion about the same distance.
74
+ `${column}>*{margin-block:0}`,
75
+ // …except the section rule, which is a break and not a block.
76
+ `${column}>.${DETAIL_RULE_CLASS}{margin-block:${String(DETAIL_RULE_SPACE)}px}`,
77
+ ].join("");
78
+ }
@@ -141,3 +141,35 @@ export function PriceTrendIcon(props: {
141
141
  </svg>
142
142
  );
143
143
  }
144
+
145
+ /**
146
+ * The condensed bar's back arrow.
147
+ *
148
+ * `aria-hidden`, like the heart and the share glyph: the control around it
149
+ * carries "Back" as its accessible name, and a glyph announced beside its own
150
+ * label reads the action twice.
151
+ *
152
+ * Drawn with the logical writing direction in mind only as far as a library
153
+ * honestly can: the shape points at the INLINE START, and a right-to-left host
154
+ * flips it with one `transform` against this package's own class rather than
155
+ * getting a mirrored copy nobody can name.
156
+ */
157
+ export function BackIcon(): ReactElement {
158
+ return (
159
+ <svg
160
+ width="16"
161
+ height="16"
162
+ viewBox="0 0 24 24"
163
+ fill="none"
164
+ stroke="currentColor"
165
+ strokeWidth="2"
166
+ strokeLinecap="round"
167
+ strokeLinejoin="round"
168
+ role="img"
169
+ aria-hidden="true"
170
+ >
171
+ <path d="M19 12H5" />
172
+ <path d="M12 19l-7-7 7-7" />
173
+ </svg>
174
+ );
175
+ }