@hh.ru/magritte-ui-bottom-sheet 5.3.29 → 5.5.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
@@ -16,11 +16,10 @@ import { ClickInterceptor } from './ClickInterceptor.js';
16
16
  import { useBreakpoint } from '@hh.ru/magritte-ui-breakpoint';
17
17
  import { Divider } from '@hh.ru/magritte-ui-divider';
18
18
  import { Layer } from '@hh.ru/magritte-ui-layer';
19
- import { NavigationBarContext } from '@hh.ru/magritte-ui-navigation-bar';
19
+ import { isNavigationBarComponent, NavigationBarComponent } from '@hh.ru/magritte-ui-navigation-bar';
20
20
  import { isValidTreeSelectorWrapper } from '@hh.ru/magritte-ui-tree-selector';
21
- import { s as styles } from './bottom-sheet-W0jm1DWc.js';
21
+ import { s as styles } from './bottom-sheet-CPDqyiXV.js';
22
22
 
23
- const NAVIGATION_BAR_CONTEXT_PROPS = { insideBottomSheet: true };
24
23
  const CSS_VAR_ENTER_ANIMATION_DURATION = '--enter-animation-duration';
25
24
  const CSS_VAR_EXIT_ANIMATION_DURATION = '--exit-animation-duration';
26
25
  const CSS_VAR_HEIGHT_ANIMATION_DURATION = '--height-transition-duration';
@@ -30,15 +29,7 @@ const hasSelectedText = () => {
30
29
  const selection = document.getSelection();
31
30
  return !!selection && !selection.isCollapsed;
32
31
  };
