@stapel/search-react 0.39.0 → 0.41.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 (44) hide show
  1. package/CHANGELOG.md +167 -0
  2. package/README.md +97 -6
  3. package/dist/api/generated/schema.d.ts +8 -2
  4. package/dist/api/generated/schema.d.ts.map +1 -1
  5. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  6. package/dist/default/FacetGroupControl.js +31 -4
  7. package/dist/default/FacetGroupControl.js.map +1 -1
  8. package/dist/default/FacetPanelPane.d.ts +24 -0
  9. package/dist/default/FacetPanelPane.d.ts.map +1 -1
  10. package/dist/default/FacetPanelPane.js +27 -6
  11. package/dist/default/FacetPanelPane.js.map +1 -1
  12. package/dist/default/SearchPage.d.ts +33 -1
  13. package/dist/default/SearchPage.d.ts.map +1 -1
  14. package/dist/default/SearchPage.js +32 -5
  15. package/dist/default/SearchPage.js.map +1 -1
  16. package/dist/default/SearchResultsPane.d.ts +76 -0
  17. package/dist/default/SearchResultsPane.d.ts.map +1 -1
  18. package/dist/default/SearchResultsPane.js +132 -45
  19. package/dist/default/SearchResultsPane.js.map +1 -1
  20. package/dist/default/index.d.ts +4 -2
  21. package/dist/default/index.d.ts.map +1 -1
  22. package/dist/default/index.js +2 -1
  23. package/dist/default/index.js.map +1 -1
  24. package/dist/default/swatches.d.ts +68 -0
  25. package/dist/default/swatches.d.ts.map +1 -0
  26. package/dist/default/swatches.js +258 -0
  27. package/dist/default/swatches.js.map +1 -0
  28. package/dist/state/facets.d.ts +17 -0
  29. package/dist/state/facets.d.ts.map +1 -1
  30. package/dist/state/facets.js +8 -0
  31. package/dist/state/facets.js.map +1 -1
  32. package/llms.txt +2 -2
  33. package/manifest.json +3 -1
  34. package/nav-manifest.json +1 -1
  35. package/package.json +5 -5
  36. package/src/analytics/generated/events.json +1 -1
  37. package/src/api/generated/schema.ts +8 -2
  38. package/src/default/FacetGroupControl.tsx +39 -0
  39. package/src/default/FacetPanelPane.tsx +61 -5
  40. package/src/default/SearchPage.tsx +72 -1
  41. package/src/default/SearchResultsPane.tsx +134 -5
  42. package/src/default/index.ts +15 -0
  43. package/src/default/swatches.ts +286 -0
  44. package/src/state/facets.ts +27 -0
@@ -67,6 +67,7 @@ import type { CSSProperties, ReactElement } from "react";
67
67
  import { Button, Checkbox, Flex, Input, Typography } from "antd";
68
68
  import { useT } from "@stapel/core";
69
69
  import { SkinPickerSheet } from "@stapel/tokens-antd/skin";
70
+ import { SWATCH_SIZE, facetSwatch } from "./swatches.js";
70
71
  import type { PickerGroup, PickerOption } from "@stapel/tokens-antd/skin";
71
72
  import { controls, cssVar, radii, spacing } from "@stapel/tokens";
72
73
  import { featureConfig, featureType } from "@stapel/attributes-react";
