@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.
- package/CHANGELOG.md +70 -0
- package/README.md +13 -0
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +31 -7
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/PopularValues.d.ts +9 -0
- package/dist/default/PopularValues.d.ts.map +1 -1
- package/dist/default/PopularValues.js +45 -9
- package/dist/default/PopularValues.js.map +1 -1
- package/dist/default/SearchPage.d.ts +57 -47
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +77 -85
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/focusRing.d.ts +83 -0
- package/dist/default/focusRing.d.ts.map +1 -0
- package/dist/default/focusRing.js +37 -0
- package/dist/default/focusRing.js.map +1 -0
- package/dist/default/index.d.ts +6 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +4 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/railFit.d.ts +14 -0
- package/dist/default/railFit.d.ts.map +1 -0
- package/dist/default/railFit.js +80 -0
- package/dist/default/railFit.js.map +1 -0
- package/dist/default/scrollbar.d.ts +89 -0
- package/dist/default/scrollbar.d.ts.map +1 -0
- package/dist/default/scrollbar.js +69 -0
- package/dist/default/scrollbar.js.map +1 -0
- package/llms.txt +1 -1
- package/manifest.json +1 -1
- package/nav-manifest.json +1 -1
- package/package.json +4 -4
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FacetGroupControl.tsx +58 -1
- package/src/default/PopularValues.tsx +62 -8
- package/src/default/SearchPage.tsx +135 -91
- package/src/default/focusRing.ts +100 -0
- package/src/default/index.ts +18 -0
- package/src/default/railFit.ts +90 -0
- 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,
|
|
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
|
|
270
|
-
*
|
|
271
|
-
*
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
*
|
|
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,
|
|
380
|
-
* a function rather than one property: a
|
|
381
|
-
* cap is still `100dvh` ends 64px past
|
|
382
|
-
*
|
|
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(
|
|
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
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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"
|
|
1532
|
-
|
|
1533
|
-
|
|
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" };
|
package/src/default/index.ts
CHANGED
|
@@ -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];
|