33
- const isSafariFunc = () => {
34
- let lazyValue = null;
35
- return () => {
36
- if (lazyValue === null) {
37
- lazyValue = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
38
- }
39
- return lazyValue;
40
- };
41
- };
32
+ const isSafari = () => /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
42
33
  const toNumber = (value) => {
43
34
  const result = parseInt(value, 10);
44
35
  return Number.isInteger(result) ? result : 0;
@@ -52,6 +43,7 @@ const INITIAL_STATE = {
52
43
  scrollOffset: 0,
53
44
  swipeOffset: 0,
54
45
  touchAction: null,
46
+ exitHandlers: [],
55
47
  heightAnimationDiff: null,
56
48
  };
57
49
  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) => {
@@ -69,8 +61,8 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
69
61
  const swipeContainerRef = useRef(null);
70
62
  const visualContainerRef = useRef(null);
71
63
  const bottomSheetRef = useMultipleRefs(ref, visualContainerRef);
72
- const { isMobile } = useBreakpoint();
73
- const currentVisible = isMobile && visible;
64
+ const scrollContextProviderRef = useRef(null);
65
+ const currentVisible = useBreakpoint().isMobile && visible;
74
66
  const onAppearRef = useRef(onAppear);
75
67
  onAppearRef.current = onAppear;
76
68
  const onCloseRef = useRef(onClose);
@@ -78,75 +70,72 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
78
70
  const stateRef = useRef({ ...INITIAL_STATE });
79
71
  const [animationTimeout, setAnimationTimeout] = useState(null);
80
72
  const [heightAnimationRunning, setHeightAnimationRunning] = useState(false);
81
- const contextValue = useMemo(() => ({ contentOverlayRef }), [contentOverlayRef]);
82
- const isSafari = useRef(isSafariFunc()).current;
83
- const scrollContextProviderRef = useRef(null);
84
- const exitHandlersRef = useRef([]);
73
+ const bottomSheetContext = useMemo(() => ({ contentOverlayRef }), [contentOverlayRef]);
85
74
  const isContentSizedFullHeight = isValidTreeSelectorWrapper(children);
75
+ const deviceFlagsRef = useRef({});
76
+ if (typeof deviceFlagsRef.current.isSafari !== 'boolean' && typeof navigator !== 'undefined') {
77
+ deviceFlagsRef.current.isSafari = isSafari();
78
+ }
79
+ if (typeof deviceFlagsRef.current.useCustomScroll !== 'boolean' && typeof window !== 'undefined') {
80
+ deviceFlagsRef.current.useCustomScroll = 'ontouchstart' in window;
81
+ }
86
82
  const LayoutMetrics = useRef((() => {
87
- let cachedBottomSheetHeight = null;
88
- let cachedContentHeight = null;
89
- let cachedFooterHeight = null;
90
- let cachedHeaderHeight = null;
91
- let cachedInitialOffset = null;
92
- let cachedRemainingAvailableHeight = null;
93
- let cachedScrollContainerHeight = null;
83
+ let cache = {};
94
84
  return {
85
+ get availableScrollHeight() {
86
+ if (!('availableScrollHeight' in cache) && scrollContainerRef.current !== null) {
87
+ cache.availableScrollHeight =
88
+ scrollContainerRef.current.clientHeight - LayoutMetrics.current.headerHeight;
89
+ }
90
+ return cache.availableScrollHeight ?? 0;
91
+ },
95
92
  get bottomSheetHeight() {
96
- if (cachedBottomSheetHeight === null && visualContainerRef.current !== null) {
97
- cachedBottomSheetHeight = visualContainerRef.current.clientHeight;
93
+ if (!('bottomSheetHeight' in cache) && visualContainerRef.current !== null) {
94
+ cache.bottomSheetHeight = visualContainerRef.current.clientHeight;
98
95
  }
99
- return cachedBottomSheetHeight ?? 0;
96
+ return cache.bottomSheetHeight ?? 0;
100
97
  },
101
98
  get contentHeight() {
102
- if (cachedContentHeight === null && contentRef.current !== null) {
103
- cachedContentHeight = contentRef.current.clientHeight;
99
+ if (!('contentHeight' in cache) && contentRef.current !== null) {
100
+ cache.contentHeight = contentRef.current.clientHeight;
104
101
  }
105
- return cachedContentHeight ?? 0;
102
+ return cache.contentHeight ?? 0;
106
103
  },
107
104
  get footerHeight() {
108
- if (cachedFooterHeight === null && footerRef.current !== null) {
109
- cachedFooterHeight = footerRef.current.clientHeight;
105
+ if (!('footerHeight' in cache) && footerRef.current !== null) {
106
+ cache.footerHeight = footerRef.current.clientHeight;
110
107
  }
111
- return cachedFooterHeight ?? 0;
108
+ return cache.footerHeight ?? 0;
112
109
  },
113
110
  get headerHeight() {
114
- if (cachedHeaderHeight === null && headerRef.current !== null) {
115
- cachedHeaderHeight = headerRef.current.clientHeight;
111
+ if (!('headerHeight' in cache) && headerRef.current !== null) {
112
+ cache.headerHeight = headerRef.current.layoutHeight;
116
113
  }
117
- return cachedHeaderHeight ?? 0;
114
+ return cache.headerHeight ?? 0;
118
115
  },
119
116
  get initialOffset() {
120
- if (cachedInitialOffset === null &&
121
- visualContainerRef.current !== null &&
122
- visualViewport !== null) {
123
- cachedInitialOffset =
124
- height === 'half-screen'
125
- ? Math.max(Math.round(visualContainerRef.current.clientHeight - visualViewport.height / 2), 0)
126
- : 0;
117
+ if (!('initialOffset' in cache)) {
118
+ if (height === 'half-screen' &&
119
+ deviceFlagsRef.current.useCustomScroll &&
120
+ visualContainerRef.current !== null &&
121
+ visualViewport !== null) {
122
+ const halfScreenOffset = visualContainerRef.current.clientHeight - visualViewport.height / 2;
123
+ cache.initialOffset = Math.max(Math.round(halfScreenOffset), 0);
124
+ }
125
+ else {
126
+ cache.initialOffset = 0;
127
+ }
127
128
  }
128
- return cachedInitialOffset ?? 0;
129
+ return cache.initialOffset ?? 0;
129
130
  },
130
131
  get remainingAvailableHeight() {
131
- if (cachedRemainingAvailableHeight === null && grabberRef.current !== null) {
132
- cachedRemainingAvailableHeight = grabberRef.current.offsetTop;
133
- }
134
- return cachedRemainingAvailableHeight ?? 0;
135
- },
136
- get scrollContainerHeight() {
137
- if (cachedScrollContainerHeight === null && scrollContainerRef.current !== null) {
138
- cachedScrollContainerHeight = scrollContainerRef.current.clientHeight;
132
+ if (!('remainingAvailableHeight' in cache) && grabberRef.current !== null) {
133
+ cache.remainingAvailableHeight = grabberRef.current.offsetTop;
139
134
  }
140
- return cachedScrollContainerHeight ?? 0;
135
+ return cache.remainingAvailableHeight ?? 0;
141
136
  },
142
137
  invalidateCache() {
143
- cachedBottomSheetHeight = null;
144
- cachedContentHeight = null;
145
- cachedFooterHeight = null;
146
- cachedHeaderHeight = null;
147
- cachedInitialOffset = null;
148
- cachedRemainingAvailableHeight = null;
149
- cachedScrollContainerHeight = null;
138
+ cache = {};
150
139
  },
151
140
  };
152
141
  })());
@@ -177,75 +166,8 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
177
166
  document.body.removeChild(animationTimeoutElement);
178
167
  setAnimationTimeout({ appear: { enter, exit }, height: { enter: height, exit } });
179
168
  }, [setAnimationTimeout]);
180
- // при изменении высоты контента анимируем ее
181
- // задаем боттомшиту фиксированную высоту и пересчитываем ее самостоятельно,
182
- // чтобы не было мерцания, когда новый контент отрисовался до срабатывания колбека ResizeObserver
183
- const initHeightObserver = useCallback(() => {
184
- if (!contentRef.current || !footerRef.current || !headerRef.current || !visualContainerRef.current) {
185
- return void 0;
186
- }
187
- const visualContainer = visualContainerRef.current;
188
- let prevContentHeight = 0;
189
- let prevHeaderHeight = 0;
190
- let prevFooterHeight = 0;
191
- let prevScrollContainerHeight = 0;
192
- let skipFirst = true;
193
- const observer = new ResizeObserver(() => {
194
- LayoutMetrics.current.invalidateCache();
195
- if (skipFirst) {
196
- visualContainer.style.height = `${LayoutMetrics.current.bottomSheetHeight}px`;
197
- prevHeaderHeight = LayoutMetrics.current.headerHeight;
198
- prevContentHeight = LayoutMetrics.current.contentHeight;
199
- prevFooterHeight = LayoutMetrics.current.footerHeight;
200
- prevScrollContainerHeight = LayoutMetrics.current.scrollContainerHeight;
201
- skipFirst = false;
202
- return;
203
- }
204
- if (stateRef.current.heightAnimationDiff !== null) {
205
- // если предыдущая анимация не завершилась, без анимации сбрасываем высоту на вычисленную браузером
206
- visualContainer.style.height = ``;
207
- }
208
- else {
209
- const contentHeightDiff = LayoutMetrics.current.contentHeight - prevContentHeight;
210
- const containersHeightDiff = LayoutMetrics.current.headerHeight -
211
- prevHeaderHeight +
212
- LayoutMetrics.current.footerHeight -
213
- prevFooterHeight;
214
- // предположим, что scrollContainer останется таким же или станет меньше
215
- // тогда можем рассчитать минимальную видимую высоту контента как min(scrollContainer.height, contentHeight)
216
- const prevVisibleContentHeight = Math.min(prevScrollContainerHeight, prevContentHeight);
217
- const newMinVisibleContentHeight = Math.min(prevScrollContainerHeight - containersHeightDiff, LayoutMetrics.current.contentHeight);
218
- const minVisibleContentHeightDiff = newMinVisibleContentHeight - prevVisibleContentHeight;
219
- // предположим, что scrollContainer станет больше
220
- // тогда контент не может увеличиться больше, чем на расстояние между боттомшитом и верхним краем экрана
221
- const maxVisibleContentHeightDiff = LayoutMetrics.current.remainingAvailableHeight - containersHeightDiff;
222
- const visibleContentHeightDiff = Math.min(Math.max(contentHeightDiff, minVisibleContentHeightDiff), maxVisibleContentHeightDiff);
223
- if (visibleContentHeightDiff !== 0) {
224
- scrollContextProviderRef.current?.notify({ scrollTop: 0 });
225
- }
226
- const heightAnimationDiff = visibleContentHeightDiff + containersHeightDiff;
227
- if (heightAnimationDiff !== 0) {
228
- // запоминаем высоту scrollContainer после того, как боттомшиту будет присвоена новая высота.
229
- // до этого значение некорректно, т.к. новый контент уже был отрендерен,
230
- // но в инлайн-стилях боттомшита остается старая высота
231
- stateRef.current.heightAnimationDiff = heightAnimationDiff;
232
- stateRef.current.heightAnimationCallback = () => {
233
- prevScrollContainerHeight = LayoutMetrics.current.scrollContainerHeight;
234
- };
235
- setHeightAnimationRunning(true);
236
- }
237
- }
238
- prevHeaderHeight = LayoutMetrics.current.headerHeight;
239
- prevContentHeight = LayoutMetrics.current.contentHeight;
240
- prevFooterHeight = LayoutMetrics.current.footerHeight;
241
- });
242
- observer.observe(headerRef.current);
243
- observer.observe(contentRef.current);
244
- observer.observe(footerRef.current);
245
- return () => observer.disconnect();
246
- }, [setHeightAnimationRunning]);
247
169
  useEffect(() => {
248
- if (!currentVisible || isSafari()) {
170
+ if (!currentVisible || deviceFlagsRef.current.isSafari) {
249
171
  return;
250
172
  }
251
173
  // используем Virtual Keyboard API через meta-тег вместо navigator.virtualKeyboard,
@@ -265,7 +187,7 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
265
187
  .map((keyValuePair) => keyValuePair.join('='))
266
188
  .join(',');
267
189
  meta.setAttribute('content', attributesStrUpdated);
268
- }, [currentVisible, isSafari, keyboardOverlaysContent]);
190
+ }, [currentVisible, keyboardOverlaysContent]);
269
191
  const recalcKeyboardOffsets = useCallback(() => {
270
192
  if (!headerRef.current || !overlayRef.current || !visualViewport) {
271
193
  return;
@@ -302,8 +224,7 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
302
224
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `${-layoutViewportDiff}px`);
303
225
  // при этом может возникнуть проблема, что клавиатура перекрыла хедер
304
226
  // проверяем это и компенсируем величину перекрытия при необходимости
305
- const headerDOMRect = headerRef.current.getBoundingClientRect();
306
- const headerOutOfViewportHeight = Math.round(headerDOMRect.bottom + layoutViewportDiff - DOCUMENT_HEIGHT.current);
227
+ const headerOutOfViewportHeight = Math.round(headerRef.current.bottom + layoutViewportDiff - DOCUMENT_HEIGHT.current);
307
228
  if (headerOutOfViewportHeight > 0) {
308
229
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `${headerOutOfViewportHeight - layoutViewportDiff}px`);
309
230
  }
@@ -311,7 +232,7 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
311
232
  }
312
233
  // keyboardOverlaysContent=false, клавиатура ПОД контентом
313
234
  // этот кейс нужно корректировать только в Safari
314
- if (!keyboardOverlaysContent && isSafari()) {
235
+ if (!keyboardOverlaysContent && deviceFlagsRef.current.isSafari) {
315
236
  const visualViewportDiff = Math.round(overlayDOMRect.bottom - visualViewport.height);
316
237
  if (visualViewportDiff > 0) {
317
238
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `${visualViewportShift}px`);
@@ -324,7 +245,7 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
324
245
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `0px`);
325
246
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `0px`);
326
247
  }
327
- }, [isSafari, keyboardOverlaysContent]);
248
+ }, [keyboardOverlaysContent]);
328
249
  const handleFocus = useCallback((event) => {
329
250
  const focusedElement = event.target;
330
251
  const initialViewportHeight = visualViewport?.height;
@@ -355,7 +276,7 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
355
276
  }
356
277
  };
357
278
  const handleBlur = () => {
358
- if (isSafari()) {
279
+ if (deviceFlagsRef.current.isSafari) {
359
280
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `0px`);
360
281
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `0px`);
361
282
  }
@@ -370,7 +291,7 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
370
291
  stateRef.current.resizeRAFHandle = requestAnimationFrame(waitForResize);
371
292
  visualViewport?.addEventListener('resize', handleResize);
372
293
  focusedElement.addEventListener('blur', handleBlur);
373
- }, [isSafari, recalcKeyboardOffsets]);
294
+ }, [recalcKeyboardOffsets]);
374
295
  // contentOverlay совпадает по границам с контентом боттомшита, но лежит вне боттомшита,
375
296
  // чтобы чайлды contentOverlay не обрезались границами боттомшита
376
297
  // например, снекбар, лежащий внутри contentOverlay, при смахивании может оказаться в любом месте экрана
@@ -379,15 +300,18 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
379
300
  if (contentOverlayRef.current !== null &&
380
301
  scrollContainerRef.current !== null &&
381
302
  visualContainerRef.current !== null) {
382
- contentOverlayRef.current.style.top = `${scrollContainerRef.current.offsetTop + visualContainerRef.current.offsetTop}px`;
383
- contentOverlayRef.current.style.height = `${scrollContainerRef.current.clientHeight}px`;
303
+ const visibleHeaderHeight = headerRef.current?.visibleHeight ?? 0;
304
+ contentOverlayRef.current.style.top = `${scrollContainerRef.current.offsetTop + visualContainerRef.current.offsetTop + visibleHeaderHeight}px`;
305
+ contentOverlayRef.current.style.height = `${LayoutMetrics.current.availableScrollHeight}px`;
384
306
  }
