fix(stream-header): block body's drag-up from closed so pinning the directs/channels/bots header now requires the dedicated pin handle
This commit is contained in:
parent
fda6c7bd7e
commit
67ee378b39
2 changed files with 88 additions and 48 deletions
|
|
@ -198,7 +198,6 @@ export function StreamHeader({
|
||||||
scrollRef,
|
scrollRef,
|
||||||
snap: curtain.snap,
|
snap: curtain.snap,
|
||||||
pinned: curtain.pinned,
|
pinned: curtain.pinned,
|
||||||
setPinned: curtain.setPinned,
|
|
||||||
setLiveDrag: curtain.setLiveDrag,
|
setLiveDrag: curtain.setLiveDrag,
|
||||||
commit: curtain.commit,
|
commit: curtain.commit,
|
||||||
disabled: gestureDisabled,
|
disabled: gestureDisabled,
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ import {
|
||||||
COMMIT_THRESHOLD,
|
COMMIT_THRESHOLD,
|
||||||
DIRECTION_DEAD_ZONE_PX,
|
DIRECTION_DEAD_ZONE_PX,
|
||||||
PEEK_TRAVEL_PX,
|
PEEK_TRAVEL_PX,
|
||||||
PIN_COMMIT_THRESHOLD,
|
|
||||||
PIN_TRAVEL_PX,
|
|
||||||
RUBBER_BAND,
|
RUBBER_BAND,
|
||||||
} from './geometry';
|
} from './geometry';
|
||||||
import { CurtainSnap, isFormSnap } from './useCurtainState';
|
import { CurtainSnap, isFormSnap } from './useCurtainState';
|
||||||
|
|
@ -52,15 +50,17 @@ type Args = {
|
||||||
// Current snap stop. Mirrored into a ref so the listener — bound
|
// Current snap stop. Mirrored into a ref so the listener — bound
|
||||||
// once per `disabled` flip — reads fresh values without rebinding.
|
// once per `disabled` flip — reads fresh values without rebinding.
|
||||||
snap: CurtainSnap;
|
snap: CurtainSnap;
|
||||||
// Per-pane pinned overlay; also ref-mirrored.
|
// Per-pane pinned overlay; also ref-mirrored. Only READ here (used
|
||||||
|
// by the touchstart bail) — pin / unpin commits are the handle's
|
||||||
|
// exclusive contract, see «Direction asymmetry» on the hook.
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
setPinned: (next: boolean) => void;
|
|
||||||
// Live drag delta sink — feeds the curtain's `top` via React state,
|
// Live drag delta sink — feeds the curtain's `top` via React state,
|
||||||
// no direct DOM writes.
|
// no direct DOM writes.
|
||||||
setLiveDrag: (px: number, dragging: boolean) => void;
|
setLiveDrag: (px: number, dragging: boolean) => void;
|
||||||
// Snap commit (peek / close-peek / form-close). Narrowed to the two
|
// Snap commit. `'peek'` fires from closed-free's down-half;
|
||||||
// non-form destinations the hook ever reaches. pin/unpin flips
|
// `'closed'` fires from close-peek and form-close. The pin / unpin
|
||||||
// `pinned` instead.
|
// paths are handle-only and never flip state through this setter
|
||||||
|
// from the body.
|
||||||
commit: (next: 'peek' | 'closed') => void;
|
commit: (next: 'peek' | 'closed') => void;
|
||||||
// Suppress gesture binding entirely. Same conditions as the handle
|
// Suppress gesture binding entirely. Same conditions as the handle
|
||||||
// hook — see StreamHeader's `gestureDisabled`.
|
// hook — see StreamHeader's `gestureDisabled`.
|
||||||
|
|
@ -79,20 +79,44 @@ type Args = {
|
||||||
// Why a second surface? On listing surfaces with content that fits in
|
// Why a second surface? On listing surfaces with content that fits in
|
||||||
// one screen (empty Direct / Bots / Channels states, the ChannelsLanding
|
// one screen (empty Direct / Bots / Channels states, the ChannelsLanding
|
||||||
// CTA, a workspace with few rooms) the user's natural «pull the curtain
|
// CTA, a workspace with few rooms) the user's natural «pull the curtain
|
||||||
// down to peek» / «push the curtain up to pin» gestures happen anywhere
|
// down to peek» gesture happens anywhere on the visible card.
|
||||||
// on the visible card. Restricting all motion to the 32 px handle on
|
// Restricting all motion to the 32 px handle on these surfaces felt
|
||||||
// these surfaces felt artificial. On the other hand, surfaces with a
|
// artificial. On the other hand, surfaces with a scrollable list need
|
||||||
// scrollable list need their native vertical scroll preserved — so the
|
// their native vertical scroll preserved — so the body gesture is
|
||||||
// body gesture is *conditional*: it engages only when the chat list
|
// *conditional*: it engages only when the chat list has no scrollable
|
||||||
// has no scrollable content (scrollHeight ≤ clientHeight + 1). Long
|
// content (scrollHeight ≤ clientHeight + 1). Long lists keep using the
|
||||||
// lists keep using the handle for curtain motion.
|
// handle for curtain motion.
|
||||||
|
//
|
||||||
|
// Direction asymmetry — pinning is handle-only, retracting is shared.
|
||||||
|
// The body engages on:
|
||||||
|
// * closed + DOWN → peek (closed-free, down-half only)
|
||||||
|
// * peek + UP → closed (close-peek)
|
||||||
|
// * form-* + UP → closed (form-close)
|
||||||
|
// The body does NOT engage on:
|
||||||
|
// * closed + UP → would be pin via closed-free's up-half. The
|
||||||
|
// user reported that arbitrary upward drag on
|
||||||
|
// the body made it too easy to accidentally
|
||||||
|
// close the directs/channels/bots header by
|
||||||
|
// pinning. Pin must be a deliberate gesture on
|
||||||
|
// the dedicated pin-handle. After close-peek /
|
||||||
|
// form-close lands at `closed`, the curtain
|
||||||
|
// can only go further up via the handle.
|
||||||
|
// * pinned + DOWN → unpin / peek-from-pinned. Same rationale: the
|
||||||
|
// pin handle owns the unpin contract too, so an
|
||||||
|
// accidental drag on the visible card can't
|
||||||
|
// undo it. Bailed at touchstart (see Pinned
|
||||||
|
// override below).
|
||||||
|
// The asymmetric block on closed+UP is implemented in onTouchMove
|
||||||
|
// after the transition resolves — we only bail closed-free's UP half,
|
||||||
|
// not every upward drag, so close-peek and form-close still engage on
|
||||||
|
// the body.
|
||||||
//
|
//
|
||||||
// Dynamics: all transitions use rubber-band 0.65 (= RUBBER_BAND) so
|
// Dynamics: all transitions use rubber-band 0.65 (= RUBBER_BAND) so
|
||||||
// the body drag feels physically «heavier» than the handle's crisp
|
// the body drag feels physically «heavier» than the handle's crisp
|
||||||
// 1:1 — the user reads the two surfaces as distinct affordances. The
|
// 1:1 — the user reads the two surfaces as distinct affordances. The
|
||||||
// commit math is expressed in CURTAIN displacement (lastDelta), not
|
// commit math is expressed in CURTAIN displacement (lastDelta), not
|
||||||
// raw finger pull, so a body «commit at PIN_COMMIT_THRESHOLD ×
|
// raw finger pull, so a body «commit at COMMIT_THRESHOLD ×
|
||||||
// PIN_TRAVEL_PX» visually matches a handle commit at the same point —
|
// PEEK_TRAVEL_PX» visually matches a handle commit at the same point —
|
||||||
// only the finger pull needed to get there differs.
|
// only the finger pull needed to get there differs.
|
||||||
//
|
//
|
||||||
// Form-snap override: when a form is mounted, the chat list under it
|
// Form-snap override: when a form is mounted, the chat list under it
|
||||||
|
|
@ -114,7 +138,6 @@ export function useCurtainBodyGesture({
|
||||||
scrollRef,
|
scrollRef,
|
||||||
snap,
|
snap,
|
||||||
pinned,
|
pinned,
|
||||||
setPinned,
|
|
||||||
setLiveDrag,
|
setLiveDrag,
|
||||||
commit,
|
commit,
|
||||||
disabled,
|
disabled,
|
||||||
|
|
@ -124,8 +147,6 @@ export function useCurtainBodyGesture({
|
||||||
snapRef.current = snap;
|
snapRef.current = snap;
|
||||||
const pinnedRef = useRef<boolean>(pinned);
|
const pinnedRef = useRef<boolean>(pinned);
|
||||||
pinnedRef.current = pinned;
|
pinnedRef.current = pinned;
|
||||||
const setPinnedRef = useRef(setPinned);
|
|
||||||
setPinnedRef.current = setPinned;
|
|
||||||
const commitRef = useRef(commit);
|
const commitRef = useRef(commit);
|
||||||
commitRef.current = commit;
|
commitRef.current = commit;
|
||||||
const setHandleStateRef = useRef(setHandleState);
|
const setHandleStateRef = useRef(setHandleState);
|
||||||
|
|
@ -218,14 +239,31 @@ export function useCurtainBodyGesture({
|
||||||
direction = delta > 0 ? 'down' : 'up';
|
direction = delta > 0 ? 'down' : 'up';
|
||||||
transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction);
|
transition = resolveCurtainTransition(snapRef.current, pinnedRef.current, direction);
|
||||||
if (transition === null) {
|
if (transition === null) {
|
||||||
// (snap, pinned, direction) has no valid motion — pinned+up,
|
// (snap, pinned, direction) has no valid motion — peek+down,
|
||||||
// peek+down, form+down. Bail without preventDefault so any
|
// form+down (pinned+up also resolves to null, though the
|
||||||
// native default (overscroll bounce, etc.) can still play.
|
// touchstart pinned-bail already filters every pinned
|
||||||
|
// gesture before we reach here). Bail without preventDefault
|
||||||
|
// so any native default (overscroll bounce, etc.) can still
|
||||||
|
// play.
|
||||||
startX = null;
|
startX = null;
|
||||||
startY = null;
|
startY = null;
|
||||||
direction = null;
|
direction = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Closed-free UP-half bail. closed-free is the only transition
|
||||||
|
// whose upward direction commits to pin — and pin via body is
|
||||||
|
// exactly what the user banned (see «Direction asymmetry» on
|
||||||
|
// the hook). The downward half (closed → peek) stays on body.
|
||||||
|
// close-peek and form-close are also upward, but their commit
|
||||||
|
// target is `closed` — they're the «retract» gestures the user
|
||||||
|
// explicitly wants to keep on the body, so they pass through.
|
||||||
|
if (transition === 'closed-free' && direction === 'up') {
|
||||||
|
startX = null;
|
||||||
|
startY = null;
|
||||||
|
direction = null;
|
||||||
|
transition = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
engaged = true;
|
engaged = true;
|
||||||
|
|
@ -238,31 +276,33 @@ export function useCurtainBodyGesture({
|
||||||
let atCommit = false;
|
let atCommit = false;
|
||||||
switch (transition) {
|
switch (transition) {
|
||||||
case 'closed-free':
|
case 'closed-free':
|
||||||
// Rubber-banded free-range drag spanning pin↔closed↔peek
|
// Body-side `closed-free` is DOWN-only: the handle owns the
|
||||||
// in one motion. NO clamps either side — the curtain
|
// UP half (pin commit) per «Direction asymmetry» above, and
|
||||||
// follows the finger off-screen upward and continuously
|
// the closed-free up-bail in the dead-zone block makes sure
|
||||||
// into peek territory downward. Direction-aware atCommit
|
// we only ever engage this branch with direction='down'.
|
||||||
// shows the right commit feedback for whichever side the
|
// Clamp at 0 below so a mid-gesture finger-up past the
|
||||||
// user is leaning into. Mirrors the handle's `closed-free`
|
// start point can't drag the curtain into pin territory
|
||||||
// but with 0.65× displacement so the body drag reads as
|
// and offer a pin commit that the user explicitly didn't
|
||||||
// physically «heavier».
|
// want exposed on the body. Rubber-banded 0.65×
|
||||||
lastDelta = delta * RUBBER_BAND;
|
// displacement matches the «physically heavier» body feel.
|
||||||
atCommit =
|
lastDelta = Math.max(0, delta * RUBBER_BAND);
|
||||||
lastDelta <= 0
|
atCommit = lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
|
||||||
? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD
|
|
||||||
: lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
|
|
||||||
break;
|
break;
|
||||||
case 'close-peek':
|
case 'close-peek':
|
||||||
// Rubber-banded up. No clamp either side — matches the
|
// Rubber-banded up. No clamp either side — matches the
|
||||||
// original list-bound peek feel; a downward jitter past the
|
// original list-bound peek feel; a downward jitter past the
|
||||||
// peek snap is visually negligible against the rubber-band
|
// peek snap is visually negligible against the rubber-band
|
||||||
// damping.
|
// damping. Commit target is `closed`; no path into pin
|
||||||
|
// territory (the user's hard rule — pin is handle-only).
|
||||||
lastDelta = delta * RUBBER_BAND;
|
lastDelta = delta * RUBBER_BAND;
|
||||||
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
|
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
|
||||||
break;
|
break;
|
||||||
case 'form-close':
|
case 'form-close':
|
||||||
// Rubber-banded up; capped at 0 so an accidental downward
|
// Rubber-banded up; capped at 0 so an accidental downward
|
||||||
// jitter doesn't push the curtain below its form-snap top.
|
// jitter doesn't push the curtain below its form-snap top.
|
||||||
|
// Commit target is `closed` (the form-close drag retracts
|
||||||
|
// through the form's vertical footprint into the closed
|
||||||
|
// snap).
|
||||||
lastDelta = Math.min(0, delta * RUBBER_BAND);
|
lastDelta = Math.min(0, delta * RUBBER_BAND);
|
||||||
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
|
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
|
||||||
break;
|
break;
|
||||||
|
|
@ -296,12 +336,11 @@ export function useCurtainBodyGesture({
|
||||||
}
|
}
|
||||||
switch (transition) {
|
switch (transition) {
|
||||||
case 'closed-free':
|
case 'closed-free':
|
||||||
// Direction-aware commit, sign-exclusive: pin wins UP-side,
|
// Body is DOWN-only — peek is the sole commit target. Pin
|
||||||
// peek wins DOWN-side, below both thresholds spring back to
|
// commit lives on the handle (see «Direction asymmetry»
|
||||||
// closed.
|
// above and the touchmove switch). Below threshold the
|
||||||
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
|
// curtain springs back to closed.
|
||||||
setPinnedRef.current(true);
|
if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
|
||||||
} else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
|
|
||||||
commitRef.current('peek');
|
commitRef.current('peek');
|
||||||
} else {
|
} else {
|
||||||
setLiveDrag(0, false);
|
setLiveDrag(0, false);
|
||||||
|
|
@ -323,9 +362,11 @@ export function useCurtainBodyGesture({
|
||||||
break;
|
break;
|
||||||
case 'pinned-free':
|
case 'pinned-free':
|
||||||
case null:
|
case null:
|
||||||
// Both unreachable per the touchmove switch above; the
|
// Both unreachable per the touchmove switch above (pinned
|
||||||
// setLiveDrag fallback preserves spring-back behaviour if a
|
// bail at touchstart filters pinned-free; `engaged` only
|
||||||
// future change exposes either path here.
|
// flips once `transition` is non-null). The setLiveDrag
|
||||||
|
// fallback preserves spring-back behaviour if a future
|
||||||
|
// change exposes either path here.
|
||||||
setLiveDrag(0, false);
|
setLiveDrag(0, false);
|
||||||
break;
|
break;
|
||||||
default: {
|
default: {
|
||||||
|
|
@ -373,8 +414,8 @@ export function useCurtainBodyGesture({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// setLiveDrag is stable; the ref args are stable. `snap`, `pinned`,
|
// setLiveDrag is stable; the ref args are stable. `snap`, `pinned`,
|
||||||
// `setPinned` and `commit` are ref-mirrored. Only `disabled` needs
|
// and `commit` are ref-mirrored. Only `disabled` needs to tear
|
||||||
// to tear listeners down — it's the sole effect dep.
|
// listeners down — it's the sole effect dep.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [curtainRef, handleRef, bottomPinnedRef, scrollRef, setLiveDrag, disabled]);
|
}, [curtainRef, handleRef, bottomPinnedRef, scrollRef, setLiveDrag, disabled]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue