feat: add animation for pages

This commit is contained in:
Sergey Krylov 2024-07-05 05:38:22 +03:00
parent 50c7f4f76c
commit 5313c5c134
27 changed files with 369 additions and 124 deletions

View File

@ -99,6 +99,11 @@
"**/.eslintrc.[j|t]s"
],
"optionalDependencies": false
}],
"react/function-component-definition": [
"error", {
"namedComponents": "arrow-function",
"unnamedComponents": "arrow-function"
}]
}
}

View File

@ -36,6 +36,7 @@
"@emotion/styled": "11.11.5",
"@mui/icons-material": "^5.15.19",
"@mui/material": "5.15.19",
"@react-spring/web": "9.7.3",
"classnames": "2.5.1",
"notistack": "3.0.1",
"prop-types": "15.8.1",

View File

@ -1,11 +1,10 @@
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted';
import PieChartIcon from '@mui/icons-material/PieChart';
import { CircularProgress } from '@mui/material';
import CircularProgress from '@mui/material/CircularProgress';
import { Suspense } from 'react';
import { createBrowserRouter, Navigate, RouterProvider as RRDRouterProvider } from 'react-router-dom';
// todo lazy & access
import { AnalyzePage, AnalyzePageToolbar } from 'pages/AnalyzePage';
import { ErrorPage, ErrorPageToolbar } from 'pages/ErrorPage';
import { PaymentsPage, PaymentsPageToolbar } from 'pages/PaymentsPage';

View File

