From ab24a71761a82cff3fdbc256a71d7d9f8bf8fa2f Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Tue, 19 May 2026 14:28:56 +0300 Subject: [PATCH] fix(mobile-tabs-pager): restore segment-button taps in pager mode via opacity-0 per-pane row and route their commits through an instant no-transition strip jump --- .../MobilePagerPaneContext.tsx | 22 ++++- .../mobile-tabs-pager/MobileTabsPager.tsx | 96 ++++++++++++++----- .../components/stream-header/StreamHeader.tsx | 58 ++++++++--- 3 files changed, 131 insertions(+), 45 deletions(-) diff --git a/src/app/components/mobile-tabs-pager/MobilePagerPaneContext.tsx b/src/app/components/mobile-tabs-pager/MobilePagerPaneContext.tsx index 29f774d0..82ae1807 100644 --- a/src/app/components/mobile-tabs-pager/MobilePagerPaneContext.tsx +++ b/src/app/components/mobile-tabs-pager/MobilePagerPaneContext.tsx @@ -1,19 +1,31 @@ import { createContext, useContext } from 'react'; +export type MobilePagerTab = 'direct' | 'channels' | 'bots'; + // 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. +// pager mode at all, (b) whether it's the currently active pane, and +// (c) how to request a tab switch with no animation (used by the +// invisible per-pane Segment buttons that capture taps in the safe- +// top + tabsRow band — see `StreamHeader.tsx` for why the row is +// `opacity:0` instead of `visibility:hidden`). // // "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. +// renders visibly — when we're in pager mode the tabs row is painted +// invisible (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`. +// +// `selectTabInstant` is the tap commit path. It snaps the strip to +// the target tab without the swipe-finish CSS transition — taps feel +// snappy where swipes still animate. Swipes still go through the +// pager's gesture hook which uses a separate animated commit. export type MobilePagerPaneInfo = { isActive: boolean; + selectTabInstant: (target: MobilePagerTab) => void; }; const MobilePagerPaneContext = createContext(null); diff --git a/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx b/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx index dd216c69..c5d7bde4 100644 --- a/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx +++ b/src/app/components/mobile-tabs-pager/MobileTabsPager.tsx @@ -20,13 +20,16 @@ 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 { MobilePagerPaneProvider, MobilePagerTab } 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'; +// Aliased to the context's `MobilePagerTab` so the per-pane Segment's +// `selectTabInstant(target)` callback (exposed via MobilePagerPaneInfo) +// stays type-aligned with the pager's own `tabs` array. +type Tab = MobilePagerTab; // URL-safe wrapper around decodeURIComponent — matches the same helper // inside `useActiveSpace`. Used here to validate the URL `:spaceIdOrAlias` @@ -180,6 +183,12 @@ export function MobileTabsPager() { const [dragPx, setDragPxState] = useState(0); const [dragging, setDraggingState] = useState(false); + // Tap-driven commits set this for one frame to suppress the strip's + // CSS transform transition. The strip jumps to the new tab without + // the 280ms slide animation that swipe commits intentionally play. + // Cleared in a rAF effect below so subsequent state changes resume + // with normal transitions. + const [instantSwitch, setInstantSwitch] = 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 @@ -221,30 +230,56 @@ export function MobileTabsPager() { [mx, activeSpaceId] ); - const commitTo = useCallback( - (idx: number) => { + // Shared commit core. `instant=true` flips `instantSwitch` for one + // frame so the strip's transform jump from old tab to new lands + // without the swipe-finish CSS transition — used by tap paths + // (static header Segments + per-pane Segments). `instant=false` + // preserves the animation — used by the swipe gesture commit so + // the strip smoothly completes the user's drag. + const commitToInternal = useCallback( + (idx: number, instant: boolean) => { const target = tabs[idx]; if (!target) return; setDragPxState(0); setDraggingState(false); + if (instant) setInstantSwitch(true); 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 commitToSwipe = useCallback( + (idx: number) => commitToInternal(idx, false), + [commitToInternal] + ); + + // Tap entry by tab name. Exposed to per-pane Segments via + // `MobilePagerPaneInfo.selectTabInstant` so the invisible per-pane + // segment buttons (which capture taps when the static header isn't + // z-elevated) route through the same instant commit path as the + // static header's own segment buttons. + const selectTabInstant = useCallback( + (target: Tab) => { + const i = tabs.indexOf(target); + if (i >= 0) commitToInternal(i, true); + }, + [tabs, commitToInternal] + ); + + const onSelectDirect = useCallback(() => selectTabInstant('direct'), [selectTabInstant]); + const onSelectChannels = useCallback(() => selectTabInstant('channels'), [selectTabInstant]); + const onSelectBots = useCallback(() => selectTabInstant('bots'), [selectTabInstant]); + + // Clear `instantSwitch` on the next paint frame so subsequent + // transform changes use the normal animated transition again. rAF + // (not setTimeout 0) so we cancel cleanly if a new commit re-arms + // the flag before the frame fires. + useEffect(() => { + if (!instantSwitch) return undefined; + const id = requestAnimationFrame(() => setInstantSwitch(false)); + return () => cancelAnimationFrame(id); + }, [instantSwitch]); const pendingTargetIdx = pendingTargetTab !== null ? tabs.indexOf(pendingTargetTab) : -1; @@ -292,7 +327,12 @@ export function MobileTabsPager() { tabsCount: tabs.length, disabled: gestureDisabled, setDragPx, - commitTo, + // Swipe commits keep the slide animation — the strip glides from + // the drag-released position to the target tab. Tap commits use + // `selectTabInstant` (passed down via per-pane context + the + // static header's `onSelectXxx` props) which sets `instantSwitch` + // for one frame to skip the transition. + commitTo: commitToSwipe, }); // Gap-aware strip transform. Each adjacent pane is offset by an @@ -307,10 +347,16 @@ export function MobileTabsPager() { 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}`, + // Transition suppressed while a finger drag is in flight (the + // strip follows the finger 1:1) AND for the single frame after + // a tap-driven commit (`instantSwitch`) so segment taps snap to + // the target tab without the 280ms slide. Swipe commits leave + // `instantSwitch` false so the slide-to-target animation plays. + transition: + dragging || instantSwitch ? 'none' : `transform ${PAGER_TRANSITION_MS}ms ${PAGER_EASING}`, gap: `${PANE_GAP_PX}px`, }), - [tabs.length, visualIdx, visualDragPx, dragging] + [tabs.length, visualIdx, visualDragPx, dragging, instantSwitch] ); // Per-pane context values memoised separately so each pane's @@ -323,16 +369,16 @@ export function MobileTabsPager() { const channelsIdx = useMemo(() => tabs.indexOf('channels'), [tabs]); const botsIdx = useMemo(() => tabs.indexOf('bots'), [tabs]); const directPaneInfo = useMemo( - () => ({ isActive: urlActiveIdx === directIdx }), - [urlActiveIdx, directIdx] + () => ({ isActive: urlActiveIdx === directIdx, selectTabInstant }), + [urlActiveIdx, directIdx, selectTabInstant] ); const channelsPaneInfo = useMemo( - () => ({ isActive: urlActiveIdx === channelsIdx }), - [urlActiveIdx, channelsIdx] + () => ({ isActive: urlActiveIdx === channelsIdx, selectTabInstant }), + [urlActiveIdx, channelsIdx, selectTabInstant] ); const botsPaneInfo = useMemo( - () => ({ isActive: urlActiveIdx === botsIdx }), - [urlActiveIdx, botsIdx] + () => ({ isActive: urlActiveIdx === botsIdx, selectTabInstant }), + [urlActiveIdx, botsIdx, selectTabInstant] ); // The static header doesn't need useMatch of its own — `urlActiveIdx` diff --git a/src/app/components/stream-header/StreamHeader.tsx b/src/app/components/stream-header/StreamHeader.tsx index 22d52c47..89309273 100644 --- a/src/app/components/stream-header/StreamHeader.tsx +++ b/src/app/components/stream-header/StreamHeader.tsx @@ -68,12 +68,34 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre // 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 + // geometry) but rendered with `opacity: 0` (still tap-able). Only + // the currently active pane writes its curtain controls to // `mobilePagerCurtainAtom` so the shared icons drive THIS curtain. + // + // `selectTabInstant` is the pager's tap-commit entrypoint: when our + // invisible per-pane Segments capture a tap (because the static + // header sits behind the strip in z-stack at rest), routing through + // this callback runs the same commit path as the static header's + // taps and suppresses the swipe-finish slide animation so tab + // switches feel snappy. Falls back to a plain `navigate(...)` on + // surfaces outside pager mode (desktop, non-listing routes). const pagerPane = useMobilePagerPane(); const inPagerMode = pagerPane !== null; const isActivePagerPane = pagerPane?.isActive ?? false; + const selectTabInstant = pagerPane?.selectTabInstant ?? null; + + const onSegmentDirect = useCallback(() => { + if (selectTabInstant) selectTabInstant('direct'); + else navigate(DIRECT_PATH, navOpts); + }, [selectTabInstant, navigate, navOpts]); + const onSegmentChannels = useCallback(() => { + if (selectTabInstant) selectTabInstant('channels'); + else navigate(CHANNELS_PATH, navOpts); + }, [selectTabInstant, navigate, navOpts]); + const onSegmentBots = useCallback(() => { + if (selectTabInstant) selectTabInstant('bots'); + else navigate(BOTS_PATH, navOpts); + }, [selectTabInstant, navigate, navOpts]); const curtain = useCurtainState(pinKey); @@ -216,40 +238,46 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre {/* ── 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. + via `opacity: 0` because the shared static tabs row at the + pager root owns the visible chrome. Critically opacity (not + `visibility: hidden`) keeps the row HIT-TESTABLE while + invisible — the strip's stacking context paints ON TOP of + the static pager header at rest (the curtain-rises-over- + header contract), so without per-pane taps the segments + would be unreachable. Both rows wire onClick to the same + navigate() destination, so whichever row captures the tap + the user-visible result is identical. The static header z- + elevates only while a horseshoe sheet is active and the + active pane isn't pinned — in that window the static header + sits above the strip and captures taps directly; the rest + of the time taps land on this opacity-0 row and resolve + through its own per-pane onClick handlers. `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. */} + single visible set from the shared static header. */}
navigate(DIRECT_PATH, navOpts)} + onClick={onSegmentDirect} /> navigate(CHANNELS_PATH, navOpts)} + onClick={onSegmentChannels} /> {showBotsSegment && ( navigate(BOTS_PATH, navOpts)} + onClick={onSegmentBots} /> )}