385
307
  }, []);
386
308
  const recalcScrollFlags = useCallback(() => {
309
+ const scrollOffset = deviceFlagsRef.current.useCustomScroll
310
+ ? stateRef.current.scrollOffset
311
+ : -(scrollContainerRef.current?.scrollTop ?? 0);
387
312
  if (dividerRef.current !== null) {
388
313
  const prevDividerVisible = stateRef.current.dividerVisible;
389
- const isNotScrolledToEnd = LayoutMetrics.current.contentHeight + stateRef.current.scrollOffset >
390
- LayoutMetrics.current.scrollContainerHeight;
314
+ const isNotScrolledToEnd = LayoutMetrics.current.contentHeight + scrollOffset > LayoutMetrics.current.availableScrollHeight;
391
315
  stateRef.current.dividerVisible =
392
316
  showDivider === 'always' || (showDivider === 'with-scroll' && isNotScrolledToEnd);
393
317
  if (stateRef.current.dividerVisible !== prevDividerVisible) {
@@ -397,13 +321,21 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
397
321
  if (grabberRef.current !== null) {
398
322
  const prevGrabberUnsafe = stateRef.current.grabberUnsafe;
399
323
  stateRef.current.grabberUnsafe =
400
- Math.round(Math.max(stateRef.current.scrollOffset, 0) + stateRef.current.swipeOffset) ===
324
+ Math.round(Math.max(scrollOffset, 0) + stateRef.current.swipeOffset) ===
401
325
  LayoutMetrics.current.remainingAvailableHeight;
402
326
  if (stateRef.current.grabberUnsafe !== prevGrabberUnsafe) {
403
327
  grabberRef.current.classList.toggle(styles.grabberEnsureSafe, stateRef.current.grabberUnsafe);
404
328
  }
405
329
  }
406
330
  }, [showDivider]);
331
+ const resetScrollPosition = useCallback(() => {
332
+ if (deviceFlagsRef.current.useCustomScroll) {
333
+ scrollContextProviderRef.current?.notify({ scrollTop: 0 });
334
+ }
335
+ else {
336
+ scrollContainerRef.current?.scrollTo({ top: 0 });
337
+ }
338
+ }, []);
407
339
  // помещает боттомшит в позицию вне экрана снизу, которая может быть начальной либо конечной точкой анимации
408
340
  const setTransformToInvisible = useCallback(() => {
409
341
  LayoutMetrics.current.invalidateCache();
@@ -431,8 +363,8 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
431
363
  }
432
364
  requestAnimationFrame(recalcContentOverlayPosition);
433
365
  requestAnimationFrame(recalcScrollFlags);
434
- scrollContextProviderRef.current?.notify({ scrollTop: 0 });
435
- }, [recalcContentOverlayPosition, recalcScrollFlags]);
366
+ resetScrollPosition();
367
+ }, [recalcContentOverlayPosition, recalcScrollFlags, resetScrollPosition]);
436
368
  const handleExitAnimationStart = useCallback(() => {
437
369
  setTransformToVisible();
438
370
  onBeforeExit?.();
@@ -442,8 +374,8 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
442
374
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `0px`);
443
375
  stateRef.current.resizeRAFHandle !== null && cancelAnimationFrame(stateRef.current.resizeRAFHandle);
444
376
  stateRef.current = { ...INITIAL_STATE };
445
- exitHandlersRef.current.forEach((handler) => handler());
446
- exitHandlersRef.current = [];
377
+ stateRef.current.exitHandlers.forEach((handler) => handler());
378
+ stateRef.current.exitHandlers = [];
447
379
  onAfterExit?.();
448
380
  }, [onAfterExit]);
