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

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, CustomScrollContextProvider } from '@hh.ru/magritte-internal-custom-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,134 @@ 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-D8XuBr1R.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 scrollContextProviderRef = useRef(null);
80
+ const LayoutMetrics = useRef((() => {
81
+ let cachedBottomSheetHeight = null;
82
+ let cachedContentHeight = null;
83
+ let cachedInitialOffset = null;
84
+ let cachedRemainingAvailableHeight = null;
85
+ let cachedScrollContainerHeight = null;
86
+ return {
87
+ get bottomSheetHeight() {
88
+ if (cachedBottomSheetHeight === null && visualContainerRef.current !== null) {
89
+ cachedBottomSheetHeight = visualContainerRef.current.clientHeight;
90
+ }
91
+ return cachedBottomSheetHeight ?? 0;
92
+ },
93
+ get contentHeight() {
94
+ if (cachedContentHeight === null && contentRef.current !== null) {
95
+ cachedContentHeight = contentRef.current.clientHeight;
96
+ }
97
+ return cachedContentHeight ?? 0;
98
+ },
99
+ get initialOffset() {
100
+ if (cachedInitialOffset === null &&
101
+ visualContainerRef.current !== null &&
102
+ visualViewport !== null) {
103
+ cachedInitialOffset =
104
+ height === 'half-screen'
105
+ ? Math.max(Math.round(visualContainerRef.current.clientHeight - visualViewport.height / 2), 0)
106
+ : 0;
107
+ }
108
+ return cachedInitialOffset ?? 0;
109
+ },
110
+ get remainingAvailableHeight() {
111
+ if (cachedRemainingAvailableHeight === null && grabberRef.current !== null) {
112
+ cachedRemainingAvailableHeight = grabberRef.current.offsetTop;
113
+ }
114
+ return cachedRemainingAvailableHeight ?? 0;
115
+ },
116
+ get scrollContainerHeight() {
117
+ if (cachedScrollContainerHeight === null && scrollContainerRef.current !== null) {
118
+ cachedScrollContainerHeight = scrollContainerRef.current.clientHeight;
119
+ }
120
+ return cachedScrollContainerHeight ?? 0;
121
+ },
122
+ invalidateCache() {
123
+ cachedBottomSheetHeight = null;
124
+ cachedContentHeight = null;
125
+ cachedInitialOffset = null;
126
+ cachedRemainingAvailableHeight = null;
127
+ cachedScrollContainerHeight = null;
128
+ },
129
+ };
130
+ })());
81
131
  const setCSSVariable = (name, value) => cssVariablesContainerRef.current !== null && cssVariablesContainerRef.current.style.setProperty(name, value);
