Skip to content

Commit 1227f86

Browse files
zombieJclaude
andcommitted
feat: add touch scroll support for stack notifications
- Implement touch event handlers (touchStart, touchMove, touchEnd) in useListScroll - Add touch scroll interaction for mobile devices - Include unit test for touch scroll functionality Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bcba5d0 commit 1227f86

File tree

3 files changed

+101
-5
lines changed

3 files changed

+101
-5
lines changed

src/NotificationList.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,8 @@ const NotificationList: React.FC<NotificationListProps> = (props) => {
9090

9191
const [gap, setGap] = React.useState(0);
9292
const [notificationPosition, setNodeSize] = useListPosition(configList, stackPosition, gap);
93-
const { contentRef, onWheel, scrollOffset, viewportRef } = useListScroll(
94-
keyList,
95-
notificationPosition,
96-
);
93+
const { contentRef, onTouchEnd, onTouchMove, onTouchStart, onWheel, scrollOffset, viewportRef } =
94+
useListScroll(keyList, notificationPosition);
9795

9896
React.useEffect(() => {
9997
const listNode = contentRef.current;
@@ -124,6 +122,9 @@ const NotificationList: React.FC<NotificationListProps> = (props) => {
124122
[`${prefixCls}-stack-expanded`]: expanded,
125123
},
126124
)}
125+
onTouchEnd={onTouchEnd}
126+
onTouchMove={onTouchMove}
127+
onTouchStart={onTouchStart}
127128
onWheel={onWheel}
128129
onMouseEnter={() => {
129130
setListHovering(true);

src/hooks/useListScroll.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ function getViewportInnerHeight(node: HTMLDivElement | null) {
1111
}
1212

1313
const { paddingBottom, paddingTop } = window.getComputedStyle(node);
14+
const topPadding = parseFloat(paddingTop) || 0;
15+
const bottomPadding = parseFloat(paddingBottom) || 0;
1416

15-
return node.clientHeight - parseFloat(paddingTop) - parseFloat(paddingBottom);
17+
return node.clientHeight - topPadding - bottomPadding;
1618
}
1719

1820
function getMaxScroll(viewportNode: HTMLDivElement | null, contentNode: HTMLDivElement | null) {
@@ -28,6 +30,8 @@ export default function useListScroll(
2830
) {
2931
const viewportRef = React.useRef<HTMLDivElement>(null);
3032
const contentRef = React.useRef<HTMLDivElement>(null);
33+
const touchStartYRef = React.useRef<number | null>(null);
34+
const touchStartOffsetRef = React.useRef(0);
3135
const prevKeyListRef = React.useRef<string[]>(keyList);
3236
const prevNotificationPositionRef = React.useRef<Map<string, NodePosition>>(new Map());
3337
const scrollOffsetRef = React.useRef(0);
@@ -107,8 +111,50 @@ export default function useListScroll(
107111
[syncScrollOffset],
108112
);
109113

114+
const onTouchStart = React.useCallback((event: React.TouchEvent<HTMLDivElement>) => {
115+
const touch = event.touches[0];
116+
117+
if (!touch) {
118+
return;
119+
}
120+
121+
touchStartYRef.current = touch.clientY;
122+
touchStartOffsetRef.current = scrollOffsetRef.current;
123+
}, []);
124+
125+
const onTouchMove = React.useCallback(
126+
(event: React.TouchEvent<HTMLDivElement>) => {
127+
const touch = event.touches[0];
128+
const touchStartY = touchStartYRef.current;
129+
const maxScroll = getMaxScroll(viewportRef.current, contentRef.current);
130+
131+
if (!touch || touchStartY === null || !maxScroll) {
132+
return;
133+
}
134+
135+
event.preventDefault();
136+
137+
const nextOffset = clampScrollOffset(
138+
touchStartOffsetRef.current + touch.clientY - touchStartY,
139+
maxScroll,
140+
);
141+
142+
if (nextOffset !== scrollOffsetRef.current) {
143+
syncScrollOffset(nextOffset);
144+
}
145+
},
146+
[syncScrollOffset],
147+
);
148+
149+
const onTouchEnd = React.useCallback(() => {
150+
touchStartYRef.current = null;
151+
}, []);
152+
110153
return {
111154
contentRef,
155+
onTouchEnd,
156+
onTouchMove,
157+
onTouchStart,
112158
onWheel,
113159
scrollOffset,
114160
viewportRef,

tests/stack.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,53 @@ describe('stack', () => {
230230
getComputedStyleSpy.mockRestore();
231231
offsetHeightSpy.mockRestore();
232232
});
233+
234+
it('supports touch scroll on mobile', () => {
235+
const clientHeightSpy = vi
236+
.spyOn(HTMLElement.prototype, 'clientHeight', 'get')
237+
.mockImplementation(function mockClientHeight() {
238+
if (this.classList?.contains('rc-notification-list')) {
239+
return 120;
240+
}
241+
242+
return 0;
243+
});
244+
const scrollHeightSpy = vi
245+
.spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
246+
.mockImplementation(function mockScrollHeight() {
247+
if (this.classList?.contains('rc-notification-list-content')) {
248+
return 300;
249+
}
250+
251+
return 0;
252+
});
253+
254+
render(
255+
<NotificationList
256+
placement="topRight"
257+
configList={Array.from({ length: 5 }, (_, index) => ({
258+
key: index,
259+
description: `Notice ${index}`,
260+
duration: false,
261+
}))}
262+
/>,
263+
);
264+
265+
const list = document.querySelector<HTMLElement>('.rc-notification-list');
266+
const content = document.querySelector<HTMLElement>('.rc-notification-list-content');
267+
268+
fireEvent.touchStart(list!, {
269+
touches: [{ clientY: 120 }],
270+
});
271+
fireEvent.touchMove(list!, {
272+
touches: [{ clientY: 60 }],
273+
});
274+
275+
expect(content?.style.transform).toBe('translate3d(0, -60px, 0)');
276+
277+
fireEvent.touchEnd(list!);
278+
279+
clientHeightSpy.mockRestore();
280+
scrollHeightSpy.mockRestore();
281+
});
233282
});

0 commit comments

Comments
 (0)