Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
98eca61
Add SmartScrollbar component to ui-next
dan-rukas Mar 25, 2026
455256e
Added warning when indicator is missing
dan-rukas Mar 26, 2026
12c062a
remove unused svgIdPrefix from context
dan-rukas Mar 26, 2026
ca2e29b
clamp contiguous run lengths to prevent fill overflow
dan-rukas Mar 26, 2026
5154700
use callback ref for stable layer to fix first-render timing
dan-rukas Mar 26, 2026
fd2cf1b
add keyboard navigation for WAI-ARIA slider compliance
dan-rukas Mar 26, 2026
25d9ef4
Added missing React imports.
jbocce Mar 27, 2026
8389296
For SmartScrollbarTrack, after the loading pattern is faded out, inst…
jbocce Mar 27, 2026
e33d47e
Made keyboard navigation optional.
jbocce Mar 27, 2026
fcb2b29
Merge remote-tracking branch 'origin/master' into feat/smart-scrollbar
jbocce Mar 29, 2026
447fd2f
Remove redundant data-scrollbar-track attribute — all track divs shar…
jbocce Mar 29, 2026
883564a
Split SmartScrollbar context into layout and scroll contexts to avoid…
jbocce Mar 30, 2026
a388084
Eliminate use of slices in SmartScrollbar property and variable names.
jbocce Mar 31, 2026
f4ab073
Throw error instead of console.warn when SmartScrollbarIndicator is m…
dan-rukas Mar 31, 2026
ec15f20
Merge remote feat/smart-scrollbar with indicator validation change
dan-rukas Mar 31, 2026
778affa
Replace Set with Uint8Array in SmartScrollbar components.
jbocce Mar 31, 2026
bc80fae
Render SmartScrollbar fill/endpoints in pixel space and align indicat…
jbocce Apr 1, 2026
cd70261
Minor changes to SmartScrollbar components.
jbocce Apr 1, 2026
1ae805a
Add unit tests for SmartScrollbar utils functions computePixelFilledF…
jbocce Apr 1, 2026
a62f792
SmartScrollbar: use stable fill keys and expand pixel-fill rounding t…
jbocce Apr 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions platform/ui-next/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const base = require('../../jest.config.base.js');
const pkg = require('./package');

module.exports = {
...base,
displayName: pkg.name,

// Override the base setting that transforms node_modules.
transformIgnorePatterns: ['/node_modules/'],
};
2 changes: 2 additions & 0 deletions platform/ui-next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"start": "yarn run build --watch",
"dev": "cross-env NODE_ENV=development webpack serve --config .webpack/webpack.playground.js",
"test": "echo \"Error: no test specified\" && exit 1",
"test:unit": "jest --watchAll",
"test:unit:ci": "jest --ci --runInBand --collectCoverage",
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package": "yarn run build"
},
Expand Down
292 changes: 292 additions & 0 deletions platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
import React, {
createContext,
useContext,
useState,
useEffect,
useRef,
useCallback,
useMemo,
Children,
isValidElement,
} from 'react';
import { getIndicatorLayout } from './utils';
import { SmartScrollbarIndicator } from './SmartScrollbarIndicator';

// ── Child validation ────────────────────────────────────────────
function validateChildren(children: React.ReactNode): void {
let hasIndicator = false;

Children.forEach(children, child => {
if (!isValidElement(child)) return;
if (child.type === SmartScrollbarIndicator) hasIndicator = true;
});

if (!hasIndicator) {
throw new Error(
'SmartScrollbar: <SmartScrollbarIndicator> is a required child. ' +
'Users will not see their current scroll position without it.'
);
}
}

// ── Layout and timing constants ─────────────────────────────────
const TRACK_WIDTH = 8;
const RESTING_WIDTH = 4;
const FILL_PADDING = 3;
const INDICATOR_SIZE = 8;
const INDICATOR_BORDER_WIDTH = 1;
const SETTLE_DELAY = 600;

// ── Contexts ───────────────────────────────────────────────────
export interface SmartScrollbarLayoutContextValue {
total: number;
trackHeight: number;
isLoading: boolean;
effectiveWidth: number;
trackWidth: number;
fillPadding: number;
stableLayerEl: HTMLDivElement | null;
}

const SmartScrollbarLayoutContext = createContext<SmartScrollbarLayoutContextValue | null>(null);
const SmartScrollbarScrollContext = createContext<number | null>(null);

export function useSmartScrollbarLayoutContext(): SmartScrollbarLayoutContextValue {
const ctx = useContext(SmartScrollbarLayoutContext);
if (!ctx)
throw new Error('SmartScrollbar compound components must be used inside <SmartScrollbar>');
return ctx;
}

export function useSmartScrollbarScrollContext(): number {
const value = useContext(SmartScrollbarScrollContext);
if (value === null)
throw new Error('SmartScrollbar compound components must be used inside <SmartScrollbar>');
return value;
}

// ── Props ──────────────────────────────────────────────────────
interface SmartScrollbarProps {
value: number;
total: number;
onValueChange: (index: number) => void;
isLoading?: boolean;
enableKeyboardNavigation?: boolean;
'aria-label'?: string;
className?: string;
children: React.ReactNode;
}

