@hh.ru/magritte-ui-bottom-sheet 6.0.1 → 7.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
@@ -18,18 +18,18 @@ import { Divider } from '@hh.ru/magritte-ui-divider';
18
18
  import { Layer } from '@hh.ru/magritte-ui-layer';
19
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-BNgV4RDn.js';
21
+ import { s as styles } from './bottom-sheet-C4VJXwIv.js';
22
22
 
23
23
  const CSS_VAR_ENTER_ANIMATION_DURATION = '--enter-animation-duration';
24
24
  const CSS_VAR_EXIT_ANIMATION_DURATION = '--exit-animation-duration';
25
25
  const CSS_VAR_HEIGHT_ANIMATION_DURATION = '--height-transition-duration';
26
26
  const CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET = '--virtual-keyboard-top-offset';
27
27
  const CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET = '--virtual-keyboard-bottom-offset';
28
+ const checkSafari = () => /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
28
29
  const hasSelectedText = () => {
29
30
  const selection = document.getSelection();
30
31
  return !!selection && !selection.isCollapsed;
31
32
  };
32
- const isSafari = () => /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
33
33
  const toNumber = (value) => {
34
34
  const result = parseInt(value, 10);
35
35
  return Number.isInteger(result) ? result : 0;
@@ -56,7 +56,7 @@ const makeInitialState = () => ({
56
56
  exitHandlers: [],
57
57
  heightAnimationDiff: null,
58
58
  });
59
- 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) => {
59
+ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, header, height = 'content', interceptClickHandlers = true, keyboardOverlaysFooter = true, onAppear, onBeforeExit, onAfterExit, onClose, showDivider = 'with-scroll', showOverlay = true, visible = false, withContentPaddings = true, }, ref) => {
60
60
  const DOCUMENT_HEIGHT = useRef(0);
61
61
  const SWIPE_THRESHOLD = useRef({ max: Infinity });
62
62
  const contentRef = useRef(null);
@@ -83,13 +83,25 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
83
83
  const [heightAnimationRunning, setHeightAnimationRunning] = useState(false);
84
84
  const bottomSheetContext = useMemo(() => ({ contentOverlayRef }), [contentOverlayRef]);
85
85
  const isContentSizedFullHeight = isValidTreeSelectorWrapper(children);
86
- const deviceFlagsRef = useRef({});
87
- if (typeof deviceFlagsRef.current.isSafari !== 'boolean' && typeof navigator !== 'undefined') {
88
- deviceFlagsRef.current.isSafari = isSafari();
89
- }
90
- if (typeof deviceFlagsRef.current.hasTouchSupport !== 'boolean' && typeof window !== 'undefined') {
91
- deviceFlagsRef.current.hasTouchSupport = 'ontouchstart' in window;
92
- }
86
+ const virtualKeyboardOffsetsRef = useRef({ top: 0, bottom: 0 });
87
+ const [isSafari, setIsSafari] = useState(null);
88
+ useEffect(() => setIsSafari(checkSafari()), [setIsSafari]);
89
+ const [hasTouchSupport, setHasTouchSupport] = useState(null);
90
+ const hasTouchSupportRef = useRef(null);
91
+ useEffect(() => {
92
+ if (!('matchMedia' in window)) {
93
+ return void 0;
94
+ }
95
+ const mediaQuery = window.matchMedia('(pointer:coarse)');
96
+ const handleMediaQueryChange = () => {
97
+ hasTouchSupportRef.current = mediaQuery.matches;
98
+ setHasTouchSupport(hasTouchSupportRef.current);
99
+ };
100
+ mediaQuery.addEventListener('change', handleMediaQueryChange);
101
+ hasTouchSupportRef.current = mediaQuery.matches;
102
+ setHasTouchSupport(hasTouchSupportRef.current);
103
+ return () => mediaQuery.removeEventListener('change', handleMediaQueryChange);
104
+ }, [setHasTouchSupport]);
93
105
  const bindEscapeToClose = useEscapeToClose(() => currentVisible && onClose?.());
94
106
  useEffect(() => bindEscapeToClose(document.body), [bindEscapeToClose]);
95
107
  const LayoutMetrics = useRef((() => {
@@ -150,7 +162,7 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
150
162
  get initialOffset() {
151
163
  if (!('initialOffset' in cache)) {
152
164
  if (height === 'half-screen' &&
153
- deviceFlagsRef.current.hasTouchSupport &&
165
+ hasTouchSupportRef.current &&
154
166
  visualContainerRef.current !== null &&
155
167
  visualViewport !== null) {
156
168
  const halfScreenOffset = visualContainerRef.current.clientHeight - visualViewport.height / 2;
@@ -162,6 +174,9 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
162
174
  }
163
175
  return cache.initialOffset ?? 0;
164
176
  },
177
+ get maxScrollTop() {
178
+ return LayoutMetrics.current.contentHeight - LayoutMetrics.current.visibleContentHeight;
179
+ },
165
180
  /**
166
181
  * Расстояние между верхним краем боттомшита и границей вьюпорта
167
182
  */
@@ -204,7 +219,7 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
204
219
  setAnimationTimeout({ appear: { enter, exit }, height: { enter: height, exit } });
205
220
  }, [setAnimationTimeout]);
206
221
  useEffect(() => {
207
- if (!currentVisible || deviceFlagsRef.current.isSafari) {
222
+ if (!currentVisible || isSafari) {
208
223
  return;
209
224
  }
210
225
  // используем Virtual Keyboard API через meta-тег вместо navigator.virtualKeyboard,
@@ -219,73 +234,129 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
219
234
  const attributes = (attributesStr !== null
220
235
  ? Object.fromEntries(attributesStr.split(',').map((keyValuePairStr) => keyValuePairStr.split('=')))
221
236
  : {});
222
- attributes['interactive-widget'] = keyboardOverlaysContent ? 'resizes-visual' : 'resizes-content';
237
+ attributes['interactive-widget'] = keyboardOverlaysFooter ? 'resizes-visual' : 'resizes-content';
223
238
  const attributesStrUpdated = Object.entries(attributes)
224
239
  .map((keyValuePair) => keyValuePair.join('='))
225
240
  .join(',');
226
241
  meta.setAttribute('content', attributesStrUpdated);
227
- }, [currentVisible, keyboardOverlaysContent]);
242
+ }, [currentVisible, isSafari, keyboardOverlaysFooter]);
243
+ const fixOverscroll = useCallback(() => {
244
+ if (hasTouchSupport) {
245
+ const isOverscrolled = LayoutMetrics.current.contentHeight + stateRef.current.scrollOffset <
246
+ LayoutMetrics.current.visibleContentHeight;
247
+ if (isOverscrolled) {
248
+ stateRef.current.scrollOffset =
249
+ LayoutMetrics.current.visibleContentHeight - LayoutMetrics.current.contentHeight;
250
+ if (contentRef.current !== null) {
251
+ contentRef.current.style.transform = translateY(stateRef.current.scrollOffset);
252
+ }
253
+ scrollContextProviderRef.current?.notify({
254
+ scrollTop: -stateRef.current.scrollOffset,
255
+ maxScrollTop: LayoutMetrics.current.maxScrollTop,
256
+ });
257
+ }
258
+ }
259
+ }, [hasTouchSupport]);
260
+ const resetScrollPosition = useCallback(() => {
261
+ if (hasTouchSupport) {
262
+ stateRef.current.scrollOffset = LayoutMetrics.current.initialOffset;
263
+ stateRef.current.swipeOffset = 0;
264
+ if (contentRef.current !== null) {
265
+ contentRef.current.style.transform = translateY(0);
266
+ }
267
+ if (swipeContainerRef.current !== null) {
268
+ swipeContainerRef.current.style.transform = translateY(LayoutMetrics.current.initialOffset);
269
+ }
270
+ }
271
+ else {
272
+ scrollContainerRef.current?.scrollTo({ top: 0 });
273
+ }
274
+ }, [hasTouchSupport]);
275
+ const recalcScrollFlags = useCallback(() => {
276
+ const scrollOffset = hasTouchSupport
277
+ ? stateRef.current.scrollOffset
278
+ : -(scrollContainerRef.current?.scrollTop ?? 0);
279
+ if (dividerRef.current !== null) {
280
+ const prevDividerVisible = stateRef.current.dividerVisible;
281
+ const isNotScrolledToEnd = LayoutMetrics.current.contentHeight + scrollOffset > LayoutMetrics.current.visibleContentHeight;
282
+ stateRef.current.dividerVisible =
283
+ showDivider === 'always' || (showDivider === 'with-scroll' && isNotScrolledToEnd);
284
+ if (stateRef.current.dividerVisible !== prevDividerVisible) {
285
+ dividerRef.current.classList.toggle(styles.dividerVisible, stateRef.current.dividerVisible);
286
+ }
287
+ }
288
+ if (grabberRef.current !== null) {
289
+ const prevGrabberUnsafe = stateRef.current.grabberUnsafe;
290
+ stateRef.current.grabberUnsafe =
291
+ Math.round(Math.max(scrollOffset, 0) + stateRef.current.swipeOffset) ===
292
+ LayoutMetrics.current.remainingAvailableHeight;
293
+ if (stateRef.current.grabberUnsafe !== prevGrabberUnsafe) {
294
+ grabberRef.current.classList.toggle(styles.grabberEnsureSafe, stateRef.current.grabberUnsafe);
295
+ }
296
+ }
297
+ }, [hasTouchSupport, showDivider]);
298
+ // терминология: https://developer.chrome.com/blog/viewport-resize-behavior/
299
+ //
300
+ // при открытии виртуальной клавиатуры браузеры двигают Visual Viewport и Layout Viewport
301
+ // с помощью двух отступов делаем так, чтобы overlay боттомшита совпадал с видимой частью экрана
228
302
  const recalcKeyboardOffsets = useCallback(() => {
229
- if (!overlayRef.current || !visualViewport) {
303
+ if (!hasTouchSupport || !contentRef.current || !overlayRef.current || !visualViewport) {
230
304
  return;
231
305
  }
306
+ LayoutMetrics.current.invalidateCache();
232
307
  if (stateRef.current.hasFocus && focusedElementRef.current !== null) {
233
- // терминология: https://developer.chrome.com/blog/viewport-resize-behavior/
234
- //
235
- // делим браузеры на три группы в зависимости от поведения при открытии виртуальной клавиатуры:
236
- // 1. Safari — ресайзит Visual Viewport, не меняет Layout Viewport.
237
- // В нем не нужно ничего корректировать при keyboardOverlaysContent=true,
238
- // а при keyboardOverlaysContent=false нужно сдвинуть НИЖНИЙ край контейнера ВВЕРХ,
239
- // чтобы он совпал с границей Visual Viewport
240
- // 2. Chrome < 108 & Chromium-based — ресайзит и Visual Viewport, и Layout Viewport.
241
- // В нем не нужно ничего корректировать при keyboardOverlaysContent=false,
242
- // а при keyboardOverlaysContent=true нужно сдвинуть НИЖНИЙ край контейнера ВНИЗ,
243
- // чтобы футер уехал под клавиатуру
244
- // 3. Chrome >= 108 — поддерживает Virtual Keyboard API и meta-тег interactive-widget.
245
- // Используем поведение `resizes-visual` (как в Safari) в случае keyboardOverlaysContent=true
246
- // и `resizes-content` (как в Chrome < 108 & Chromium-based) в случае keyboardOverlaysContent=false
247
- // Таким образом в нем ничего не нужно корректировать
248
308
  const overlayDOMRect = overlayRef.current.getBoundingClientRect();
249
309
  // любой браузер может сдвинуть Visual Viewport вверх, если фокусируемый инпут находится близко к нижней границе
250
310
  // из-за этого может возникнуть проблема, что ВЕРХНИЙ край контента уехал за границу Visual Viewport
251
311
  // сдвигаем ВЕРХНИЙ край контейнера ВНИЗ, чтобы он совпал с границей Visual Viewport
252
- const visualViewportShift = Math.round(-overlayDOMRect.top);
253
- // keyboardOverlaysContent=true, клавиатура ПОВЕРХ контента
254
- // этот кейс нужно корректировать только в Chrome < 108 & Chromium-based
255
- if (keyboardOverlaysContent) {
256
- // браузеры из этой группы меняют Layout Viewport
257
- const layoutViewportDiff = Math.round(DOCUMENT_HEIGHT.current - document.documentElement.clientHeight);
258
- setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `${visualViewportShift}px`);
259
- if (layoutViewportDiff > 0) {
260
- setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `${visualViewportShift}px`);
261
- // сдвигаем НИЖНИЙ край контейнера ВНИЗ, чтобы футер уехал под клавиатуру
262
- setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `${-layoutViewportDiff}px`);
263
- // при этом может возникнуть проблема, что клавиатура перекрыла инпут
264
- // проверяем это и компенсируем величину перекрытия при необходимости
265
- const focusedElementOutOfViewportHeight = Math.round(focusedElementRef.current.getBoundingClientRect().bottom +
266
- layoutViewportDiff -
267
- DOCUMENT_HEIGHT.current);
268
- if (focusedElementOutOfViewportHeight > 0) {
269
- setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `${focusedElementOutOfViewportHeight - layoutViewportDiff}px`);
270
- }
271
- }
272
- }
273
- // keyboardOverlaysContent=false, клавиатура ПОД контентом
274
- // этот кейс нужно корректировать только в Safari
275
- if (!keyboardOverlaysContent && deviceFlagsRef.current.isSafari) {
312
+ const topOffset = Math.round(-overlayDOMRect.top);
313
+ let bottomOffset = 0;
314
+ if (isSafari) {
276
315
  const visualViewportDiff = Math.round(overlayDOMRect.bottom - visualViewport.height);
277
- setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `${visualViewportShift}px`);
278
316
  if (visualViewportDiff > 0) {
279
317
  // сдвигаем НИЖНИЙ край контейнера ВВЕРХ, чтобы он совпал с границей Visual Viewport
280
- setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `${visualViewportDiff}px`);
318
+ bottomOffset = visualViewportDiff;
281
319
  }
282
320
  }
321
+ if (keyboardOverlaysFooter && footerRef.current !== null) {
322
+ bottomOffset -= footerRef.current.clientHeight;
323
+ }
324
+ if (topOffset !== virtualKeyboardOffsetsRef.current.top ||
325
+ bottomOffset !== virtualKeyboardOffsetsRef.current.bottom) {
326
+ setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `${topOffset}px`);
327
+ setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `${bottomOffset}px`);
328
+ }
329
+ LayoutMetrics.current.invalidateCache();
330
+ // если фокусируемый элемент лежит внутри скроллящегося контейнера и не виден, скроллим к нему
331
+ if (contentRef.current.contains(focusedElementRef.current)) {
332
+ let focusedElementOffset = 0;
333
+ for (let offsetElement = focusedElementRef.current.closest('[data-interactive]') ?? focusedElementRef.current; offsetElement !== contentRef.current && offsetElement !== null; offsetElement = offsetElement.offsetParent) {
334
+ focusedElementOffset += offsetElement.offsetTop;
335
+ }
336
+ const newScrollOffset = -Math.min(Math.max(focusedElementOffset - 10, 0), LayoutMetrics.current.maxScrollTop);
337
+ if (stateRef.current.scrollOffset !== newScrollOffset) {
338
+ stateRef.current.scrollOffset = newScrollOffset;
339
+ contentRef.current.style.transform = translateY(stateRef.current.scrollOffset);
340
+ scrollContextProviderRef.current?.notify({
341
+ scrollTop: -stateRef.current.scrollOffset,
342
+ maxScrollTop: LayoutMetrics.current.maxScrollTop,
343
+ });
344
+ }
345
+ }
346
+ recalcScrollFlags();
347
+ virtualKeyboardOffsetsRef.current.top = topOffset;
348
+ virtualKeyboardOffsetsRef.current.bottom = bottomOffset;
283
349
  }