449
381
  const handleHeightAnimationStart = useCallback(() => {
@@ -471,7 +403,8 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
471
403
  setHeightAnimationRunning(false);
472
404
  requestAnimationFrame(recalcContentOverlayPosition);
473
405
  requestAnimationFrame(recalcScrollFlags);
474
- }, [setHeightAnimationRunning, recalcContentOverlayPosition, recalcScrollFlags]);
406
+ resetScrollPosition();
407
+ }, [setHeightAnimationRunning, recalcContentOverlayPosition, recalcScrollFlags, resetScrollPosition]);
475
408
  const handleSwipeMove = useCallback((event) => {
476
409
  if ((stateRef.current.touchAction !== null && stateRef.current.touchAction !== 'swipe') ||
477
410
  hasSelectedText()) {
@@ -523,12 +456,13 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
523
456
  onSwipeCancel: handleSwipeCancel,
524
457
  });
525
458
  const initTransformHandlers = useCallback(() => {
526
- if (!visualContainerRef.current) {
459
+ const visualContainer = visualContainerRef.current;
460
+ if (!visualContainer) {
527
461
  return void 0;
528
462
  }
529
463
  const handleScroll = (delta) => {
530
464
  if (LayoutMetrics.current.initialOffset !== 0 ||
531
- LayoutMetrics.current.contentHeight > LayoutMetrics.current.scrollContainerHeight) {
465
+ LayoutMetrics.current.contentHeight > LayoutMetrics.current.availableScrollHeight) {
532
466
  // храним неокругленное значение для translateY, чтобы анимация была плавнее
533
467
  let newScrollOffset = stateRef.current.scrollOffset + delta;
534
468
  const roundedNewScrollOffset = Math.round(newScrollOffset);
@@ -541,10 +475,10 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
541
475
  }
542
476
  }
543
477
  else if (LayoutMetrics.current.contentHeight + roundedNewScrollOffset <=
544
- LayoutMetrics.current.scrollContainerHeight) {
478
+ LayoutMetrics.current.availableScrollHeight) {
545
479
  // скролла нет (touchAction is null)
546
480
  // либо контент проскроллен до конца, тогда не даем скроллить дальше
547
- newScrollOffset = LayoutMetrics.current.scrollContainerHeight - LayoutMetrics.current.contentHeight;
481
+ newScrollOffset = LayoutMetrics.current.availableScrollHeight - LayoutMetrics.current.contentHeight;
548
482
  if (stateRef.current.touchAction === 'scroll') {
549
483
  stateRef.current.touchAction = 'complete';
550
484
  }
@@ -591,12 +525,6 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
591
525
  }
592
526
  handleScroll(event.delta);
593
527
  };
594
- const onWheelMove = (event) => {
595
- if (!allowScrollWhileFocused && stateRef.current.hasFocus) {
596
- return;
597
- }
598
- handleScroll(event.delta);
599
- };
600
528
  const onTouchEnd = () => {
601
529
  if (stateRef.current.touchAction === 'scroll') {
602
530
  stateRef.current.touchAction = null;
@@ -618,9 +546,7 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
618
546
  wrapperRef: visualContainerRef,
619
547
  onTouchMove,
620
548
  onTouchEnd,
621
- onWheelMove,
622
549
  });
623
- const visualContainer = visualContainerRef.current;
624
550
  visualContainer.addEventListener('touchstart', handleTouchStart);
625
551
  visualContainer.addEventListener('touchmove', handleTouchMove);
626
552
  visualContainer.addEventListener('touchend', swipeHandlers.onTouchEnd);
@@ -633,38 +559,133 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
633
559
  visualContainer.removeEventListener('touchcancel', swipeHandlers.onTouchCancel);
634
560
  };
635
561
  }, [allowScrollWhileFocused, recalcScrollFlags, swipeHandlers]);