@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { SchedulePage, SchedulePageToolbar } from 'pages/SchedulePage';
@ -7,12 +7,15 @@ describe('Test SchedulePage', () => {
<SchedulePage data-testid="schedule-page" />,
);
test('should render correctly', () => {
test('should render correctly', async () => {
setup();
const pageElem = screen.getByTestId('schedule-page');
expect(pageElem).toBeVisible();
expect(pageElem).toHaveTextContent('Schedule Page');
await waitFor(() => {
const pageElem = screen.getByTestId('schedule-page');
expect(pageElem).toBeVisible();
// eslint-disable-next-line testing-library/no-wait-for-multiple-assertions
expect(pageElem).toHaveTextContent('Schedule Page');
});
});
});

View File

@ -1,8 +1,5 @@
// todo fix eslint
import type { FC, PropsWithChildren } from 'react';
// eslint-disable-next-line react/function-component-definition
const withProvider = <T,>(Provider: FC<PropsWithChildren>) => (Component: FC<T>) => (...params: T[]) => (
<Provider>
{/* @ts-ignore */}

View File

@ -1,7 +1,7 @@
import { renderWithProvider } from '__spec__/custon-renders';
import PieChartIcon from '@mui/icons-material/PieChart';
import { fireEvent, screen } from '@testing-library/react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import AsideMenuLayout from './AsideMenuLayout.ui';
@ -41,10 +41,6 @@ describe('Test AsideMenuLayout', () => {
<AsideMenuLayout
Content={<Content />}
data-testid="aside-menu-layout"
DrawerSwitcherProps={{
// @ts-ignore
'data-testid': 'drawer-switcher',
}}
pages={pages}
Toolbar={<Toolbar />}
/>,
@ -58,18 +54,22 @@ describe('Test AsideMenuLayout', () => {
expect(layoutElem).toBeVisible();
});
test('should render toolbar', () => {
test('should render toolbar', async () => {
setup();
const toolbarElem = screen.getByText('Test Toolbar');
expect(toolbarElem).toBeVisible();
await waitFor(() => {
const toolbarElem = screen.getByText('Test Toolbar');
expect(toolbarElem).toBeVisible();
});
});
test('should render content', () => {
test('should render content', async () => {
setup();
const toolbarElem = screen.getByText('Test Content');
expect(toolbarElem).toBeVisible();
await waitFor(() => {
const toolbarElem = screen.getByText('Test Content');
expect(toolbarElem).toBeVisible();
});
});
test('should render navigation routes', () => {

View File

@ -1,104 +1,137 @@
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import MenuIcon from '@mui/icons-material/Menu';
import { useMediaQuery } from '@mui/material';
import Box from '@mui/material/Box';
import CssBaseline from '@mui/material/CssBaseline';
import Divider from '@mui/material/Divider';
import List from '@mui/material/List';
import { useState } from 'react';
import { useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import { animated, useTransition } from '@react-spring/web';
import { useContext } from 'react';
import { useLocation } from 'react-router-dom';
import AppBar from './components/AppBar';
import Drawer from './components/Drawer';
import DrawerHeader from './components/DrawerMenu';
import DrawerSwitcher from './components/DrawerSwitcher';
import MenuItem from './components/MenuItem/MenuItem.ui';
import { DesktopAppBar, MobileAppBar } from './components/AppBar';
import { AsideMenuContext, AsideMenuProvider } from './components/AsideMenuProvider';
import { DesktopDrawer } from './components/DesktopDrawer';
import { MenuItem } from './components/MenuItem';
import { MobileDrawer } from './components/MobileDrawer';
import type { MenuItemProps } from './components/MenuItem/MenuItem.ui';
import type { MenuItemProps } from './components/MenuItem/ui/MenuItem.ui';
import type { BoxProps } from '@mui/material/Box/Box';
import type {
FC,
ReactNode,
DetailedHTMLProps,
HTMLAttributes,
} from 'react';
import type { FC, ReactNode } from 'react';
const drawerWidth = 250;
export const DESKTOP_DRAWER_WIDTH = 250;
export const MOBILE_DRAWER_WIDTH = 250;
type AsideMenuLayoutProps = BoxProps & {
export type AsideMenuLayoutProps = BoxProps & {
pages: MenuItemProps['page'][];
Content: ReactNode;
Toolbar?: ReactNode;
DrawerSwitcherProps?: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
};
const AsideMenuLayout: FC<AsideMenuLayoutProps> = (props) => {
const {
pages,
Content,
Toolbar,
DrawerSwitcherProps,
...restProps
} = props;
const [isOpen, setIsOpen] = useState(true);
const { desktopDrawerOpen, setMobileDrawerOpen } = useContext(AsideMenuContext);
const theme = useTheme();
const isMobileView = useMediaQuery(theme.breakpoints.down('sm'));
const handleDrawerOpen = () => {
setIsOpen(true);
};
const handleDrawerClose = () => {
setIsOpen(false);
};
const location = useLocation();
const transitions = useTransition(location, {
from: { opacity: 0 },
enter: { opacity: 1 },
leave: { opacity: 0 },
unique: true,
reset: true,
});
const AnimatedContent = (
<>
{transitions((styles) => (
<animated.div style={styles}>
{Content}
</animated.div>
))}
</>
);
const AnimatedToolBar = (
<>
{transitions((styles) => (
<animated.div style={styles}>
{Toolbar}
</animated.div>
))}
</>
);
if (isMobileView) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column' }} {...restProps}>
{
Boolean(Toolbar) && (
<MobileAppBar position="static">
<MobileDrawer width={MOBILE_DRAWER_WIDTH}>
<List>
{pages.map((page) => (
<MenuItem
key={page.path}
page={page}
onClick={() => { setMobileDrawerOpen(false); }}
/>
))}
</List>
</MobileDrawer>
{AnimatedToolBar}
</MobileAppBar>
)
}
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
{AnimatedContent}
</Box>
</Box>
);
}
return (
<Box sx={{ display: 'flex' }} {...restProps}>
<CssBaseline />
<Drawer
drawerWidth={drawerWidth}
open={isOpen}
variant="permanent"
>
<DrawerHeader>
<DrawerSwitcher
open={isOpen}
onClick={isOpen ? handleDrawerClose : handleDrawerOpen}
{...DrawerSwitcherProps}
>
{ isOpen ? <ChevronLeftIcon /> : <MenuIcon /> }
</DrawerSwitcher>
</DrawerHeader>
<Divider />
<DesktopDrawer width={DESKTOP_DRAWER_WIDTH}>
<List>
{pages.map((page) => (
<MenuItem key={page.path} isOpen={isOpen} page={page} />
))}
desktopDrawerOpen
? (
<MenuItem key={page.path} isOpen={desktopDrawerOpen} page={page} />
)
: (
<Tooltip key={page.path} placement="right" title={page.title}>
<MenuItem isOpen={desktopDrawerOpen} page={page} />
</Tooltip>
)))}
</List>
</Drawer>
</DesktopDrawer>
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
{
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
Toolbar
? (
<>
<AppBar
drawerWidth={drawerWidth}
open={isOpen}
position="fixed"
>
{Toolbar}
</AppBar>
<Box sx={{ flexGrow: 1 }}>
{ Boolean(Toolbar) && (
<DesktopAppBar position="static">
{AnimatedToolBar}
</DesktopAppBar>
)}
<DrawerHeader />
</>
)
: null
}
{Content}
<Box component="main" sx={{ p: 3 }}>
{AnimatedContent}
</Box>
</Box>
</Box>
);
};
export default AsideMenuLayout;
const withProvider = (Component: FC<AsideMenuLayoutProps>) => (props: AsideMenuLayoutProps) => (
<AsideMenuProvider>
<Component {...props} />
</AsideMenuProvider>
);
export default withProvider(AsideMenuLayout);

View File

@ -1,30 +1,21 @@
import MuiAppBar from '@mui/material/AppBar';
import { styled } from '@mui/material/styles';
import type { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar/AppBar';
type AppBarProps = MuiAppBarProps & {
open?: boolean;
drawerWidth: number;
};
const AppBar = styled(MuiAppBar, { shouldForwardProp: (prop) => !['open', 'drawerWidth'].includes(prop as string) })<AppBarProps>(
({ theme, open, drawerWidth }) => ({
export const DesktopAppBar = styled(MuiAppBar)(
({ theme }) => ({
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
marginLeft: `calc(${theme.spacing(7)} + 1px)`,
width: `calc(100% - calc(${theme.spacing(7)} + 1px))`,
...Boolean(open) && {
marginLeft: drawerWidth,
width: `calc(100% - ${drawerWidth}px)`,
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
},
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
}),
);
export default AppBar;
export const MobileAppBar = styled(MuiAppBar)(() => ({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
padding: '0 24px',
}));

View File

@ -0,0 +1,2 @@
export { default as AsideMenuProvider } from './ui/AsideMenuProvider.ui';
export { AsideMenuContext } from './model/context';

View File

@ -0,0 +1,10 @@
import { createContext } from 'react';
export type AsideMenuContextType = {
desktopDrawerOpen: boolean;
setDesktopDrawerOpen: (isOpen: boolean) => void;
mobileDrawerOpen: boolean;
setMobileDrawerOpen: (isOpen: boolean) => void;
};
export const AsideMenuContext = createContext<AsideMenuContextType>({} as AsideMenuContextType);

View File

@ -0,0 +1,31 @@
import { useMemo, useState } from 'react';
import { AsideMenuContext, type AsideMenuContextType } from '../model/context';
import type { FC, ReactNode } from 'react';
type AsideMenuProviderProps = {
children: ReactNode;
};
const AsideMenuProvider: FC<AsideMenuProviderProps> = (props) => {
const { children } = props;
// todo desktop from default value localstorage
const [desktopDrawerOpen, setDesktopDrawerOpen] = useState(true);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const value = useMemo<AsideMenuContextType>(() => ({
desktopDrawerOpen,
setDesktopDrawerOpen,
mobileDrawerOpen,
setMobileDrawerOpen,
}), [desktopDrawerOpen, mobileDrawerOpen]);
return (
<AsideMenuContext.Provider value={value}>
{children}
</AsideMenuContext.Provider>
);
};
export default AsideMenuProvider;

View File

@ -0,0 +1,2 @@
export type { DesktopDrawerProps } from './ui/DesktopDrawer.ui';
export { default as DesktopDrawer } from './ui/DesktopDrawer.ui';

View File

@ -0,0 +1,53 @@
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import MenuIcon from '@mui/icons-material/Menu';
import Divider from '@mui/material/Divider';
import { useContext } from 'react';
import { AsideMenuContext } from '../../AsideMenuProvider';
import Drawer from './components/Drawer';
import DrawerHeader from './components/DrawerMenu';
import DrawerSwitcher from './components/DrawerSwitcher';
import type { FC, ReactNode } from 'react';
export type DesktopDrawerProps = {
width: number;
children: ReactNode;
};
const DesktopDrawer: FC<DesktopDrawerProps> = (props) => {
const {
width,
children,
} = props;
const { desktopDrawerOpen, setDesktopDrawerOpen } = useContext(AsideMenuContext);
const handleDrawerOpen = () => {
setDesktopDrawerOpen(true);
};
const handleDrawerClose = () => {
setDesktopDrawerOpen(false);
};
return (
<Drawer open={desktopDrawerOpen} variant="permanent" width={width}>
<DrawerHeader>
<DrawerSwitcher
data-testid="drawer-switcher"
open={desktopDrawerOpen}
onClick={desktopDrawerOpen ? handleDrawerClose : handleDrawerOpen}
>
{ desktopDrawerOpen ? <ChevronLeftIcon /> : <MenuIcon /> }
</DrawerSwitcher>
</DrawerHeader>
<Divider />
{children}
</Drawer>
);
};
export default DesktopDrawer;

View File

@ -3,11 +3,11 @@ import { type CSSObject, styled, type Theme } from '@mui/material/styles';
type DrawerProps = {
open: boolean;
drawerWidth: number;
width: number;
};
const openedMixin = (theme: Theme, { drawerWidth }: Pick<DrawerProps, 'drawerWidth'>): CSSObject => ({
width: drawerWidth,
const openedMixin = (theme: Theme, { width }: Pick<DrawerProps, 'width'>): CSSObject => ({
width,
transition: theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
@ -24,15 +24,15 @@ const closedMixin = (theme: Theme): CSSObject => ({
width: `calc(${theme.spacing(8)} + 1px)`,
});
const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => !['open', 'drawerWidth'].includes(prop as string) })<DrawerProps>(
({ theme, open, drawerWidth }) => ({
width: drawerWidth,
const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => !['open', 'width'].includes(prop as string) })<DrawerProps>(
({ theme, open, width }) => ({
width,
flexShrink: 0,
whiteSpace: 'nowrap',
boxSizing: 'border-box',
...open && {
...openedMixin(theme, { drawerWidth }),
'& .MuiDrawer-paper': openedMixin(theme, { drawerWidth }),
...openedMixin(theme, { width }),
'& .MuiDrawer-paper': openedMixin(theme, { width }),
},
...!open && {
...closedMixin(theme),

View File

@ -0,0 +1 @@
export { default as MenuItem } from './ui/MenuItem.ui';

View File

@ -4,10 +4,11 @@ import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import { styled, useTheme } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import { forwardRef } from 'react';
import { NavLink as RRDNavLink } from 'react-router-dom';
import type { ListItemProps } from '@mui/material/ListItem/ListItem';
import type { FC, ReactNode } from 'react';
import type { ReactNode } from 'react';
import type { NavLinkProps } from 'react-router-dom';
const NavLink = styled(RRDNavLink)(({ theme }) => ({
@ -26,7 +27,7 @@ export type MenuItemProps = ListItemProps & {
NavLinkProps?: NavLinkProps;
};
const MenuItem: FC<MenuItemProps> = (props) => {
const MenuItem = forwardRef<HTMLLIElement, MenuItemProps>((props, ref) => {
const {
page,
isOpen = true,
@ -36,7 +37,7 @@ const MenuItem: FC<MenuItemProps> = (props) => {
const theme = useTheme();
return (
<ListItem disablePadding {...restProps}>
<ListItem ref={ref} disablePadding {...restProps}>
<NavLink to={page.path} {...NavLinkProps}>
{({ isActive }) => (
<ListItemButton
@ -72,6 +73,6 @@ const MenuItem: FC<MenuItemProps> = (props) => {
</NavLink>
</ListItem>
);
};
});
export default MenuItem;

View File

@ -0,0 +1 @@
export { default as MobileDrawer } from './ui/MobileDrawer.ui';

View File

@ -0,0 +1,56 @@
import MenuIcon from '@mui/icons-material/Menu';
import MuiDrawer from '@mui/material/Drawer';
import IconButton from '@mui/material/IconButton';
import { useContext } from 'react';
import { AsideMenuContext } from '../../AsideMenuProvider';
import type { FC, ReactNode } from 'react';
type MobileDrawerProps = {
children: ReactNode;
width: number;
onOpen?: () => void;
onClose?: () => void;
};
const MobileDrawer: FC<MobileDrawerProps> = (props) => {
const {
children,
width,
onOpen,
onClose,
} = props;
const { mobileDrawerOpen, setMobileDrawerOpen } = useContext(AsideMenuContext);
const handleDrawerOpen = () => {
setMobileDrawerOpen(true);
onOpen?.();
};
const handleDrawerClose = () => {
setMobileDrawerOpen(false);
onClose?.();
};
return (
<>
<IconButton onClick={handleDrawerOpen}>
<MenuIcon style={{ fill: '#fff' }} />
</IconButton>
<MuiDrawer
open={mobileDrawerOpen}
sx={{ width }}
onClose={handleDrawerClose}
>
{children}
</MuiDrawer>
</>
);
};
export default MobileDrawer;

View File

@ -4496,6 +4496,64 @@ __metadata:
languageName: node
linkType: hard
"@react-spring/animated@npm:~9.7.3":
version: 9.7.3
resolution: "@react-spring/animated@npm:9.7.3"
dependencies:
"@react-spring/shared": "npm:~9.7.3"
"@react-spring/types": "npm:~9.7.3"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/5151da4fa7da010bb2edbee05871aa7a4aea8763fe617389d17605810aa0dd817374205e5fb3930b650f4a7f25fcdf23205fdfb7365686ff75888bdfd0b39839
languageName: node
linkType: hard
"@react-spring/core@npm:~9.7.3":
version: 9.7.3
resolution: "@react-spring/core@npm:9.7.3"
dependencies:
"@react-spring/animated": "npm:~9.7.3"
"@react-spring/shared": "npm:~9.7.3"
"@react-spring/types": "npm:~9.7.3"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/e28c05de8435bf2eaf8481f8acdf093909d4be9881a9a854c51dfac7c2d5562088d0fb2ce04e2f07e1b3bf621d8da3ab57bf6fedb4fdc954e3aa263bc1e393af
languageName: node
linkType: hard
"@react-spring/shared@npm:~9.7.3":
version: 9.7.3
resolution: "@react-spring/shared@npm:9.7.3"
dependencies:
"@react-spring/types": "npm:~9.7.3"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/afb03ed28ccf62efa4012e531c3659999bb364d1e0eb0fb729b962f9bd21a0b772a2d98e862062c9c32c06edf72327afcc45984d9eb22fdd961706b6ddf6950d
languageName: node
linkType: hard
"@react-spring/types@npm:~9.7.3":
version: 9.7.3
resolution: "@react-spring/types@npm:9.7.3"
checksum: 10c0/d645044f3cc9ceb7c4f6c4d061aaf6660018568a1553d05638f56b3328c5f91597ee4118334abe22fc8f07f5ee02f054340170c1d52e11b3faea22888b5170d4
languageName: node
linkType: hard
"@react-spring/web@npm:9.7.3":
version: 9.7.3
resolution: "@react-spring/web@npm:9.7.3"
dependencies:
"@react-spring/animated": "npm:~9.7.3"
"@react-spring/core": "npm:~9.7.3"
"@react-spring/shared": "npm:~9.7.3"
"@react-spring/types": "npm:~9.7.3"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/a5b4847a2921a29a3e8ce569f4951abeb268b6e8eb230f8c49d98709216b2b3a23ba1e58628c51c9eaa0bd20d83f9d7b8f8f03df98215e933de4c66c39e17fe1
languageName: node
linkType: hard
"@remix-run/router@npm:1.16.1":
version: 1.16.1
resolution: "@remix-run/router@npm:1.16.1"
@ -12874,6 +12932,7 @@ __metadata:
"@emotion/styled": "npm:11.11.5"
"@mui/icons-material": "npm:^5.15.19"
"@mui/material": "npm:5.15.19"
"@react-spring/web": "npm:9.7.3"
"@testing-library/jest-dom": "npm:6.4.2"
"@testing-library/react": "npm:14.3.1"
"@testing-library/user-event": "npm:^14.5.2"