284
350
  else {
285
351
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `0px`);
286
352
  setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `0px`);
353
+ LayoutMetrics.current.invalidateCache();
354
+ fixOverscroll();
355
+ recalcScrollFlags();
356
+ virtualKeyboardOffsetsRef.current.top = 0;
357
+ virtualKeyboardOffsetsRef.current.bottom = 0;
287
358
  }
288
- }, [keyboardOverlaysContent]);
359
+ }, [fixOverscroll, hasTouchSupport, isSafari, keyboardOverlaysFooter, recalcScrollFlags]);
289
360
  const handleFocus = useCallback((event) => {
290
361
  const focusedElement = event.target;
291
362
  const initialViewportHeight = visualViewport?.height;
@@ -301,6 +372,7 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
301
372
  recalcKeyboardOffsets();
302
373
  if (!stateRef.current.hasFocus) {
303
374
  visualViewport?.removeEventListener('resize', handleResize);
375
+ visualViewport?.removeEventListener('scroll', handleResize);
304
376
  }
305
377
  };
306
378
  // если спамить фокус/блюр инпута, ивент visualViewport.resize может не долететь
@@ -308,6 +380,7 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
308
380
  const waitForResize = () => {
309
381
  if (performance.now() - resizeRAFStart > 1000 || visualViewport?.height !== initialViewportHeight) {
310
382
  visualViewport?.removeEventListener('resize', handleResize);
383
+ visualViewport?.removeEventListener('scroll', handleResize);
311
384
  stateRef.current.resizeRAFHandle = null;
312
385
  recalcKeyboardOffsets();
313
386
  }
@@ -316,10 +389,6 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
316
389
  }
317
390
  };
