@stapel/search-react 0.42.0 → 0.42.2
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 +69 -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 +7 -42
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +9 -74
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/SearchResultsPane.d.ts +28 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -1
- package/dist/default/SearchResultsPane.js +34 -4
- package/dist/default/SearchResultsPane.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 +5 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +4 -2
- package/dist/default/index.js.map +1 -1
- 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 +6 -6
- 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 +18 -77
- package/src/default/SearchResultsPane.tsx +35 -4
- package/src/default/focusRing.ts +100 -0
- package/src/default/index.ts +17 -0
- package/src/default/scrollbar.ts +119 -0
|
@@ -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 { 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";
|
|
@@ -78,6 +78,11 @@ 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
80
|
import { useRailFits } from "./railFit.js";
|
|
81
|
+
import {
|
|
82
|
+
RAIL_SCROLLBAR_CLASS,
|
|
83
|
+
RAIL_STYLE_HREF,
|
|
84
|
+
railScrollbarCss,
|
|
85
|
+
} from "./scrollbar.js";
|
|
81
86
|
import { FacetPanelPane } from "./FacetPanelPane.js";
|
|
82
87
|
import type {
|
|
83
88
|
CategoryFilterSlotProps,
|
|
@@ -267,14 +272,19 @@ export const FILTERS_RAIL_WIDTH = 280;
|
|
|
267
272
|
export const RAIL_CLASS = "stapel-search-rail";
|
|
268
273
|
|
|
269
274
|
/**
|
|
270
|
-
* The
|
|
271
|
-
*
|
|
272
|
-
*
|
|
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.
|
|
273
281
|
*/
|
|
274
|
-
export
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
282
|
+
export {
|
|
283
|
+
RAIL_SCROLLBAR_CLASS,
|
|
284
|
+
RAIL_SCROLLBAR_WIDTH,
|
|
285
|
+
RAIL_STYLE_HREF,
|
|
286
|
+
railScrollbarCss,
|
|
287
|
+
} from "./scrollbar.js";
|
|
278
288
|
|
|
279
289
|
/** Whose scrollbar the rail's own scroll port draws — see
|
|
280
290
|
* {@link SearchPageProps.railScrollbar}. */
|
|
@@ -284,75 +294,6 @@ export type SearchRailScrollbar = "styled" | "system";
|
|
|
284
294
|
* {@link SearchPageProps.railScroll}. */
|
|
285
295
|
export type SearchRailScroll = "internal" | "page";
|
|
286
296
|
|
|
287
|
-
/**
|
|
288
|
-
* The scrollbar's track width, in CSS pixels.
|
|
289
|
-
*
|
|
290
|
-
* Not on the spacing scale on purpose, and not a spacing decision: this is the
|
|
291
|
-
* thickness of a hairline instrument, the size every platform's own overlay
|
|
292
|
-
* bar lands within, and the number the storefront's owner named. Six is thin
|
|
293
|
-
* enough to read as part of the panel and thick enough to grab.
|
|
294
|
-
*/
|
|
295
|
-
const RAIL_SCROLLBAR_WIDTH = 6;
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* The rail scrolls, and the bar that says so is the SKIN's, not the platform's.
|
|
299
|
-
*
|
|
300
|
-
* The system bar was never a decision — it is what an `overflow-y: auto` box
|
|
301
|
-
* gets when nobody says otherwise, and on the storefront it landed as a grey
|
|
302
|
-
* chrome-coloured strip standing next to the filters in a dark theme. What it
|
|
303
|
-
* is replaced with:
|
|
304
|
-
*
|
|
305
|
-
* - a 6px track with no arrows and no track fill — the rail's own hairline,
|
|
306
|
-
* not a widget;
|
|
307
|
-
* - a thumb that is TRANSPARENT at rest and appears on `:hover` of the rail
|
|
308
|
-
* (which is what a pointer scrolling inside it is doing) and on
|
|
309
|
-
* `:focus-within` (which is what a keyboard is doing). A coarse pointer
|
|
310
|
-
* fires neither, so under `(pointer: coarse)` the thumb stands — a touch
|
|
311
|
-
* surface with an invisible scrollbar is a rail with no sign it has a tail;
|
|
312
|
-
* - `scrollbar-gutter: stable`, so the panel's right edge does not move when
|
|
313
|
-
* the thumb arrives.
|
|
314
|
-
*
|
|
315
|
-
* Both vendor forms, because they are not alternatives: Firefox reads
|
|
316
|
-
* `scrollbar-width`/`scrollbar-color` and nothing else, WebKit and Chromium
|
|
317
|
-
* read the `::-webkit-scrollbar` pseudo-elements and (in Chromium) the
|
|
318
|
-
* standard properties too.
|
|
319
|
-
*
|
|
320
|
-
* The colours are `--stapel-*` custom properties, which resolve per theme at
|
|
321
|
-
* paint time — an inline colour or a `useToken()` value would freeze whichever
|
|
322
|
-
* theme was mounted first. This design system's neutral vocabulary has no
|
|
323
|
-
* `colorFill*` ramp of its own: `border` IS its tertiary-fill role (the
|
|
324
|
-
* hairline every pane is separated by) and `text-subtle` is that role one step
|
|
325
|
-
* stronger, which is what the thumb takes when a pointer is on the thumb
|
|
326
|
-
* itself.
|
|
327
|
-
*
|
|
328
|
-
* Emitted as one hoisted `<style>` (React 19 dedupes by `href`), because a
|
|
329
|
-
* pseudo-element is unreachable from an inline style — the same reason
|
|
330
|
-
* `<LocationSummaryLine>` hoists one.
|
|
331
|
-
*/
|
|
332
|
-
export function railScrollbarCss(): string {
|
|
333
|
-
const bar = `.${RAIL_SCROLLBAR_CLASS}`;
|
|
334
|
-
const size = `${String(RAIL_SCROLLBAR_WIDTH)}px`;
|
|
335
|
-
const thumb = cssVar("border");
|
|
336
|
-
const awake = `${bar}:hover,${bar}:focus-within`;
|
|
337
|
-
return [
|
|
338
|
-
// ── Firefox ────────────────────────────────────────────────────────────
|
|
339
|
-
`${bar}{scrollbar-width:thin;scrollbar-gutter:stable;` +
|
|
340
|
-
`scrollbar-color:transparent transparent}`,
|
|
341
|
-
`${awake}{scrollbar-color:${thumb} transparent}`,
|
|
342
|
-
// ── WebKit / Chromium ──────────────────────────────────────────────────
|
|
343
|
-
`${bar}::-webkit-scrollbar{inline-size:${size};block-size:${size}}`,
|
|
344
|
-
`${bar}::-webkit-scrollbar-track{background:transparent}`,
|
|
345
|
-
`${bar}::-webkit-scrollbar-thumb{background:transparent;` +
|
|
346
|
-
`border-radius:${cssVar("radius-full")}}`,
|
|
347
|
-
`${bar}:hover::-webkit-scrollbar-thumb,` +
|
|
348
|
-
`${bar}:focus-within::-webkit-scrollbar-thumb{background:${thumb}}`,
|
|
349
|
-
`${bar}::-webkit-scrollbar-thumb:hover{background:${cssVar("text-subtle")}}`,
|
|
350
|
-
// ── A surface with no hover at all ─────────────────────────────────────
|
|
351
|
-
`@media (pointer:coarse){${bar}{scrollbar-color:${thumb} transparent}` +
|
|
352
|
-
`${bar}::-webkit-scrollbar-thumb{background:${thumb}}}`,
|
|
353
|
-
].join("\n");
|
|
354
|
-
}
|
|
355
|
-
|
|
356
297
|
const RAIL: CSSProperties = {
|
|
357
298
|
flex: `0 0 ${String(FILTERS_RAIL_WIDTH)}px`,
|
|
358
299
|
// Both bounds, not just the upper one. `flex-shrink: 0` already holds the
|
|
@@ -159,6 +159,35 @@ export const RESULTS_TOOLBAR_TOP_VAR = "--stapel-search-toolbar-top";
|
|
|
159
159
|
/** The `href` the hoisted toolbar sheet is deduplicated by. */
|
|
160
160
|
export const RESULTS_TOOLBAR_STYLE_HREF = "stapel-search-toolbar";
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* THE LAYER A STICKY CHROME BAND PAINTS ON (D473).
|
|
164
|
+
*
|
|
165
|
+
* The row used to say `z-index: 1`, which is not a layer above the feed — it
|
|
166
|
+
* is a layer IN it. Measured on a live stand at 1440, scrolled 900px: a feed
|
|
167
|
+
* card's favourite overlay is `position: absolute; z-index: 2` and its photo
|
|
168
|
+
* counter is `z-index: 1`, so the heart painted over the pinned bar and the
|
|
169
|
+
* counter tied with it and won on document order. Reviewers read the same
|
|
170
|
+
* thing at 390, 768 and 1440 in both themes — a card's heart and its "1 of N"
|
|
171
|
+
* badge over the "105 listings / List / Sort" row, with the card underneath
|
|
172
|
+
* sliced mid-price.
|
|
173
|
+
*
|
|
174
|
+
* `20` is the fleet's existing number for exactly this shape: `listings-react`
|
|
175
|
+
* pins its condensed listing bar at `z-index: 20`, "above a page's own content
|
|
176
|
+
* and deliberately below the range a modal layer uses". `@stapel/tokens` ships
|
|
177
|
+
* NO z-index vocabulary (it carries colour roles, spacing, radii, breakpoints
|
|
178
|
+
* and type — no layering scale), so there is no role to read this from; the
|
|
179
|
+
* number is published here rather than written twice as a literal, and the two
|
|
180
|
+
* pins (the host's inline one and the default rule set) both read it, or they
|
|
181
|
+
* would drift into a bar that is over the feed on one arm and under it on the
|
|
182
|
+
* other.
|
|
183
|
+
*
|
|
184
|
+
* The ceiling is as load-bearing as the floor: `<PublicShell>`'s header is
|
|
185
|
+
* `z-index: 1000` and antd's popups start at `zIndexPopupBase` (1000), so the
|
|
186
|
+
* band has to stay well below both — a sort select must still open OVER its
|
|
187
|
+
* own bar, and the bar must still pass UNDER the header it is pinned beneath.
|
|
188
|
+
*/
|
|
189
|
+
export const RESULTS_TOOLBAR_Z_INDEX = 20;
|
|
190
|
+
|
|
162
191
|
/**
|
|
163
192
|
* The default pin's rule set.
|
|
164
193
|
*
|
|
@@ -175,8 +204,9 @@ export function toolbarStickyCss(): string {
|
|
|
175
204
|
`${bar}{position:sticky;top:var(${RESULTS_TOOLBAR_TOP_VAR},0px);` +
|
|
176
205
|
// Over the cards, under the page's own chrome — and under antd's popups,
|
|
177
206
|
// so the sort select still opens over its own bar. Opaque, or the cards
|
|
178
|
-
// scroll THROUGH the row.
|
|
179
|
-
|
|
207
|
+
// scroll THROUGH the row. See {@link RESULTS_TOOLBAR_Z_INDEX} for why the
|
|
208
|
+
// layer is what it is.
|
|
209
|
+
`z-index:${String(RESULTS_TOOLBAR_Z_INDEX)};background:${cssVar("surface")}}}`
|
|
180
210
|
);
|
|
181
211
|
}
|
|
182
212
|
|
|
@@ -356,8 +386,9 @@ function toolbarPinStyle(pin: SearchToolbarPin | undefined): CSSProperties | und
|
|
|
356
386
|
position: "sticky",
|
|
357
387
|
top: pin.top ?? 0,
|
|
358
388
|
// Over the cards, under the page's own chrome — and under antd's popups,
|
|
359
|
-
// so the sort select still opens over its own bar.
|
|
360
|
-
|
|
389
|
+
// so the sort select still opens over its own bar. The SAME layer the
|
|
390
|
+
// default rule set uses — see {@link RESULTS_TOOLBAR_Z_INDEX}.
|
|
391
|
+
zIndex: RESULTS_TOOLBAR_Z_INDEX,
|
|
361
392
|
// Opaque, or the cards scroll THROUGH the bar. The theme's own surface
|
|
362
393
|
// role, resolved at paint time, so it follows the brand and the dark side
|
|
363
394
|
// without a second colour being written here.
|
|
@@ -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
|
@@ -54,6 +54,21 @@ export {
|
|
|
54
54
|
railStyle,
|
|
55
55
|
} from "./SearchPage.js";
|
|
56
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";
|
|
57
72
|
export type {
|
|
58
73
|
SearchPageProps,
|
|
59
74
|
SearchFiltersLayout,
|
|
@@ -91,6 +106,7 @@ export {
|
|
|
91
106
|
RESULTS_TOOLBAR_STICKY_CLASS,
|
|
92
107
|
RESULTS_TOOLBAR_STYLE_HREF,
|
|
93
108
|
RESULTS_TOOLBAR_TOP_VAR,
|
|
109
|
+
RESULTS_TOOLBAR_Z_INDEX,
|
|
94
110
|
resultsColumnsCss,
|
|
95
111
|
toolbarRowMinHeight,
|
|
96
112
|
toolbarStickyCss,
|
|
@@ -178,6 +194,7 @@ export {
|
|
|
178
194
|
POPULAR_VALUES_COLUMNS,
|
|
179
195
|
POPULAR_VALUES_LADDER,
|
|
180
196
|
POPULAR_VALUES_LIMIT,
|
|
197
|
+
POPULAR_VALUES_MAX_COLUMNS,
|
|
181
198
|
POPULAR_VALUES_STYLE_HREF,
|
|
182
199
|
POPULAR_VALUE_COLUMN_WIDTH,
|
|
183
200
|
} from "./PopularValues.js";
|
|
@@ -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];
|