@helloimjolopez-pathway/pathway-components 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/_shared/use-reduced-motion.jsx +49 -0
- package/components/button/button.jsx +512 -0
- package/components/icon/action-icon.jsx +155 -0
- package/components/icon/display-icon.jsx +137 -0
- package/components/icon/featured-icon.jsx +162 -0
- package/components/icon/icon.jsx +154 -0
- package/components/module-icon/module-icon.jsx +387 -0
- package/components/org-switcher/org-switcher.jsx +604 -0
- package/components/page-template/page-template.jsx +147 -0
- package/components/scrollbar/scrollbar.jsx +232 -0
- package/components/search/search.jsx +689 -0
- package/components/sidenav/sidenav.jsx +951 -0
- package/components/top-nav/top-nav.jsx +1003 -0
- package/package.json +26 -4
- package/README.md +0 -3
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PageTemplate: the screen a Pathway page is built on.
|
|
3
|
+
*
|
|
4
|
+
* Figma `ScreenTemplate`, set 40009709:26016, page "↳ ❇️ Page Template".
|
|
5
|
+
*
|
|
6
|
+
* TWO NESTED SURFACES, and the distinction is the whole component:
|
|
7
|
+
*
|
|
8
|
+
* Container.Screen the CANVAS behind everything. Fill/Surface/Canvas,
|
|
9
|
+
* padded by SheetContainer/Padding/*, no radius.
|
|
10
|
+
* └─ Container.Screen.Main the SHEET the page actually sits on.
|
|
11
|
+
* Fill/Surface/Elevated, padded by Sheet/Padding/*,
|
|
12
|
+
* rounded at the TOP ONLY, with Shadow/Sheet.
|
|
13
|
+
* ├─ Slot.PageNavigation optional, holds Tabs in Figma's example
|
|
14
|
+
* └─ Container.ScreenContent the page: heading, toolbar, sections
|
|
15
|
+
*
|
|
16
|
+
* IT TAKES NO BREAKPOINT PROP, and that is not an omission. Figma models the
|
|
17
|
+
* three breakpoints as variants because Figma has no media queries. Every value
|
|
18
|
+
* that differs between them is already a `Responsive: Layout` token, and those
|
|
19
|
+
* tokens are emitted into media queries by the token pipeline. Read off both
|
|
20
|
+
* sides on 2026-10-12, they agree exactly:
|
|
21
|
+
*
|
|
22
|
+
* SheetContainer pad Sheet pad (top/h/bottom) radius
|
|
23
|
+
* Desktop 16 / 24 16 / 32 / 56 28
|
|
24
|
+
* Tablet 12 / 16 14 / 24 / 48 24
|
|
25
|
+
* Mobile 8 / 12 8 / 16 / 32 20
|
|
26
|
+
*
|
|
27
|
+
* So the component reads the tokens and the browser picks the breakpoint. A
|
|
28
|
+
* `breakpoint` prop here would be a second source of truth for values the CSS
|
|
29
|
+
* already has, and it would be wrong the moment someone resized a window.
|
|
30
|
+
*
|
|
31
|
+
* WHY THIS EXISTS AT ALL. A widget on a dashboard is not understandable without
|
|
32
|
+
* the page around it: the grid's 12 columns are 12 columns OF SOMETHING, and
|
|
33
|
+
* that something is this sheet's content box, which is the viewport minus two
|
|
34
|
+
* levels of padding that both change with the breakpoint. Demonstrating a board
|
|
35
|
+
* without it shows widgets spanning a container nobody can see.
|
|
36
|
+
*/
|
|
37
|
+
import React from "react";
|
|
38
|
+
|
|
39
|
+
const R = (n) => `var(--responsive-layout-${n})`;
|
|
40
|
+
const U = (n) => `var(--semantic-layout-units-${n})`;
|
|
41
|
+
const C = (n) => `var(--semantic-color-${n})`;
|
|
42
|
+
|
|
43
|
+
export const T = {
|
|
44
|
+
canvas: C("fill-surface-canvas"),
|
|
45
|
+
sheet: C("fill-surface-elevated"),
|
|
46
|
+
divider: C("stroke-static-neutral-faint"),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const L = {
|
|
50
|
+
// The canvas. Bottom is 0 in every variant: the sheet runs to the edge.
|
|
51
|
+
screenPadH: R("sheetcontainer-padding-horizontal"),
|
|
52
|
+
screenPadT: R("sheetcontainer-padding-top"),
|
|
53
|
+
// The sheet.
|
|
54
|
+
sheetPadH: R("sheet-padding-horizontal"),
|
|
55
|
+
sheetPadT: R("sheet-padding-top"),
|
|
56
|
+
sheetPadB: R("sheet-padding-bottom"),
|
|
57
|
+
sheetRadius: R("sheet-corner-radius-cornerradius"),
|
|
58
|
+
// Gaps: the sheet's own children, then the page content's sections.
|
|
59
|
+
sheetGap: U("gap-tight"), // 8
|
|
60
|
+
contentGap: U("gap-base"), // 16
|
|
61
|
+
navHeight: 49,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {node} children Container.ScreenContent: the page itself.
|
|
66
|
+
* @param {node} navigation Slot.PageNavigation. Figma's `Show PageTopNavigation`
|
|
67
|
+
* boolean is simply whether this is passed.
|
|
68
|
+
* @param {bool} grid Draws the 12/8/4 column guides over the content, so a
|
|
69
|
+
* dashboard demo can show what its spans are spans OF.
|
|
70
|
+
*/
|
|
71
|
+
export function PageTemplate({ children, navigation, grid = false, className = "", style }) {
|
|
72
|
+
return (
|
|
73
|
+
<div className={`pw-screen ${className}`} style={{
|
|
74
|
+
background: T.canvas, minHeight: "100%", boxSizing: "border-box",
|
|
75
|
+
paddingLeft: L.screenPadH, paddingRight: L.screenPadH,
|
|
76
|
+
paddingTop: L.screenPadT, paddingBottom: 0,
|
|
77
|
+
...style,
|
|
78
|
+
}}>
|
|
79
|
+
<div className="pw-screen-main" style={{
|
|
80
|
+
display: "flex", flexDirection: "column", gap: L.sheetGap,
|
|
81
|
+
background: T.sheet, boxSizing: "border-box",
|
|
82
|
+
paddingLeft: L.sheetPadH, paddingRight: L.sheetPadH,
|
|
83
|
+
paddingTop: L.sheetPadT, paddingBottom: L.sheetPadB,
|
|
84
|
+
/* TOP CORNERS ONLY. The sheet's bottom edge meets the bottom of the
|
|
85
|
+
viewport, so rounding it would put a visible notch there. Same shape
|
|
86
|
+
as the Widget's inner card, for the same reason. */
|
|
87
|
+
borderTopLeftRadius: L.sheetRadius,
|
|
88
|
+
borderTopRightRadius: L.sheetRadius,
|
|
89
|
+
boxShadow: "var(--elevation-sheet)",
|
|
90
|
+
minHeight: "100%",
|
|
91
|
+
}}>
|
|
92
|
+
{navigation ? (
|
|
93
|
+
<div className="pw-screen-nav" style={{
|
|
94
|
+
flex: "0 0 auto", minHeight: L.navHeight, boxSizing: "border-box",
|
|
95
|
+
borderBottom: `1px solid ${T.divider}`,
|
|
96
|
+
}}>{navigation}</div>
|
|
97
|
+
) : null}
|
|
98
|
+
|
|
99
|
+
<div className="pw-screen-content" style={{
|
|
100
|
+
display: "flex", flexDirection: "column", gap: L.contentGap,
|
|
101
|
+
flex: 1, minHeight: 0, position: "relative",
|
|
102
|
+
}}>
|
|
103
|
+
{grid ? <ColumnGuides /> : null}
|
|
104
|
+
{children}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The column guides, drawn behind the content. Not part of Figma's component:
|
|
113
|
+
* this is a teaching aid, which is why it is off by default and why it is
|
|
114
|
+
* `aria-hidden`. It exists because the board's spans are meaningless without
|
|
115
|
+
* something to count them against.
|
|
116
|
+
*/
|
|
117
|
+
export function ColumnGuides() {
|
|
118
|
+
return (
|
|
119
|
+
<div aria-hidden="true" className="pw-screen-guides" style={{
|
|
120
|
+
position: "absolute", inset: 0, pointerEvents: "none",
|
|
121
|
+
display: "grid", gap: U("gap-base"),
|
|
122
|
+
}}>
|
|
123
|
+
{Array.from({ length: 12 }).map((_, i) => (
|
|
124
|
+
<span key={i} style={{
|
|
125
|
+
gridRow: 1,
|
|
126
|
+
background: C("fill-action-primary-subtle-rest"),
|
|
127
|
+
opacity: 0.35,
|
|
128
|
+
}} />
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The breakpoint rules the guides need, since a count cannot be a token. */
|
|
135
|
+
export const PageTemplateStyles = () => (
|
|
136
|
+
<style>{`
|
|
137
|
+
.pw-screen-guides { grid-template-columns: repeat(12, minmax(0, 1fr)); }
|
|
138
|
+
@media (max-width: 1023px) {
|
|
139
|
+
.pw-screen-guides { grid-template-columns: repeat(8, minmax(0, 1fr)); }
|
|
140
|
+
}
|
|
141
|
+
@media (max-width: 767px) {
|
|
142
|
+
.pw-screen-guides { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
143
|
+
}
|
|
144
|
+
`}</style>
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
export default PageTemplate;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scrollable, Pathway Design System
|
|
3
|
+
*
|
|
4
|
+
* A reusable overlay-scrollbar wrapper. Hides the native OS scrollbar entirely and renders
|
|
5
|
+
* one custom liquid-glass thumb so scrolling looks and behaves IDENTICALLY on macOS, Windows,
|
|
6
|
+
* iOS, and Android (native scrollbars can't be made consistent: this sidesteps them).
|
|
7
|
+
*
|
|
8
|
+
* Behaviour (see components/scrollbar/scrollbar-spec.md):
|
|
9
|
+
* - Thumb height is PROPORTIONAL to content: (viewport / total) × track, floored at 28px.
|
|
10
|
+
* - Reveal: appears while scrolling, fades ~900ms after; also appears when the MOUSE is near
|
|
11
|
+
* the right-edge bar (so it can be grabbed). It does NOT appear from hovering the panel.
|
|
12
|
+
* - Drag: MOUSE only, via a wider invisible grab strip (the 6px thumb alone is hard to hit).
|
|
13
|
+
* - Touch: passive: the bar just shows position; you scroll by swiping content. The grab
|
|
14
|
+
* strip is disabled on touch devices via a `(hover: none)` rule.
|
|
15
|
+
* - Keyboard / a11y: the content scrolls natively (arrows, PageUp/Down, Home/End, Space); the
|
|
16
|
+
* thumb is decorative (`aria-hidden`) and never the only scroll mechanism.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* <Scrollable style={{ flex: 1, minHeight: 0 }}>…tall content…</Scrollable>
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import React, { useRef, useState, useEffect, useCallback } from "react";
|
|
23
|
+
import { useReducedMotion } from "../_shared/use-reduced-motion.jsx";
|
|
24
|
+
|
|
25
|
+
// ─── TOKENS ──────────────────────────────────────────────────────────────────
|
|
26
|
+
// Every colour + unit resolves through a SEMANTIC Pathway token: no primitives,
|
|
27
|
+
// no hardcoded hex/px. Motion uses the --motion-* tokens (durations/easings from design-system-spec §2).
|
|
28
|
+
export const SCROLL = {
|
|
29
|
+
thumbWidth: "var(--semantic-layout-units-padding-xtight)", // 6px: visible thumb thickness
|
|
30
|
+
thumbRadius: "var(--semantic-layout-units-cornerradius-full)", // fully-rounded pill
|
|
31
|
+
thumbMin: 28, // px, min thumb length (grab-target floor); JS layout math
|
|
32
|
+
gutter: "var(--semantic-layout-units-padding-xxxtight)", // 2px, inset from the right edge
|
|
33
|
+
grabZone: 16, // px, invisible mouse grab strip (wider than the 6px thumb so it's catchable)
|
|
34
|
+
// THE LIGHT RUNGS ARE A DELIBERATE CHOICE BY THE DESIGN OWNER. Do not raise
|
|
35
|
+
// them again without asking Jo first.
|
|
36
|
+
//
|
|
37
|
+
// These were briefly Subtle (50%) and Base (70%) to clear the 3:1 AA floor
|
|
38
|
+
// for a non-text UI component, after one person reported the scrollbar as
|
|
39
|
+
// missing while another saw it fine. Measured over #fafafa: Faint 16% =
|
|
40
|
+
// 1.4:1, Light 24% = 1.68:1, Subtle 50% = 3.4:1, Base 70% = 6.57:1.
|
|
41
|
+
//
|
|
42
|
+
// Jo reviewed that in Storybook and rejected it: the heavier thumb reads as
|
|
43
|
+
// a solid grey bar rather than the light warm overlay the design intends, and
|
|
44
|
+
// an overlay scrollbar is a redundant affordance (the content scrolls with or
|
|
45
|
+
// without it, and the OS bar remains). So contrast is bought back with SIZE
|
|
46
|
+
// and PRESENCE rather than with darkness: see thumbWidth and the grab strip.
|
|
47
|
+
// Scrim/Faint and Scrim/Light resolve to Cool Neutral 800, so the thumb was
|
|
48
|
+
// drawing a COOL grey over warm surfaces and reading as a cold bar against
|
|
49
|
+
// them. Reported 2026-10-02. The comment below has always said "light warm
|
|
50
|
+
// overlay": the value simply did not match it.
|
|
51
|
+
//
|
|
52
|
+
// Scrim is also the wrong family: it is the modal backdrop, where a cool
|
|
53
|
+
// near-black is right. The thumb is an overlay on a surface, and under the
|
|
54
|
+
// alpha rule surfaces and fills are WARM. So the thumb now resolves through
|
|
55
|
+
// its own themed custom properties, set in the injected sheet below off the
|
|
56
|
+
// warm neutral alpha primitives. No new design token: a component-local
|
|
57
|
+
// property, the same mechanism the Widget uses for its column spans.
|
|
58
|
+
thumbRest: "var(--pw-scrollbar-thumb-rest)", // warm neutral 16%
|
|
59
|
+
thumbHover: "var(--pw-scrollbar-thumb-hover)", // warm neutral 24%
|
|
60
|
+
thumbBlur: "blur(8px) saturate(180%)",
|
|
61
|
+
// Hairline glass edge: semantic white at 35% via color-mix.
|
|
62
|
+
thumbEdge: "inset 0 0 0 0.5px color-mix(in srgb, var(--semantic-color-fill-static-neutral-faint) 35%, transparent)",
|
|
63
|
+
// Fade OUT gracefully, but appear INSTANTLY. Animating opacity on the way in
|
|
64
|
+
// multiplied the thumb's own alpha by the in-flight opacity, so during the
|
|
65
|
+
// 200ms glide-in it rendered as low as 1.18:1, below the floor at the exact
|
|
66
|
+
// moment the user is looking for it. Only the fade-out is animated now.
|
|
67
|
+
// Asymmetric fade: snappy decelerate glide-IN, graceful fade-OUT. Each entry
|
|
68
|
+
// names every property it is responsible for, because the element swaps
|
|
69
|
+
// between them with `transition: active ? fadeIn : fadeOut`.
|
|
70
|
+
//
|
|
71
|
+
// fadeIn briefly listed only `background`, on the reasoning that animating
|
|
72
|
+
// opacity multiplies the thumb's own alpha by the in-flight opacity and dips
|
|
73
|
+
// the contrast mid-glide. The visible result was worse than the thing it
|
|
74
|
+
// avoided: the thumb POPPED into existence the instant you scrolled. The
|
|
75
|
+
// glide is the point, so opacity is back.
|
|
76
|
+
fadeIn: "opacity var(--motion-duration-3) var(--motion-easing-decelerate), background var(--motion-duration-3) var(--motion-easing-standard)",
|
|
77
|
+
fadeOut: "opacity var(--motion-duration-5) var(--motion-easing-standard), background var(--motion-duration-3) var(--motion-easing-standard)",
|
|
78
|
+
idleHideMs: 500, // hide the thumb this long after scrolling stops / mouse leaves the bar
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Injected once: hide the native bar on the opt-in class, and disable the mouse grab strip on
|
|
82
|
+
// touch devices so finger-swipes scroll the content underneath (the thumb stays a passive indicator).
|
|
83
|
+
if (typeof document !== "undefined" && !document.getElementById("pds-scrollable-base")) {
|
|
84
|
+
const s = document.createElement("style");
|
|
85
|
+
s.id = "pds-scrollable-base";
|
|
86
|
+
s.textContent =
|
|
87
|
+
// Warm neutral, and it has to INVERT: 500 is the dark warm grey for a light
|
|
88
|
+
// surface, 100 the light warm grey for a dark one. An inline style cannot
|
|
89
|
+
// branch on [data-theme], which is why these live here rather than in the
|
|
90
|
+
// SCROLL map above.
|
|
91
|
+
":root,[data-theme=\"light\"]{" +
|
|
92
|
+
"--pw-scrollbar-thumb-rest:var(--primitive-color-warm-neutral-500-16);" +
|
|
93
|
+
"--pw-scrollbar-thumb-hover:var(--primitive-color-warm-neutral-500-24)}" +
|
|
94
|
+
"[data-theme=\"midnight\"],[data-theme=\"dark\"]{" +
|
|
95
|
+
"--pw-scrollbar-thumb-rest:var(--primitive-color-warm-neutral-100-16);" +
|
|
96
|
+
"--pw-scrollbar-thumb-hover:var(--primitive-color-warm-neutral-100-24)}" +
|
|
97
|
+
".pds-scrollable__view{scrollbar-width:none;-ms-overflow-style:none}" +
|
|
98
|
+
".pds-scrollable__view:-webkit-scrollbar{width:0;height:0;display:none}" +
|
|
99
|
+
"@media (hover: none){.pds-scrollable__grab{pointer-events:none!important}}";
|
|
100
|
+
document.head.appendChild(s);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function Scrollable({ children, className = "", style = {}, viewClassName = "", viewStyle = {} }) {
|
|
104
|
+
const noMotion = useReducedMotion();
|
|
105
|
+
const viewRef = useRef(null);
|
|
106
|
+
const dragRef = useRef(null);
|
|
107
|
+
const idleRef = useRef(null);
|
|
108
|
+
const nearRef = useRef(false); // mouse currently near the right-edge bar
|
|
109
|
+
const [thumb, setThumb] = useState({ h: 0, top: 0, show: false });
|
|
110
|
+
const [active, setActive] = useState(false); // thumb visible (scrolling, near-bar, or dragging)
|
|
111
|
+
const [hot, setHot] = useState(false); // mouse over / dragging the grab strip
|
|
112
|
+
|
|
113
|
+
// Thumb height is PROPORTIONAL to content; floored at thumbMin. Position maps the floored
|
|
114
|
+
// thumb across the full track so it still reaches top and bottom exactly.
|
|
115
|
+
const recompute = useCallback(() => {
|
|
116
|
+
const el = viewRef.current;
|
|
117
|
+
if (!el) return;
|
|
118
|
+
const { scrollHeight, clientHeight, scrollTop } = el;
|
|
119
|
+
if (scrollHeight <= clientHeight + 1) { setThumb(t => (t.show ? { ...t, show: false } : t)); return; }
|
|
120
|
+
const h = Math.max(SCROLL.thumbMin, (clientHeight / scrollHeight) * clientHeight);
|
|
121
|
+
const top = (scrollTop / (scrollHeight - clientHeight)) * (clientHeight - h);
|
|
122
|
+
setThumb({ h, top, show: true });
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
recompute();
|
|
127
|
+
const el = viewRef.current;
|
|
128
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
129
|
+
const ro = new ResizeObserver(recompute);
|
|
130
|
+
ro.observe(el);
|
|
131
|
+
Array.from(el.children).forEach(c => ro.observe(c));
|
|
132
|
+
return () => ro.disconnect();
|
|
133
|
+
}, [children, recompute]);
|
|
134
|
+
|
|
135
|
+
const scheduleHide = () => {
|
|
136
|
+
clearTimeout(idleRef.current);
|
|
137
|
+
idleRef.current = setTimeout(() => {
|
|
138
|
+
if (!dragRef.current && !nearRef.current) setActive(false);
|
|
139
|
+
}, SCROLL.idleHideMs);
|
|
140
|
+
};
|
|
141
|
+
const wake = () => { setActive(true); scheduleHide(); }; // reveal while scrolling
|
|
142
|
+
const onScroll = () => { recompute(); wake(); };
|
|
143
|
+
|
|
144
|
+
// Edge-proximity reveal (mouse): show the bar when the cursor is within grabZone of the right
|
|
145
|
+
// edge, so it can be grabbed: but NOT from hovering the rest of the panel.
|
|
146
|
+
const onMouseMove = (e) => {
|
|
147
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
148
|
+
if (rect.right - e.clientX <= SCROLL.grabZone) {
|
|
149
|
+
nearRef.current = true; setActive(true); clearTimeout(idleRef.current);
|
|
150
|
+
} else if (nearRef.current) {
|
|
151
|
+
nearRef.current = false; scheduleHide();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const onMouseLeave = () => { nearRef.current = false; if (!dragRef.current) scheduleHide(); };
|
|
155
|
+
|
|
156
|
+
// Drag (mouse only). The grab strip carries this; the visual thumb is decorative.
|
|
157
|
+
const onGrabDown = (e) => {
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
const el = viewRef.current;
|
|
160
|
+
if (!el) return;
|
|
161
|
+
dragRef.current = { startY: e.clientY, startTop: el.scrollTop };
|
|
162
|
+
setHot(true); setActive(true); clearTimeout(idleRef.current);
|
|
163
|
+
const onMove = (ev) => {
|
|
164
|
+
const { scrollHeight, clientHeight } = el;
|
|
165
|
+
const h = Math.max(SCROLL.thumbMin, (clientHeight / scrollHeight) * clientHeight);
|
|
166
|
+
const ratio = (scrollHeight - clientHeight) / (clientHeight - h);
|
|
167
|
+
el.scrollTop = dragRef.current.startTop + (ev.clientY - dragRef.current.startY) * ratio;
|
|
168
|
+
};
|
|
169
|
+
const onUp = () => {
|
|
170
|
+
dragRef.current = null; setHot(false);
|
|
171
|
+
document.removeEventListener("mousemove", onMove);
|
|
172
|
+
document.removeEventListener("mouseup", onUp);
|
|
173
|
+
wake();
|
|
174
|
+
};
|
|
175
|
+
document.addEventListener("mousemove", onMove);
|
|
176
|
+
document.addEventListener("mouseup", onUp);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const barTop = thumb.top, barH = thumb.h;
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div
|
|
183
|
+
className={`pds-scrollable ${className}`}
|
|
184
|
+
style={{ position: "relative", minHeight: 0, ...style }}
|
|
185
|
+
onMouseMove={onMouseMove}
|
|
186
|
+
onMouseLeave={onMouseLeave}
|
|
187
|
+
>
|
|
188
|
+
<div
|
|
189
|
+
ref={viewRef}
|
|
190
|
+
className={`pds-scrollable__view ${viewClassName}`}
|
|
191
|
+
onScroll={onScroll}
|
|
192
|
+
style={{ height: "100%", overflowY: "auto", overflowX: "hidden", ...viewStyle }}
|
|
193
|
+
>
|
|
194
|
+
{children}
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
{thumb.show && (
|
|
198
|
+
<>
|
|
199
|
+
{/* Visual thumb, decorative, never intercepts pointer events. */}
|
|
200
|
+
<div
|
|
201
|
+
aria-hidden="true"
|
|
202
|
+
style={{
|
|
203
|
+
position: "absolute", top: barTop, right: SCROLL.gutter,
|
|
204
|
+
width: SCROLL.thumbWidth, height: barH, borderRadius: SCROLL.thumbRadius,
|
|
205
|
+
background: hot ? SCROLL.thumbHover : SCROLL.thumbRest,
|
|
206
|
+
backdropFilter: SCROLL.thumbBlur, WebkitBackdropFilter: SCROLL.thumbBlur,
|
|
207
|
+
boxShadow: SCROLL.thumbEdge,
|
|
208
|
+
opacity: active ? 1 : 0, transition: noMotion ? "none" : (active ? SCROLL.fadeIn : SCROLL.fadeOut),
|
|
209
|
+
pointerEvents: "none", zIndex: 5,
|
|
210
|
+
}}
|
|
211
|
+
/>
|
|
212
|
+
{/* Invisible mouse grab strip, wider than the thumb so it's catchable; disabled on
|
|
213
|
+
touch via the (hover:none) rule so finger-swipes scroll the content beneath. */}
|
|
214
|
+
<div
|
|
215
|
+
aria-hidden="true"
|
|
216
|
+
className="pds-scrollable__grab"
|
|
217
|
+
onMouseDown={onGrabDown}
|
|
218
|
+
onMouseEnter={() => setHot(true)}
|
|
219
|
+
onMouseLeave={() => setHot(false)}
|
|
220
|
+
style={{
|
|
221
|
+
position: "absolute", top: barTop, right: 0,
|
|
222
|
+
width: SCROLL.grabZone, height: barH,
|
|
223
|
+
pointerEvents: active ? "auto" : "none", zIndex: 6, background: "transparent",
|
|
224
|
+
}}
|
|
225
|
+
/>
|
|
226
|
+
</>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export default Scrollable;
|