@stapel/search-react 0.32.7 → 0.33.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.
@@ -94,6 +94,7 @@ import { SearchResultsPane } from "./SearchResultsPane.js";
94
94
  import type {
95
95
  ResultsColumns,
96
96
  SearchResultsWrapper,
97
+ SearchToolbarPin,
97
98
  } from "./SearchResultsPane.js";
98
99
  import { SortSelect } from "./SortSelect.js";
99
100
  import { SEARCH_BUILTIN_VIEWS, ViewSwitch, resolveView } from "./ViewSwitch.js";
@@ -283,6 +284,24 @@ const RAIL: CSSProperties = {
283
284
  paddingBlockEnd: spacing[2],
284
285
  };
285
286
 
287
+ /**
288
+ * The rail with a host's own offset under it.
289
+ *
290
+ * `top` and the height cap move TOGETHER, and that is the whole reason this is
291
+ * a function rather than one property: a rail pushed 64px down the window whose
292
+ * cap is still `100dvh` ends 64px past the foot of the screen, so its last
293
+ * control is unreachable — the internal scroll has scrolled past the window.
294
+ *
295
+ * A number is pixels; a string is taken as written, so
296
+ * `railTop="var(--stapel-header-height)"` reads the height `<PublicShell>`
297
+ * publishes instead of restating it.
298
+ */
299
+ export function railStyle(top: number | string | undefined): CSSProperties {
300
+ if (top === undefined) return RAIL;
301
+ const offset = typeof top === "number" ? `${String(top)}px` : top;
302
+ return { ...RAIL, top, maxHeight: `calc(100dvh - ${offset})` };
303
+ }
304
+
286
305
  /** The results take what is left. `minWidth: 0` so a long word inside a card
287
306
  * cannot push the grid wider than its column. */
288
307
  const RESULTS_COLUMN: CSSProperties = { flex: "1 1 auto", minWidth: 0 };
@@ -522,6 +541,50 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
522
541
  * rail beside a 110px result column.
523
542
  */
524
543
  readonly filtersLayout?: SearchFiltersLayout;
544
+ /**
545
+ * WHERE the filter rail's sticky top edge is — the offset of whatever chrome
546
+ * is pinned above this page.
547
+ *
548
+ * ```tsx
549
+ * // the height <PublicShell> publishes, read rather than restated
550
+ * <SearchPage railTop="var(--stapel-header-height)" />
551
+ * ```
552
+ *
553
+ * The rail is `position: sticky; top: 0` written INLINE, and an inline
554
+ * declaration is beaten by nothing but `!important` — so a host with a fixed
555
+ * header had exactly one way to say "start below it", and the fleet's
556
+ * storefront carried that `!important` as the only one in the repo aimed at a
557
+ * pair's own geometry. Every deployment with a pinned header has this
558
+ * problem; none of them could state it.
559
+ *
560
+ * A number is pixels; a string is taken as written (a `var()`, a `calc()`,
561
+ * `"4rem"`). The internal scroll cap moves with it — see {@link railStyle} —
562
+ * so the rail still ends at the foot of the window rather than that far past
563
+ * it. Default `0`, which is where the rail has always started.
564
+ *
565
+ * The phone SHEET is unaffected: it is a dialog, not a column, and it has no
566
+ * header to clear.
567
+ */
568
+ readonly railTop?: number | string;
569
+ /**
570
+ * PIN the results toolbar under whatever chrome is above this page — the
571
+ * other column's half of {@link railTop}.
572
+ *
573
+ * ```tsx
574
+ * <SearchPage
575
+ * railTop="var(--stapel-header-height)"
576
+ * stickyToolbar={{ top: "var(--stapel-header-height)" }}
577
+ * />
578
+ * ```
579
+ *
580
+ * Handed straight to `<SearchResultsPane stickyToolbar>`, which pins the row
581
+ * it actually drew — the whole header block in the wide shape, the toolbar
582
+ * alone in the compact one. There is exactly one sort control on a results
583
+ * page and this pins it where it stands; a host that draws a bar of its own
584
+ * over the page ends up with two, and the one a person presses is whichever
585
+ * the layout put under their thumb.
586
+ */
587
+ readonly stickyToolbar?: SearchToolbarPin;
525
588
  /**
526
589
  * The host's own exits from an empty result — sibling sections with their
527
590
  * counts. A SLOT for the same reason `breadcrumb` is one: walking the tree
@@ -639,6 +702,8 @@ interface SearchPageBodyProps {
639
702
  readonly degradationNotice?: DegradationNoticeVariant;
640
703
  readonly railFrom?: SearchRailFrom;
641
704
  readonly filtersLayout?: SearchFiltersLayout;
705
+ readonly railTop?: number | string;
706
+ readonly stickyToolbar?: SearchToolbarPin;
642
707
  readonly defaultFiltersOpen?: boolean;
643
708
  readonly pageSize?: boolean;
644
709
  readonly breadcrumb?: ReactNode;
@@ -838,6 +903,9 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
838
903
  toolbar={toolbar}
839
904
  {...(props.resultsLead !== undefined ? { lead: props.resultsLead } : {})}
840
905
  {...(phoneToolbar ? { header: "compact" as const } : {})}
906
+ {...(props.stickyToolbar !== undefined
907
+ ? { stickyToolbar: props.stickyToolbar }
908
+ : {})}
841
909
  headingLevel={props.resultsHeadingLevel ?? 1}
842
910
  {...(view.render !== undefined ? { renderResults: view.render } : {})}
843
911
  {...(props.wrapResults !== undefined ? { wrapResults: props.wrapResults } : {})}
@@ -998,7 +1066,7 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
998
1066
  </>
999
1067
  ) : showFilters ? (
1000
1068
  <Flex align="flex-start" gap={spacing[5]} data-testid="search-page-columns">
1001
- <div className={RAIL_CLASS} style={RAIL}>
1069
+ <div className={RAIL_CLASS} style={railStyle(props.railTop)}>
1002
1070
  {/* The rail's scrollbar, in the gutter and in the token palette —
1003
1071
  see `railScrollbarCss`. Hoisted, deduped by `href`. */}