318
391
  const handleBlur = () => {
319
- if (deviceFlagsRef.current.isSafari) {
320
- setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_TOP_OFFSET, `0px`);
321
- setCSSVariable(CSS_VAR_VIRTUAL_KEYBOARD_BOTTOM_OFFSET, `0px`);
322
- }
323
392
  stateRef.current.hasFocus = false;
324
393
  if (stateRef.current.resizeRAFHandle !== null) {
325
394
  cancelAnimationFrame(stateRef.current.resizeRAFHandle);
@@ -327,13 +396,22 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
327
396
  }
328
397
  focusedElement.removeEventListener('blur', handleBlur);
329
398
  focusedElementRef.current = null;
399
+ if (isSafari) {
400
+ recalcKeyboardOffsets();
401
+ }
330
402
  };
331
403
  stateRef.current.hasFocus = true;
332
404
  stateRef.current.resizeRAFHandle = requestAnimationFrame(waitForResize);
333
405
  visualViewport?.addEventListener('resize', handleResize);
406
+ // событие scroll может прилететь, только если браузер сдвинул вьюпорт из-за появления виртуальной клавиатуры
407
+ // пользовательский скролл не вызывает событие scroll, т.к. нативный скролл отключен
408
+ // в Safari событие resize может прилететь до того как браузер сдвинул вьюпорт, поэтому слушаем scroll
409
+ if (isSafari) {
410
+ visualViewport?.addEventListener('scroll', handleResize);
411
+ }
334
412
  focusedElement.addEventListener('blur', handleBlur);
