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:
parent
31a3880de7
commit
ab24a71761
3 changed files with 131 additions and 45 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue