vojo/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx

146 lines
5.6 KiB
TypeScript

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 (
<div className={css.pagerStaticHeader}>
<div className={streamHeaderCss.tabsRow}>
<div className={streamHeaderCss.tabsCluster}>
<Segment
active={activeTab === 'direct'}
label={t('Direct.segment_dm')}
onClick={onSelectDirect}
/>
<Segment
active={activeTab === 'channels'}
label={t('Direct.segment_channels')}
onClick={onSelectChannels}
/>
{showBots && (
<Segment
active={activeTab === 'bots'}
label={t('Direct.segment_bots')}
onClick={onSelectBots}
/>
)}
</div>
<Box grow="Yes" />
{isFormActive ? (
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={closeForm}
aria-label={t('Direct.close')}
aria-controls={INLINE_FORM_ID}
aria-expanded
disabled={iconsDisabled}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
) : (
<div className={streamHeaderCss.iconsCluster}>
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={openChat}
aria-label={t('Direct.create_chat')}
aria-controls={INLINE_FORM_ID}
aria-expanded={false}
aria-haspopup="dialog"
disabled={iconsDisabled}
>
<Icon size="100" src={Icons.Plus} />
</IconButton>
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={openSearch}
aria-label={t('Search.search')}
aria-controls={INLINE_FORM_ID}
aria-expanded={false}
aria-haspopup="dialog"
disabled={iconsDisabled}
>
<Icon size="100" src={Icons.Search} />
</IconButton>
</div>
)}
</div>
</div>
);
}