82
132
  useEffect(() => {
83
133
  if (!currentVisible) {
84
134
  return void 0;
85
135
  }
86
- layoutMetricsRef.current.documentHeight = document.documentElement.clientHeight;
136
+ DOCUMENT_HEIGHT.current = document.documentElement.clientHeight;
137
+ if (visualViewport !== null) {
138
+ SWIPE_THRESHOLD.current = { max: Math.round(visualViewport.height * 0.8) };
139
+ }
87
140
  if (!showOverlay) {
88
141
  const enableOverscroll = disableOverscroll();
89
142
  return enableOverscroll;
90
143
  }
91
144
  const enableScroll = disableScroll();
92
145
  return enableScroll;
93
- }, [showOverlay, currentVisible]);
146
+ }, [currentVisible, showOverlay]);
94
147
  useEffect(() => {
95
148
  const animationTimeoutElement = document.createElement('div');
96
149
  animationTimeoutElement.classList.add(styles.animationTimeout);
@@ -102,63 +155,60 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
102
155
  document.body.removeChild(animationTimeoutElement);
103
156
  setAnimationTimeout({ appear: { enter, exit }, height: { enter: height, exit } });
104
157
  }, [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
- }
158
+ useEffect(() => {
159
+ if (!currentVisible || !contentRef.current) {
160
+ return void 0;
161
+ }
162
+ // при изменении высоты контента анимируем ее
163
+ // самому боттомшиту задаем фиксированную высоту и пересчитываем ее самостоятельно,
164
+ // чтобы не было мерцания, когда новый контент отрисовался до срабатывания колбека ResizeObserver
165
+ let prevContentHeight = LayoutMetrics.current.contentHeight;
166
+ if (visualContainerRef.current !== null) {
167
+ visualContainerRef.current.style.height = `${LayoutMetrics.current.bottomSheetHeight}px`;
168
+ }
169
+ const observer = new ResizeObserver(() => {
170
+ if (stateRef.current.heightAnimationRunning) {
171
+ return;
137
172
  }
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));
173
+ LayoutMetrics.current.invalidateCache();
174
+ // в общем случае видимую высоту контента можно посчитать как min(scrollContainer.height, contentHeight)
175
+ const contentHeightDiff = LayoutMetrics.current.contentHeight - prevContentHeight;
176
+ if (contentHeightDiff > 0) {
177
+ // но если высота контента увеличилась, мы не знаем новую высоту scrollContainer.height
178
+ // т.к. фиксированная высота боттомшита не дает scrollContainer увеличиться
179
+ // но сам боттомшит может увеличиться не больше чем на расстояние между ним и верхним краем экрана
180
+ const heightDiff = Math.round(Math.min(contentHeightDiff, LayoutMetrics.current.remainingAvailableHeight));
181
+ // триггерим анимацию даже при нулевом heightDiff, чтобы сбросить позицию скролла
182
+ setVisibleHeightDiff(heightDiff);
183
+ stateRef.current.heightAnimationRunning = true;
184
+ }
185
+ else if (contentHeightDiff < 0) {
186
+ // если высота контента уменьшилась, новая высота scrollContainer будет такая же или меньше
187
+ // поэтому можем посчитать новую видимую высоту контента и сравнить со старой
188
+ const prevVisibleHeight = Math.min(LayoutMetrics.current.scrollContainerHeight, prevContentHeight);
189
+ const newVisibleHeight = Math.min(LayoutMetrics.current.scrollContainerHeight, LayoutMetrics.current.contentHeight);
190
+ const heightDiff = Math.round(newVisibleHeight - prevVisibleHeight);
191
+ // триггерим анимацию даже при нулевом heightDiff, чтобы сбросить позицию скролла
192
+ setVisibleHeightDiff(heightDiff);
193
+ stateRef.current.heightAnimationRunning = true;
147
194
  }
195
+ prevContentHeight = LayoutMetrics.current.contentHeight;
196
+ });
197
+ observer.observe(contentRef.current);
198
+ return () => observer.disconnect();
199
+ }, [currentVisible, setVisibleHeightDiff]);
200
+ useEffect(() => {
201
+ if (!currentVisible || isSafari()) {
202
+ return;
148
203
  }
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);
204
+ // используем Virtual Keyboard API через meta-тег вместо navigator.virtualKeyboard,
205
+ // потому что второй способ работает только на страницах, открытых через HTTPS, что мешает тестированию
206
+ let meta = document.querySelector('meta[name="viewport"]');
207
+ if (!meta) {
208
+ meta = document.createElement('meta');
209
+ meta.setAttribute('name', 'viewport');
210
+ document.head.appendChild(meta);
156
211
  }
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
212
  const attributesStr = meta.getAttribute('content');
163
213
  const attributes = (attributesStr !== null
164
214
  ? Object.fromEntries(attributesStr.split(',').map((keyValuePairStr) => keyValuePairStr.split('=')))
@@ -167,11 +217,10 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
167
217
  const attributesStrUpdated = Object.entries(attributes)
168
218
  .map((keyValuePair) => keyValuePair.join('='))
169
219
  .join(',');
170
- meta.setAttribute('name', 'viewport');
171
220
  meta.setAttribute('content', attributesStrUpdated);
172
- }, [keyboardOverlaysContent]);
221
+ }, [currentVisible, isSafari, keyboardOverlaysContent]);
173
222
  const recalcKeyboardOffsets = useCallback(() => {
174
- if (!headerRef.current || !scrollContainerRef.current || !visualViewport) {
223
+ if (!headerRef.current || !overlayRef.current || !visualViewport) {
175
224
  return;
176
225
  }
177
226
  if (stateRef.current.hasFocus) {
@@ -182,26 +231,24 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
182
231
  // В нем не нужно ничего корректировать при keyboardOverlaysContent=true,
183
232
  // а при keyboardOverlaysContent=false нужно сдвинуть НИЖНИЙ край контейнера ВВЕРХ,
184
233
  // чтобы он совпал с границей Visual Viewport
185
- // 2. Chrome < 108 & Chromium-based — ресайзит и Visual Viewport, и Layout Viewport
234
+ // 2. Chrome < 108 & Chromium-based — ресайзит и Visual Viewport, и Layout Viewport.
186
235
  // В нем не нужно ничего корректировать при keyboardOverlaysContent=false,
187
236
  // а при keyboardOverlaysContent=true нужно сдвинуть НИЖНИЙ край контейнера ВНИЗ,
188
237
  // чтобы футер уехал под клавиатуру
189
- // 3. Chrome >= 108 — поддерживает Virtual Keyboard API и meta-тег interactive-widget
238
+ // 3. Chrome >= 108 — поддерживает Virtual Keyboard API и meta-тег interactive-widget.
190
239
  // Используем поведение `resizes-visual` (как в Safari) в случае keyboardOverlaysContent=true
191
240
  // и `resizes-content` (как в Chrome < 108 & Chromium-based) в случае keyboardOverlaysContent=false
192
- // Таким образом в нем ничего не нужно корректировать.
193
- const scrollContainerDOMREct = scrollContainerRef.current.getBoundingClientRect();
241
+ // Таким образом в нем ничего не нужно корректировать
242
+ const overlayDOMRect = overlayRef.current.getBoundingClientRect();
194
243
  // любой браузер может сдвинуть Visual Viewport вверх, если фокусируемый инпут находится близко к нижней границе
195
244
  // из-за этого может возникнуть проблема, что ВЕРХНИЙ край контента уехал за границу Visual Viewport
196
245
  // сдвигаем ВЕРХНИЙ край контейнера ВНИЗ, чтобы он совпал с границей Visual Viewport
197
- const visualViewportShift = Math.round(layoutMetricsRef.current.grabberSpacing - scrollContainerDOMREct.top);
198
- // запоминаем сдвиг для коррекции позиции граббера
199
- layoutMetricsRef.current.viewportShift = visualViewportShift;
200
- // клавиатура ПОВЕРХ контента
246
+ const visualViewportShift = Math.round(-overlayDOMRect.top);
247
+ // keyboardOverlaysContent=true, клавиатура ПОВЕРХ контента
201
248
  // этот кейс нужно корректировать только в Chrome < 108 & Chromium-based
202
249
  if (keyboardOverlaysContent) {
203
250
  // браузеры из этой группы меняют Layout Viewport
204
- const layoutViewportDiff = Math.round(layoutMetricsRef.current.documentHeight - document.documentElement.clientHeight);
251
+ const layoutViewportDiff = Math.round(DOCUMENT_HEIGHT.current - document.documentElement.clientHeight);
205
252
  if (layoutViewportDiff > 0) {
206
253
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `${visualViewportShift}px`);
207
254
  // сдвигаем НИЖНИЙ край контейнера ВНИЗ, чтобы футер уехал под клавиатуру
@@ -209,17 +256,16 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
209
256
  // при этом может возникнуть проблема, что клавиатура перекрыла хедер
210
257
  // проверяем это и компенсируем величину перекрытия при необходимости
211
258
  const headerDOMRect = headerRef.current.getBoundingClientRect();
212
- const headerOutOfViewportHeight = Math.round(headerDOMRect.bottom + layoutViewportDiff - layoutMetricsRef.current.documentHeight);
259
+ const headerOutOfViewportHeight = Math.round(headerDOMRect.bottom + layoutViewportDiff - DOCUMENT_HEIGHT.current);
213
260
  if (headerOutOfViewportHeight > 0) {
214
261
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `${headerOutOfViewportHeight - layoutViewportDiff}px`);
215
262
  }
216
263
  }
217
264
  }
218
- else {
219
- // клавиатура ПОД контентом
220
- // этот кейс нужно корректировать только в Safari
221
- // Safari ресайзит Visual Viewport
222
- const visualViewportDiff = Math.round(scrollContainerDOMREct.bottom - visualViewport.height);
265
+ // keyboardOverlaysContent=false, клавиатура ПОД контентом
266
+ // этот кейс нужно корректировать только в Safari
267
+ if (!keyboardOverlaysContent && isSafari()) {
268
+ const visualViewportDiff = Math.round(overlayDOMRect.bottom - visualViewport.height);
223
269
  if (visualViewportDiff > 0) {
224
270
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `${visualViewportShift}px`);
225
271
  // сдвигаем НИЖНИЙ край контейнера ВВЕРХ, чтобы он совпал с границей Visual Viewport
@@ -231,11 +277,8 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
231
277
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `0px`);
232
278
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `0px`);
233
279
  }
234
- }, [keyboardOverlaysContent]);
280
+ }, [isSafari, keyboardOverlaysContent]);
235
281
  const handleFocus = useCallback((event) => {
236
- if (!scrollContainerRef.current) {
237
- return;
238
- }
239
282
  const focusedElement = event.target;
240
283
  const initialViewportHeight = visualViewport?.height;
241
284
  const resizeRAFStart = performance.now();
@@ -248,7 +291,12 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
248
291
  stateRef.current.resizeRAFHandle = null;
249
292
  }
250
293
  recalcKeyboardOffsets();
294
+ if (!stateRef.current.hasFocus) {
295
+ visualViewport?.removeEventListener('resize', handleResize);
296
+ }
251
297
  };