1004
1072
  <style href={RAIL_STYLE_HREF} precedence="default">
@@ -1050,6 +1118,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1050
1118
  degradationNotice,
1051
1119
  railFrom,
1052
1120
  filtersLayout,
1121
+ railTop,
1122
+ stickyToolbar,
1053
1123
  defaultFiltersOpen,
1054
1124
  pageSize,
1055
1125
  breadcrumb,
@@ -1102,6 +1172,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1102
1172
  {...(degradationNotice !== undefined ? { degradationNotice } : {})}
1103
1173
  {...(railFrom !== undefined ? { railFrom } : {})}
1104
1174
  {...(filtersLayout !== undefined ? { filtersLayout } : {})}
1175
+ {...(railTop !== undefined ? { railTop } : {})}
1176
+ {...(stickyToolbar !== undefined ? { stickyToolbar } : {})}
1105
1177
  {...(defaultFiltersOpen !== undefined ? { defaultFiltersOpen } : {})}
1106
1178
  {...(pageSize !== undefined ? { pageSize } : {})}
1107
1179
  {...(breadcrumb !== undefined ? { breadcrumb } : {})}
@@ -35,7 +35,7 @@ import {
35
35
  SkinTheme,
36
36
  visuallyHidden,
37
37
  } from "@stapel/tokens-antd/skin";
38
- import { breakpoints, spacing } from "@stapel/tokens";
38
+ import { breakpoints, cssVar, spacing } from "@stapel/tokens";
39
39
  import type { FeatureDef } from "@stapel/attributes-react";
40
40
  import type { SearchItem } from "../api/types.js";
41
41
  import { SearchResults } from "../headless/SearchResults.js";