// ── Component ──────────────────────────────────────────────────
export function SmartScrollbar({
value,
total,
onValueChange,
isLoading = false,
enableKeyboardNavigation = false,
'aria-label': ariaLabel = 'Scroll position',
className,
children,
}: SmartScrollbarProps) {
validateChildren(children);

// ── ResizeObserver for trackHeight ───────────────────────────
const containerRef = useRef<HTMLDivElement>(null);
const [trackHeight, setTrackHeight] = useState(0);

useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
setTrackHeight(entry.contentRect.height);
});
ro.observe(el);
return () => ro.disconnect();
}, []);

// ── Contraction state ────────────────────────────────────────
const [isHovered, setIsHovered] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const isDraggingRef = useRef(false);
const trackTopRef = useRef(0);

// Settle delay — only contract after a real loading→done transition
const [hasSettled, setHasSettled] = useState(false);
const wasEverLoading = useRef(false);

useEffect(() => {
if (isLoading) {
wasEverLoading.current = true;
setHasSettled(false);
} else if (wasEverLoading.current) {
const timer = setTimeout(() => setHasSettled(true), SETTLE_DELAY);
return () => clearTimeout(timer);
}
}, [isLoading]);

const isExpanded = !hasSettled || isHovered || isDragging;
const effectiveWidth = isExpanded ? TRACK_WIDTH : RESTING_WIDTH;

// ── Hit zone extension ───────────────────────────────────────
const { leftPos } = getIndicatorLayout(TRACK_WIDTH, INDICATOR_SIZE, INDICATOR_BORDER_WIDTH);
const hitZoneLeftExtension = Math.max(0, -leftPos);

// ── Stable layer (for elements that shouldn't move during contraction) ──
// Uses useState + callback ref so React triggers a re-render when the
// DOM node mounts — ensuring endpoints render on the first valid pass.
const [stableLayerEl, setStableLayerEl] = useState<HTMLDivElement | null>(null);

// ── Pointer helpers ──────────────────────────────────────────
const clamp = useCallback(
(val: number) => Math.max(0, Math.min(total - 1, val)),
[total]
);

const indexFromPointerY = useCallback(
(clientY: number) => {
const ratio = Math.max(0, Math.min(1, (clientY - trackTopRef.current) / trackHeight));
return Math.round(ratio * (total - 1));
},
[trackHeight, total]
);

const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
trackTopRef.current = e.currentTarget.getBoundingClientRect().top;

isDraggingRef.current = true;
setIsDragging(true);
e.currentTarget.setPointerCapture(e.pointerId);

onValueChange(clamp(indexFromPointerY(e.clientY)));
},
[clamp, indexFromPointerY, onValueChange]
);

const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isDraggingRef.current) return;
onValueChange(clamp(indexFromPointerY(e.clientY)));
},
[clamp, indexFromPointerY, onValueChange]
);

const handlePointerUp = useCallback((e: React.PointerEvent) => {
isDraggingRef.current = false;
setIsDragging(false);
e.currentTarget.releasePointerCapture(e.pointerId);
}, []);

// ── Keyboard interaction (WAI-ARIA slider spec) ────────────
const PAGE_STEP = 10;

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
let next: number | null = null;

switch (e.key) {
case 'ArrowUp':
case 'ArrowLeft':
next = value - 1;
break;
case 'ArrowDown':
case 'ArrowRight':
next = value + 1;
break;
case 'PageUp':
next = value - PAGE_STEP;
break;
case 'PageDown':
next = value + PAGE_STEP;
break;
case 'Home':
next = 0;
break;
case 'End':
next = total - 1;
break;
default:
return;
}

e.preventDefault();
onValueChange(clamp(next));
},
[value, total, clamp, onValueChange]
);

// ── Context values ───────────────────────────────────────────
const layoutCtx = useMemo<SmartScrollbarLayoutContextValue>(() => ({
total,
trackHeight,
isLoading,
effectiveWidth,
trackWidth: TRACK_WIDTH,
fillPadding: FILL_PADDING,
stableLayerEl,
}), [total, trackHeight, isLoading, effectiveWidth, stableLayerEl]);
return (
<SmartScrollbarLayoutContext.Provider value={layoutCtx}>
<SmartScrollbarScrollContext.Provider value={value}>
<div
ref={containerRef}
role="slider"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={total - 1}
aria-orientation="vertical"
aria-label={ariaLabel}
tabIndex={0}
className={className}
style={{
width: TRACK_WIDTH + hitZoneLeftExtension,
height: '100%',
position: 'relative',
marginLeft: -hitZoneLeftExtension,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none',
}}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onKeyDown={enableKeyboardNavigation ? handleKeyDown : undefined}
>
{trackHeight > 0 && (
<div
style={{
position: 'absolute',
right: 0,
top: 0,
width: TRACK_WIDTH,
height: trackHeight,
display: 'flex',
justifyContent: 'center',
}}
>
<div
className="relative"
style={{
width: effectiveWidth,
height: trackHeight,
transition: 'width 300ms ease',
}}
>
{children}
</div>
{/* Stable layer — always TRACK_WIDTH, never contracts. For elements like
endpoints that must not jitter during width transitions. Children
render here via createPortal using stableLayerRef from context. */}
<div
ref={setStableLayerEl}
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
/>
</div>
)}
</div>
</SmartScrollbarScrollContext.Provider>
</SmartScrollbarLayoutContext.Provider>
);
}
Loading
Loading