Skip to content

Commit a3eb900

Browse files
committed
✨ Mobile menu component
1 parent 653750b commit a3eb900

17 files changed

+323
-27
lines changed

.storybook/config.js

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
import { configure } from '@storybook/react';
22

3+
import '../public/animations.css';
4+
35
configure(require.context('../', true, /\.stories\.tsx?$/), module);

src/app/home/home.component.tsx

+1-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import React from 'react';
2-
import { Navigation } from '../../ui/navigation/navigation.component';
32
import { Header } from './header/header.component';
43

54
interface TemplateThumnbail {
@@ -14,10 +13,5 @@ interface HomeProps {
1413
}
1514

1615
export const Home = () => {
17-
return (
18-
<>
19-
<Navigation />
20-
<Header />
21-
</>
22-
);
16+
return <Header />;
2317
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { MobileMenuActionType, TOGGLE_MENU } from './mobile-menu.reducer';
2+
3+
export const toggleMobileMenu = (): MobileMenuActionType => ({
4+
type: TOGGLE_MENU,
5+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import React from 'react';
2+
import { MobileMenuState, MobileMenuActionType } from './mobile-menu.reducer';
3+
4+
export type MobileMenuApi = {
5+
state: MobileMenuState;
6+
dispatch: React.Dispatch<MobileMenuActionType>;
7+
};
8+
9+
export const mobileMenuInitialState: MobileMenuState = {
10+
isVisible: false,
11+
};
12+
13+
export const MobileMenuContext = React.createContext<MobileMenuState | MobileMenuApi>(
14+
mobileMenuInitialState
15+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { AppAction } from '../../../types';
2+
3+
export const TOGGLE_MENU = 'mobile-menu/toggle';
4+
5+
export type MobileMenuActionType = AppAction<typeof TOGGLE_MENU>;
6+
7+
export interface MobileMenuState {
8+
isVisible: boolean;
9+
}
10+
11+
export const mobileMenuReducer = (
12+
state: MobileMenuState,
13+
action: MobileMenuActionType
14+
): MobileMenuState => {
15+
switch (action.type) {
16+
case TOGGLE_MENU:
17+
return {
18+
...state,
19+
isVisible: !state.isVisible,
20+
};
21+
22+
default:
23+
return state;
24+
}
25+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import React from 'react';
2+
import {
3+
MobileMenuContext,
4+
MobileMenuApi,
5+
} from '../../context/mobile-menu/mobile-menu.context';
6+
7+
export const useMobileMenu = () => {
8+
const context = React.useContext(MobileMenuContext) as MobileMenuApi;
9+
10+
if (!context) {
11+
throw new Error('useMobieMenu should be used within MobileMenuProvider');
12+
}
13+
14+
return context;
15+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import React from 'react';
2+
3+
export enum ScrollDirection {
4+
UP = 'up',
5+
DOWN = 'down',
6+
}
7+
8+
interface UseStickyNavProps {
9+
initialDirection?: ScrollDirection;
10+
thresholdPixels?: number;
11+
stickyRef: React.RefObject<HTMLElement>;
12+
}
13+
14+
export const useStickyNav = ({
15+
initialDirection = ScrollDirection.DOWN,
16+
thresholdPixels,
17+
stickyRef,
18+
}: UseStickyNavProps) => {
19+
const [scrollDirection, setScrollDirection] = React.useState<ScrollDirection>(
20+
initialDirection
21+
);
22+
const [isSticky, setSticky] = React.useState(false);
23+
24+
React.useEffect(() => {
25+
const threshold = thresholdPixels ?? 0;
26+
27+
let lastScrollPositon = 0;
28+
let ticking = false;
29+
30+
const updateScrollDirection = () => {
31+
if (Math.abs(window.pageYOffset - lastScrollPositon) < threshold) {
32+
ticking = false;
33+
return;
34+
}
35+
36+
setScrollDirection(
37+
window.pageYOffset > lastScrollPositon ? ScrollDirection.DOWN : ScrollDirection.UP
38+
);
39+
40+
setSticky(
41+
stickyRef.current &&
42+
window.pageYOffset > stickyRef.current.getBoundingClientRect().top
43+
? true
44+
: false
45+
);
46+
47+
lastScrollPositon = window.pageYOffset > 0 ? window.pageYOffset : 0;
48+
ticking = false;
49+
};
50+
51+
const handleScroll = () => {
52+
if (!ticking) {
53+
window.requestAnimationFrame(updateScrollDirection);
54+
ticking = true;
55+
}
56+
};
57+
58+
window.addEventListener('scroll', handleScroll);
59+
60+
return () => {
61+
window.removeEventListener('scroll', handleScroll);
62+
};
63+
}, [initialDirection, thresholdPixels, stickyRef]);
64+
65+
return scrollDirection === ScrollDirection.UP && isSticky;
66+
};

src/pages/_app.tsx

+4-11
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,10 @@
11
import React from 'react';
22
import NextApp from 'next/app';
33
import Head from 'next/head';
4-
import styled from 'styled-components';
5-
6-
import '../../.css/antd.less';
7-
84
import { ThemeProvider } from '../providers/theme.provider';
95

10-
const AppContainer = styled.div`
11-
max-width: 1620px;
12-
margin: 0 auto;
13-
padding: 10px 20px;
14-
`;
6+
import '../../.css/antd.less';
7+
import { LayoutProvider } from '../providers/layout.provider';
158

169
export default class App extends NextApp {
1710
componentDidMount() {
@@ -35,9 +28,9 @@ export default class App extends NextApp {
3528
/>
3629
</Head>
3730
<ThemeProvider>
38-
<AppContainer>
31+
<LayoutProvider>
3932
<Component {...pageProps} key={router.route} />
40-
</AppContainer>
33+
</LayoutProvider>
4134
</ThemeProvider>
4235
</>
4336
);

src/providers/layout.provider.tsx

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import React from 'react';
2+
import styled from 'styled-components';
3+
import { useMobileMenu } from '../hooks/use-mobile-menu/use-mobile-menu.hook';
4+
import { theme } from '../theme/theme-config';
5+
import { Navigation } from '../ui/navigation/navigation.component';
6+
import { useStickyNav } from '../hooks/use-sticky-nav/use-sticky-nav.hook';
7+
import { MobileMenu } from '../ui/mobile-menu/mobile-menu.component';
8+
9+
const AppContainer = styled.div`
10+
max-width: 1620px;
11+
margin: 0 auto;
12+
padding: 10px 20px;
13+
min-height: 100vh;
14+
background-color: ${({ theme }) => theme.color.background};
15+
`;
16+
17+
export const LayoutProvider = ({ children }: { children: React.ReactNode }) => {
18+
const stickyRef = React.useRef(null);
19+
20+
const isStickyNavigation = useStickyNav({ stickyRef });
21+
22+
const {
23+
state: { isVisible: isMobileMenuVisible },
24+
} = useMobileMenu();
25+
26+
return (
27+
<AppContainer>
28+
<style jsx global>
29+
{`
30+
body {
31+
${isMobileMenuVisible && 'overflow: hidden;'}
32+
}
33+
`}
34+
</style>
35+
{isStickyNavigation && (
36+
<div
37+
style={{
38+
height: '35px',
39+
}}
40+
></div>
41+
)}
42+
<div
43+
ref={stickyRef}
44+
style={{
45+
position: isStickyNavigation ? 'sticky' : 'initial',
46+
top: '0',
47+
zIndex: 9999,
48+
...(isStickyNavigation && { padding: '10px 0' }),
49+
background: theme.color.background,
50+
}}
51+
>
52+
<Navigation />
53+
</div>
54+
{children}
55+
{isMobileMenuVisible && <MobileMenu />}
56+
</AppContainer>
57+
);
58+
};
+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import React from 'react';
2+
import {
3+
MobileMenuContext,
4+
mobileMenuInitialState,
5+
} from '../context/mobile-menu/mobile-menu.context';
6+
import {
7+
MobileMenuState,
8+
mobileMenuReducer,
9+
MobileMenuActionType,
10+
} from '../context/mobile-menu/mobile-menu.reducer';
11+
12+
export const MobileMenuProvider = ({ children }: { children: React.ReactNode }) => {
13+
const [state, dispatch] = React.useReducer<
14+
React.Reducer<MobileMenuState, MobileMenuActionType>
15+
>(mobileMenuReducer, mobileMenuInitialState);
16+
17+
return (
18+
<MobileMenuContext.Provider
19+
value={{
20+
state,
21+
dispatch,
22+
}}
23+
>
24+
{children}
25+
</MobileMenuContext.Provider>
26+
);
27+
};

src/providers/theme.provider.tsx

+8-5
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import { AnimatePresence } from 'framer-motion';
44
import { theme } from '../theme/theme-config';
55
import { ResetStyles } from '../theme/reset-styles';
66
import { GlobalStyles } from '../theme/global-styles';
7+
import { MobileMenuProvider } from './mobile-menu.provider';
78

89
export const ThemeProvider = ({ children }: { children: React.ReactNode }) => (
910
<AnimatePresence exitBeforeEnter>
10-
<StyledThemeProvider theme={theme}>
11-
<ResetStyles />
12-
<GlobalStyles />
13-
{children}
14-
</StyledThemeProvider>
11+
<MobileMenuProvider>
12+
<StyledThemeProvider theme={theme}>
13+
<ResetStyles />
14+
<GlobalStyles />
15+
{children}
16+
</StyledThemeProvider>
17+
</MobileMenuProvider>
1518
</AnimatePresence>
1619
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import React from 'react';
2+
import Link from 'next/link';
3+
import { AppRoute } from '../../router/app-routes';
4+
import { Navigation } from '../navigation/navigation.component';
5+
import { StyledNav, StyledList, StyledListItem, StyledLink } from './mobile-menu.styles';
6+
7+
export const MobileMenu = () => {
8+
return (
9+
<StyledNav>
10+
<Navigation />
11+
<StyledList>
12+
<StyledListItem>
13+
<Link href={AppRoute.HOME}>
14+
<StyledLink>HOME</StyledLink>
15+
</Link>
16+
</StyledListItem>
17+
<StyledListItem>
18+
<Link href={AppRoute.ABOUT}>
19+
<StyledLink>ABOUT</StyledLink>
20+
</Link>
21+
</StyledListItem>
22+
<StyledListItem>
23+
<Link href={AppRoute.HOW_TO_CONTRIBUTE}>
24+
<StyledLink>HOW TO CONTRIBUTE</StyledLink>
25+
</Link>
26+
</StyledListItem>
27+
</StyledList>
28+
</StyledNav>
29+
);
30+
};
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import React from 'react';
2+
import { MobileMenu } from './mobile-menu.component';
3+
import { ThemeProvider } from '../../providers/theme.provider';
4+
5+
export default {
6+
title: 'Mobile Menu',
7+
component: MobileMenu,
8+
decorators: [(story) => <ThemeProvider>{story()}</ThemeProvider>],
9+
};
10+
11+
export const Default = () => <MobileMenu />;
+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import styled from 'styled-components';
2+
3+
export const StyledNav = styled.nav`
4+
position: fixed;
5+
top: 0;
6+
left: 0;
7+
background-color: white;
8+
height: 100vh;
9+
width: 100%;
10+
padding: 10px 20px;
11+
z-index: 9999;
12+
`;
13+
14+
export const StyledList = styled.ul`
15+
list-style: none;
16+
17+
& > li:first-child {
18+
border-top: 1px solid ${({ theme }) => theme.color.gray400};
19+
20+
margin-top: 30px;
21+
}
22+
`;
23+
24+
export const StyledListItem = styled.li`
25+
border-bottom: 1px solid ${({ theme }) => theme.color.gray400};
26+
padding: 10px 0;
27+
`;
28+
29+
export const StyledLink = styled.a`
30+
cursor: pointer;
31+
font-weight: 500;
32+
font-size: 21px;
33+
`;

0 commit comments

Comments
 (0)