Files
baya-monorepo/client/src/layout/TopBarAndSideBarLayout.tsx
T
2026-07-19 21:31:59 +03:30

105 lines
3.9 KiB
TypeScript

'use client';
import { FunctionComponent, PropsWithChildren, ReactNode, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Box, Stack, useTheme } from '@mui/material';
import { AppIconButton, ErrorBoundary, RouteFadeIn } from '@/components';
import { LinkToPage } from '@/utils';
import { TopBar } from './components';
import SideBar from './components/SideBar';
import { DarkModeToggleButton } from './components/DarkModeButton';
import { TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT } from './config';
import { useRouteTitle } from './routeTitle';
interface Props {
sidebarItems: Array<LinkToPage>;
/** Rendered below the sidebar's brand header (the nurse shell's `ProfileSummary` identity card). */
sidebarIdentity?: ReactNode;
/** Rendered in the TopBar, before the header controls (the admin/partner identity chip). */
identity?: ReactNode;
/** Extra chrome actions (e.g. the notification bell) rendered next to the dark-mode toggle. */
headerActions?: ReactNode;
/**
* A mobile-only (`<md`) bottom nav rendered under the content column (the nurse shell's 5-tab
* bar). Receives a callback that opens the same mobile drawer the TopBar's menu button opens, so
* a "more" tab can reveal the rest of the sidebar without a second drawer.
*/
mobileBottomBar?: (openMobileSidebar: () => void) => ReactNode;
}
/**
* The shared engine behind the nurse/admin/partner shells: a fixed `TopBar` showing the current
* page title (`useRouteTitle`, longest-prefix over `ROUTES.*`) plus a `SideBar` that renders as a
* mobile overlay and a desktop in-flow column at once (see `SideBar` for why that kills the SSR
* flash). The sidebar and the main column are flex siblings in a row — native RTL flexbox puts the
* sidebar at the reading-start side with no manual offset math.
* @layout TopBarAndSideBarLayout
*/
const TopBarAndSideBarLayout: FunctionComponent<PropsWithChildren<Props>> = ({
children,
sidebarItems,
sidebarIdentity,
identity,
headerActions,
mobileBottomBar,
}) => {
const tChrome = useTranslations('routeChrome');
const tCommon = useTranslations('common');
const theme = useTheme();
const title = useRouteTitle();
const [mobileOpen, setMobileOpen] = useState(false);
const anchor = theme.direction === 'rtl' ? 'right' : 'left';
const headerControls = (
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.5 }}>
{identity}
{headerActions}
{/* DarkModeToggleButton subscribes to useColorScheme() on its own — this layout never re-renders on a theme flip */}
<DarkModeToggleButton />
</Stack>
);
return (
<Stack sx={{ minHeight: '100vh' }}>
<TopBar
startNode={
<AppIconButton
icon="menu"
title={tCommon('open_sidebar')}
onClick={() => setMobileOpen(true)}
sx={{ display: { xs: 'inline-flex', md: 'none' } }}
/>
}
title={title ?? ''}
align="start"
endNode={headerControls}
/>
<Stack direction="row" sx={{ flexGrow: 1, pt: { xs: TOP_BAR_MOBILE_HEIGHT, md: TOP_BAR_DESKTOP_HEIGHT } }}>
<SideBar
items={sidebarItems}
identity={sidebarIdentity}
anchor={anchor}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
<Stack component="main" sx={{ flexGrow: 1, minWidth: 0, padding: 2 }}>
<ErrorBoundary
name="Content"
title={tChrome('error_title')}
body={tChrome('error_body')}
retryLabel={tChrome('error_retry')}
>
<RouteFadeIn>{children}</RouteFadeIn>
</ErrorBoundary>
</Stack>
</Stack>
{mobileBottomBar && <Box sx={{ display: { xs: 'block', md: 'none' } }}>{mobileBottomBar(() => setMobileOpen(true))}</Box>}
</Stack>
);
};
export default TopBarAndSideBarLayout;