@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.
- package/CHANGELOG.md +119 -0
- package/dist/default/CardBadges.d.ts +32 -0
- package/dist/default/CardBadges.d.ts.map +1 -1
- package/dist/default/CardBadges.js +41 -3
- package/dist/default/CardBadges.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +115 -8
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +358 -177
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingRelated.d.ts +88 -0
- package/dist/default/ListingRelated.d.ts.map +1 -0
- package/dist/default/ListingRelated.js +41 -0
- package/dist/default/ListingRelated.js.map +1 -0
- package/dist/default/ListingSpecList.d.ts +29 -34
- package/dist/default/ListingSpecList.d.ts.map +1 -1
- package/dist/default/ListingSpecList.js +67 -12
- package/dist/default/ListingSpecList.js.map +1 -1
- package/dist/default/condensedBar.d.ts +17 -0
- package/dist/default/condensedBar.d.ts.map +1 -0
- package/dist/default/condensedBar.js +73 -0
- package/dist/default/condensedBar.js.map +1 -0
- package/dist/default/detailGallery.d.ts +40 -40
- package/dist/default/detailGallery.d.ts.map +1 -1
- package/dist/default/detailGallery.js +129 -0
- package/dist/default/detailGallery.js.map +1 -1
- package/dist/default/detailRhythm.d.ts +15 -0
- package/dist/default/detailRhythm.d.ts.map +1 -0
- package/dist/default/detailRhythm.js +74 -0
- package/dist/default/detailRhythm.js.map +1 -0
- package/dist/default/icons.d.ts +13 -0
- package/dist/default/icons.d.ts.map +1 -1
- package/dist/default/icons.js +15 -0
- package/dist/default/icons.js.map +1 -1
- package/dist/default/index.d.ts +9 -5
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +13 -4
- package/dist/default/index.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +9 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +23 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +32 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +9 -0
- package/dist/i18n/ru.js.map +1 -1
- package/llms.txt +3 -2
- package/manifest.json +28 -1
- package/nav-manifest.json +1 -1
- package/package.json +4 -4
- package/src/analytics/generated/events.json +1 -1
- package/src/default/CardBadges.tsx +65 -4
- package/src/default/ListingDetailPane.tsx +490 -52
- package/src/default/ListingRelated.tsx +154 -0
- package/src/default/ListingSpecList.tsx +64 -1
- package/src/default/condensedBar.ts +74 -0
- package/src/default/detailGallery.ts +141 -0
- package/src/default/detailRhythm.ts +78 -0
- package/src/default/icons.tsx +32 -0
- package/src/default/index.ts +45 -2
- package/src/i18n/es.ts +9 -0
- package/src/i18n/keys.ts +32 -0
- 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
|
-
{
|
|
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
|
+
}
|
package/src/default/icons.tsx
CHANGED
|
@@ -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
|
+
}
|