562
+ // при изменении высоты контента анимируем ее
563
+ // задаем боттомшиту фиксированную высоту и пересчитываем ее самостоятельно,
564
+ // чтобы не было мерцания, когда новый контент отрисовался до срабатывания колбека ResizeObserver
565
+ const initHeightObserver = useCallback(() => {
566
+ const visualContainer = visualContainerRef.current;
567
+ if (!visualContainer) {
568
+ return void 0;
569
+ }
570
+ let prevContentHeight = 0;
571
+ let prevHeaderHeight = 0;
572
+ let prevFooterHeight = 0;
573
+ let prevAvailableScrollHeight = 0;
574
+ let skipFirstResizeCallback = true;
575
+ let collapseResizeCallbacks = false;
576
+ const handleHeightChange = () => {
577
+ LayoutMetrics.current.invalidateCache();
578
+ if (skipFirstResizeCallback) {
579
+ visualContainer.style.height = `${LayoutMetrics.current.bottomSheetHeight}px`;
580
+ prevHeaderHeight = LayoutMetrics.current.headerHeight;
581
+ prevContentHeight = LayoutMetrics.current.contentHeight;
582
+ prevFooterHeight = LayoutMetrics.current.footerHeight;
583
+ prevAvailableScrollHeight = LayoutMetrics.current.availableScrollHeight;
584
+ skipFirstResizeCallback = false;
585
+ return;
586
+ }
587
+ if (stateRef.current.heightAnimationDiff !== null) {
588
+ if (!collapseResizeCallbacks) {
589
+ // если предыдущая анимация не завершилась, без анимации сбрасываем высоту на вычисленную браузером
590
+ visualContainer.style.height = ``;
591
+ }
592
+ }
593
+ else {
594
+ const contentHeightDiff = LayoutMetrics.current.contentHeight - prevContentHeight;
595
+ const containersHeightDiff = LayoutMetrics.current.headerHeight -
596
+ prevHeaderHeight +
597
+ LayoutMetrics.current.footerHeight -
598
+ prevFooterHeight;
599
+ // предположим, что scrollContainer останется таким же или станет меньше
600
+ // тогда можем рассчитать минимальную видимую высоту контента как min(scrollContainer.height, contentHeight)
601
+ const prevVisibleContentHeight = Math.min(prevAvailableScrollHeight, prevContentHeight);
602
+ const newMinVisibleContentHeight = Math.min(prevAvailableScrollHeight - containersHeightDiff, LayoutMetrics.current.contentHeight);
603
+ const minVisibleContentHeightDiff = newMinVisibleContentHeight - prevVisibleContentHeight;
604
+ // предположим, что scrollContainer станет больше
605
+ // тогда контент не может увеличиться больше, чем на расстояние между боттомшитом и верхним краем экрана
606
+ const maxVisibleContentHeightDiff = LayoutMetrics.current.remainingAvailableHeight - containersHeightDiff;
607
+ const visibleContentHeightDiff = Math.min(Math.max(contentHeightDiff, minVisibleContentHeightDiff), maxVisibleContentHeightDiff);
608
+ if (visibleContentHeightDiff !== 0) {
609
+ resetScrollPosition();
610
+ }
611
+ const heightAnimationDiff = visibleContentHeightDiff + containersHeightDiff;
612
+ if (heightAnimationDiff !== 0) {
613
+ // запоминаем высоту scrollContainer после того, как боттомшиту будет присвоена новая высота.
614
+ // до этого значение некорректно, т.к. новый контент уже был отрендерен,
615
+ // но в инлайн-стилях боттомшита остается старая высота
616
+ stateRef.current.heightAnimationDiff = heightAnimationDiff;
617
+ stateRef.current.heightAnimationCallback = () => {
618
+ prevAvailableScrollHeight = LayoutMetrics.current.availableScrollHeight;
619
+ };
620
+ setHeightAnimationRunning(true);
621
+ collapseResizeCallbacks = true;
622
+ requestAnimationFrame(() => {
623
+ collapseResizeCallbacks = false;
624
+ });
625
+ }
626
+ else {
627
+ recalcContentOverlayPosition();
628
+ recalcScrollFlags();
629
+ }
630
+ }
631
+ prevHeaderHeight = LayoutMetrics.current.headerHeight;
632
+ prevContentHeight = LayoutMetrics.current.contentHeight;
633
+ prevFooterHeight = LayoutMetrics.current.footerHeight;
634
+ };
635
+ const resizeObserver = new ResizeObserver(handleHeightChange);
636
+ const content = contentRef.current;
637
+ const footer = footerRef.current;
638
+ const header = headerRef.current;
639
+ content !== null && resizeObserver.observe(content);
640
+ footer !== null && resizeObserver.observe(footer);
641
+ header !== null && header.addHeightObserver(handleHeightChange);
642
+ return () => {
643
+ resizeObserver.disconnect();
644
+ header !== null && header.removeHeightObserver(handleHeightChange);
645
+ };
646
+ }, [setHeightAnimationRunning, recalcScrollFlags, recalcContentOverlayPosition, resetScrollPosition]);
636
647
  const handleAppearAnimationEnd = useCallback(() => {
637
- exitHandlersRef.current.push(...[initHeightObserver(), initTransformHandlers()].filter(Boolean));
648
+ const removeHeightObserver = initHeightObserver();
649
+ removeHeightObserver && stateRef.current.exitHandlers.push(removeHeightObserver);
650
+ const removeTransformHandlers = deviceFlagsRef.current.useCustomScroll && initTransformHandlers();
651
+ removeTransformHandlers && stateRef.current.exitHandlers.push(removeTransformHandlers);
638
652
  onAppearRef.current?.();
639
653
  }, [initHeightObserver, initTransformHandlers]);
640
654
  const { onTouchEnd, ...eventHandlers } = useNoBubbling();
641
655
  if (!animationTimeout) {
642
656
  return null;
643
657
  }
644
- const clonedFooter = footer && isActionBarComponent(footer) ? cloneElement(footer, { type: footer.props.type || 'mobile' }) : footer;
645
- const renderFunc = (appearTransition, heightTransition) => (jsx(Layer, { layer: InternalLayerName.BottomSheet, children: jsxs("div", { ...eventHandlers, className: styles.overlay, children: [jsx("div", { className: classnames(styles.overlayBackground, {
646
- [styles.overlayBackgroundVisible]: showOverlay,
647
- [styles.appearAnimation]: appearTransition === 'entering',
648
- [styles.disappearAnimation]: appearTransition === 'exiting',
649
- }), ...(appearTransition === 'entered'
650
- ? { 'data-qa': 'bottom-sheet-overlay', onClick: onCloseRef.current }
651
- : {}), ref: overlayRef }), jsxs("div", { className: classnames(styles.swipeContainer, {
652
- [styles.appearAnimation]: appearTransition === 'entering',
653
- [styles.closeBySwipeAnimation]: appearTransition === 'exiting' && stateRef.current.touchAction === 'swipe',
654
- [styles.disappearAnimation]: appearTransition === 'exiting',
655
- }), "data-qa": appearTransition === 'entered' ? 'bottom-sheet-container' : undefined, ref: swipeContainerRef, children: [jsx("div", { className: classnames(styles.grabber, styles.grabberTransitionAnimation, {
656
- [styles.grabberEnsureSafe]: stateRef.current.grabberUnsafe,
657
- }), ref: grabberRef }), jsx("div", { className: classnames(styles.visualContainer, {
658
- [styles.visualContainerFullScreen]: height === 'full-screen',
659
- [styles.heightTransitionAnimation]: heightTransition === 'entering',
660
- }), "data-qa": appearTransition === 'entered' ? 'bottom-sheet-content' : undefined, ref: bottomSheetRef, children: jsxs(CustomScrollContextProvider, { ref: scrollContextProviderRef, children: [jsx("div", { className: styles.header, onFocus: handleFocus, ref: headerRef, children: jsx(NavigationBarContext.Provider, { value: NAVIGATION_BAR_CONTEXT_PROPS, children: header }) }), jsx("div", { className: styles.scrollContainer, ref: scrollContainerRef, children: jsx("div", { className: classnames(styles.content, {
661
- [styles.contentFullScreen]: height === 'full-screen',
662
- [styles.contentWithPaddings]: withContentPaddings,
663
- [styles.contentWithoutHeader]: !header,
664
- [styles.contentSizedFullScreen]: isContentSizedFullHeight,
665
- }), 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, {
658
+ const renderFunc = (appearTransition, heightTransition) => {
659
+ const navigationBar = header && isNavigationBarComponent(header) ? (jsx(NavigationBarComponent, { ...header.props, forwardedRef: headerRef })) : null;
660
+ const content = (jsx("div", { className: classnames(styles.content, {
661
+ [styles.contentFullScreen]: height === 'full-screen',
662
+ [styles.contentWithPaddings]: withContentPaddings,
663
+ [styles.contentWithoutHeader]: !header,
664
+ [styles.contentSizedFullScreen]: isContentSizedFullHeight,
665
+ }), ref: contentRef, children: jsx(BottomSheetContext.Provider, { value: bottomSheetContext, children: children }) }));
666
+ const scrollContainer = deviceFlagsRef.current.useCustomScroll ? (jsx(CustomScrollContextProvider, { ref: scrollContextProviderRef, children: jsxs("div", { className: styles.scrollContainer, onFocus: handleFocus, ref: scrollContainerRef, children: [navigationBar, content] }) })) : (jsxs("div", { className: classnames(styles.scrollContainer, styles.nativeScrollContainer), onFocus: handleFocus, onScroll: recalcScrollFlags, ref: scrollContainerRef, children: [navigationBar, content] }));
667
+ const clonedFooter = footer && isActionBarComponent(footer)
668
+ ? cloneElement(footer, { type: footer.props.type || 'mobile', showDivider: false })
669
+ : footer;
670
+ return (jsx(Layer, { layer: InternalLayerName.BottomSheet, children: jsxs("div", { ...eventHandlers, className: styles.overlay, children: [jsx("div", { className: classnames(styles.overlayBackground, {
671
+ [styles.overlayBackgroundVisible]: showOverlay,
672
+ [styles.appearAnimation]: appearTransition === 'entering',
673
+ [styles.disappearAnimation]: appearTransition === 'exiting',
674
+ }), ...(appearTransition === 'entered'
675
+ ? { 'data-qa': 'bottom-sheet-overlay', onClick: onCloseRef.current }
676
+ : {}), ref: overlayRef }), jsxs("div", { className: classnames(styles.swipeContainer, {
677
+ [styles.appearAnimation]: appearTransition === 'entering',
678
+ [styles.closeBySwipeAnimation]: appearTransition === 'exiting' && stateRef.current.touchAction === 'swipe',
679
+ [styles.disappearAnimation]: appearTransition === 'exiting',
680
+ }), "data-qa": appearTransition === 'entered' ? 'bottom-sheet-container' : undefined, ref: swipeContainerRef, children: [jsx("div", { className: classnames(styles.grabber, styles.grabberTransitionAnimation, {
681
+ [styles.grabberEnsureSafe]: stateRef.current.grabberUnsafe,
682
+ }), ref: grabberRef }), jsxs("div", { className: classnames(styles.visualContainer, {
683
+ [styles.visualContainerFullScreen]: height === 'full-screen',
684
+ [styles.heightTransitionAnimation]: heightTransition === 'entering',
685
+ }), "data-qa": appearTransition === 'entered' ? 'bottom-sheet-content' : undefined, ref: bottomSheetRef, children: [scrollContainer, jsxs("div", { className: styles.footer, ref: footerRef, children: [footer && (jsx("div", { className: classnames(styles.divider, {
666
686
  [styles.dividerVisible]: stateRef.current.dividerVisible,
667
- }), ref: dividerRef, children: jsx(Divider, {}) })), interceptClickHandlers ? (jsx(ClickInterceptor, { children: clonedFooter })) : (clonedFooter)] })] }) }), jsx("div", { className: styles.contentOverlay, ref: contentOverlayRef })] })] }) }));
687
+ }), ref: dividerRef, children: jsx(Divider, {}) })), interceptClickHandlers ? (jsx(ClickInterceptor, { children: clonedFooter })) : (clonedFooter)] })] }), jsx("div", { className: styles.contentOverlay, ref: contentOverlayRef })] })] }) }));
688
+ };
668
689
  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: handleAppearAnimationEnd, onExit: handleExitAnimationStart, onExiting: setTransformToInvisible, onExited: handleExitAnimationEnd, timeout: animationTimeout.appear, unmountOnExit: true, nodeRef: contentRef, children: (appearTransition) => (jsx(Transition, { in: heightAnimationRunning, onEnter: handleHeightAnimationStart, onEntered: handleHeightAnimationEnd, timeout: animationTimeout.height, nodeRef: contentRef, children: (heightTransition) => renderFunc(appearTransition, heightTransition) })) }) }), document.body);
669
690
  };
670
691
  const BottomSheet = forwardRef(BottomSheetRenderFunc);