335
413
  focusedElementRef.current = focusedElement;
336
- }, [recalcKeyboardOffsets]);
414
+ }, [isSafari, recalcKeyboardOffsets]);
337
415
  // contentOverlay совпадает по границам с контентом боттомшита, но лежит вне боттомшита,
338
416
  // чтобы чайлды contentOverlay не обрезались границами боттомшита
339
417
  // например, снекбар, лежащий внутри contentOverlay, при смахивании может оказаться в любом месте экрана
@@ -346,29 +424,6 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
346
424
  contentOverlayRef.current.style.height = `${LayoutMetrics.current.visibleContentHeight}px`;
347
425
  }
348
426
  }, []);
349
- const recalcScrollFlags = useCallback(() => {
350
- const scrollOffset = deviceFlagsRef.current.hasTouchSupport
351
- ? stateRef.current.scrollOffset
352
- : -(scrollContainerRef.current?.scrollTop ?? 0);
353
- if (dividerRef.current !== null) {
354
- const prevDividerVisible = stateRef.current.dividerVisible;
355
- const isNotScrolledToEnd = LayoutMetrics.current.contentHeight + scrollOffset > LayoutMetrics.current.visibleContentHeight;
356
- stateRef.current.dividerVisible =
357
- showDivider === 'always' || (showDivider === 'with-scroll' && isNotScrolledToEnd);
358
- if (stateRef.current.dividerVisible !== prevDividerVisible) {
359
- dividerRef.current.classList.toggle(styles.dividerVisible, stateRef.current.dividerVisible);
360
- }
361
- }
362
- if (grabberRef.current !== null) {
363
- const prevGrabberUnsafe = stateRef.current.grabberUnsafe;
364
- stateRef.current.grabberUnsafe =
365
- Math.round(Math.max(scrollOffset, 0) + stateRef.current.swipeOffset) ===
366
- LayoutMetrics.current.remainingAvailableHeight;
367
- if (stateRef.current.grabberUnsafe !== prevGrabberUnsafe) {
368
- grabberRef.current.classList.toggle(styles.grabberEnsureSafe, stateRef.current.grabberUnsafe);
369
- }
370
- }
371
- }, [showDivider]);
372
427
  // помещает боттомшит в позицию вне экрана снизу, которая может быть начальной либо конечной точкой анимации
