refactor(stream-header): reset live drag on gesture teardown, drop dead pinned-local fallback, narrow commit() to peek|closed, add exhaustive transition guards and align stale comments
This commit is contained in:
parent
8fb885df1b
commit
240bb54c29
8 changed files with 153 additions and 84 deletions
|
|
@ -143,8 +143,11 @@ export const strip = style({
|
||||||
//
|
//
|
||||||
// No paddingTop here: the per-pane StreamHeader still renders its
|
// No paddingTop here: the per-pane StreamHeader still renders its
|
||||||
// own tabs row (kept for the curtain's TABS_ROW_PX snap math, just
|
// own tabs row (kept for the curtain's TABS_ROW_PX snap math, just
|
||||||
// painted invisible via visibility:hidden), and PageNav's inner
|
// painted invisible via `opacity: 0` — load-bearing because
|
||||||
// column reserves the status-bar safe-area inset via its own
|
// `visibility: hidden` would remove the row from hit-testing and
|
||||||
|
// the per-pane Segments need to capture taps at rest, see
|
||||||
|
// `StreamHeader.tsx` tabsRow rationale), and PageNav's inner column
|
||||||
|
// reserves the status-bar safe-area inset via its own
|
||||||
// `paddingTop: var(--vojo-safe-top)`. The static header overlay at
|
// `paddingTop: var(--vojo-safe-top)`. The static header overlay at
|
||||||
// the pager root simply paints OVER the same screen zone, so the
|
// the pager root simply paints OVER the same screen zone, so the
|
||||||
// underlying geometry stays identical to non-pager mode.
|
// underlying geometry stays identical to non-pager mode.
|
||||||
|
|
|
||||||
|
|
@ -182,20 +182,17 @@ export const handleBar = style({
|
||||||
|
|
||||||
// Wrapper around `bottomPinned` inside the curtain. Anchored to the
|
// Wrapper around `bottomPinned` inside the curtain. Anchored to the
|
||||||
// curtain's flex-bottom by virtue of being the last child. The TSX
|
// curtain's flex-bottom by virtue of being the last child. The TSX
|
||||||
// applies a `transform: translateY(keyboardH)` to this element when
|
// collapses this slot to `{ height: 0, overflow: hidden }` when the
|
||||||
// the on-screen keyboard rises (via `VisualViewport.height` shrink)
|
// on-screen keyboard rises (via `VisualViewport.height` shrink) so
|
||||||
// so the row stays at its ORIGINAL viewport-bottom position — under
|
// the row neither paints nor claims flex space above the keyboard.
|
||||||
// the keyboard, clipped by the curtain's `overflow: hidden`. Without
|
// Without this compensation, `interactive-widget=resizes-content`
|
||||||
// this compensation, `interactive-widget=resizes-content` (global
|
// (global viewport meta — load-bearing for the room composer)
|
||||||
// meta — load-bearing for the room composer) shrinks the layout
|
// shrinks the layout viewport, dragging every `bottom: 0` element
|
||||||
// viewport, dragging every `bottom: 0` element up over the inline
|
// up over the inline form. The DirectSelfRow ending up immediately
|
||||||
// form. The DirectSelfRow ending up immediately above the keyboard
|
// above the keyboard would block the user's view of the form they're
|
||||||
// blocks the user's view of the form they're typing into.
|
// typing into.
|
||||||
export const bottomPinnedSlot = style({
|
export const bottomPinnedSlot = style({
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
// Compositor hint — the transform is applied/cleared on every
|
|
||||||
// VisualViewport resize while a keyboard is open.
|
|
||||||
willChange: 'transform',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Segment button (Direct / Channels / Bots).
|
// Segment button (Direct / Channels / Bots).
|
||||||
|
|
|
||||||
|
|
@ -51,12 +51,13 @@ type StreamHeaderProps = {
|
||||||
bottomPinned?: ReactNode;
|
bottomPinned?: ReactNode;
|
||||||
// Stable identifier used to persist the curtain's pinned overlay
|
// Stable identifier used to persist the curtain's pinned overlay
|
||||||
// across listing-pane remounts (the user taps into a Room and back,
|
// across listing-pane remounts (the user taps into a Room and back,
|
||||||
// which unmounts the listing pane). When provided, pin state is
|
// which unmounts the listing pane). Pin state is stored in
|
||||||
// stored in `curtainPinnedByTabAtom[pinKey]`; without it, pin lives
|
// `curtainPinnedByTabAtom[pinKey]` so it outlives any individual
|
||||||
// in a local useState that resets on unmount. Listing surfaces
|
// StreamHeader instance. Each listing tab (Direct/Channels/Bots)
|
||||||
// wired into the mobile pager (Direct / Channels / Bots) all pass
|
// passes its own key; the Channels landing CTA and workspace
|
||||||
// a key; other consumers can omit it.
|
// listing share `"channels"` so pin survives the toggle between
|
||||||
pinKey?: string;
|
// empty state and a chosen workspace.
|
||||||
|
pinKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: StreamHeaderProps) {
|
export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: StreamHeaderProps) {
|
||||||
|
|
|
||||||
|
|
@ -106,10 +106,13 @@ export const PIN_TRAVEL_PX = TABS_ROW_PX;
|
||||||
// displacement is reached with a longer finger pull because the body
|
// displacement is reached with a longer finger pull because the body
|
||||||
// path is rubber-banded (×0.65).
|
// path is rubber-banded (×0.65).
|
||||||
//
|
//
|
||||||
// Unpin is the one exception that keeps a hard ±PIN_TRAVEL_PX clamp:
|
// Unpin's clamp is asymmetric — `pinned-free` lower-bounds the live
|
||||||
// the handle-only contract makes it a deliberate full-travel pull,
|
// delta at 0 (no destination above pinned) but leaves the upper
|
||||||
// so we don't want the finger overshooting past closed into peek
|
// direction unclamped so the same gesture can carry the curtain
|
||||||
// territory mid-gesture.
|
// through closed into peek territory in one motion. The handle-only
|
||||||
|
// contract on unpin means the body never resolves to `pinned-free`,
|
||||||
|
// so the no-upper-clamp tolerance only applies on the dedicated
|
||||||
|
// drag-handle.
|
||||||
export const PIN_COMMIT_THRESHOLD = 0.95;
|
export const PIN_COMMIT_THRESHOLD = 0.95;
|
||||||
|
|
||||||
// Drag-handle hit-zone at the top of the curtain. NATIVE-ONLY: the
|
// Drag-handle hit-zone at the top of the curtain. NATIVE-ONLY: the
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,11 @@ import {
|
||||||
RUBBER_BAND,
|
RUBBER_BAND,
|
||||||
} from './geometry';
|
} from './geometry';
|
||||||
import { CurtainSnap, isFormSnap } from './useCurtainState';
|
import { CurtainSnap, isFormSnap } from './useCurtainState';
|
||||||
import { CurtainTransition, resolveCurtainTransition } from './useCurtainHandleGesture';
|
import {
|
||||||
|
assertNeverCurtainTransition,
|
||||||
|
CurtainTransition,
|
||||||
|
resolveCurtainTransition,
|
||||||
|
} from './useCurtainHandleGesture';
|
||||||
|
|
||||||
type Args = {
|
type Args = {
|
||||||
// The curtain element. Touch listeners bind here so anywhere on the
|
// The curtain element. Touch listeners bind here so anywhere on the
|
||||||
|
|
@ -54,9 +58,10 @@ type Args = {
|
||||||
// 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). pin/unpin flips
|
// Snap commit (peek / close-peek / form-close). Narrowed to the two
|
||||||
|
// non-form destinations the hook ever reaches. pin/unpin flips
|
||||||
// `pinned` instead.
|
// `pinned` instead.
|
||||||
commit: (next: CurtainSnap) => 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`.
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
@ -261,13 +266,21 @@ export function useCurtainBodyGesture({
|
||||||
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;
|
||||||
// `pinned-free` is intentionally absent — the pinned-bail
|
case 'pinned-free':
|
||||||
// at touchstart prevents the body hook from ever resolving
|
// Unreachable on the body — the pinned bail at touchstart
|
||||||
// to it. If a future change exposes pinned-free on the
|
// prevents the hook from ever resolving this transition.
|
||||||
// body, add the dispatch alongside this default so the
|
// Kept here so the `never` default below stays exhaustive
|
||||||
// linter keeps the switch exhaustive.
|
// and a future opening of pinned-free on the body would
|
||||||
default:
|
// need to wire the dispatch explicitly.
|
||||||
break;
|
break;
|
||||||
|
case null:
|
||||||
|
// Unreachable: `engaged` is set only after `transition` is
|
||||||
|
// resolved non-null in the dead-zone block above.
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
assertNeverCurtainTransition(transition);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setLiveDrag(lastDelta, true);
|
setLiveDrag(lastDelta, true);
|
||||||
emitHandle(true, atCommit);
|
emitHandle(true, atCommit);
|
||||||
|
|
@ -308,9 +321,18 @@ export function useCurtainBodyGesture({
|
||||||
setLiveDrag(0, false);
|
setLiveDrag(0, false);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
case 'pinned-free':
|
||||||
|
case null:
|
||||||
|
// Both unreachable per the touchmove switch above; the
|
||||||
|
// setLiveDrag fallback preserves spring-back behaviour if a
|
||||||
|
// future change exposes either path here.
|
||||||
setLiveDrag(0, false);
|
setLiveDrag(0, false);
|
||||||
break;
|
break;
|
||||||
|
default: {
|
||||||
|
assertNeverCurtainTransition(transition);
|
||||||
|
setLiveDrag(0, false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
startX = null;
|
startX = null;
|
||||||
startY = null;
|
startY = null;
|
||||||
|
|
@ -341,6 +363,14 @@ export function useCurtainBodyGesture({
|
||||||
curtain.removeEventListener('touchmove', onTouchMove);
|
curtain.removeEventListener('touchmove', onTouchMove);
|
||||||
curtain.removeEventListener('touchend', onTouchEnd);
|
curtain.removeEventListener('touchend', onTouchEnd);
|
||||||
curtain.removeEventListener('touchcancel', onTouchCancel);
|
curtain.removeEventListener('touchcancel', onTouchCancel);
|
||||||
|
// Same teardown contract as the handle hook — see its cleanup for
|
||||||
|
// the rationale. If `disabled` flips true while a body drag is in
|
||||||
|
// flight, the touchend never reaches us and the curtain would stay
|
||||||
|
// frozen at the finger position until the next touch.
|
||||||
|
if (engaged) {
|
||||||
|
setLiveDrag(0, false);
|
||||||
|
emitHandle(false, false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// 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
|
// `setPinned` and `commit` are ref-mirrored. Only `disabled` needs
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,10 @@ type Args = {
|
||||||
// curtain's `top` re-render — no direct DOM writes.
|
// curtain's `top` re-render — no direct DOM writes.
|
||||||
setLiveDrag: (px: number, dragging: boolean) => void;
|
setLiveDrag: (px: number, dragging: boolean) => void;
|
||||||
// Snap commit. Called on release for peek / close-peek / form-close
|
// Snap commit. Called on release for peek / close-peek / form-close
|
||||||
// (the pin / unpin paths flip `pinned` instead). Also resets
|
// (the pin / unpin paths flip `pinned` instead). Narrowed to the
|
||||||
|
// two non-form destinations the hook ever reaches. Also resets
|
||||||
// liveDragPx + isDragging atomically inside the parent state.
|
// liveDragPx + isDragging atomically inside the parent state.
|
||||||
commit: (next: CurtainSnap) => void;
|
commit: (next: 'peek' | 'closed') => void;
|
||||||
// Suppress gesture binding entirely. Used to gate motion when a
|
// Suppress gesture binding entirely. Used to gate motion when a
|
||||||
// bottom sheet is open or when this pane is inactive inside the
|
// bottom sheet is open or when this pane is inactive inside the
|
||||||
// swipe pager.
|
// swipe pager.
|
||||||
|
|
@ -79,6 +80,14 @@ type Args = {
|
||||||
// touchstart while pinned so unpin remains a deliberate handle pull.
|
// touchstart while pinned so unpin remains a deliberate handle pull.
|
||||||
export type CurtainTransition = 'closed-free' | 'pinned-free' | 'close-peek' | 'form-close';
|
export type CurtainTransition = 'closed-free' | 'pinned-free' | 'close-peek' | 'form-close';
|
||||||
|
|
||||||
|
// Exhaustive-check helper. Used in the `default` branch of every
|
||||||
|
// switch over `CurtainTransition | null` so that adding a fifth
|
||||||
|
// variant to the union fails typecheck at every dispatch site
|
||||||
|
// rather than silently no-op'ing through default. The argument is
|
||||||
|
// prefixed with `_` so eslint's `argsIgnorePattern: '^_'` keeps the
|
||||||
|
// rule happy without us tagging it `// eslint-disable`.
|
||||||
|
export const assertNeverCurtainTransition = (_value: never): void => {};
|
||||||
|
|
||||||
// Decide which transition the gesture arms based on the snap state
|
// Decide which transition the gesture arms based on the snap state
|
||||||
// at direction-resolution time and the finger direction. `null` means
|
// at direction-resolution time and the finger direction. `null` means
|
||||||
// the (snap, pinned, direction) triple has no valid motion and the
|
// the (snap, pinned, direction) triple has no valid motion and the
|
||||||
|
|
@ -128,11 +137,12 @@ export function resolveCurtainTransition(
|
||||||
// hijacked under their finger. The body is also fully inert while
|
// hijacked under their finger. The body is also fully inert while
|
||||||
// pinned, so unpin (and unpin → peek overshoot) stays a deliberate
|
// pinned, so unpin (and unpin → peek overshoot) stays a deliberate
|
||||||
// handle pull.
|
// handle pull.
|
||||||
// History note: an earlier `useCurtainGesture` bound the peek /
|
//
|
||||||
// form-close paths to the list scroll viewport directly. That coupling
|
// Design rationale: gestures used to bind to the chat list's scroll
|
||||||
// produced repeating «drag-up at scrollTop=0 hijacks for pin» / «drag-
|
// viewport directly, which produced repeating «drag-at-scrollTop=0
|
||||||
// down at scrollTop=0 hijacks for peek» bugs and was removed when
|
// hijacks for pin/peek» bugs. Moving every transition onto a
|
||||||
// pin / unpin moved here.
|
// dedicated handle (plus an opt-in body surface that bails on
|
||||||
|
// scrollable lists) removes the scroll/gesture race entirely.
|
||||||
//
|
//
|
||||||
// Per-transition dynamics — all track the finger 1:1, but the clamp
|
// Per-transition dynamics — all track the finger 1:1, but the clamp
|
||||||
// shapes differ to keep on-screen motion sensible while preserving
|
// shapes differ to keep on-screen motion sensible while preserving
|
||||||
|
|
@ -325,10 +335,19 @@ export function useCurtainHandleGesture({
|
||||||
lastDelta = Math.min(0, delta);
|
lastDelta = Math.min(0, delta);
|
||||||
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
|
atCommit = -lastDelta >= ACTIVE_CLOSE_THRESHOLD_PX;
|
||||||
break;
|
break;
|
||||||
default:
|
case null:
|
||||||
// Unreachable — transition is non-null past the dead-zone
|
// Unreachable: `engaged` is set only after `transition` is
|
||||||
// resolution above and is never cleared mid-gesture.
|
// resolved non-null in the dead-zone block above; reaching
|
||||||
|
// this case would imply the gesture engaged without a
|
||||||
|
// transition, which the control flow above forbids.
|
||||||
break;
|
break;
|
||||||
|
default: {
|
||||||
|
// Exhaustive guard. The `never` cast turns a future addition
|
||||||
|
// to `CurtainTransition` into a compile error here — adding
|
||||||
|
// a fifth member without wiring its dispatch fails typecheck.
|
||||||
|
assertNeverCurtainTransition(transition);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setLiveDrag(lastDelta, true);
|
setLiveDrag(lastDelta, true);
|
||||||
emitHandle(true, atCommit);
|
emitHandle(true, atCommit);
|
||||||
|
|
@ -398,9 +417,19 @@ export function useCurtainHandleGesture({
|
||||||
setLiveDrag(0, false);
|
setLiveDrag(0, false);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
case null:
|
||||||
|
// Unreachable: `engaged` is set only after `transition` is
|
||||||
|
// resolved non-null. Mirrors the touchmove switch.
|
||||||
setLiveDrag(0, false);
|
setLiveDrag(0, false);
|
||||||
break;
|
break;
|
||||||
|
default: {
|
||||||
|
// Exhaustive guard — see the touchmove switch for the same
|
||||||
|
// pattern. setLiveDrag fallback preserves spring-back if a
|
||||||
|
// future transition lands here unhandled at runtime.
|
||||||
|
assertNeverCurtainTransition(transition);
|
||||||
|
setLiveDrag(0, false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
startX = null;
|
startX = null;
|
||||||
startY = null;
|
startY = null;
|
||||||
|
|
@ -432,6 +461,16 @@ export function useCurtainHandleGesture({
|
||||||
handle.removeEventListener('touchmove', onTouchMove);
|
handle.removeEventListener('touchmove', onTouchMove);
|
||||||
handle.removeEventListener('touchend', onTouchEnd);
|
handle.removeEventListener('touchend', onTouchEnd);
|
||||||
handle.removeEventListener('touchcancel', onTouchCancel);
|
handle.removeEventListener('touchcancel', onTouchCancel);
|
||||||
|
// If `disabled` flips true while a drag is in flight, the touchend
|
||||||
|
// we'd normally rely on for snap-back never reaches us (the listener
|
||||||
|
// is gone). Without an explicit reset the curtain stays frozen at
|
||||||
|
// the finger position with `transition: none` and the grabber pill
|
||||||
|
// stuck Primary-blue until the user starts a new touch — visible as
|
||||||
|
// a half-open curtain after, say, a sheet opens mid-drag.
|
||||||
|
if (engaged) {
|
||||||
|
setLiveDrag(0, false);
|
||||||
|
emitHandle(false, false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// setLiveDrag is a stable useCallback; handleRef is stable. `snap`,
|
// setLiveDrag is a stable useCallback; handleRef is stable. `snap`,
|
||||||
// `pinned`, `setPinned` and `commit` are mirrored via the refs
|
// `pinned`, `setPinned` and `commit` are mirrored via the refs
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,7 @@ export type CurtainState = {
|
||||||
// the consumer-supplied `pinKey` so the lock survives the route-
|
// the consumer-supplied `pinKey` so the lock survives the route-
|
||||||
// driven listing-pane unmount when the user taps into a Room and
|
// driven listing-pane unmount when the user taps into a Room and
|
||||||
// back. Each tab keeps its own pin (Direct/Channels/Bots are
|
// back. Each tab keeps its own pin (Direct/Channels/Bots are
|
||||||
// independent). If no `pinKey` is provided, the pin lives in a
|
// independent).
|
||||||
// local useState that resets on unmount — fine for non-listing
|
|
||||||
// surfaces where pinning isn't expected anyway.
|
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
// Setter for the pinned overlay. Called by the gesture hook on
|
// Setter for the pinned overlay. Called by the gesture hook on
|
||||||
// commit (drag-up-from-closed past threshold sets true; drag-down-
|
// commit (drag-up-from-closed past threshold sets true; drag-down-
|
||||||
|
|
@ -69,7 +67,10 @@ export type CurtainState = {
|
||||||
close: () => void;
|
close: () => void;
|
||||||
// Commit a snap stop directly. Used by the touch gesture on release.
|
// Commit a snap stop directly. Used by the touch gesture on release.
|
||||||
// Also resets `liveDragPx` and `isDragging` in one batched update.
|
// Also resets `liveDragPx` and `isDragging` in one batched update.
|
||||||
commit: (next: CurtainSnap) => void;
|
// Narrowed to the two non-form destinations the gesture hooks ever
|
||||||
|
// reach — peek-reveal and close. Form snaps are entered through
|
||||||
|
// `open()` which sets `activeForm` synchronously alongside the snap.
|
||||||
|
commit: (next: 'peek' | 'closed') => void;
|
||||||
// Setter for the live drag delta — called from the touch gesture on
|
// Setter for the live drag delta — called from the touch gesture on
|
||||||
// every touchmove. Updates are batched by React inside event handlers.
|
// every touchmove. Updates are batched by React inside event handlers.
|
||||||
setLiveDrag: (px: number, dragging: boolean) => void;
|
setLiveDrag: (px: number, dragging: boolean) => void;
|
||||||
|
|
@ -101,25 +102,23 @@ export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCurtainState(pinKey?: string): CurtainState {
|
export function useCurtainState(pinKey: string): CurtainState {
|
||||||
const [snap, setSnap] = useState<CurtainSnap>('closed');
|
const [snap, setSnap] = useState<CurtainSnap>('closed');
|
||||||
const [activeForm, setActiveForm] = useState<ActiveForm>(null);
|
const [activeForm, setActiveForm] = useState<ActiveForm>(null);
|
||||||
const [formHeightPx, setFormHeightPx] = useState<number | null>(null);
|
const [formHeightPx, setFormHeightPx] = useState<number | null>(null);
|
||||||
const [liveDragPx, setLiveDragPx] = useState(0);
|
const [liveDragPx, setLiveDragPx] = useState(0);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
// Pin storage split: atom-backed when `pinKey` is supplied (survives
|
// Per-tab pin lives in `curtainPinnedByTabAtom` so the lock survives
|
||||||
// listing-pane remount on Room navigate-back), local useState
|
// the route-driven listing-pane unmount that happens when the user
|
||||||
// fallback when no key is supplied (web/non-listing mounts where
|
// taps into a Room and back. The atom outlives any individual
|
||||||
// pinning isn't expected).
|
// StreamHeader instance.
|
||||||
const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom);
|
const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom);
|
||||||
const [pinnedLocal, setPinnedLocal] = useState(false);
|
const pinned = !!pinnedMap[pinKey];
|
||||||
const pinned = pinKey ? !!pinnedMap[pinKey] : pinnedLocal;
|
|
||||||
|
|
||||||
const formMeasureRef = useRef<HTMLDivElement>(null);
|
const formMeasureRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const setPinned = useCallback(
|
const setPinned = useCallback(
|
||||||
(next: boolean) => {
|
(next: boolean) => {
|
||||||
if (pinKey) {
|
|
||||||
setPinnedMap((prev) => {
|
setPinnedMap((prev) => {
|
||||||
// Compare-and-skip so we don't allocate a fresh object (and
|
// Compare-and-skip so we don't allocate a fresh object (and
|
||||||
// re-render every other subscriber of the atom) when nothing
|
// re-render every other subscriber of the atom) when nothing
|
||||||
|
|
@ -127,9 +126,6 @@ export function useCurtainState(pinKey?: string): CurtainState {
|
||||||
if (!!prev[pinKey] === next) return prev;
|
if (!!prev[pinKey] === next) return prev;
|
||||||
return { ...prev, [pinKey]: next };
|
return { ...prev, [pinKey]: next };
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
setPinnedLocal(next);
|
|
||||||
}
|
|
||||||
// Drop any in-flight live drag on commit so the curtain renders
|
// Drop any in-flight live drag on commit so the curtain renders
|
||||||
// at the new pinned-derived top without a residual finger offset.
|
// at the new pinned-derived top without a residual finger offset.
|
||||||
setLiveDragPx(0);
|
setLiveDragPx(0);
|
||||||
|
|
@ -145,12 +141,12 @@ export function useCurtainState(pinKey?: string): CurtainState {
|
||||||
setLiveDragPx(0);
|
setLiveDragPx(0);
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
// Safety net: clear pin so the form is visible. In practice the
|
// Safety net: clear pin so the form is visible. In practice the
|
||||||
// static pager header's icons (the only call site of open()) are
|
// visible openers (static pager header icons, in-pane chips on
|
||||||
// covered by the curtain when pinned, so the user can't trigger
|
// non-pager surfaces) are all covered by the curtain when pinned,
|
||||||
// this directly — but a future programmatic open() or a per-pane
|
// so the user can't trigger this directly — but a future
|
||||||
// tabsRow that escapes the pager-mode visibility:hidden gate
|
// programmatic open() would otherwise mount the form behind the
|
||||||
// would otherwise mount the form behind the still-pinned curtain
|
// still-pinned curtain at curtainTop=0 and present an invisible
|
||||||
// at curtainTop=0 and the user would see an invisible form.
|
// form.
|
||||||
setPinned(false);
|
setPinned(false);
|
||||||
},
|
},
|
||||||
[setPinned]
|
[setPinned]
|
||||||
|
|
@ -162,17 +158,14 @@ export function useCurtainState(pinKey?: string): CurtainState {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const commit = useCallback((next: CurtainSnap) => {
|
const commit = useCallback((next: 'peek' | 'closed') => {
|
||||||
setSnap(next);
|
setSnap(next);
|
||||||
setLiveDragPx(0);
|
setLiveDragPx(0);
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
if (isFormSnap(next)) {
|
// `activeForm` is intentionally NOT cleared here — it stays set
|
||||||
setActiveForm(next === 'form-search' ? 'search' : 'chat');
|
// so the closing transition has form content beneath the curtain
|
||||||
}
|
// as it slides up. `acknowledgeClosed` clears it once the snap
|
||||||
// Note: when committing to a non-form snap (peek*/closed) we do
|
// settles at `closed`.
|
||||||
// NOT clear `activeForm` here — it stays set so the closing
|
|
||||||
// transition has form content beneath. `acknowledgeClosed` clears
|
|
||||||
// it once the curtain settles at `closed`.
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setLiveDrag = useCallback((px: number, dragging: boolean) => {
|
const setLiveDrag = useCallback((px: number, dragging: boolean) => {
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,12 @@ function BotRow({ preset }: { preset: BotPreset }) {
|
||||||
|
|
||||||
export function Bots() {
|
export function Bots() {
|
||||||
const bots = useBotPresets();
|
const bots = useBotPresets();
|
||||||
// `scrollRef` is passed to the header so the touch gesture (native
|
// `scrollRef` is forwarded so the curtain body gesture can check
|
||||||
// only) can recognise list scrollTop=0 and engage the curtain peek.
|
// whether the list is scrollable and bail to native scroll on long
|
||||||
// Icons + click flows work on every platform regardless.
|
// lists. Short / empty lists let the curtain body itself drive the
|
||||||
|
// gesture. The dedicated 32 px drag-handle on the curtain works
|
||||||
|
// regardless of this ref. Native-only — desktop / Electron have
|
||||||
|
// no curtain gestures.
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
// Skip PageNav surface in pager mode — see Direct.tsx for the
|
// Skip PageNav surface in pager mode — see Direct.tsx for the
|
||||||
// rationale; the static header behind the strip owns the visible
|
// rationale; the static header behind the strip owns the visible
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue