react-modal-sheet
Version:
Flexible bottom sheet component for your React apps
1,372 lines (1,358 loc) • 42.6 kB
JavaScript
import { motion, useMotionValue, useTransform, useReducedMotion, animate } from 'motion/react';
import React4, { createContext, forwardRef, useRef, useState, useImperativeHandle, useContext, useCallback, useEffect, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import useMeasure from 'react-use-measure';
import { transform } from 'motion';
// src/SheetBackdrop.tsx
var SheetContext = createContext(
void 0
);
function useSheetContext() {
const context = useContext(SheetContext);
if (!context) throw new Error("Sheet context error");
return context;
}
// src/styles.ts
var styles = {
root: {
base: {
position: "fixed",
top: 0,
bottom: 0,
left: 0,
right: 0,
overflow: "hidden",
pointerEvents: "none"
},
decorative: {}
},
backdrop: {
base: {
zIndex: 1,
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
touchAction: "none",
userSelect: "none"
},
decorative: {
backgroundColor: "rgba(0, 0, 0, 0.2)",
border: "none",
WebkitTapHighlightColor: "transparent"
}
},
container: {
base: {
zIndex: 2,
position: "absolute",
left: 0,
bottom: 0,
width: "100%",
pointerEvents: "auto",
display: "flex",
flexDirection: "column"
},
decorative: {
backgroundColor: "#fff",
borderTopRightRadius: "8px",
borderTopLeftRadius: "8px",
boxShadow: "0px -2px 16px rgba(0, 0, 0, 0.3)"
}
},
headerWrapper: {
base: {
width: "100%"
},
decorative: {}
},
header: {
base: {
width: "100%",
position: "relative"
},
decorative: {
height: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center"
}
},
indicatorWrapper: {
base: {
display: "flex"
},
decorative: {}
},
indicator: {
base: {
display: "inline-block"
},
decorative: {
width: "18px",
height: "4px",
borderRadius: "99px",
backgroundColor: "#ddd"
}
},
content: {
base: {
minHeight: "0px",
position: "relative",
flexGrow: 1,
display: "flex",
flexDirection: "column"
},
decorative: {}
},
scroller: {
base: {
height: "100%",
overflowY: "auto",
overscrollBehaviorY: "none"
},
decorative: {}
}
};
// src/constants.ts
var DEFAULT_HEIGHT = "calc(100% - env(safe-area-inset-top) - 34px)";
var IS_SSR = typeof window === "undefined";
var DEFAULT_TWEEN_CONFIG = {
ease: "easeOut",
duration: 0.2
};
var REDUCED_MOTION_TWEEN_CONFIG = {
ease: "linear",
duration: 0.01
};
var DEFAULT_DRAG_CLOSE_THRESHOLD = 0.6;
var DEFAULT_DRAG_VELOCITY_THRESHOLD = 500;
// src/utils.ts
function applyStyles(styles2, unstyled) {
return unstyled ? styles2.base : { ...styles2.base, ...styles2.decorative };
}
function isAscendingOrder(arr) {
for (let i = 0; i < arr.length; i++) {
if (arr[i + 1] < arr[i]) return false;
}
return true;
}
function mergeRefs(refs) {
return (value) => {
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(value);
} else if (ref) {
ref.current = value;
}
});
};
}
function testPlatform(re) {
var _a;
return typeof window !== "undefined" && window.navigator != null ? re.test(
// @ts-expect-error
((_a = window.navigator.userAgentData) == null ? void 0 : _a.platform) || window.navigator.platform
) : false;
}
function cached(fn) {
let res = null;
return () => {
if (res == null) {
res = fn();
}
return res;
};
}
var isMac = cached(function() {
return testPlatform(/^Mac/i);
});
var isIPhone = cached(function() {
return testPlatform(/^iPhone/i);
});
var isIPad = cached(function() {
return testPlatform(/^iPad/i) || isMac() && navigator.maxTouchPoints > 1;
});
var isIOS = cached(function() {
return isIPhone() || isIPad();
});
function waitForElement(className, interval = 50, maxAttempts = 20) {
return new Promise((resolve) => {
let attempts = 0;
const timer = setInterval(() => {
const element = document.getElementsByClassName(
className
)[0];
attempts++;
if (element || attempts >= maxAttempts) {
clearInterval(timer);
resolve(element);
}
}, interval);
});
}
// src/SheetBackdrop.tsx
var isClickable = (props) => !!props.onClick || !!props.onTap;
var SheetBackdrop = forwardRef(
({ style, className = "", unstyled, ...rest }, ref) => {
const sheetContext = useSheetContext();
const clickable = isClickable(rest);
const Comp = clickable ? motion.button : motion.div;
const pointerEvents = clickable ? "auto" : "none";
const isUnstyled = unstyled ?? sheetContext.unstyled;
const backdropStyle = {
...applyStyles(styles.backdrop, isUnstyled),
...style,
pointerEvents
};
return /* @__PURE__ */ React4.createElement(
Comp,
{
...rest,
ref,
className: `react-modal-sheet-backdrop ${className}`,
style: backdropStyle,
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 1 }
}
);
}
);
SheetBackdrop.displayName = "SheetBackdrop";
var SheetContainer = forwardRef(
({ children, style, className = "", unstyled, ...rest }, ref) => {
const sheetContext = useSheetContext();
const isUnstyled = unstyled ?? sheetContext.unstyled;
const containerStyle = {
...applyStyles(styles.container, isUnstyled),
...style,
y: sheetContext.y
};
if (sheetContext.detent === "default") {
containerStyle.height = DEFAULT_HEIGHT;
}
if (sheetContext.detent === "full") {
containerStyle.height = "100%";
containerStyle.maxHeight = "100%";
}
if (sheetContext.detent === "content") {
containerStyle.height = "auto";
containerStyle.maxHeight = DEFAULT_HEIGHT;
}
return /* @__PURE__ */ React4.createElement(
motion.div,
{
...rest,
ref: mergeRefs([
ref,
sheetContext.sheetRef,
sheetContext.sheetBoundsRef
]),
className: `react-modal-sheet-container ${className}`,
style: containerStyle
},
children
);
}
);
SheetContainer.displayName = "SheetContainer";
var constraints = { bottom: 0, top: 0, left: 0, right: 0 };
function useDragConstraints() {
const ref = useRef(null);
const onMeasure = useCallback(() => constraints, []);
return { ref, onMeasure };
}
function useScrollPosition(options = {}) {
const { debounceDelay = 32, isEnabled = true } = options;
const [element, setElement] = useState(null);
const [scrollPosition, setScrollPosition] = useState(void 0);
useEffect(() => {
if (!element || !isEnabled) return;
let scrollTimeout = null;
function determineScrollPosition(element2) {
const { scrollTop, scrollHeight, clientHeight } = element2;
const isScrollable2 = scrollHeight > clientHeight;
if (!isScrollable2) {
if (scrollPosition) setScrollPosition(void 0);
return;
}
const isAtTop = scrollTop <= 0;
const isAtBottom = Math.ceil(scrollHeight) - Math.ceil(scrollTop) === Math.ceil(clientHeight);
let position;
if (isAtTop) {
position = "top";
} else if (isAtBottom) {
position = "bottom";
} else {
position = "middle";
}
if (position === scrollPosition) return;
setScrollPosition(position);
}
function onScroll(event) {
if (event.currentTarget instanceof HTMLElement) {
const el = event.currentTarget;
if (scrollTimeout) clearTimeout(scrollTimeout);
if (debounceDelay === 0) {
determineScrollPosition(el);
} else {
scrollTimeout = setTimeout(
() => determineScrollPosition(el),
debounceDelay
);
}
}
}
function onTouchStart(event) {
if (event.currentTarget instanceof HTMLElement) {
determineScrollPosition(event.currentTarget);
}
}
determineScrollPosition(element);
element.addEventListener("scroll", onScroll);
element.addEventListener("touchstart", onTouchStart);
return () => {
if (scrollTimeout) clearTimeout(scrollTimeout);
element.removeEventListener("scroll", onScroll);
element.removeEventListener("touchstart", onTouchStart);
};
}, [element, isEnabled]);
return {
scrollRef: (element2) => setElement(element2),
scrollPosition
};
}
// src/SheetContent.tsx
var SheetContent = forwardRef(
({
disableScroll: disableScrollProp,
disableDrag: disableDragProp,
children,
style: styleProp,
className = "",
scrollClassName = "",
scrollStyle: scrollStyleProp,
scrollRef: scrollRefProp = null,
unstyled,
...rest
}, ref) => {
const sheetContext = useSheetContext();
const dragConstraints = useDragConstraints();
const scroll = useScrollPosition();
const disableScroll = typeof disableScrollProp === "function" ? disableScrollProp({
scrollPosition: scroll.scrollPosition,
currentSnap: sheetContext.currentSnap
}) : Boolean(disableScrollProp);
const disableDragDueToScroll = !disableScroll && scroll.scrollPosition && scroll.scrollPosition !== "top";
const disableDragDueToProp = typeof disableDragProp === "function" ? disableDragProp({
scrollPosition: scroll.scrollPosition,
currentSnap: sheetContext.currentSnap
}) : Boolean(disableDragProp);
const disableDrag = disableDragDueToProp || disableDragDueToScroll || sheetContext.disableDrag;
const dragProps = disableDrag || sheetContext.disableDrag ? void 0 : sheetContext.dragProps;
const isUnstyled = unstyled ?? sheetContext.unstyled;
const contentStyle = {
...applyStyles(styles.content, isUnstyled),
...styleProp
};
const scrollStyle = applyStyles(styles.scroller, isUnstyled);
if (sheetContext.avoidKeyboard) {
scrollStyle.paddingBottom = "env(keyboard-inset-height, var(--keyboard-inset-height, 0px))";
}
if (disableScroll) {
scrollStyle.overflowY = "hidden";
}
return /* @__PURE__ */ React4.createElement(
motion.div,
{
...rest,
ref: mergeRefs([ref, dragConstraints.ref]),
className: `react-modal-sheet-content ${className}`,
style: contentStyle,
...dragProps,
dragConstraints: dragConstraints.ref,
onMeasureDragConstraints: dragConstraints.onMeasure
},
/* @__PURE__ */ React4.createElement(
motion.div,
{
ref: mergeRefs([scroll.scrollRef, scrollRefProp]),
style: { ...scrollStyle, ...scrollStyleProp },
className: `react-modal-sheet-content-scroller ${scrollClassName}`
},
children
)
);
}
);
SheetContent.displayName = "SheetContent";
function SheetDragIndicator({
style,
className = "",
unstyled,
...rest
}) {
const sheetContext = useSheetContext();
const indicator1Transform = useTransform(
sheetContext.indicatorRotation,
(r) => `translateX(2px) rotate(${r}deg)`
);
const indicator2Transform = useTransform(
sheetContext.indicatorRotation,
(r) => `translateX(-2px) rotate(${ -1 * r}deg)`
);
const isUnstyled = unstyled ?? sheetContext.unstyled;
const indicatorWrapperStyle = {
...applyStyles(styles.indicatorWrapper, isUnstyled),
...style
};
const indicatorStyle = applyStyles(styles.indicator, isUnstyled);
return /* @__PURE__ */ React4.createElement(
"div",
{
className: `react-modal-sheet-drag-indicator-container ${className}`,
style: indicatorWrapperStyle,
...rest
},
/* @__PURE__ */ React4.createElement(
motion.span,
{
className: "react-modal-sheet-drag-indicator",
style: { ...indicatorStyle, transform: indicator1Transform }
}
),
/* @__PURE__ */ React4.createElement(
motion.span,
{
className: "react-modal-sheet-drag-indicator",
style: { ...indicatorStyle, transform: indicator2Transform }
}
)
);
}
var SheetHeader = forwardRef(
({ children, style, disableDrag, unstyled, className = "", ...rest }, ref) => {
const sheetContext = useSheetContext();
const dragConstraints = useDragConstraints();
const dragProps = disableDrag || sheetContext.disableDrag ? void 0 : sheetContext.dragProps;
const isUnstyled = unstyled ?? sheetContext.unstyled;
const headerWrapperStyle = {
...applyStyles(styles.headerWrapper, isUnstyled),
...style
};
const headerStyle = applyStyles(styles.header, isUnstyled);
return /* @__PURE__ */ React4.createElement(
motion.div,
{
...rest,
ref: mergeRefs([ref, dragConstraints.ref]),
style: headerWrapperStyle,
className: `react-modal-sheet-header-container ${className}`,
...dragProps,
dragConstraints: dragConstraints.ref,
onMeasureDragConstraints: dragConstraints.onMeasure
},
children || /* @__PURE__ */ React4.createElement("div", { className: "react-modal-sheet-header", style: headerStyle }, /* @__PURE__ */ React4.createElement(SheetDragIndicator, null))
);
}
);
SheetHeader.displayName = "SheetHeader";
var useIsomorphicLayoutEffect = IS_SSR ? useEffect : useLayoutEffect;
// src/hooks/use-dimensions.ts
function useDimensions() {
const [dimensions, setDimensions] = useState(() => ({
windowHeight: !IS_SSR ? window.innerHeight : 0,
windowWidth: !IS_SSR ? window.innerWidth : 0
}));
useIsomorphicLayoutEffect(() => {
function handler() {
setDimensions({
windowHeight: window.innerHeight,
windowWidth: window.innerWidth
});
}
handler();
window.addEventListener("resize", handler);
return () => {
window.removeEventListener("resize", handler);
};
}, []);
return dimensions;
}
function useSafeAreaInsets() {
const [insets] = useState(() => {
const fallback = { top: 0, left: 0, right: 0, bottom: 0 };
if (IS_SSR) return fallback;
const root = document.querySelector(":root");
if (!root) return fallback;
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 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");
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 insets;
}
function getComputedValue(computed, property) {
const strValue = computed.getPropertyValue(property).replace("px", "").trim();
return parseInt(strValue, 10) || 0;
}
// src/hooks/use-modal-effect.ts
function useModalEffect({
y,
detent,
rootId: _rootId,
sheetHeight,
snapPoints,
startThreshold
}) {
const insetTop = useSafeAreaInsets().top;
let rootId = _rootId;
if (rootId && detent === "full") {
console.warn('Using "full" detent with modal effect is not supported.');
rootId = void 0;
}
useIsomorphicLayoutEffect(() => {
return () => {
if (rootId) cleanupModalEffect(rootId);
};
}, []);
useIsomorphicLayoutEffect(() => {
if (!rootId) return;
const root = document.querySelector(`#${rootId}`);
if (!root) return;
const removeStartListener = y.on("animationStart", () => {
setupModalEffect(rootId);
});
const removeChangeListener = y.on("change", (yValue) => {
if (!root) return;
let progress = Math.max(0, 1 - yValue / sheetHeight);
const snapThresholdPoint = snapPoints.length > 1 ? snapPoints[snapPoints.length - 2] : void 0;
if (snapThresholdPoint !== void 0) {
const snapThresholdValue = snapThresholdPoint.snapValueY;
if (yValue <= snapThresholdValue) {
progress = (snapThresholdValue - yValue) / snapThresholdValue;
} else {
progress = 0;
}
}
if (startThreshold !== void 0) {
const startThresholdValue = sheetHeight - Math.min(Math.floor(startThreshold * sheetHeight), sheetHeight);
if (yValue <= startThresholdValue) {
progress = (startThresholdValue - yValue) / startThresholdValue;
} else {
progress = 0;
}
}
progress = Math.max(0, Math.min(1, progress));
const pageWidth = window.innerWidth;
const ty = transform(progress, [0, 1], [0, 24 + insetTop]);
const s = transform(progress, [0, 1], [1, (pageWidth - 16) / pageWidth]);
const borderRadius = transform(progress, [0, 1], [0, 10]);
root.style.transform = `scale(${s}) translate3d(0, ${ty}px, 0)`;
root.style.borderTopRightRadius = `${borderRadius}px`;
root.style.borderTopLeftRadius = `${borderRadius}px`;
});
function onCompleted() {
if (y.get() - 5 >= sheetHeight) {
cleanupModalEffect(rootId);
}
}
const removeCompleteListener = y.on("animationComplete", onCompleted);
const removeCancelListener = y.on("animationCancel", onCompleted);
return () => {
removeStartListener();
removeChangeListener();
removeCompleteListener();
removeCancelListener();
};
}, [y, rootId, insetTop, startThreshold, sheetHeight]);
}
function setupModalEffect(rootId) {
const root = document.querySelector(`#${rootId}`);
const body = document.querySelector("body");
if (!root) return;
body.style.backgroundColor = "#000";
root.style.overflow = "hidden";
root.style.transitionTimingFunction = "cubic-bezier(0.32, 0.72, 0, 1)";
root.style.transitionProperty = "transform, border-radius";
root.style.transitionDuration = "0.5s";
root.style.transformOrigin = "center top";
}
function cleanupModalEffect(rootId) {
const root = document.querySelector(`#${rootId}`);
const body = document.querySelector("body");
if (!root) return;
body.style.removeProperty("background-color");
root.style.removeProperty("overflow");
root.style.removeProperty("transition-timing-function");
root.style.removeProperty("transition-property");
root.style.removeProperty("transition-duration");
root.style.removeProperty("transform-origin");
root.style.removeProperty("transform");
root.style.removeProperty("border-top-right-radius");
root.style.removeProperty("border-top-left-radius");
}
// src/hooks/use-prevent-scroll.ts
var KEYBOARD_BUFFER = 24;
function chain(...callbacks) {
return (...args) => {
for (const callback of callbacks) {
if (typeof callback === "function") {
callback(...args);
}
}
};
}
var visualViewport = typeof document !== "undefined" && window.visualViewport;
function isScrollable(node, checkForOverflow) {
if (!node) {
return false;
}
const style = window.getComputedStyle(node);
let isScrollable2 = /(auto|scroll)/.test(
style.overflow + style.overflowX + style.overflowY
);
if (isScrollable2 && checkForOverflow) {
isScrollable2 = node.scrollHeight !== node.clientHeight || node.scrollWidth !== node.clientWidth;
}
return isScrollable2;
}
function getScrollParent(node, checkForOverflow) {
let scrollableNode = node;
if (isScrollable(scrollableNode, checkForOverflow)) {
scrollableNode = scrollableNode.parentElement;
}
while (scrollableNode && !isScrollable(scrollableNode, checkForOverflow)) {
scrollableNode = scrollableNode.parentElement;
}
return scrollableNode || document.scrollingElement || document.documentElement;
}
var nonTextInputTypes = /* @__PURE__ */ new Set([
"checkbox",
"radio",
"range",
"color",
"file",
"image",
"button",
"submit",
"reset"
]);
var preventScrollCount = 0;
var restore;
function usePreventScroll(options = {}) {
const { isDisabled } = options;
useIsomorphicLayoutEffect(() => {
if (isDisabled) {
return;
}
preventScrollCount++;
if (preventScrollCount === 1) {
if (isIOS()) {
restore = preventScrollMobileSafari();
} else {
restore = preventScrollStandard();
}
}
return () => {
preventScrollCount--;
if (preventScrollCount === 0) {
restore == null ? void 0 : restore();
}
};
}, [isDisabled]);
}
function preventScrollStandard() {
return chain(
setStyle(
document.documentElement,
"paddingRight",
`${window.innerWidth - document.documentElement.clientWidth}px`
),
setStyle(document.documentElement, "overflow", "hidden")
);
}
function preventScrollMobileSafari() {
let scrollable;
let lastY = 0;
const onTouchStart = (e) => {
var _a;
const target = (_a = e.composedPath()) == null ? void 0 : _a[0];
scrollable = getScrollParent(target, true);
if (scrollable === document.documentElement && scrollable === document.body) {
return;
}
lastY = e.changedTouches[0].pageY;
};
const onTouchMove = (e) => {
if (scrollable === void 0) {
return;
}
if (!scrollable || scrollable === document.documentElement || scrollable === document.body) {
e.preventDefault();
return;
}
const y = e.changedTouches[0].pageY;
const scrollTop = scrollable.scrollTop;
const bottom = scrollable.scrollHeight - scrollable.clientHeight;
if (bottom === 0) {
return;
}
if (scrollTop <= 0 && y > lastY || scrollTop >= bottom && y < lastY) {
e.preventDefault();
}
lastY = y;
};
const onTouchEnd = (e) => {
var _a;
const target = (_a = e.composedPath()) == null ? void 0 : _a[0];
if (willOpenKeyboard(target) && target !== document.activeElement) {
e.preventDefault();
target.style.transform = "translateY(-2000px)";
target.focus();
requestAnimationFrame(() => {
target.style.transform = "";
});
}
};
const onFocus = (e) => {
var _a;
const target = (_a = e.composedPath()) == null ? void 0 : _a[0];
if (willOpenKeyboard(target)) {
target.style.transform = "translateY(-2000px)";
requestAnimationFrame(() => {
target.style.transform = "";
if (visualViewport) {
if (visualViewport.height < window.innerHeight) {
requestAnimationFrame(() => {
scrollIntoView(target);
});
} else {
visualViewport.addEventListener(
"resize",
() => scrollIntoView(target),
{ once: true }
);
}
}
});
}
};
const onWindowScroll = () => {
window.scrollTo(0, 0);
};
const scrollX = window.pageXOffset;
const scrollY = window.pageYOffset;
const restoreStyles = chain(
setStyle(
document.documentElement,
"paddingRight",
`${window.innerWidth - document.documentElement.clientWidth}px`
),
setStyle(document.documentElement, "overflow", "hidden"),
setStyle(document.body, "marginTop", `-${scrollY}px`)
);
window.scrollTo(0, 0);
const removeEvents = chain(
addEvent(document, "touchstart", onTouchStart, {
passive: false,
capture: true
}),
addEvent(document, "touchmove", onTouchMove, {
passive: false,
capture: true
}),
addEvent(document, "touchend", onTouchEnd, {
passive: false,
capture: true
}),
addEvent(document, "focus", onFocus, true),
addEvent(window, "scroll", onWindowScroll)
);
return () => {
restoreStyles();
removeEvents();
window.scrollTo(scrollX, scrollY);
};
}
function setStyle(element, style, value) {
const cur = element.style[style];
element.style[style] = value;
return () => {
element.style[style] = cur;
};
}
function addEvent(target, event, handler, options) {
target.addEventListener(event, handler, options);
return () => {
target.removeEventListener(event, handler, options);
};
}
function scrollIntoView(target) {
const root = document.scrollingElement || document.documentElement;
while (target && target !== root) {
const scrollable = getScrollParent(target);
if (scrollable !== document.documentElement && scrollable !== document.body && scrollable !== target) {
const scrollableTop = scrollable.getBoundingClientRect().top;
const targetTop = target.getBoundingClientRect().top;
const targetBottom = target.getBoundingClientRect().bottom;
const keyboardHeight = scrollable.getBoundingClientRect().bottom + KEYBOARD_BUFFER;
if (targetBottom > keyboardHeight) {
scrollable.scrollTop += targetTop - scrollableTop;
}
}
target = scrollable.parentElement;
}
}
function willOpenKeyboard(target) {
return target instanceof HTMLInputElement && !nonTextInputTypes.has(target.type) || target instanceof HTMLTextAreaElement || target instanceof HTMLElement && target.isContentEditable;
}
function useStableCallback(handler) {
const handlerRef = useRef(void 0);
useIsomorphicLayoutEffect(() => {
handlerRef.current = handler;
});
return useCallback((...args) => {
const fn = handlerRef.current;
return fn == null ? void 0 : fn(...args);
}, []);
}
// src/hooks/use-sheet-state.ts
function useSheetState({
isOpen,
onClosed: _onClosed,
onOpening: _onOpening,
onOpen: _onOpen,
onClosing: _onClosing
}) {
const [state, setState] = useState(isOpen ? "opening" : "closed");
const onClosed = useStableCallback(() => _onClosed == null ? void 0 : _onClosed());
const onOpening = useStableCallback(() => _onOpening == null ? void 0 : _onOpening());
const onOpen = useStableCallback(() => _onOpen == null ? void 0 : _onOpen());
const onClosing = useStableCallback(() => _onClosing == null ? void 0 : _onClosing());
useEffect(() => {
if (isOpen && state === "closed") {
setState("opening");
} else if (!isOpen && (state === "open" || state === "opening")) {
setState("closing");
}
}, [isOpen, state]);
useEffect(() => {
async function handle() {
switch (state) {
case "closed":
await (onClosed == null ? void 0 : onClosed());
break;
case "opening":
await (onOpening == null ? void 0 : onOpening());
setState("open");
break;
case "open":
await (onOpen == null ? void 0 : onOpen());
break;
case "closing":
await (onClosing == null ? void 0 : onClosing());
setState("closed");
break;
}
}
handle().catch((error) => {
console.error("Internal sheet state error:", error);
});
}, [state]);
return state;
}
function useVirtualKeyboard(options = {}) {
const {
containerRef,
isEnabled = true,
debounceDelay = 100,
includeContentEditable = true,
visualViewportThreshold = 100
} = options;
const [state, setState] = useState({
isVisible: false,
height: 0
});
const focusedElementRef = useRef(null);
const debounceTimer = useRef(null);
const isTextInput = useStableCallback((el) => {
return (el == null ? void 0 : el.tagName) === "INPUT" || (el == null ? void 0 : el.tagName) === "TEXTAREA" || includeContentEditable && el instanceof HTMLElement && el.isContentEditable;
});
useEffect(() => {
if (!isEnabled) return;
const vv = window.visualViewport;
const vk = navigator.virtualKeyboard;
function setKeyboardInsetHeightEnv(height) {
const element = (containerRef == null ? void 0 : containerRef.current) || document.documentElement;
if (window.isSecureContext) {
element.style.setProperty(
"--keyboard-inset-height",
`env(keyboard-inset-height, ${height}px)`
);
} else {
element.style.setProperty("--keyboard-inset-height", `${height}px`);
}
}
function handleFocusIn(e) {
if (e.target instanceof HTMLElement && isTextInput(e.target)) {
focusedElementRef.current = e.target;
updateKeyboardState();
}
}
function handleFocusOut() {
focusedElementRef.current = null;
updateKeyboardState();
}
function updateKeyboardState() {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
debounceTimer.current = setTimeout(() => {
const active = focusedElementRef.current;
const inputIsFocused = isTextInput(active);
if (!inputIsFocused) {
setKeyboardInsetHeightEnv(0);
setState({ isVisible: false, height: 0 });
return;
}
if (vv) {
const heightDiff = window.innerHeight - vv.height;
if (heightDiff > visualViewportThreshold) {
setKeyboardInsetHeightEnv(heightDiff);
setState({ isVisible: true, height: heightDiff });
} else {
setKeyboardInsetHeightEnv(0);
setState({ isVisible: false, height: 0 });
}
}
}, debounceDelay);
}
window.addEventListener("focusin", handleFocusIn);
window.addEventListener("focusout", handleFocusOut);
if (vv) {
vv.addEventListener("resize", updateKeyboardState);
vv.addEventListener("scroll", updateKeyboardState);
}
let currentOverlaysContent = false;
if (vk) {
currentOverlaysContent = vk.overlaysContent;
vk.overlaysContent = true;
}
return () => {
window.removeEventListener("focusin", handleFocusIn);
window.removeEventListener("focusout", handleFocusOut);
if (vv) {
vv.removeEventListener("resize", updateKeyboardState);
vv.removeEventListener("scroll", updateKeyboardState);
}
if (vk) {
vk.overlaysContent = currentOverlaysContent;
}
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
};
}, [
debounceDelay,
includeContentEditable,
isEnabled,
visualViewportThreshold
]);
return {
keyboardHeight: state.height,
isKeyboardOpen: state.isVisible
};
}
// src/snap.ts
function computeSnapPoints({
snapPointsProp,
sheetHeight
}) {
if (snapPointsProp[0] !== 0) {
console.error(
`First snap point should be 0 to ensure the sheet can be fully closed. Got: [${snapPointsProp.join(", ")}]`
);
snapPointsProp.unshift(0);
}
if (snapPointsProp[snapPointsProp.length - 1] !== 1) {
console.error(
`Last snap point should be 1 to ensure the sheet can be fully opened. Got: [${snapPointsProp.join(", ")}]`
);
snapPointsProp.push(1);
}
if (sheetHeight <= 0) {
console.error(
`Sheet height is ${sheetHeight}, cannot compute snap points. Make sure the sheet is mounted and has a valid height.`
);
return [];
}
const snapPointValues = snapPointsProp.map((point) => {
if (point > 0 && point <= 1) {
return Math.round(point * sheetHeight);
}
return point < 0 ? sheetHeight + point : point;
});
console.assert(
isAscendingOrder(snapPointValues),
`Snap points need to be in ascending order got: [${snapPointsProp.join(", ")}]`
);
snapPointValues.forEach((snap) => {
if (snap < 0 || snap > sheetHeight) {
console.warn(
`Snap point ${snap} is outside of the sheet height ${sheetHeight}. This can cause unexpected behavior. Consider adjusting your snap points.`
);
}
});
if (!snapPointValues.includes(sheetHeight)) {
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);
}
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
}));
}
function findClosestSnapPoint({
snapPoints,
currentY
}) {
return snapPoints.reduce(
(closest, snap) => Math.abs(snap.snapValueY - currentY) < Math.abs(closest.snapValueY - currentY) ? snap : closest
);
}
function findNextSnapPointInDirection({
y,
snapPoints,
dragDirection
}) {
if (dragDirection === "down") {
return snapPoints.slice().reverse().find((s) => s.snapValueY > y);
} else {
return snapPoints.find((s) => s.snapValueY < y);
}
}
function handleHighVelocityDrag({
dragDirection,
snapPoints
}) {
const bottomSnapPoint = snapPoints[0];
const topSnapPoint = snapPoints[snapPoints.length - 1];
if (dragDirection === "down") {
return {
yTo: bottomSnapPoint.snapValueY,
snapIndex: bottomSnapPoint.snapIndex
};
}
return {
yTo: topSnapPoint.snapValueY,
snapIndex: topSnapPoint.snapIndex
};
}
function handleLowVelocityDrag({
currentSnapPoint,
currentY,
dragDirection,
snapPoints,
velocity
}) {
const closestSnapRelativeToCurrentY = findClosestSnapPoint({
snapPoints,
currentY
});
if (Math.abs(velocity) < 20) {
return {
yTo: closestSnapRelativeToCurrentY.snapValueY,
snapIndex: closestSnapRelativeToCurrentY.snapIndex
};
}
const nextSnapInDirectionRelativeToCurrentY = findNextSnapPointInDirection({
y: currentY,
snapPoints,
dragDirection
});
if (nextSnapInDirectionRelativeToCurrentY) {
return {
yTo: nextSnapInDirectionRelativeToCurrentY.snapValueY,
snapIndex: nextSnapInDirectionRelativeToCurrentY.snapIndex
};
}
return {
yTo: currentSnapPoint.snapValueY,
snapIndex: currentSnapPoint.snapIndex
};
}
// src/sheet.tsx
var Sheet = forwardRef(
({
avoidKeyboard = true,
children,
className = "",
detent = "default",
disableDismiss = false,
disableDrag: disableDragProp = false,
disableScrollLocking = false,
dragCloseThreshold = DEFAULT_DRAG_CLOSE_THRESHOLD,
dragVelocityThreshold = DEFAULT_DRAG_VELOCITY_THRESHOLD,
initialSnap,
isOpen,
modalEffectRootId,
modalEffectThreshold,
mountPoint,
prefersReducedMotion = false,
snapPoints: snapPointsProp,
style,
tweenConfig = DEFAULT_TWEEN_CONFIG,
unstyled = false,
onOpenStart,
onOpenEnd,
onClose,
onCloseStart,
onCloseEnd,
onSnap,
onDrag: onDragProp,
onDragStart: onDragStartProp,
onDragEnd: onDragEndProp,
...rest
}, ref) => {
const [sheetBoundsRef, sheetBounds] = useMeasure();
const sheetRef = useRef(null);
const sheetHeight = Math.round(sheetBounds.height);
const [currentSnap, setCurrentSnap] = useState(initialSnap);
const snapPoints = snapPointsProp && sheetHeight > 0 ? computeSnapPoints({ sheetHeight, snapPointsProp }) : [];
const { windowHeight } = useDimensions();
const closedY = sheetHeight > 0 ? sheetHeight : windowHeight;
const y = useMotionValue(closedY);
const yInverted = useTransform(y, (val) => Math.max(sheetHeight - val, 0));
const indicatorRotation = useMotionValue(0);
const shouldReduceMotion = useReducedMotion();
const reduceMotion = Boolean(prefersReducedMotion || shouldReduceMotion);
const animationOptions = {
type: "tween",
...reduceMotion ? REDUCED_MOTION_TWEEN_CONFIG : tweenConfig
};
const keyboard = useVirtualKeyboard({
isEnabled: isOpen && avoidKeyboard,
containerRef: sheetRef
});
const disableDrag = keyboard.isKeyboardOpen || disableDragProp;
const zIndex = useTransform(
y,
(val) => val + 2 >= closedY ? -1 : (style == null ? void 0 : style.zIndex) ?? 9999
);
const visibility = useTransform(
y,
(val) => val + 2 >= closedY ? "hidden" : "visible"
);
const updateSnap = useStableCallback((snapIndex) => {
setCurrentSnap(snapIndex);
onSnap == null ? void 0 : onSnap(snapIndex);
});
const getSnapPoint = useStableCallback((snapIndex) => {
if (snapPointsProp && snapPoints) {
if (snapIndex < 0 || snapIndex >= snapPoints.length) {
console.warn(
`Invalid snap index ${snapIndex}. Snap points are: [${snapPointsProp.join(", ")}] and their computed values are: [${snapPoints.map((point) => point.snapValue).join(", ")}]`
);
return null;
}
return snapPoints[snapIndex];
}
return null;
});
const snapTo = useStableCallback(async (snapIndex) => {
if (!snapPointsProp) {
console.warn("Snapping is not possible without `snapPoints` prop.");
return;
}
const snapPoint = getSnapPoint(snapIndex);
if (snapPoint === null) {
console.warn(`Invalid snap index ${snapIndex}.`);
return;
}
if (snapIndex === 0) {
onClose();
return;
}
await animate(y, snapPoint.snapValueY, {
...animationOptions,
onComplete: () => updateSnap(snapIndex)
});
});
const blurActiveInput = useStableCallback(() => {
const focusedElement = document.activeElement;
if (!focusedElement || !sheetRef.current) return;
const isInput = focusedElement.tagName === "INPUT" || focusedElement.tagName === "TEXTAREA";
if (isInput && sheetRef.current.contains(focusedElement)) {
focusedElement.blur();
}
});
const onDrag = useStableCallback((event, info) => {
onDragProp == null ? void 0 : onDragProp(event, info);
const currentY = y.get();
const velocity = y.getVelocity();
if (velocity > 0) indicatorRotation.set(10);
if (velocity < 0) indicatorRotation.set(-10);
y.set(Math.max(currentY + info.delta.y, 0));
});
const onDragStart = useStableCallback((event, info) => {
blurActiveInput();
onDragStartProp == null ? void 0 : onDragStartProp(event, info);
});
const onDragEnd = useStableCallback((event, info) => {
blurActiveInput();
onDragEndProp == null ? void 0 : onDragEndProp(event, info);
const currentY = y.get();
let yTo = 0;
const currentSnapPoint = currentSnap !== void 0 ? getSnapPoint(currentSnap) : null;
if (currentSnapPoint) {
const dragOffsetDirection = info.offset.y > 0 ? "down" : "up";
const dragVelocityDirection = info.velocity.y > 0 ? "down" : "up";
const isHighVelocity = Math.abs(info.velocity.y) > dragVelocityThreshold;
let result;
if (isHighVelocity) {
result = handleHighVelocityDrag({
snapPoints,
dragDirection: dragVelocityDirection
});
} else {
result = handleLowVelocityDrag({
currentSnapPoint,
currentY,
dragDirection: dragOffsetDirection,
snapPoints,
velocity: info.velocity.y
});
}
yTo = result.yTo;
if (disableDismiss && yTo + 1 >= sheetHeight) {
const bottomSnapPoint = snapPoints.find((s) => s.snapValue > 0);
if (bottomSnapPoint) {
yTo = bottomSnapPoint.snapValueY;
updateSnap(bottomSnapPoint.snapIndex);
} else {
yTo = currentY;
}
} else if (result.snapIndex !== void 0) {
updateSnap(result.snapIndex);
}
} else if (info.velocity.y > dragVelocityThreshold || currentY > sheetHeight * dragCloseThreshold) {
if (disableDismiss) {
yTo = 0;
} else {
yTo = closedY;
}
}
animate(y, yTo, animationOptions);
if (yTo + 1 >= sheetHeight && !disableDismiss) {
onClose();
}
indicatorRotation.set(0);
});
useImperativeHandle(ref, () => ({
y,
yInverted,
height: sheetHeight,
snapTo
}));
useModalEffect({
y,
detent,
sheetHeight,
snapPoints,
rootId: modalEffectRootId,
startThreshold: modalEffectThreshold
});
usePreventScroll({
isDisabled: disableScrollLocking || !isOpen
});
const state = useSheetState({
isOpen,
onOpen: async () => {
onOpenStart == null ? void 0 : onOpenStart();
await waitForElement("react-modal-sheet-container");
const initialSnapPoint = initialSnap !== void 0 ? getSnapPoint(initialSnap) : null;
const yTo = (initialSnapPoint == null ? void 0 : initialSnapPoint.snapValueY) ?? 0;
await animate(y, yTo, animationOptions);
if (initialSnap !== void 0) {
updateSnap(initialSnap);
}
onOpenEnd == null ? void 0 : onOpenEnd();
},
onClosing: async () => {
onCloseStart == null ? void 0 : onCloseStart();
await animate(y, closedY, animationOptions);
onCloseEnd == null ? void 0 : onCloseEnd();
}
});
const dragProps = {
drag: "y",
dragElastic: 0,
dragMomentum: false,
dragPropagation: false,
onDrag,
onDragStart,
onDragEnd
};
const context = {
currentSnap,
detent,
disableDrag,
dragProps,
indicatorRotation,
avoidKeyboard,
sheetBoundsRef,
sheetRef,
unstyled,
y
};
const sheet = /* @__PURE__ */ React4.createElement(SheetContext.Provider, { value: context }, /* @__PURE__ */ React4.createElement(
motion.div,
{
...rest,
ref,
"data-sheet-state": state,
className: `react-modal-sheet-root ${className}`,
style: {
...applyStyles(styles.root, unstyled),
zIndex,
visibility,
...style
}
},
state !== "closed" ? children : null
));
if (IS_SSR) return sheet;
return createPortal(sheet, mountPoint ?? document.body);
}
);
Sheet.displayName = "Sheet";
// src/index.tsx
var Sheet2 = Object.assign(Sheet, {
Container: SheetContainer,
Header: SheetHeader,
DragIndicator: SheetDragIndicator,
Content: SheetContent,
Backdrop: SheetBackdrop
});
export { Sheet2 as Sheet, useScrollPosition, useVirtualKeyboard };
//# sourceMappingURL=index.mjs.map
//# sourceMappingURL=index.mjs.map