@hh.ru/magritte-ui-bottom-sheet 4.1.40 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/BottomSheet.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import './index.css';
2
2
  import { jsx, jsxs } from 'react/jsx-runtime';
3
- import { forwardRef, useRef, useState, useEffect, useCallback, useMemo, useLayoutEffect } from 'react';
3
+ import { forwardRef, useRef, useState, useMemo, useEffect, useCallback } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
5
  import { Transition } from 'react-transition-group';
6
6
  import classnames from 'classnames';
7
7
  import { disableOverscroll, disableScroll } from '@hh.ru/magritte-common-modal-helper';
8
8
  import { useMultipleRefs } from '@hh.ru/magritte-common-use-multiple-refs';
9
9
  import { useNoBubbling } from '@hh.ru/magritte-common-use-no-bubbling';
10
- import { useSwipe } from '@hh.ru/magritte-common-use-swipe';
10
+ import { useSwipeHandlers } from '@hh.ru/magritte-common-use-swipe';
11
+ import { initTouchHandlers } from '@hh.ru/magritte-internal-inertial-scroll';
11
12
  import { InternalLayerName } from '@hh.ru/magritte-internal-layer-name';
12
13
  import { BottomSheetContext } from './BottomSheetContext.js';
13
14
  import { ClickInterceptor } from './ClickInterceptor.js';
@@ -15,82 +16,133 @@ import { useBreakpoint } from '@hh.ru/magritte-ui-breakpoint';
15
16
  import { Divider } from '@hh.ru/magritte-ui-divider';
16
17
  import { Layer } from '@hh.ru/magritte-ui-layer';
17
18
  import { NavigationBarContext } from '@hh.ru/magritte-ui-navigation-bar';
18
- import { s as styles } from './bottom-sheet-Uf681RNm.js';
19
+ import { s as styles } from './bottom-sheet-C_sZ1cCA.js';
19
20
 
20
- const CSS_VAR_CONTENT_OVERLAY_TOP = '--content-overlay-top';
21
- const CSS_VAR_CONTENT_OVERLAY_HEIGHT = '--content-overlay-height';
22
- const CSS_VAR_INITIAL_VIEWPORT_HEIGHT = '--initial-viewport-height';
23
21
  const CSS_VAR_ENTER_ANIMATION_DURATION = '--enter-animation-duration';
24
22
  const CSS_VAR_EXIT_ANIMATION_DURATION = '--exit-animation-duration';
25
23
  const CSS_VAR_HEIGHT_ANIMATION_DURATION = '--height-transition-duration';
26
- const CSS_VAR_OVERLAY_OPACITY = '--overlay-opacity';
27
24
  const CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET = '--virtual-keyboard-top-offset';
28
25
  const CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET = '--virtual-keyboard-bottom-offset';
29
- const INITIAL_VIEWPORT_HEIGHT = (typeof window !== 'undefined' ? window.visualViewport?.height : null) ?? 0;
30
26
  const NAVIGATION_BAR_SIZE_OVERRIDE = { size: 'standard' };
31
- const SWIPE_THRESHOLD_REF = { current: { max: Math.round(INITIAL_VIEWPORT_HEIGHT * 0.8) } };
32
- const forceRepaint = (node) => node.scrollTop;
33
- const isSafari = typeof navigator !== 'undefined' && /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
27
+ const hasSelectedText = () => {
28
+ const selection = document.getSelection();
29
+ return !!selection && !selection.isCollapsed;
30
+ };
31
+ const isSafariFunc = () => {
32
+ let lazyValue = null;
33
+ return () => {
34
+ if (lazyValue === null) {
35
+ lazyValue = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
36
+ }
37
+ return lazyValue;
38
+ };
39
+ };
34
40
  const toNumber = (value) => {
35
41
  const result = parseInt(value, 10);
36
42
  return Number.isInteger(result) ? result : 0;
37
43
  };
