146 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|