@stapel/search-react 0.32.3 → 0.32.5

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.
@@ -35,7 +35,7 @@ import {
35
35
  SkinTheme,
36
36
  visuallyHidden,
37
37
  } from "@stapel/tokens-antd/skin";
38
- import { spacing } from "@stapel/tokens";
38
+ import { breakpoints, 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";
@@ -143,6 +143,64 @@ const RESULTS_LIST: CSSProperties = {
143
143
  gridTemplateColumns: "1fr",
144
144
  };
145
145
 
146
+ /**
147
+ * HOW MANY CARD COLUMNS, when the host has an opinion.
148
+ *
149
+ * A number is that many columns at every width. A map is one number per token
150
+ * breakpoint, and the two named rungs are the only ones there are: `phone` is
151
+ * the base, `tablet` and `desktop` take over as the block gets wider. A key
152
+ * left out inherits the rung below it.
153
+ */
154
+ export type ResultsColumns =
155
+ | number
156
+ | Readonly<Partial<Record<"phone" | "tablet" | "desktop", number>>>;
157
+
158
+ /** The class the responsive arm's rules are hung on. */
159
+ export const RESULTS_COLUMNS_CLASS = "stapel-search-results-columns";
160
+ /** The `href` the hoisted column sheet is deduplicated by. */
161
+ export const RESULTS_COLUMNS_STYLE_HREF = "stapel-search-results-columns";
162
+
163
+ /** N equal tracks, each allowed to shrink below its content — `minmax(0,
164
+ * 1fr)` rather than `1fr`, or one long unbroken title widens the whole row. */
165
+ function fixedColumns(count: number): string {
166
+ return `repeat(${String(Math.max(1, Math.floor(count)))}, minmax(0, 1fr))`;
167
+ }
168
+
169
+ /**
170
+ * The map, as CSS — and the query measures the BLOCK, not the window.
171
+ *
172
+ * It has to: this grid sits in the results column, which on a 1440px desktop
173
+ * is the window minus a 280px filter rail minus the gap. A media query would
174
+ * hand it a desktop column count at a width it never has, and the same three
175
+ * rungs are what `<PopularValues columns="responsive">` already climbs one
176
+ * pane over. The keys NAME the token breakpoints because those are the
177
+ * numbers a host thinks in; what they are compared against is this block's
178
+ * own inline size.
179
+ *
180
+ * Ascending, so the widest matching rung wins by ordinary cascade order.
181
+ */
182
+ export function resultsColumnsCss(columns: ResultsColumns): string {
183
+ const block = `.${RESULTS_COLUMNS_CLASS}`;
184
+ if (typeof columns === "number") {
185
+ return `${block}{grid-template-columns:${fixedColumns(columns)}}`;
186
+ }
187
+ const rules = [`${block}{container-type:inline-size}`];
188
+ if (columns.phone !== undefined) {
189
+ rules.push(`${block}{grid-template-columns:${fixedColumns(columns.phone)}}`);
190
+ }
191
+ for (const rung of [
192
+ { at: breakpoints.tablet, count: columns.tablet },
193
+ { at: breakpoints.desktop, count: columns.desktop },
194
+ ]) {
195
+ if (rung.count === undefined) continue;
196
+ rules.push(
197
+ `@container (min-width: ${String(rung.at)}px)` +
198
+ `{${block}{grid-template-columns:${fixedColumns(rung.count)}}}`
199
+ );
200
+ }
201
+ return rules.join("\n");
202
+ }
203
+
146
204
  export interface SearchResultsPaneProps extends ThemeModeProp {
147
205
  /**
148
206
  * The card slot (spec §6.2 item 1). A storefront passes
@@ -230,6 +288,26 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
230
288
  * Ignored when `renderResults` replaces the arrangement entirely.
231
289
  */
232
290
  readonly layout?: SearchResultsLayout;
291
+ /**
292
+ * HOW MANY COLUMNS the `"grid"` arrangement draws.
293
+ *
294
+ * Default: none of them — `repeat(auto-fill, minmax(260px, 1fr))` decides,
295
+ * which is the right answer for a storefront that wants as many readable
296
+ * cards as fit and no breakpoint table to maintain. It is the wrong answer
297
+ * for a deployment whose cards are taller or wider than the default's, and
298
+ * such a host had exactly one way to say so: an `!important` rule on this
299
+ * pane's grid from outside, against a declaration it could not read back.
300
+ *
301
+ * A number is that many columns at every width. A map ({@link
302
+ * ResultsColumns}) is one number per token breakpoint — `{ phone: 1, tablet:
303
+ * 2 }` is the two-cards-per-row tablet SERP this exists for — compared
304
+ * against the width of this BLOCK rather than of the window, because the
305
+ * filter rail takes 280px of that window and the cards never see it.
306
+ *
307
+ * Ignored by `layout="list"` (one row per result IS one column) and by
308
+ * `renderResults`, which replaces the arrangement entirely.
309
+ */
310
+ readonly columns?: ResultsColumns;
233
311
  /**
234
312
  * Heading level for the results caption. Default `4`, which is what every
235
313
  * surface that EMBEDS this pane under its own title needs.
@@ -364,6 +442,12 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
364
442
  const { renderCard, renderResults, wrapResults } = props;
365
443
  const scorerName = useScorerNames();
366
444
  const maxWidth = props.maxWidth === undefined ? RESULTS_MAX_WIDTH : props.maxWidth;
445
+ // A column count is the host's opinion about the GRID; the list arrangement
446
+ // is one column by definition and has nothing to say to it.
447
+ const columnRules =
448
+ props.columns === undefined || props.layout === "list"
449
+ ? null
450
+ : resultsColumnsCss(props.columns);
367
451
 
368
452
  return (
369
453
  <SkinTheme
@@ -482,9 +566,31 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
482
566
  ) : (
483
567
  <div
484
568
  style={props.layout === "list" ? RESULTS_LIST : RESULTS_GRID}
569
+ // The class carries the host's column count; without one
570
+ // it is absent and the `auto-fill` default is the only
571
+ // rule in play.
572
+ {...(columnRules !== null
573
+ ? { className: RESULTS_COLUMNS_CLASS }
574
+ : {})}
485
575
  data-testid="search-results-grid"
486
576
  data-layout={props.layout ?? "grid"}
577
+ {...(columnRules !== null
578
+ ? {
579
+ "data-columns":
580
+ typeof props.columns === "number"
581
+ ? String(props.columns)
582
+ : "responsive",
583
+ }
584
+ : {})}
487
585
  >
586
+ {columnRules !== null ? (
587
+ <style
588
+ href={RESULTS_COLUMNS_STYLE_HREF}
589
+ precedence="default"
590
+ >
591
+ {columnRules}
592
+ </style>
593
+ ) : null}
488
594
  {items.map((item: SearchItem) => (
489
595
  <div key={item.key}>
490
596
  {renderCard !== undefined ? (
@@ -55,6 +55,7 @@ export {
55
55
  OTHER_CATEGORIES_SLOT_MIN_HEIGHT,
56
56
  OTHER_CATEGORIES_STYLE_HREF,
57
57
  otherCategoriesCss,
58
+ otherCategoriesSlotHeight,
58
59
  } from "./OtherCategoriesLine.js";
59
60
  export type {
60
61
  OtherCategoriesLineProps,
@@ -62,8 +63,15 @@ export type {
62
63
  OtherCategoryNamer,
63
64
  } from "./OtherCategoriesLine.js";
64
65
 
65
- export { SearchResultsPane, RESULTS_MAX_WIDTH } from "./SearchResultsPane.js";
66
+ export {
67
+ SearchResultsPane,
68
+ RESULTS_MAX_WIDTH,
69
+ RESULTS_COLUMNS_CLASS,
70
+ RESULTS_COLUMNS_STYLE_HREF,
71
+ resultsColumnsCss,
72
+ } from "./SearchResultsPane.js";
66
73
  export type {
74
+ ResultsColumns,
67
75
  SearchResultsPaneProps,
68
76
  SearchResultsRenderer,
69
77
  SearchResultsWrapper,
@@ -77,6 +85,7 @@ export {
77
85
  CHIP_ROW_MIN_HEIGHT,
78
86
  CHIP_ROW_STYLE_HREF,
79
87
  appliedChipTestId,
88
+ appliedRowMinHeight,
80
89
  buildAppliedChips,
81
90
  capChipRow,
82
91
  categoryLeaf,