@stapel/search-react 0.32.8 → 0.33.1

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,144 @@ 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 SAME
124
+ * THING: the controls' own line, and never the heading beside it (D452). The
125
+ * compact shape always drew it that way; the wide shape did not, and pinning a
126
+ * block that carried the `<h1>` pinned 100px of chrome at 1440 and 150px at
127
+ * 1280 — see `WIDE_HEADING_ROW`.
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
+ /**
177
+ * The WIDE shape's heading row — a row of its own, at the results column's
178
+ * full measure (D452).
179
+ *
180
+ * The heading used to be the leading flex item of the block that pins, with
181
+ * the controls as the trailing one. Two things followed, and both were
182
+ * measured on the stand:
183
+ *
184
+ * - the controls row is `nowrap` and holds a count, a sort select, a page
185
+ * size and a view switch, so it took whatever width it needed and left the
186
+ * heading **415px** of a 1200px column. An `<h1>` at 42px (50 at 1280) then
187
+ * wrapped to TWO LINES;
188
+ * - those two lines were inside the pinned box, so what stood under the
189
+ * header while the feed scrolled was **100px** of chrome at 1440 and
190
+ * **150px** at 1280 — a third of the fold on a laptop.
191
+ *
192
+ * Nothing about the heading belongs in a pinned bar: it names the page once,
193
+ * it is read once, and it is the one element on the page whose length the pair
194
+ * does not control (`resultsHeading` is the host's sentence — "Buy a
195
+ * Samsung Galaxy S23 in Kazan"). So it takes its own line at the full width,
196
+ * where it has room not to wrap, and the pin acts on the controls alone —
197
+ * which is what `header="compact"` already did.
198
+ *
199
+ * `100%` is DECLARED rather than left to the column's `align-items: stretch`:
200
+ * the row is what a host reads to know the heading is not in the pinned box,
201
+ * and a width that only happens to be full is not a contract.
202
+ */
203
+ const WIDE_HEADING_ROW: CSSProperties = { inlineSize: "100%", minInlineSize: 0 };
204
+
205
+ /** The wide heading itself: no margin of its own — the column's gap is the
206
+ * rhythm — and free to shrink inside its row rather than forcing it wider. */
207
+ const WIDE_HEADING: CSSProperties = { margin: 0, minInlineSize: 0 };
208
+
209
+ /**
210
+ * The wide row's trailing group — the surface's controls.
211
+ *
212
+ * `flex: 0 0 auto`: the count on the leading end is the elastic half (it is
213
+ * one short string and it arrives late), and the controls keep every pixel
214
+ * they need. Both ends shrinking is how a sort select ends up narrower than
215
+ * its own longest option.
216
+ */
217
+ const TOOLBAR_END: CSSProperties = { flex: "0 0 auto" };
218
+
219
+ /** The compact shape's toolbar box — the row the pin acts on. */
220
+ const COMPACT_TOOLBAR: CSSProperties = {
221
+ display: "flex",
222
+ alignItems: "center",
223
+ ...TOOLBAR_ROW,
224
+ };
225
+
226
+ /**
227
+ * The pin itself, and it is NET-ZERO in flow: `position` and a layer and a
228
+ * background, and not one pixel of padding.
229
+ *
230
+ * A pinned bar that grows padding to breathe over the cards passing under it
231
+ * has to take the same padding back as negative margin or the column pays for
232
+ * it, and the pair does not know which of those two a host's spacing wants. A
233
+ * row that occupies exactly the box it already occupied cannot shift anything.
234
+ */
235
+ function toolbarPinStyle(pin: SearchToolbarPin | undefined): CSSProperties | undefined {
236
+ if (pin === undefined) return undefined;
237
+ return {
238
+ position: "sticky",
239
+ top: pin.top ?? 0,
240
+ // Over the cards, under the page's own chrome — and under antd's popups,
241
+ // so the sort select still opens over its own bar.
242
+ zIndex: 1,
243
+ // Opaque, or the cards scroll THROUGH the bar. The theme's own surface
244
+ // role, resolved at paint time, so it follows the brand and the dark side
245
+ // without a second colour being written here.
246
+ background: cssVar("surface"),
247
+ };
248
+ }
249
+
112
250
  /**
113
251
  * The results grid. `auto-fill` + `minmax(260px, 1fr)`: as many columns as fit,
114
252
  * each at least a readable card and never wider than its share — and the same
@@ -396,6 +534,33 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
396
534
  * viewport is not spent saying "Results" over a list of results.
397
535
  */
398
536
  readonly header?: "banner" | "compact";
