Skip to content

Commit 085deca

Browse files
dan-rukasjbocce
authored andcommitted
feat(component): Adds SmartScrollbar to ui-next - OHIF-2558 (#5924)
Co-authored-by: Joe Boccanfuso <joe.boccanfuso@radicalimaging.com>
1 parent bbf9628 commit 085deca

12 files changed

Lines changed: 953 additions & 1 deletion

platform/ui-next/jest.config.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
const base = require('../../jest.config.base.js');
2+
const pkg = require('./package');
3+
4+
module.exports = {
5+
...base,
6+
displayName: pkg.name,
7+
8+
// Override the base setting that transforms node_modules.
9+
transformIgnorePatterns: ['/node_modules/'],
10+
};

platform/ui-next/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"start": "yarn run build --watch",
1818
"dev": "cross-env NODE_ENV=development webpack serve --config .webpack/webpack.playground.js",
1919
"test": "echo \"Error: no test specified\" && exit 1",
20+
"test:unit": "jest --watchAll",
21+
"test:unit:ci": "jest --ci --runInBand --collectCoverage",
2022
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
2123
"build:package": "yarn run build"
2224
},
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import React, {
2+
createContext,
3+
useContext,
4+
useState,
5+
useEffect,
6+
useRef,
7+
useCallback,
8+
useMemo,
9+
Children,
10+
isValidElement,
11+
} from 'react';
12+
import { getIndicatorLayout } from './utils';
13+
import { SmartScrollbarIndicator } from './SmartScrollbarIndicator';
14+
15+
// ── Child validation ────────────────────────────────────────────
16+
function validateChildren(children: React.ReactNode): void {
17+
let hasIndicator = false;
18+
19+
Children.forEach(children, child => {
20+
if (!isValidElement(child)) return;
21+
if (child.type === SmartScrollbarIndicator) hasIndicator = true;
22+
});
23+
24+
if (!hasIndicator) {
25+
throw new Error(
26+
'SmartScrollbar: <SmartScrollbarIndicator> is a required child. ' +
27+
'Users will not see their current scroll position without it.'
28+
);
29+
}
30+
}
31+
32+
// ── Layout and timing constants ─────────────────────────────────
33+
const TRACK_WIDTH = 8;
34+
const RESTING_WIDTH = 4;
35+
const FILL_PADDING = 3;
36+
const INDICATOR_SIZE = 8;
37+
const INDICATOR_BORDER_WIDTH = 1;
38+
const SETTLE_DELAY = 600;
39+
40+
// ── Contexts ───────────────────────────────────────────────────
41+
export interface SmartScrollbarLayoutContextValue {
42+
total: number;
43+
trackHeight: number;
44+
isLoading: boolean;
45+
effectiveWidth: number;
46+
trackWidth: number;
47+
fillPadding: number;
48+
stableLayerEl: HTMLDivElement | null;
49+
}
50+
51+
const SmartScrollbarLayoutContext = createContext<SmartScrollbarLayoutContextValue | null>(null);
52+
const SmartScrollbarScrollContext = createContext<number | null>(null);
53+
54+
export function useSmartScrollbarLayoutContext(): SmartScrollbarLayoutContextValue {
55+
const ctx = useContext(SmartScrollbarLayoutContext);
56+
if (!ctx)
57+
throw new Error('SmartScrollbar compound components must be used inside <SmartScrollbar>');
58+
return ctx;
59+
}
60+
61+
export function useSmartScrollbarScrollContext(): number {
62+
const value = useContext(SmartScrollbarScrollContext);
63+
if (value === null)
64+
throw new Error('SmartScrollbar compound components must be used inside <SmartScrollbar>');
65+
return value;
66+
}
67+
68+
// ── Props ──────────────────────────────────────────────────────
69+
interface SmartScrollbarProps {
70+
value: number;
71+
total: number;
72+
onValueChange: (index: number) => void;
73+
isLoading?: boolean;
74+
enableKeyboardNavigation?: boolean;
75+
'aria-label'?: string;
76+
className?: string;
77+
children: React.ReactNode;
78+
}
79+
80+
// ── Component ──────────────────────────────────────────────────
81+
export function SmartScrollbar({
82+
value,
83+
total,
84+
onValueChange,
85+
isLoading = false,
86+
enableKeyboardNavigation = false,
87+
'aria-label': ariaLabel = 'Scroll position',
88+
className,
89+
children,
90+
}: SmartScrollbarProps) {
91+
validateChildren(children);
92+
93+
// ── ResizeObserver for trackHeight ───────────────────────────
94+
const containerRef = useRef<HTMLDivElement>(null);
95+
const [trackHeight, setTrackHeight] = useState(0);
96+
97+
useEffect(() => {
98+
const el = containerRef.current;
99+
if (!el) return;
100+
const ro = new ResizeObserver(([entry]) => {
101+
setTrackHeight(entry.contentRect.height);
102+
});
103+
ro.observe(el);
104+
return () => ro.disconnect();
105+
}, []);
106+
107+
// ── Contraction state ────────────────────────────────────────
108+
const [isHovered, setIsHovered] = useState(false);
109+
const [isDragging, setIsDragging] = useState(false);
110+
const isDraggingRef = useRef(false);
111+
const trackTopRef = useRef(0);
112+
113+
// Settle delay — only contract after a real loading→done transition
114+
const [hasSettled, setHasSettled] = useState(false);
115+
const wasEverLoading = useRef(false);
116+
117+
useEffect(() => {
118+
if (isLoading) {
119+
wasEverLoading.current = true;
120+
setHasSettled(false);
121+
} else if (wasEverLoading.current) {
122+
const timer = setTimeout(() => setHasSettled(true), SETTLE_DELAY);
123+
return () => clearTimeout(timer);
124+
}
125+
}, [isLoading]);
126+
127+
const isExpanded = !hasSettled || isHovered || isDragging;
128+
const effectiveWidth = isExpanded ? TRACK_WIDTH : RESTING_WIDTH;
129+
130+
// ── Hit zone extension ───────────────────────────────────────
131+
const { leftPos } = getIndicatorLayout(TRACK_WIDTH, INDICATOR_SIZE, INDICATOR_BORDER_WIDTH);
132+
const hitZoneLeftExtension = Math.max(0, -leftPos);
133+
134+
// ── Stable layer (for elements that shouldn't move during contraction) ──
135+
// Uses useState + callback ref so React triggers a re-render when the
136+
// DOM node mounts — ensuring endpoints render on the first valid pass.
137+
const [stableLayerEl, setStableLayerEl] = useState<HTMLDivElement | null>(null);
138+
139+
// ── Pointer helpers ──────────────────────────────────────────
140+
const clamp = useCallback(
141+
(val: number) => Math.max(0, Math.min(total - 1, val)),
142+
[total]
143+
);
144+
145+
const indexFromPointerY = useCallback(
146+
(clientY: number) => {
147+
const ratio = Math.max(0, Math.min(1, (clientY - trackTopRef.current) / trackHeight));
148+
return Math.round(ratio * (total - 1));
149+
},
150+
[trackHeight, total]
151+
);
152+
153+
const handlePointerDown = useCallback(
154+
(e: React.PointerEvent) => {
155+
trackTopRef.current = e.currentTarget.getBoundingClientRect().top;
156+
157+
isDraggingRef.current = true;
158+
setIsDragging(true);
159+
e.currentTarget.setPointerCapture(e.pointerId);
160+
161+
onValueChange(clamp(indexFromPointerY(e.clientY)));
162+
},
163+
[clamp, indexFromPointerY, onValueChange]
164+
);
165+
166+
const handlePointerMove = useCallback(
167+
(e: React.PointerEvent) => {
168+
if (!isDraggingRef.current) return;
169+
onValueChange(clamp(indexFromPointerY(e.clientY)));
170+
},
171+
[clamp, indexFromPointerY, onValueChange]
172+
);
173+
174+
const handlePointerUp = useCallback((e: React.PointerEvent) => {
175+
isDraggingRef.current = false;
176+
setIsDragging(false);
177+
e.currentTarget.releasePointerCapture(e.pointerId);
178+
}, []);
179+
180+
// ── Keyboard interaction (WAI-ARIA slider spec) ────────────
181+
const PAGE_STEP = 10;
182+
183+
const handleKeyDown = useCallback(
184+
(e: React.KeyboardEvent) => {
185+
let next: number | null = null;
186+
187+
switch (e.key) {
188+
case 'ArrowUp':
189+
case 'ArrowLeft':
190+
next = value - 1;
191+
break;
192+
case 'ArrowDown':
193+
case 'ArrowRight':
194+
next = value + 1;
195+
break;
196+
case 'PageUp':
197+
next = value - PAGE_STEP;
198+
break;
199+
case 'PageDown':
200+
next = value + PAGE_STEP;
201+
break;
202+
case 'Home':
203+
next = 0;
204+
break;
205+
case 'End':
206+
next = total - 1;
207+
break;
208+
default:
209+
return;
210+
}
211+
212+
e.preventDefault();
213+
onValueChange(clamp(next));
214+
},
215+
[value, total, clamp, onValueChange]
216+
);
217+
218+
// ── Context values ───────────────────────────────────────────
219+
const layoutCtx = useMemo<SmartScrollbarLayoutContextValue>(() => ({
220+
total,
221+
trackHeight,
222+
isLoading,
223+
effectiveWidth,
224+
trackWidth: TRACK_WIDTH,
225+
fillPadding: FILL_PADDING,
226+
stableLayerEl,
227+
}), [total, trackHeight, isLoading, effectiveWidth, stableLayerEl]);
228+
return (
229+
<SmartScrollbarLayoutContext.Provider value={layoutCtx}>
230+
<SmartScrollbarScrollContext.Provider value={value}>
231+
<div
232+
ref={containerRef}
233+
role="slider"
234+
aria-valuenow={value}
235+
aria-valuemin={0}
236+
aria-valuemax={total - 1}
237+
aria-orientation="vertical"
238+
aria-label={ariaLabel}
239+
tabIndex={0}
240+
className={className}
241+
style={{
242+
width: TRACK_WIDTH + hitZoneLeftExtension,
243+
height: '100%',
244+
position: 'relative',
245+
marginLeft: -hitZoneLeftExtension,
246+
cursor: isDragging ? 'grabbing' : 'grab',
247+
touchAction: 'none',
248+
}}
249+
onPointerEnter={() => setIsHovered(true)}
250+
onPointerLeave={() => setIsHovered(false)}
251+
onPointerDown={handlePointerDown}
252+
onPointerMove={handlePointerMove}
253+
onPointerUp={handlePointerUp}
254+
onPointerCancel={handlePointerUp}
255+
onKeyDown={enableKeyboardNavigation ? handleKeyDown : undefined}
256+
>
257+
{trackHeight > 0 && (
258+
<div
259+
style={{
260+
position: 'absolute',
261+
right: 0,
262+
top: 0,
263+
width: TRACK_WIDTH,
264+
height: trackHeight,
265+
display: 'flex',
266+
justifyContent: 'center',
267+
}}
268+
>
269+
<div
270+
className="relative"
271+
style={{
272+
width: effectiveWidth,
273+
height: trackHeight,
274+
transition: 'width 300ms ease',
275+
}}
276+
>
277+
{children}
278+
</div>
279+
{/* Stable layer — always TRACK_WIDTH, never contracts. For elements like
280+
endpoints that must not jitter during width transitions. Children
281+
render here via createPortal using stableLayerRef from context. */}
282+
<div
283+
ref={setStableLayerEl}
284+
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
285+
/>
286+
</div>
287+
)}
288+
</div>
289+
</SmartScrollbarScrollContext.Provider>
290+
</SmartScrollbarLayoutContext.Provider>
291+
);
292+
}

0 commit comments

Comments
 (0)