@@ -109,6 +109,101 @@ export type SearchResultsWrapper = (
109
109
  */
110
110
  export const RESULTS_MAX_WIDTH = 1400;
111
111
 
112
+ /**
113
+ * The class on the row that carries the results toolbar — the sort control, the
114
+ * view switch, the page size, the count and the surface's own action.
115
+ *
116
+ * The row had no element of its own: no class, no `data-testid`, and in the
117
+ * compact header it was one child of a 112px vertical stack. A host that wanted
118
+ * it pinned under a fixed header therefore had to reach it with a `:has()` on
119
+ * the heading beside it, a `display: contents` to drop the stack's box, and the
120
+ * pane's own breakpoint restated in a media query — three rules aimed at a
121
+ * shape the pane could change under them at any release.
122
+ *
123
+ * It is the same class in BOTH header shapes, and in both it names the row that
124
+ * would pin: the WIDE header block (heading, count and toolbar on one line —
125
+ * the block IS the row) and, in `header="compact"`, the toolbar alone, because
126
+ * pinning the compact stack would put ~112px of chrome under a 56px header on a
127
+ * 390px screen.
128
+ */
129
+ export const RESULTS_TOOLBAR_CLASS = "stapel-search-results-toolbar";
130
+
131
+ /**
132
+ * Where {@link SearchResultsPaneProps.stickyToolbar} pins the row.
133
+ *
134
+ * `top` is a CSS length — a number is pixels, a string is taken as written, so
135
+ * `top: "var(--stapel-header-height)"` reads the height `<PublicShell>`
136
+ * publishes rather than restating it. Default `0`.
137
+ */
138
+ export interface SearchToolbarPin {
139
+ readonly top?: number | string;
140
+ }
141
+
142
+ /**
143
+ * The controls' own line: it may not become two.
144
+ *
145
+ * A wrapping row changes height when the count lands — the count arrives with
146
+ * the answer, one render after the toolbar was already on screen — and a bar
147
+ * that grows while it is pinned pushes the first cards of the feed down under
148
+ * the reader's eye. `nowrap` makes the height a constant; `min-inline-size: 0`
149
+ * plus `overflow-x: auto` is what a row that cannot wrap does with the overflow
150
+ * instead, which on a phone is the scroll strip every sort row already is.
151
+ */
152
+ const TOOLBAR_ROW: CSSProperties = {
153
+ flexWrap: "nowrap",
154
+ minInlineSize: 0,
155
+ overflowX: "auto",
156
+ // Thin, and only when there is something to scroll to — the same reason the
157
+ // filter rail states its own: an invisible scroll port is indistinguishable
158
+ // from a row that ends where it was cut.
159
+ scrollbarWidth: "thin",
160
+ };
161
+
162
+ /**
163
+ * The compact header's stack, WITHOUT its box.
164
+ *
165
+ * `position: sticky` travels inside its parent, so a toolbar nested in a 112px
166
+ * stack can move 112px and no further — the parent has to be the results
167
+ * column, which is as tall as the feed. `display: contents` drops the stack's
168
+ * box and hands its three rows straight to that column, which is the same
169
+ * device the fleet's storefront was writing in its own sheet.
170
+ *
171
+ * The one visible consequence: the gap between heading, toolbar and count
172
+ * becomes the column's rather than the stack's.
173
+ */
174
+ const COMPACT_STACK: CSSProperties = { display: "contents" };
175
+
176
+ /** The compact shape's toolbar box — the row the pin acts on. */
177
+ const COMPACT_TOOLBAR: CSSProperties = {
178
+ display: "flex",
179
+ alignItems: "center",
180
+ ...TOOLBAR_ROW,
181
+ };
182
+
183
+ /**
184
+ * The pin itself, and it is NET-ZERO in flow: `position` and a layer and a
185
+ * background, and not one pixel of padding.
186
+ *
187
+ * A pinned bar that grows padding to breathe over the cards passing under it
188
+ * has to take the same padding back as negative margin or the column pays for
189
+ * it, and the pair does not know which of those two a host's spacing wants. A
190
+ * row that occupies exactly the box it already occupied cannot shift anything.
191
+ */
192
+ function toolbarPinStyle(pin: SearchToolbarPin | undefined): CSSProperties | undefined {
193
+ if (pin === undefined) return undefined;
194
+ return {
195
+ position: "sticky",
196
+ top: pin.top ?? 0,
197
+ // Over the cards, under the page's own chrome — and under antd's popups,
198
+ // so the sort select still opens over its own bar.
199
+ zIndex: 1,
200
+ // Opaque, or the cards scroll THROUGH the bar. The theme's own surface
201
+ // role, resolved at paint time, so it follows the brand and the dark side
202
+ // without a second colour being written here.
203
+ background: cssVar("surface"),
204
+ };
205
+ }
206
+
112
207
  /**
113
208
  * The results grid. `auto-fill` + `minmax(260px, 1fr)`: as many columns as fit,
114
209
  * each at least a readable card and never wider than its share — and the same
@@ -396,6 +491,33 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
396
491
  * viewport is not spent saying "Results" over a list of results.
397
492
  */
398
493
  readonly header?: "banner" | "compact";
494
+ /**
495
+ * PIN the toolbar row under whatever chrome is above this pane.
496
+ *
497
+ * ```tsx
498
+ * // the height <PublicShell> publishes, read rather than restated
499
+ * <SearchResultsPane stickyToolbar={{ top: "var(--stapel-header-height)" }} />
500
+ * ```
501
+ *
502
+ * A catalogue page is thirty cards long and the control that reorders them is
503
+ * at the top of it, so by the fourth row the sort is a screenful above the
504
+ * list it sorts — the same argument that made the filter rail sticky, applied
505
+ * to the other column.
506
+ *
507
+ * It pins the row the pane ACTUALLY DREW, which is why this is a prop and not
508
+ * a host's stylesheet: the pane knows which of its two header shapes it is in
509
+ * and a sheet has to guess. In `"banner"` the whole header block pins (it is
510
+ * one line: heading, count, toolbar). In `"compact"` the toolbar alone pins —
511
+ * one row, the height of one phone control — because the stack it sits in is
512
+ * ~112px and that is not a bar anybody can pin under a 56px header.
513
+ *
514
+ * Two things it does NOT do, both deliberate: it adds no padding (see
515
+ * {@link toolbarPinStyle} — a pinned bar that grows to breathe has to give
516
+ * the room back, and which spacing that is belongs to the host), and it draws
517
+ * no second sort control. There is exactly one on a results page, and this
518
+ * pins it where it stands.
519
+ */
520
+ readonly stickyToolbar?: SearchToolbarPin;
399
521
  /**
400
522
  * The category's feature schema, used ONLY to name an applied filter in the
401
523
  * empty state's exits ("Without Brand" rather than "Without vendor").
@@ -503,6 +625,7 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
503
625
  // The compact arm's caption: the host's word is drawn, the pair's own is
504
626
  // not, and `headingVisible` overrides either way — see that prop.
505
627
  const compactHeadingSeen = props.headingVisible ?? props.heading !== undefined;
628
+ const toolbarPin = toolbarPinStyle(props.stickyToolbar);
506
629
  const columnRules =
507
630
  props.columns === undefined || props.layout === "list"
508
631
  ? null
@@ -542,7 +665,15 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
542
665
  <div data-testid="search-results-lead">{props.lead}</div>
543
666
  )}
544
667
  {props.header === "compact" ? (
545
- <Flex vertical gap={spacing[2]} data-testid="search-results-header-compact">
668
+ /* No box of its own — see `COMPACT_STACK`. The three rows are
669
+ items of the results COLUMN, so the toolbar between them has a
670
+ parent as tall as the feed to travel in. */
671
+ <Flex
672
+ vertical
673
+ gap={spacing[2]}
674
+ style={COMPACT_STACK}
675
+ data-testid="search-results-header-compact"
676
+ >
546
677
  <Typography.Title
547
678
  level={props.headingLevel ?? 4}
548
679
  style={compactHeadingSeen ? { margin: 0 } : visuallyHidden}
@@ -551,19 +682,35 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
551
682
  >
552
683
  {props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
553
684
  </Typography.Title>
554
- {props.toolbar}
685
+ <div
686
+ className={RESULTS_TOOLBAR_CLASS}
687
+ data-testid="search-results-toolbar"
688
+ style={{ ...COMPACT_TOOLBAR, ...toolbarPin }}
689
+ >
690
+ {props.toolbar}
691
+ </div>
555
692
  <Count bag={bag} />
556
693
  </Flex>
557
694
  ) : (
558
- <Flex justify="space-between" align="center" wrap gap={spacing[2]}>
695
+ /* The wide shape's header block IS the toolbar row: one line,
696
+ heading at one end, count and controls at the other, and a
697
+ direct child of the results column — so it pins as it stands. */
698
+ <Flex
699
+ justify="space-between"
700
+ align="center"
701
+ gap={spacing[2]}
702
+ className={RESULTS_TOOLBAR_CLASS}
703
+ data-testid="search-results-toolbar"
704
+ {...(toolbarPin !== undefined ? { style: toolbarPin } : {})}
705
+ >
559
706
  <Typography.Title
560
707
  level={props.headingLevel ?? 4}
561
- style={{ margin: 0 }}
708
+ style={{ margin: 0, minInlineSize: 0 }}
562
709
  data-testid="search-results-heading"
563
710
  >
564
711
  {props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
565
712
  </Typography.Title>
566
- <Flex align="center" wrap gap={spacing[3]}>
713
+ <Flex align="center" gap={spacing[3]} style={TOOLBAR_ROW}>
567
714
  <Count bag={bag} />
568
715
  {props.toolbar}
569
716
  </Flex>
@@ -45,6 +45,7 @@ export {
45
45
  RAIL_CLASS,
46
46
  RAIL_STYLE_HREF,
47
47
  railScrollbarCss,
48
+ railStyle,
48
49
  } from "./SearchPage.js";
49
50
  export type {
50
51
  SearchPageProps,
@@ -73,6 +74,7 @@ export {
73
74
  RESULTS_AUTO_FILL_COLUMNS,
74
75
  RESULTS_COLUMNS_CLASS,
75
76
  RESULTS_COLUMNS_STYLE_HREF,
77
+ RESULTS_TOOLBAR_CLASS,
76
78
  resultsColumnsCss,
77
79
  } from "./SearchResultsPane.js";
78
80
  export type {
@@ -80,6 +82,7 @@ export type {
80
82
  SearchResultsPaneProps,
81
83
  SearchResultsRenderer,
82
84
  SearchResultsWrapper,
85
+ SearchToolbarPin,
83
86
  } from "./SearchResultsPane.js";
84
87
 
85
88
  export {