-
Notifications
You must be signed in to change notification settings - Fork 179
/
useCarouselScroll.ts
202 lines (177 loc) · 5.75 KB
/
useCarouselScroll.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
/*
* Copyright 2020 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* External dependencies
*/
import {
useCallback,
useRef,
useState,
useEffect,
useLayoutEffect,
} from '@googleforcreators/react';
/**
* Internal dependencies
*/
import { useConfig } from '../../../../app/config';
import { useStory } from '../../../../app/story';
import { useLayout } from '../../../../app/layout';
import { CarouselState } from '../../../../constants';
interface UseCarouselScrollProps {
listElement: HTMLElement | null;
carouselWidth: number;
hasOverflow: boolean;
showablePages: number;
pageThumbWidth: number;
pageThumbMargin: number;
}
function useCarouselScroll({
listElement,
carouselWidth,
hasOverflow,
showablePages,
pageThumbWidth,
pageThumbMargin,
}: UseCarouselScrollProps) {
const [ratio, setRatio] = useState(0);
const { isRTL } = useConfig();
const { carouselState } = useLayout(({ state: { carouselState } }) => ({
carouselState,
}));
const { currentPageIndex } = useStory(
({ state: { currentPageId, pages } }) => ({
currentPageIndex: pages.findIndex(({ id }) => id === currentPageId),
})
);
const scroll = useCallback(
(offset: number) => {
if (!listElement) {
return;
}
if (isRTL) {
offset *= -1;
}
if (!listElement.scrollBy) {
listElement.scrollLeft += offset;
return;
}
listElement.scrollBy({
left: offset,
behavior: 'smooth',
});
},
[listElement, isRTL]
);
const scrollByPx = carouselWidth;
const isAtStart = isRTL ? 0 === ratio : 0 === ratio;
const isAtEnd = isRTL ? -1 === ratio : 1 === ratio;
const canScrollBack = hasOverflow && !isAtStart;
const canScrollForward = hasOverflow && !isAtEnd;
const scrollForward = useCallback(
() => scroll(scrollByPx),
[scroll, scrollByPx]
);
const scrollBack = useCallback(
() => scroll(-scrollByPx),
[scroll, scrollByPx]
);
// This effects handles setting the scroll ratio, which is need to
// enable and disable the scroll arrows correctly.
useLayoutEffect(() => {
if (!hasOverflow || !listElement) {
return undefined;
}
const handleScroll = () => {
const { offsetWidth, scrollLeft, scrollWidth } = listElement;
const max = scrollWidth - offsetWidth;
setRatio(scrollLeft / max);
};
listElement.addEventListener('scroll', handleScroll, { passive: true });
return () => listElement.removeEventListener('scroll', handleScroll);
}, [listElement, hasOverflow]);
const getOffsetFromIndex = useCallback(
(index) => (pageThumbWidth + pageThumbMargin) * index,
[pageThumbWidth, pageThumbMargin]
);
// Is this the first scroll (which will be instant rather than animated)?
const firstScroll = useRef(true);
// If the carousel drawer is collapsed, reset first scroll to true
useEffect(() => {
if (carouselState === CarouselState.Closed) {
firstScroll.current = true;
}
}, [carouselState]);
// This effect makes sure, that whenever the current page changes, it'll be in focus
// Note that it doesn't run just because the current page is updated (some element
// added or removed to page). Only when the actual index for the current page is updated
// (because a page is added or deleted or user navigate to another page), this runs.
useLayoutEffect(() => {
if (!hasOverflow || !listElement) {
return undefined;
}
const { scrollLeft } = listElement;
// If scrolled to the start, what is the min index of the first visible page
// in order for the current page to be at the very end of the current scoll
const minVisiblePageIndex = Math.max(
0,
currentPageIndex - showablePages + 1
);
// If scrolled to the very end, the current page must be the first visible page
const maxVisiblePageIndex = currentPageIndex;
// Convert these to scroll numbers
const minOffset = getOffsetFromIndex(minVisiblePageIndex);
const maxOffset = getOffsetFromIndex(maxVisiblePageIndex);
if (scrollLeft >= minOffset && scrollLeft <= maxOffset) {
// Page is already visible
return undefined;
}
// Scroll so that current page is visible with least amount of delta needed
const targetLeft = scrollLeft < minOffset ? minOffset : maxOffset;
const doScroll = () => {
// However, if at the end of container, new page might not have been added yet.
const maxScroll = listElement.scrollWidth - listElement.offsetWidth;
// If so, wait a bit
if (maxScroll < targetLeft) {
return;
}
// If this is the first scroll, jump instantly to the target offset
const scrollBehavior = firstScroll.current ? 'auto' : 'smooth';
firstScroll.current = false;
// Otherwise, do scroll and cancel interval
listElement.scrollTo({
left: targetLeft,
top: 0,
behavior: scrollBehavior,
});
clearInterval(retry);
};
const retry = setInterval(doScroll, 100);
return () => clearInterval(retry);
}, [
listElement,
hasOverflow,
currentPageIndex,
showablePages,
getOffsetFromIndex,
]);
return {
canScrollBack,
canScrollForward,
scrollForward,
scrollBack,
};
}
export default useCarouselScroll;