diff --git a/packages/vkui/src/helpers/range.ts b/packages/vkui/src/helpers/range.ts index 715a2febfb..982c67f1c2 100644 --- a/packages/vkui/src/helpers/range.ts +++ b/packages/vkui/src/helpers/range.ts @@ -24,3 +24,7 @@ export function rangeIncrement(from: number, to: number, step = 1): number[] { return range(from, to, step); } + +export function inRange(number: number, from: number, to: number) { + return number >= from && number <= to; +} diff --git a/packages/vkui/src/lib/adaptivity/functions.ts b/packages/vkui/src/lib/adaptivity/functions.ts index af99fdf8e0..8b8e416dea 100644 --- a/packages/vkui/src/lib/adaptivity/functions.ts +++ b/packages/vkui/src/lib/adaptivity/functions.ts @@ -1,6 +1,7 @@ import type { Exact } from '../../types'; +import { getWindow } from '../dom'; import { type PlatformType } from '../platform'; -import { BREAKPOINTS } from './breakpoints'; +import { BREAKPOINTS, MEDIA_QUERIES } from './breakpoints'; import { type SizeTypeValues, VIEW_WIDTH_TO_CSS_BREAKPOINT_MAP, @@ -138,6 +139,12 @@ export function tryToCheckIsDesktop( return (widthIsLikeDesktop && otherParametersIsLikeDesktop) || IS_VKCOM_CRUTCH; } +export function isSmallTablePlus(el: HTMLElement) { + const win = getWindow(el); + // eslint-disable-next-line no-restricted-properties + return win ? win.matchMedia(MEDIA_QUERIES.SMALL_TABLET_PLUS).matches : false; +} + /** * Конвертирует `viewWidth` в CSS брейкпоинты (см. тесты для наглядности). * diff --git a/packages/vkui/src/lib/dom.tsx b/packages/vkui/src/lib/dom.tsx index b17bc5e1d7..177dfafb07 100644 --- a/packages/vkui/src/lib/dom.tsx +++ b/packages/vkui/src/lib/dom.tsx @@ -15,6 +15,7 @@ export { getNodeScroll, isHTMLElement, isElement, + getParentNode, } from '@vkontakte/vkui-floating-ui/utils/dom'; export { canUseDOM, canUseEventListeners, onDOMLoaded } from '@vkontakte/vkjs'; @@ -260,3 +261,8 @@ export const initializeBrowserGesturePreventionEffect = (window: Window): VoidFu window.removeEventListener('touchmove', handleWindowTouchMove, options); }; }; + +export const hasSelectionWithRangeType = (node: unknown) => { + const selection = getWindow(node).getSelection(); + return selection ? selection.type === 'Range' : false; +}; diff --git a/packages/vkui/src/lib/sheet/constants.ts b/packages/vkui/src/lib/sheet/constants.ts new file mode 100644 index 0000000000..e6dc92b2dc --- /dev/null +++ b/packages/vkui/src/lib/sheet/constants.ts @@ -0,0 +1,32 @@ +/** @public */ +export const BLOCK_SHEET_BEHAVIOR_DATA_ATTRIBUTE_KEY = 'data-vkui-block-sheet-behavior'; + +/** @public */ +export const BLOCK_SHEET_BEHAVIOR_DATA_ATTRIBUTE = { + [BLOCK_SHEET_BEHAVIOR_DATA_ATTRIBUTE_KEY]: true, +}; + +/** @private */ +export const DRAG_THRESHOLDS = { + DISTANCE_FOR_MOVING_START: 12 as const, + VELOCITY: 500 as const, +}; + +/** @private */ +export const SNAP_POINT_SAFE_RANGE = { + LOWER: 25 as const, + HIGHEST: 90 as const, +}; + +/** @private */ +export const SNAP_POINT_DETENTS = { + MIN: 0 as const, + MEDIUM: 50 as const, + LARGE: 100 as const, +}; + +/** @private */ +export const DYNAMIC_SNAP_POINT_DATA = { + IDLE_POINT_VALUE: 0 as const, + COMPUTED_INDEX: 1 as const, +}; diff --git a/packages/vkui/src/lib/sheet/controllers/BottomSheetController.ts b/packages/vkui/src/lib/sheet/controllers/BottomSheetController.ts new file mode 100644 index 0000000000..5d7a2d5fbc --- /dev/null +++ b/packages/vkui/src/lib/sheet/controllers/BottomSheetController.ts @@ -0,0 +1,331 @@ +import { noop } from '@vkontakte/vkjs'; +import { clamp } from '../../../helpers/math'; +import { inRange } from '../../../helpers/range'; +import { rubberbandIfOutOfBounds } from '../../animation'; +import { hasSelectionWithRangeType } from '../../dom'; +import { UIPanGestureRecognizer } from '../../touch/UIPanGestureRecognizer'; +import { + BLOCK_SHEET_BEHAVIOR_DATA_ATTRIBUTE_KEY, + DRAG_THRESHOLDS, + DYNAMIC_SNAP_POINT_DATA, + SNAP_POINT_DETENTS, + SNAP_POINT_SAFE_RANGE, +} from '../constants'; +import type { CSSTransitionController } from './CSSTransitionController'; + +export type SnapPointDetents = [number, number] | [number, number, number]; + +export type BottomSheetControllerSnapPointData = { + unit: '%' | 'px'; + currentSnapPoint: number; + snapPointDetents: SnapPointDetents; +}; + +export type BottomSheetControllerOptions = { + sheetScrollEl: HTMLElement | null; + sheetTransitionController: CSSTransitionController; + backdropTransitionController: CSSTransitionController | null; + onDismiss: VoidFunction; +}; + +export class BottomSheetController { + static parseInitialSnapPoint( + initialSnapPoint: 'auto' | number = SNAP_POINT_DETENTS.MEDIUM, + ): BottomSheetControllerSnapPointData { + if (initialSnapPoint === 'auto') { + return { + unit: 'px', + currentSnapPoint: DYNAMIC_SNAP_POINT_DATA.IDLE_POINT_VALUE, + snapPointDetents: [SNAP_POINT_DETENTS.MIN, DYNAMIC_SNAP_POINT_DATA.IDLE_POINT_VALUE], + }; + } + + const currentSnapPoint = Math.min( + Math.max(initialSnapPoint, SNAP_POINT_SAFE_RANGE.LOWER), + SNAP_POINT_DETENTS.LARGE, + ); + + return { + unit: '%', + currentSnapPoint, + snapPointDetents: inRange( + currentSnapPoint, + SNAP_POINT_SAFE_RANGE.LOWER, + SNAP_POINT_SAFE_RANGE.HIGHEST, + ) + ? [SNAP_POINT_DETENTS.MIN, currentSnapPoint, SNAP_POINT_DETENTS.LARGE] + : [SNAP_POINT_DETENTS.MIN, currentSnapPoint], + }; + } + + constructor( + private readonly sheetEl: HTMLElement, + { + sheetScrollEl, + sheetTransitionController, + backdropTransitionController, + onDismiss, + }: BottomSheetControllerOptions, + ) { + this.onDismiss = onDismiss; + this.panGestureRecognizer = new UIPanGestureRecognizer(); + this.sheetScrollEl = sheetScrollEl; + this.sheetTransitionController = sheetTransitionController; + this.backdropTransitionController = backdropTransitionController; + } + + init(initialSnapPoint?: number | 'auto') { + this.isInitialized = true; + + const { unit, currentSnapPoint, snapPointDetents } = + BottomSheetController.parseInitialSnapPoint(initialSnapPoint); + + this.unit = unit; + this.currentSnapPoint = currentSnapPoint; + this.snapPointDetents = snapPointDetents; + } + + destroy() { + this.isInitialized = false; + this.pannedEl = null; + this.sheetTransitionController.cleanup(); + this.backdropTransitionController?.cleanup(); + + this.disableScrollBouncingDispose(); + this.disableScrollBouncingDispose = noop; + } + + panStart(event: UIEvent) { + if ( + !this.isInitialized || + this.panState !== 'idle' || + event.defaultPrevented || + hasSelectionWithRangeType(event.target) + ) { + return; + } + + this.panState = 'start'; + this.pannedEl = event.target as HTMLElement; + this.panGestureRecognizer.setStartCoords(event); + } + + panMove(event: UIEvent) { + switch (this.panState) { + case 'start': + this.panGestureRecognizer.setInitialTimeOnce(); + this.panGestureRecognizer.setEndCoords(event); + + if ( + event.defaultPrevented || + this.shouldBePreventedIfPanGestureDistanceIsNotAsExpected() || + this.shouldBePreventedIfPanGestureDirectionIsNotVertical() || + // Может быть `null` если нажали на Shadow DOM. + this.pannedEl === null || + this.shouldBePreventedIfPannedElIsExternal(this.pannedEl) || + this.shouldBePreventedByDataAttribute(this.pannedEl) || + this.shouldBePreventedIfIsScrolled(this.pannedEl) + ) { + return; + } + + this.panState = 'moving'; + this.panGestureRecognizer.setStartCoords(event); + + this.disableScrollBouncingDispose = BottomSheetController.disableScrollBouncing( + this.sheetScrollEl, + ); + this.sheetHeight = this.sheetEl.offsetHeight; + + if (this.isDynamicSnapPoint) { + this.currentSnapPoint = this.sheetHeight; + this.snapPointDetents[DYNAMIC_SNAP_POINT_DATA.COMPUTED_INDEX] = this.sheetHeight; + } + break; + case 'moving': + if (event.cancelable) { + event.preventDefault(); + } + + this.panGestureRecognizer.setEndCoords(event); + + const { y1, y2 } = this.panGestureRecognizer; + + this.nextSnapPoint = rubberbandIfOutOfBounds( + this.currentSnapPoint - ((y2 - y1) / this.sheetHeight) * this.currentSnapPoint, + SNAP_POINT_DETENTS.MIN, + this.isDynamicSnapPoint ? this.sheetHeight : SNAP_POINT_DETENTS.LARGE, + ); + + this.calculateSnapPoint(this.nextSnapPoint, true); + break; + } + } + + panEnd() { + switch (this.panState) { + case 'moving': + this.currentSnapPoint = this.getSnapPointTo(this.nextSnapPoint); + this.calculateSnapPoint(this.currentSnapPoint); + break; + } + + this.panState = 'idle'; + this.panGestureRecognizer.reset(); + + this.disableScrollBouncingDispose(); + this.disableScrollBouncingDispose = noop; + } + + private isInitialized = false; + private panState: 'idle' | 'start' | 'moving' = 'idle'; + private pannedEl: HTMLElement | null = null; + private sheetHeight = 0; + private rafId: number | null = null; + private currentSnapPoint = 0; + private nextSnapPoint = 0; + private snapPointDetents: SnapPointDetents = [0, 0]; + private unit: 'px' | '%' = '%'; + private get isDynamicSnapPoint() { + return this.unit === 'px'; + } + private disableScrollBouncingDispose = noop; + private readonly sheetScrollEl: HTMLElement | null; + private readonly sheetTransitionController: CSSTransitionController; + private readonly backdropTransitionController: CSSTransitionController | null; + private readonly panGestureRecognizer: UIPanGestureRecognizer; + private readonly onDismiss: VoidFunction; + + private calculateSnapPoint(nextSnapPoint: number, immediately = false) { + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + } + + if (nextSnapPoint <= SNAP_POINT_DETENTS.MIN) { + this.sheetTransitionController.enableTransition(); + this.backdropTransitionController?.enableTransition(); + this.onDismiss(); + return; + } + + const backdropOpacity = clamp( + this.isDynamicSnapPoint + ? nextSnapPoint / this.sheetHeight + : (nextSnapPoint * 2) / SNAP_POINT_DETENTS.LARGE, + 0, + 1, + ); + + this.rafId = requestAnimationFrame(() => { + if (immediately) { + this.backdropTransitionController?.disableTransition().set(backdropOpacity); + this.sheetTransitionController.disableTransition().set(`${nextSnapPoint}${this.unit}`); + return; + } + + if (this.isDynamicSnapPoint) { + this.sheetTransitionController.cleanupOnTransitionEnd(); + } + + this.backdropTransitionController?.unset(); + this.sheetTransitionController.enableTransition().set(`${this.currentSnapPoint}${this.unit}`); + }); + } + + private getSnapPointTo(nextSnapPoint: number) { + const closestSnapPoint = BottomSheetController.getClosestSnapPoint( + this.snapPointDetents, + nextSnapPoint, + ); + if (closestSnapPoint !== this.currentSnapPoint) { + return closestSnapPoint; + } + + const panDirection = this.panGestureRecognizer.direction(); + if (panDirection.axis !== 'y' || panDirection.direction === null) { + return this.currentSnapPoint; + } + + const velocity = this.panGestureRecognizer.velocity(); + if (Math.abs(velocity.y) < DRAG_THRESHOLDS.VELOCITY) { + return this.currentSnapPoint; + } + + const closestSnapPointByDirection = BottomSheetController.getClosestSnapPointByDirection( + this.snapPointDetents, + closestSnapPoint, + panDirection.direction, + ); + + return closestSnapPointByDirection; + } + + private shouldBePreventedIfPanGestureDistanceIsNotAsExpected() { + return this.panGestureRecognizer.distance() < DRAG_THRESHOLDS.DISTANCE_FOR_MOVING_START; + } + + private shouldBePreventedIfPanGestureDirectionIsNotVertical() { + return this.panGestureRecognizer.direction().axis === 'x'; + } + + private shouldBePreventedIfPannedElIsExternal(pannedEl: HTMLElement) { + return !this.sheetEl.contains(pannedEl); + } + + private shouldBePreventedByDataAttribute(pannedEl: HTMLElement) { + // eslint-disable-next-line no-restricted-properties + return pannedEl.closest(`[${BLOCK_SHEET_BEHAVIOR_DATA_ATTRIBUTE_KEY}=true]`); + } + + private shouldBePreventedIfIsScrolled(pannedEl: HTMLElement) { + if (this.sheetScrollEl === null || !this.sheetScrollEl.contains(pannedEl)) { + return false; + } + + if (this.sheetScrollEl.scrollTop === 0) { + const panDirection = this.panGestureRecognizer.direction(); + return panDirection.direction === -1; + } + + return true; + } + + private static disableScrollBouncing(node: HTMLElement | null) { + if (node === null) { + return noop; + } + node.style.setProperty('overflow', 'hidden'); + return function dispose() { + node.style.removeProperty('overflow'); + }; + } + + private static getClosestSnapPointByDirection( + snapPointDetents: SnapPointDetents, + currentY: number, + direction: -1 | 1, + ): number { + const foundIndex = snapPointDetents.findIndex((i) => i === currentY); + switch (direction) { + case -1: + return snapPointDetents[foundIndex + 1] ?? snapPointDetents[snapPointDetents.length - 1]; + case 1: + return snapPointDetents[foundIndex - 1] ?? snapPointDetents[0]; + } + } + + private static getClosestSnapPoint(snapPointDetents: SnapPointDetents, currentY: number) { + let closest = snapPointDetents[0]; + let minDifference = Math.abs(snapPointDetents[0] - currentY); + + for (let i = 1; i < snapPointDetents.length; i += 1) { + const difference = Math.abs(snapPointDetents[i] - currentY); + if (difference < minDifference) { + closest = snapPointDetents[i]; + minDifference = difference; + } + } + + return closest; + } +} diff --git a/packages/vkui/src/lib/sheet/controllers/CSSTransitionController.ts b/packages/vkui/src/lib/sheet/controllers/CSSTransitionController.ts new file mode 100644 index 0000000000..c0e23304e7 --- /dev/null +++ b/packages/vkui/src/lib/sheet/controllers/CSSTransitionController.ts @@ -0,0 +1,51 @@ +export type CSSTransitionControllerUnit = 'px' | '%' | ''; + +export class CSSTransitionController { + constructor( + public readonly el: HTMLElement, + public readonly property: string, + ) {} + + set(to: V) { + this.el.style.setProperty(this.property, `${to}`); + return this; + } + + unset(from?: V) { + if (from !== undefined) { + this.el.addEventListener('transitionend', this.handleTransitionEnd, { once: true }); + this.enableTransition(); + this.el.style.setProperty(this.property, `${from}`); + return this; + } + + return this.cleanup(); + } + + enableTransition() { + this.el.style.removeProperty('transition'); + return this; + } + + disableTransition() { + this.el.style.setProperty('transition', 'none'); + return this; + } + + cleanup() { + this.el.removeEventListener('transitionend', this.handleTransitionEnd); + this.el.style.removeProperty('transition'); + this.el.style.removeProperty(this.property); + return this; + } + + cleanupOnTransitionEnd() { + this.el.addEventListener('transitionend', this.handleTransitionEnd, { once: true }); + return this; + } + + private readonly handleTransitionEnd = () => { + this.cleanup(); + return this; + }; +} diff --git a/packages/vkui/src/lib/sheet/index.ts b/packages/vkui/src/lib/sheet/index.ts new file mode 100644 index 0000000000..954301a4a2 --- /dev/null +++ b/packages/vkui/src/lib/sheet/index.ts @@ -0,0 +1,10 @@ +export { + BLOCK_SHEET_BEHAVIOR_DATA_ATTRIBUTE_KEY, + BLOCK_SHEET_BEHAVIOR_DATA_ATTRIBUTE, +} from './constants'; +export { + type UseBottomSheetOptions, + type UseBottomSheetHandlers, + type UseBottomSheetResult, + useBottomSheet, +} from './useBottomSheet'; diff --git a/packages/vkui/src/lib/sheet/useBottomSheet.ts b/packages/vkui/src/lib/sheet/useBottomSheet.ts new file mode 100644 index 0000000000..c8d9b17e2e --- /dev/null +++ b/packages/vkui/src/lib/sheet/useBottomSheet.ts @@ -0,0 +1,143 @@ +'use client'; + +import { + type CSSProperties, + type Dispatch, + type SetStateAction, + type UIEvent, + type UIEventHandler, + useMemo, + useState, +} from 'react'; +import { noop } from '@vkontakte/vkjs'; +import { useStableCallback } from '../../hooks/useStableCallback'; +import { useIsomorphicLayoutEffect } from '../useIsomorphicLayoutEffect'; +import { BottomSheetController } from './controllers/BottomSheetController'; +import { CSSTransitionController } from './controllers/CSSTransitionController'; + +export type UseBottomSheetOptions = { + sheetCSSProperty: string; + backdropCSSProperty: string; + initialSnapPoint?: number | 'auto'; + blocked?: boolean; + onDismiss?: VoidFunction; +}; + +export type UseBottomSheetHandlers = { + onTouchStart: UIEventHandler; + onTouchMove: UIEventHandler; + onTouchEnd: UIEventHandler; + onMouseDown: UIEventHandler; + onMouseMove: UIEventHandler; + onMouseUp: UIEventHandler; + onMouseLeave: UIEventHandler; +}; + +export type UseBottomSheetResult = [ + { + initialStyle?: CSSProperties; + setSheetEl: Dispatch>; + setSheetScrollEl: Dispatch>; + setBackdropEl: Dispatch>; + }, + UseBottomSheetHandlers | undefined, +]; + +export const useBottomSheet = ( + enabled: boolean, + { + blocked, + initialSnapPoint, + sheetCSSProperty, + backdropCSSProperty, + onDismiss: onDismissProp, + }: UseBottomSheetOptions, +): UseBottomSheetResult => { + const [sheetScrollEl, setSheetScrollEl] = useState(null); + const [sheetEl, setSheetEl] = useState(null); + const [backdropEl, setBackdropEl] = useState(null); + + const initialStyle = useMemo(() => { + if (!enabled) { + return; + } + + const { unit, currentSnapPoint } = + BottomSheetController.parseInitialSnapPoint(initialSnapPoint); + + return unit === '%' ? { [sheetCSSProperty]: `${currentSnapPoint}${unit}` } : undefined; + }, [enabled, initialSnapPoint, sheetCSSProperty]); + + const onDismiss = useStableCallback(onDismissProp || noop); + const bsController = useMemo(() => { + if (!enabled || sheetEl === null) { + return null; + } + + return new BottomSheetController(sheetEl, { + sheetScrollEl: sheetScrollEl || null, + sheetTransitionController: new CSSTransitionController(sheetEl, sheetCSSProperty), + backdropTransitionController: backdropEl + ? new CSSTransitionController(backdropEl, backdropCSSProperty) + : null, + onDismiss, + }); + }, [ + enabled, + sheetEl, + sheetCSSProperty, + sheetScrollEl, + backdropEl, + backdropCSSProperty, + onDismiss, + ]); + + const onPanStart = function onPanStart(event: UIEvent) { + if (!blocked) { + bsController!.panStart(event.nativeEvent); + } + }; + + const onPanMove = function onPanMove(event: UIEvent) { + bsController!.panMove(event.nativeEvent); + }; + + const onPanEnd = function onPanEnd() { + bsController!.panEnd(); + }; + + useIsomorphicLayoutEffect( + function init() { + if (bsController) { + bsController.init(initialSnapPoint); + } + + return function destroy() { + if (bsController) { + bsController.destroy(); + } + }; + }, + [initialSnapPoint, bsController], + ); + + return [ + { + initialStyle, + setSheetEl, + setSheetScrollEl, + setBackdropEl, + }, + bsController !== null + ? { + onTouchStart: onPanStart, + onTouchMove: onPanMove, + onTouchEnd: onPanEnd, + onMouseDown: onPanStart, + onMouseMove: onPanMove, + onMouseUp: onPanEnd, + onMouseLeave: onPanEnd, + } + : undefined, + ]; +}; diff --git a/packages/vkui/src/lib/touch/UIPanGestureRecognizer.ts b/packages/vkui/src/lib/touch/UIPanGestureRecognizer.ts index 2c4bee1277..5ef72184dd 100644 --- a/packages/vkui/src/lib/touch/UIPanGestureRecognizer.ts +++ b/packages/vkui/src/lib/touch/UIPanGestureRecognizer.ts @@ -1,6 +1,8 @@ import { getFirstTouchEventData } from '../dom'; -export type VCoords = { x: number; y: number }; +export type Direction = { axis: 'x' | 'y'; direction: -1 | 1 | null }; + +export type Coords = { x: number; y: number }; const DEFAULT_INITIAL_TIME = 0; const MILLISECONDS = 1000; @@ -35,10 +37,7 @@ export class UIPanGestureRecognizer { this.y2 = clientY; } - delta(): { - x: number; - y: number; - } { + delta(): Coords { return { x: this.x2 - this.x1, y: this.y2 - this.y1, @@ -50,10 +49,7 @@ export class UIPanGestureRecognizer { return Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); } - velocity(): { - x: number; - y: number; - } { + velocity(): Coords { const deltaTime = (Date.now() - this.initialTime) / MILLISECONDS; if (deltaTime <= 0) { @@ -72,6 +68,13 @@ export class UIPanGestureRecognizer { return degrees < 0 ? 360 + degrees : degrees; } + direction(): Direction { + const { x, y } = this.delta(); + return Math.abs(x) > Math.abs(y) + ? { axis: 'x', direction: x > 0 ? 1 : x < 0 ? -1 : null } + : { axis: 'y', direction: y > 0 ? 1 : y < 0 ? -1 : null }; + } + reset(): void { this.initialTime = DEFAULT_INITIAL_TIME; this.x1 = this.y1 = 0; diff --git a/packages/vkui/src/lib/touch/index.ts b/packages/vkui/src/lib/touch/index.ts index edb0340c98..036ffd824d 100644 --- a/packages/vkui/src/lib/touch/index.ts +++ b/packages/vkui/src/lib/touch/index.ts @@ -1,5 +1,8 @@ export type * from './functions'; export { getSupportedEvents, coordX, coordY, touchEnabled, rubber } from './functions'; -export type { VCoords } from './UIPanGestureRecognizer'; +export type { + Direction as UIPanGestureRecognizerDirection, + Coords as UIPanGestureRecognizerCoords, +} from './UIPanGestureRecognizer'; export { UIPanGestureRecognizer } from './UIPanGestureRecognizer';