@@ -342,6 +343,42 @@ function OptionCount(props: {
342
343
  /** The indent one level of a hierarchical facet is drawn with. */
343
344
  const NEST_STEP = spacing[5];
344
345
 
346
+ /**
347
+ * The colour a value IS, drawn beside the word for it — see `./swatches.ts`.
348
+ *
349
+ * `aria-hidden`, and it never replaces the label: a dot is a second way to
350
+ * read a row that already reads, so a screen reader and a monochrome display
351
+ * lose nothing. The hairline is the panel's own border role, because a white
352
+ * swatch on a white rail is otherwise an empty hole.
353
+ */
354
+ function Swatch(props: {
355
+ readonly group: FacetGroup;
356
+ readonly value: string;
357
+ }): ReactElement | null {
358
+ const color = facetSwatch(props.group, props.value);
359
+ if (color === null) return null;
360
+ return (
361
+ <span
362
+ aria-hidden="true"
363
+ data-testid={`facet-swatch-${props.group.slug}-${props.value}`}
364
+ data-swatch={color}
365
+ style={{
366
+ display: "inline-block",
367
+ inlineSize: SWATCH_SIZE,
368
+ blockSize: SWATCH_SIZE,
369
+ flex: "0 0 auto",
370
+ borderRadius: radii.full,
371
+ background: color,
372
+ border: `1px solid ${cssVar("border")}`,
373
+ // The dot sits ON the text line rather than on the box's baseline,
374
+ // which is what keeps a 12px circle centred against a 14px label.
375
+ verticalAlign: "-0.125em",
376
+ marginInlineEnd: spacing[1],
377
+ }}
378
+ />
379
+ );
380
+ }
381
+
345
382
  function CheckboxRow(props: {
346
383
  readonly group: FacetGroup;
347
384
  readonly node: FacetOptionNode;
@@ -369,6 +406,7 @@ function CheckboxRow(props: {
369
406
  props.onToggle(group.slug, node.option.value);
370
407
  }}
371
408
  >
409
+ <Swatch group={group} value={node.option.value} />
372
410
  {node.option.label}
373
411
  </Checkbox>
374
412
  <OptionCount group={group} option={node.option} />
@@ -406,6 +444,7 @@ function OptionPill(props: {
406
444
  props.onToggle(group.slug, option.value);
407
445
  }}
408
446
  >
447
+ <Swatch group={group} value={option.value} />
409
448
  {option.count === null ? option.label : `${option.label} ${option.count}`}
410
449
  </Button>
411
450
  );
@@ -96,7 +96,7 @@ import {
96
96
  LoadList,
97
97
  SkinTheme,
98
98
  } from "@stapel/tokens-antd/skin";
99
- import { spacing } from "@stapel/tokens";
99
+ import { cssVar, spacing } from "@stapel/tokens";
100
100
  import { featureName } from "@stapel/attributes-react";
101
101
  import type { FeatureDef } from "@stapel/attributes-react";
102
102
  import type { SearchGeo } from "../api/types.js";
@@ -226,7 +226,32 @@ export interface GeoFilterSlotProps {
226
226
  readonly onChange: (geo: SearchGeo | null) => void;
227
227
  }
228
228
 
229
+ /**
230
+ * WHAT THE FILTER PANEL'S OWN BODY PAINTS — see
231
+ * {@link FacetPanelPaneProps.railSurface}.
232
+ */
233
+ export type SearchRailSurface = "flat" | "panel";
234
+
229
235
  export interface FacetPanelPaneProps extends ThemeModeProp {
236
+ /**
237
+ * WHAT THE PANEL'S OWN BODY PAINTS. Default `"flat"`.
238
+ *
239
+ * - `"flat"` — nothing. The panel is a column of controls standing on the
240
+ * page's own ground, and it takes only the text colour it needs
241
+ * (`var(--stapel-text)`), which a bare surface does not set;
242
+ * - `"panel"` — the raised container ground this pane painted until now.
243
+ *
244
+ * The default changed, and the measurement is why: on the stand's dark theme
245
+ * the rail was a **270 x 1539** filled slab with no radius and no border,
246
+ * standing on the page ground — a card shape with none of a card's edges,
247
+ * running the whole height of the feed beside it. The same read produced the
248
+ * same verdict for `categories-react`'s grid, strip and breadcrumbs, and the
249
+ * answer there was the same one: draw the controls, not a box around them.
250
+ *
251
+ * `"panel"` is the old arm kept whole, for a deployment whose page ground is
252
+ * an image or whose layout genuinely wants the filters on their own sheet.
253
+ */
254
+ readonly railSurface?: SearchRailSurface;
230
255
  /** The category's feature schema — the source of option LABELS, of which
231
256
  * slugs get a numeric range row, and of which slugs are a filter at all
232
257
  * (`isFacetableFeature`: an `imei` is counted and is not one). */
@@ -407,6 +432,9 @@ function RailFooterBar(props: {
407
432
  /** `"sticky"` pins it to the scroll port's floor; `"static"` lets it sit
408
433
  * after the last group. See {@link FacetPanelPaneProps.footerBar}. */
409
434
  readonly position: "sticky" | "static";
435
+ /** What the panel around it paints, so a pinned bar takes the SAME ground
436
+ * rather than deciding one of its own. See {@link FacetPanelPaneProps.railSurface}. */
437
+ readonly railSurface: SearchRailSurface;
410
438
  }): ReactElement | null {
411
439
  const t = useT();
412
440
  const tPlural = useTPlural();
@@ -430,9 +458,25 @@ function RailFooterBar(props: {
430
458
  data-testid="facets-footer-bar"
431
459
  data-position={props.position}
432
460
  style={{
433
- ...(props.position === "sticky" ? { position: "sticky", bottom: 0 } : {}),
434
- // Opaque, or the options scrolling under the bar read THROUGH it.
435
- background: token.colorBgContainer,
461
+ ...(props.position === "sticky"
462
+ ? {
463
+ position: "sticky",
464
+ bottom: 0,
465
+ /* THE GROUND IT IS ON, and only where it has to be opaque.
466
+ The bar used to paint antd's `colorBgContainer` in BOTH arms:
467
+ a second opinion about a colour its parent already decided,
468
+ and — since the panel's own body went flat — a lighter strip
469
+ standing across the foot of the rail on the stand's dark
470
+ theme. It now paints the same token the panel does and only
471
+ in the arm that is pinned over its own scroll port, where a
472
+ transparent floor lets the options read THROUGH it. The
473
+ static arm has nothing scrolling under it and paints
474
+ nothing. */
475
+ background: cssVar(
476
+ props.railSurface === "panel" ? "surface-raised" : "surface"
477
+ ),
478
+ }
479
+ : {}),
436
480
  borderBlockStart: `1px solid ${token.colorSplit}`,
437
481
  paddingBlockStart: spacing[2],
438
482
  display: "flex",
@@ -633,8 +677,19 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
633
677
  ? "none"
634
678
  : props.footerBar;
635
679
 
680
+ /* The panel's own ground — see `railSurface`. `"bare"` paints NOTHING, text
681
+ colour included, so the flat arm states the one property it still needs;
682
+ the theme's own custom property, so it follows the brand and the dark side
683
+ rather than freezing whichever mode mounted first. */
684
+ const railSurface: SearchRailSurface = props.railSurface ?? "flat";
685
+
636
686
  return (
637
- <SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
687
+ <SkinTheme
688
+ {...(props.mode !== undefined ? { mode: props.mode } : {})}
689
+ {...(railSurface === "flat"
690
+ ? { surface: "bare" as const, style: { color: cssVar("text") } }
691
+ : {})}
692
+ >
638
693
  <FacetPanel
639
694
  {...(props.categoryFeatures !== undefined
640
695
  ? { categoryFeatures: props.categoryFeatures }
@@ -1155,6 +1210,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
1155
1210
  activeFilters={bag.activeFilters}
1156
1211
  clearAll={bag.clearAll}
1157
1212
  position={footerBar}
1213
+ railSurface={railSurface}
1158
1214
  />
1159
1215
  )}
1160
1216
  </Flex>
@@ -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
- <div data-testid="search-results-header">{props.resultsHeader}</div>
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={RESULTS_TOOLBAR_CLASS}
905
+ className={toolbarClass}
777
906
  data-testid="search-results-toolbar"
778
- style={{ ...COMPACT_TOOLBAR, ...toolbarPin }}
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={RESULTS_TOOLBAR_CLASS}
932
+ className={toolbarClass}
804
933
  data-testid="search-results-toolbar"
805
- style={{ ...TOOLBAR_ROW, ...toolbarPin }}
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
@@ -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,15 @@ export type {
151
156
  FacetOptionNode,
152
157
  } from "./FacetGroupControl.js";
153
158
 
159
+ export {
160
+ SWATCH_SIZE,
161
+ facetSwatch,
162
+ isColorAxis,
163
+ swatchColor,
164
+ termHue,
165
+ } from "./swatches.js";
166
+ export type { ColorAxisLike, TermExtra } from "./swatches.js";
167
+
154
168
  // ── the browse surfaces a storefront PLACES (this pair does not lay them
155
169
  // out: where a popular-values block or a partition row belongs on a
156
170
  // category page is the page's decision) ──────────────────────────────────
@@ -183,6 +197,7 @@ export type {
183
197
  FacetPanelPaneProps,
184
198
  CategoryFilterSlotProps,
185
199
  GeoFilterSlotProps,
200
+ SearchRailSurface,
186
201
  } from "./FacetPanelPane.js";
187
202
 
188
203
  export { RankingDisclosurePane, RANKING_MAX_WIDTH } from "./RankingDisclosurePane.js";