@stapel/search-react 0.38.0 → 0.40.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 +161 -0
- package/README.md +85 -6
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +31 -4
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +24 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +27 -6
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/PartitionChips.d.ts +55 -0
- package/dist/default/PartitionChips.d.ts.map +1 -1
- package/dist/default/PartitionChips.js +59 -4
- package/dist/default/PartitionChips.js.map +1 -1
- package/dist/default/SearchPage.d.ts +33 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +32 -5
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +76 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +132 -45
- package/dist/default/SearchResultsPane.js.map +1 -1
- package/dist/default/index.d.ts +5 -3
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +2 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/swatches.d.ts +44 -0
- package/dist/default/swatches.d.ts.map +1 -0
- package/dist/default/swatches.js +200 -0
- package/dist/default/swatches.js.map +1 -0
- package/llms.txt +3 -3
- package/manifest.json +4 -1
- package/nav-manifest.json +1 -1
- package/package.json +4 -4
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FacetGroupControl.tsx +39 -0
- package/src/default/FacetPanelPane.tsx +61 -5
- package/src/default/PartitionChips.tsx +164 -4
- package/src/default/SearchPage.tsx +72 -1
- package/src/default/SearchResultsPane.tsx +134 -5
- package/src/default/index.ts +14 -1
- package/src/default/swatches.ts +214 -0
|
@@ -57,7 +57,7 @@ import type {
|
|
|
57
57
|
} from "react";
|
|
58
58
|
import { Button, Segmented, Typography } from "antd";
|
|
59
59
|
import { useT } from "@stapel/core";
|
|
60
|
-
import { radii, spacing } from "@stapel/tokens";
|
|
60
|
+
import { cssVar, radii, spacing } from "@stapel/tokens";
|
|
61
61
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
62
62
|
|
|
63
63
|
/** One child of a partitioned category. `path` is the slash-joined id path
|
|
@@ -81,10 +81,48 @@ export interface PartitionChild {
|
|
|
81
81
|
*
|
|
82
82
|
* Omit it and nothing is drawn — an absent count is not a zero, and a
|
|
83
83
|
* section whose total nobody asked for must not be captioned "0".
|
|
84
|
+
*
|
|
85
|
+
* Ignored on a POINTER — see {@link linked}.
|
|
84
86
|
*/
|
|
85
87
|
readonly count?: number;
|
|
88
|
+
/**
|
|
89
|
+
* This entry is a POINTER to another category, not a section of THIS
|
|
90
|
+
* template (`CategoryChild.linked`, stapel-categories 0.22.0).
|
|
91
|
+
*
|
|
92
|
+
* A partition is one template split by a value its children's names express
|
|
93
|
+
* — new / used / for rent. A pointer is a different branch of the catalogue
|
|
94
|
+
* that an operator drew among these children so a person can reach it from
|
|
95
|
+
* here. It is not one of the halves, it does not narrow this feed, and it
|
|
96
|
+
* has NO count of its own: the number beside it is the number of listings in
|
|
97
|
+
* somebody else's category, which is why the storefront's
|
|
98
|
+
* `/c/transport-avtomobili` read `All | New 0 | Used 3 | Car rental 0`
|
|
99
|
+
* — two of those zeroes were a partition's real emptiness and one was a
|
|
100
|
+
* question nobody had asked.
|
|
101
|
+
*
|
|
102
|
+
* So a linked entry never becomes a radio, never carries a count, and never
|
|
103
|
+
* matches {@link PartitionChipsProps.value} — a stale address naming one
|
|
104
|
+
* leaves the row on its parent chip rather than lighting a pointer up as the
|
|
105
|
+
* chosen section. It is drawn AFTER the partitions as a link, or not at all:
|
|
106
|
+
* see {@link PartitionChipsProps.linkedChildren}.
|
|
107
|
+
*/
|
|
108
|
+
readonly linked?: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* WHERE a pointer leads — the target's own address, as the host builds it
|
|
111
|
+
* (`/c/<slug>`). Read only on a {@link linked} entry.
|
|
112
|
+
*
|
|
113
|
+
* The pointer's `path` is an id path into the CATALOGUE, and following it
|
|
114
|
+
* as a `category` filter is exactly the confusion this shape exists to end:
|
|
115
|
+
* a pointer is a destination, so the chip is a real `<a href>` that
|
|
116
|
+
* navigates, with no `f=` and no state change on this page. A linked entry
|
|
117
|
+
* with no `href` is not drawn — a link with no address is not a link.
|
|
118
|
+
*/
|
|
119
|
+
readonly href?: string;
|
|
86
120
|
}
|
|
87
121
|
|
|
122
|
+
/** What the row does with a POINTER among its children — see
|
|
123
|
+
* {@link PartitionChipsProps.linkedChildren}. */
|
|
124
|
+
export type PartitionLinkedChildren = "chip" | "none";
|
|
125
|
+
|
|
88
126
|
export interface PartitionChipsProps {
|
|
89
127
|
/** The children, in the order the catalogue declares them. */
|
|
90
128
|
readonly items: readonly PartitionChild[];
|
|
@@ -109,6 +147,24 @@ export interface PartitionChipsProps {
|
|
|
109
147
|
* browser's own `input[type=radio]` in `segmented`.
|
|
110
148
|
*/
|
|
111
149
|
readonly variant?: "chips" | "segmented";
|
|
150
|
+
/**
|
|
151
|
+
* What the row does with a POINTER among its children. Default `"chip"`.
|
|
152
|
+
*
|
|
153
|
+
* - `"chip"` — drawn AFTER the partitions as an outlined link chip: the
|
|
154
|
+
* target's name and a trailing arrow, no count, a real `<a href>` that
|
|
155
|
+
* navigates to the target rather than filtering this page. Outlined and
|
|
156
|
+
* separate on purpose — it is not one of the choices, and a control that
|
|
157
|
+
* looks like the others while doing something else is worse than one
|
|
158
|
+
* that looks different;
|
|
159
|
+
* - `"none"` — not drawn here at all, for a page whose TILE STAGE already
|
|
160
|
+
* shows the same pointer as a tile. One destination offered twice, a row
|
|
161
|
+
* apart, is a person wondering what the difference is.
|
|
162
|
+
*
|
|
163
|
+
* Either way a pointer is out of the partition semantics: no radio, no
|
|
164
|
+
* count, never the chosen section. This prop only decides whether the link
|
|
165
|
+
* is offered in this row.
|
|
166
|
+
*/
|
|
167
|
+
readonly linkedChildren?: PartitionLinkedChildren;
|
|
112
168
|
}
|
|
113
169
|
|
|
114
170
|
/**
|
|
@@ -127,6 +183,23 @@ const ROW: CSSProperties = {
|
|
|
127
183
|
gap: spacing[2],
|
|
128
184
|
};
|
|
129
185
|
|
|
186
|
+
/**
|
|
187
|
+
* The pointer chip: the partition pill's geometry, OUTLINED — a hairline and
|
|
188
|
+
* no fill, so it reads as a way out of this page rather than as one of the
|
|
189
|
+
* choices on it.
|
|
190
|
+
*/
|
|
191
|
+
const POINTER_CHIP: CSSProperties = {
|
|
192
|
+
display: "inline-flex",
|
|
193
|
+
alignItems: "center",
|
|
194
|
+
gap: spacing[1],
|
|
195
|
+
borderRadius: radii.full,
|
|
196
|
+
border: `1px solid ${cssVar("border")}`,
|
|
197
|
+
paddingBlock: spacing[1],
|
|
198
|
+
paddingInline: spacing[3],
|
|
199
|
+
color: cssVar("text"),
|
|
200
|
+
lineHeight: 1.4,
|
|
201
|
+
};
|
|
202
|
+
|
|
130
203
|
/** The keys that move the choice, in both variants. */
|
|
131
204
|
const ARROW_KEYS = new Set(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
|
|
132
205
|
|
|
@@ -189,6 +262,68 @@ function ChildLabel(props: { readonly child: PartitionChild }): ReactElement {
|
|
|
189
262
|
);
|
|
190
263
|
}
|
|
191
264
|
|
|
265
|
+
/**
|
|
266
|
+
* The pointer chip's trailing mark — an arrow leaving to the right, the one
|
|
267
|
+
* glyph that says "this goes somewhere else" rather than "this narrows what
|
|
268
|
+
* is here".
|
|
269
|
+
*
|
|
270
|
+
* Drawn inline in `currentColor`, like every other glyph in this skin
|
|
271
|
+
* (`ChevronGlyph`, `PinGlyph`, `SlidersGlyph`): this package ships no icon set
|
|
272
|
+
* and one arrow is not the reason to take one. `aria-hidden`, because the
|
|
273
|
+
* chip's accessible name is the target's own — a screen reader announcing an
|
|
274
|
+
* arrow after it would be reading the decoration.
|
|
275
|
+
*/
|
|
276
|
+
function PointerGlyph(): ReactElement {
|
|
277
|
+
return (
|
|
278
|
+
<svg
|
|
279
|
+
aria-hidden="true"
|
|
280
|
+
focusable="false"
|
|
281
|
+
viewBox="0 0 16 16"
|
|
282
|
+
width="1em"
|
|
283
|
+
height="1em"
|
|
284
|
+
style={{ flex: "0 0 auto" }}
|
|
285
|
+
>
|
|
286
|
+
<path
|
|
287
|
+
d="M6 3.5 10.5 8 6 12.5"
|
|
288
|
+
fill="none"
|
|
289
|
+
stroke="currentColor"
|
|
290
|
+
strokeWidth="1.75"
|
|
291
|
+
strokeLinecap="round"
|
|
292
|
+
strokeLinejoin="round"
|
|
293
|
+
/>
|
|
294
|
+
</svg>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The pointers, after the partitions and outside the radiogroup.
|
|
300
|
+
*
|
|
301
|
+
* OUTSIDE is not a layout preference: a `role="radiogroup"` whose children
|
|
302
|
+
* include a link announces a choice that has an option you cannot choose. The
|
|
303
|
+
* pointers are their own row, and each one is an ordinary anchor — a
|
|
304
|
+
* middle-click, a ctrl/cmd-click and "open in a new tab" all work, which is
|
|
305
|
+
* the whole difference between a destination and a filter.
|
|
306
|
+
*/
|
|
307
|
+
function PointerChips(props: {
|
|
308
|
+
readonly items: readonly PartitionChild[];
|
|
309
|
+
}): ReactElement {
|
|
310
|
+
return (
|
|
311
|
+
<div style={ROW} data-testid="partition-links">
|
|
312
|
+
{props.items.map((item) => (
|
|
313
|
+
<a
|
|
314
|
+
key={item.path}
|
|
315
|
+
href={item.href}
|
|
316
|
+
style={POINTER_CHIP}
|
|
317
|
+
data-testid={`partition-link-${item.path}`}
|
|
318
|
+
>
|
|
319
|
+
{item.name}
|
|
320
|
+
<PointerGlyph />
|
|
321
|
+
</a>
|
|
322
|
+
))}
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
192
327
|
/** The row's cells, as `[value, label]` — the parent first, then the
|
|
193
328
|
* children in catalogue order. */
|
|
194
329
|
function cells(
|
|
@@ -206,8 +341,19 @@ function cells(
|
|
|
206
341
|
export function PartitionChips(props: PartitionChipsProps): ReactElement {
|
|
207
342
|
const t = useT();
|
|
208
343
|
const row = useRef<HTMLDivElement>(null);
|
|
344
|
+
/* THE TWO KINDS OF CHILD, SPLIT ONCE. Everything below the split — the
|
|
345
|
+
cells, the roving stop, the value lookup, the arrow keys — sees only the
|
|
346
|
+
SECTIONS, which is what keeps a pointer out of the partition's semantics
|
|
347
|
+
rather than out of one rendering of them. */
|
|
348
|
+
const sections = props.items.filter((item) => item.linked !== true);
|
|
349
|
+
const pointers =
|
|
350
|
+
props.linkedChildren === "none"
|
|
351
|
+
? []
|
|
352
|
+
: props.items.filter(
|
|
353
|
+
(item) => item.linked === true && item.href !== undefined
|
|
354
|
+
);
|
|
209
355
|
const options = cells(
|
|
210
|
-
|
|
356
|
+
sections,
|
|
211
357
|
props.allLabel ?? t(SEARCH_I18N_KEYS.partitionAll)
|
|
212
358
|
);
|
|
213
359
|
|
|
@@ -282,6 +428,20 @@ export function PartitionChips(props: PartitionChipsProps): ReactElement {
|
|
|
282
428
|
|
|
283
429
|
const name = props.label ?? t(SEARCH_I18N_KEYS.partitionLabel);
|
|
284
430
|
|
|
431
|
+
/* The pointers ride BESIDE whichever control was drawn, never inside it —
|
|
432
|
+
see `PointerChips`. A fragment rather than a wrapper element: this row is
|
|
433
|
+
mounted in a vertical `<Flex>` that already spaces its children, and an
|
|
434
|
+
extra box here would take that gap away from the row it wraps. */
|
|
435
|
+
const withPointers = (control: ReactElement): ReactElement =>
|
|
436
|
+
pointers.length === 0 ? (
|
|
437
|
+
control
|
|
438
|
+
) : (
|
|
439
|
+
<>
|
|
440
|
+
{control}
|
|
441
|
+
<PointerChips items={pointers} />
|
|
442
|
+
</>
|
|
443
|
+
);
|
|
444
|
+
|
|
285
445
|
if (props.variant === "segmented") {
|
|
286
446
|
// antd's own control: `.ant-segmented`, one `input[type=radio]` per cell
|
|
287
447
|
// under a shared `name`, the selected cell's `checked`, and the arrow keys
|
|
@@ -289,7 +449,7 @@ export function PartitionChips(props: PartitionChipsProps): ReactElement {
|
|
|
289
449
|
// `aria-label` reach the root because the component spreads what it is
|
|
290
450
|
// given over its own defaults (which are `radiogroup` and the string
|
|
291
451
|
// "segmented control").
|
|
292
|
-
return (
|
|
452
|
+
return withPointers(
|
|
293
453
|
<Segmented
|
|
294
454
|
block
|
|
295
455
|
size="small"
|
|
@@ -328,7 +488,7 @@ export function PartitionChips(props: PartitionChipsProps): ReactElement {
|
|
|
328
488
|
);
|
|
329
489
|
}
|
|
330
490
|
|
|
331
|
-
return (
|
|
491
|
+
return withPointers(
|
|
332
492
|
<div
|
|
333
493
|
style={ROW}
|
|
334
494
|
data-variant="chips"
|
|
@@ -82,6 +82,7 @@ import type {
|
|
|
82
82
|
CategoryFilterSlotProps,
|
|
83
83
|
FacetPanelPaneProps,
|
|
84
84
|
GeoFilterSlotProps,
|
|
85
|
+
SearchRailSurface,
|
|
85
86
|
} from "./FacetPanelPane.js";
|
|
86
87
|
import { FilterChips } from "./FilterChips.js";
|
|
87
88
|
import { LocationSummaryLine } from "./LocationSummaryLine.js";
|
|
@@ -394,6 +395,9 @@ export function railStyle(top: number | string | undefined): CSSProperties {
|
|
|
394
395
|
* cannot push the grid wider than its column. */
|
|
395
396
|
const RESULTS_COLUMN: CSSProperties = { flex: "1 1 auto", minWidth: 0 };
|
|
396
397
|
|
|
398
|
+
/** A wrapper that names a slot without occupying one — see `resultsHeader`. */
|
|
399
|
+
const CONTENTS_BOX: CSSProperties = { display: "contents" };
|
|
400
|
+
|
|
397
401
|
/* ── THE RHYTHM: ONE GAP BETWEEN BLOCKS, SAID ONCE ─────────────────────────
|
|
398
402
|
*
|
|
399
403
|
* This page is an assembly of BLOCKS — the query box, the breadcrumb, the
|
|
@@ -785,6 +789,16 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
785
789
|
* page was measured on.
|
|
786
790
|
*/
|
|
787
791
|
readonly railScrollbar?: SearchRailScrollbar;
|
|
792
|
+
/**
|
|
793
|
+
* WHAT THE FILTER PANEL'S OWN BODY PAINTS. Default `"flat"`.
|
|
794
|
+
*
|
|
795
|
+
* Handed straight to {@link FacetPanelPaneProps.railSurface}, in the column
|
|
796
|
+
* and in the phone sheet alike. `"flat"` draws the controls and no box
|
|
797
|
+
* around them (the stand's dark theme read the old ground as a 270 x 1539
|
|
798
|
+
* filled slab with no radius and no border, standing on the page ground);
|
|
799
|
+
* `"panel"` restores the raised container this page painted until now.
|
|
800
|
+
*/
|
|
801
|
+
readonly railSurface?: SearchRailSurface;
|
|
788
802
|
/**
|
|
789
803
|
* WHERE the space between this page's blocks comes from. Default `"token"`.
|
|
790
804
|
*
|
|
@@ -822,6 +836,28 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
822
836
|
* the layout put under their thumb.
|
|
823
837
|
*/
|
|
824
838
|
readonly stickyToolbar?: SearchToolbarPin;
|
|
839
|
+
/**
|
|
840
|
+
* PIN the results toolbar by default, on a fine pointer, at {@link railTop}.
|
|
841
|
+
* Default `true`.
|
|
842
|
+
*
|
|
843
|
+
* {@link stickyToolbar} is the same pin asked for explicitly, and it existed
|
|
844
|
+
* for a release without a single deployment turning it on — a default nobody
|
|
845
|
+
* sets is a feature nobody has. The reference pins its sort bar once a reader
|
|
846
|
+
* has scrolled into the results (REPORT §24, Surface 2) and this page now
|
|
847
|
+
* does the same without being asked:
|
|
848
|
+
*
|
|
849
|
+
* - it pins at `railTop`, so the two columns clear the host's header by the
|
|
850
|
+
* same edge and there is no second number to keep in step;
|
|
851
|
+
* - it is a `@media (pointer: fine)` rule, so a phone keeps its toolbar in
|
|
852
|
+
* flow — a pinned bar over a 390px viewport spends the fold on chrome;
|
|
853
|
+
* - the row reserves its own height whether or not it pins, so nothing in
|
|
854
|
+
* the feed moves when the rule engages.
|
|
855
|
+
*
|
|
856
|
+
* `stickyToolbar` still wins where it is given (it pins on every pointer, at
|
|
857
|
+
* its own offset). `false` turns the default off and leaves the row static —
|
|
858
|
+
* for a surface that draws a bar of its own over the page.
|
|
859
|
+
*/
|
|
860
|
+
readonly toolbarSticky?: boolean;
|
|
825
861
|
/**
|
|
826
862
|
* The host's own exits from an empty result — sibling sections with their
|
|
827
863
|
* counts. A SLOT for the same reason `breadcrumb` is one: walking the tree
|
|
@@ -990,8 +1026,10 @@ interface SearchPageBodyProps {
|
|
|
990
1026
|
readonly filtersLayout?: SearchFiltersLayout;
|
|
991
1027
|
readonly railTop?: number | string;
|
|
992
1028
|
readonly railScrollbar?: SearchRailScrollbar;
|
|
1029
|
+
readonly railSurface?: SearchRailSurface;
|
|
993
1030
|
readonly blockRhythm?: SearchBlockRhythm;
|
|
994
1031
|
readonly stickyToolbar?: SearchToolbarPin;
|
|
1032
|
+
readonly toolbarSticky?: boolean;
|
|
995
1033
|
readonly defaultFiltersOpen?: boolean;
|
|
996
1034
|
readonly filtersOpen?: boolean;
|
|
997
1035
|
readonly onFiltersOpenChange?: (
|
|
@@ -1178,6 +1216,9 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
1178
1216
|
<FacetPanelPane
|
|
1179
1217
|
{...(layout === "sheet" ? { heading: null } : {})}
|
|
1180
1218
|
{...(footerBar !== undefined ? { footerBar } : {})}
|
|
1219
|
+
{...(props.railSurface !== undefined
|
|
1220
|
+
? { railSurface: props.railSurface }
|
|
1221
|
+
: {})}
|
|
1181
1222
|
dictionaryMode={props.dictionaryMode ?? (layout === "sheet" ? "sheet" : "field")}
|
|
1182
1223
|
// `??` would treat an explicit `null` ("never fold") the same as
|
|
1183
1224
|
// "not set": `visibleGroups` uses `null` as a real value, unlike
|
|
@@ -1250,6 +1291,10 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
1250
1291
|
</Flex>
|
|
1251
1292
|
);
|
|
1252
1293
|
|
|
1294
|
+
/* ONE OFFSET FOR BOTH COLUMNS. The rail's sticky top edge and the toolbar's
|
|
1295
|
+
are the same edge — the foot of whatever chrome the host pinned above this
|
|
1296
|
+
page — so `railTop` feeds both rather than the page asking for the number
|
|
1297
|
+
twice and letting the two halves disagree. */
|
|
1253
1298
|
const results = (
|
|
1254
1299
|
<SearchResultsPane
|
|
1255
1300
|
toolbar={toolbar}
|
|
@@ -1258,6 +1303,10 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
1258
1303
|
{...(props.stickyToolbar !== undefined
|
|
1259
1304
|
? { stickyToolbar: props.stickyToolbar }
|
|
1260
1305
|
: {})}
|
|
1306
|
+
{...(props.toolbarSticky !== undefined
|
|
1307
|
+
? { toolbarSticky: props.toolbarSticky }
|
|
1308
|
+
: {})}
|
|
1309
|
+
{...(props.railTop !== undefined ? { toolbarTop: props.railTop } : {})}
|
|
1261
1310
|
headingLevel={props.resultsHeadingLevel ?? 1}
|
|
1262
1311
|
{...(view.render !== undefined ? { renderResults: view.render } : {})}
|
|
1263
1312
|
{...(props.wrapResults !== undefined ? { wrapResults: props.wrapResults } : {})}
|
|
@@ -1363,7 +1412,25 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
1363
1412
|
the written decision, not an oversight: a page with nothing to say
|
|
1364
1413
|
about location says nothing rather than reserving a blank row. */}
|
|
1365
1414
|
{props.resultsHeader !== undefined && (
|
|
1366
|
-
|
|
1415
|
+
/* NO BOX OF ITS OWN (`display: contents`). The slot is a NODE, and a
|
|
1416
|
+
node that renders nothing is indistinguishable from one that renders
|
|
1417
|
+
something until React has run it — so the wrapper was mounted on the
|
|
1418
|
+
prop alone and stood in this column as a 1392 x 0 element whenever
|
|
1419
|
+
the host's header had nothing to say. Inside the block rhythm an
|
|
1420
|
+
empty child is not free: the column's `gap` is charged on BOTH sides
|
|
1421
|
+
of it, so the distance between the two real blocks around it
|
|
1422
|
+
measured 64px where 32 is declared, on every feed page (owner's
|
|
1423
|
+
tidiness probe on the stand).
|
|
1424
|
+
|
|
1425
|
+
`display: contents` generates no box at all: with nothing inside,
|
|
1426
|
+
there is no flex item and no gap; with something inside, the host's
|
|
1427
|
+
own element IS the column's child and takes exactly one gap. The
|
|
1428
|
+
`data-testid` survives either way, and the consumer stylesheet's
|
|
1429
|
+
stand-in (`[data-testid="search-results-header"]:empty{display:none}`)
|
|
1430
|
+
can go. */
|
|
1431
|
+
<div style={CONTENTS_BOX} data-testid="search-results-header">
|
|
1432
|
+
{props.resultsHeader}
|
|
1433
|
+
</div>
|
|
1367
1434
|
)}
|
|
1368
1435
|
|
|
1369
1436
|
{/* What the search is NARROWED to, above the results, each constraint
|
|
@@ -1502,8 +1569,10 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
1502
1569
|
filtersLayout,
|
|
1503
1570
|
railTop,
|
|
1504
1571
|
railScrollbar,
|
|
1572
|
+
railSurface,
|
|
1505
1573
|
blockRhythm,
|
|
1506
1574
|
stickyToolbar,
|
|
1575
|
+
toolbarSticky,
|
|
1507
1576
|
defaultFiltersOpen,
|
|
1508
1577
|
filtersOpen,
|
|
1509
1578
|
onFiltersOpenChange,
|
|
@@ -1568,8 +1637,10 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
1568
1637
|
{...(filtersLayout !== undefined ? { filtersLayout } : {})}
|
|
1569
1638
|
{...(railTop !== undefined ? { railTop } : {})}
|
|
1570
1639
|
{...(railScrollbar !== undefined ? { railScrollbar } : {})}
|
|
1640
|
+
{...(railSurface !== undefined ? { railSurface } : {})}
|
|
1571
1641
|
{...(blockRhythm !== undefined ? { blockRhythm } : {})}
|
|
1572
1642
|
{...(stickyToolbar !== undefined ? { stickyToolbar } : {})}
|
|
1643
|
+
{...(toolbarSticky !== undefined ? { toolbarSticky } : {})}
|
|
1573
1644
|
{...(defaultFiltersOpen !== undefined ? { defaultFiltersOpen } : {})}
|
|
1574
1645
|
{...(filtersOpen !== undefined ? { filtersOpen } : {})}
|
|
1575
1646
|
{...(onFiltersOpenChange !== undefined ? { onFiltersOpenChange } : {})}
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* when the answer actually has another page in some direction.
|
|
26
26
|
*/
|
|
27
27
|
import type { CSSProperties, ReactElement, ReactNode } from "react";
|
|
28
|
-
import { Flex, Typography } from "antd";
|
|
28
|
+
import { Flex, Typography, theme as antdTheme } from "antd";
|
|
29
29
|
import { errorCode, useT, useTPlural } from "@stapel/core";
|
|
30
30
|
import {
|
|
31
31
|
EmptyState,
|
|
@@ -139,6 +139,76 @@ export interface SearchToolbarPin {
|
|
|
139
139
|
readonly top?: number | string;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The class the DEFAULT pin's rules are hung on — the media-gated half of
|
|
144
|
+
* {@link SearchResultsPaneProps.toolbarSticky}.
|
|
145
|
+
*
|
|
146
|
+
* A class and a sheet rather than an inline style, for the one reason a sheet
|
|
147
|
+
* is ever right here: the pin is `@media (pointer: fine)` and a media query
|
|
148
|
+
* cannot be said in a `style` attribute. A pinned sort row is a desktop
|
|
149
|
+
* affordance — a phone's toolbar is already one tap from the top of a short
|
|
150
|
+
* scroll, and a bar standing over a 390px viewport spends the fold on chrome
|
|
151
|
+
* the reader did not ask for (REPORT §24, Surface 2: what the reference pins
|
|
152
|
+
* is the DESKTOP feed's sort row).
|
|
153
|
+
*/
|
|
154
|
+
export const RESULTS_TOOLBAR_STICKY_CLASS = "stapel-search-results-toolbar-sticky";
|
|
155
|
+
|
|
156
|
+
/** The custom property the default pin reads its offset from. */
|
|
157
|
+
export const RESULTS_TOOLBAR_TOP_VAR = "--stapel-search-toolbar-top";
|
|
158
|
+
|
|
159
|
+
/** The `href` the hoisted toolbar sheet is deduplicated by. */
|
|
160
|
+
export const RESULTS_TOOLBAR_STYLE_HREF = "stapel-search-toolbar";
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The default pin's rule set.
|
|
164
|
+
*
|
|
165
|
+
* Everything {@link toolbarPinStyle} writes inline, said once in a sheet and
|
|
166
|
+
* gated on a fine pointer — the offset arrives per instance through
|
|
167
|
+
* {@link RESULTS_TOOLBAR_TOP_VAR}, which is how one static rule serves a page
|
|
168
|
+
* whose header height only the host knows. `0px` is the fallback, which is
|
|
169
|
+
* where a page with no chrome above it pins.
|
|
170
|
+
*/
|
|
171
|
+
export function toolbarStickyCss(): string {
|
|
172
|
+
const bar = `.${RESULTS_TOOLBAR_STICKY_CLASS}`;
|
|
173
|
+
return (
|
|
174
|
+
`@media (pointer:fine){` +
|
|
175
|
+
`${bar}{position:sticky;top:var(${RESULTS_TOOLBAR_TOP_VAR},0px);` +
|
|
176
|
+
// Over the cards, under the page's own chrome — and under antd's popups,
|
|
177
|
+
// so the sort select still opens over its own bar. Opaque, or the cards
|
|
178
|
+
// scroll THROUGH the row.
|
|
179
|
+
`z-index:1;background:${cssVar("surface")}}}`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The toolbar row's own block-size, reserved from the FIRST frame.
|
|
185
|
+
*
|
|
186
|
+
* The same discipline — and the same arithmetic — as `chipRowMinHeight`: a
|
|
187
|
+
* NUMBER OUT OF THE THEME rather than a constant, because the shared
|
|
188
|
+
* `SkinTheme` raises `controlHeight` to the 44px touch floor below the tablet
|
|
189
|
+
* breakpoint and a hard-coded reserve is then right on one surface and wrong
|
|
190
|
+
* on the other.
|
|
191
|
+
*
|
|
192
|
+
* Why a row that is already this tall states it anyway: the row's contents
|
|
193
|
+
* arrive in two frames — the count lands with the answer, and a sort select
|
|
194
|
+
* whose options are still loading measures its placeholder — so the box a
|
|
195
|
+
* pinned bar occupies must not be a consequence of what is inside it. A
|
|
196
|
+
* reserve makes the height a constant before and after the pin engages, which
|
|
197
|
+
* is what "no layout shift when it pins" means in a `position: sticky` world:
|
|
198
|
+
* the sticky box keeps its place in flow, so the only way it can move the feed
|
|
199
|
+
* is by changing its own height.
|
|
200
|
+
*/
|
|
201
|
+
export function toolbarRowMinHeight(controlHeight: number): number {
|
|
202
|
+
return controlHeight + spacing[1] * 2;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A CSS length from a prop that is a number of pixels or a string as written
|
|
206
|
+
* (a `var()`, a `calc()`, `"4rem"`) — the rule `railStyle` follows. */
|
|
207
|
+
function toolbarTopLength(top: number | string | undefined): string {
|
|
208
|
+
if (top === undefined) return "0px";
|
|
209
|
+
return typeof top === "number" ? `${String(top)}px` : top;
|
|
210
|
+
}
|
|
211
|
+
|
|
142
212
|
/**
|
|
143
213
|
* The controls' own line: it may not become two.
|
|
144
214
|
*
|
|
@@ -598,6 +668,36 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
|
|
|
598
668
|
* pins it where it stands.
|
|
599
669
|
*/
|
|
600
670
|
readonly stickyToolbar?: SearchToolbarPin;
|
|
671
|
+
/**
|
|
672
|
+
* PIN the toolbar row by default, on a fine pointer. Default `true`.
|
|
673
|
+
*
|
|
674
|
+
* The same argument as {@link stickyToolbar} and none of the wiring: a
|
|
675
|
+
* catalogue page is thirty cards long, the control that reorders them is at
|
|
676
|
+
* the top of it, and by the fourth row the sort is a screenful above the
|
|
677
|
+
* list it sorts. The reference pins its sort bar once a reader has scrolled
|
|
678
|
+
* into results (REPORT §24, Surface 2); this pair had the mechanism and made
|
|
679
|
+
* every host ask for it, so no deployment had it.
|
|
680
|
+
*
|
|
681
|
+
* What the default does that the explicit prop cannot: it is a `@media
|
|
682
|
+
* (pointer: fine)` rule (see {@link toolbarStickyCss}), so a phone — where
|
|
683
|
+
* a pinned bar costs a fifth of the fold and the feed is a flick long —
|
|
684
|
+
* keeps its toolbar in flow. `stickyToolbar` stays the host's own
|
|
685
|
+
* instruction and is honoured on every pointer; passing it takes this
|
|
686
|
+
* default out of play, so the two can never both be on one row.
|
|
687
|
+
*
|
|
688
|
+
* `false` leaves the row exactly where it stood: no class, no sheet, no
|
|
689
|
+
* custom property.
|
|
690
|
+
*/
|
|
691
|
+
readonly toolbarSticky?: boolean;
|
|
692
|
+
/**
|
|
693
|
+
* WHERE the default pin's top edge is — the results column's half of
|
|
694
|
+
* `<SearchPage railTop>`, and the same value.
|
|
695
|
+
*
|
|
696
|
+
* A number is pixels; a string is taken as written, so
|
|
697
|
+
* `toolbarTop="var(--stapel-header-height)"` reads the height
|
|
698
|
+
* `<PublicShell>` publishes rather than restating it. Default `0`.
|
|
699
|
+
*/
|
|
700
|
+
readonly toolbarTop?: number | string;
|
|
601
701
|
/**
|
|
602
702
|
* The category's feature schema, used ONLY to name an applied filter in the
|
|
603
703
|
* empty state's exits ("Without Brand" rather than "Without vendor").
|
|
@@ -706,6 +806,34 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
706
806
|
// not, and `headingVisible` overrides either way — see that prop.
|
|
707
807
|
const compactHeadingSeen = props.headingVisible ?? props.heading !== undefined;
|
|
708
808
|
const toolbarPin = toolbarPinStyle(props.stickyToolbar);
|
|
809
|
+
// The chips' own height, from the theme they are drawn in — see
|
|
810
|
+
// `toolbarRowMinHeight`.
|
|
811
|
+
const { token } = antdTheme.useToken();
|
|
812
|
+
/* The DEFAULT pin, and the host's explicit one takes precedence: a row
|
|
813
|
+
carrying both would be pinned twice at two offsets, and which one wins
|
|
814
|
+
would be whichever declaration the cascade happened to resolve last. */
|
|
815
|
+
const defaultPin = props.stickyToolbar === undefined && props.toolbarSticky !== false;
|
|
816
|
+
const toolbarClass = defaultPin
|
|
817
|
+
? `${RESULTS_TOOLBAR_CLASS} ${RESULTS_TOOLBAR_STICKY_CLASS}`
|
|
818
|
+
: RESULTS_TOOLBAR_CLASS;
|
|
819
|
+
/* The reserve is written whether or not the row pins, and that is the
|
|
820
|
+
point: a height that only exists while pinned is a height that changes
|
|
821
|
+
when the pin engages. */
|
|
822
|
+
const toolbarBox: CSSProperties = {
|
|
823
|
+
minBlockSize: toolbarRowMinHeight(token.controlHeight),
|
|
824
|
+
...(defaultPin
|
|
825
|
+
? ({ [RESULTS_TOOLBAR_TOP_VAR]: toolbarTopLength(props.toolbarTop) } as CSSProperties)
|
|
826
|
+
: {}),
|
|
827
|
+
...toolbarPin,
|
|
828
|
+
};
|
|
829
|
+
/* Hoisted and deduped by `href` (React 19), and not mounted at all when
|
|
830
|
+
nothing carries the class — a sheet whose only selector is a class no
|
|
831
|
+
node has is dead weight in the document. */
|
|
832
|
+
const toolbarSheet = defaultPin ? (
|
|
833
|
+
<style href={RESULTS_TOOLBAR_STYLE_HREF} precedence="default">
|
|
834
|
+
{toolbarStickyCss()}
|
|
835
|
+
</style>
|
|
836
|
+
) : null;
|
|
709
837
|
const columnRules =
|
|
710
838
|
props.columns === undefined || props.layout === "list"
|
|
711
839
|
? null
|
|
@@ -739,6 +867,7 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
739
867
|
...(maxWidth !== null ? { maxWidth } : {}),
|
|
740
868
|
}}
|
|
741
869
|
>
|
|
870
|
+
{toolbarSheet}
|
|
742
871
|
<SearchResults {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}>
|
|
743
872
|
{(bag) => (
|
|
744
873
|
<Flex
|
|
@@ -773,9 +902,9 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
773
902
|
{props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
|
|
774
903
|
</Typography.Title>
|
|
775
904
|
<div
|
|
776
|
-
className={
|
|
905
|
+
className={toolbarClass}
|
|
777
906
|
data-testid="search-results-toolbar"
|
|
778
|
-
style={{ ...COMPACT_TOOLBAR, ...
|
|
907
|
+
style={{ ...COMPACT_TOOLBAR, ...toolbarBox }}
|
|
779
908
|
>
|
|
780
909
|
{props.toolbar}
|
|
781
910
|
</div>
|
|
@@ -800,9 +929,9 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
|
|
|
800
929
|
<Flex
|
|
801
930
|
align="center"
|
|
802
931
|
gap={spacing[3]}
|
|
803
|
-
className={
|
|
932
|
+
className={toolbarClass}
|
|
804
933
|
data-testid="search-results-toolbar"
|
|
805
|
-
style={{ ...TOOLBAR_ROW, ...
|
|
934
|
+
style={{ ...TOOLBAR_ROW, ...toolbarBox }}
|
|
806
935
|
>
|
|
807
936
|
{/* The elastic half, present whether or not there is a
|
|
808
937
|
count in it — see TOOLBAR_LEAD. It is what holds the
|
package/src/default/index.ts
CHANGED
|
@@ -86,7 +86,12 @@ export {
|
|
|
86
86
|
RESULTS_COLUMNS_CLASS,
|
|
87
87
|
RESULTS_COLUMNS_STYLE_HREF,
|
|
88
88
|
RESULTS_TOOLBAR_CLASS,
|
|
89
|
+
RESULTS_TOOLBAR_STICKY_CLASS,
|
|
90
|
+
RESULTS_TOOLBAR_STYLE_HREF,
|
|
91
|
+
RESULTS_TOOLBAR_TOP_VAR,
|
|
89
92
|
resultsColumnsCss,
|
|
93
|
+
toolbarRowMinHeight,
|
|
94
|
+
toolbarStickyCss,
|
|
90
95
|
} from "./SearchResultsPane.js";
|
|
91
96
|
export type {
|
|
92
97
|
ResultsColumns,
|
|
@@ -151,6 +156,9 @@ export type {
|
|
|
151
156
|
FacetOptionNode,
|
|
152
157
|
} from "./FacetGroupControl.js";
|
|
153
158
|
|
|
159
|
+
export { SWATCH_SIZE, facetSwatch, isColorAxis, swatchColor } from "./swatches.js";
|
|
160
|
+
export type { ColorAxisLike } from "./swatches.js";
|
|
161
|
+
|
|
154
162
|
// ── the browse surfaces a storefront PLACES (this pair does not lay them
|
|
155
163
|
// out: where a popular-values block or a partition row belongs on a
|
|
156
164
|
// category page is the page's decision) ──────────────────────────────────
|
|
@@ -167,7 +175,11 @@ export {
|
|
|
167
175
|
} from "./PopularValues.js";
|
|
168
176
|
export type { PopularValuesProps } from "./PopularValues.js";
|
|
169
177
|
export { PartitionChips } from "./PartitionChips.js";
|
|
170
|
-
export type {
|
|
178
|
+
export type {
|
|
179
|
+
PartitionChild,
|
|
180
|
+
PartitionChipsProps,
|
|
181
|
+
PartitionLinkedChildren,
|
|
182
|
+
} from "./PartitionChips.js";
|
|
171
183
|
|
|
172
184
|
export {
|
|
173
185
|
FacetPanelPane,
|
|
@@ -179,6 +191,7 @@ export type {
|
|
|
179
191
|
FacetPanelPaneProps,
|
|
180
192
|
CategoryFilterSlotProps,
|
|
181
193
|
GeoFilterSlotProps,
|
|
194
|
+
SearchRailSurface,
|
|
182
195
|
} from "./FacetPanelPane.js";
|
|
183
196
|
|
|
184
197
|
export { RankingDisclosurePane, RANKING_MAX_WIDTH } from "./RankingDisclosurePane.js";
|