537
+ /**
538
+ * PIN the toolbar row under whatever chrome is above this pane.
539
+ *
540
+ * ```tsx
541
+ * // the height <PublicShell> publishes, read rather than restated
542
+ * <SearchResultsPane stickyToolbar={{ top: "var(--stapel-header-height)" }} />
543
+ * ```
544
+ *
545
+ * A catalogue page is thirty cards long and the control that reorders them is
546
+ * at the top of it, so by the fourth row the sort is a screenful above the
547
+ * list it sorts — the same argument that made the filter rail sticky, applied
548
+ * to the other column.
549
+ *
550
+ * It pins the row the pane ACTUALLY DREW, which is why this is a prop and not
551
+ * a host's stylesheet: the pane knows which of its two header shapes it is in
552
+ * and a sheet has to guess. In `"banner"` the whole header block pins (it is
553
+ * one line: heading, count, toolbar). In `"compact"` the toolbar alone pins —
554
+ * one row, the height of one phone control — because the stack it sits in is
555
+ * ~112px and that is not a bar anybody can pin under a 56px header.
556
+ *
557
+ * Two things it does NOT do, both deliberate: it adds no padding (see
558
+ * {@link toolbarPinStyle} — a pinned bar that grows to breathe has to give
559
+ * the room back, and which spacing that is belongs to the host), and it draws
560
+ * no second sort control. There is exactly one on a results page, and this
561
+ * pins it where it stands.
562
+ */
563
+ readonly stickyToolbar?: SearchToolbarPin;
399
564
  /**
400
565
  * The category's feature schema, used ONLY to name an applied filter in the
401
566
  * empty state's exits ("Without Brand" rather than "Without vendor").
@@ -503,6 +668,7 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
503
668
  // The compact arm's caption: the host's word is drawn, the pair's own is
504
669
  // not, and `headingVisible` overrides either way — see that prop.
505
670
  const compactHeadingSeen = props.headingVisible ?? props.heading !== undefined;
671
+ const toolbarPin = toolbarPinStyle(props.stickyToolbar);
506
672
  const columnRules =
507
673
  props.columns === undefined || props.layout === "list"
508
674
  ? null
@@ -542,7 +708,15 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
542
708
  <div data-testid="search-results-lead">{props.lead}</div>
543
709
  )}
544
710
  {props.header === "compact" ? (
545
- <Flex vertical gap={spacing[2]} data-testid="search-results-header-compact">
711
+ /* No box of its own — see `COMPACT_STACK`. The three rows are
712
+ items of the results COLUMN, so the toolbar between them has a
713
+ parent as tall as the feed to travel in. */
714
+ <Flex
715
+ vertical
716
+ gap={spacing[2]}
717
+ style={COMPACT_STACK}
718
+ data-testid="search-results-header-compact"
719
+ >
546
720
  <Typography.Title
547
721
  level={props.headingLevel ?? 4}
548
722
  style={compactHeadingSeen ? { margin: 0 } : visuallyHidden}
@@ -551,23 +725,45 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
551
725
  >
552
726
  {props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
553
727
  </Typography.Title>
554
- {props.toolbar}
728
+ <div
729
+ className={RESULTS_TOOLBAR_CLASS}
730
+ data-testid="search-results-toolbar"
731
+ style={{ ...COMPACT_TOOLBAR, ...toolbarPin }}
732
+ >
733
+ {props.toolbar}
734
+ </div>
555
735
  <Count bag={bag} />
556
736
  </Flex>
557
737
  ) : (
558
- <Flex justify="space-between" align="center" wrap gap={spacing[2]}>
559
- <Typography.Title
560
- level={props.headingLevel ?? 4}
561
- style={{ margin: 0 }}
562
- data-testid="search-results-heading"
738
+ /* D452 the heading is its OWN row, at the column's full width,
739
+ and the row that pins is the controls. See `WIDE_HEADING_ROW`.
740
+ Both are direct children of the results column: the toolbar
741
+ has a parent as tall as the feed to travel in, and the heading
742
+ has the whole measure to set its line in. */
743
+ <>
744
+ <div style={WIDE_HEADING_ROW} data-testid="search-results-heading-row">
745
+ <Typography.Title
746
+ level={props.headingLevel ?? 4}
747
+ style={WIDE_HEADING}
748
+ data-testid="search-results-heading"
749
+ >
750
+ {props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
751
+ </Typography.Title>
752
+ </div>
753
+ <Flex
754
+ justify="space-between"
755
+ align="center"
756
+ gap={spacing[3]}
757
+ className={RESULTS_TOOLBAR_CLASS}
758
+ data-testid="search-results-toolbar"
759
+ style={{ ...TOOLBAR_ROW, ...toolbarPin }}
563
760
  >
564
- {props.heading ?? t(SEARCH_I18N_KEYS.resultsTitle)}
565
- </Typography.Title>
566
- <Flex align="center" wrap gap={spacing[3]}>
567
761
  <Count bag={bag} />
568
- {props.toolbar}
762
+ <Flex align="center" gap={spacing[3]} style={TOOLBAR_END}>
763
+ {props.toolbar}
764
+ </Flex>
569
765
  </Flex>
570
- </Flex>
766
+ </>
571
767
  )}
572
768
 
573
769
  <DegradationNotice
@@ -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 {