1.1.2
Some checks failed
Build and Release / build-and-release (push) Has been cancelled

This commit is contained in:
Bryan1029384756
2026-04-17 21:44:01 -05:00
parent a3f6e29d6a
commit ad54f60225
30 changed files with 2517 additions and 475 deletions

View File

@@ -37,7 +37,19 @@
overflow: hidden;
pointer-events: auto;
padding-bottom: env(safe-area-inset-bottom, 0);
touch-action: pan-y;
will-change: transform;
}
.dragZone {
touch-action: none;
user-select: none;
-webkit-user-select: none;
cursor: grab;
flex-shrink: 0;
}
.dragZone:active {
cursor: grabbing;
}
.handleRow {
@@ -46,7 +58,6 @@
justify-content: center;
padding: 8px 0 4px;
flex-shrink: 0;
cursor: grab;
}
.handle {
@@ -106,4 +117,5 @@
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useCallback, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { AnimatePresence, motion, useDragControls } from 'framer-motion';
import { X } from '@phosphor-icons/react';
import clsx from 'clsx';
import styles from './BottomSheet.module.css';
@@ -164,6 +164,22 @@ export function BottomSheet({
const targetY = snap === 'full' ? snapFullPx : snapInitialPx;
// Framer Motion drag controls — we attach drag listening only to a
// dedicated "drag zone" (handle + header) rather than the whole
// sheet. This keeps the body's native scroll from competing with
// the drag gesture, which was the main source of the sluggish/
// inconsistent feel on Android: when the finger landed on body
// content the browser arbitrated scroll-vs-drag every frame.
const dragControls = useDragControls();
const dragEnabled = dismissible && draggable;
const hasDragZone =
dragEnabled && (showHandle || (!disableDefaultHeader && !!title));
const handleDragZonePointerDown = (e: React.PointerEvent) => {
if (!dragEnabled) return;
dragControls.start(e);
};
return createPortal(
<AnimatePresence>
{isOpen && (
@@ -187,9 +203,15 @@ export function BottomSheet({
initial={{ y: '100%' }}
animate={{ y: targetY }}
exit={{ y: '100%' }}
transition={{ type: 'spring', stiffness: 400, damping: 36 }}
transition={{ type: 'spring', stiffness: 500, damping: 42 }}
onClick={(e) => e.stopPropagation()}
drag={dismissible && draggable ? 'y' : false}
drag={dragEnabled ? 'y' : false}
// When a drag zone exists, only it can initiate drag.
// Otherwise (rare: no handle + no header) fall back
// to dragging anywhere on the sheet.
dragListener={hasDragZone ? false : dragEnabled}
dragControls={dragEnabled ? dragControls : undefined}
dragMomentum={false}
dragConstraints={{
// Allow dragging UP to the fully-open snap when
// the sheet is partial and expandable; otherwise
@@ -197,39 +219,33 @@ export function BottomSheet({
top: partial && expandable ? snapFullPx - targetY : 0,
bottom: snapClosedPx - targetY,
}}
dragElastic={{ top: partial && expandable ? 0.05 : 0, bottom: 0.2 }}
dragElastic={{
top: partial && expandable ? 0.12 : 0,
bottom: 0.12,
}}
onDragEnd={(_, info) => {
const finalY = targetY + info.offset.y;
// Velocity-driven flicks first — they win over
// position-based heuristics so a fast swipe
// always does the obvious thing.
if (info.velocity.y > 600) {
// Strong downward flick → close.
// always does the obvious thing. Threshold
// lowered from 600 px/s so deliberate (non-
// flick) drags also register.
if (info.velocity.y > 400) {
onClose();
return;
}
if (partial && expandable && info.velocity.y < -600) {
// Strong upward flick → expand to full.
if (partial && expandable && info.velocity.y < -400) {
setSnap('full');
return;
}
// Position-based snap fallback.
if (!partial) {
// Original behavior — close if dragged
// down past the threshold, otherwise
// spring back to fully open.
if (info.offset.y > 120) onClose();
return;
}
// Partial sheet position-based snap:
// - past the initial position by half its
// distance to closed → close
// - between snaps → snap to nearest
// - above the initial position by 1/3 the
// distance to full → expand
const closeThreshold = snapInitialPx + (snapClosedPx - snapInitialPx) * 0.4;
const expandThreshold = snapInitialPx - snapInitialPx * 0.33;
@@ -244,24 +260,53 @@ export function BottomSheet({
setSnap('initial');
}}
>
{showHandle && (
<div className={styles.handleRow}>
<div className={styles.handle} />
</div>
)}
{hasDragZone ? (
<div
className={styles.dragZone}
onPointerDown={handleDragZonePointerDown}
>
{showHandle && (
<div className={styles.handleRow}>
<div className={styles.handle} />
</div>
)}
{!disableDefaultHeader && title && (
<div className={styles.header}>
<h2 className={styles.title}>{title}</h2>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Close"
>
<X size={20} weight="bold" />
</button>
{!disableDefaultHeader && title && (
<div className={styles.header}>
<h2 className={styles.title}>{title}</h2>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
onPointerDown={(e) => e.stopPropagation()}
aria-label="Close"
>
<X size={20} weight="bold" />
</button>
</div>
)}
</div>
) : (
<>
{showHandle && (
<div className={styles.handleRow}>
<div className={styles.handle} />
</div>
)}
{!disableDefaultHeader && title && (
<div className={styles.header}>
<h2 className={styles.title}>{title}</h2>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label="Close"
>
<X size={20} weight="bold" />
</button>
</div>
)}
</>
)}
<div className={styles.body}>{children}</div>