373
428
  const setTransformToInvisible = useCallback(() => {
374
429
  LayoutMetrics.current.invalidateCache();
@@ -410,21 +465,6 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
410
465
  stateRef.current.exitHandlers = [];
411
466
  onAfterExit?.();
412
467
  }, [onAfterExit]);
413
- const resetScrollPosition = useCallback(() => {
414
- if (deviceFlagsRef.current.hasTouchSupport) {
415
- stateRef.current.scrollOffset = LayoutMetrics.current.initialOffset;
416
- stateRef.current.swipeOffset = 0;
417
- if (contentRef.current !== null) {
418
- contentRef.current.style.transform = translateY(0);
419
- }
420
- if (swipeContainerRef.current !== null) {
421
- swipeContainerRef.current.style.transform = translateY(LayoutMetrics.current.initialOffset);
422
- }
423
- }
424
- else {
425
- scrollContainerRef.current?.scrollTo({ top: 0 });
426
- }
427
- }, []);
428
468
  const handleHeightAnimationStart = useCallback(() => {
429
469
  LayoutMetrics.current.invalidateCache();
430
470
  if (stateRef.current.heightAnimationDiff !== null && visualContainerRef.current !== null) {
@@ -444,7 +484,7 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
444
484
  requestAnimationFrame(recalcScrollFlags);
445
485
  scrollContextProviderRef.current?.notify({
446
486
  scrollTop: 0,
447
- maxScrollTop: LayoutMetrics.current.contentHeight - LayoutMetrics.current.visibleContentHeight,
487
+ maxScrollTop: LayoutMetrics.current.maxScrollTop,
448
488
  });
449
489
  }, [setHeightAnimationRunning, recalcContentOverlayPosition, recalcScrollFlags]);
450
490
  const handleSwipeMove = useCallback((event) => {
@@ -555,7 +595,7 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
555
595
  recalcScrollFlags();
556
596
  scrollContextProviderRef.current?.notify({
557
597
  scrollTop: Math.max(-stateRef.current.scrollOffset, 0),
558
- maxScrollTop: LayoutMetrics.current.contentHeight - LayoutMetrics.current.visibleContentHeight,
598
+ maxScrollTop: LayoutMetrics.current.maxScrollTop,
559
599
  });
560
600
  }
561
601
  }
@@ -565,7 +605,9 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
565
605
  if ((!allowScrollWhileFocused && stateRef.current.hasFocus) ||
566
606
  (stateRef.current.touchAction !== null && stateRef.current.touchAction !== 'scroll') ||
567
607
  hasSelectedText() ||
568
- (focusedElementRef.current !== null && focusedElementTouchY !== null)) {
608
+ (focusedElementRef.current !== null &&
609
+ focusedElementTouchY !== null &&
610
+ focusedElementRef.current.clientHeight !== focusedElementRef.current.scrollHeight)) {
569
611
  return;
570
612
  }
571
613
  handleScroll(event.delta);
@@ -589,6 +631,7 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
589
631
  focusedElementTouchY = null;
590
632
  swipeHandlers.onTouchStart(event);
591
633
  }
