diff --git a/package-lock.json b/package-lock.json index d93f202..f338133 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "tsup": "8.4.0", "typescript": "5.8.3", "vite": "6.3.3", - "vitest": "3.1.2" + "vitest": "3.1.2", + "yalc": "1.0.0-pre.53" }, "engines": { "node": ">=18" @@ -2447,6 +2448,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/detect-indent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", + "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2883,6 +2894,28 @@ } } }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3277,6 +3310,55 @@ "node": ">=0.10.0" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-walk": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.4.tgz", + "integrity": "sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^3.0.4" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -3774,6 +3856,16 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4076,6 +4168,64 @@ "semver": "bin/semver" } }, + "node_modules/npm-bundled": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz", + "integrity": "sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^1.0.1" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz", + "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==", + "dev": true, + "license": "ISC" + }, + "node_modules/npm-packlist": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-2.2.2.tgz", + "integrity": "sha512-Jt01acDvJRhJGthnUJVF/w6gumWOZxO7IkpY/lsX9//zqQgnF7OJaxgQXcerd4uQOLu7W5bkb4mChL9mdfm+Zg==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.6", + "ignore-walk": "^3.0.3", + "npm-bundled": "^1.1.1", + "npm-normalize-package-bin": "^1.0.1" + }, + "bin": { + "npm-packlist": "bin/index.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm-packlist/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/npm-run-all": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", @@ -4231,6 +4381,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -4282,6 +4442,16 @@ "dev": true, "license": "MIT" }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", @@ -5633,6 +5803,16 @@ "node": ">=4" } }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -6123,6 +6303,13 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", @@ -6172,6 +6359,48 @@ "node": ">=10" } }, + "node_modules/yalc": { + "version": "1.0.0-pre.53", + "resolved": "https://registry.npmjs.org/yalc/-/yalc-1.0.0-pre.53.tgz", + "integrity": "sha512-tpNqBCpTXplnduzw5XC+FF8zNJ9L/UXmvQyyQj7NKrDNavbJtHvzmZplL5ES/RCnjX7JR7W9wz5GVDXVP3dHUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "detect-indent": "^6.0.0", + "fs-extra": "^8.0.1", + "glob": "^7.1.4", + "ignore": "^5.0.4", + "ini": "^2.0.0", + "npm-packlist": "^2.1.5", + "yargs": "^16.1.1" + }, + "bin": { + "yalc": "src/yalc.js" + } + }, + "node_modules/yalc/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 258840c..1bb89b2 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "link": "yalc publish && npm run link:example && npm run link:example-ssr", "link:example": "cd example && yalc add react-modal-sheet && npm i", "link:example-ssr": "cd example-ssr && yalc add react-modal-sheet && npm i", - "link:update": "tsup --dts-only && yalc push --replace", + "link:update": "tsup --dts-only && yalc push --sig --replace", "test": "vitest run", "typecheck": "tsc --noEmit", "verify": "run-p format:check lint:check typecheck", @@ -70,7 +70,8 @@ "tsup": "8.4.0", "typescript": "5.8.3", "vite": "6.3.3", - "vitest": "3.1.2" + "vitest": "3.1.2", + "yalc": "1.0.0-pre.53" }, "engines": { "node": ">=18" diff --git a/src/SheetContainer.tsx b/src/SheetContainer.tsx index 2773a14..6da269b 100644 --- a/src/SheetContainer.tsx +++ b/src/SheetContainer.tsx @@ -1,4 +1,9 @@ -import { type MotionStyle, motion } from 'motion/react'; +import { + type MotionStyle, + motion, + useMotionTemplate, + useTransform, +} from 'motion/react'; import React, { forwardRef } from 'react'; import { DEFAULT_HEIGHT } from './constants'; @@ -6,43 +11,67 @@ import { useSheetContext } from './context'; import { styles } from './styles'; import { type SheetContainerProps } from './types'; import { applyStyles, mergeRefs } from './utils'; +import { useDimensions } from './hooks/use-dimensions'; -export const SheetContainer = forwardRef( - ({ children, style, className = '', unstyled, ...rest }, ref) => { +type SheetPositionerProps = { + children: React.ReactNode; +}; + +export const SheetPositioner = forwardRef( + ({ children }, ref) => { const sheetContext = useSheetContext(); - const isUnstyled = unstyled ?? sheetContext.unstyled; + const isUnstyled = sheetContext.unstyled; - const containerStyle: MotionStyle = { - ...applyStyles(styles.container, isUnstyled), - ...style, - y: sheetContext.y, - }; + // y might be negative due to elastic + // for a better experience, we clamp the y value to 0 + // and use the overflow value to add padding to the bottom of the container + // causing the illusion of the sheet being elastic + const y = sheetContext.y; + const nonNegativeY = useTransform(sheetContext.y, (val) => + Math.max(0, val) + ); + + const positionerStyle: MotionStyle = { + // Use motion template for performant CSS variable updates + '--overflow': useMotionTemplate`${sheetContext.yOverflow}px`, + ...applyStyles(styles.positioner, isUnstyled), + ...(isUnstyled ? { y } : { y: nonNegativeY }), + } as any; if (sheetContext.detent === 'default') { - containerStyle.height = DEFAULT_HEIGHT; + positionerStyle.height = DEFAULT_HEIGHT; } if (sheetContext.detent === 'full') { - containerStyle.height = '100%'; - containerStyle.maxHeight = '100%'; + positionerStyle.height = '100%'; + positionerStyle.maxHeight = '100%'; } if (sheetContext.detent === 'content') { - containerStyle.height = 'auto'; - containerStyle.maxHeight = DEFAULT_HEIGHT; + positionerStyle.height = 'auto'; + positionerStyle.maxHeight = `calc(${DEFAULT_HEIGHT} - ${sheetContext.safeSpaceTop}px)`; + } + + if (sheetContext.detent === 'initial-content') { + // Use locked height if available, otherwise auto (during initial measurement) + if (sheetContext.lockedContentHeight !== null) { + positionerStyle.height = `${sheetContext.lockedContentHeight}px`; + } else { + positionerStyle.height = 'auto'; + } + positionerStyle.maxHeight = `calc(${DEFAULT_HEIGHT} - ${sheetContext.safeSpaceTop}px)`; } return ( {children} @@ -50,4 +79,86 @@ export const SheetContainer = forwardRef( } ); +SheetPositioner.displayName = 'SheetPositioner'; + +export const SheetContainer = forwardRef( + ( + { + children, + style, + className = '', + unstyled, + renderAbove, + positionerRef, + ...rest + }, + ref + ) => { + const sheetContext = useSheetContext(); + const containerRef = sheetContext.containerRef; + + const isUnstyled = unstyled ?? sheetContext.unstyled; + + const { windowHeight } = useDimensions(); + const didHitMaxHeight = + windowHeight - sheetContext.safeSpaceTop <= sheetContext.sheetHeight; + + const containerStyle: MotionStyle = { + ...applyStyles(styles.container, isUnstyled), + ...style, + // compensate height for the elastic behavior of the sheet + ...(!didHitMaxHeight && { paddingBottom: sheetContext.yOverflow }), + // Ensure the container sits above the "above" element + position: 'relative', + zIndex: 1, + }; + + // Animate the translateY of the above element so it slides down behind the sheet as it closes + // The minimum y value (fully open) depends on the detent type + const isContentDetent = + sheetContext.detent === 'content' || + sheetContext.detent === 'initial-content'; + const minY = + sheetContext.detent === 'full' || isContentDetent + ? 0 + : sheetContext.safeSpaceTop; + + // When y is at minY (fully open), translateY is -100% (above the sheet) + // As y increases (sheet closing), translateY moves toward 0% (behind the sheet) + const aboveTranslateYPercent = useTransform( + sheetContext.y, + [minY, sheetContext.sheetHeight], + [-100, 0] + ); + const aboveTranslateY = useMotionTemplate`translateY(${aboveTranslateYPercent}%)`; + + return ( + + {renderAbove && ( + + {renderAbove} + + )} + + {children} + + + ); + } +); + SheetContainer.displayName = 'SheetContainer'; diff --git a/src/SheetContent.tsx b/src/SheetContent.tsx index 963ec5f..2bb04b0 100644 --- a/src/SheetContent.tsx +++ b/src/SheetContent.tsx @@ -18,6 +18,7 @@ export const SheetContent = forwardRef( className = '', scrollRef: scrollRefProp = null, unstyled, + avoidKeyboard: avoidKeyboardProp, ...rest }, ref @@ -66,13 +67,16 @@ export const SheetContent = forwardRef( const scrollStyle: MotionStyle = applyStyles(styles.scroller, isUnstyled); - if (sheetContext.avoidKeyboard) { - scrollStyle.paddingBottom = - 'env(keyboard-inset-height, var(--keyboard-inset-height, 0px))'; - } + const shouldRenderScroller = disableScrollProp === false || !disableScroll; + + const avoidKeyboard = avoidKeyboardProp ?? sheetContext.avoidKeyboard; - if (disableScroll) { - scrollStyle.overflowY = 'hidden'; + if (avoidKeyboard) { + if (disableScroll) { + contentStyle.paddingBottom = 'var(--keyboard-inset-height, 0px)'; + } else { + scrollStyle.paddingBottom = 'var(--keyboard-inset-height, 0px)'; + } } return ( @@ -85,13 +89,17 @@ export const SheetContent = forwardRef( dragConstraints={dragConstraints.ref} onMeasureDragConstraints={dragConstraints.onMeasure} > - - {children} - + {shouldRenderScroller ? ( + + {children} + + ) : ( + children + )} ); } diff --git a/src/constants.ts b/src/constants.ts index e7f236e..10b69c0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,8 @@ import type { SheetTweenConfig } from './types'; -export const DEFAULT_HEIGHT = 'calc(100% - env(safe-area-inset-top) - 34px)'; +export const DEFAULT_TOP_CONSTRAINT = 34; + +export const DEFAULT_HEIGHT = 'calc(var(--overflow, 0px) + 100%)'; export const IS_SSR = typeof window === 'undefined'; diff --git a/src/hooks/isTextInput.ts b/src/hooks/isTextInput.ts new file mode 100644 index 0000000..4a401b7 --- /dev/null +++ b/src/hooks/isTextInput.ts @@ -0,0 +1,12 @@ +/** + * Checks if an element is a text input + */ + +export function isTextInput(el: Element | null): el is HTMLElement { + return ( + el instanceof HTMLElement && + (el.tagName === 'INPUT' || + el.tagName === 'TEXTAREA' || + el.isContentEditable) + ); +} diff --git a/src/hooks/use-prevent-scroll.ts b/src/hooks/use-prevent-scroll.ts index 75dce89..afa92f1 100644 --- a/src/hooks/use-prevent-scroll.ts +++ b/src/hooks/use-prevent-scroll.ts @@ -2,6 +2,7 @@ import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect'; import { isIOS } from '../utils'; +import { isTextInput } from './isTextInput'; const KEYBOARD_BUFFER = 24; @@ -154,11 +155,18 @@ function preventScrollStandard() { function preventScrollMobileSafari() { let scrollable: Element | undefined; let lastY = 0; + // Track if the user moved their finger during the touch gesture (scroll vs tap) + let didScroll = false; const onTouchStart = (e: TouchEvent) => { // Use `composedPath` to support shadow DOM. const target = e.composedPath()?.[0] as HTMLElement; + // Reset scroll tracking for new gesture + didScroll = false; + + if (isTextInput(target)) return; + // Store the nearest scrollable parent element from the element that the user touched. scrollable = getScrollParent(target, true); @@ -173,6 +181,9 @@ function preventScrollMobileSafari() { }; const onTouchMove = (e: TouchEvent) => { + // Mark that user is scrolling (not tapping) - must be before any returns + didScroll = true; + // In special situations, `onTouchStart` may be called without `onTouchStart` being called. // (e.g. when the user places a finger on the screen before the is mounted and then moves the finger after it is mounted). // If `onTouchStart` is not called, `scrollable` is `undefined`. Therefore, such cases are ignored. @@ -213,6 +224,11 @@ function preventScrollMobileSafari() { // Use `composedPath` to support shadow DOM. const target = e.composedPath()?.[0] as HTMLElement; + // Skip focusing if user was scrolling, not tapping + if (didScroll) { + return; + } + // Apply this change if we're not already focused on the target element if (willOpenKeyboard(target) && target !== document.activeElement) { e.preventDefault(); diff --git a/src/hooks/use-resize-observer.ts b/src/hooks/use-resize-observer.ts index 4fbc8db..aa79453 100644 --- a/src/hooks/use-resize-observer.ts +++ b/src/hooks/use-resize-observer.ts @@ -1,10 +1,10 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useStableCallback } from './use-stable-callback'; export function useResizeObserver( callback: ResizeObserverCallback ) { - const observeRef = useRef(null); + const [observeElement, setObserveElement] = useState(null); const timeoutRef = useRef | null>(null); const debouncedCallback: ResizeObserverCallback = useStableCallback( @@ -15,7 +15,7 @@ export function useResizeObserver( ); useEffect(() => { - const element = observeRef.current; + const element = observeElement; if (!element) return; const observer = new ResizeObserver(debouncedCallback); @@ -25,7 +25,7 @@ export function useResizeObserver( observer.disconnect(); if (timeoutRef.current) clearTimeout(timeoutRef.current); }; - }, []); + }, [observeElement]); - return { observeRef }; + return { observeRef: setObserveElement }; } diff --git a/src/hooks/use-safe-area-insets.ts b/src/hooks/use-safe-area-insets.ts index c472995..e08ecaf 100644 --- a/src/hooks/use-safe-area-insets.ts +++ b/src/hooks/use-safe-area-insets.ts @@ -1,40 +1,69 @@ -import { useState } from 'react'; +import { useLayoutEffect, useState } from 'react'; import { IS_SSR } from '../constants'; +const fallback = { top: 0, left: 0, right: 0, bottom: 0 }; export function useSafeAreaInsets() { - const [insets] = useState(() => { - const fallback = { top: 0, left: 0, right: 0, bottom: 0 }; + const [insets, setInsets] = useState(() => + IS_SSR ? fallback : getSafeAreaInsets(createSafeAreaDetector()) + ); - if (IS_SSR) return fallback; + useLayoutEffect(() => { + if (IS_SSR) return setInsets(fallback); - const root = document.querySelector(':root'); + // Create a hidden element that uses safe area insets + const safeAreaDetector = createSafeAreaDetector(); - if (!root) return fallback; + const computeInsets = () => + setInsets(() => getSafeAreaInsets(safeAreaDetector)); - root.style.setProperty('--rms-sat', 'env(safe-area-inset-top)'); - root.style.setProperty('--rms-sal', 'env(safe-area-inset-left)'); - root.style.setProperty('--rms-sar', 'env(safe-area-inset-right)'); - root.style.setProperty('--rms-sab', 'env(safe-area-inset-bottom)'); + const observer = new ResizeObserver(computeInsets); - const computedStyle = getComputedStyle(root); - const sat = getComputedValue(computedStyle, '--rms-sat'); - const sal = getComputedValue(computedStyle, '--rms-sal'); - const sar = getComputedValue(computedStyle, '--rms-sar'); - const sab = getComputedValue(computedStyle, '--rms-sab'); + computeInsets(); + observer.observe(safeAreaDetector); - root.style.removeProperty('--rms-sat'); - root.style.removeProperty('--rms-sal'); - root.style.removeProperty('--rms-sar'); - root.style.removeProperty('--rms-sab'); - - return { top: sat, left: sal, right: sar, bottom: sab }; - }); + return () => { + observer.disconnect(); + }; + }, []); return insets; } -function getComputedValue(computed: CSSStyleDeclaration, property: string) { - const strValue = computed.getPropertyValue(property).replace('px', '').trim(); - return parseInt(strValue, 10) || 0; +const safeAreaDetectorId = 'react-modal-sheet-safe-area-detector'; +const safeAreaDetectorStyle = ` + position: fixed; + top: -1000px; + left: -1000px; + pointer-events: none; + z-index: -1; + visibility: hidden; + width: 1px; + height: 1px; + padding-top: env(safe-area-inset-top); + padding-right: env(safe-area-inset-right); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); +`; + +function createSafeAreaDetector() { + let safeAreaDetector = document.getElementById(safeAreaDetectorId); + if (safeAreaDetector) return safeAreaDetector; + + safeAreaDetector = document.createElement('div'); + safeAreaDetector.id = safeAreaDetectorId; + safeAreaDetector.style.cssText = safeAreaDetectorStyle; + document.body.appendChild(safeAreaDetector); + return safeAreaDetector; +} + +// Read the computed values +function getSafeAreaInsets(element: HTMLElement) { + const styles = getComputedStyle(element); + return { + top: parseFloat(styles.paddingTop) || 0, + right: parseFloat(styles.paddingRight) || 0, + bottom: parseFloat(styles.paddingBottom) || 0, + left: parseFloat(styles.paddingLeft) || 0, + }; } diff --git a/src/hooks/use-scroll-position.ts b/src/hooks/use-scroll-position.ts index 730e318..69ab005 100644 --- a/src/hooks/use-scroll-position.ts +++ b/src/hooks/use-scroll-position.ts @@ -1,30 +1,26 @@ -import { useEffect, useRef, useState } from 'react'; +import { RefObject, useCallback, useEffect, useMemo, useState } from 'react'; +import { useStableCallback } from './use-stable-callback'; +import { useResizeObserver } from './use-resize-observer'; export function useScrollPosition() { - const ref = useRef(null); const [scrollPosition, setScrollPosition] = useState< 'top' | 'bottom' | 'middle' | undefined >(undefined); - useEffect(() => { - const element = ref.current; - if (!element) return; - - let scrollTimeout: number | null = null; - - function determineScrollPosition(element: HTMLDivElement) { + const determineScrollPosition = useStableCallback( + (element: HTMLDivElement) => { const { scrollTop, scrollHeight, clientHeight } = element; const isScrollable = scrollHeight > clientHeight; if (!isScrollable) { // Reset scroll position if the content is not scrollable anymore - if (scrollPosition) setScrollPosition(undefined); + setScrollPosition(undefined); return; } const isAtTop = scrollTop <= 0; const isAtBottom = - Math.ceil(scrollHeight) - Math.ceil(scrollTop) === + Math.ceil(scrollHeight) - Math.ceil(scrollTop) <= Math.ceil(clientHeight); let position: 'top' | 'bottom' | 'middle'; @@ -37,9 +33,34 @@ export function useScrollPosition() { position = 'middle'; } - if (position === scrollPosition) return; setScrollPosition(position); } + ); + + const [internalRef, setInternalRef] = useState(null); + + const { observeRef } = useResizeObserver( + () => internalRef && determineScrollPosition(internalRef) + ); + + const mergedRef = useCallback( + (el: HTMLDivElement | null) => { + setInternalRef(el); + observeRef(el); + }, + [observeRef] + ); + + const ref = useMemo( + () => Object.assign(mergedRef, { current: internalRef }), + [mergedRef, internalRef] + ) as RefObject; + + useEffect(() => { + const element = internalRef; + if (!element) return; + + let scrollTimeout: number | null = null; function onScroll(event: Event) { if (event.currentTarget instanceof HTMLDivElement) { @@ -66,7 +87,7 @@ export function useScrollPosition() { element.removeEventListener('scroll', onScroll); element.removeEventListener('touchstart', onTouchStart); }; - }, []); + }, [internalRef]); return { ref, scrollPosition }; } diff --git a/src/hooks/use-scroll-to-focused-input.ts b/src/hooks/use-scroll-to-focused-input.ts new file mode 100644 index 0000000..c18ab06 --- /dev/null +++ b/src/hooks/use-scroll-to-focused-input.ts @@ -0,0 +1,199 @@ +import { type RefObject, useEffect, useRef } from 'react'; +import { isTextInput } from './isTextInput'; + +type UseScrollToFocusedInputOptions = { + /** + * Ref to the container element that contains the inputs + */ + containerRef: RefObject; + /** + * Whether the keyboard is currently open + */ + isKeyboardOpen: boolean; + /** + * The current keyboard height in pixels + */ + keyboardHeight: number; + /** + * Bottom offset to account for (e.g. safe area + custom spacing) + */ + bottomOffset?: number; +}; + +/** + * Finds the nearest scrollable ancestor of an element + */ +function findScrollableAncestor(element: HTMLElement): HTMLElement | null { + let parent = element.parentElement; + + while (parent) { + const style = getComputedStyle(parent); + const overflowY = style.overflowY; + + if ( + overflowY === 'auto' || + overflowY === 'scroll' || + // Check if element is actually scrollable + (overflowY !== 'hidden' && parent.scrollHeight > parent.clientHeight) + ) { + return parent; + } + + parent = parent.parentElement; + } + + return null; +} + +/** + * Finds the label associated with an input element + */ +function findAssociatedLabel(element: HTMLElement): HTMLElement | null { + // Check if input is wrapped in a label + const parentLabel = element.closest('label'); + if (parentLabel) return parentLabel as HTMLElement; + + // Check for label with matching 'for' attribute + if (element.id) { + const labelFor = document.querySelector( + `label[for="${element.id}"]` + ) as HTMLElement | null; + if (labelFor) return labelFor; + } + + // Check for aria-labelledby + const labelledBy = element.getAttribute('aria-labelledby'); + if (labelledBy) { + const ariaLabel = document.getElementById(labelledBy) as HTMLElement | null; + if (ariaLabel) return ariaLabel; + } + + return null; +} + +/** + * Scrolls a focused input (and its label) into view, centering it in the + * visible area while respecting scroll bounds. + */ +function scrollFocusedInputIntoView( + element: HTMLElement, + keyboardHeight: number, + bottomOffset: number +) { + // setTimeout instead of requestAnimationFrame is required otherwise the + // scrolling doesn't work if you switch from one field to another. + setTimeout(() => { + const inputRect = element.getBoundingClientRect(); + const label = findAssociatedLabel(element); + + // Calculate combined rect including label + let targetTop = inputRect.top; + let targetBottom = inputRect.bottom; + + if (label) { + const labelRect = label.getBoundingClientRect(); + targetTop = Math.min(inputRect.top, labelRect.top); + targetBottom = Math.max(inputRect.bottom, labelRect.bottom); + } + + const scrollContainer = findScrollableAncestor(element); + + if (scrollContainer) { + const containerRect = scrollContainer.getBoundingClientRect(); + + // Account for keyboard height + bottom offset when calculating visible bottom + const effectiveBottomOffset = Math.max(keyboardHeight, bottomOffset); + + // Calculate visible boundaries relative to viewport + const visibleTop = containerRect.top; + const visibleBottom = Math.min( + containerRect.bottom, + window.innerHeight - effectiveBottomOffset + ); + + // Calculate centers for centering logic + const targetCenter = (targetTop + targetBottom) / 2; + const visibleCenter = (visibleTop + visibleBottom) / 2; + + // Calculate ideal scroll to center the element + let scrollOffset = targetCenter - visibleCenter; + + // Clamp scroll offset to prevent overscrolling + const maxScrollDown = + scrollContainer.scrollHeight - + scrollContainer.scrollTop - + scrollContainer.clientHeight; + const maxScrollUp = -scrollContainer.scrollTop; + + scrollOffset = Math.max( + maxScrollUp, + Math.min(maxScrollDown, scrollOffset) + ); + + // Only scroll if there's a meaningful offset + if (Math.abs(scrollOffset) > 1) { + scrollContainer.scrollBy({ + top: scrollOffset, + behavior: 'smooth', + }); + } + } else { + // Fallback to native scrollIntoView + element.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + }, 0); +} + +/** + * Hook that automatically scrolls focused inputs into view when the keyboard + * opens or when focus changes between inputs while the keyboard is open. + */ +export function useScrollToFocusedInput({ + containerRef, + isKeyboardOpen, + keyboardHeight, + bottomOffset = 0, +}: UseScrollToFocusedInputOptions) { + const prevKeyboardOpen = useRef(false); + + useEffect(() => { + const keyboardOpening = isKeyboardOpen && !prevKeyboardOpen.current; + prevKeyboardOpen.current = isKeyboardOpen; + + // Scroll on keyboard open + if (keyboardOpening && containerRef.current) { + const focusedElement = document.activeElement; + if ( + isTextInput(focusedElement) && + containerRef.current.contains(focusedElement) + ) { + scrollFocusedInputIntoView( + focusedElement, + keyboardHeight, + bottomOffset + ); + } + } + + // Listen for focus changes while keyboard is open + if (!isKeyboardOpen) return; + if (!containerRef.current) return; + + const handleFocusIn = (e: FocusEvent) => { + const target = e.target as Element | null; + if (isTextInput(target) && containerRef.current?.contains(target)) { + scrollFocusedInputIntoView(target, keyboardHeight, bottomOffset); + } + }; + + containerRef.current.addEventListener('focusin', handleFocusIn); + const currentContainerRef = containerRef.current; + + return () => { + currentContainerRef.removeEventListener('focusin', handleFocusIn); + }; + }, [isKeyboardOpen, keyboardHeight, bottomOffset, containerRef]); +} diff --git a/src/hooks/use-sheet-state.ts b/src/hooks/use-sheet-state.ts index 85d077a..16e05be 100644 --- a/src/hooks/use-sheet-state.ts +++ b/src/hooks/use-sheet-state.ts @@ -1,63 +1,56 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useStableCallback } from './use-stable-callback'; type SheetState = 'closed' | 'opening' | 'open' | 'closing'; type UseSheetStatesProps = { isOpen: boolean; - onClosed?: () => Promise | void; onOpening?: () => Promise | void; - onOpen?: () => Promise | void; onClosing?: () => Promise | void; }; export function useSheetState({ isOpen, - onClosed: _onClosed, onOpening: _onOpening, - onOpen: _onOpen, onClosing: _onClosing, }: UseSheetStatesProps) { const [state, setState] = useState(isOpen ? 'opening' : 'closed'); - const onClosed = useStableCallback(() => _onClosed?.()); + const abortControllerRef = useRef(null); const onOpening = useStableCallback(() => _onOpening?.()); - const onOpen = useStableCallback(() => _onOpen?.()); const onClosing = useStableCallback(() => _onClosing?.()); useEffect(() => { - if (isOpen && state === 'closed') { - setState('opening'); - } else if (!isOpen && (state === 'open' || state === 'opening')) { - setState('closing'); - } - }, [isOpen, state]); + abortControllerRef.current?.abort(); - useEffect(() => { - async function handle() { - switch (state) { - case 'closed': - await onClosed?.(); - break; + const abortController = new AbortController(); + abortControllerRef.current = abortController; - case 'opening': + async function handle() { + switch (isOpen) { + case true: + setState('opening'); await onOpening?.(); - setState('open'); + if (!abortController.signal.aborted) setState('open'); break; - case 'open': - await onOpen?.(); - break; - - case 'closing': + case false: + setState('closing'); await onClosing?.(); - setState('closed'); + if (!abortController.signal.aborted) setState('closed'); break; } } + handle().catch((error) => { - console.error('Internal sheet state error:', error); + if (error instanceof Error) { + console.error('Internal sheet state error:', error); + } }); - }, [state]); + + return () => { + abortController.abort(); + }; + }, [isOpen]); return state; } diff --git a/src/hooks/use-snap-on-focus.ts b/src/hooks/use-snap-on-focus.ts new file mode 100644 index 0000000..1f3fd8a --- /dev/null +++ b/src/hooks/use-snap-on-focus.ts @@ -0,0 +1,81 @@ +import { useEffect, type RefObject } from 'react'; +import { isTextInput } from './isTextInput'; + +type UseSnapOnFocusOptions = { + /** Ref to the container element to listen for focus events */ + containerRef: RefObject; + /** Whether the sheet is open */ + isOpen: boolean; + /** Current snap point index */ + currentSnap: number | undefined; + /** The last (full height) snap point index */ + lastSnapPointIndex: number; + /** Whether the hook is enabled */ + isEnabled: boolean; + /** Callback to snap to full height, returns a cleanup function to restore */ + onSnapToFull: (() => VoidFunction) | (() => void); +}; + +/** + * Snaps the sheet to full height when an input/textarea inside receives focus. + * This happens before the keyboard opens, providing a smoother experience. + */ +export function useSnapOnFocus({ + containerRef, + isOpen, + currentSnap, + lastSnapPointIndex, + isEnabled, + onSnapToFull, +}: UseSnapOnFocusOptions) { + useEffect(() => { + if (!isOpen) return; + if (!isEnabled) return; + + const container = containerRef.current; + if (!container) return; + + let cleanup: (() => void) | undefined; + + const handleFocusIn = (event: FocusEvent) => { + const target = event.target as HTMLElement | null; + if (!target) return; + + if (!isTextInput(target)) return; + + // Already at full height, nothing to do + if (currentSnap === lastSnapPointIndex) return; + + cleanup = onSnapToFull() ?? undefined; + }; + + const handleFocusOut = (event: FocusEvent) => { + const relatedTarget = event.relatedTarget as HTMLElement | null; + + // If focus is moving to another input inside the container, don't restore + if (relatedTarget && container.contains(relatedTarget)) { + if (!isTextInput(relatedTarget)) return; + } + + // Focus left the container or moved to a non-input element, restore snap + cleanup?.(); + cleanup = undefined; + }; + + container.addEventListener('focusin', handleFocusIn); + container.addEventListener('focusout', handleFocusOut); + + return () => { + container.removeEventListener('focusin', handleFocusIn); + container.removeEventListener('focusout', handleFocusOut); + cleanup?.(); + }; + }, [ + isOpen, + isEnabled, + containerRef, + currentSnap, + lastSnapPointIndex, + onSnapToFull, + ]); +} diff --git a/src/hooks/use-virtual-keyboard.ts b/src/hooks/use-virtual-keyboard.ts index c9e750f..5d222de 100644 --- a/src/hooks/use-virtual-keyboard.ts +++ b/src/hooks/use-virtual-keyboard.ts @@ -1,5 +1,6 @@ import { type RefObject, useEffect, useRef, useState } from 'react'; -import { useStableCallback } from './use-stable-callback'; +import { isIOSSafari26 } from '../utils'; +import { isTextInput } from './isTextInput'; type VirtualKeyboardState = { isVisible: boolean; @@ -8,9 +9,9 @@ type VirtualKeyboardState = { type UseVirtualKeyboardOptions = { /** - * Ref to the container element to apply `keyboard-inset-height` CSS variable updates (required) + * Ref to the positioner element to apply `keyboard-inset-height` CSS variable updates (required) */ - containerRef: RefObject; + positionerRef: RefObject; /** * Enable or disable the hook entirely (default: true) */ @@ -30,7 +31,7 @@ type UseVirtualKeyboardOptions = { }; export function useVirtualKeyboard({ - containerRef, + positionerRef, isEnabled = true, debounceDelay = 100, includeContentEditable = true, @@ -44,16 +45,6 @@ export function useVirtualKeyboard({ const focusedElementRef = useRef(null); const debounceTimer = useRef | null>(null); - const isTextInput = useStableCallback((el: Element | null) => { - return ( - el?.tagName === 'INPUT' || - el?.tagName === 'TEXTAREA' || - (includeContentEditable && - el instanceof HTMLElement && - el.isContentEditable) - ); - }); - useEffect(() => { if (!isEnabled) return; @@ -61,14 +52,20 @@ export function useVirtualKeyboard({ const vk = (navigator as any).virtualKeyboard; function setKeyboardInsetHeightEnv(height: number) { - containerRef.current?.style.setProperty( + positionerRef.current?.style.setProperty( '--keyboard-inset-height', - `${height}px` + // Safari 26 uses a floating address bar when keyboard is open that occludes the bottom of the sheet + // and its height is not considered in the visual viewport. It is estimated to be 25px. + `${isIOSSafari26() ? (height ? height + 10 : 0) : height}px` ); } function handleFocusIn(e: FocusEvent) { - if (e.target instanceof HTMLElement && isTextInput(e.target)) { + if ( + e.target instanceof HTMLElement && + isTextInput(e.target) && + positionerRef.current?.contains(e.target) + ) { focusedElementRef.current = e.target; updateKeyboardState(); } @@ -93,6 +90,18 @@ export function useVirtualKeyboard({ return; } + if (vk) { + const virtualKeyboardHeight = vk.boundingRect.height; + + setKeyboardInsetHeightEnv(virtualKeyboardHeight); + setState({ + isVisible: virtualKeyboardHeight > 0, + height: virtualKeyboardHeight, + }); + + return; + } + if (vv) { const heightDiff = window.innerHeight - vv.height; @@ -103,6 +112,8 @@ export function useVirtualKeyboard({ setKeyboardInsetHeightEnv(0); setState({ isVisible: false, height: 0 }); } + + return; } }, debounceDelay); } @@ -120,6 +131,7 @@ export function useVirtualKeyboard({ if (vk) { currentOverlaysContent = vk.overlaysContent; vk.overlaysContent = true; + vk.addEventListener('geometrychange', updateKeyboardState); } return () => { @@ -133,6 +145,7 @@ export function useVirtualKeyboard({ if (vk) { vk.overlaysContent = currentOverlaysContent; + vk.removeEventListener('geometrychange', updateKeyboardState); } if (debounceTimer.current) { diff --git a/src/index.tsx b/src/index.tsx index 994a762..1ef2d6a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,13 +6,21 @@ import { SheetContent } from './SheetContent'; import { SheetDragIndicator } from './SheetDragIndicator'; import { SheetHeader } from './SheetHeader'; import { Sheet as SheetBase } from './sheet'; -import type { SheetCompound } from './types'; +import type { SheetCompound, SheetSnapPoint } from './types'; +import { useScrollPosition } from './hooks/use-scroll-position'; +import { useSafeAreaInsets } from './hooks/use-safe-area-insets'; +import { RefObject } from 'react'; export interface SheetRef { y: MotionValue; yInverted: MotionValue; height: number; - snapTo: (index: number) => Promise; + snapTo: (index: number, options?: { immediate?: boolean }) => Promise; + currentSnap: number | undefined; + getSnapPoint: (index: number) => SheetSnapPoint | null; + snapPoints: SheetSnapPoint[]; + currentSnapPoint: SheetSnapPoint | null; + openStateRef: RefObject<'closed' | 'open' | 'opening' | 'closing'>; } export const Sheet: SheetCompound = Object.assign(SheetBase, { @@ -23,6 +31,8 @@ export const Sheet: SheetCompound = Object.assign(SheetBase, { Backdrop: SheetBackdrop, }); +export { useScrollPosition, useSafeAreaInsets }; + // Export types export type { SheetBackdropProps, diff --git a/src/sheet.tsx b/src/sheet.tsx index 910a8c4..8d8920e 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -1,5 +1,6 @@ import { animate, + Axis, type DragHandler, motion, type Transition, @@ -9,9 +10,11 @@ import { } from 'motion/react'; import React, { forwardRef, + useEffect, useImperativeHandle, useRef, useState, + useMemo, } from 'react'; import { createPortal } from 'react-dom'; import useMeasure from 'react-use-measure'; @@ -19,6 +22,7 @@ import useMeasure from 'react-use-measure'; import { DEFAULT_DRAG_CLOSE_THRESHOLD, DEFAULT_DRAG_VELOCITY_THRESHOLD, + DEFAULT_TOP_CONSTRAINT, DEFAULT_TWEEN_CONFIG, IS_SSR, REDUCED_MOTION_TWEEN_CONFIG, @@ -29,6 +33,8 @@ import { useModalEffect } from './hooks/use-modal-effect'; import { usePreventScroll } from './hooks/use-prevent-scroll'; import { useSheetState } from './hooks/use-sheet-state'; import { useStableCallback } from './hooks/use-stable-callback'; +import { useScrollToFocusedInput } from './hooks/use-scroll-to-focused-input'; +import { useSnapOnFocus } from './hooks/use-snap-on-focus'; import { useVirtualKeyboard } from './hooks/use-virtual-keyboard'; import { computeSnapPoints, @@ -37,7 +43,8 @@ import { } from './snap'; import { styles } from './styles'; import { type SheetContextType, type SheetProps } from './types'; -import { applyStyles, waitForElement } from './utils'; +import { applyConstraints, applyStyles, waitForElement } from './utils'; +import { useSafeAreaInsets } from './hooks/use-safe-area-insets'; export const Sheet = forwardRef( ( @@ -49,6 +56,7 @@ export const Sheet = forwardRef( disableDismiss = false, disableDrag: disableDragProp = false, disableScrollLocking = false, + disableCloseOnEscape = false, dragCloseThreshold = DEFAULT_DRAG_CLOSE_THRESHOLD, dragVelocityThreshold = DEFAULT_DRAG_VELOCITY_THRESHOLD, initialSnap, @@ -61,6 +69,7 @@ export const Sheet = forwardRef( style, tweenConfig = DEFAULT_TWEEN_CONFIG, unstyled = false, + safeSpace: safeSpaceProp, onOpenStart, onOpenEnd, onClose, @@ -70,22 +79,80 @@ export const Sheet = forwardRef( onDrag: onDragProp, onDragStart: onDragStartProp, onDragEnd: onDragEndProp, + onKeyboardOpen, + skipOpenAnimation = false, + inert, ...rest }, ref ) => { + const { windowHeight } = useDimensions(); + const safeAreaInsets = useSafeAreaInsets(); + const [sheetBoundsRef, sheetBounds] = useMeasure(); - const sheetRef = useRef(null); - const sheetHeight = Math.round(sheetBounds.height); + const positionerRef = useRef(null); + const containerRef = useRef(null); const [currentSnap, setCurrentSnap] = useState(initialSnap); - const snapPoints = - snapPointsProp && sheetHeight > 0 - ? computeSnapPoints({ sheetHeight, snapPointsProp }) + + // For initial-content detent, lock the height once the sheet opens + const [lockedContentHeight, setLockedContentHeight] = useState< + number | null + >(null); + + const measuredContentHeight = Math.round(sheetBounds.height); + // Keep a ref to access current measured height in callbacks + const measuredContentHeightRef = useRef(measuredContentHeight); + measuredContentHeightRef.current = measuredContentHeight; + const sheetHeight = + detent === 'default' || detent === 'full' + ? windowHeight + : detent === 'initial-content' && lockedContentHeight !== null + ? lockedContentHeight + : measuredContentHeight; + + const isContentDetent = + detent === 'content' || detent === 'initial-content'; + + const safeSpaceTop = + detent === 'full' ? 0 : (safeSpaceProp?.top ?? DEFAULT_TOP_CONSTRAINT); + + const safeSpaceBottom = safeSpaceProp?.bottom ?? 0; + + const minSnapValue = safeSpaceBottom + ? safeSpaceBottom + safeAreaInsets.bottom + : 0; + const maxSnapValueOnDefaultDetent = + windowHeight - safeSpaceTop - safeAreaInsets.top; + const maxSnapValue = + detent === 'full' || isContentDetent + ? windowHeight + : maxSnapValueOnDefaultDetent; + + const dragConstraints: Axis = { + min: + detent === 'full' || isContentDetent + ? 0 + : safeSpaceTop + safeAreaInsets.top, // top constraint (applied through sheet height instead) + max: windowHeight - safeSpaceBottom - safeAreaInsets.bottom, // bottom constraint + }; + + const snapPoints = useMemo(() => { + return snapPointsProp && sheetHeight > 0 + ? computeSnapPoints({ + sheetHeight, + snapPointsProp, + minSnapValue, + maxSnapValue, + }) : []; + }, [sheetHeight, snapPointsProp, minSnapValue, maxSnapValue]); - const { windowHeight } = useDimensions(); const closedY = sheetHeight > 0 ? sheetHeight : windowHeight; const y = useMotionValue(closedY); + const yUnconstrainedRef = useRef(undefined); + // y is below 0 when the sheet is overextended + // this happens because the sheet is elastic and can be dragged beyond the full open position + const yOverflow = useTransform(y, (val) => (val < 0 ? Math.abs(val) : 0)); const yInverted = useTransform(y, (val) => Math.max(sheetHeight - val, 0)); const indicatorRotation = useMotionValue(0); @@ -98,7 +165,8 @@ export const Sheet = forwardRef( const keyboard = useVirtualKeyboard({ isEnabled: isOpen && avoidKeyboard, - containerRef: sheetRef, + positionerRef, + debounceDelay: 0, }); // Disable drag if the keyboard is open to avoid weird behavior @@ -106,10 +174,10 @@ export const Sheet = forwardRef( // +2 for tolerance in case the animated value is slightly off const zIndex = useTransform(y, (val) => - val + 2 >= closedY ? -1 : (style?.zIndex ?? 9999) + val >= closedY ? -1 : (style?.zIndex ?? 9999) ); const visibility = useTransform(y, (val) => - val + 2 >= closedY ? 'hidden' : 'visible' + val >= closedY ? 'hidden' : 'visible' ); const updateSnap = useStableCallback((snapIndex: number) => { @@ -132,72 +200,96 @@ export const Sheet = forwardRef( return null; }); - const snapTo = useStableCallback(async (snapIndex: number) => { - if (!snapPointsProp) { - console.warn('Snapping is not possible without `snapPoints` prop.'); - return; - } + const snapTo = useStableCallback( + async (snapIndex: number, options?: { immediate?: boolean }) => { + if (!snapPointsProp) { + console.warn('Snapping is not possible without `snapPoints` prop.'); + return; + } - const snapPoint = getSnapPoint(snapIndex); + if (currentSnap === snapIndex) return; - if (snapPoint === null) { - console.warn(`Invalid snap index ${snapIndex}.`); - return; - } + const snapPoint = getSnapPoint(snapIndex); - if (snapIndex === 0) { - onClose(); - return; - } + if (snapPoint === null) { + console.warn(`Invalid snap index ${snapIndex}.`); + return; + } - await animate(y, snapPoint.snapValueY, { - ...animationOptions, - onComplete: () => updateSnap(snapIndex), - }); - }); + if (snapIndex === 0) { + onClose(); + return; + } + + if (options?.immediate) { + y.set(snapPoint.snapValueY); + updateSnap(snapIndex); + return; + } + + await animate(y, snapPoint.snapValueY, { + ...animationOptions, + onComplete: () => updateSnap(snapIndex), + }); + } + ); const blurActiveInput = useStableCallback(() => { // Find focused input inside the sheet and blur it when dragging starts // to prevent a weird ghost caret "bug" on mobile const focusedElement = document.activeElement as HTMLElement | null; - if (!focusedElement || !sheetRef.current) return; + if (!focusedElement || !positionerRef.current) return; const isInput = focusedElement.tagName === 'INPUT' || focusedElement.tagName === 'TEXTAREA'; // Only blur the focused element if it's inside the sheet - if (isInput && sheetRef.current.contains(focusedElement)) { + if (isInput && positionerRef.current.contains(focusedElement)) { focusedElement.blur(); } }); const onDrag = useStableCallback((event, info) => { - onDragProp?.(event, info); + if (yUnconstrainedRef.current === undefined) return; - const currentY = y.get(); + onDragProp?.(event, info); + if (event.defaultPrevented) return; // Update drag indicator rotation based on drag velocity const velocity = y.getVelocity(); if (velocity > 0) indicatorRotation.set(10); if (velocity < 0) indicatorRotation.set(-10); - // Make sure user cannot drag beyond the top of the sheet - y.set(Math.max(currentY + info.delta.y, 0)); + const currentY = yUnconstrainedRef.current; + const nextY = currentY + info.delta.y; + yUnconstrainedRef.current = nextY; + const constrainedY = applyConstraints(nextY, dragConstraints, { + min: 0.1, + max: 0.1, + }); + y.set(constrainedY); }); const onDragStart = useStableCallback((event, info) => { - blurActiveInput(); + yUnconstrainedRef.current = y.get(); + if (y.isAnimating()) { + y.stop(); + } onDragStartProp?.(event, info); + if (event.defaultPrevented) return; + blurActiveInput(); }); const onDragEnd = useStableCallback((event, info) => { - blurActiveInput(); onDragEndProp?.(event, info); + if (event.defaultPrevented) return; + blurActiveInput(); const currentY = y.get(); let yTo = 0; + let snapIndex: number | undefined; const currentSnapPoint = currentSnap !== undefined ? getSnapPoint(currentSnap) : null; @@ -226,6 +318,7 @@ export const Sheet = forwardRef( } yTo = result.yTo; + snapIndex = result.snapIndex; // If disableDismiss is true, prevent closing via gesture if (disableDismiss && yTo + 1 >= sheetHeight) { @@ -234,6 +327,7 @@ export const Sheet = forwardRef( if (bottomSnapPoint) { yTo = bottomSnapPoint.snapValueY; + snapIndex = bottomSnapPoint.snapIndex; updateSnap(bottomSnapPoint.snapIndex); } else { // If no open snap points available, stay at current position @@ -256,8 +350,15 @@ export const Sheet = forwardRef( } } + const shouldBounce = currentSnapPoint?.snapIndex !== snapIndex; + + const bounce = shouldBounce + ? linear(Math.abs(info.velocity.y), 0, 1000, 0.175, 0.25) + : 0; + // Update the spring value so that the sheet is animated to the snap point - animate(y, yTo, animationOptions); + animate(y, yTo, { ...animationOptions, bounce }); + yUnconstrainedRef.current = undefined; // +1px for imprecision tolerance // Only call onClose if disableDismiss is false or if we're actually closing @@ -269,12 +370,39 @@ export const Sheet = forwardRef( indicatorRotation.set(0); }); - useImperativeHandle(ref, () => ({ - y, - yInverted, - height: sheetHeight, - snapTo, - })); + const openStateRef = useRef<'closed' | 'open' | 'opening' | 'closing'>( + isOpen ? 'opening' : 'closed' + ); + + const currentSnapPoint = currentSnap + ? (snapPoints[currentSnap] ?? null) + : null; + + useImperativeHandle( + ref, + () => ({ + y, + yInverted, + height: sheetHeight, + snapTo, + getSnapPoint, + snapPoints, + currentSnap, + currentSnapPoint, + openStateRef, + }), + [ + y, + yInverted, + sheetHeight, + snapTo, + getSnapPoint, + snapPoints, + currentSnap, + currentSnapPoint, + openStateRef, + ] + ); useModalEffect({ y, @@ -285,6 +413,53 @@ export const Sheet = forwardRef( startThreshold: modalEffectThreshold, }); + const lastSnapPointIndex = snapPoints.length - 1; + + const handleKeyboardOpen = useStableCallback(() => { + if (!onKeyboardOpen) { + const currentSnapPoint = currentSnap; + if (currentSnapPoint === lastSnapPointIndex) return; + + // fully open the sheet + snapTo(lastSnapPointIndex, { immediate: true }); + + // restore the previous snap point once the keyboard is closed + return () => { + currentSnapPoint !== undefined && + snapTo(currentSnapPoint, { immediate: true }); + }; + } + + return onKeyboardOpen(); + }); + + useSnapOnFocus({ + containerRef: positionerRef, + isOpen: openStateRef.current === 'open', + currentSnap, + lastSnapPointIndex, + isEnabled: detent === 'default', + onSnapToFull: handleKeyboardOpen, + }); + + useScrollToFocusedInput({ + containerRef, + isKeyboardOpen: keyboard.isKeyboardOpen, + keyboardHeight: keyboard.keyboardHeight, + bottomOffset: safeAreaInsets.bottom + safeSpaceBottom, + }); + + // keep the sheet at the current snap point if it changes + const currentSnapPointY = currentSnap + ? snapPoints[currentSnap]?.snapValueY + : null; + useEffect(() => { + if (currentSnapPointY === undefined) return; + if (currentSnapPointY === null) return; + if (openStateRef.current !== 'open') return; + animate(y, currentSnapPointY); + }, [currentSnapPointY]); + /** * Motion should handle body scroll locking but it's not working properly on iOS. * Scroll locking from React Aria seems to work much better 🤷‍♂️ @@ -293,37 +468,164 @@ export const Sheet = forwardRef( isDisabled: disableScrollLocking || !isOpen, }); - const state = useSheetState({ - isOpen, - onOpen: async () => { - onOpenStart?.(); + // Close the sheet when the escape key is pressed + useEffect(() => { + if (!isOpen || disableCloseOnEscape) return; - /** - * This is not very React-y but we need to wait for the sheet - * but we need to wait for the sheet to be rendered and visible - * before we can measure it and animate it to the initial snap point. - */ - await waitForElement('react-modal-sheet-container'); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if (inert === '') return; - const initialSnapPoint = - initialSnap !== undefined ? getSnapPoint(initialSnap) : null; + const visibilityValue = visibility.get(); + if (visibilityValue === 'hidden') return; - const yTo = initialSnapPoint?.snapValueY ?? 0; + event.preventDefault(); + onClose(); + } + }; - await animate(y, yTo, animationOptions); + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [disableCloseOnEscape, isOpen, onClose, visibility, inert]); - if (initialSnap !== undefined) { - updateSnap(initialSnap); - } + const yListenersRef = useRef([]); + const clearYListeners = useStableCallback(() => { + yListenersRef.current.forEach((listener) => listener()); + yListenersRef.current = []; + }); - onOpenEnd?.(); + const state = useSheetState({ + isOpen, + onOpening: () => { + return new Promise((resolve, reject) => { + clearYListeners(); + + openStateRef.current = 'opening'; + y.stop(); + onOpenStart?.(); + + const handleOpenEnd = () => { + if (initialSnap !== undefined) { + updateSnap(initialSnap); + } + + // Lock the content height for initial-content detent to prevent resizing + // Use ref to get current measured height (not stale closure value) + const currentMeasuredHeight = measuredContentHeightRef.current; + if (detent === 'initial-content' && currentMeasuredHeight > 0) { + setLockedContentHeight(currentMeasuredHeight); + } + + openStateRef.current = 'open'; + requestAnimationFrame(() => { + onOpenEnd?.(); + }); + }; + + const doWhenSheetReady = () => { + const initialSnapPoint = + initialSnap !== undefined ? getSnapPoint(initialSnap) : null; + + const onAnimationComplete = makeCallableSingleTime(() => { + clearYListeners(); + handleOpenEnd(); + resolve(); + }); + + if (!initialSnapPoint) { + console.warn( + 'No initial snap point found', + initialSnap, + snapPoints + ); + onAnimationComplete(); + return; + } + + if (skipOpenAnimation) { + handleOpenEnd(); + resolve(); + y.set(initialSnapPoint.snapValueY); + } else { + yListenersRef.current.push( + y.on('animationCancel', () => { + clearYListeners(); + if (openStateRef.current === 'opening') { + onAnimationComplete(); + } else { + reject('stopped opening'); + } + }), + y.on('animationComplete', onAnimationComplete) + ); + + animate(y, initialSnapPoint.snapValueY, animationOptions).then( + onAnimationComplete + ); + } + }; + + /** + * This is not very React-y but we need to wait for the sheet + * but we need to wait for the sheet to be rendered and visible + * before we can measure it and animate it to the initial snap point. + */ + if (isContentDetent) { + waitForElement('react-modal-sheet-container').then( + doWhenSheetReady + ); + } else { + doWhenSheetReady(); + } + }); }, - onClosing: async () => { - onCloseStart?.(); + onClosing: () => { + return new Promise((resolve, reject) => { + clearYListeners(); + + y.stop(); + openStateRef.current = 'closing'; + onCloseStart?.(); + + const handleCloseEnd = () => { + // Reset locked content height for initial-content detent + if (detent === 'initial-content') { + setLockedContentHeight(null); + } + + if (onCloseEnd) { + // waiting a frame to ensure the sheet is fully closed + // otherwise it was causing some issue with AnimatePresence's safeToRemove + requestAnimationFrame(() => onCloseEnd()); + } + openStateRef.current = 'closed'; + }; + + const onAnimationComplete = makeCallableSingleTime(() => { + clearYListeners(); + handleCloseEnd(); + resolve(); + }); - await animate(y, closedY, animationOptions); + yListenersRef.current.push( + y.on('animationCancel', () => { + clearYListeners(); + + if (openStateRef.current === 'closing') { + onAnimationComplete(); + } else { + reject('stopped closing'); + } + }), + y.on('animationComplete', () => { + onAnimationComplete(); + }) + ); - onCloseEnd?.(); + animate(y, closedY, animationOptions).then(() => { + onAnimationComplete(); + }); + }); }, }); @@ -345,9 +647,15 @@ export const Sheet = forwardRef( indicatorRotation, avoidKeyboard, sheetBoundsRef, - sheetRef, + positionerRef, + containerRef, unstyled, y, + yOverflow, + sheetHeight, + safeSpaceTop: safeSpaceTop + safeAreaInsets.top, + safeSpaceBottom: safeSpaceBottom + safeAreaInsets.bottom, + lockedContentHeight, }; const sheet = ( @@ -355,7 +663,9 @@ export const Sheet = forwardRef( ( ); Sheet.displayName = 'Sheet'; + +function linear( + value: number, + inputMin: number, + inputMax: number, + outputMin: number, + outputMax: number +): number { + const t = Math.max( + 0, + Math.min(1, (value - inputMin) / (inputMax - inputMin)) + ); + return outputMin + (outputMax - outputMin) * t; +} + +function makeCallableSingleTime(fn: () => T) { + let called = false; + return () => { + if (called) return; + called = true; + fn(); + }; +} diff --git a/src/snap.ts b/src/snap.ts index cefdb57..e404ce9 100644 --- a/src/snap.ts +++ b/src/snap.ts @@ -39,9 +39,13 @@ import { isAscendingOrder } from './utils'; export function computeSnapPoints({ snapPointsProp, sheetHeight, + minSnapValue, + maxSnapValue, }: { snapPointsProp: number[]; sheetHeight: number; + minSnapValue: number; + maxSnapValue: number; }): SheetSnapPoint[] { if (snapPointsProp[0] !== 0) { console.error( @@ -69,11 +73,15 @@ export function computeSnapPoints({ const snapPointValues = snapPointsProp.map((point) => { // Percentage values e.g. between 0.0 and 1.0 + let value: number; if (point > 0 && point <= 1) { - return Math.round(point * sheetHeight); + value = Math.round(point * sheetHeight); + } else { + value = point < 0 ? sheetHeight + point : point; // negative values } - return point < 0 ? sheetHeight + point : point; // negative values + // Apply min/max constraints to the snap values + return Math.min(Math.max(value, minSnapValue), maxSnapValue); }); console.assert( @@ -91,20 +99,30 @@ export function computeSnapPoints({ } }); - if (!snapPointValues.includes(sheetHeight)) { + const constrainedSheetHeight = Math.min( + Math.max(sheetHeight, minSnapValue), + maxSnapValue + ); + if (!snapPointValues.includes(constrainedSheetHeight)) { console.warn( 'Snap points do not include the sheet height.' + 'Please include `1` as the last snap point or it will be included automatically.' + 'This is to ensure the sheet can be fully opened.' ); - snapPointValues.push(sheetHeight); + snapPointValues.push(constrainedSheetHeight); } - return snapPointValues.map((snap, index) => ({ - snapIndex: index, - snapValue: snap, // Absolute value from the bottom of the sheet - snapValueY: sheetHeight - snap, // Y value is inverted as `y = 0` means sheet is at the top - })); + const minSnapValueY = sheetHeight - maxSnapValue; + const maxSnapValueY = sheetHeight - minSnapValue; + + return snapPointValues.map((snap, index) => { + const snapValueY = sheetHeight - snap; + return { + snapIndex: index, + snapValue: snap, // Absolute value from the bottom of the sheet + snapValueY: Math.min(Math.max(snapValueY, minSnapValueY), maxSnapValueY), // Y value is inverted as `y = 0` means sheet is at the top + }; + }); } function findClosestSnapPoint({ @@ -234,9 +252,29 @@ export function handleLowVelocityDrag({ }; } - // No snap point down, stay at current - return { + const noChangesResult = { yTo: currentSnapPoint.snapValueY, snapIndex: currentSnapPoint.snapIndex, }; + + switch (dragDirection) { + case 'down': { + const firstSnapPoint = snapPoints.at(0); + // No snap point down, stay at current + if (!firstSnapPoint) return noChangesResult; + return { + yTo: firstSnapPoint.snapValueY, + snapIndex: firstSnapPoint.snapIndex, + }; + } + case 'up': { + const lastSnapPoint = snapPoints.at(-1); + // No snap point up, stay at current + if (!lastSnapPoint) return noChangesResult; + return { + yTo: lastSnapPoint.snapValueY, + snapIndex: lastSnapPoint.snapIndex, + }; + } + } } diff --git a/src/styles.ts b/src/styles.ts index a37fdad..6b08a91 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -8,7 +8,7 @@ export const styles = { bottom: 0, left: 0, right: 0, - overflow: 'hidden', + overflow: 'clip', pointerEvents: 'none', }, decorative: {}, @@ -23,6 +23,7 @@ export const styles = { height: '100%', touchAction: 'none', userSelect: 'none', + WebkitUserSelect: 'none', }, decorative: { backgroundColor: 'rgba(0, 0, 0, 0.2)', @@ -30,16 +31,26 @@ export const styles = { WebkitTapHighlightColor: 'transparent', }, }, - container: { + positioner: { base: { zIndex: 2, position: 'absolute', left: 0, bottom: 0, + display: 'flex', + flexDirection: 'column', width: '100%', - pointerEvents: 'auto', + pointerEvents: 'none', + }, + decorative: {}, + }, + container: { + base: { display: 'flex', flexDirection: 'column', + overflow: 'hidden', + pointerEvents: 'auto', + flex: 1, }, decorative: { backgroundColor: '#fff', diff --git a/src/types.tsx b/src/types.tsx index c918f3f..095c72f 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -1,4 +1,5 @@ import { + type Ref, type ComponentPropsWithoutRef, type ForwardRefExoticComponent, type FunctionComponent, @@ -15,7 +16,7 @@ import { type motion, } from 'motion/react'; -export type SheetDetent = 'default' | 'full' | 'content'; +export type SheetDetent = 'default' | 'full' | 'content' | 'initial-content'; type CommonProps = { className?: string; @@ -24,7 +25,10 @@ type CommonProps = { type MotionProps = ComponentPropsWithoutRef; -type MotionCommonProps = Omit; +type MotionCommonProps = Omit< + MotionProps, + 'initial' | 'animate' | 'exit' | 'dragConstraints' +>; export interface SheetTweenConfig { ease: EasingDefinition; @@ -34,13 +38,16 @@ export interface SheetTweenConfig { export type SheetProps = { unstyled?: boolean; avoidKeyboard?: boolean; + onKeyboardOpen?: (() => VoidFunction) | (() => void); children: ReactNode; detent?: SheetDetent; disableDismiss?: boolean; disableDrag?: boolean; disableScrollLocking?: boolean; + disableCloseOnEscape?: boolean; dragCloseThreshold?: number; dragVelocityThreshold?: number; + safeSpace?: Partial<{ top: number; bottom: number }>; // pixels initialSnap?: number; // index of snap points array isOpen: boolean; modalEffectRootId?: string; @@ -49,17 +56,21 @@ export type SheetProps = { prefersReducedMotion?: boolean; snapPoints?: number[]; tweenConfig?: SheetTweenConfig; + skipOpenAnimation?: boolean; onClose: () => void; onCloseEnd?: () => void; onCloseStart?: () => void; onOpenEnd?: () => void; onOpenStart?: () => void; onSnap?: (index: number) => void; + inert?: ''; } & MotionCommonProps; export type SheetContainerProps = MotionCommonProps & CommonProps & { children: ReactNode; + renderAbove?: ReactNode; + positionerRef?: Ref; }; export type SheetHeaderProps = MotionCommonProps & @@ -73,6 +84,7 @@ export type SheetContentProps = MotionCommonProps & disableDrag?: boolean | ((args: SheetStateInfo) => boolean); disableScroll?: boolean | ((args: SheetStateInfo) => boolean); scrollRef?: RefObject; + avoidKeyboard?: boolean; }; export type SheetBackdropProps = MotionProps & @@ -112,9 +124,16 @@ export interface SheetContextType { indicatorRotation: MotionValue; avoidKeyboard: boolean; sheetBoundsRef: (node: HTMLDivElement | null) => void; - sheetRef: RefObject; + positionerRef: RefObject; + containerRef: RefObject; unstyled: boolean; y: MotionValue; + yOverflow: MotionValue; + sheetHeight: number; + safeSpaceTop: number; + safeSpaceBottom: number; + /** For initial-content detent: the locked height once the sheet opens */ + lockedContentHeight: number | null; } export interface SheetScrollerContextType { diff --git a/src/utils.ts b/src/utils.ts index 2b6f8c7..0b1ccff 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,7 @@ import { type CSSProperties, type ForwardedRef, type RefCallback } from 'react'; import { IS_SSR } from './constants'; +import type { Axis } from 'motion/react'; +import { mixNumber } from 'motion/react'; export function applyStyles( styles: { base: CSSProperties; decorative: CSSProperties }, @@ -68,6 +70,16 @@ export const isIOS = cached(function () { return isIPhone() || isIPad(); }); +const isSafari = cached(function () { + return navigator.userAgent.search(/Safari/g) !== -1; +}); + +export const isIOSSafari26 = cached(function () { + if (!isIOS()) return false; + if (!isSafari()) return false; + return navigator.userAgent.search(/Version\/26[0-9.]*/g) !== -1; +}); + /** Wait for an element to be rendered and visible */ export function waitForElement( className: string, @@ -88,3 +100,25 @@ export function waitForElement( }, interval); }); } + +// source: https://github.com/motiondivision/motion/blob/main/packages/framer-motion/src/gestures/drag/utils/constraints.ts#L18 +/** + * Apply constraints to a point. These constraints are both physical along an + * axis, and an elastic factor that determines how much to constrain the point + * by if it does lie outside the defined parameters. + */ +export function applyConstraints( + point: number, + { min, max }: Partial, + elastic?: Axis +): number { + if (min !== undefined && point < min) { + // If we have a min point defined, and this is outside of that, constrain + point = elastic ? mixNumber(min, point, elastic.min) : Math.max(point, min); + } else if (max !== undefined && point > max) { + // If we have a max point defined, and this is outside of that, constrain + point = elastic ? mixNumber(max, point, elastic.max) : Math.min(point, max); + } + + return point; +}