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
0422a9832f
commit
7c5a1f2ee7
3 changed files with 131 additions and 45 deletions
|
|
@ -1,19 +1,31 @@
|
||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export type MobilePagerTab = 'direct' | 'channels' | 'bots';
|
||||||
|
|
||||||
// Set by MobileTabsPager around each of its three listing panes. Lets
|
// Set by MobileTabsPager around each of its three listing panes. Lets
|
||||||
// the StreamHeader inside the pane discover (a) that it's mounted in
|
// 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
|
// "Mounted in pager mode" controls whether the per-pane tabs row
|
||||||
// renders visibly — when we're in pager mode the tabs row is hidden
|
// renders visibly — when we're in pager mode the tabs row is painted
|
||||||
// via visibility:hidden (the shared static header at pager root paints
|
// invisible (the shared static header at pager root paints the visible
|
||||||
// the visible tabs + icons), but the row still occupies its
|
// tabs + icons), but the row still occupies its TABS_ROW_PX height so
|
||||||
// TABS_ROW_PX height so the curtain's snap geometry is unchanged.
|
// the curtain's snap geometry is unchanged.
|
||||||
//
|
//
|
||||||
// "isActive" controls which pane's curtain is wired to the shared
|
// "isActive" controls which pane's curtain is wired to the shared
|
||||||
// static header's action icons via `mobilePagerCurtainAtom`.
|
// 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 = {
|
export type MobilePagerPaneInfo = {
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
selectTabInstant: (target: MobilePagerTab) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MobilePagerPaneContext = createContext<MobilePagerPaneInfo | null>(null);
|
const MobilePagerPaneContext = createContext<MobilePagerPaneInfo | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,16 @@ import { Bots } from '../../pages/client/bots';
|
||||||
import { ChannelsModeProvider } from '../../hooks/useChannelsMode';
|
import { ChannelsModeProvider } from '../../hooks/useChannelsMode';
|
||||||
import { settingsSheetAtom } from '../../state/settingsSheet';
|
import { settingsSheetAtom } from '../../state/settingsSheet';
|
||||||
import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
|
import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
|
||||||
import { MobilePagerPaneProvider } from './MobilePagerPaneContext';
|
import { MobilePagerPaneProvider, MobilePagerTab } from './MobilePagerPaneContext';
|
||||||
import { MobileTabsPagerHeader } from './MobileTabsPagerHeader';
|
import { MobileTabsPagerHeader } from './MobileTabsPagerHeader';
|
||||||
import { useMobileTabsPagerGesture } from './useMobileTabsPagerGesture';
|
import { useMobileTabsPagerGesture } from './useMobileTabsPagerGesture';
|
||||||
import { PAGER_EASING, PAGER_TRANSITION_MS, PANE_GAP_PX } from './geometry';
|
import { PAGER_EASING, PAGER_TRANSITION_MS, PANE_GAP_PX } from './geometry';
|
||||||
import * as css from './style.css';
|
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
|
// URL-safe wrapper around decodeURIComponent — matches the same helper
|
||||||
// inside `useActiveSpace`. Used here to validate the URL `:spaceIdOrAlias`
|
// inside `useActiveSpace`. Used here to validate the URL `:spaceIdOrAlias`
|
||||||
|
|
@ -180,6 +183,12 @@ export function MobileTabsPager() {
|
||||||
|
|
||||||
const [dragPx, setDragPxState] = useState(0);
|
const [dragPx, setDragPxState] = useState(0);
|
||||||
const [dragging, setDraggingState] = useState(false);
|
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
|
// Stored as a Tab NAME, not an index. The `tabs` array's length and
|
||||||
// composition can change at runtime (showBots flipping when the user
|
// composition can change at runtime (showBots flipping when the user
|
||||||
// navigates onto/off /bots/, or when a bot-config refresh adds a
|
// navigates onto/off /bots/, or when a bot-config refresh adds a
|
||||||
|
|
@ -221,30 +230,56 @@ export function MobileTabsPager() {
|
||||||
[mx, activeSpaceId]
|
[mx, activeSpaceId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const commitTo = useCallback(
|
// Shared commit core. `instant=true` flips `instantSwitch` for one
|
||||||
(idx: number) => {
|
// 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];
|
const target = tabs[idx];
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
setDragPxState(0);
|
setDragPxState(0);
|
||||||
setDraggingState(false);
|
setDraggingState(false);
|
||||||
|
if (instant) setInstantSwitch(true);
|
||||||
setPendingTargetTab(target);
|
setPendingTargetTab(target);
|
||||||
navigate(destinationFor(target), { replace: true });
|
navigate(destinationFor(target), { replace: true });
|
||||||
},
|
},
|
||||||
[tabs, navigate, destinationFor]
|
[tabs, navigate, destinationFor]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSelectDirect = useCallback(() => {
|
const commitToSwipe = useCallback(
|
||||||
const i = tabs.indexOf('direct');
|
(idx: number) => commitToInternal(idx, false),
|
||||||
if (i >= 0) commitTo(i);
|
[commitToInternal]
|
||||||
}, [tabs, commitTo]);
|
);
|
||||||
const onSelectChannels = useCallback(() => {
|
|
||||||
const i = tabs.indexOf('channels');
|
// Tap entry by tab name. Exposed to per-pane Segments via
|
||||||
if (i >= 0) commitTo(i);
|
// `MobilePagerPaneInfo.selectTabInstant` so the invisible per-pane
|
||||||
}, [tabs, commitTo]);
|
// segment buttons (which capture taps when the static header isn't
|
||||||
const onSelectBots = useCallback(() => {
|
// z-elevated) route through the same instant commit path as the
|
||||||
const i = tabs.indexOf('bots');
|
// static header's own segment buttons.
|
||||||
if (i >= 0) commitTo(i);
|
const selectTabInstant = useCallback(
|
||||||
}, [tabs, commitTo]);
|
(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;
|
const pendingTargetIdx = pendingTargetTab !== null ? tabs.indexOf(pendingTargetTab) : -1;
|
||||||
|
|
||||||
|
|
@ -292,7 +327,12 @@ export function MobileTabsPager() {
|
||||||
tabsCount: tabs.length,
|
tabsCount: tabs.length,
|
||||||
disabled: gestureDisabled,
|
disabled: gestureDisabled,
|
||||||
setDragPx,
|
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
|
// Gap-aware strip transform. Each adjacent pane is offset by an
|
||||||
|
|
@ -307,10 +347,16 @@ export function MobileTabsPager() {
|
||||||
transform: `translate3d(calc(${-visualIdx * 100}vw - ${
|
transform: `translate3d(calc(${-visualIdx * 100}vw - ${
|
||||||
visualIdx * PANE_GAP_PX
|
visualIdx * PANE_GAP_PX
|
||||||
}px + ${visualDragPx}px), 0, 0)`,
|
}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`,
|
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
|
// 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 channelsIdx = useMemo(() => tabs.indexOf('channels'), [tabs]);
|
||||||
const botsIdx = useMemo(() => tabs.indexOf('bots'), [tabs]);
|
const botsIdx = useMemo(() => tabs.indexOf('bots'), [tabs]);
|
||||||
const directPaneInfo = useMemo(
|
const directPaneInfo = useMemo(
|
||||||
() => ({ isActive: urlActiveIdx === directIdx }),
|
() => ({ isActive: urlActiveIdx === directIdx, selectTabInstant }),
|
||||||
[urlActiveIdx, directIdx]
|
[urlActiveIdx, directIdx, selectTabInstant]
|
||||||
);
|
);
|
||||||
const channelsPaneInfo = useMemo(
|
const channelsPaneInfo = useMemo(
|
||||||
() => ({ isActive: urlActiveIdx === channelsIdx }),
|
() => ({ isActive: urlActiveIdx === channelsIdx, selectTabInstant }),
|
||||||
[urlActiveIdx, channelsIdx]
|
[urlActiveIdx, channelsIdx, selectTabInstant]
|
||||||
);
|
);
|
||||||
const botsPaneInfo = useMemo(
|
const botsPaneInfo = useMemo(
|
||||||
() => ({ isActive: urlActiveIdx === botsIdx }),
|
() => ({ isActive: urlActiveIdx === botsIdx, selectTabInstant }),
|
||||||
[urlActiveIdx, botsIdx]
|
[urlActiveIdx, botsIdx, selectTabInstant]
|
||||||
);
|
);
|
||||||
|
|
||||||
// The static header doesn't need useMatch of its own — `urlActiveIdx`
|
// 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
|
// MobileTabsPager, the shared static tabs row at the pager root
|
||||||
// owns the visible segments + action icons; our local tabs row is
|
// owns the visible segments + action icons; our local tabs row is
|
||||||
// kept in DOM (preserving the curtain's TABS_ROW_PX-based snap
|
// kept in DOM (preserving the curtain's TABS_ROW_PX-based snap
|
||||||
// geometry) but rendered with `visibility: hidden`. Only the
|
// geometry) but rendered with `opacity: 0` (still tap-able). Only
|
||||||
// currently active pane writes its curtain controls to
|
// the currently active pane writes its curtain controls to
|
||||||
// `mobilePagerCurtainAtom` so the shared icons drive THIS curtain.
|
// `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 pagerPane = useMobilePagerPane();
|
||||||
const inPagerMode = pagerPane !== null;
|
const inPagerMode = pagerPane !== null;
|
||||||
const isActivePagerPane = pagerPane?.isActive ?? false;
|
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);
|
const curtain = useCurtainState(pinKey);
|
||||||
|
|
||||||
|
|
@ -216,40 +238,46 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
|
||||||
{/* ── Tabs row + action icons (always visible) ───────────
|
{/* ── Tabs row + action icons (always visible) ───────────
|
||||||
In pager mode the row stays mounted (curtain snap math
|
In pager mode the row stays mounted (curtain snap math
|
||||||
depends on its TABS_ROW_PX height) but is painted invisible
|
depends on its TABS_ROW_PX height) but is painted invisible
|
||||||
because the shared static tabs row at the pager root owns
|
via `opacity: 0` because the shared static tabs row at the
|
||||||
the visible chrome. Hit-testing the invisible row is fine —
|
pager root owns the visible chrome. Critically opacity (not
|
||||||
the static header sits above it in z-stack and absorbs
|
`visibility: hidden`) keeps the row HIT-TESTABLE while
|
||||||
taps; even if a tap leaked through, both rows trigger the
|
invisible — the strip's stacking context paints ON TOP of
|
||||||
same navigate() so the user-visible result is identical.
|
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
|
`aria-hidden` removes the duplicate (per-pane) segments and
|
||||||
icons from the accessibility tree so screen readers don't
|
icons from the accessibility tree so screen readers don't
|
||||||
announce three sets of "Direct / Channels / Bots" plus the
|
announce three sets of "Direct / Channels / Bots" plus the
|
||||||
single visible set from the shared static header. Chromium
|
single visible set from the shared static header. */}
|
||||||
already prunes `visibility: hidden` nodes from a11y, but
|
|
||||||
making the intent explicit guards against AT/browser
|
|
||||||
variations. */}
|
|
||||||
<div
|
<div
|
||||||
className={css.tabsRow}
|
className={css.tabsRow}
|
||||||
style={inPagerMode ? { visibility: 'hidden' } : undefined}
|
style={inPagerMode ? { opacity: 0 } : undefined}
|
||||||
aria-hidden={inPagerMode || undefined}
|
aria-hidden={inPagerMode || undefined}
|
||||||
>
|
>
|
||||||
<div className={css.tabsCluster}>
|
<div className={css.tabsCluster}>
|
||||||
<Segment
|
<Segment
|
||||||
active={!!directMatch}
|
active={!!directMatch}
|
||||||
label={t('Direct.segment_dm')}
|
label={t('Direct.segment_dm')}
|
||||||
onClick={() => navigate(DIRECT_PATH, navOpts)}
|
onClick={onSegmentDirect}
|
||||||
/>
|
/>
|
||||||
<Segment
|
<Segment
|
||||||
active={!!channelsMatch}
|
active={!!channelsMatch}
|
||||||
label={t('Direct.segment_channels')}
|
label={t('Direct.segment_channels')}
|
||||||
onClick={() => navigate(CHANNELS_PATH, navOpts)}
|
onClick={onSegmentChannels}
|
||||||
/>
|
/>
|
||||||
{showBotsSegment && (
|
{showBotsSegment && (
|
||||||
<Segment
|
<Segment
|
||||||
active={!!botsMatch}
|
active={!!botsMatch}
|
||||||
label={t('Direct.segment_bots')}
|
label={t('Direct.segment_bots')}
|
||||||
onClick={() => navigate(BOTS_PATH, navOpts)}
|
onClick={onSegmentBots}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue