@stapel/search-react 0.36.1 → 0.38.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.
@@ -66,7 +66,7 @@ import type { CSSProperties, ReactElement, ReactNode } from "react";
66
66
  import { Button, Flex } from "antd";
67
67
  import { SkinDialog, SkinTheme, useDialogSurface } from "@stapel/tokens-antd/skin";
68
68
  import { useT, useTPlural } from "@stapel/core";
69
- import { cssVar, spacing } from "@stapel/tokens";
69
+ import { breakpoints, cssVar, spacing } from "@stapel/tokens";
70
70
  import type { FeatureDef } from "@stapel/attributes-react";
71
71
  import { SearchStateProvider, useSearchState } from "../headless/SearchStateProvider.js";
72
72
  import type { SearchParamsAdapter } from "../headless/SearchStateProvider.js";
@@ -261,47 +261,89 @@ export const FILTERS_RAIL_WIDTH = 280;
261
261
  * `alignSelf: flex-start` is load-bearing: a flex child stretches to the row's
262
262
  * height by default, and a stretched box has nothing to stick to.
263
263
  */
264
- /** The class the rail's own scrollbar rules are hung on. */
264
+ /** The class the rail's own geometry and scrollbar rules are hung on. */
265
265
  export const RAIL_CLASS = "stapel-search-rail";
266
266
 
267
+ /**
268
+ * The class that carries the SKIN's scrollbar — present under
269
+ * `railScrollbar: "styled"` and absent under `"system"`, so the two arms are
270
+ * one class apart and a stand can read which one is on screen.
271
+ */
272
+ export const RAIL_SCROLLBAR_CLASS = "stapel-search-rail-scrollbar";
273
+
267
274
  /** The `href` the hoisted rail sheet is deduplicated by. */
268
275
  export const RAIL_STYLE_HREF = "stapel-search-rail";
269
276
 
277
+ /** Whose scrollbar the rail's own scroll port draws — see
278
+ * {@link SearchPageProps.railScrollbar}. */
279
+ export type SearchRailScrollbar = "styled" | "system";
280
+
270
281
  /**
271
- * The rail scrolls, and its scrollbar must not sit ON the filters.
282
+ * The scrollbar's track width, in CSS pixels.
272
283
  *
273
- * `scrollbar-width: thin` and `scrollbar-gutter: stable` (below, in `RAIL`)
274
- * are the standard half of this and they are not enough: on every WebKit
275
- * platform with overlay scrollbars a Mac by default, every iOS browser
276
- * the bar is drawn OVER the content and the gutter reserves nothing, so the
277
- * walker saw the bar lying across the right edge of the checkbox labels.
284
+ * Not on the spacing scale on purpose, and not a spacing decision: this is the
285
+ * thickness of a hairline instrument, the size every platform's own overlay
286
+ * bar lands within, and the number the storefront's owner named. Six is thin
287
+ * enough to read as part of the panel and thick enough to grab.
288
+ */
289
+ const RAIL_SCROLLBAR_WIDTH = 6;
290
+
291
+ /**
292
+ * The rail scrolls, and the bar that says so is the SKIN's, not the platform's.
293
+ *
294
+ * The system bar was never a decision — it is what an `overflow-y: auto` box
295
+ * gets when nobody says otherwise, and on the storefront it landed as a grey
296
+ * chrome-coloured strip standing next to the filters in a dark theme. What it
297
+ * is replaced with:
298
+ *
299
+ * - a 6px track with no arrows and no track fill — the rail's own hairline,
300
+ * not a widget;
301
+ * - a thumb that is TRANSPARENT at rest and appears on `:hover` of the rail
302
+ * (which is what a pointer scrolling inside it is doing) and on
303
+ * `:focus-within` (which is what a keyboard is doing). A coarse pointer
304
+ * fires neither, so under `(pointer: coarse)` the thumb stands — a touch
305
+ * surface with an invisible scrollbar is a rail with no sign it has a tail;
306
+ * - `scrollbar-gutter: stable`, so the panel's right edge does not move when
307
+ * the thumb arrives.
278
308
  *
279
- * So the rail also declares a CLASSIC scrollbar through the WebKit
280
- * pseudo-elements: a bar with a real width, which pushes the panel's content
281
- * in by exactly that much instead of floating above it, drawn in the token
282
- * palette so it is the panel's own hairline in both themes rather than a
283
- * hard-coded grey that glows in the dark one. `--stapel-*` custom properties
284
- * resolve per theme at paint time, which is why this is a sheet and not a
285
- * pair of computed inline values: an inline colour would freeze whichever
286
- * theme was mounted first.
309
+ * Both vendor forms, because they are not alternatives: Firefox reads
310
+ * `scrollbar-width`/`scrollbar-color` and nothing else, WebKit and Chromium
311
+ * read the `::-webkit-scrollbar` pseudo-elements and (in Chromium) the
312
+ * standard properties too.
313
+ *
314
+ * The colours are `--stapel-*` custom properties, which resolve per theme at
315
+ * paint time an inline colour or a `useToken()` value would freeze whichever
316
+ * theme was mounted first. This design system's neutral vocabulary has no
317
+ * `colorFill*` ramp of its own: `border` IS its tertiary-fill role (the
318
+ * hairline every pane is separated by) and `text-subtle` is that role one step
319
+ * stronger, which is what the thumb takes when a pointer is on the thumb
320
+ * itself.
287
321
  *
288
322
  * Emitted as one hoisted `<style>` (React 19 dedupes by `href`), because a
289
323
  * pseudo-element is unreachable from an inline style — the same reason
290
324
  * `<LocationSummaryLine>` hoists one.
291
325
  */
292
326
  export function railScrollbarCss(): string {
293
- const rail = `.${RAIL_CLASS}`;
327
+ const bar = `.${RAIL_SCROLLBAR_CLASS}`;
328
+ const size = `${String(RAIL_SCROLLBAR_WIDTH)}px`;
329
+ const thumb = cssVar("border");
330
+ const awake = `${bar}:hover,${bar}:focus-within`;
294
331
  return [
295
- // A real width: an overlay bar occupies no space and therefore overlaps.
296
- `${rail}::-webkit-scrollbar{inline-size:8px;block-size:8px}`,
297
- `${rail}::-webkit-scrollbar-track{background:transparent}`,
298
- `${rail}::-webkit-scrollbar-thumb{background:${cssVar("border")};` +
332
+ // ── Firefox ────────────────────────────────────────────────────────────
333
+ `${bar}{scrollbar-width:thin;scrollbar-gutter:stable;` +
334
+ `scrollbar-color:transparent transparent}`,
335
+ `${awake}{scrollbar-color:${thumb} transparent}`,
336
+ // ── WebKit / Chromium ──────────────────────────────────────────────────
337
+ `${bar}::-webkit-scrollbar{inline-size:${size};block-size:${size}}`,
338
+ `${bar}::-webkit-scrollbar-track{background:transparent}`,
339
+ `${bar}::-webkit-scrollbar-thumb{background:transparent;` +
299
340
  `border-radius:${cssVar("radius-full")}}`,
300
- `${rail}::-webkit-scrollbar-thumb:hover{background:${cssVar("text-subtle")}}`,
301
- // Firefox/Chromium's standard properties, stated here too so the rule
302
- // travels with the class when the panel is used outside `<SearchPage>`.
303
- `${rail}{scrollbar-width:thin;scrollbar-gutter:stable;` +
304
- `scrollbar-color:${cssVar("border")} transparent}`,
341
+ `${bar}:hover::-webkit-scrollbar-thumb,` +
342
+ `${bar}:focus-within::-webkit-scrollbar-thumb{background:${thumb}}`,
343
+ `${bar}::-webkit-scrollbar-thumb:hover{background:${cssVar("text-subtle")}}`,
344
+ // ── A surface with no hover at all ─────────────────────────────────────
345
+ `@media (pointer:coarse){${bar}{scrollbar-color:${thumb} transparent}` +
346
+ `${bar}::-webkit-scrollbar-thumb{background:${thumb}}}`,
305
347
  ].join("\n");
306
348
  }
307
349
 
