-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
feat(component): Adds SmartScrollbar to ui-next - OHIF-2558 #5924
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
dan-rukas
wants to merge
20
commits into
OHIF:master
Choose a base branch
from
dan-rukas:feat/smart-scrollbar
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 455256e
Added warning when indicator is missing
dan-rukas 12c062a
remove unused svgIdPrefix from context
dan-rukas ca2e29b
clamp contiguous run lengths to prevent fill overflow
dan-rukas 5154700
use callback ref for stable layer to fix first-render timing
dan-rukas fd2cf1b
add keyboard navigation for WAI-ARIA slider compliance
dan-rukas 25d9ef4
Added missing React imports.
jbocce 8389296
For SmartScrollbarTrack, after the loading pattern is faded out, inst…
jbocce e33d47e
Made keyboard navigation optional.
jbocce fcb2b29
Merge remote-tracking branch 'origin/master' into feat/smart-scrollbar
jbocce 447fd2f
Remove redundant data-scrollbar-track attribute — all track divs shar…
jbocce 883564a
Split SmartScrollbar context into layout and scroll contexts to avoid…
jbocce a388084
Eliminate use of slices in SmartScrollbar property and variable names.
jbocce f4ab073
Throw error instead of console.warn when SmartScrollbarIndicator is m…
dan-rukas ec15f20
Merge remote feat/smart-scrollbar with indicator validation change
dan-rukas 778affa
Replace Set with Uint8Array in SmartScrollbar components.
jbocce bc80fae
Render SmartScrollbar fill/endpoints in pixel space and align indicat…
jbocce cd70261
Minor changes to SmartScrollbar components.
jbocce 1ae805a
Add unit tests for SmartScrollbar utils functions computePixelFilledF…
jbocce a62f792
SmartScrollbar: use stable fill keys and expand pixel-fill rounding t…
jbocce File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/'], | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
292 changes: 292 additions & 0 deletions
292
platform/ui-next/src/components/SmartScrollbar/SmartScrollbar.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.