298
+ // если спамить фокус/блюр инпута, ивент visualViewport.resize может не долететь
299
+ // поэтому проверяем изменение высоты в рекурсивном RAF
252
300
  const waitForResize = () => {
253
301
  if (performance.now() - resizeRAFStart > 1000 || visualViewport?.height !== initialViewportHeight) {
254
302
  visualViewport?.removeEventListener('resize', handleResize);
@@ -260,292 +308,289 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
260
308
  }
261
309
  };
262
310
  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) {
311
+ if (isSafari()) {
270
312
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `0px`);
271
313
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `0px`);
272
314
  }
315
+ stateRef.current.hasFocus = false;
273
316
  if (stateRef.current.resizeRAFHandle !== null) {
274
317
  cancelAnimationFrame(stateRef.current.resizeRAFHandle);
275
318
  stateRef.current.resizeRAFHandle = null;
276
319
  }
277
- visualViewport?.removeEventListener('resize', handleResize);
320
+ focusedElement.removeEventListener('blur', handleBlur);
278
321
  };
279
- focusedElement.addEventListener('blur', handleBlur);
280
322
  stateRef.current.hasFocus = true;
281
- if (!allowScrollWhileFocused) {
282
- scrollContainerRef.current.classList.add(styles.scrollContainerNoScroll);
283
- }
284
- scrollContainerRef.current.classList.add(styles.virtualKeyboardAnimation);
285
323
  stateRef.current.resizeRAFHandle = requestAnimationFrame(waitForResize);
286
324
  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;
325
+ focusedElement.addEventListener('blur', handleBlur);
326
+ }, [isSafari, recalcKeyboardOffsets]);
327
+ // contentOverlay совпадает по границам с контентом боттомшита, но лежит вне боттомшита,
328
+ // чтобы чайлды contentOverlay не обрезались границами боттомшита
329
+ // например, снекбар, лежащий внутри contentOverlay, при смахивании может оказаться в любом месте экрана
330
+ // поэтому позицию contentOverlay нужно синхронизировать
331
+ const recalcContentOverlayPosition = useCallback(() => {
332
+ if (contentOverlayRef.current !== null &&
333
+ scrollContainerRef.current !== null &&
334
+ visualContainerRef.current !== null) {
335
+ contentOverlayRef.current.style.top = `${scrollContainerRef.current.offsetTop + visualContainerRef.current.offsetTop}px`;
336
+ contentOverlayRef.current.style.height = `${scrollContainerRef.current.clientHeight}px`;
297
337
  }
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)`;
338
+ }, []);
339
+ const recalcScrollFlags = useCallback(() => {
340
+ if (dividerRef.current !== null) {
341
+ const prevDividerVisible = stateRef.current.dividerVisible;
342
+ const isNotScrolledToEnd = LayoutMetrics.current.contentHeight + stateRef.current.scrollOffset >
343
+ LayoutMetrics.current.scrollContainerHeight;
344
+ stateRef.current.dividerVisible =
345
+ showDivider === 'always' || (showDivider === 'with-scroll' && isNotScrolledToEnd);
346
+ if (stateRef.current.dividerVisible !== prevDividerVisible) {
347
+ dividerRef.current.classList.toggle(styles.dividerVisible, stateRef.current.dividerVisible);
303
348
  }
304
- else {
305
- heightTransitionElementRef.current.style.flexBasis = `${-heightDiff}px`;
349
+ }
350
+ if (grabberRef.current !== null) {
351
+ const prevGrabberUnsafe = stateRef.current.grabberUnsafe;
352
+ stateRef.current.grabberUnsafe =
353
+ Math.round(Math.max(stateRef.current.scrollOffset, 0) + stateRef.current.swipeOffset) ===
354
+ LayoutMetrics.current.remainingAvailableHeight;
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
+ scrollContextProviderRef.current?.notify({ scrollTop: 0 });
388
+ }, [recalcContentOverlayPosition, recalcScrollFlags]);
367
389
  const handleExitAnimationStart = useCallback(() => {
368
- resetTranslateY();
390
+ setTransformToVisible();
369
391
  onBeforeExit?.();
370
- }, [resetTranslateY, onBeforeExit]);
392
+ }, [setTransformToVisible, onBeforeExit]);
371
393
  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
394
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `0px`);
383
395
  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);
396
+ stateRef.current.resizeRAFHandle !== null && cancelAnimationFrame(stateRef.current.resizeRAFHandle);
397
+ stateRef.current = { ...INITIAL_STATE };
394
398
  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;
399
+ }, [onAfterExit]);
400
+ const handleHeightAnimationStart = useCallback(() => {
401
+ LayoutMetrics.current.invalidateCache();
402
+ stateRef.current.scrollOffset = LayoutMetrics.current.initialOffset;
403
+ stateRef.current.swipeOffset = 0;
404
+ if (contentRef.current !== null) {
405
+ contentRef.current.style.transform = translateY(0);
403
406
  }
404
- if (Object.is(Math.round(scrollContainerRef.current.scrollTop), 0)) {
405
- stateRef.current.isSwipeEnabled = true;
406
- stateRef.current.swipeOffset = 0;
407
+ if (swipeContainerRef.current !== null) {
408
+ swipeContainerRef.current.style.transform = translateY(LayoutMetrics.current.initialOffset);
407
409
  }
408
- }, []);
409
- const handleSwipeMove = useCallback((event) => {
410
- if (!contentRef.current ||
411
- !contentOverlayRef.current ||
412
- !stateRef.current.isSwipeEnabled ||
413
- !scrollContainerRef.current) {
414
- return;
410
+ if (visibleHeightDiff !== null && visualContainerRef.current !== null) {
411
+ visualContainerRef.current.style.height = `${LayoutMetrics.current.bottomSheetHeight + visibleHeightDiff}px`;
415
412
  }
416
- const selection = document.getSelection();
417
- if (selection && !selection.isCollapsed) {
413
+ scrollContextProviderRef.current?.notify({ scrollTop: 0 });
414
+ }, [visibleHeightDiff]);
415
+ const handleHeightAnimationEnd = useCallback(() => {
416
+ LayoutMetrics.current.invalidateCache();
417
+ stateRef.current.heightAnimationRunning = false;
418
+ setVisibleHeightDiff(null);
419
+ requestAnimationFrame(recalcContentOverlayPosition);
420
+ requestAnimationFrame(recalcScrollFlags);
421
+ }, [setVisibleHeightDiff, recalcContentOverlayPosition, recalcScrollFlags]);
422
+ const handleSwipeMove = useCallback((event) => {
423
+ if ((stateRef.current.touchAction !== null && stateRef.current.touchAction !== 'swipe') ||
424
+ hasSelectedText()) {
418
425
  return;
419
426
  }
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);
427
+ // храним неокругленное значение для translateY, чтобы анимация была плавнее
428
+ let newSwipeOffset = stateRef.current.swipeOffset + event.deltaY;
429
+ if (Math.round(newSwipeOffset) <= 0) {
430
+ // боттомшит уперся в верхний край экрана, не даем свайпать дальше
431
+ newSwipeOffset = 0;
432
+ if (stateRef.current.touchAction === 'swipe') {
433
+ stateRef.current.touchAction = 'complete';
425
434
  }
426
- recalcScrollFlags();
427
435
  }
428
436
  else {
429
- contentRef.current.style.transform = ``;
430
- contentOverlayRef.current.style.transform = ``;
431
- stateRef.current.isSwipeEnabled = false;
432
- changeSwipeProgressState(stateRef, scrollContainerRef, false, !!allowScrollWhileFocused);
437
+ // свайп в процессе
438
+ stateRef.current.touchAction = 'swipe';
433
439
  }
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 = ``;
440
+ if (stateRef.current.swipeOffset !== newSwipeOffset) {
441
+ stateRef.current.swipeOffset = newSwipeOffset;
442
+ if (swipeContainerRef.current !== null) {
443
+ swipeContainerRef.current.style.transform = translateY(Math.max(stateRef.current.scrollOffset, 0) + newSwipeOffset);
444
+ }
445
+ recalcScrollFlags();
443
446
  }
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();
447
+ }, [recalcScrollFlags]);
448
+ const handleSwipeCancel = useCallback(() => {
449
+ if (stateRef.current.touchAction === 'swipe') {
450
+ stateRef.current.swipeOffset = 0;
451
+ stateRef.current.touchAction = null;
452
+ if (swipeContainerRef.current !== null) {
453
+ swipeContainerRef.current.classList.add(styles.swipeCancelAnimation);
454
+ const swipeContainer = swipeContainerRef.current;
455
+ setTimeout(() => swipeContainer.classList.remove(styles.swipeCancelAnimation), 100);
456
+ swipeContainerRef.current.style.transform = translateY(Math.max(stateRef.current.scrollOffset, 0));
457
+ }
458
+ recalcScrollFlags();
452
459
  }
453
- else {
454
- handleSwipeCancel(event);
460
+ }, [recalcScrollFlags]);
461
+ const handleSwipeEnd = useCallback(() => {
462
+ if (stateRef.current.touchAction === 'swipe') {
463
+ onCloseRef.current();
455
464
  }
456
- }, [handleClose, handleSwipeCancel]);
457
- const { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel } = useSwipe({
458
- thresholdYRef: SWIPE_THRESHOLD_REF,
459
- onSwipeStart: handleSwipeStart,
465
+ }, []);
466
+ const swipeHandlers = useSwipeHandlers({
467
+ thresholdYRef: SWIPE_THRESHOLD,
460
468
  onSwipeMove: handleSwipeMove,
461
469
  onSwipeEnd: handleSwipeEnd,
462
470
  onSwipeCancel: handleSwipeCancel,
463
471
  });
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;
472
+ useEffect(() => {
473
+ if (!currentVisible || visualContainerRef.current === null) {
474
+ return void 0;
483
475
  }
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`);
476
+ const handleScroll = (event) => {
477
+ if ((!allowScrollWhileFocused && stateRef.current.hasFocus) ||
478
+ (stateRef.current.touchAction !== null && stateRef.current.touchAction !== 'scroll') ||
479
+ hasSelectedText()) {
480
+ return;
492
481
  }
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);
482
+ if (LayoutMetrics.current.initialOffset !== 0 ||
483
+ LayoutMetrics.current.contentHeight > LayoutMetrics.current.scrollContainerHeight) {
484
+ // храним неокругленное значение для translateY, чтобы анимация была плавнее
485
+ let newScrollOffset = stateRef.current.scrollOffset + event.delta;
486
+ const roundedNewScrollOffset = Math.round(newScrollOffset);
487
+ if (roundedNewScrollOffset >= LayoutMetrics.current.initialOffset) {
488
+ // скролла нет (touchAction is null)
489
+ // либо контент проскроллен в начало, тогда не даем скроллить дальше
490
+ newScrollOffset = LayoutMetrics.current.initialOffset;
491
+ if (stateRef.current.touchAction === 'scroll') {
492
+ stateRef.current.touchAction = 'complete';
493
+ }
494
+ }
495
+ else if (LayoutMetrics.current.contentHeight + roundedNewScrollOffset <=
496
+ LayoutMetrics.current.scrollContainerHeight) {
497
+ // скролла нет (touchAction is null)
498
+ // либо контент проскроллен до конца, тогда не даем скроллить дальше
499
+ newScrollOffset = LayoutMetrics.current.scrollContainerHeight - LayoutMetrics.current.contentHeight;
500
+ if (stateRef.current.touchAction === 'scroll') {
501
+ stateRef.current.touchAction = 'complete';
502
+ }
503
+ }
504
+ else {
505
+ // скролл в процессе
506
+ stateRef.current.touchAction = 'scroll';
507
+ }
508
+ if (stateRef.current.scrollOffset !== newScrollOffset) {
509
+ const offsetWasPositive = stateRef.current.scrollOffset > 0;
510
+ stateRef.current.scrollOffset = newScrollOffset;
511
+ if (contentRef.current !== null && swipeContainerRef.current !== null) {
512
+ if (newScrollOffset > 0) {
513
+ if (!offsetWasPositive) {
514
+ contentRef.current.style.transform = translateY(0);
515
+ }
516
+ swipeContainerRef.current.style.transform = translateY(newScrollOffset);
517
+ if (footerRef.current !== null) {
518
+ footerRef.current.style.transform = translateY(-newScrollOffset);
519
+ }
520
+ }
521
+ else {
522
+ contentRef.current.style.transform = translateY(newScrollOffset);
523
+ if (offsetWasPositive) {
524
+ swipeContainerRef.current.style.transform = translateY(0);
525
+ if (footerRef.current !== null) {
526
+ footerRef.current.style.transform = translateY(0);
527
+ }
528
+ }
529
+ }
530
+ }
531
+ recalcScrollFlags();
532
+ scrollContextProviderRef.current?.notify({
533
+ scrollTop: Math.max(-stateRef.current.scrollOffset, 0),
534
+ });
500
535
  }
501
- scrollContainerRef.current.scrollTo({ top: scrollTop });
502
536
  }
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();
537
+ };
538
+ const handleScrollEnd = () => {
539
+ if (stateRef.current.touchAction === 'scroll') {
540
+ stateRef.current.touchAction = null;
541
+ }
542
+ };
543
+ const handleTouchStart = (event) => {
544
+ if (stateRef.current.touchAction === 'complete') {
545
+ stateRef.current.touchAction = null;
546
+ }
547
+ swipeHandlers.onTouchStart(event);
548
+ };
549
+ const handleTouchMove = (event) => {
550
+ event.preventDefault();
551
+ event.stopPropagation();
552
+ swipeHandlers.onTouchMove(event);
553
+ };
554
+ const removeScrollHandlers = initTouchHandlers('vertical', visualContainerRef, handleScroll, handleScrollEnd);
555
+ const visualContainer = visualContainerRef.current;
556
+ visualContainer.addEventListener('touchstart', handleTouchStart);
557
+ visualContainer.addEventListener('touchmove', handleTouchMove);
558
+ visualContainer.addEventListener('touchend', swipeHandlers.onTouchEnd);
559
+ visualContainer.addEventListener('touchcancel', swipeHandlers.onTouchCancel);
560
+ return () => {
561
+ removeScrollHandlers();
562
+ visualContainer.removeEventListener('touchstart', handleTouchStart);
563
+ visualContainer.removeEventListener('touchmove', handleTouchMove);
564
+ visualContainer.removeEventListener('touchend', swipeHandlers.onTouchEnd);
565
+ visualContainer.removeEventListener('touchcancel', swipeHandlers.onTouchCancel);
566
+ };
567
+ }, [allowScrollWhileFocused, currentVisible, recalcScrollFlags, swipeHandlers]);
568
+ const { onTouchEnd, ...eventHandlers } = useNoBubbling();
511
569
  if (!animationTimeout) {
512
570
  return null;
513
571
  }
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, {
572
+ const renderFunc = (appearTransition, heightTransition) => (jsx(Layer, { layer: InternalLayerName.BottomSheet, children: jsxs("div", { ...eventHandlers, className: styles.overlay, children: [jsx("div", { className: classnames(styles.overlayBackground, {
573
+ [styles.overlayBackgroundVisible]: showOverlay,
574
+ [styles.appearAnimation]: appearTransition === 'entering',
575
+ [styles.disappearAnimation]: appearTransition === 'exiting',
576
+ }), "data-qa": "bottom-sheet-overlay", onClick: appearTransition === 'entered' ? onCloseRef.current : undefined, ref: overlayRef }), jsxs("div", { className: classnames(styles.swipeContainer, {
517
577
  [styles.appearAnimation]: appearTransition === 'entering',
578
+ [styles.closeBySwipeAnimation]: appearTransition === 'exiting' && stateRef.current.touchAction === 'swipe',
518
579
  [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',
580
+ }), "data-qa": "bottom-sheet-container", ref: swipeContainerRef, children: [jsx("div", { className: classnames(styles.grabber, styles.grabberTransitionAnimation, {
581
+ [styles.grabberEnsureSafe]: stateRef.current.grabberUnsafe,
582
+ }), ref: grabberRef }), jsx("div", { className: classnames(styles.visualContainer, {
583
+ [styles.visualContainerFullScreen]: height === 'full-screen',
534
584
  [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);
585
+ }), "data-qa": "bottom-sheet-content", ref: bottomSheetRef, children: jsxs(CustomScrollContextProvider, { ref: scrollContextProviderRef, 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, {
586
+ [styles.contentWithPaddings]: withContentPaddings,
587
+ [styles.contentWithoutHeader]: !header,
588
+ }), 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, {
589
+ [styles.dividerVisible]: stateRef.current.dividerVisible,
590
+ }), ref: dividerRef, children: jsx(Divider, {}) })), interceptClickHandlers ? jsx(ClickInterceptor, { children: footer }) : footer] })] }) }), jsx("div", { className: styles.contentOverlay, ref: contentOverlayRef })] })] }) }));
591
+ 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
592
  };
547
593
  const BottomSheet = forwardRef(BottomSheetRenderFunc);
548
- BottomSheet.displayName = 'BottomSheet';
549
594
 
550
595
  export { BottomSheet };
551
596
  //# sourceMappingURL=BottomSheet.js.map