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

This commit is contained in:
heaven 2026-05-19 14:28:56 +03:00
parent 0422a9832f
commit 7c5a1f2ee7
3 changed files with 131 additions and 45 deletions

View file

@ -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<MobilePagerPaneInfo | null>(null);

View file

@ -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`

View file

@ -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. */}
<div
className={css.tabsRow}
style={inPagerMode ? { visibility: 'hidden' } : undefined}
style={inPagerMode ? { opacity: 0 } : undefined}
aria-hidden={inPagerMode || undefined}
>
<div className={css.tabsCluster}>
<Segment
active={!!directMatch}
label={t('Direct.segment_dm')}
onClick={() => navigate(DIRECT_PATH, navOpts)}
onClick={onSegmentDirect}
/>
<Segment
active={!!channelsMatch}
label={t('Direct.segment_channels')}
onClick={() => navigate(CHANNELS_PATH, navOpts)}
onClick={onSegmentChannels}
/>
{showBotsSegment && (
<Segment
active={!!botsMatch}
label={t('Direct.segment_bots')}
onClick={() => navigate(BOTS_PATH, navOpts)}
onClick={onSegmentBots}
/>
)}
</div>