@stapel/search-react 0.41.2 → 0.42.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +13 -0
  3. package/dist/default/FacetGroupControl.d.ts.map +1 -1
  4. package/dist/default/FacetGroupControl.js +31 -7
  5. package/dist/default/FacetGroupControl.js.map +1 -1
  6. package/dist/default/PopularValues.d.ts +9 -0
  7. package/dist/default/PopularValues.d.ts.map +1 -1
  8. package/dist/default/PopularValues.js +45 -9
  9. package/dist/default/PopularValues.js.map +1 -1
  10. package/dist/default/SearchPage.d.ts +57 -47
  11. package/dist/default/SearchPage.d.ts.map +1 -1
  12. package/dist/default/SearchPage.js +77 -85
  13. package/dist/default/SearchPage.js.map +1 -1
  14. package/dist/default/focusRing.d.ts +83 -0
  15. package/dist/default/focusRing.d.ts.map +1 -0
  16. package/dist/default/focusRing.js +37 -0
  17. package/dist/default/focusRing.js.map +1 -0
  18. package/dist/default/index.d.ts +6 -2
  19. package/dist/default/index.d.ts.map +1 -1
  20. package/dist/default/index.js +4 -1
  21. package/dist/default/index.js.map +1 -1
  22. package/dist/default/railFit.d.ts +14 -0
  23. package/dist/default/railFit.d.ts.map +1 -0
  24. package/dist/default/railFit.js +80 -0
  25. package/dist/default/railFit.js.map +1 -0
  26. package/dist/default/scrollbar.d.ts +89 -0
  27. package/dist/default/scrollbar.d.ts.map +1 -0
  28. package/dist/default/scrollbar.js +69 -0
  29. package/dist/default/scrollbar.js.map +1 -0
  30. package/llms.txt +1 -1
  31. package/manifest.json +1 -1
  32. package/nav-manifest.json +1 -1
  33. package/package.json +4 -4
  34. package/src/analytics/generated/events.json +1 -1
  35. package/src/default/FacetGroupControl.tsx +58 -1
  36. package/src/default/PopularValues.tsx +62 -8
  37. package/src/default/SearchPage.tsx +135 -91
  38. package/src/default/focusRing.ts +100 -0
  39. package/src/default/index.ts +18 -0
  40. package/src/default/railFit.ts +90 -0
  41. package/src/default/scrollbar.ts +119 -0
@@ -61,12 +61,12 @@
61
61
  * nothing in it, and a layout that reflowed underneath a person mid-load would
62
62
  * be worse than the hole.
63
63
  */
64
- import { useState, useSyncExternalStore } from "react";
64
+ import { useRef, useState, useSyncExternalStore } from "react";
65
65
  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 { breakpoints, cssVar, spacing } from "@stapel/tokens";
69
+ import { breakpoints, 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";
@@ -77,6 +77,12 @@ import type { ParseSearchStateOptions } from "../state/urlState.js";
77
77
  import type { SearchGeo } from "../api/types.js";
78
78
  import { buildRangeGroups } from "../state/ranges.js";
79
79
  import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
80
+ import { useRailFits } from "./railFit.js";
81
+ import {
82
+ RAIL_SCROLLBAR_CLASS,
83
+ RAIL_STYLE_HREF,
84
+ railScrollbarCss,
85
+ } from "./scrollbar.js";
80
86
  import { FacetPanelPane } from "./FacetPanelPane.js";
81
87
  import type {
82
88
  CategoryFilterSlotProps,
@@ -266,87 +272,27 @@ export const FILTERS_RAIL_WIDTH = 280;
266
272
  export const RAIL_CLASS = "stapel-search-rail";
267
273
 
268
274
  /**
269
- * The class that carries the SKIN's scrollbar present under
270
- * `railScrollbar: "styled"` and absent under `"system"`, so the two arms are
271
- * one class apart and a stand can read which one is on screen.
275
+ * The skin's scrollbar, which is no longer only the RAIL's — the dictionary
276
+ * facet inside the panel is a scroll port too, and it kept the platform's bar
277
+ * painted over its count column. The rule set, the class, the `href` and the
278
+ * track's width now live in `./scrollbar.ts` so both ports mount one hoisted
279
+ * sheet; they are re-exported here unchanged because they are this package's
280
+ * published surface.
272
281
  */
273
- export const RAIL_SCROLLBAR_CLASS = "stapel-search-rail-scrollbar";
274
-
275
- /** The `href` the hoisted rail sheet is deduplicated by. */
276
- export const RAIL_STYLE_HREF = "stapel-search-rail";
282
+ export {
283
+ RAIL_SCROLLBAR_CLASS,
284
+ RAIL_SCROLLBAR_WIDTH,
285
+ RAIL_STYLE_HREF,
286
+ railScrollbarCss,
287
+ } from "./scrollbar.js";
277
288
 
278
289
  /** Whose scrollbar the rail's own scroll port draws — see
279
290
  * {@link SearchPageProps.railScrollbar}. */
280
291
  export type SearchRailScrollbar = "styled" | "system";
281
292
 
282
- /**
283
- * The scrollbar's track width, in CSS pixels.
284
- *
285
- * Not on the spacing scale on purpose, and not a spacing decision: this is the
286
- * thickness of a hairline instrument, the size every platform's own overlay
287
- * bar lands within, and the number the storefront's owner named. Six is thin
288
- * enough to read as part of the panel and thick enough to grab.
289
- */
290
- const RAIL_SCROLLBAR_WIDTH = 6;
291
-
292
- /**
293
- * The rail scrolls, and the bar that says so is the SKIN's, not the platform's.
294
- *
295
- * The system bar was never a decision — it is what an `overflow-y: auto` box
296
- * gets when nobody says otherwise, and on the storefront it landed as a grey
297
- * chrome-coloured strip standing next to the filters in a dark theme. What it
298
- * is replaced with:
299
- *
300
- * - a 6px track with no arrows and no track fill — the rail's own hairline,
301
- * not a widget;
302
- * - a thumb that is TRANSPARENT at rest and appears on `:hover` of the rail
303
- * (which is what a pointer scrolling inside it is doing) and on
304
- * `:focus-within` (which is what a keyboard is doing). A coarse pointer
305
- * fires neither, so under `(pointer: coarse)` the thumb stands — a touch
306
- * surface with an invisible scrollbar is a rail with no sign it has a tail;
307
- * - `scrollbar-gutter: stable`, so the panel's right edge does not move when
308
- * the thumb arrives.
309
- *
310
- * Both vendor forms, because they are not alternatives: Firefox reads
311
- * `scrollbar-width`/`scrollbar-color` and nothing else, WebKit and Chromium
312
- * read the `::-webkit-scrollbar` pseudo-elements and (in Chromium) the
313
- * standard properties too.
314
- *
315
- * The colours are `--stapel-*` custom properties, which resolve per theme at
316
- * paint time — an inline colour or a `useToken()` value would freeze whichever
317
- * theme was mounted first. This design system's neutral vocabulary has no
318
- * `colorFill*` ramp of its own: `border` IS its tertiary-fill role (the
319
- * hairline every pane is separated by) and `text-subtle` is that role one step
320
- * stronger, which is what the thumb takes when a pointer is on the thumb
321
- * itself.
322
- *
323
- * Emitted as one hoisted `<style>` (React 19 dedupes by `href`), because a
324
- * pseudo-element is unreachable from an inline style — the same reason
325
- * `<LocationSummaryLine>` hoists one.
326
- */
327
- export function railScrollbarCss(): string {
328
- const bar = `.${RAIL_SCROLLBAR_CLASS}`;
329
- const size = `${String(RAIL_SCROLLBAR_WIDTH)}px`;
330
- const thumb = cssVar("border");
331
- const awake = `${bar}:hover,${bar}:focus-within`;
332
- return [
333
- // ── Firefox ────────────────────────────────────────────────────────────
334
- `${bar}{scrollbar-width:thin;scrollbar-gutter:stable;` +
335
- `scrollbar-color:transparent transparent}`,
336
- `${awake}{scrollbar-color:${thumb} transparent}`,
337
- // ── WebKit / Chromium ──────────────────────────────────────────────────
338
- `${bar}::-webkit-scrollbar{inline-size:${size};block-size:${size}}`,
339
- `${bar}::-webkit-scrollbar-track{background:transparent}`,
340
- `${bar}::-webkit-scrollbar-thumb{background:transparent;` +
341
- `border-radius:${cssVar("radius-full")}}`,
342
- `${bar}:hover::-webkit-scrollbar-thumb,` +
343
- `${bar}:focus-within::-webkit-scrollbar-thumb{background:${thumb}}`,
344
- `${bar}::-webkit-scrollbar-thumb:hover{background:${cssVar("text-subtle")}}`,
345
- // ── A surface with no hover at all ─────────────────────────────────────
346
- `@media (pointer:coarse){${bar}{scrollbar-color:${thumb} transparent}` +
347
- `${bar}::-webkit-scrollbar-thumb{background:${thumb}}}`,
348
- ].join("\n");
349
- }
293
+ /** WHAT SCROLLS when the filters are longer than the window — see
294
+ * {@link SearchPageProps.railScroll}. */
295
+ export type SearchRailScroll = "internal" | "page";
350
296
 
351
297
  const RAIL: CSSProperties = {
352
298
  flex: `0 0 ${String(FILTERS_RAIL_WIDTH)}px`,
@@ -373,19 +319,59 @@ const RAIL: CSSProperties = {
373
319
  paddingBlockEnd: spacing[2],
374
320
  };
375
321
 
322
+ /**
323
+ * The rail that is NOT a scroll port — `railScroll="page"`.
324
+ *
325
+ * The column, and nothing that makes it a container: no `overflow-y`, no
326
+ * height cap, no scroll gutter, no `overscroll-behavior`. What decides
327
+ * `position` is not written here but MEASURED — see {@link railStyle} and
328
+ * `useRailFits`.
329
+ */
330
+ const RAIL_IN_PAGE: CSSProperties = {
331
+ flex: `0 0 ${String(FILTERS_RAIL_WIDTH)}px`,
332
+ minWidth: FILTERS_RAIL_WIDTH,
333
+ maxWidth: FILTERS_RAIL_WIDTH,
334
+ // Load-bearing in both arms: a flex child stretches to the row's height by
335
+ // default, and a stretched box has nothing to stick to.
336
+ alignSelf: "flex-start",
337
+ paddingBlockEnd: spacing[2],
338
+ };
339
+
376
340
  /**
377
341
  * The rail with a host's own offset under it.
378
342
  *
379
- * `top` and the height cap move TOGETHER, and that is the whole reason this is
380
- * a function rather than one property: a rail pushed 64px down the window whose
381
- * cap is still `100dvh` ends 64px past the foot of the screen, so its last
382
- * control is unreachable the internal scroll has scrolled past the window.
343
+ * Under the default `"internal"` arm, `top` and the height cap move TOGETHER,
344
+ * and that is the whole reason this is a function rather than one property: a
345
+ * rail pushed 64px down the window whose cap is still `100dvh` ends 64px past
346
+ * the foot of the screen, so its last control is unreachable — the internal
347
+ * scroll has scrolled past the window.
348
+ *
349
+ * Under `"page"` there is no cap and no port. The rail is sticky WHILE IT
350
+ * FITS (`fits`, measured by `useRailFits`) and static when it does not, since
351
+ * a stuck box taller than the window is cut off at the foot of the screen with
352
+ * no scroll of its own to reveal the rest. The offset is written twice on
353
+ * purpose: as `top`, which is what sticky uses, and as `scroll-margin-top`,
354
+ * which is the one property that MEANS "this much of the top of the scrollport
355
+ * is covered" and which the engine resolves to pixels for the fit test to read
356
+ * back.
383
357
  *
384
358
  * A number is pixels; a string is taken as written, so
385
359
  * `railTop="var(--stapel-header-height)"` reads the height `<PublicShell>`
386
360
  * publishes instead of restating it.
387
361
  */
388
- export function railStyle(top: number | string | undefined): CSSProperties {
362
+ export function railStyle(
363
+ top: number | string | undefined,
364
+ scroll: SearchRailScroll = "internal",
365
+ fits = false
366
+ ): CSSProperties {
367
+ if (scroll === "page") {
368
+ const offset = top ?? 0;
369
+ return {
370
+ ...RAIL_IN_PAGE,
371
+ scrollMarginTop: offset,
372
+ ...(fits ? { position: "sticky", top: offset } : { position: "static" }),
373
+ };
374
+ }
389
375
  if (top === undefined) return RAIL;
390
376
  const offset = typeof top === "number" ? `${String(top)}px` : top;
391
377
  return { ...RAIL, top, maxHeight: `calc(100dvh - ${offset})` };
@@ -787,8 +773,40 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
787
773
  * The default is the NEW behaviour, deliberately: the system bar was never a
788
774
  * design decision here — it was the absence of one, and it is the thing the
789
775
  * page was measured on.
776
+ *
777
+ * Read under `railScroll="page"` by nothing: a rail that is not a scroll
778
+ * port has no bar to dress, so neither the class nor the rule set is
779
+ * mounted there.
790
780
  */
791
781
  readonly railScrollbar?: SearchRailScrollbar;
782
+ /**
783
+ * WHAT SCROLLS when the filters are longer than the window.
784
+ * Default `"internal"` — no host changes behaviour by upgrading.
785
+ *
786
+ * - `"internal"` — the rail is its own scroll container: it stays put at
787
+ * {@link railTop} and the filters scroll INSIDE it, capped at the height
788
+ * of the window. A person who has scrolled the filters and ticked one
789
+ * does not find the page has moved under them;
790
+ * - `"page"` — the rail is a column of the page and the PAGE is the only
791
+ * thing that scrolls. No `overflow-y`, no height cap, no gutter, no
792
+ * `overscroll-behavior`, and no scrollbar of its own to dress.
793
+ *
794
+ * `"page"` is not "sticky off". A rail SHORTER than the room under the
795
+ * host's chrome still pins at {@link railTop}, exactly as it does in the
796
+ * other arm — it is only the rail TALLER than the window that goes static,
797
+ * because a stuck box that tall is cut off at the foot of the screen and the
798
+ * page scroll, which is now the only scroll, cannot reach its last controls.
799
+ * Which of the two a leaf gets is measured (the rail's own height against
800
+ * `window.innerHeight` minus the offset), not guessed from a breakpoint: the
801
+ * same catalogue draws four facet groups on one section and twenty on the
802
+ * next.
803
+ *
804
+ * For the surface whose owner reads a second scrollbar standing beside the
805
+ * results as a second page — a storefront's whole filter column in one
806
+ * gesture with the feed — and for any host whose own chrome already gives
807
+ * the window one scroll and wants no other.
808
+ */
809
+ readonly railScroll?: SearchRailScroll;
792
810
  /**
793
811
  * WHAT THE FILTER PANEL'S OWN BODY PAINTS. Default `"flat"`.
794
812
  *
@@ -1026,6 +1044,7 @@ interface SearchPageBodyProps {
1026
1044
  readonly filtersLayout?: SearchFiltersLayout;
1027
1045
  readonly railTop?: number | string;
1028
1046
  readonly railScrollbar?: SearchRailScrollbar;
1047
+ readonly railScroll?: SearchRailScroll;
1029
1048
  readonly railSurface?: SearchRailSurface;
1030
1049
  readonly blockRhythm?: SearchBlockRhythm;
1031
1050
  readonly stickyToolbar?: SearchToolbarPin;
@@ -1082,6 +1101,22 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
1082
1101
  */
1083
1102
  const footerBar: FacetPanelPaneProps["footerBar"] =
1084
1103
  props.footerBar ?? (layout === "sheet" ? undefined : "static");
1104
+ /*
1105
+ * THE RAIL'S OWN GEOMETRY, under `railScroll` — one element, one ref, and a
1106
+ * measurement that only runs in the arm that needs it.
1107
+ *
1108
+ * The ref is on the rail for the whole life of the page: the fit answer must
1109
+ * not arrive by REMOUNTING the column. Ticking a facet re-renders the panel
1110
+ * inside this box; if the box itself were replaced, the browser would put
1111
+ * the new one at the top of its flow and a person three screens down the
1112
+ * results would be thrown back up the page — which is the very thing this
1113
+ * arm exists to prevent.
1114
+ */
1115
+ const railRef = useRef<HTMLDivElement | null>(null);
1116
+ const railFits = useRailFits(railRef, props.railScroll === "page");
1117
+ /* The skin's bar dresses a scroll port, and under `"page"` there is none. */
1118
+ const railStyled =
1119
+ props.railScroll !== "page" && props.railScrollbar !== "system";
1085
1120
  // Controlled or not, decided by the PRESENCE of `filtersOpen` and read once
1086
1121
  // per render — the state the page keeps is only ever the uncontrolled half,
1087
1122
  // and a controlled host's value is never copied into it (copying it is how
@@ -1519,18 +1554,25 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
1519
1554
  ) : showFilters ? (
1520
1555
  <Flex align="flex-start" gap={spacing[5]} data-testid="search-page-columns">
1521
1556
  <div
1522
- className={
1523
- props.railScrollbar === "system"
1524
- ? RAIL_CLASS
1525
- : `${RAIL_CLASS} ${RAIL_SCROLLBAR_CLASS}`
1526
- }
1527
- style={railStyle(props.railTop)}
1557
+ ref={railRef}
1558
+ className={railStyled ? `${RAIL_CLASS} ${RAIL_SCROLLBAR_CLASS}` : RAIL_CLASS}
1559
+ style={railStyle(props.railTop, props.railScroll, railFits)}
1560
+ {...(props.railScroll === "page"
1561
+ ? {
1562
+ /* What the column IS doing, on the element that is doing it:
1563
+ a stand reads the arm and the measured answer instead of
1564
+ inferring both from a computed style. */
1565
+ "data-rail-scroll": "page",
1566
+ "data-rail-sticky": String(railFits),
1567
+ }
1568
+ : {})}
1528
1569
  >
1529
1570
  {/* The rail's scrollbar, in the gutter and in the token palette —
1530
1571
  see `railScrollbarCss`. Hoisted, deduped by `href`. Not mounted
1531
- at all under `"system"`: a sheet whose only selector is a class
1532
- nothing carries is dead weight in the document. */}
1533
- {props.railScrollbar !== "system" && (
1572
+ at all under `"system"` nor under `railScroll="page"`, where
1573
+ there is no scroll port to dress: a sheet whose only selector is
1574
+ a class nothing carries is dead weight in the document. */}
1575
+ {railStyled && (
1534
1576
  <style href={RAIL_STYLE_HREF} precedence="default">
1535
1577
  {railScrollbarCss()}
1536
1578
  </style>
@@ -1585,6 +1627,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1585
1627
  filtersLayout,
1586
1628
  railTop,
1587
1629
  railScrollbar,
1630
+ railScroll,
1588
1631
  railSurface,
1589
1632
  blockRhythm,
1590
1633
  stickyToolbar,
@@ -1653,6 +1696,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
1653
1696
  {...(filtersLayout !== undefined ? { filtersLayout } : {})}
1654
1697
  {...(railTop !== undefined ? { railTop } : {})}
1655
1698
  {...(railScrollbar !== undefined ? { railScrollbar } : {})}
1699
+ {...(railScroll !== undefined ? { railScroll } : {})}
1656
1700
  {...(railSurface !== undefined ? { railSurface } : {})}
1657
1701
  {...(blockRhythm !== undefined ? { blockRhythm } : {})}
1658
1702
  {...(stickyToolbar !== undefined ? { stickyToolbar } : {})}
@@ -0,0 +1,100 @@
1
+ /**
2
+ * A focus ring the KEYBOARD gets and the mouse does not.
3
+ *
4
+ * On a live storefront, clicking the "collapse" control under a facet group
5
+ * left a red outline standing around it until something else took focus. The
6
+ * ring itself is right — it is the design system's `:focus-visible` outline,
7
+ * and a keyboard must have it — but it was being drawn for a POINTER, which is
8
+ * the one modality that already knows where it clicked.
9
+ *
10
+ * ── Why `:focus-visible` alone is not the fix ─────────────────────────────
11
+ *
12
+ * The usual answer, "draw the ring on `:focus-visible` and not on `:focus`",
13
+ * was already true here: antd draws its button ring from
14
+ * `&:not(:disabled):focus-visible` and nothing in this pair or the storefront's
15
+ * sheet draws one on bare `:focus`. `:focus-visible` is a HEURISTIC, though,
16
+ * not a statement about the last input device: the engine may match it for a
17
+ * click when focus lands on an element whose surroundings have just changed
18
+ * under it — which is exactly what a disclosure toggle does to its own list.
19
+ * Suppressing the ring on `:focus:not(:focus-visible)` therefore fixes nothing
20
+ * on the very control that showed the defect.
21
+ *
22
+ * ── What this states instead ──────────────────────────────────────────────
23
+ *
24
+ * The modality, as a fact the control records for itself: a pointer press
25
+ * stamps `data-pointer-focus` on the element, and the first key it sees — or
26
+ * losing focus — clears it. The sheet then turns the ring OFF while that stamp
27
+ * is on, and leaves every other case to the design system. Tab to the control
28
+ * and the ring is there; click it and it is not; click it and then Tab back to
29
+ * it and it is there again, because the stamp died with the blur.
30
+ *
31
+ * The attribute is written straight to the node rather than held in state, on
32
+ * purpose: it is presentation the frame does not depend on, one control at a
33
+ * time, and a `useState` per facet button would re-render a rail of forty
34
+ * groups to paint an outline. React does not own this attribute, so nothing
35
+ * it renders fights over it.
36
+ *
37
+ * The class is doubled in the selector (`.c.c[…]`) for exactly one reason:
38
+ * specificity. antd's own ring is `.ant-btn:not(:disabled):focus-visible`
39
+ * (0,3,0); a single class plus the attribute plus the pseudo-class ties it, and
40
+ * a tie is settled by whichever stylesheet the engine saw last — which for a
41
+ * runtime CSS-in-JS design system is not a thing this pair gets to decide.
42
+ */
43
+ import type { CSSProperties } from "react";
44
+
45
+ /** The class the pointer-focus rule is hung on. */
46
+ export const POINTER_FOCUS_CLASS = "stapel-search-pointer-focus";
47
+
48
+ /** The attribute a pointer press stamps, and a key or a blur clears. */
49
+ export const POINTER_FOCUS_ATTR = "data-pointer-focus";
50
+
51
+ /** The `href` the hoisted focus sheet is deduplicated by (React 19). */
52
+ export const POINTER_FOCUS_STYLE_HREF = "stapel-search-pointer-focus";
53
+
54
+ /** See the module note. */
55
+ export function pointerFocusCss(): string {
56
+ const c = `.${POINTER_FOCUS_CLASS}`;
57
+ return `${c}${c}[${POINTER_FOCUS_ATTR}]:focus-visible{outline:none;box-shadow:none}`;
58
+ }
59
+
60
+ /**
61
+ * The handlers a control spreads to opt into the rule above.
62
+ *
63
+ * Typed against the DOM rather than against React's synthetic event so the
64
+ * same object fits a native `<button>` and antd's `<Button>` — both forward
65
+ * these three props to the element, which is the only thing this needs.
66
+ */
67
+ export interface PointerFocusProps {
68
+ readonly className: string;
69
+ readonly onPointerDown: (event: { currentTarget: Element }) => void;
70
+ readonly onKeyDown: (event: { currentTarget: Element }) => void;
71
+ readonly onBlur: (event: { currentTarget: Element }) => void;
72
+ }
73
+
74
+ /**
75
+ * Spread onto any control whose ring should be the keyboard's alone.
76
+ *
77
+ * A constant, not a hook: it closes over nothing, so one object serves every
78
+ * control in the tree and a facet group of forty rows allocates none.
79
+ */
80
+ export const POINTER_FOCUS: PointerFocusProps = {
81
+ className: POINTER_FOCUS_CLASS,
82
+ onPointerDown: (event) => {
83
+ event.currentTarget.setAttribute(POINTER_FOCUS_ATTR, "");
84
+ },
85
+ onKeyDown: (event) => {
86
+ event.currentTarget.removeAttribute(POINTER_FOCUS_ATTR);
87
+ },
88
+ onBlur: (event) => {
89
+ event.currentTarget.removeAttribute(POINTER_FOCUS_ATTR);
90
+ },
91
+ };
92
+
93
+ /** {@link POINTER_FOCUS} with a className of the caller's own merged in. */
94
+ export function pointerFocus(className: string): PointerFocusProps {
95
+ return { ...POINTER_FOCUS, className: `${className} ${POINTER_FOCUS_CLASS}` };
96
+ }
97
+
98
+ /** Nothing this module styles inline — exported so a host composing its own
99
+ * control can state the same intent without re-deriving the selector. */
100
+ export const POINTER_FOCUS_NO_RING: CSSProperties = { outline: "none" };
@@ -53,6 +53,22 @@ export {
53
53
  railScrollbarCss,
54
54
  railStyle,
55
55
  } from "./SearchPage.js";
56
+ export { RAIL_OFFSET_PROPERTY, useRailFits } from "./railFit.js";
57
+ export {
58
+ RAIL_SCROLLBAR_WIDTH,
59
+ SCROLL_GUTTER_INLINE_END,
60
+ SCROLL_LIST_INSET_BLOCK_START,
61
+ } from "./scrollbar.js";
62
+ export {
63
+ POINTER_FOCUS,
64
+ POINTER_FOCUS_ATTR,
65
+ POINTER_FOCUS_CLASS,
66
+ POINTER_FOCUS_NO_RING,
67
+ POINTER_FOCUS_STYLE_HREF,
68
+ pointerFocus,
69
+ pointerFocusCss,
70
+ } from "./focusRing.js";
71
+ export type { PointerFocusProps } from "./focusRing.js";
56
72
  export type {
57
73
  SearchPageProps,
58
74
  SearchFiltersLayout,
@@ -61,6 +77,7 @@ export type {
61
77
  SearchFiltersOpenReason,
62
78
  SearchBlockRhythm,
63
79
  SearchRailFrom,
80
+ SearchRailScroll,
64
81
  SearchRailScrollbar,
65
82
  } from "./SearchPage.js";
66
83
 
@@ -176,6 +193,7 @@ export {
176
193
  POPULAR_VALUES_COLUMNS,
177
194
  POPULAR_VALUES_LADDER,
178
195
  POPULAR_VALUES_LIMIT,
196
+ POPULAR_VALUES_MAX_COLUMNS,
179
197
  POPULAR_VALUES_STYLE_HREF,
180
198
  POPULAR_VALUE_COLUMN_WIDTH,
181
199
  } from "./PopularValues.js";
@@ -0,0 +1,90 @@
1
+ /**
2
+ * DOES THE FILTER RAIL FIT UNDER THE HOST'S HEADER?
3
+ *
4
+ * The question exists only for `railScroll="page"` — the arm where the rail is
5
+ * NOT a scroll container of its own and the page is the only thing that
6
+ * scrolls. There a rail taller than the window cannot be sticky: a stuck box
7
+ * is cut off at the foot of the screen and its last controls become
8
+ * unreachable, because the scroll that would reveal them is the page's and the
9
+ * page is not moving the rail. So the rail sticks while it fits and stands in
10
+ * flow while it does not, and "fits" is a measurement, not a breakpoint:
11
+ * the same catalogue draws four facet groups on one leaf and twenty on the
12
+ * next.
13
+ *
14
+ * Two numbers, and both are read from the browser rather than restated:
15
+ *
16
+ * - the rail's own height — `ResizeObserver`, because the rail changes height
17
+ * without the window changing at all (a facet group unfolds, an answer
18
+ * lands with more values, the schema arrives and reorders the panel);
19
+ * - the room under the host's chrome — `window.innerHeight` minus the offset
20
+ * the rail already carries as `scroll-margin-top`. That property is the
21
+ * same offset said in the one property that MEANS it (the header covers
22
+ * that much of the top of the scrollport), and the engine resolves it to
23
+ * pixels — a `var()`, a `calc()` or a `rem` included — so the fit test
24
+ * needs no CSS parser of its own to read what a host wrote.
25
+ *
26
+ * The house rule from `useElementWidth` holds here too: **zero is not a
27
+ * measurement**. A detached or `display: none` rail reports 0 and would
28
+ * otherwise be declared to fit forever. And the honest answer before the first
29
+ * measurement is "no": a static rail scrolls with the page under every
30
+ * circumstance, so an unmeasured frame degrades to the arm that cannot hide a
31
+ * control.
32
+ */
33
+ import { useEffect, useState } from "react";
34
+ import type { RefObject } from "react";
35
+
36
+ /** The CSS property the rail's top offset is carried in — see the note above.
37
+ * Exported so a test can assert the two halves read and write the same one. */
38
+ export const RAIL_OFFSET_PROPERTY = "scrollMarginTop";
39
+
40
+ /**
41
+ * Measure whether `ref`'s element is short enough to stand under the chrome
42
+ * its own `scroll-margin-top` names.
43
+ *
44
+ * `enabled` is the `railScroll === "page"` arm: under `"internal"` nothing is
45
+ * measured and nothing is listened to, so the default arm carries no observer
46
+ * and no listener at all.
47
+ */
48
+ export function useRailFits(
49
+ ref: RefObject<HTMLElement | null>,
50
+ enabled: boolean
51
+ ): boolean {
52
+ const [fits, setFits] = useState(false);
53
+
54
+ useEffect(() => {
55
+ if (!enabled) {
56
+ // Leaving the arm resets the answer: a rail that stops being measured
57
+ // must not keep the last measurement's `position: sticky`.
58
+ setFits(false);
59
+ return undefined;
60
+ }
61
+ const element = ref.current;
62
+ if (element === null) return undefined;
63
+
64
+ const measure = (): void => {
65
+ const height = element.getBoundingClientRect().height;
66
+ // Zero is not a measurement — see the note above.
67
+ if (height <= 0) return;
68
+ const offset = Number.parseFloat(
69
+ window.getComputedStyle(element)[RAIL_OFFSET_PROPERTY]
70
+ );
71
+ const room = window.innerHeight - (Number.isFinite(offset) ? offset : 0);
72
+ const next = height <= room;
73
+ setFits((previous) => (previous === next ? previous : next));
74
+ };
75
+
76
+ measure();
77
+ // The window's own height is not the element's: a rotation, a devtools
78
+ // pane or a resized window changes the room without changing the rail.
79
+ window.addEventListener("resize", measure);
80
+ const observer =
81
+ typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
82
+ observer?.observe(element);
83
+ return () => {
84
+ window.removeEventListener("resize", measure);
85
+ observer?.disconnect();
86
+ };
87
+ }, [ref, enabled]);
88
+
89
+ return fits;
90
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The skin's scrollbar — one rule set, for every scroll port this pair owns.
3
+ *
4
+ * It started as the rail's own: `<SearchPage>` replaced the platform bar
5
+ * standing next to the filters with a 6px hairline in the token palette. But
6
+ * the rail is not the only box in the panel that scrolls — a dictionary facet
7
+ * (the make axis, 418 car makes) is a scroll port INSIDE the rail, and kept the
8
+ * platform's bar: on the live storefront the system thumb was painted over the
9
+ * count column, so «Chery 5» read as «Chery» with the 5 under a grey strip.
10
+ *
11
+ * A box that scrolls owes the reader two things, and both are here:
12
+ *
13
+ * - `scrollbar-gutter: stable`, so the space the bar needs is subtracted from
14
+ * the CONTENT box rather than overlaid on it, and the box's right edge does
15
+ * not move when the thumb arrives;
16
+ * - a thumb that is the skin's — a 6px track with no arrows and no track
17
+ * fill, transparent at rest, appearing on `:hover` (a pointer scrolling
18
+ * inside it) and `:focus-within` (a keyboard), and standing permanently
19
+ * under `(pointer: coarse)`, where neither fires and an invisible bar is a
20
+ * box with no sign it has a tail.
21
+ *
22
+ * Both vendor forms, because they are not alternatives: Firefox reads
23
+ * `scrollbar-width`/`scrollbar-color` and nothing else, WebKit and Chromium
24
+ * read the `::-webkit-scrollbar` pseudo-elements and (in Chromium) the
25
+ * standard properties too.
26
+ *
27
+ * The colours are `--stapel-*` custom properties, which resolve per theme at
28
+ * paint time — an inline colour or a `useToken()` value would freeze whichever
29
+ * theme was mounted first. This design system's neutral vocabulary has no
30
+ * `colorFill*` ramp of its own: `border` IS its tertiary-fill role (the
31
+ * hairline every pane is separated by) and `text-subtle` is that role one step
32
+ * stronger, which is what the thumb takes when a pointer is on the thumb
33
+ * itself.
34
+ *
35
+ * Emitted as one hoisted `<style href precedence>` (React 19 dedupes by
36
+ * `href`), because a pseudo-element is unreachable from an inline style — so
37
+ * the rail and the dictionary list mount the SAME element, once, however many
38
+ * of each are on screen.
39
+ *
40
+ * The names still say "rail" and they stay that way: they are this package's
41
+ * published surface, and a hoisted sheet is identified by its `href`. Renaming
42
+ * them would break every host that reads the class to assert which bar is on
43
+ * screen, and would buy nothing but a tidier word.
44
+ */
45
+ import type { CSSProperties } from "react";
46
+ import { cssVar, spacing } from "@stapel/tokens";
47
+
48
+ /**
49
+ * The class that carries the SKIN's scrollbar — present on the rail under
50
+ * `railScrollbar: "styled"` and absent under `"system"`, so the two arms are
51
+ * one class apart and a stand can read which one is on screen. Always present
52
+ * on a scroll port INSIDE the panel, which is not a surface a host chose.
53
+ */
54
+ export const RAIL_SCROLLBAR_CLASS = "stapel-search-rail-scrollbar";
55
+
56
+ /** The `href` the hoisted rail sheet is deduplicated by. */
57
+ export const RAIL_STYLE_HREF = "stapel-search-rail";
58
+
59
+ /**
60
+ * The scrollbar's track width, in CSS pixels.
61
+ *
62
+ * Not on the spacing scale on purpose, and not a spacing decision: this is the
63
+ * thickness of a hairline instrument, the size every platform's own overlay
64
+ * bar lands within, and the number the storefront's owner named. Six is thin
65
+ * enough to read as part of the panel and thick enough to grab.
66
+ */
67
+ export const RAIL_SCROLLBAR_WIDTH = 6;
68
+
69
+ /** See the module note. */
70
+ export function railScrollbarCss(): string {
71
+ const bar = `.${RAIL_SCROLLBAR_CLASS}`;
72
+ const size = `${String(RAIL_SCROLLBAR_WIDTH)}px`;
73
+ const thumb = cssVar("border");
74
+ const awake = `${bar}:hover,${bar}:focus-within`;
75
+ return [
76
+ // ── Firefox ────────────────────────────────────────────────────────────
77
+ `${bar}{scrollbar-width:thin;scrollbar-gutter:stable;` +
78
+ `scrollbar-color:transparent transparent}`,
79
+ `${awake}{scrollbar-color:${thumb} transparent}`,
80
+ // ── WebKit / Chromium ──────────────────────────────────────────────────
81
+ `${bar}::-webkit-scrollbar{inline-size:${size};block-size:${size}}`,
82
+ `${bar}::-webkit-scrollbar-track{background:transparent}`,
83
+ `${bar}::-webkit-scrollbar-thumb{background:transparent;` +
84
+ `border-radius:${cssVar("radius-full")}}`,
85
+ `${bar}:hover::-webkit-scrollbar-thumb,` +
86
+ `${bar}:focus-within::-webkit-scrollbar-thumb{background:${thumb}}`,
87
+ `${bar}::-webkit-scrollbar-thumb:hover{background:${cssVar("text-subtle")}}`,
88
+ // ── A surface with no hover at all ─────────────────────────────────────
89
+ `@media (pointer:coarse){${bar}{scrollbar-color:${thumb} transparent}` +
90
+ `${bar}::-webkit-scrollbar-thumb{background:${thumb}}}`,
91
+ ].join("\n");
92
+ }
93
+
94
+ /**
95
+ * What a scroll port inside the panel reserves at its trailing edge.
96
+ *
97
+ * `scrollbar-gutter: stable` is the rule and the sheet above states it; this
98
+ * is the same number as a value, for the ROW to keep its last column clear of.
99
+ * `scrollbar-gutter` subtracts the gutter from the scroll container's content
100
+ * box, which is enough on every engine that honours it — and is ignored
101
+ * outright by Safari, where an overlay thumb still lands on top of whatever
102
+ * sits at the trailing edge. A count is the one thing in a facet row that
103
+ * lives there, so it carries the gutter as padding of its own: correct twice
104
+ * over on Chromium and Firefox, and the only thing standing between the number
105
+ * and the thumb on WebKit.
106
+ */
107
+ export const SCROLL_GUTTER_INLINE_END: CSSProperties = {
108
+ paddingInlineEnd: RAIL_SCROLLBAR_WIDTH,
109
+ };
110
+
111
+ /**
112
+ * The top of a list that sits directly under a control.
113
+ *
114
+ * The dictionary list's first row was drawn flush against the box that filters
115
+ * it — at a glance the row read as clipped BY the input rather than as the
116
+ * first value under it. One step of the scale is the whole fix; it is stated
117
+ * here rather than inline so the rail's two scroll ports open the same way.
118
+ */
119
+ export const SCROLL_LIST_INSET_BLOCK_START: number = spacing[1];