634
+ visualContainer.classList.add(styles.noCaret);
592
635
  };
593
636
  const handleTouchMove = (event) => {
594
637
  event.preventDefault();
@@ -601,6 +644,14 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
601
644
  swipeHandlers.onTouchMove(event);
602
645
  }
603
646
  };
647
+ const handleTouchEnd = (event) => {
648
+ swipeHandlers.onTouchEnd(event);
649
+ visualContainer.classList.remove(styles.noCaret);
650
+ };
651
+ const handleTouchCancel = (event) => {
652
+ swipeHandlers.onTouchCancel(event);
653
+ visualContainer.classList.remove(styles.noCaret);
654
+ };
604
655
  const removeScrollHandlers = initScrollHandlers({
605
656
  axis: 'vertical',
606
657
  wrapperRef: visualContainerRef,
@@ -609,14 +660,14 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
609
660
  });
610
661
  visualContainer.addEventListener('touchstart', handleTouchStart);
611
662
  visualContainer.addEventListener('touchmove', handleTouchMove);
612
- visualContainer.addEventListener('touchend', swipeHandlers.onTouchEnd);
613
- visualContainer.addEventListener('touchcancel', swipeHandlers.onTouchCancel);
663
+ visualContainer.addEventListener('touchend', handleTouchEnd);
664
+ visualContainer.addEventListener('touchcancel', handleTouchCancel);
614
665
  return () => {
615
666
  removeScrollHandlers();
616
667
  visualContainer.removeEventListener('touchstart', handleTouchStart);
617
668
  visualContainer.removeEventListener('touchmove', handleTouchMove);
618
- visualContainer.removeEventListener('touchend', swipeHandlers.onTouchEnd);
619
- visualContainer.removeEventListener('touchcancel', swipeHandlers.onTouchCancel);
669
+ visualContainer.removeEventListener('touchend', handleTouchEnd);
670
+ visualContainer.removeEventListener('touchcancel', handleTouchCancel);
620
671
  };
621
672
  }, [allowScrollWhileFocused, recalcScrollFlags, swipeHandlers]);
622
673
  // при изменении высоты контента анимируем ее
@@ -668,20 +719,11 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
668
719
  if (visibleContentHeightDiff !== 0) {
669
720
  resetScrollPosition();
670
721
  }
671
- else if (deviceFlagsRef.current.hasTouchSupport) {
722
+ else {
672
723
  // если высота видимой части контента не изменилась,
673
724
  // но позиция скролла превысила максимально допустимую из-за уменьшения высоты контента,
674
725
  // сбрасываем позицию скролла на максимально допустимую
675
- const isOverscrolled = LayoutMetrics.current.contentHeight + stateRef.current.scrollOffset <
676
- LayoutMetrics.current.visibleContentHeight;
677
- if (isOverscrolled) {
678
- stateRef.current.scrollOffset =
679
- LayoutMetrics.current.visibleContentHeight - LayoutMetrics.current.contentHeight;
680
- if (contentRef.current !== null) {
681
- contentRef.current.style.transform = translateY(stateRef.current.scrollOffset);
682
- }
683
- scrollContextProviderRef.current?.notify({ scrollTop: -stateRef.current.scrollOffset });
684
- }
726
+ fixOverscroll();
685
727
  }
686
728
  const heightAnimationDiff = visibleContentHeightDiff + containersHeightDiff;
687
729
  if (heightAnimationDiff !== 0) {
@@ -718,14 +760,20 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
718
760
  resizeObserver.disconnect();
719
761
  header !== null && header.removeHeightObserver(handleHeightChange);
720
762
  };
721
- }, [setHeightAnimationRunning, recalcScrollFlags, recalcContentOverlayPosition, resetScrollPosition]);
763
+ }, [
764
+ fixOverscroll,
765
+ recalcContentOverlayPosition,
766
+ recalcScrollFlags,
767
+ resetScrollPosition,
768
+ setHeightAnimationRunning,
769
+ ]);
722
770
  const handleAppearAnimationEnd = useCallback(() => {
723
771
  const removeHeightObserver = initHeightObserver();
724
772
  removeHeightObserver && stateRef.current.exitHandlers.push(removeHeightObserver);
725
- const removeTransformHandlers = deviceFlagsRef.current.hasTouchSupport && initTransformHandlers();
773
+ const removeTransformHandlers = hasTouchSupport && initTransformHandlers();
726
774
  removeTransformHandlers && stateRef.current.exitHandlers.push(removeTransformHandlers);
727
775
  onAppearRef.current?.();
728
- }, [initHeightObserver, initTransformHandlers]);
776
+ }, [hasTouchSupport, initHeightObserver, initTransformHandlers]);
729
777
  const { onTouchEnd, ...eventHandlers } = useNoBubbling();
