From eb29c28ff0f87b320cbb90c65acf18d270391218 Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Mon, 18 May 2026 22:00:53 +0300 Subject: [PATCH] feat(mobile-tabs-pager): swipe between Direct, Channels and Bots on Capacitor native with static header, 24px gap, atom-bridged action icons and inert offscreen panes --- .../MobilePagerPaneContext.tsx | 25 ++ .../mobile-tabs-pager/MobileTabsLayout.tsx | 48 +++ .../mobile-tabs-pager/MobileTabsPager.tsx | 389 ++++++++++++++++++ .../MobileTabsPagerHeader.tsx | 146 +++++++ .../components/mobile-tabs-pager/geometry.ts | 40 ++ src/app/components/mobile-tabs-pager/index.ts | 4 + .../components/mobile-tabs-pager/style.css.ts | 87 ++++ .../useMobileTabsPagerGesture.ts | 220 ++++++++++ .../components/stream-header/StreamHeader.tsx | 64 ++- .../stream-header/useCurtainGesture.ts | 23 ++ .../settings/MobileSettingsHorseshoe.tsx | 111 +++-- src/app/hooks/useNavToActivePathMapper.ts | 10 +- src/app/pages/Router.tsx | 183 ++++---- .../pages/client/channels/ChannelsLanding.tsx | 21 +- .../channels/ChannelsWorkspaceHorseshoe.tsx | 82 +++- src/app/pages/client/direct/Direct.tsx | 6 +- src/app/state/mobilePagerHeader.ts | 22 + 17 files changed, 1342 insertions(+), 139 deletions(-) create mode 100644 src/app/components/mobile-tabs-pager/MobilePagerPaneContext.tsx create mode 100644 src/app/components/mobile-tabs-pager/MobileTabsLayout.tsx create mode 100644 src/app/components/mobile-tabs-pager/MobileTabsPager.tsx create mode 100644 src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx create mode 100644 src/app/components/mobile-tabs-pager/geometry.ts create mode 100644 src/app/components/mobile-tabs-pager/index.ts create mode 100644 src/app/components/mobile-tabs-pager/style.css.ts create mode 100644 src/app/components/mobile-tabs-pager/useMobileTabsPagerGesture.ts create mode 100644 src/app/state/mobilePagerHeader.ts diff --git a/src/app/components/mobile-tabs-pager/MobilePagerPaneContext.tsx b/src/app/components/mobile-tabs-pager/MobilePagerPaneContext.tsx new file mode 100644 index 00000000..29f774d0 --- /dev/null +++ b/src/app/components/mobile-tabs-pager/MobilePagerPaneContext.tsx @@ -0,0 +1,25 @@ +import { createContext, useContext } from 'react'; + +// Set by MobileTabsPager around each of its three listing panes. Lets +// the StreamHeader inside the pane discover (a) that it's mounted in +// pager mode at all, and (b) whether it's the currently active pane. +// +// "Mounted in pager mode" controls whether the per-pane tabs row +// renders visibly — when we're in pager mode the tabs row is hidden +// via visibility:hidden (the shared static header at pager root paints +// the visible tabs + icons), but the row still occupies its +// TABS_ROW_PX height so the curtain's snap geometry is unchanged. +// +// "isActive" controls which pane's curtain is wired to the shared +// static header's action icons via `mobilePagerCurtainAtom`. +export type MobilePagerPaneInfo = { + isActive: boolean; +}; + +const MobilePagerPaneContext = createContext(null); + +export const MobilePagerPaneProvider = MobilePagerPaneContext.Provider; + +export function useMobilePagerPane(): MobilePagerPaneInfo | null { + return useContext(MobilePagerPaneContext); +} diff --git a/src/app/components/mobile-tabs-pager/MobileTabsLayout.tsx b/src/app/components/mobile-tabs-pager/MobileTabsLayout.tsx new file mode 100644 index 00000000..5f1cfcb5 --- /dev/null +++ b/src/app/components/mobile-tabs-pager/MobileTabsLayout.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Outlet, useMatch } from 'react-router-dom'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { isNativePlatform } from '../../utils/capacitor'; +import { BOTS_PATH, CHANNELS_PATH, CHANNELS_SPACE_PATH, DIRECT_PATH } from '../../pages/paths'; +import { MobileTabsPager } from './MobileTabsPager'; + +// Router-level wrapper around the three listing tabs (/direct/, +// /channels/, /bots/). When all of (mobile breakpoint, Capacitor +// native runtime, listing-root URL) hold, we hijack rendering and +// mount `MobileTabsPager` directly — the wrapped routes' Outlet is +// never read, so their `element` chains stay unmounted. Anywhere else +// — non-mobile breakpoints (tablet, desktop), non-Capacitor runtimes +// (mobile web, Electron desktop), AND detail URLs nested under any +// listing root (/direct/!roomId, /channels/!space/!roomId, +// /bots/:botId) — we pass through to `` and the existing +// route tree renders unchanged. +// +// Channels has TWO listing-root URLs that both activate the pager on +// the Channels tab: +// +// * `/channels/` — landing (empty-state CTA when the user has no +// orphan spaces; otherwise pager-internal render of the active +// workspace via the persisted active-space resolver). +// * `/channels/!space/` — workspace listing for that specific space. +// This is what `commitTo('channels')` actually navigates to when +// an active space is known, so the user lands directly on the +// workspace view without bouncing through `/channels/` and the +// `ChannelsLanding` redirect (which previously cut the +// pager animation short and made the swipe feel jerky). +// +// Detail URLs nested under either (`/channels/!space/!roomId`, etc.) +// flip both matches to false and fall through to — Room +// renders full-screen on mobile as before. +export function MobileTabsLayout() { + const mobile = useScreenSizeContext() === ScreenSize.Mobile; + const native = isNativePlatform(); + const directRoot = !!useMatch({ path: DIRECT_PATH, end: true }); + const channelsRoot = !!useMatch({ path: CHANNELS_PATH, end: true }); + const channelsSpaceRoot = !!useMatch({ path: CHANNELS_SPACE_PATH, end: true }); + const botsRoot = !!useMatch({ path: BOTS_PATH, end: true }); + const onListingRoot = directRoot || channelsRoot || channelsSpaceRoot || botsRoot; + + if (!(mobile && native) || !onListingRoot) { + return ; + } + return ; +} diff --git a/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx b/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx new file mode 100644 index 00000000..dc4304e2 --- /dev/null +++ b/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx @@ -0,0 +1,389 @@ +import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Outlet, useMatch, useNavigate } from 'react-router-dom'; +import { useAtomValue } from 'jotai'; +import { BOTS_PATH, CHANNELS_PATH, CHANNELS_SPACE_PATH, DIRECT_PATH } from '../../pages/paths'; +import { useBotPresets } from '../../features/bots/catalog'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { allRoomsAtom } from '../../state/room-list/roomList'; +import { roomToParentsAtom } from '../../state/room/roomToParents'; +import { useOrphanSpaces } from '../../state/hooks/roomList'; +import { + getCanonicalAliasOrRoomId, + getCanonicalAliasRoomId, + isRoomAlias, +} from '../../utils/matrix'; +import { getChannelsSpacePath } from '../../pages/pathUtils'; +import { SpaceProvider } from '../../hooks/useSpace'; +import { Direct } from '../../pages/client/direct'; +import { Channels, ChannelsRootNav, useActiveSpace } from '../../pages/client/channels'; +import { Bots } from '../../pages/client/bots'; +import { ChannelsModeProvider } from '../../hooks/useChannelsMode'; +import { settingsSheetAtom } from '../../state/settingsSheet'; +import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet'; +import { MobilePagerPaneProvider } from './MobilePagerPaneContext'; +import { MobileTabsPagerHeader } from './MobileTabsPagerHeader'; +import { useMobileTabsPagerGesture } from './useMobileTabsPagerGesture'; +import { PAGER_EASING, PAGER_TRANSITION_MS, PANE_GAP_PX } from './geometry'; +import * as css from './style.css'; + +type Tab = 'direct' | 'channels' | 'bots'; + +// URL-safe wrapper around decodeURIComponent — matches the same helper +// inside `useActiveSpace`. Used here to validate the URL `:spaceIdOrAlias` +// param before we decide whether to mount the pager or defer to the +// existing route tree's JoinBeforeNavigate fallback. +const safeDecode = (raw: string): string | undefined => { + try { + return decodeURIComponent(raw); + } catch { + return undefined; + } +}; + +type PaneSlotProps = { + isActive: boolean; + children: ReactNode; +}; + +// Wraps a pane's DOM box and toggles the `inert` HTMLElement property +// based on the active flag, plus mirrors it into `aria-hidden`. +// +// `inert` removes the off-screen pane subtree from focus order, click +// handling, and the accessibility tree — important so assistive tech +// (and stray keyboard focus on devices with hardware keyboards) can't +// reach controls that are visually translateX'd out of the viewport. +// `aria-hidden` is the long-supported half of the same intent and +// alone covers most screen readers. Both are applied for full +// portability across AT/browser combinations. +// +// `inert` is assigned via ref (not as a JSX attr) because React 18.2's +// HTMLAttributes typing doesn't include it. The underlying DOM +// property is supported by Chromium 102+ / Safari 15.5+ which covers +// Capacitor's WebView baseline. +function PaneSlot({ isActive, children }: PaneSlotProps) { + const ref = useRef(null); + useEffect(() => { + if (ref.current) ref.current.inert = !isActive; + }, [isActive]); + return ( +
+ {children} +
+ ); +} + +// Mobile + Capacitor horizontal swipe pager. Mounts all three listing +// surfaces once, slides between them via CSS transform. +// +// Visual layout decomposes into a STATIC overlay header at the top +// (segments + action icons, painted by `MobileTabsPagerHeader`) and a +// translating strip below it. Each pane's StreamHeader still renders +// its own tabs row but `visibility: hidden` in pager mode — kept in +// DOM only so the curtain's TABS_ROW_PX-based snap geometry is +// preserved. Action icons in the static header proxy through +// `mobilePagerCurtainAtom`, written by whichever pane is active. +// +// Channels tab specifics: the pane content depends on whether the +// user has at least one joined orphan space. If yes, we render +// `` (workspace listing) keyed to the active space; if no, +// `` paints the empty-state CTA. `commitTo('channels')` +// navigates to /channels/!{spaceId}/ when an active space is known so +// the swipe never bounces through /channels/ + the ChannelsLanding +// `` redirect (which previously cut the slide animation +// short and made the gesture feel jerky). +// +// Inter-pane gap: `PANE_GAP_PX` is inserted between adjacent panes +// via inline `gap` on the strip. The pagerRoot's SurfaceVariant +// backdrop shows through the gap during a swipe, matching the user +// request for a light-blue divider colour identical to the header. +// +// Invalid space URL fall-through: if the URL is `/channels/:alias/` +// but `:alias` doesn't resolve to a joined orphan space (deep-link +// to a workspace the user isn't in, or a typo), the pager bails out +// and renders `` instead. That delegates to the existing +// `/channels/!space/` route element whose `RouteSpaceProvider` shows +// `JoinBeforeNavigate`. Without this guard, `useActiveSpace` would +// silently fall back to the persisted-or-first-orphan space and the +// pager would show a DIFFERENT workspace than the URL claims — +// confusing the user and breaking deep-link semantics. +export function MobileTabsPager() { + const mx = useMatrixClient(); + const navigate = useNavigate(); + const bots = useBotPresets(); + + // `end: true` matches the listing-root URL exactly. Detail URLs + // (/channels/!space/!room/, /direct/!room, /bots/:botId) flip these + // to false — and MobileTabsLayout above us would have rendered + // Outlet instead of the pager in that case, so we never see those + // states. Channels is matched via EITHER /channels/ (landing) OR + // /channels/!space/ (workspace listing) — both keep us on the + // channels tab. + const channelsRoot = !!useMatch({ path: CHANNELS_PATH, end: true }); + const channelsSpaceMatch = useMatch({ path: CHANNELS_SPACE_PATH, end: true }); + const channelsSpaceRoot = !!channelsSpaceMatch; + const channelsActive = channelsRoot || channelsSpaceRoot; + const botsRoot = !!useMatch({ path: BOTS_PATH, end: true }); + + // Active space resolution mirrors ChannelsLanding: URL > localStorage + // > first joined orphan. `useOrphanSpaces` filters `allRoomsAtom` + // through `isSpace(mx.getRoom) && !roomToParents.has(...)`, so every + // entry it returns is BOTH (a) a Space the user has joined and (b) + // an orphan (no parent Space). `useActiveSpace` then constrains its + // result to that orphan set. Net invariant: if `activeSpaceId` is + // defined, `mx.getRoom(activeSpaceId)` is a Space the user is + // currently a member of — which is exactly the precondition + // `RouteSpaceProvider` would otherwise enforce via + // `joinedSpaces.includes(space.roomId)` before mounting Channels. + // That's why the pager can mount Channels with a plain + // `` and skip RouteSpaceProvider's + // JoinBeforeNavigate fallback path safely — for VALID URL spaces. + // Invalid URL spaces hit the early-return below. + const roomToParents = useAtomValue(roomToParentsAtom); + const orphanSpaceIds = useOrphanSpaces(mx, allRoomsAtom, roomToParents); + const activeSpaceId = useActiveSpace(orphanSpaceIds); + const activeSpace = activeSpaceId ? mx.getRoom(activeSpaceId) : null; + + // Validate the URL `:spaceIdOrAlias` param when on the workspace + // route. If the param exists but doesn't resolve to a joined orphan + // (deep-link to an unjoined / unknown space), we'll defer to the + // original route tree below instead of silently substituting another + // workspace. + const urlSpaceParam = channelsSpaceMatch?.params.spaceIdOrAlias; + const urlSpaceIsValid = useMemo(() => { + if (!urlSpaceParam) return true; + const decoded = safeDecode(urlSpaceParam); + if (!decoded) return false; + const resolved = isRoomAlias(decoded) ? getCanonicalAliasRoomId(mx, decoded) : decoded; + return resolved !== undefined && orphanSpaceIds.includes(resolved); + }, [mx, urlSpaceParam, orphanSpaceIds]); + + const showBots = bots.length > 0 || botsRoot; + + const tabs = useMemo(() => { + const list: Tab[] = ['direct', 'channels']; + if (showBots) list.push('bots'); + return list; + }, [showBots]); + + const urlActiveIdx = useMemo(() => { + if (botsRoot) { + const i = tabs.indexOf('bots'); + return i >= 0 ? i : 0; + } + if (channelsActive) { + const i = tabs.indexOf('channels'); + return i >= 0 ? i : 0; + } + const i = tabs.indexOf('direct'); + return i >= 0 ? i : 0; + }, [tabs, channelsActive, botsRoot]); + + const [dragPx, setDragPxState] = useState(0); + const [dragging, setDraggingState] = useState(false); + // Stored as a Tab NAME, not an index. The `tabs` array's length and + // composition can change at runtime (showBots flipping when the user + // navigates onto/off /bots/, or when a bot-config refresh adds a + // bot) — a stored index could end up pointing at the wrong tab or + // off the end of the array. A stable name lets us re-derive the + // index on every render via `tabs.indexOf(...)`. + // + // Load-bearing: react-router-dom v6's `useNavigate` does NOT auto- + // wrap in `React.startTransition` and its router-state update + // (`useSyncExternalStore`-backed) is asynchronous relative to React + // 18's auto-batching of setState in the same event handler. Without + // this pending lock, the commit path `setDragPx(0); setDragging + // (false); navigate(...)` can land in two renders — first with + // dragPx=0 at the OLD urlActiveIdx (strip snaps back to source tab), + // then with the NEW urlActiveIdx (strip animates to target). That + // two-stage flicker is exactly the "jerk on release" the user + // reported. See + // https://github.com/remix-run/react-router/issues/11003 for the + // upstream non-batching discussion. Do NOT remove without measuring. + // Cleared once the URL catches up (or after a safety timeout — see + // the effect below). + const [pendingTargetTab, setPendingTargetTab] = useState(null); + + const setDragPx = useCallback((px: number, drag: boolean) => { + setDragPxState(px); + setDraggingState(drag); + }, []); + + const destinationFor = useCallback( + (tab: Tab): string => { + if (tab === 'direct') return DIRECT_PATH; + if (tab === 'bots') return BOTS_PATH; + if (activeSpaceId) { + const alias = getCanonicalAliasOrRoomId(mx, activeSpaceId); + return getChannelsSpacePath(alias); + } + return CHANNELS_PATH; + }, + [mx, activeSpaceId] + ); + + const commitTo = useCallback( + (idx: number) => { + const target = tabs[idx]; + if (!target) return; + setDragPxState(0); + setDraggingState(false); + setPendingTargetTab(target); + navigate(destinationFor(target), { replace: true }); + }, + [tabs, navigate, destinationFor] + ); + + const onSelectDirect = useCallback(() => { + const i = tabs.indexOf('direct'); + if (i >= 0) commitTo(i); + }, [tabs, commitTo]); + const onSelectChannels = useCallback(() => { + const i = tabs.indexOf('channels'); + if (i >= 0) commitTo(i); + }, [tabs, commitTo]); + const onSelectBots = useCallback(() => { + const i = tabs.indexOf('bots'); + if (i >= 0) commitTo(i); + }, [tabs, commitTo]); + + const pendingTargetIdx = pendingTargetTab !== null ? tabs.indexOf(pendingTargetTab) : -1; + + useEffect(() => { + if (pendingTargetTab === null) return undefined; + // Tab disappeared from the array mid-animation (e.g. /bots/ deep- + // link held the Bots tab visible, the user committed to Direct, + // and during the slide the bot config became empty so showBots + // flipped to false). The stored target no longer maps to any + // index — clear immediately so visualIdx falls back to urlActive. + if (pendingTargetIdx === -1) { + setPendingTargetTab(null); + return undefined; + } + if (pendingTargetIdx === urlActiveIdx) { + setPendingTargetTab(null); + return undefined; + } + const id = window.setTimeout(() => setPendingTargetTab(null), PAGER_TRANSITION_MS + 100); + return () => window.clearTimeout(id); + }, [pendingTargetTab, pendingTargetIdx, urlActiveIdx]); + + const visualIdx = pendingTargetIdx >= 0 ? pendingTargetIdx : urlActiveIdx; + const visualDragPx = pendingTargetTab !== null ? 0 : dragPx; + + // Suppress the pager gesture while ANY of: + // 1. A horseshoe sheet is open (Settings or workspace switcher). + // A horizontal swipe on the sheet body, or on the still- + // visible listing above the sheet, would steer the pager into + // a sibling tab and unmount the sheet's host. + // 2. A commit-slide animation is in flight (pendingTargetTab set). + // Starting a new gesture during the 280ms transition would + // either jump (because visualDragPx is forced to 0) or commit + // relative to a stale urlActiveIdx — same UX hazard React + // Navigation's TabView avoids by locking gestures during + // transitions. + const settingsSheetOpen = !!useAtomValue(settingsSheetAtom); + const workspaceSheetOpen = !!useAtomValue(channelsWorkspaceSheetAtom); + const gestureDisabled = settingsSheetOpen || workspaceSheetOpen || pendingTargetTab !== null; + + const rootRef = useRef(null); + useMobileTabsPagerGesture({ + rootRef, + activeIdx: urlActiveIdx, + tabsCount: tabs.length, + disabled: gestureDisabled, + setDragPx, + commitTo, + }); + + // Gap-aware strip transform. Each adjacent pane is offset by an + // extra `PANE_GAP_PX` so a swipe past the gap zone exposes the + // pagerRoot backdrop colour, matching the static header tone. + // Memoised so the inline object identity is stable when dragPx + // doesn't change — avoids extra child re-renders when other state + // updates (e.g. atom subscription) tick the parent. + const stripStyle = useMemo( + () => ({ + width: `calc(${tabs.length * 100}vw + ${(tabs.length - 1) * PANE_GAP_PX}px)`, + transform: `translate3d(calc(${-visualIdx * 100}vw - ${ + visualIdx * PANE_GAP_PX + }px + ${visualDragPx}px), 0, 0)`, + transition: dragging ? 'none' : `transform ${PAGER_TRANSITION_MS}ms ${PAGER_EASING}`, + gap: `${PANE_GAP_PX}px`, + }), + [tabs.length, visualIdx, visualDragPx, dragging] + ); + + // Per-pane context values memoised separately so each pane's + // `useMobilePagerPane()` consumer (the inner StreamHeader) only + // re-runs when ITS isActive flag toggles, not every time the parent + // re-renders (e.g. on every touchmove during a drag). Without this, + // a fresh `{ isActive: bool }` object per render would tick every + // pane's context subscription at 60Hz during a swipe. + const directIdx = useMemo(() => tabs.indexOf('direct'), [tabs]); + const channelsIdx = useMemo(() => tabs.indexOf('channels'), [tabs]); + const botsIdx = useMemo(() => tabs.indexOf('bots'), [tabs]); + const directPaneInfo = useMemo( + () => ({ isActive: urlActiveIdx === directIdx }), + [urlActiveIdx, directIdx] + ); + const channelsPaneInfo = useMemo( + () => ({ isActive: urlActiveIdx === channelsIdx }), + [urlActiveIdx, channelsIdx] + ); + const botsPaneInfo = useMemo( + () => ({ isActive: urlActiveIdx === botsIdx }), + [urlActiveIdx, botsIdx] + ); + + // The static header doesn't need useMatch of its own — `urlActiveIdx` + // is already the authoritative source of truth for which tab is + // active. Map it back to a Tab name and pass down. + const activeTab: Tab = tabs[urlActiveIdx] ?? 'direct'; + + // Invalid URL space — defer to the existing route tree which + // handles unjoined / unknown spaces via JoinBeforeNavigate. All + // hooks above must run unconditionally for rules-of-hooks + // compliance; this early-return is the first conditional render. + if (channelsSpaceRoot && !urlSpaceIsValid) { + return ; + } + + return ( +
+ +
+ + + + + + + + + {activeSpace ? ( + + + + ) : ( + + )} + + + + {showBots && ( + + + + + + )} +
+
+ ); +} diff --git a/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx b/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx new file mode 100644 index 00000000..e140150a --- /dev/null +++ b/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx @@ -0,0 +1,146 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAtomValue } from 'jotai'; +import { Box, Icon, IconButton, Icons } from 'folds'; +import { mobilePagerCurtainAtom } from '../../state/mobilePagerHeader'; +import { Segment } from '../stream-header/Segment'; +import * as streamHeaderCss from '../stream-header/StreamHeader.css'; +import * as css from './style.css'; + +type Tab = 'direct' | 'channels' | 'bots'; + +// Must match the `INLINE_FORM_ID` local constant in +// `StreamHeader.tsx`. The shared static header's action icons are +// `aria-controls`-linked to the form region that the active pane's +// StreamHeader renders inside its curtain — mirroring the original +// in-pane buttons' ARIA semantics so assistive tech still announces +// the relationship correctly. Keep the two literals in lockstep. +const INLINE_FORM_ID = 'stream-header-inline-form'; + +type MobileTabsPagerHeaderProps = { + showBots: boolean; + // Active tab name resolved by the parent pager from the URL. We + // accept it as a prop rather than re-running `useMatch` here — the + // pager already knows the answer and passing it down keeps the + // segment highlight in lock-step with the strip's visual position + // (one source of truth = `urlActiveIdx`). + activeTab: Tab; + onSelectDirect: () => void; + onSelectChannels: () => void; + onSelectBots: () => void; +}; + +// Static shared tabs row painted at the top of MobileTabsPager. Lives +// outside the swipe strip so it doesn't translate with the panes — +// addresses the user-visible regression where each pane's identical +// tabs row sliding underneath felt like "the header is moving" even +// though the segments matched at every pixel. +// +// Reuses `stream-header/StreamHeader.css.ts` classes (`tabsRow`, +// `tabsCluster`, `iconsCluster`) so the layout, padding, and segment +// styling stay identical to the per-pane tabs row that sits hidden +// underneath. The only structural difference is the surrounding +// `pagerStaticHeader` wrapper which positions this row absolute at +// the top of the pager and reserves the status-bar safe-area inset. +// +// Segment clicks call the pager's commit callbacks (so the swipe +// animation uses the same pendingTargetIdx path as a finger swipe). +// Action icons read `mobilePagerCurtainAtom` — the active pane's +// StreamHeader writes its curtain controls there, so Plus/Search/X +// drive whichever curtain is currently visible. +// +// ARIA: action icons mirror the original in-pane buttons' +// `aria-controls` / `aria-expanded` / `aria-haspopup` relationship to +// the form region (`#stream-header-inline-form`). When `iconsDisabled` +// (atom not yet populated on initial mount) the buttons report +// `aria-disabled` so assistive tech announces the unavailable state +// instead of silently failing on activation. +export function MobileTabsPagerHeader({ + showBots, + activeTab, + onSelectDirect, + onSelectChannels, + onSelectBots, +}: MobileTabsPagerHeaderProps) { + const { t } = useTranslation(); + + const curtainControls = useAtomValue(mobilePagerCurtainAtom); + const isFormActive = curtainControls?.isFormActive ?? false; + const openChat = useCallback(() => curtainControls?.openChat(), [curtainControls]); + const openSearch = useCallback(() => curtainControls?.openSearch(), [curtainControls]); + const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]); + const iconsDisabled = curtainControls === null; + + return ( +
+
+
+ + + {showBots && ( + + )} +
+ + {isFormActive ? ( + + + + ) : ( +
+ + + + + + +
+ )} +
+
+ ); +} diff --git a/src/app/components/mobile-tabs-pager/geometry.ts b/src/app/components/mobile-tabs-pager/geometry.ts new file mode 100644 index 00000000..ad7b4b36 --- /dev/null +++ b/src/app/components/mobile-tabs-pager/geometry.ts @@ -0,0 +1,40 @@ +// Mobile horizontal swipe pager — tuning constants. +// +// The pager is only active on Capacitor + mobile + listing-root URLs +// (/direct/, /channels/, /bots/). Everywhere else MobileTabsLayout +// passes through to and these values are inert. + +// Direction-resolve dead-zone (px). The finger must travel at least +// this far on either axis before we resolve the gesture as horizontal +// (engage the pager) or vertical (bail, let curtain / horseshoes / +// scroll take over). +export const DEAD_ZONE_PX = 12; + +// Edge-guard band (px). Touchstart inside this strip from the L or R +// viewport edge is ignored — that zone belongs to the Android system +// back-gesture in edge-to-edge mode, and reacting to it would steal +// the back-swipe. +export const EDGE_GUARD_PX = 24; + +// Rubber-band attenuation factor applied when the user pulls past the +// leftmost or rightmost tab boundary. Soft pull, never commits. +export const RUBBER_BAND_FACTOR = 0.35; + +// Commit threshold = max(MIN_COMMIT_PX, viewport_width × COMMIT_FRACTION). +// Tuned wide on purpose (≈40% of viewport, floor 150px) so accidental +// horizontal jitter on long DM rows doesn't flip tabs. +export const MIN_COMMIT_PX = 150; +export const COMMIT_FRACTION = 0.4; + +// Snap-back / commit-slide animation. Same curve & duration as the +// curtain commit so the two motions feel consistent. +export const PAGER_TRANSITION_MS = 280; +export const PAGER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; + +// Visible gap between adjacent panes inside the strip. Surfaces the +// `SurfaceVariant.Container` (pagerRoot's backdrop) during a swipe, +// matching the design intent of a light-blue divider between screens. +// Roughly 2× the standard horseshoe seam (`VOJO_HORSESHOE_GAP_PX=12`) +// — the inter-pane gap reads as a transitional void rather than a +// horseshoe surface boundary, so it's tuned wider to feel breathing. +export const PANE_GAP_PX = 24; diff --git a/src/app/components/mobile-tabs-pager/index.ts b/src/app/components/mobile-tabs-pager/index.ts new file mode 100644 index 00000000..c10d0a08 --- /dev/null +++ b/src/app/components/mobile-tabs-pager/index.ts @@ -0,0 +1,4 @@ +// `MobileTabsPager` is intentionally NOT exported — it's mounted only +// from `MobileTabsLayout` based on the routed activation conditions, +// never directly by route or app code. +export { MobileTabsLayout } from './MobileTabsLayout'; diff --git a/src/app/components/mobile-tabs-pager/style.css.ts b/src/app/components/mobile-tabs-pager/style.css.ts new file mode 100644 index 00000000..2a55e46d --- /dev/null +++ b/src/app/components/mobile-tabs-pager/style.css.ts @@ -0,0 +1,87 @@ +import { style } from '@vanilla-extract/css'; +import { color } from 'folds'; + +// Pager root. Sits inside the authed shell's row-flex slot +// (ClientLayout → Box grow=Yes), so `flex: 1 1 0` fills the slot +// horizontally; `align-items: stretch` on the parent fills vertically. +// +// `touch-action: pan-y` lets the browser keep doing native vertical +// scroll (DM list virtualizer, curtain peek pull-down) without us +// having to call preventDefault on every move — only the pager's own +// listener calls preventDefault, and only after axis-resolve commits +// to "horizontal". +// +// `SurfaceVariant.Container` backdrop intentionally shows through +// (a) the inter-pane gap during a swipe — the gap colour the user +// asked for is "light blue same as the header", which IS this +// SurfaceVariant tone — and (b) any sub-pixel rounding seam at rest. +export const pagerRoot = style({ + position: 'relative', + flex: '1 1 0', + minWidth: 0, + minHeight: 0, + height: '100%', + overflow: 'hidden', + touchAction: 'pan-y', + backgroundColor: color.SurfaceVariant.Container, +}); + +// Shared static tabs row painted ABOVE the strip. Reserves the +// status-bar safe-area inset via padding-top so the segments + icons +// sit just below the system status bar, and so the backdrop colour +// extends through the inset zone (matching the per-pane PageNav's +// own `paddingTop: var(--vojo-safe-top)` so there's no visible band +// boundary at the inset edge). +// +// `z-index: 10` keeps this above the strip and any in-pane curtain +// (curtain is `z-index: 2` within its pane's stacking context, which +// in turn lives inside the strip with z-index auto = 0 in pagerRoot's +// context — so 10 reliably wins). +export const pagerStaticHeader = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 10, + paddingTop: 'var(--vojo-safe-top, 0px)', + // The wrapped tabsRow has its own height of TABS_ROW_PX via the + // stream-header recipe; we don't set a fixed height here so the + // status-bar inset adds on top naturally. + backgroundColor: color.SurfaceVariant.Container, +}); + +// Horizontal strip carrying all three panes side-by-side. Width & +// transform are computed inline in the JSX (they depend on tabs.length +// and visualIdx + visualDragPx, and the gap math couples to them). +// +// `gap: PANE_GAP_PX` is what makes the inter-pane void visible during +// a swipe — the pagerRoot's SurfaceVariant.Container colour shows +// through the gap, matching the static header tone exactly. +export const strip = style({ + display: 'flex', + flexDirection: 'row', + height: '100%', + willChange: 'transform', +}); + +// Each pane is exactly one viewport wide. CRITICALLY `display: flex; +// flex-direction: row` so the nested Folds PageNav (which is a flex +// child with `flex-grow: 1` on mobile to override its 256px recipe +// width) expands to fill the pane. A column-flex parent here would +// leave PageNav at 256px — the bug that ate the previous attempt. +// +// No paddingTop here: the per-pane StreamHeader still renders its +// own tabs row (kept for the curtain's TABS_ROW_PX snap math, just +// painted invisible via visibility:hidden), 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 +// the pager root simply paints OVER the same screen zone, so the +// underlying geometry stays identical to non-pager mode. +export const pane = style({ + display: 'flex', + flexDirection: 'row', + flexShrink: 0, + width: '100vw', + height: '100%', + minWidth: 0, +}); diff --git a/src/app/components/mobile-tabs-pager/useMobileTabsPagerGesture.ts b/src/app/components/mobile-tabs-pager/useMobileTabsPagerGesture.ts new file mode 100644 index 00000000..8bb2928a --- /dev/null +++ b/src/app/components/mobile-tabs-pager/useMobileTabsPagerGesture.ts @@ -0,0 +1,220 @@ +import { MutableRefObject, useEffect, useRef } from 'react'; +import { + COMMIT_FRACTION, + DEAD_ZONE_PX, + EDGE_GUARD_PX, + MIN_COMMIT_PX, + RUBBER_BAND_FACTOR, +} from './geometry'; + +type Args = { + // Root element the touch listeners attach to. Touches outside this + // element never reach the pager — that's how we keep the gesture + // scoped to the listing surface and out of detail routes. + rootRef: MutableRefObject; + // Index of the currently active pane. Mirrored into a ref so the + // single bound effect reads fresh values without re-attaching. + activeIdx: number; + // Total number of panes. Used to clamp commit + rubber-band edges. + tabsCount: number; + // While true the listeners stay bound but every touchstart bails + // immediately. Used by the parent to suppress the gesture when an + // overlay sheet (settings, workspace switcher) is open — a swipe + // there shouldn't navigate sibling tabs. + disabled: boolean; + // Setter for the live drag delta. The pager component re-renders the + // strip transform on every change. + setDragPx: (px: number, dragging: boolean) => void; + // Commit a tab change. The caller is expected to reset dragPx to 0 + // AND call navigate(replace) in the same React batch so the strip's + // transform jumps from (oldIdx, dragPx) to (newIdx, 0) in one render + // — CSS transition then animates the (small) remaining distance + // smoothly without an intermediate "snap back" flash. + commitTo: (idx: number) => void; +}; + +// Horizontal swipe driver for the mobile listing tab pager. Mirrors +// the shape of `useCurtainGesture`: single listener bound to the +// pager root, refs for the latest snap/index state, axis-resolve in +// the dead-zone, rubber-band at boundaries, threshold-commit on +// release. +// +// Conflict resolution with other gestures sharing the same surface +// (curtain, MobileSettingsHorseshoe, ChannelsWorkspaceHorseshoe) is +// cooperative: every gesture-owner resolves axis at the same dead- +// zone (12px) and bails when its own axis doesn't dominate. The pager +// wins horizontal; the others win vertical. +export function useMobileTabsPagerGesture({ + rootRef, + activeIdx, + tabsCount, + disabled, + setDragPx, + commitTo, +}: Args): void { + const activeRef = useRef(activeIdx); + const countRef = useRef(tabsCount); + const disabledRef = useRef(disabled); + activeRef.current = activeIdx; + countRef.current = tabsCount; + disabledRef.current = disabled; + + useEffect(() => { + const root = rootRef.current; + if (!root) return undefined; + + let startX: number | null = null; + let startY: number | null = null; + let engaged = false; + let bailed = false; + let lastDragPx = 0; + + const reset = () => { + startX = null; + startY = null; + engaged = false; + bailed = false; + lastDragPx = 0; + }; + + const onTouchStart = (e: TouchEvent) => { + if (disabledRef.current) { + reset(); + return; + } + if (e.touches.length !== 1) { + reset(); + return; + } + const t = e.touches[0]; + const vw = window.innerWidth; + // Android system back-gesture lives in the L/R edge strip in + // edge-to-edge mode. Ignore touches there so we don't fight it. + if (t.clientX < EDGE_GUARD_PX || t.clientX > vw - EDGE_GUARD_PX) { + reset(); + return; + } + startX = t.clientX; + startY = t.clientY; + engaged = false; + bailed = false; + lastDragPx = 0; + }; + + const onTouchMove = (e: TouchEvent) => { + if (e.touches.length !== 1) { + // Second finger landed mid-gesture — abort without commit. + if (engaged) setDragPx(0, false); + reset(); + bailed = true; + return; + } + // Defensive symmetry with onTouchStart's disabled check: a sheet + // opening async between touchstart and touchmove (e.g. atom flip + // from a delayed effect) shouldn't let an already-armed pager + // gesture commit through. + if (disabledRef.current) { + if (engaged) setDragPx(0, false); + reset(); + bailed = true; + return; + } + if (startX === null || startY === null || bailed) return; + const t = e.touches[0]; + const dx = t.clientX - startX; + const dy = t.clientY - startY; + + if (!engaged) { + // Wait for the finger to leave the dead-zone before deciding + // who owns the gesture. The pager only engages when |dx| + // strictly dominates |dy|; ties go to vertical (curtain + + // horseshoe pull-down feels more natural than horizontal + // commit for ambiguous gestures). + if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) return; + if (Math.abs(dy) >= Math.abs(dx)) { + bailed = true; + return; + } + engaged = true; + } + + if (e.cancelable) e.preventDefault(); + const vw = window.innerWidth; + const idx = activeRef.current; + const count = countRef.current; + let drag = dx; + // Rubber-band at the leftmost (dx > 0 = trying to go past idx 0) + // and rightmost (dx < 0 = trying to go past last idx) boundary. + // Soft attenuation — commit threshold can never be reached, so + // the spring-back on release lands us back on the current tab. + if (idx === 0 && dx > 0) drag = dx * RUBBER_BAND_FACTOR; + else if (idx === count - 1 && dx < 0) drag = dx * RUBBER_BAND_FACTOR; + // Clamp to ±one viewport so an overshooting swipe doesn't + // translate the strip into nonsense territory. + drag = Math.max(-vw, Math.min(vw, drag)); + lastDragPx = drag; + setDragPx(drag, true); + }; + + const onTouchEnd = () => { + if (!engaged) { + reset(); + return; + } + // Defensive recheck symmetric with onTouchStart / onTouchMove: + // an overlay sheet could have opened between the last touchmove + // and this touchend (atom flip from a delayed effect, a system + // dialog, etc.). Committing under those circumstances would + // navigate sibling tabs from beneath the overlay — same hazard + // the touchstart/move gates exist to prevent. Spring back + // instead. + if (disabledRef.current) { + setDragPx(0, false); + reset(); + return; + } + const vw = window.innerWidth; + const idx = activeRef.current; + const count = countRef.current; + const threshold = Math.max(MIN_COMMIT_PX, vw * COMMIT_FRACTION); + + let nextIdx = idx; + // Negative drag (finger moved left) → next tab to the right. + // Positive drag (finger moved right) → previous tab to the left. + if (lastDragPx <= -threshold && idx < count - 1) nextIdx = idx + 1; + else if (lastDragPx >= threshold && idx > 0) nextIdx = idx - 1; + + if (nextIdx !== idx) { + commitTo(nextIdx); + } else { + // No commit — re-enable transition and animate the strip back + // to its resting position at the current tab. + setDragPx(0, false); + } + + reset(); + }; + + const onTouchCancel = () => { + // System cancel (incoming call, scroll-take-over, etc.) — never + // commit; just spring back if a drag was in flight. + if (engaged) setDragPx(0, false); + reset(); + }; + + root.addEventListener('touchstart', onTouchStart, { passive: true }); + root.addEventListener('touchmove', onTouchMove, { passive: false }); + root.addEventListener('touchend', onTouchEnd, { passive: true }); + root.addEventListener('touchcancel', onTouchCancel, { passive: true }); + return () => { + root.removeEventListener('touchstart', onTouchStart); + root.removeEventListener('touchmove', onTouchMove); + root.removeEventListener('touchend', onTouchEnd); + root.removeEventListener('touchcancel', onTouchCancel); + }; + // setDragPx / commitTo are stable useCallbacks from the parent; + // activeIdx / tabsCount are mirrored via the refs above so the + // listener reads fresh values without re-binding on every nav. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rootRef, setDragPx, commitTo]); +} diff --git a/src/app/components/stream-header/StreamHeader.tsx b/src/app/components/stream-header/StreamHeader.tsx index 817a6ca1..6111d387 100644 --- a/src/app/components/stream-header/StreamHeader.tsx +++ b/src/app/components/stream-header/StreamHeader.tsx @@ -9,10 +9,13 @@ import React, { } from 'react'; import { useTranslation } from 'react-i18next'; import { useMatch, useNavigate } from 'react-router-dom'; +import { useSetAtom } from 'jotai'; import { Box, Icon, IconButton, Icons, toRem } from 'folds'; import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths'; import { isNativePlatform } from '../../utils/capacitor'; import { useBotPresets } from '../../features/bots/catalog'; +import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext'; +import { MobilePagerCurtainControls, mobilePagerCurtainAtom } from '../../state/mobilePagerHeader'; import * as css from './StreamHeader.css'; import { CHIP_ROW_PX, TABS_ROW_PX } from './geometry'; import { Segment } from './Segment'; @@ -52,6 +55,17 @@ export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeader const channelsMatch = useMatch({ path: CHANNELS_PATH, caseSensitive: true, end: false }); const showBotsSegment = bots.length > 0 || !!botsMatch; + // Pager mode wiring. When this StreamHeader is mounted inside + // MobileTabsPager, the shared static tabs row at the pager root + // owns the visible segments + action icons; our local tabs row is + // kept in DOM (preserving the curtain's TABS_ROW_PX-based snap + // geometry) but rendered with `visibility: hidden`. Only the + // currently active pane writes its curtain controls to + // `mobilePagerCurtainAtom` so the shared icons drive THIS curtain. + const pagerPane = useMobilePagerPane(); + const inPagerMode = pagerPane !== null; + const isActivePagerPane = pagerPane?.isActive ?? false; + const curtain = useCurtainState(); useCurtainGesture({ @@ -66,6 +80,33 @@ export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeader const openChat = useCallback(() => curtain.open('chat'), [curtain]); const { close } = curtain; + // Memoised controls object so the cleanup's identity check (atom + // compare-and-clear) is meaningful — without useMemo a fresh object + // would be created on every render and the cleanup of an earlier + // render would never match the atom's current contents. + const pagerControls = useMemo( + () => ({ + openSearch, + openChat, + closeForm: close, + isFormActive: isActive, + }), + [openSearch, openChat, close, isActive] + ); + + const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom); + useEffect(() => { + if (!isActivePagerPane) return undefined; + setPagerCurtain(pagerControls); + // Compare-and-clear cleanup: only wipe the atom if it still holds + // OUR controls. If another pane became active between this render + // and the cleanup (rapid tab switch), it has already overwritten + // the atom with its own controls — we must not clobber that. + return () => { + setPagerCurtain((prev) => (prev === pagerControls ? null : prev)); + }; + }, [isActivePagerPane, pagerControls, setPagerCurtain]); + // Curtain's `top` is the resting snap position plus the live drag // delta. React-driven (no inline DOM writes), so finger-tracking and // commit happen in the same render pipeline and there's no @@ -131,8 +172,27 @@ export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeader return (
- {/* ── Tabs row + action icons (always visible) ─────────── */} -
+ {/* ── Tabs row + action icons (always visible) ─────────── + In pager mode the row stays mounted (curtain snap math + depends on its TABS_ROW_PX height) but is painted invisible + because the shared static tabs row at the pager root owns + the visible chrome. Hit-testing the invisible row is fine — + the static header sits above it in z-stack and absorbs + taps; even if a tap leaked through, both rows trigger the + same navigate() so the user-visible result is identical. + + `aria-hidden` removes the duplicate (per-pane) segments and + icons from the accessibility tree so screen readers don't + announce three sets of "Direct / Channels / Bots" plus the + single visible set from the shared static header. Chromium + already prunes `visibility: hidden` nodes from a11y, but + making the intent explicit guards against AT/browser + variations. */} +
{ if (e.touches.length !== 1) return; + startX = e.touches[0].clientX; startY = e.touches[0].clientY; direction = null; engaged = false; @@ -61,6 +63,7 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args // scroll). Form-close engages regardless of scrollTop (the form // is open, the list scroll is the close target). if (!isFormSnap(snapRef.current) && list.scrollTop !== 0) { + startX = null; startY = null; } }; @@ -68,6 +71,7 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args const onTouchMove = (e: TouchEvent) => { if (e.touches.length !== 1) { // Second finger landed mid-gesture — abort. + startX = null; startY = null; direction = null; if (engaged) setLiveDrag(0, false); @@ -78,6 +82,7 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args if (startY === null) { // Active mode may re-arm startY here if onTouchStart bailed. if (isFormSnap(snapRef.current)) { + startX = e.touches[0].clientX; startY = e.touches[0].clientY; } else { return; @@ -85,26 +90,42 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args } const delta = e.touches[0].clientY - startY; + const deltaX = startX !== null ? e.touches[0].clientX - startX : 0; const currentSnap = snapRef.current; // Resolve a direction once the finger crosses the dead-zone. if (direction === null) { if (Math.abs(delta) < DIRECTION_DEAD_ZONE_PX) return; + // Horizontal-bail: if the finger crosses the dead-zone with + // |dx| strictly greater than |dy|, the user is swiping the + // mobile tab pager, not pulling the curtain. Drop our tracking + // state so the pager owns the gesture; ties still resolve to + // vertical (curtain) because pull-down is the more common + // intent on the listing surface. + if (Math.abs(deltaX) > Math.abs(delta)) { + startX = null; + startY = null; + direction = null; + return; + } direction = delta > 0 ? 'down' : 'up'; // Direction guards: nothing higher than `closed`; nothing // lower than `peek`; form snaps only close (up). if (currentSnap === 'closed' && direction === 'up') { + startX = null; startY = null; direction = null; return; } if (currentSnap === 'peek' && direction === 'down') { + startX = null; startY = null; direction = null; return; } if (isFormSnap(currentSnap) && direction === 'down') { + startX = null; startY = null; direction = null; return; @@ -167,6 +188,7 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args setLiveDrag(0, false); } + startX = null; startY = null; direction = null; engaged = false; @@ -176,6 +198,7 @@ export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args const onTouchCancel = () => { // System cancel never commits — always snap back to current snap. if (engaged) setLiveDrag(0, false); + startX = null; startY = null; direction = null; engaged = false; diff --git a/src/app/features/settings/MobileSettingsHorseshoe.tsx b/src/app/features/settings/MobileSettingsHorseshoe.tsx index d105ee99..19e3f492 100644 --- a/src/app/features/settings/MobileSettingsHorseshoe.tsx +++ b/src/app/features/settings/MobileSettingsHorseshoe.tsx @@ -51,10 +51,7 @@ import { createPortal } from 'react-dom'; import { useAtomValue } from 'jotai'; import { useTranslation } from 'react-i18next'; import { settingsSheetAtom } from '../../state/settingsSheet'; -import { - useCloseSettingsSheet, - useOpenSettingsSheet, -} from '../../state/hooks/settingsSheet'; +import { useCloseSettingsSheet, useOpenSettingsSheet } from '../../state/hooks/settingsSheet'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { HorseshoeEnabledContext } from '../../components/page'; import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe'; @@ -91,14 +88,21 @@ const HORSESHOE_EMERGE_PX = 80; // barely visible until ~40% of the way through the gesture, then // blossoms around the midpoint. Used only during finger-drag; release // transitions use the asymmetric VAUL_EASING curve in CSS. -const easeInOutCubic = (t: number): number => - t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2; +const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2); type DragSource = 'directSelfRow' | 'handle'; +// Axis dead-zone for horizontal-bail. The finger must travel this far +// on either axis before we resolve the gesture as vertical (open/close +// the sheet) or horizontal (yield to MobileTabsPager). Same value as +// the curtain gesture and the pager itself so all three resolve at the +// same threshold and never compete. +const AXIS_DEAD_ZONE_PX = 12; + type DragState = { source: DragSource; inputType: 'touch' | 'pointer'; + startX: number; startY: number; deltaY: number; }; @@ -228,9 +232,7 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) const target = e.target as HTMLElement | null; if ( target && - (target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.isContentEditable) + (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) ) { return; } @@ -256,6 +258,21 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) useEffect(() => { const handleEl = handleRef.current; + // Per-drag axis-resolution flag. Reset to false in every touchstart / + // pointerdown handler, flipped to true in applyMove once the finger + // leaves the dead-zone with a vertical-dominant delta. If the finger + // leaves the dead-zone horizontally, we drop the drag and yield to + // MobileTabsPager instead of opening/closing the sheet. + let axisResolved = false; + // Sticky flag flipped once a touch series has yielded to the pager + // (axis-resolved as horizontal). Without it, a rapid reversal back + // toward vertical inside the same touch could re-enter the + // axisResolved=false branch — the closure's `axisResolved` is still + // false because we returned early — and re-engage the sheet drag, + // even though we explicitly handed the gesture off. Reset on every + // fresh touchstart / pointerdown. + let touchBailed = false; + // CLAMP, not early-return. Reversal of gesture direction must // drag `deltaY` back toward 0, not leave the stale value. The // earlier `if (wrong direction) return` branch let a user swipe @@ -274,15 +291,37 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) // try to expand past its full open height. // // preventDefault always (when cancelable) — these touches belong - // to our gesture. DM-list scroll lives ABOVE the row; touches on - // those rows don't hit `[data-settings-drag-origin]` so they - // never enter this handler. - const applyMove = (clientY: number, e: TouchEvent | PointerEvent) => { + // to our gesture once axis-resolved. DM-list scroll lives ABOVE + // the row; touches on those rows don't hit + // `[data-settings-drag-origin]` so they never enter this handler. + const applyMove = (clientX: number, clientY: number, e: TouchEvent | PointerEvent) => { + if (touchBailed) return; const d = dragRef.current; if (!d) return; - const rawDelta = clientY - d.startY; + const rawDeltaX = clientX - d.startX; + const rawDeltaY = clientY - d.startY; + + if (!axisResolved) { + const dxAbs = Math.abs(rawDeltaX); + const dyAbs = Math.abs(rawDeltaY); + // Stay quiet inside the dead-zone — both the pager and us + // need a stable signal before deciding who owns the gesture. + if (dxAbs < AXIS_DEAD_ZONE_PX && dyAbs < AXIS_DEAD_ZONE_PX) return; + if (dxAbs > dyAbs) { + // Horizontal-dominant — the user is steering the pager. + // Drop our drag state without preventDefault so the pager's + // touchmove handler can engage and own the rest of the touch. + // Stick the bail for the remainder of this touch series so a + // late vertical reversal can't sneak back into the sheet. + touchBailed = true; + setDrag(null); + return; + } + axisResolved = true; + } + const nextDelta = - d.source === 'directSelfRow' ? Math.min(0, rawDelta) : Math.max(0, rawDelta); + d.source === 'directSelfRow' ? Math.min(0, rawDeltaY) : Math.max(0, rawDeltaY); if (e.cancelable) e.preventDefault(); setDrag({ ...d, deltaY: nextDelta }); }; @@ -309,9 +348,12 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) if (sheetRef.current) return; // sheet is open — handle owns drag if (!targetIsDragOrigin(e.target)) return; const touch = e.touches[0]; + axisResolved = false; + touchBailed = false; setDrag({ source: 'directSelfRow', inputType: 'touch', + startX: touch.clientX, startY: touch.clientY, deltaY: 0, }); @@ -320,12 +362,20 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) if (dragRef.current) return; if (!sheetRef.current) return; const touch = e.touches[0]; - setDrag({ source: 'handle', inputType: 'touch', startY: touch.clientY, deltaY: 0 }); + axisResolved = false; + touchBailed = false; + setDrag({ + source: 'handle', + inputType: 'touch', + startX: touch.clientX, + startY: touch.clientY, + deltaY: 0, + }); }; const onTouchMove = (e: TouchEvent) => { const d = dragRef.current; if (!d || d.inputType !== 'touch') return; - applyMove(e.touches[0].clientY, e); + applyMove(e.touches[0].clientX, e.touches[0].clientY, e); }; const onTouchEnd = () => { const d = dragRef.current; @@ -340,9 +390,12 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) if (sheetRef.current) return; if (e.button !== 0) return; if (!targetIsDragOrigin(e.target)) return; + axisResolved = false; + touchBailed = false; setDrag({ source: 'directSelfRow', inputType: 'pointer', + startX: e.clientX, startY: e.clientY, deltaY: 0, }); @@ -352,13 +405,21 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) if (dragRef.current) return; if (!sheetRef.current) return; if (e.button !== 0) return; - setDrag({ source: 'handle', inputType: 'pointer', startY: e.clientY, deltaY: 0 }); + axisResolved = false; + touchBailed = false; + setDrag({ + source: 'handle', + inputType: 'pointer', + startX: e.clientX, + startY: e.clientY, + deltaY: 0, + }); }; const onDocPointerMove = (e: PointerEvent) => { if (e.pointerType === 'touch') return; const d = dragRef.current; if (!d || d.inputType !== 'pointer') return; - applyMove(e.clientY, e); + applyMove(e.clientX, e.clientY, e); }; const onDocPointerEnd = (e: PointerEvent) => { if (e.pointerType === 'touch') return; @@ -461,7 +522,11 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
{open && portalTarget ? createPortal( -