11import * as React from 'react' ;
22import type { NodePosition } from './useListPosition' ;
33
4- function clampScrollOffset ( offset : number , maxScroll : number ) {
5- return Math . min ( 0 , Math . max ( - maxScroll , offset ) ) ;
6- }
7-
8- function getViewportInnerHeight ( node : HTMLDivElement | null ) {
9- if ( ! node ) {
4+ /**
5+ * Measures how much the list content can scroll inside the viewport.
6+ */
7+ function getMaxScroll ( viewportNode : HTMLDivElement | null , contentNode : HTMLDivElement | null ) {
8+ if ( ! viewportNode ) {
109 return 0 ;
1110 }
1211
13- const { paddingBottom, paddingTop } = window . getComputedStyle ( node ) ;
14- const topPadding = parseFloat ( paddingTop ) || 0 ;
15- const bottomPadding = parseFloat ( paddingBottom ) || 0 ;
16-
17- return node . clientHeight - topPadding - bottomPadding ;
18- }
19-
20- function getMaxScroll ( viewportNode : HTMLDivElement | null , contentNode : HTMLDivElement | null ) {
21- const viewportHeight = getViewportInnerHeight ( viewportNode ) ;
22- const measuredContentHeight = contentNode ?. scrollHeight ?? 0 ;
12+ const { paddingBottom, paddingTop } = window . getComputedStyle ( viewportNode ) ;
13+ const viewportHeight =
14+ viewportNode . clientHeight - ( parseFloat ( paddingTop ) || 0 ) - ( parseFloat ( paddingBottom ) || 0 ) ;
2315
24- return Math . max ( measuredContentHeight - viewportHeight , 0 ) ;
16+ return Math . max ( ( contentNode ?. scrollHeight ?? 0 ) - viewportHeight , 0 ) ;
2517}
2618
19+ /**
20+ * Manages wheel and touch scrolling for the notification list.
21+ */
2722export default function useListScroll (
2823 keyList : string [ ] ,
2924 notificationPosition : Map < string , NodePosition > ,
3025 expanded = false ,
3126) {
3227 const viewportRef = React . useRef < HTMLDivElement > ( null ) ;
3328 const contentRef = React . useRef < HTMLDivElement > ( null ) ;
34- const touchStartYRef = React . useRef < number | null > ( null ) ;
35- const touchStartOffsetRef = React . useRef ( 0 ) ;
36- const prevKeyListRef = React . useRef < string [ ] > ( keyList ) ;
37- const prevNotificationPositionRef = React . useRef < Map < string , NodePosition > > ( new Map ( ) ) ;
29+ const touchStartRef = React . useRef < { y : number ; offset : number } | null > ( null ) ;
30+ const prevRef = React . useRef ( { keyList, notificationPosition } ) ;
3831 const scrollOffsetRef = React . useRef ( 0 ) ;
3932 const [ scrollOffset , setScrollOffset ] = React . useState ( 0 ) ;
4033
34+ /**
35+ * Applies the next offset and keeps it within the current scroll bounds.
36+ */
4137 const syncScrollOffset = React . useCallback ( ( nextOffset : number ) => {
4238 const maxScroll = getMaxScroll ( viewportRef . current , contentRef . current ) ;
43- const mergedOffset = clampScrollOffset ( nextOffset , maxScroll ) ;
39+ // Clamp the offset so the content never scrolls past its visible range.
40+ const mergedOffset = Math . max ( - maxScroll , Math . min ( 0 , nextOffset ) ) ;
4441
4542 scrollOffsetRef . current = mergedOffset ;
46- setScrollOffset ( ( prev ) => ( prev === mergedOffset ? prev : mergedOffset ) ) ;
43+ setScrollOffset ( mergedOffset ) ;
4744 } , [ ] ) ;
4845
4946 React . useLayoutEffect ( ( ) => {
50- const prevKeyList = prevKeyListRef . current ;
51- const prevNotificationPosition = prevNotificationPositionRef . current ;
47+ const { keyList : prevKeyList , notificationPosition : prevNotificationPosition } =
48+ prevRef . current ;
49+ let nextOffset = scrollOffsetRef . current ;
5250
53- if ( scrollOffsetRef . current < 0 ) {
54- const prependCount = prevKeyList . length
55- ? keyList . findIndex ( ( key ) => key === prevKeyList [ 0 ] )
56- : - 1 ;
57- const removedCount = keyList . length ? prevKeyList . findIndex ( ( key ) => key === keyList [ 0 ] ) : - 1 ;
51+ if ( nextOffset < 0 ) {
52+ const prevFirstKey = prevKeyList [ 0 ] ;
53+ const firstKey = keyList [ 0 ] ;
54+ const prependCount = prevFirstKey === undefined ? - 1 : keyList . indexOf ( prevFirstKey ) ;
55+ const removedCount = firstKey === undefined ? - 1 : prevKeyList . indexOf ( firstKey ) ;
5856
5957 if ( prependCount > 0 ) {
60- const prependHeight = notificationPosition . get ( prevKeyList [ 0 ] ) ?. y ?? 0 ;
61- syncScrollOffset ( scrollOffsetRef . current - prependHeight ) ;
58+ nextOffset -= notificationPosition . get ( prevFirstKey ) ?. y ?? 0 ;
6259 } else if ( removedCount > 0 ) {
63- const removedHeight = keyList [ 0 ] ? ( prevNotificationPosition . get ( keyList [ 0 ] ) ?. y ?? 0 ) : 0 ;
64- syncScrollOffset ( scrollOffsetRef . current + removedHeight ) ;
65- } else {
66- syncScrollOffset ( scrollOffsetRef . current ) ;
60+ nextOffset += prevNotificationPosition . get ( firstKey ) ?. y ?? 0 ;
6761 }
68- } else {
69- syncScrollOffset ( scrollOffsetRef . current ) ;
7062 }
7163
72- prevKeyListRef . current = keyList ;
73- prevNotificationPositionRef . current = new Map ( notificationPosition ) ;
64+ syncScrollOffset ( nextOffset ) ;
65+ prevRef . current = { keyList , notificationPosition } ;
7466 } , [ keyList , notificationPosition , syncScrollOffset ] ) ;
7567
7668 React . useLayoutEffect ( ( ) => {
@@ -81,82 +73,69 @@ export default function useListScroll(
8173 return ;
8274 }
8375
84- const resizeObserver = new ResizeObserver ( ( ) => {
85- syncScrollOffset ( scrollOffsetRef . current ) ;
86- } ) ;
76+ const resizeObserver = new ResizeObserver ( ( ) => syncScrollOffset ( scrollOffsetRef . current ) ) ;
8777
8878 resizeObserver . observe ( viewportNode ) ;
8979 resizeObserver . observe ( contentNode ) ;
9080
91- return ( ) => {
92- resizeObserver . disconnect ( ) ;
93- } ;
81+ return ( ) => resizeObserver . disconnect ( ) ;
9482 } , [ syncScrollOffset ] ) ;
9583
9684 React . useEffect ( ( ) => {
97- if ( ! expanded ) {
98- touchStartYRef . current = null ;
99- touchStartOffsetRef . current = 0 ;
100- syncScrollOffset ( 0 ) ;
85+ if ( expanded ) {
86+ return ;
10187 }
88+
89+ touchStartRef . current = null ;
90+ syncScrollOffset ( 0 ) ;
10291 } , [ expanded , syncScrollOffset ] ) ;
10392
93+ /**
94+ * Updates the list offset from mouse wheel input.
95+ */
10496 const onWheel = React . useCallback (
10597 ( event : React . WheelEvent < HTMLDivElement > ) => {
106- const maxScroll = getMaxScroll ( viewportRef . current , contentRef . current ) ;
107-
108- if ( ! maxScroll ) {
98+ if ( ! getMaxScroll ( viewportRef . current , contentRef . current ) ) {
10999 return ;
110100 }
111101
112102 event . preventDefault ( ) ;
113-
114- const nextOffset = clampScrollOffset ( scrollOffsetRef . current - event . deltaY , maxScroll ) ;
115-
116- if ( nextOffset !== scrollOffsetRef . current ) {
117- syncScrollOffset ( nextOffset ) ;
118- }
103+ syncScrollOffset ( scrollOffsetRef . current - event . deltaY ) ;
119104 } ,
120105 [ syncScrollOffset ] ,
121106 ) ;
122107
108+ /**
109+ * Stores the touch start position and current offset.
110+ */
123111 const onTouchStart = React . useCallback ( ( event : React . TouchEvent < HTMLDivElement > ) => {
124112 const touch = event . touches [ 0 ] ;
125-
126- if ( ! touch ) {
127- return ;
128- }
129-
130- touchStartYRef . current = touch . clientY ;
131- touchStartOffsetRef . current = scrollOffsetRef . current ;
113+ touchStartRef . current = touch ? { y : touch . clientY , offset : scrollOffsetRef . current } : null ;
132114 } , [ ] ) ;
133115
116+ /**
117+ * Updates the list offset while the user is dragging.
118+ */
134119 const onTouchMove = React . useCallback (
135120 ( event : React . TouchEvent < HTMLDivElement > ) => {
136121 const touch = event . touches [ 0 ] ;
137- const touchStartY = touchStartYRef . current ;
138- const maxScroll = getMaxScroll ( viewportRef . current , contentRef . current ) ;
122+ const touchStart = touchStartRef . current ;
139123
140- if ( ! touch || touchStartY === null || ! maxScroll ) {
124+ if ( ! touch || ! touchStart || ! getMaxScroll ( viewportRef . current , contentRef . current ) ) {
141125 return ;
142126 }
143127
144128 event . preventDefault ( ) ;
145-
146- const nextOffset = clampScrollOffset (
147- touchStartOffsetRef . current + touch . clientY - touchStartY ,
148- maxScroll ,
149- ) ;
150-
151- if ( nextOffset !== scrollOffsetRef . current ) {
152- syncScrollOffset ( nextOffset ) ;
153- }
129+ syncScrollOffset ( touchStart . offset + touch . clientY - touchStart . y ) ;
154130 } ,
155131 [ syncScrollOffset ] ,
156132 ) ;
157133
134+ /**
135+ * Clears the active touch scroll state.
136+ */
158137 const onTouchEnd = React . useCallback ( ( ) => {
159- touchStartYRef . current = null ;
138+ touchStartRef . current = null ;
160139 } , [ ] ) ;
161140
162141 return {
0 commit comments