730
778
  if (!animationTimeout) {
731
779
  return null;
@@ -738,7 +786,7 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
738
786
  [styles.contentWithoutHeader]: !header,
739
787
  [styles.contentSizedFullScreen]: isContentSizedFullHeight,
740
788
  }), ref: contentRef, children: jsx(BottomSheetContext.Provider, { value: bottomSheetContext, children: children }) }));
741
- const scrollContainer = deviceFlagsRef.current.hasTouchSupport ? (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] }));
789
+ const scrollContainer = hasTouchSupport ? (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] }));
742
790
  const clonedFooter = footer && isActionBarComponent(footer)
743
791
  ? cloneElement(footer, { type: footer.props.type || 'mobile', showDivider: false })
744
792
  : footer;
@@ -759,9 +807,9 @@ const BottomSheetRenderFunc = ({ allowScrollWhileFocused, children, footer, head
759
807
  [styles.heightTransitionAnimation]: heightTransition === 'entering',
760
808
  }), "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, {
761
809
  [styles.dividerVisible]: stateRef.current.dividerVisible,
762
- }), ref: dividerRef, children: jsx(Divider, {}) })), interceptClickHandlers && deviceFlagsRef.current.hasTouchSupport ? (jsx(ClickInterceptor, { children: clonedFooter })) : (clonedFooter)] })] }), jsx("div", { className: styles.contentOverlay, ref: contentOverlayRef })] })] }) }));
810
+ }), ref: dividerRef, children: jsx(Divider, {}) })), interceptClickHandlers && hasTouchSupport ? (jsx(ClickInterceptor, { children: clonedFooter })) : (clonedFooter)] })] }), jsx("div", { className: styles.contentOverlay, ref: contentOverlayRef })] })] }) }));
763
811
  };
764
- 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);
812
+ 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) })) }, `hasTouchSupport:${hasTouchSupport}`) }), document.body);
765
813
  };
766
814
  const BottomSheet = forwardRef(BottomSheetRenderFunc);
767
815