38
- const changeSwipeProgressState = (state, scrollContainerRef, isSwipeInProgress, allowScrollWhileFocused) => {
39
- if (!state.current) {
40
- return;
41
- }
42
- state.current.isSwipeInProgress = isSwipeInProgress;
43
- if (!scrollContainerRef.current) {
44
- return;
45
- }
46
- scrollContainerRef.current.classList.toggle(styles.scrollContainerNoScroll, isSwipeInProgress || (!allowScrollWhileFocused && state.current.hasFocus));
44
+ const translateY = (value) => `translate3d(0, ${value}px, 0)`;
45
+ const INITIAL_STATE = {
46
+ dividerVisible: false,
47
+ grabberUnsafe: false,
48
+ hasFocus: false,
49
+ resizeRAFHandle: null,
50
+ scrollOffset: 0,
51
+ swipeOffset: 0,
52
+ touchAction: null,
53
+ heightAnimationRunning: false,
47
54
  };
48
- const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, header, height = 'content', interceptClickHandlers = true, keyboardOverlaysContent = true, onAppear, onBeforeExit, onAfterExit, onClose, showDivider = 'always-if-has-scroll', showOverlay = true, visible = false, withContentPaddings = true, }, ref) => {
55
+ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, header, height = 'content', interceptClickHandlers = true, keyboardOverlaysContent = true, onAppear, onBeforeExit, onAfterExit, onClose, showDivider = 'with-scroll', showOverlay = true, visible = false, withContentPaddings = true, }, ref) => {
56
+ const DOCUMENT_HEIGHT = useRef(0);
57
+ const SWIPE_THRESHOLD = useRef({ max: Infinity });
49
58
  const contentRef = useRef(null);
50
59
  const contentOverlayRef = useRef(null);
51
60
  const cssVariablesContainerRef = useRef(null);
52
- const fixedGrabberRef = useRef(null);
61
+ const dividerRef = useRef(null);
53
62
  const footerRef = useRef(null);
63
+ const grabberRef = useRef(null);
54
64
  const headerRef = useRef(null);
55
- const heightTransitionElementRef = useRef(null);
65
+ const overlayRef = useRef(null);
56
66
  const scrollContainerRef = useRef(null);
57
- const stickyGrabberRef = useRef(null);
58
- const scrollContainerRefMulti = useMultipleRefs(scrollContainerRef, ref);
67
+ const swipeContainerRef = useRef(null);
68
+ const visualContainerRef = useRef(null);
69
+ const bottomSheetRef = useMultipleRefs(ref, visualContainerRef);
59
70
  const { isMobile } = useBreakpoint();
60
71
  const currentVisible = isMobile && visible;
61
- const layoutMetricsRef = useRef({
62
- documentHeight: 0,
63
- fillHeight: 0,
64
- grabberSpacing: 0,
65
- viewportShift: 0,
66
- });
67
- const prevPropsRef = useRef({ children, height, visible });
68
- const stateRef = useRef({
69
- grabber: 'sticky',
70
- hasFocus: false,
71
- isSwipeEnabled: false,
72
- isSwipeInProgress: false,
73
- swipeOffset: 0,
74
- resizeRAFHandle: null,
75
- virtualKeyboardHeight: 0,
76
- });
72
+ const onCloseRef = useRef(onClose);
73
+ onCloseRef.current = onClose;
74
+ const stateRef = useRef({ ...INITIAL_STATE });
77
75
  const [animationTimeout, setAnimationTimeout] = useState(null);
78
- const [heightDiff, setHeightDiff] = useState(null);
79
- const [isDividerVisible, setDividerVisible] = useState(false);
80
- const [onCloseContractCheck, setOnCloseContractCheck] = useState(false);
76
+ const [visibleHeightDiff, setVisibleHeightDiff] = useState(null);
77
+ const contextValue = useMemo(() => ({ contentOverlayRef }), [contentOverlayRef]);
78
+ const isSafari = useRef(isSafariFunc()).current;
79
+ const LayoutMetrics = useRef((() => {
80
+ let cachedBottomSheetHeight = null;
81
+ let cachedContentHeight = null;
82
+ let cachedInitialOffset = null;
83
+ let cachedRemainingAvailableHeight = null;
84
+ let cachedScrollContainerHeight = null;
85
+ return {
86
+ get bottomSheetHeight() {
87
+ if (cachedBottomSheetHeight === null && visualContainerRef.current !== null) {
88
+ cachedBottomSheetHeight = visualContainerRef.current.clientHeight;
89
+ }
90
+ return cachedBottomSheetHeight ?? 0;
91
+ },
92
+ get contentHeight() {
93
+ if (cachedContentHeight === null && contentRef.current !== null) {
94
+ cachedContentHeight = contentRef.current.clientHeight;
95
+ }
96
+ return cachedContentHeight ?? 0;
97
+ },
98
+ get initialOffset() {
99
+ if (cachedInitialOffset === null &&
100
+ visualContainerRef.current !== null &&
101
+ visualViewport !== null) {
102
+ cachedInitialOffset =
103
+ height === 'half-screen'
104
+ ? Math.max(Math.round(visualContainerRef.current.clientHeight - visualViewport.height / 2), 0)
105
+ : 0;
106
+ }
107
+ return cachedInitialOffset ?? 0;
108
+ },
109
+ get remainingAvailableHeight() {
110
+ if (cachedRemainingAvailableHeight === null && grabberRef.current !== null) {
111
+ cachedRemainingAvailableHeight = grabberRef.current.offsetTop;
112
+ }
113
+ return cachedRemainingAvailableHeight ?? 0;
114
+ },
115
+ get scrollContainerHeight() {
116
+ if (cachedScrollContainerHeight === null && scrollContainerRef.current !== null) {
117
+ cachedScrollContainerHeight = scrollContainerRef.current.clientHeight;
118
+ }
119
+ return cachedScrollContainerHeight ?? 0;
120
+ },
121
+ invalidateCache() {
122
+ cachedBottomSheetHeight = null;
123
+ cachedContentHeight = null;
124
+ cachedInitialOffset = null;
125
+ cachedRemainingAvailableHeight = null;
126
+ cachedScrollContainerHeight = null;
127
+ },
128
+ };
129
+ })());
81
130
  const setCSSVariable = (name, value) => cssVariablesContainerRef.current !== null && cssVariablesContainerRef.current.style.setProperty(name, value);
82
131
  useEffect(() => {
83
132
  if (!currentVisible) {
84
133
  return void 0;
85
134
  }
86
- layoutMetricsRef.current.documentHeight = document.documentElement.clientHeight;
135
+ DOCUMENT_HEIGHT.current = document.documentElement.clientHeight;
136
+ if (visualViewport !== null) {
137
+ SWIPE_THRESHOLD.current = { max: Math.round(visualViewport.height * 0.8) };
138
+ }
87
139
  if (!showOverlay) {
88
140
  const enableOverscroll = disableOverscroll();
89
141
  return enableOverscroll;
90
142
  }
91
143
  const enableScroll = disableScroll();
92
144
  return enableScroll;
93
- }, [showOverlay, currentVisible]);
145
+ }, [currentVisible, showOverlay]);
94
146
  useEffect(() => {
95
147
  const animationTimeoutElement = document.createElement('div');
96
148
  animationTimeoutElement.classList.add(styles.animationTimeout);
@@ -102,63 +154,60 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
102
154
  document.body.removeChild(animationTimeoutElement);
103
155
  setAnimationTimeout({ appear: { enter, exit }, height: { enter: height, exit } });
104
156
  }, [setAnimationTimeout]);
105
- // Пересчитывает флаги отображения граббера и дивайдера в зависимости от скролла
106
- const recalcScrollFlags = useCallback(() => {
107
- // расчет состояния граббера в зависимости от того, проскроллен ли контент до верхней границы вьюпорта
108
- requestAnimationFrame(() => {
109
- if (fixedGrabberRef.current !== null && stickyGrabberRef.current !== null) {
110
- const stickyGrabberDOMRect = stickyGrabberRef.current.getBoundingClientRect();
111
- const grabberTop = Math.round(stickyGrabberDOMRect.top);
112
- if (grabberTop >= layoutMetricsRef.current.grabberSpacing * 2 || heightDiff !== null) {
113
- if (stateRef.current.grabber !== 'sticky') {
114
- stateRef.current.grabber = 'sticky';
115
- stickyGrabberRef.current.classList.remove(styles.grabberInvisible);
116
- fixedGrabberRef.current.classList.add(styles.grabberInvisible);
117
- fixedGrabberRef.current.classList.remove(styles.grabberFixed, styles.grabberFakeSticky);
118
- fixedGrabberRef.current.style.top = ``;
119
- }
120
- }
121
- else if (grabberTop > layoutMetricsRef.current.grabberSpacing) {
122
- if (stateRef.current.grabber !== 'fake-sticky') {
123
- stateRef.current.grabber = 'fake-sticky';
124
- stickyGrabberRef.current.classList.add(styles.grabberInvisible);
125
- fixedGrabberRef.current.classList.add(styles.grabberFakeSticky);
126
- fixedGrabberRef.current.classList.remove(styles.grabberSticky, styles.grabberInvisible);
127
- }
128
- fixedGrabberRef.current.style.top = `${grabberTop + layoutMetricsRef.current.viewportShift}px`;
129
- }
130
- else if (stateRef.current.grabber !== 'fixed') {
131
- stateRef.current.grabber = 'fixed';
132
- stickyGrabberRef.current.classList.add(styles.grabberInvisible);
133
- fixedGrabberRef.current.classList.add(styles.grabberFixed);
134
- fixedGrabberRef.current.classList.remove(styles.grabberFakeSticky, styles.grabberInvisible);
135
- fixedGrabberRef.current.style.top = ``;
136
- }
157
+ useEffect(() => {
158
+ if (!currentVisible || !contentRef.current) {
159
+ return void 0;
160
+ }
161
+ // при изменении высоты контента анимируем ее
162
+ // самому боттомшиту задаем фиксированную высоту и пересчитываем ее самостоятельно,
163
+ // чтобы не было мерцания, когда новый контент отрисовался до срабатывания колбека ResizeObserver
164
+ let prevContentHeight = LayoutMetrics.current.contentHeight;
165
+ if (visualContainerRef.current !== null) {
166
+ visualContainerRef.current.style.height = `${LayoutMetrics.current.bottomSheetHeight}px`;
167
+ }
168
+ const observer = new ResizeObserver(() => {
169
+ if (stateRef.current.heightAnimationRunning) {
170
+ return;
137
171
  }
138
- });
139
- // расчет того, нужно ли показывать дивайдер, в зависимости от того, проскроллен ли контент до конца
140
- if (contentRef.current !== null && scrollContainerRef.current !== null) {
141
- if (footer && (showDivider === 'always-if-has-scroll' || showDivider === 'if-not-scrolled-to-end')) {
142
- const hasScroll = contentRef.current.clientHeight > scrollContainerRef.current.clientHeight + 1;
143
- const isScrolledToContentEnd = Math.round(scrollContainerRef.current.scrollTop + scrollContainerRef.current.clientHeight) ===
144
- scrollContainerRef.current.scrollHeight;
145
- setDividerVisible((showDivider === 'always-if-has-scroll' && hasScroll) ||
146
- (showDivider === 'if-not-scrolled-to-end' && hasScroll && !isScrolledToContentEnd));
172
+ LayoutMetrics.current.invalidateCache();
173
+ // в общем случае видимую высоту контента можно посчитать как min(scrollContainer.height, contentHeight)
174
+ const contentHeightDiff = LayoutMetrics.current.contentHeight - prevContentHeight;
175
+ if (contentHeightDiff > 0) {
176
+ // но если высота контента увеличилась, мы не знаем новую высоту scrollContainer.height
177
+ // т.к. фиксированная высота боттомшита не дает scrollContainer увеличиться
178
+ // но сам боттомшит может увеличиться не больше чем на расстояние между ним и верхним краем экрана
179
+ const heightDiff = Math.round(Math.min(contentHeightDiff, LayoutMetrics.current.remainingAvailableHeight));
180
+ // триггерим анимацию даже при нулевом heightDiff, чтобы сбросить позицию скролла
181
+ setVisibleHeightDiff(heightDiff);
182
+ stateRef.current.heightAnimationRunning = true;
183
+ }
184
+ else if (contentHeightDiff < 0) {
185
+ // если высота контента уменьшилась, новая высота scrollContainer будет такая же или меньше
186
+ // поэтому можем посчитать новую видимую высоту контента и сравнить со старой
187
+ const prevVisibleHeight = Math.min(LayoutMetrics.current.scrollContainerHeight, prevContentHeight);
188
+ const newVisibleHeight = Math.min(LayoutMetrics.current.scrollContainerHeight, LayoutMetrics.current.contentHeight);
189
+ const heightDiff = Math.round(newVisibleHeight - prevVisibleHeight);
190
+ // триггерим анимацию даже при нулевом heightDiff, чтобы сбросить позицию скролла
191
+ setVisibleHeightDiff(heightDiff);
192
+ stateRef.current.heightAnimationRunning = true;
147
193
  }
194
+ prevContentHeight = LayoutMetrics.current.contentHeight;
195
+ });
196
+ observer.observe(contentRef.current);
197
+ return () => observer.disconnect();
198
+ }, [currentVisible, setVisibleHeightDiff]);
199
+ useEffect(() => {
200
+ if (!currentVisible || isSafari()) {
201
+ return;
148
202
  }
149
- }, [footer, heightDiff, setDividerVisible, showDivider]);
150
- const resetScrollFlags = useCallback(() => {
151
- if (fixedGrabberRef.current !== null && stickyGrabberRef.current !== null) {
152
- stateRef.current.grabber = 'sticky';
153
- fixedGrabberRef.current.classList.add(styles.grabberInvisible);
154
- fixedGrabberRef.current.classList.remove(styles.grabberTransitionAnimation);
155
- stickyGrabberRef.current.classList.remove(styles.grabberInvisible);
203
+ // используем Virtual Keyboard API через meta-тег вместо navigator.virtualKeyboard,
204
+ // потому что второй способ работает только на страницах, открытых через HTTPS, что мешает тестированию
205
+ let meta = document.querySelector('meta[name="viewport"]');
206
+ if (!meta) {
207
+ meta = document.createElement('meta');
208
+ meta.setAttribute('name', 'viewport');
209
+ document.head.appendChild(meta);
156
210
  }
157
- setDividerVisible(false);
158
- }, [setDividerVisible]);
159
- const resetHeightDiff = useCallback(() => setHeightDiff(null), [setHeightDiff]);
160
- useEffect(() => {
161
- const meta = document.querySelector('meta[name="viewport"]') ?? document.createElement('meta');
162
211
  const attributesStr = meta.getAttribute('content');
163
212
  const attributes = (attributesStr !== null
164
213
  ? Object.fromEntries(attributesStr.split(',').map((keyValuePairStr) => keyValuePairStr.split('=')))
@@ -167,11 +216,10 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
167
216
  const attributesStrUpdated = Object.entries(attributes)
168
217
  .map((keyValuePair) => keyValuePair.join('='))
169
218
  .join(',');
170
- meta.setAttribute('name', 'viewport');
171
219
  meta.setAttribute('content', attributesStrUpdated);
172
- }, [keyboardOverlaysContent]);
220
+ }, [currentVisible, isSafari, keyboardOverlaysContent]);
173
221
  const recalcKeyboardOffsets = useCallback(() => {
174
- if (!headerRef.current || !scrollContainerRef.current || !visualViewport) {
222
+ if (!headerRef.current || !overlayRef.current || !visualViewport) {
175
223
  return;
176
224
  }
177
225
  if (stateRef.current.hasFocus) {
@@ -182,26 +230,24 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
182
230
  // В нем не нужно ничего корректировать при keyboardOverlaysContent=true,
183
231
  // а при keyboardOverlaysContent=false нужно сдвинуть НИЖНИЙ край контейнера ВВЕРХ,
184
232
  // чтобы он совпал с границей Visual Viewport
185
- // 2. Chrome < 108 & Chromium-based — ресайзит и Visual Viewport, и Layout Viewport
233
+ // 2. Chrome < 108 & Chromium-based — ресайзит и Visual Viewport, и Layout Viewport.
186
234
  // В нем не нужно ничего корректировать при keyboardOverlaysContent=false,
187
235
  // а при keyboardOverlaysContent=true нужно сдвинуть НИЖНИЙ край контейнера ВНИЗ,
188
236
  // чтобы футер уехал под клавиатуру
189
- // 3. Chrome >= 108 — поддерживает Virtual Keyboard API и meta-тег interactive-widget
237
+ // 3. Chrome >= 108 — поддерживает Virtual Keyboard API и meta-тег interactive-widget.
190
238
  // Используем поведение `resizes-visual` (как в Safari) в случае keyboardOverlaysContent=true
191
239
  // и `resizes-content` (как в Chrome < 108 & Chromium-based) в случае keyboardOverlaysContent=false
192
- // Таким образом в нем ничего не нужно корректировать.
193
- const scrollContainerDOMREct = scrollContainerRef.current.getBoundingClientRect();
240
+ // Таким образом в нем ничего не нужно корректировать
241
+ const overlayDOMRect = overlayRef.current.getBoundingClientRect();
194
242
  // любой браузер может сдвинуть Visual Viewport вверх, если фокусируемый инпут находится близко к нижней границе
195
243
  // из-за этого может возникнуть проблема, что ВЕРХНИЙ край контента уехал за границу Visual Viewport
196
244
  // сдвигаем ВЕРХНИЙ край контейнера ВНИЗ, чтобы он совпал с границей Visual Viewport
197
- const visualViewportShift = Math.round(layoutMetricsRef.current.grabberSpacing - scrollContainerDOMREct.top);
198
- // запоминаем сдвиг для коррекции позиции граббера
199
- layoutMetricsRef.current.viewportShift = visualViewportShift;
200
- // клавиатура ПОВЕРХ контента
245
+ const visualViewportShift = Math.round(-overlayDOMRect.top);
246
+ // keyboardOverlaysContent=true, клавиатура ПОВЕРХ контента
201
247
  // этот кейс нужно корректировать только в Chrome < 108 & Chromium-based
202
248
  if (keyboardOverlaysContent) {
203
249
  // браузеры из этой группы меняют Layout Viewport
204
- const layoutViewportDiff = Math.round(layoutMetricsRef.current.documentHeight - document.documentElement.clientHeight);
250
+ const layoutViewportDiff = Math.round(DOCUMENT_HEIGHT.current - document.documentElement.clientHeight);
205
251
  if (layoutViewportDiff > 0) {
206
252
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `${visualViewportShift}px`);
207
253
  // сдвигаем НИЖНИЙ край контейнера ВНИЗ, чтобы футер уехал под клавиатуру
@@ -209,17 +255,16 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
209
255
  // при этом может возникнуть проблема, что клавиатура перекрыла хедер
210
256
  // проверяем это и компенсируем величину перекрытия при необходимости
211
257
  const headerDOMRect = headerRef.current.getBoundingClientRect();
212
- const headerOutOfViewportHeight = Math.round(headerDOMRect.bottom + layoutViewportDiff - layoutMetricsRef.current.documentHeight);
258
+ const headerOutOfViewportHeight = Math.round(headerDOMRect.bottom + layoutViewportDiff - DOCUMENT_HEIGHT.current);
213
259
  if (headerOutOfViewportHeight > 0) {
214
260
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `${headerOutOfViewportHeight - layoutViewportDiff}px`);
215
261
  }
216
262
  }
217
263
  }
218
- else {
219
- // клавиатура ПОД контентом
220
- // этот кейс нужно корректировать только в Safari
221
- // Safari ресайзит Visual Viewport
222
- const visualViewportDiff = Math.round(scrollContainerDOMREct.bottom - visualViewport.height);
264
+ // keyboardOverlaysContent=false, клавиатура ПОД контентом
265
+ // этот кейс нужно корректировать только в Safari
266
+ if (!keyboardOverlaysContent && isSafari()) {
267
+ const visualViewportDiff = Math.round(overlayDOMRect.bottom - visualViewport.height);
223
268
  if (visualViewportDiff > 0) {
224
269
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `${visualViewportShift}px`);
225
270
  // сдвигаем НИЖНИЙ край контейнера ВВЕРХ, чтобы он совпал с границей Visual Viewport
@@ -231,11 +276,8 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
231
276
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `0px`);
232
277
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `0px`);
233
278
  }
234
- }, [keyboardOverlaysContent]);
279
+ }, [isSafari, keyboardOverlaysContent]);
235
280
  const handleFocus = useCallback((event) => {
236
- if (!scrollContainerRef.current) {
237
- return;
238
- }
239
281
  const focusedElement = event.target;
240
282
  const initialViewportHeight = visualViewport?.height;
241
283
  const resizeRAFStart = performance.now();
@@ -248,7 +290,12 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
248
290
  stateRef.current.resizeRAFHandle = null;
249
291
  }
250
292
  recalcKeyboardOffsets();
293
+ if (!stateRef.current.hasFocus) {
294
+ visualViewport?.removeEventListener('resize', handleResize);
295
+ }
251
296
  };
297
+ // если спамить фокус/блюр инпута, ивент visualViewport.resize может не долететь
298
+ // поэтому проверяем изменение высоты в рекурсивном RAF
252
299
  const waitForResize = () => {
253
300
  if (performance.now() - resizeRAFStart > 1000 || visualViewport?.height !== initialViewportHeight) {
254
301
  visualViewport?.removeEventListener('resize', handleResize);
@@ -260,292 +307,285 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
260
307
  }
261
308
  };
262
309
  const handleBlur = () => {
263
- if (!scrollContainerRef.current) {
264
- return;
265
- }
266
- focusedElement.removeEventListener('blur', handleBlur);
267
- stateRef.current.hasFocus = false;
268
- scrollContainerRef.current.classList.remove(styles.scrollContainerNoScroll, styles.virtualKeyboardAnimation);
269
- if (isSafari) {
310
+ if (isSafari()) {
270
311
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `0px`);
271
312
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `0px`);
272
313
  }
314
+ stateRef.current.hasFocus = false;
273
315
  if (stateRef.current.resizeRAFHandle !== null) {
274
316
  cancelAnimationFrame(stateRef.current.resizeRAFHandle);
275
317
  stateRef.current.resizeRAFHandle = null;
276
318
  }
277
- visualViewport?.removeEventListener('resize', handleResize);
319
+ focusedElement.removeEventListener('blur', handleBlur);
278
320
  };
279
- focusedElement.addEventListener('blur', handleBlur);
280
321
  stateRef.current.hasFocus = true;
281
- if (!allowScrollWhileFocused) {
282
- scrollContainerRef.current.classList.add(styles.scrollContainerNoScroll);
283
- }
284
- scrollContainerRef.current.classList.add(styles.virtualKeyboardAnimation);
285
322
  stateRef.current.resizeRAFHandle = requestAnimationFrame(waitForResize);
286
323
  visualViewport?.addEventListener('resize', handleResize);
287
- }, [allowScrollWhileFocused, recalcKeyboardOffsets]);
288
- // Запускает анимацию translateY для показа/скрытия боттомшита или изменения высоты контента
289
- const runTranslateYAnimation = useCallback(() => {
290
- if (!contentRef.current ||
291
- !contentOverlayRef.current ||
292
- !footerRef.current ||
293
- !heightTransitionElementRef.current ||
294
- !scrollContainerRef.current ||
295
- !visualViewport) {
296
- return;
324
+ focusedElement.addEventListener('blur', handleBlur);
325
+ }, [isSafari, recalcKeyboardOffsets]);
326
+ // contentOverlay совпадает по границам с контентом боттомшита, но лежит вне боттомшита,
327
+ // чтобы чайлды contentOverlay не обрезались границами боттомшита
328
+ // например, снекбар, лежащий внутри contentOverlay, при смахивании может оказаться в любом месте экрана
329
+ // поэтому позицию contentOverlay нужно синхронизировать
330
+ const recalcContentOverlayPosition = useCallback(() => {
331
+ if (contentOverlayRef.current !== null &&
332
+ scrollContainerRef.current !== null &&
333
+ visualContainerRef.current !== null) {
334
+ contentOverlayRef.current.style.top = `${scrollContainerRef.current.offsetTop + visualContainerRef.current.offsetTop}px`;
335
+ contentOverlayRef.current.style.height = `${scrollContainerRef.current.clientHeight}px`;
297
336
  }
298
- if (heightDiff !== null) {
299
- if (heightDiff > 0) {
300
- contentRef.current.style.transform = `translateY(${heightDiff}px)`;
301
- contentOverlayRef.current.style.transform = `translateY(${heightDiff}px)`;
302
- footerRef.current.style.transform = `translateY(${-heightDiff}px)`;
337
+ }, []);
338
+ const recalcScrollFlags = useCallback(() => {
339
+ if (dividerRef.current !== null) {
340
+ const prevDividerVisible = stateRef.current.dividerVisible;
341
+ const isNotScrolledToEnd = LayoutMetrics.current.contentHeight + stateRef.current.scrollOffset >
342
+ LayoutMetrics.current.scrollContainerHeight;
343
+ stateRef.current.dividerVisible =
344
+ showDivider === 'always' || (showDivider === 'with-scroll' && isNotScrolledToEnd);
345
+ if (stateRef.current.dividerVisible !== prevDividerVisible) {
346
+ dividerRef.current.classList.toggle(styles.dividerVisible, stateRef.current.dividerVisible);
303
347
  }
304
- else {
305
- heightTransitionElementRef.current.style.flexBasis = `${-heightDiff}px`;
348
+ }
349
+ if (grabberRef.current !== null) {
350
+ const prevGrabberUnsafe = stateRef.current.grabberUnsafe;
351
+ stateRef.current.grabberUnsafe =
352
+ LayoutMetrics.current.remainingAvailableHeight -
353
+ Math.round(Math.max(stateRef.current.scrollOffset, 0) + stateRef.current.swipeOffset) ===
354
+ 0;
355
+ if (stateRef.current.grabberUnsafe !== prevGrabberUnsafe) {
356
+ grabberRef.current.classList.toggle(styles.grabberEnsureSafe, stateRef.current.grabberUnsafe);
306
357
  }
307
358
  }
308
- else {
309
- const contentDOMREct = contentRef.current.getBoundingClientRect();
310
- const translateY = scrollContainerRef.current.clientHeight + stateRef.current.swipeOffset - contentDOMREct.top;
311
- contentRef.current.style.transform = `translateY(${translateY}px)`;
312
- contentOverlayRef.current.style.transform = `translateY(${translateY}px)`;
313
- setCSSVariable(CSS_VAR_OVERLAY_OPACITY, `0`);
359
+ }, [showDivider]);
360
+ // помещает боттомшит в позицию вне экрана снизу, которая может быть начальной либо конечной точкой анимации
361
+ const setTransformToInvisible = useCallback(() => {
362
+ LayoutMetrics.current.invalidateCache();
363
+ if (overlayRef.current !== null) {
364
+ overlayRef.current.style.opacity = `0`;
314
365
  }
315
- forceRepaint(scrollContainerRef.current);
316
- }, [heightDiff]);
317
- const resetTranslateY = useCallback(() => {
318
- if (!contentRef.current ||
319
- !contentOverlayRef.current ||
320
- !footerRef.current ||
321
- !heightTransitionElementRef.current ||
322
- !scrollContainerRef.current) {
323
- return;
366
+ if (swipeContainerRef.current !== null) {
367
+ swipeContainerRef.current.style.transform = translateY(LayoutMetrics.current.bottomSheetHeight);
324
368
  }
325
- if (heightDiff !== null) {
326
- contentRef.current.style.transform = ``;
327
- contentOverlayRef.current.style.transform = ``;
328
- footerRef.current.style.transform = ``;
329
- heightTransitionElementRef.current.style.flexBasis = ``;
369
+ requestAnimationFrame(recalcContentOverlayPosition);
370
+ requestAnimationFrame(recalcScrollFlags);
371
+ }, [recalcContentOverlayPosition, recalcScrollFlags]);
372
+ // помещает боттомшит в дефолтную позицию на экране, которая может быть начальной либо конечной точкой анимации
373
+ const setTransformToVisible = useCallback(() => {
374
+ LayoutMetrics.current.invalidateCache();
375
+ stateRef.current.scrollOffset = LayoutMetrics.current.initialOffset;
376
+ if (overlayRef.current !== null) {
377
+ overlayRef.current.style.opacity = `1`;
330
378
  }
331
- else {
332
- contentRef.current.style.transform = `translateY(${stateRef.current.swipeOffset}px)`;
333
- contentOverlayRef.current.style.transform = `translateY(${stateRef.current.swipeOffset}px)`;
334
- setCSSVariable(CSS_VAR_OVERLAY_OPACITY, `1`);
379
+ if (swipeContainerRef.current !== null) {
380
+ swipeContainerRef.current.style.transform = translateY(LayoutMetrics.current.initialOffset + stateRef.current.swipeOffset);
335
381
  }
336
- forceRepaint(scrollContainerRef.current);
337
- }, [heightDiff]);
338
- const handleAppearAnimationEnd = useCallback(() => {
339
- let scrollTop = scrollContainerRef.current?.scrollTop ?? 0;
340
- let scrollLocked = false;
341
- // Обработчик скролла для блокировки оверскролла в браузерах без поддержки css правила overscroll-behavior
342
- const handleScroll = (event) => {
343
- const currentTarget = event.currentTarget;
344
- const forward = currentTarget.scrollTop > scrollTop;
345
- const maxScroll = currentTarget.scrollHeight - currentTarget.offsetHeight;
346
- if (!scrollLocked && forward && currentTarget.scrollTop > maxScroll) {
347
- currentTarget.style.overflow = 'hidden';
348
- currentTarget.scrollTop = maxScroll;
349
- scrollLocked = true;
350
- scrollTop = maxScroll;
351
- setTimeout(() => {
352
- currentTarget.style.overflow = 'auto';
353
- scrollLocked = false;
354
- }, 1);
355
- event.preventDefault();
356
- return;
357
- }
358
- scrollTop = currentTarget.scrollTop;
359
- };
360
- // удаления хендлеров нет т.к. установлен флаг unmountOnExit, т.е. элемент удаляется из DOM
361
- // после завершения анимации
362
- scrollContainerRef.current?.addEventListener('touchmove', handleScroll, { passive: false });
363
- scrollContainerRef.current?.addEventListener('scroll', handleScroll, { passive: false });
364
- recalcScrollFlags();
365
- onAppear?.();
366
- }, [onAppear, recalcScrollFlags]);
382
+ if (footerRef.current !== null) {
383
+ footerRef.current.style.transform = translateY(-LayoutMetrics.current.initialOffset);
384
+ }
385
+ requestAnimationFrame(recalcContentOverlayPosition);
386
+ requestAnimationFrame(recalcScrollFlags);
387
+ }, [recalcContentOverlayPosition, recalcScrollFlags]);
367
388
  const handleExitAnimationStart = useCallback(() => {
368
- resetTranslateY();
389
+ setTransformToVisible();
369
390
  onBeforeExit?.();
370
- }, [resetTranslateY, onBeforeExit]);
391
+ }, [setTransformToVisible, onBeforeExit]);
371
392
  const handleExitAnimationEnd = useCallback(() => {
372
- stateRef.current.grabber = 'sticky';
373
- stateRef.current.hasFocus = false;
374
- stateRef.current.isSwipeEnabled = false;
375
- changeSwipeProgressState(stateRef, scrollContainerRef, false, !!allowScrollWhileFocused);
376
- stateRef.current.swipeOffset = 0;
377
- stateRef.current.resizeRAFHandle !== null && cancelAnimationFrame(stateRef.current.resizeRAFHandle);
378
- stateRef.current.resizeRAFHandle = null;
379
- layoutMetricsRef.current.fillHeight = 0;
380
- layoutMetricsRef.current.grabberSpacing = 0;
381
- setCSSVariable(CSS_VAR_OVERLAY_OPACITY, `1`);
382
393
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `0px`);
383
394
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `0px`);
384
- if (fixedGrabberRef.current !== null) {
385
- fixedGrabberRef.current.classList.add(styles.grabberInvisible);
386
- fixedGrabberRef.current.classList.remove(styles.grabberFixed, styles.grabberFakeSticky);
387
- fixedGrabberRef.current.style.transform = ``;
388
- }
389
- if (scrollContainerRef.current !== null) {
390
- scrollContainerRef.current.classList.remove(styles.scrollContainerNoScroll, styles.virtualKeyboardAnimation);
391
- }
392
- resetScrollFlags();
393
- setOnCloseContractCheck(false);
395
+ stateRef.current.resizeRAFHandle !== null && cancelAnimationFrame(stateRef.current.resizeRAFHandle);
396
+ stateRef.current = { ...INITIAL_STATE };
394
397
  onAfterExit?.();
395
- }, [resetScrollFlags, setOnCloseContractCheck, onAfterExit, allowScrollWhileFocused]);
396
- const handleClose = useCallback(() => {
397
- onClose();
398
- setOnCloseContractCheck(true);
399
- }, [onClose, setOnCloseContractCheck]);
400
- const handleSwipeStart = useCallback(() => {
401
- if (!scrollContainerRef.current) {
402
- return;
398
+ }, [onAfterExit]);
399
+ const handleHeightAnimationStart = useCallback(() => {
400
+ LayoutMetrics.current.invalidateCache();
401
+ stateRef.current.scrollOffset = LayoutMetrics.current.initialOffset;
402
+ stateRef.current.swipeOffset = 0;
403
+ if (contentRef.current !== null) {
404
+ contentRef.current.style.transform = translateY(0);
403
405
  }
404
- if (Object.is(Math.round(scrollContainerRef.current.scrollTop), 0)) {
405
- stateRef.current.isSwipeEnabled = true;
406
- stateRef.current.swipeOffset = 0;
406
+ if (swipeContainerRef.current !== null) {
407
+ swipeContainerRef.current.style.transform = translateY(LayoutMetrics.current.initialOffset);
407
408
  }
408
- }, []);
409
- const handleSwipeMove = useCallback((event) => {
410
- if (!contentRef.current ||
411
- !contentOverlayRef.current ||
412
- !stateRef.current.isSwipeEnabled ||
413
- !scrollContainerRef.current) {
414
- return;
409
+ if (visibleHeightDiff !== null && visualContainerRef.current !== null) {
410
+ visualContainerRef.current.style.height = `${LayoutMetrics.current.bottomSheetHeight + visibleHeightDiff}px`;
415
411
  }
416
- const selection = document.getSelection();
417
- if (selection && !selection.isCollapsed) {
412
+ }, [visibleHeightDiff]);
413
+ const handleHeightAnimationEnd = useCallback(() => {
414
+ LayoutMetrics.current.invalidateCache();
415
+ stateRef.current.heightAnimationRunning = false;
416
+ setVisibleHeightDiff(null);
417
+ requestAnimationFrame(recalcContentOverlayPosition);
418
+ requestAnimationFrame(recalcScrollFlags);
419
+ }, [setVisibleHeightDiff, recalcContentOverlayPosition, recalcScrollFlags]);
420
+ const handleSwipeMove = useCallback((event) => {
421
+ if ((stateRef.current.touchAction !== null && stateRef.current.touchAction !== 'swipe') ||
422
+ hasSelectedText()) {
418
423
  return;
419
424
  }
420
- if (event.distanceY > 0) {
421
- contentRef.current.style.transform = `translateY(${event.distanceY}px)`;
422
- contentOverlayRef.current.style.transform = `translateY(${event.distanceY}px)`;
423
- if (!stateRef.current.isSwipeInProgress) {
424
- changeSwipeProgressState(stateRef, scrollContainerRef, true, !!allowScrollWhileFocused);
425
+ // храним неокругленное значение для translateY, чтобы анимация была плавнее
426
+ let newSwipeOffset = stateRef.current.swipeOffset + event.deltaY;
427
+ if (Math.round(newSwipeOffset) <= 0) {
428
+ // боттомшит уперся в верхний край экрана, не даем свайпать дальше
429
+ newSwipeOffset = 0;
430
+ if (stateRef.current.touchAction === 'swipe') {
431
+ stateRef.current.touchAction = 'complete';
425
432
  }
426
- recalcScrollFlags();
427
433
  }
428
434
  else {
429
- contentRef.current.style.transform = ``;
430
- contentOverlayRef.current.style.transform = ``;
431
- stateRef.current.isSwipeEnabled = false;
432
- changeSwipeProgressState(stateRef, scrollContainerRef, false, !!allowScrollWhileFocused);
435
+ // свайп в процессе
436
+ stateRef.current.touchAction = 'swipe';
433
437
  }
434
- stateRef.current.swipeOffset = event.distanceY;
435
- }, [recalcScrollFlags, allowScrollWhileFocused]);
436
- const handleSwipeCancel = useCallback(() => {
437
- if (!contentRef.current || !contentOverlayRef.current || !scrollContainerRef.current) {
438
- return;
439
- }
440
- if (stateRef.current.isSwipeEnabled && stateRef.current.isSwipeInProgress) {
441
- contentRef.current.style.transform = ``;
442
- contentOverlayRef.current.style.transform = ``;
438
+ if (stateRef.current.swipeOffset !== newSwipeOffset) {
439
+ stateRef.current.swipeOffset = newSwipeOffset;
440
+ if (swipeContainerRef.current !== null) {
441
+ swipeContainerRef.current.style.transform = translateY(Math.max(stateRef.current.scrollOffset, 0) + newSwipeOffset);
442
+ }
443
+ recalcScrollFlags();
443
444
  }
444
- stateRef.current.isSwipeEnabled = false;
445
- changeSwipeProgressState(stateRef, scrollContainerRef, false, !!allowScrollWhileFocused);
446
- stateRef.current.swipeOffset = 0;
447
- recalcScrollFlags();
448
- }, [recalcScrollFlags, allowScrollWhileFocused]);
449
- const handleSwipeEnd = useCallback((event) => {
450
- if (stateRef.current.isSwipeEnabled && stateRef.current.isSwipeInProgress) {
451
- handleClose();
445
+ }, [recalcScrollFlags]);
446
+ const handleSwipeCancel = useCallback(() => {
447
+ if (stateRef.current.touchAction === 'swipe') {
448
+ stateRef.current.swipeOffset = 0;
449
+ stateRef.current.touchAction = null;
450
+ if (swipeContainerRef.current !== null) {
451
+ swipeContainerRef.current.classList.add(styles.swipeCancelAnimation);
452
+ const swipeContainer = swipeContainerRef.current;
453
+ setTimeout(() => swipeContainer.classList.remove(styles.swipeCancelAnimation), 100);
454
+ swipeContainerRef.current.style.transform = translateY(Math.max(stateRef.current.scrollOffset, 0));
455
+ }
456
+ recalcScrollFlags();
452
457
  }
453
- else {
454
- handleSwipeCancel(event);
458
+ }, [recalcScrollFlags]);
459
+ const handleSwipeEnd = useCallback(() => {
460
+ if (stateRef.current.touchAction === 'swipe') {
461
+ onCloseRef.current();
455
462
  }
456
- }, [handleClose, handleSwipeCancel]);
457
- const { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel } = useSwipe({
458
- thresholdYRef: SWIPE_THRESHOLD_REF,
459
- onSwipeStart: handleSwipeStart,
463
+ }, []);
464
+ const swipeHandlers = useSwipeHandlers({
465
+ thresholdYRef: SWIPE_THRESHOLD,
460
466
  onSwipeMove: handleSwipeMove,
461
467
  onSwipeEnd: handleSwipeEnd,
462
468
  onSwipeCancel: handleSwipeCancel,
463
469
  });
464
- const swipeHandlers = useMemo(() => {
465
- const withStopPropagation = (callback) => (event) => {
466
- event.stopPropagation();
467
- callback?.(event);
468
- };
469
- return {
470
- onTouchStart: withStopPropagation(onTouchStart),
471
- onTouchMove: withStopPropagation(onTouchMove),
472
- onTouchEnd,
473
- onTouchCancel,
474
- };
475
- }, [onTouchStart, onTouchMove, onTouchEnd, onTouchCancel]);
476
- useLayoutEffect(() => {
477
- if (!contentRef.current ||
478
- !footerRef.current ||
479
- !headerRef.current ||
480
- !scrollContainerRef.current ||
481
- !visualViewport) {
482
- return;
470
+ useEffect(() => {
471
+ if (!currentVisible || visualContainerRef.current === null) {
472
+ return void 0;
483
473
  }
484
- if (visible) {
485
- stateRef.current.grabber = contentRef.current.offsetTop > 0 ? 'sticky' : 'sticky-inside';
486
- if (visible !== prevPropsRef.current.visible) {
487
- layoutMetricsRef.current.fillHeight = contentRef.current.offsetTop;
488
- layoutMetricsRef.current.grabberSpacing = scrollContainerRef.current.offsetTop;
489
- setCSSVariable(CSS_VAR_CONTENT_OVERLAY_TOP, `${contentRef.current.offsetTop + headerRef.current.clientHeight}px`);
490
- setCSSVariable(CSS_VAR_CONTENT_OVERLAY_HEIGHT, `${footerRef.current.offsetTop - headerRef.current.clientHeight}px`);
491
- setCSSVariable(CSS_VAR_INITIAL_VIEWPORT_HEIGHT, `${INITIAL_VIEWPORT_HEIGHT}px`);
474
+ const handleScroll = (event) => {
475
+ if ((!allowScrollWhileFocused && stateRef.current.hasFocus) ||
476
+ (stateRef.current.touchAction !== null && stateRef.current.touchAction !== 'scroll') ||
477
+ hasSelectedText()) {
478
+ return;
492
479
  }
493
- else if (children !== prevPropsRef.current.children || height !== prevPropsRef.current.height) {
494
- const scrollTop = scrollContainerRef.current.scrollTop;
495
- scrollContainerRef.current.scrollTo({ top: 0 });
496
- const _heightDiff = layoutMetricsRef.current.fillHeight - contentRef.current.offsetTop;
497
- layoutMetricsRef.current.fillHeight = contentRef.current.offsetTop;
498
- if (_heightDiff !== 0 && !stateRef.current.hasFocus) {
499
- setHeightDiff(_heightDiff);
480
+ if (LayoutMetrics.current.initialOffset !== 0 ||
481
+ LayoutMetrics.current.contentHeight > LayoutMetrics.current.scrollContainerHeight) {
482
+ // храним неокругленное значение для translateY, чтобы анимация была плавнее
483
+ let newScrollOffset = stateRef.current.scrollOffset + event.delta;
484
+ const roundedNewScrollOffset = Math.round(newScrollOffset);
485
+ if (roundedNewScrollOffset >= LayoutMetrics.current.initialOffset) {
486
+ // скролла нет (touchAction is null)
487
+ // либо контент проскроллен в начало, тогда не даем скроллить дальше
488
+ newScrollOffset = LayoutMetrics.current.initialOffset;
489
+ if (stateRef.current.touchAction === 'scroll') {
490
+ stateRef.current.touchAction = 'complete';
491
+ }
492
+ }
493
+ else if (LayoutMetrics.current.contentHeight + roundedNewScrollOffset <=
494
+ LayoutMetrics.current.scrollContainerHeight) {
495
+ // скролла нет (touchAction is null)
496
+ // либо контент проскроллен до конца, тогда не даем скроллить дальше
497
+ newScrollOffset = LayoutMetrics.current.scrollContainerHeight - LayoutMetrics.current.contentHeight;
498
+ if (stateRef.current.touchAction === 'scroll') {
499
+ stateRef.current.touchAction = 'complete';
500
+ }
501
+ }
502
+ else {
503
+ // скролл в процессе
504
+ stateRef.current.touchAction = 'scroll';
505
+ }
506
+ if (stateRef.current.scrollOffset !== newScrollOffset) {
507
+ if (contentRef.current !== null && swipeContainerRef.current !== null) {
508
+ const offsetWasPositive = stateRef.current.scrollOffset > 0;
509
+ if (newScrollOffset > 0) {
510
+ if (!offsetWasPositive) {
511
+ contentRef.current.style.transform = translateY(0);
512
+ }
513
+ swipeContainerRef.current.style.transform = translateY(newScrollOffset);
514
+ if (footerRef.current !== null) {
515
+ footerRef.current.style.transform = translateY(-newScrollOffset);
516
+ }
517
+ }
518
+ else {
519
+ contentRef.current.style.transform = translateY(newScrollOffset);
520
+ if (offsetWasPositive) {
521
+ swipeContainerRef.current.style.transform = translateY(0);
522
+ if (footerRef.current !== null) {
523
+ footerRef.current.style.transform = translateY(0);
524
+ }
525
+ }
526
+ }
527
+ }
528
+ stateRef.current.scrollOffset = newScrollOffset;
529
+ recalcScrollFlags();
500
530
  }
501
- scrollContainerRef.current.scrollTo({ top: scrollTop });
502
531
  }
503
- }
504
- prevPropsRef.current = { ...prevPropsRef.current, children, height, visible };
505
- }, [children, height, setHeightDiff, visible]);
506
- if (onCloseContractCheck && visible) {
507
- console.error('onClose did not set visible=false', Date.now());
508
- }
509
- const contextValue = useMemo(() => ({ contentOverlayRef }), [contentOverlayRef]);
510
- const eventHandlers = useNoBubbling();
532
+ };
533
+ const handleScrollEnd = () => {
534
+ if (stateRef.current.touchAction === 'scroll') {
535
+ stateRef.current.touchAction = null;
536
+ }
537
+ };
538
+ const handleTouchStart = (event) => {
539
+ if (stateRef.current.touchAction === 'complete') {
540
+ stateRef.current.touchAction = null;
541
+ }
542
+ swipeHandlers.onTouchStart(event);
543
+ };
544
+ const handleTouchMove = (event) => {
545
+ event.preventDefault();
546
+ event.stopPropagation();
547
+ swipeHandlers.onTouchMove(event);
548
+ };
549
+ const removeScrollHandlers = initTouchHandlers('vertical', visualContainerRef, handleScroll, handleScrollEnd);
550
+ const visualContainer = visualContainerRef.current;
551
+ visualContainer.addEventListener('touchstart', handleTouchStart);
552
+ visualContainer.addEventListener('touchmove', handleTouchMove);
553
+ visualContainer.addEventListener('touchend', swipeHandlers.onTouchEnd);
554
+ visualContainer.addEventListener('touchcancel', swipeHandlers.onTouchCancel);
555
+ return () => {
556
+ removeScrollHandlers();
557
+ visualContainer.removeEventListener('touchstart', swipeHandlers.onTouchStart);
558
+ visualContainer.removeEventListener('touchmove', handleTouchMove);
559
+ visualContainer.removeEventListener('touchend', swipeHandlers.onTouchEnd);
560
+ visualContainer.removeEventListener('touchcancel', swipeHandlers.onTouchCancel);
561
+ };
562
+ }, [allowScrollWhileFocused, currentVisible, height, recalcScrollFlags, showOverlay, swipeHandlers]);
563
+ const { onTouchEnd, ...eventHandlers } = useNoBubbling();
511
564
  if (!animationTimeout) {
512
565
  return null;
513
566
  }
514
- const renderFunc = (appearTransition, heightTransition) => (jsx(Layer, { layer: InternalLayerName.BottomSheet, children: jsxs("div", { ...eventHandlers, className: classnames(styles.overlay, {
515
- [styles.overlayInvisible]: !showOverlay || (showOverlay && appearTransition !== 'exiting' && !currentVisible),
516
- }), "data-qa": "bottom-sheet-overlay", children: [jsx("div", { className: classnames(styles.overlayBackground, {
567
+ const renderFunc = (appearTransition, heightTransition) => (jsx(Layer, { layer: InternalLayerName.BottomSheet, children: jsxs("div", { ...eventHandlers, className: styles.overlay, children: [jsx("div", { className: classnames(styles.overlayBackground, {
568
+ [styles.overlayBackgroundVisible]: showOverlay,
569
+ [styles.appearAnimation]: appearTransition === 'entering',
570
+ [styles.disappearAnimation]: appearTransition === 'exiting',
571
+ }), "data-qa": "bottom-sheet-overlay", onClick: appearTransition === 'entered' ? onCloseRef.current : undefined, ref: overlayRef }), jsxs("div", { className: classnames(styles.swipeContainer, {
517
572
  [styles.appearAnimation]: appearTransition === 'entering',
573
+ [styles.closeBySwipeAnimation]: appearTransition === 'exiting' && stateRef.current.touchAction === 'swipe',
518
574
  [styles.disappearAnimation]: appearTransition === 'exiting',
519
- }) }), jsx("div", { className: classnames(styles.grabber, styles.grabberTransitionAnimation, {
520
- [styles.grabberInvisible]: appearTransition !== 'entered' || heightTransition !== 'exited',
521
- [styles.grabberFixed]: stateRef.current.grabber === 'fixed' || stateRef.current.grabber === 'sticky-inside',
522
- [styles.grabberFakeSticky]: stateRef.current.grabber === 'fake-sticky',
523
- }), ref: fixedGrabberRef }), jsxs("div", { className: classnames(styles.scrollContainer, {
524
- [styles.scrollContainerNoScroll]: !allowScrollWhileFocused && stateRef.current.hasFocus,
525
- [styles.virtualKeyboardAnimation]: stateRef.current.hasFocus,
526
- }), "data-qa": "bottom-sheet-container", onScroll: recalcScrollFlags, ref: scrollContainerRefMulti, children: [jsx("div", { className: classnames(styles.fill, {
527
- [styles.fillFullScreen]: height === 'full-screen',
528
- [styles.fillHalfScreen]: height === 'half-screen',
529
- }), "data-qa": "bottom-sheet-fill", onClick: handleClose }), jsxs("div", { className: classnames(styles.content, {
530
- [styles.appearAnimation]: appearTransition === 'entering',
531
- [styles.closeBySwipeAnimation]: appearTransition === 'exiting' && stateRef.current.isSwipeInProgress,
532
- [styles.contentFullScreen]: height === 'full-screen',
533
- [styles.disappearAnimation]: appearTransition === 'exiting',
575
+ }), "data-qa": "bottom-sheet-container", ref: swipeContainerRef, children: [jsx("div", { className: classnames(styles.grabber, styles.grabberTransitionAnimation, {
576
+ [styles.grabberEnsureSafe]: stateRef.current.grabberUnsafe,
577
+ }), ref: grabberRef }), jsxs("div", { className: classnames(styles.visualContainer, {
578
+ [styles.visualContainerFullScreen]: height === 'full-screen',
534
579
  [styles.heightTransitionAnimation]: heightTransition === 'entering',
535
- }), "data-qa": "bottom-sheet-content", ref: contentRef, ...swipeHandlers, children: [jsx("div", { className: classnames(styles.grabber, styles.grabberSticky, {
536
- [styles.grabberStickyInside]: appearTransition === 'entering' && stateRef.current.grabber === 'sticky-inside',
537
- }), ref: stickyGrabberRef }), jsxs("div", { className: styles.visualContainer, children: [jsx("div", { className: styles.header, onFocus: handleFocus, ref: headerRef, children: jsx(NavigationBarContext.Provider, { value: NAVIGATION_BAR_SIZE_OVERRIDE, children: header }) }), jsx("div", { className: classnames(styles.main, {
538
- [styles.mainWithPaddings]: withContentPaddings,
539
- [styles.mainWithoutHeader]: !header,
540
- }), children: jsx(BottomSheetContext.Provider, { value: contextValue, children: children }) }), jsx("div", { className: classnames(styles.heightTransitionElement, {
541
- [styles.heightTransitionAnimation]: heightTransition === 'entering',
542
- }), ref: heightTransitionElementRef }), jsxs("div", { className: classnames(styles.footer, {
543
- [styles.heightTransitionAnimation]: heightTransition === 'entering',
544
- }), ref: footerRef, children: [isDividerVisible && jsx(Divider, {}), interceptClickHandlers ? jsx(ClickInterceptor, { children: footer }) : footer] })] })] })] }), jsx("div", { className: styles.contentOverlay, ref: contentOverlayRef })] }) }));
545
- return createPortal(jsx("div", { className: styles.cssVariablesContainer, "data-qa": "bottom-sheet-css-variables", ref: cssVariablesContainerRef, children: jsx(Transition, { appear: true, in: currentVisible, mountOnEnter: true, onEnter: runTranslateYAnimation, onEntering: resetTranslateY, onEntered: handleAppearAnimationEnd, onExit: handleExitAnimationStart, onExiting: runTranslateYAnimation, onExited: handleExitAnimationEnd, timeout: animationTimeout.appear, unmountOnExit: true, nodeRef: contentRef, children: (appearTransition) => (jsx(Transition, { in: heightDiff !== null, onEnter: runTranslateYAnimation, onEntering: resetTranslateY, onEntered: resetHeightDiff, onExited: recalcScrollFlags, timeout: animationTimeout.height, nodeRef: contentRef, children: (heightTransition) => renderFunc(appearTransition, heightTransition) })) }) }), document.body);
580
+ }), "data-qa": "bottom-sheet-content", ref: bottomSheetRef, children: [jsx("div", { className: styles.header, onFocus: handleFocus, ref: headerRef, children: jsx(NavigationBarContext.Provider, { value: NAVIGATION_BAR_SIZE_OVERRIDE, children: header }) }), jsx("div", { className: styles.scrollContainer, ref: scrollContainerRef, children: jsx("div", { className: classnames(styles.content, {
581
+ [styles.contentWithPaddings]: withContentPaddings,
582
+ [styles.contentWithoutHeader]: !header,
583
+ }), ref: contentRef, children: jsx(BottomSheetContext.Provider, { value: contextValue, children: children }) }) }), jsxs("div", { className: styles.footer, ref: footerRef, children: [footer && (jsx("div", { className: classnames(styles.divider, {
584
+ [styles.dividerVisible]: stateRef.current.dividerVisible,
585
+ }), ref: dividerRef, children: jsx(Divider, {}) })), interceptClickHandlers ? jsx(ClickInterceptor, { children: footer }) : footer] })] }), jsx("div", { className: styles.contentOverlay, ref: contentOverlayRef })] })] }) }));
586
+ return createPortal(jsx("div", { className: styles.cssVariablesContainer, "data-qa": "bottom-sheet-css-variables", ref: cssVariablesContainerRef, children: jsx(Transition, { appear: true, in: currentVisible, mountOnEnter: true, onEnter: setTransformToInvisible, onEntering: setTransformToVisible, onEntered: onAppear, onExit: handleExitAnimationStart, onExiting: setTransformToInvisible, onExited: handleExitAnimationEnd, timeout: animationTimeout.appear, unmountOnExit: true, nodeRef: contentRef, children: (appearTransition) => (jsx(Transition, { in: visibleHeightDiff !== null, onEnter: handleHeightAnimationStart, onEntered: handleHeightAnimationEnd, timeout: animationTimeout.height, nodeRef: contentRef, children: (heightTransition) => renderFunc(appearTransition, heightTransition) })) }) }), document.body);
546
587
  };
547
588
  const BottomSheet = forwardRef(BottomSheetRenderFunc);
548
- BottomSheet.displayName = 'BottomSheet';
549
589
 
550
590
  export { BottomSheet };
551
591
  //# sourceMappingURL=BottomSheet.js.map