@@ -320,14 +362,11 @@ const RAIL: CSSProperties = {
320
362
  maxHeight: "100dvh",
321
363
  overflowY: "auto",
322
364
  overscrollBehavior: "contain",
323
- // The inner scroll must be VISIBLE. On overlay-scrollbar platforms (every
324
- // Mac by default, most phones) an `overflow-y: auto` column shows no
325
- // scrollbar until a pointer happens to scroll INSIDE itso a rail taller
326
- // than the window is indistinguishable from a rail that ends at the fold,
327
- // and the walker measured 5717px of panel whose tail nothing signposted.
328
- // A thin, always-there scrollbar is the sign there is more; the stable
329
- // gutter keeps the panel's right edge from jumping when it appears.
330
- scrollbarWidth: "thin",
365
+ // The gutter is the rail's, whichever bar draws in it: reserved here so the
366
+ // panel's right edge does not move when the thumb arrives. The bar's own
367
+ // width and colour are the skin's and live in `railScrollbarCss` — a rule
368
+ // set, not an inline pair, because a thumb that appears on hover cannot be
369
+ // said in a style attribute.
331
370
  scrollbarGutter: "stable",
332
371
  // Room for the focus ring of the last control against the scroll edge.
333
372
  paddingBlockEnd: spacing[2],
@@ -355,6 +394,71 @@ export function railStyle(top: number | string | undefined): CSSProperties {
355
394
  * cannot push the grid wider than its column. */
356
395
  const RESULTS_COLUMN: CSSProperties = { flex: "1 1 auto", minWidth: 0 };
357
396
 
397
+ /* ── THE RHYTHM: ONE GAP BETWEEN BLOCKS, SAID ONCE ─────────────────────────
398
+ *
399
+ * This page is an assembly of BLOCKS — the query box, the breadcrumb, the
400
+ * location row, the header band, the applied chips, the columns — and every
401
+ * gap between two of them used to be `spacing[4]` on the root plus whatever
402
+ * outer margin the block itself happened to carry. Sixteen pixels is what a
403
+ * form's fields are spaced by, not what a page's sections are: on the walked
404
+ * storefront the blocks read as one undifferentiated column, and the owner's
405
+ * word for it was that everything is stuck together.
406
+ *
407
+ * The gap is now ONE PAIR of custom properties, declared as a usage and not as
408
+ * a definition — `var(--stapel-block-gap, 32px)`. That is the whole point of
409
+ * the shape: a host (or a container's own stylesheet, or a brand) sets the
410
+ * property anywhere above this page and every block on it moves together,
411
+ * while a host that sets nothing gets the design system's own spacing step.
412
+ *
413
+ * The compact value is for a COARSE POINTER or a narrow window, in one query
414
+ * with two arms: a phone has less height to spend on air, and a tablet held in
415
+ * a hand is a phone for this purpose whatever its width says.
416
+ *
417
+ * The names are the pair's published contract, which is why they are exported:
418
+ * `categories-react`'s pages declare the same two, so a storefront that tunes
419
+ * the rhythm tunes BOTH pairs with one declaration.
420
+ */
421
+
422
+ /** The custom property every block gap on this page reads. */
423
+ export const BLOCK_GAP_VAR = "--stapel-block-gap";
424
+
425
+ /** Its coarse-pointer / narrow-window twin. */
426
+ export const BLOCK_GAP_COMPACT_VAR = "--stapel-block-gap-compact";
427
+
428
+ /** The class the rhythm's rules are hung on. */
429
+ export const BLOCK_RHYTHM_CLASS = "stapel-block-rhythm";
430
+
431
+ /** The `href` the hoisted rhythm sheet is deduplicated by. */
432
+ export const BLOCK_RHYTHM_STYLE_HREF = "stapel-block-rhythm";
433
+
434
+ /** Where the page's block gap comes from — see
435
+ * {@link SearchPageProps.blockRhythm}. */
436
+ export type SearchBlockRhythm = "token" | "legacy";
437
+
438
+ /**
439
+ * The rhythm's rule set.
440
+ *
441
+ * Three rules, and the third is half of the fix: `margin-block: 0` on every
442
+ * direct child. A gap only governs the space a container puts BETWEEN its
443
+ * children — a block that also carries its own top or bottom margin adds to it
444
+ * and the spacing stops being one number, which is exactly how a page ends up
445
+ * with four different distances nobody chose.
446
+ *
447
+ * A sheet rather than inline styles because the compact arm is a media query
448
+ * and the reset addresses children this component does not own.
449
+ */
450
+ export function blockRhythmCss(): string {
451
+ const block = `.${BLOCK_RHYTHM_CLASS}`;
452
+ const narrow = `(max-width:${String(breakpoints.tablet - 1)}px)`;
453
+ return [
454
+ `${block}{gap:var(${BLOCK_GAP_VAR},${String(spacing[6])}px)}`,
455
+ `@media (pointer:coarse),${narrow}{` +
456
+ `${block}{gap:var(${BLOCK_GAP_COMPACT_VAR},${String(spacing[5])}px)}}`,
457
+ // A block's own outer margin is a second opinion about the same distance.
458
+ `${block}>*{margin-block:0}`,
459
+ ].join("\n");
460
+ }
461
+
358
462
  export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions {
359
463
  /** The URL binding. `useRouterSearchParams()` from `./router` is the
360
464
  * react-router one. */
@@ -659,6 +763,46 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
659
763
  * header to clear.
660
764
  */
661
765
  readonly railTop?: number | string;
766
+ /**
767
+ * WHOSE SCROLLBAR the rail's own scroll port draws. Default `"styled"`.
768
+ *
769
+ * The rail is a scroll container and stays one: a person who has scrolled
770
+ * the filters and ticked one does not want the page to have moved under
771
+ * them. What it stopped drawing is the PLATFORM's bar — a grey chrome strip
772
+ * beside the filters, which is what an `overflow-y: auto` box gets when
773
+ * nobody decides otherwise.
774
+ *
775
+ * - `"styled"` — the skin's own bar, from the tokens, in both themes: a 6px
776
+ * track, no arrows, no track fill, and a thumb that is transparent at
777
+ * rest and appears while the rail is hovered or focused within (always,
778
+ * on a coarse pointer that can do neither). See {@link railScrollbarCss};
779
+ * - `"system"` — the platform's, untouched, for a host whose own stylesheet
780
+ * already dresses every scroll port on the page and would then be
781
+ * dressing this one twice.
782
+ *
783
+ * The default is the NEW behaviour, deliberately: the system bar was never a
784
+ * design decision here — it was the absence of one, and it is the thing the
785
+ * page was measured on.
786
+ */
787
+ readonly railScrollbar?: SearchRailScrollbar;
788
+ /**
789
+ * WHERE the space between this page's blocks comes from. Default `"token"`.
790
+ *
791
+ * - `"token"` — one gap for every block, read from
792
+ * `var(--stapel-block-gap)` (and `var(--stapel-block-gap-compact)` on a
793
+ * coarse pointer or a narrow window), defaulting to the design system's
794
+ * own spacing steps. Every direct block also has its outer margin reset,
795
+ * so the distance between two blocks is ONE number and a host can retune
796
+ * all of them by declaring the property once — see {@link blockRhythmCss};
797
+ * - `"legacy"` — the flat `spacing[4]` this page wrote inline for a host
798
+ * whose own layout was measured against it.
799
+ *
800
+ * The default is the NEW behaviour: 16px between a page's sections was the
801
+ * value a vertical `<Flex>` was given when the page was first assembled, not
802
+ * a rhythm anybody chose, and it is what made the blocks read as one column
803
+ * with no seams.
804
+ */
805
+ readonly blockRhythm?: SearchBlockRhythm;
662
806
  /**
663
807
  * PIN the results toolbar under whatever chrome is above this page — the
664
808
  * other column's half of {@link railTop}.
@@ -793,6 +937,13 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
793
937
  * grid it could not read back.
794
938
  */
795
939
  readonly resultsColumns?: ResultsColumns;
940
+ /**
941
+ * The box the results arrive into — `<SearchResultsPane reserve>`,
942
+ * forwarded. Here for the same reason `resultsColumns` is: the page is what
943
+ * a storefront mounts, and this was the last thing on it a host could only
944
+ * reach with `#search-page > :last-child` in its own stylesheet.
945
+ */
946
+ readonly resultsReserve?: number | string;
796
947
  /**
797
948
  * Is the results caption SEEN below the sheet breakpoint —
798
949
  * `<SearchResultsPane headingVisible>`, forwarded. Default: the host's own
@@ -804,6 +955,7 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
804
955
  interface SearchPageBodyProps {
805
956
  readonly renderCard?: SearchCardRenderer;
806
957
  readonly resultsColumns?: ResultsColumns;
958
+ readonly resultsReserve?: number | string;
807
959
  readonly resultsHeadingVisible?: boolean;
808
960
  readonly categoryFilter?: boolean;
809
961
  readonly resultsLead?: ReactNode;
@@ -837,6 +989,8 @@ interface SearchPageBodyProps {
837
989
  readonly railFrom?: SearchRailFrom;
838
990
  readonly filtersLayout?: SearchFiltersLayout;
839
991
  readonly railTop?: number | string;
992
+ readonly railScrollbar?: SearchRailScrollbar;
993
+ readonly blockRhythm?: SearchBlockRhythm;
840
994
  readonly stickyToolbar?: SearchToolbarPin;
841
995
  readonly defaultFiltersOpen?: boolean;
842
996
  readonly filtersOpen?: boolean;
@@ -1112,6 +1266,9 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
1112
1266
  {...(props.resultsColumns !== undefined
1113
1267
  ? { columns: props.resultsColumns }
1114
1268
  : {})}
1269
+ {...(props.resultsReserve !== undefined
1270
+ ? { reserve: props.resultsReserve }
1271
+ : {})}
1115
1272
  {...(props.resultsHeadingVisible !== undefined
1116
1273
  ? { headingVisible: props.resultsHeadingVisible }
1117
1274
  : {})}
@@ -1141,14 +1298,28 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
1141
1298
  />
1142
1299
  );
1143
1300
 
1301
+ const legacyRhythm = props.blockRhythm === "legacy";
1144
1302
  return (
1145
1303
  <Flex
1146
1304
  vertical
1147
- gap={spacing[4]}
1305
+ // ONE gap for every block on this page, from the token pair — or the
1306
+ // 16px this page used to state inline, for a host pinned to it.
1307
+ {...(legacyRhythm
1308
+ ? { gap: spacing[4] }
1309
+ : { className: BLOCK_RHYTHM_CLASS })}
1148
1310
  data-testid="search-page"
1311
+ data-rhythm={legacyRhythm ? "legacy" : "token"}
1149
1312
  data-filters={showFilters ? "on" : "off"}
1150
1313
  data-filters-layout={layout}
1151
1314
  >
1315
+ {/* The rhythm's rules — see `blockRhythmCss`. Hoisted, deduped by
1316
+ `href`, and not mounted at all when the host asked for the old
1317
+ inline gap. */}
1318
+ {!legacyRhythm && (
1319
+ <style href={BLOCK_RHYTHM_STYLE_HREF} precedence="default">
1320
+ {blockRhythmCss()}
1321
+ </style>
1322
+ )}
1152
1323
  {props.searchBox !== false && <SearchBox />}
1153
1324
  {props.breadcrumb !== undefined && (
1154
1325
  <div data-testid="search-breadcrumb">{props.breadcrumb}</div>
@@ -1264,12 +1435,23 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
1264
1435
  </>
1265
1436
  ) : showFilters ? (
1266
1437
  <Flex align="flex-start" gap={spacing[5]} data-testid="search-page-columns">
1267
- <div className={RAIL_CLASS} style={railStyle(props.railTop)}>
1438
+ <div
1439
+ className={
1440
+ props.railScrollbar === "system"
1441
+ ? RAIL_CLASS
1442
+ : `${RAIL_CLASS} ${RAIL_SCROLLBAR_CLASS}`
1443
+ }
1444
+ style={railStyle(props.railTop)}
1445
+ >
1268
1446
  {/* The rail's scrollbar, in the gutter and in the token palette —
1269
- see `railScrollbarCss`. Hoisted, deduped by `href`. */}
1270
- <style href={RAIL_STYLE_HREF} precedence="default">
1271
- {railScrollbarCss()}
1272
- </style>
1447
+ see `railScrollbarCss`. Hoisted, deduped by `href`. Not mounted
1448
+ at all under `"system"`: a sheet whose only selector is a class
1449
+ nothing carries is dead weight in the document. */}
1450
+ {props.railScrollbar !== "system" && (
1451
+ <style href={RAIL_STYLE_HREF} precedence="default">
1452
+ {railScrollbarCss()}
1453
+ </style>
1454
+ )}
1273
1455
  {panel}
1274
1456
  </div>
1275
1457
  {/* ONE heading and ONE sort control. The page used to caption
@@ -1319,6 +1501,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1319
1501
  railFrom,
1320
1502
  filtersLayout,
1321
1503
  railTop,
1504
+ railScrollbar,
1505
+ blockRhythm,
1322
1506
  stickyToolbar,
1323
1507
  defaultFiltersOpen,
1324
1508
  filtersOpen,
@@ -1332,6 +1516,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1332
1516
  resultsAction,
1333
1517
  resultsHeadingLevel,
1334
1518
  resultsColumns,
1519
+ resultsReserve,
1335
1520
  resultsHeadingVisible,
1336
1521
  dictionaryMode,
1337
1522
  visibleGroups,
@@ -1347,6 +1532,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1347
1532
  <SearchPageBody
1348
1533
  {...(renderCard !== undefined ? { renderCard } : {})}
1349
1534
  {...(resultsColumns !== undefined ? { resultsColumns } : {})}
1535
+ {...(resultsReserve !== undefined ? { resultsReserve } : {})}
1350
1536
  {...(resultsHeadingVisible !== undefined ? { resultsHeadingVisible } : {})}
1351
1537
  {...(dictionaryMode !== undefined ? { dictionaryMode } : {})}
1352
1538
  {...(visibleGroups !== undefined ? { visibleGroups } : {})}
@@ -1381,6 +1567,8 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1381
1567
  {...(railFrom !== undefined ? { railFrom } : {})}
1382
1568
  {...(filtersLayout !== undefined ? { filtersLayout } : {})}
1383
1569
  {...(railTop !== undefined ? { railTop } : {})}
1570
+ {...(railScrollbar !== undefined ? { railScrollbar } : {})}
1571
+ {...(blockRhythm !== undefined ? { blockRhythm } : {})}
1384
1572
  {...(stickyToolbar !== undefined ? { stickyToolbar } : {})}
1385
1573
  {...(defaultFiltersOpen !== undefined ? { defaultFiltersOpen } : {})}
1386
1574
  {...(filtersOpen !== undefined ? { filtersOpen } : {})}
@@ -487,6 +487,25 @@ export interface SearchResultsPaneProps extends ThemeModeProp {
487
487
  /** Widest the column of results may grow (default {@link RESULTS_MAX_WIDTH});
488
488
  * `null` lets the container decide. */
489
489
  readonly maxWidth?: number | null;
490
+ /**
491
+ * THE BOX THE RESULTS ARRIVE INTO — a block-size floor the pane holds while
492
+ * the first answer is in flight, and drops the moment the rows land.
493
+ *
494
+ * The pane cannot know how tall its own page will be (the row count is the
495
+ * host's `limit` and the card height is the host's card), so this is a
496
+ * number the host measures once for its own feed — `limit × card + gaps` —
497
+ * and it stops the fold below the results from rising into the space the
498
+ * feed is about to take.
499
+ *
500
+ * What it replaces is a stylesheet rule aimed at this pane with the only
501
+ * hold a consumer had on it: `#search-page > :last-child`, a selector that
502
+ * silently starts addressing something else the day this page grows a
503
+ * seventh child. The pane's root now also carries
504
+ * `data-testid="search-results-pane"`, so a host that wants its own rule
505
+ * has a handle that is part of this pair's surface instead of a count of
506
+ * siblings.
507
+ */
508
+ readonly reserve?: number | string;
490
509
  /**
491
510
  * How the loaded rows are arranged: as many card columns as fit
492
511
  * (`"grid"`, the default) or one wide row each (`"list"`). The view SWITCH
@@ -713,6 +732,7 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
713
732
  return (
714
733
  <SkinTheme
715
734
  surface="base"
735
+ data-testid="search-results-pane"
716
736
  {...(props.mode !== undefined ? { mode: props.mode } : {})}
717
737
  style={{
718
738
  width: "100%",
@@ -721,7 +741,16 @@ export function SearchResultsPane(props: SearchResultsPaneProps): ReactElement {
721
741
  >
722
742
  <SearchResults {...(props.enabled !== undefined ? { enabled: props.enabled } : {})}>
723
743
  {(bag) => (
724
- <Flex vertical gap={spacing[4]}>
744
+ <Flex
745
+ vertical
746
+ gap={spacing[4]}
747
+ // The box the results arrive into — see `reserve`. Only until the
748
+ // FIRST answer lands: a floor under rows already on screen would
749
+ // hold a gap open under a short page for the rest of the session.
750
+ {...(props.reserve !== undefined && bag.state.status === "loading"
751
+ ? { style: { minBlockSize: props.reserve } }
752
+ : {})}
753
+ >
725
754
  {props.lead !== undefined && (
726
755
  <div data-testid="search-results-lead">{props.lead}</div>
727
756
  )}
@@ -15,7 +15,7 @@
15
15
  * That is the whole defect this file used to carry: the one sort a person
16
16
  * would most want on a phone was greyed out with its explanation in a hover.
17
17
  */
18
- import type { ReactElement } from "react";
18
+ import type { CSSProperties, ReactElement } from "react";
19
19
  import { Flex, Select, Typography } from "antd";
20
20
  import { actionAvailable, actionBlocked, useT } from "@stapel/core";
21
21
  import type { ActionAvailability } from "@stapel/core";
@@ -34,6 +34,61 @@ import { sortLabelKey } from "./sortLabels.js";
34
34
  */
35
35
  export const SORT_SELECT_MIN_WIDTH = 200;
36
36
 
37
+ /**
38
+ * The select's own chrome around its label: antd's two inline paddings plus
39
+ * the caret and its margin. Added to the compact form's sizer (below), which
40
+ * measures a bare label.
41
+ */
42
+ export const SORT_SELECT_CHROME = 40;
43
+
44
+ /**
45
+ * ── THE COMPACT CONTROL'S BOX, HELD FROM THE FIRST FRAME ────────────────────
46
+ *
47
+ * The compact arm draws `value={active ?? null}`, and `active` is only known
48
+ * once the page in cache reports the sort the SERVER applied (see
49
+ * {@link useAppliedSort}) — for an address that names no `sort`, that is a
50
+ * whole round trip after the first paint. So the control rendered as a bare
51
+ * caret and then GREW by the width of its label: measured on a category leaf,
52
+ * the caret's own box moved from x=22 to x=137, 115px, the largest single
53
+ * term in that page's layout shift. `minWidth: 0` was written inline, so no
54
+ * consumer stylesheet could hold the box either.
55
+ *
56
+ * The floor is not {@link SORT_SELECT_MIN_WIDTH}: 200px is the desktop arm's
57
+ * number and would wrap a two-control toolbar onto two rows at 390px. It is
58
+ * the width of the longest label THIS control can be asked to show, at the
59
+ * font it will show it in — so it is measured by the browser rather than
60
+ * guessed in pixels, and it is right in every locale (a Russian sort label is
61
+ * half again as long as its English original).
62
+ *
63
+ * One grid cell, two children stacked in it: an `aria-hidden` sizer carrying
64
+ * the longest label, and the select itself. The cell is as wide as the sizer,
65
+ * the select fills it, and neither depends on `active`.
66
+ */
67
+ const COMPACT_WRAP: CSSProperties = {
68
+ display: "inline-grid",
69
+ flex: "0 1 auto",
70
+ };
71
+
72
+ const COMPACT_SIZER: CSSProperties = {
73
+ gridArea: "1 / 1",
74
+ visibility: "hidden",
75
+ blockSize: 0,
76
+ overflow: "hidden",
77
+ whiteSpace: "nowrap",
78
+ pointerEvents: "none",
79
+ paddingInline: SORT_SELECT_CHROME,
80
+ };
81
+
82
+ const COMPACT_SELECT: CSSProperties = { gridArea: "1 / 1", minWidth: 0 };
83
+
84
+ /** The longest of the labels the control can display, which is the one the
85
+ * sizer holds. Ties keep the first — they are the same width. */
86
+ export function longestSortLabel(labels: readonly string[]): string {
87
+ return labels.reduce((longest, label) =>
88
+ label.length > longest.length ? label : longest
89
+ , "");
90
+ }
91
+
37
92
  /** Why `sort=distance` is refused without a centre — the server's own code, so
38
93
  * the control and the 400 it would have earned say the same sentence. */
39
94
  const SORT_DISTANCE_BLOCKED = "error.400.search_sort_needs_center";
@@ -49,9 +104,11 @@ export interface SortSelectProps {
49
104
  *
50
105
  * - the "Sort" caption goes (the select already shows a sort by name; the
51
106
  * accessible name keeps the word);
52
- * - the {@link SORT_SELECT_MIN_WIDTH} floor goes, so the control shares one
107
+ * - the {@link SORT_SELECT_MIN_WIDTH} floor goes the control shares one
53
108
  * row with whatever the surface puts beside it instead of pushing it to
54
- * the next line;
109
+ * the next line — and is replaced by a floor the width of this control's
110
+ * OWN longest label, so the box does not grow when the answer names the
111
+ * sort (see `COMPACT_WRAP`);
55
112
  * - the line under the control goes. The blocked option's REASON does not:
56
113
  * it is on the option itself at every width now (see `optionsFor`), and
57
114
  * what the compact form drops is the second, separate copy of it.
@@ -110,14 +167,25 @@ export function SortSelect(props: SortSelectProps): ReactElement {
110
167
  * accessible description of the SELECT, the other is the label of the OPTION
111
168
  * that is refused, and they are read in different moments.
112
169
  */
170
+ /**
171
+ * The names this control can DISPLAY, which is what the compact form's
172
+ * sizer is measured on. The blocked row's appended reason is deliberately
173
+ * not among them: it reaches the CLOSED control only for a sort the address
174
+ * itself names, and a sort in the address is known from the first frame —
175
+ * so it can widen the box but can never move it.
176
+ */
177
+ const plainLabels = values.map((value) => {
178
+ const key = sortLabelKey(value);
179
+ return key !== undefined ? t(key) : value;
180
+ });
181
+
113
182
  const optionsFor = (): {
114
183
  readonly value: string;
115
184
  readonly label: string;
116
185
  readonly disabled: boolean;
117
186
  }[] =>
118
- values.map((value) => {
119
- const key = sortLabelKey(value);
120
- const label = key !== undefined ? t(key) : value;
187
+ values.map((value, index) => {
188
+ const label = plainLabels[index] ?? value;
121
189
  const blocked = value === "distance" && !hasCentre;
122
190
  return {
123
191
  value,
@@ -128,20 +196,23 @@ export function SortSelect(props: SortSelectProps): ReactElement {
128
196
 
129
197
  if (props.compact === true) {
130
198
  return (
131
- <Select<string>
132
- data-testid="search-sort"
133
- data-stapel-gated={hasCentre ? "available" : "blocked"}
134
- aria-label={t(SEARCH_I18N_KEYS.sortLabel)}
135
- // `minWidth: 0` and not the floor: a control that refuses to be
136
- // narrower than 200px is a control that wraps a two-item toolbar onto
137
- // two rows at 390px.
138
- style={{ minWidth: 0, flex: "0 1 auto" }}
139
- value={active ?? null}
140
- onChange={(next) => {
141
- setSort(next);
142
- }}
143
- options={optionsFor()}
144
- />
199
+ <div style={COMPACT_WRAP} data-testid="search-sort-compact">
200
+ {/* The box, not a caption — see `COMPACT_WRAP`. */}
201
+ <span aria-hidden="true" data-testid="search-sort-sizer" style={COMPACT_SIZER}>
202
+ {longestSortLabel(plainLabels)}
203
+ </span>
204
+ <Select<string>
205
+ data-testid="search-sort"
206
+ data-stapel-gated={hasCentre ? "available" : "blocked"}
207
+ aria-label={t(SEARCH_I18N_KEYS.sortLabel)}
208
+ style={COMPACT_SELECT}
209
+ value={active ?? null}
210
+ onChange={(next) => {
211
+ setSort(next);
212
+ }}
213
+ options={optionsFor()}
214
+ />
215
+ </div>
145
216
  );
146
217
  }
147
218
 
@@ -42,7 +42,13 @@
42
42
  // ── surfaces ────────────────────────────────────────────────────────────────
43
43
  export {
44
44
  SearchPage,
45
+ BLOCK_GAP_COMPACT_VAR,
46
+ BLOCK_GAP_VAR,
47
+ BLOCK_RHYTHM_CLASS,
48
+ BLOCK_RHYTHM_STYLE_HREF,
49
+ blockRhythmCss,
45
50
  RAIL_CLASS,
51
+ RAIL_SCROLLBAR_CLASS,
46
52
  RAIL_STYLE_HREF,
47
53
  railScrollbarCss,
48
54
  railStyle,
@@ -53,7 +59,9 @@ export type {
53
59
  SearchFiltersHeader,
54
60
  SearchFiltersHeaderSlotProps,
55
61
  SearchFiltersOpenReason,
62
+ SearchBlockRhythm,
56
63
  SearchRailFrom,
64
+ SearchRailScrollbar,
57
65
  } from "./SearchPage.js";
58
66
 
59
67
  export {
@@ -93,7 +101,6 @@ export {
93
101
  CHIP_BAND_ORDER,
94
102
  CHIP_ROW_CAP,
95
103
  CHIP_ROW_CLASS,
96
- CHIP_ROW_MIN_HEIGHT,
97
104
  CHIP_ROW_STYLE_HREF,
98
105
  appliedChipTestId,
99
106
  appliedRowMinHeight,
@@ -101,6 +108,7 @@ export {
101
108
  capChipRow,
102
109
  categoryLeaf,
103
110
  chipRowCss,
111
+ chipRowMinHeight,
104
112
  orderChipFilters,
105
113
  rangeChipText,
106
114
  rangeLabelSource,
@@ -179,7 +187,12 @@ export type { RankingDisclosurePaneProps } from "./RankingDisclosurePane.js";
179
187
  // ── controls, exported so a host can compose its own layout ─────────────────
180
188
  export { SearchBox } from "./SearchBox.js";
181
189
  export type { SearchBoxProps } from "./SearchBox.js";
182
- export { SortSelect, SORT_SELECT_MIN_WIDTH } from "./SortSelect.js";
190
+ export {
191
+ SortSelect,
192
+ SORT_SELECT_CHROME,
193
+ SORT_SELECT_MIN_WIDTH,
194
+ longestSortLabel,
195
+ } from "./SortSelect.js";
183
196
  export type { SortSelectProps } from "./SortSelect.js";
184
197
  export { ViewSwitch, SEARCH_BUILTIN_VIEWS, resolveView } from "./ViewSwitch.js";
185
198
  export type { ViewSwitchProps, SearchView, SearchResultsLayout } from "./ViewSwitch.js";