Skip to content

Commit eeef331

Browse files
authored
Mobile nav (#55)
* Rough implementation of nav for tablet and mobile; still needs a11y fixes and cleanup * Organize code for site header * Setup refs to help manage focus for compact nav * Prevent focus from escaping nav overlay when open * Close overlay menu if open and screen size changes from compact to standard * Backwards compatibility fix for versions before Safari 14
1 parent 5f63ea3 commit eeef331

File tree

10 files changed

+379
-31
lines changed

10 files changed

+379
-31
lines changed

components/site-header/compact.js

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { rem } from 'polished'
2+
import { bool, func, string } from 'prop-types'
3+
import React, { useEffect, useState } from 'react'
4+
import ReactDOM from 'react-dom'
5+
import styled, { css } from 'styled-components'
6+
import { usePrevious } from 'helpers/hooks'
7+
import HamburgerMenu from './hamburger-menu'
8+
import Links from './links'
9+
import Logo from './logo'
10+
11+
const NAV_ID = 'site-navigation-small'
12+
13+
const NavOverlayRoot = styled.div`
14+
position: fixed;
15+
display: none;
16+
width: 100%;
17+
height: 0px;
18+
top: 0;
19+
left: 0;
20+
right: 0;
21+
bottom: 0;
22+
background-color: #000000;
23+
color: #ffffff;
24+
flex-direction: column;
25+
align-items: center;
26+
cursor: pointer;
27+
28+
${(p) =>
29+
p.isOpen &&
30+
css`
31+
display: flex;
32+
height: 100%;
33+
`}
34+
35+
@media only screen and (min-width: 576px) {
36+
display: none;
37+
height: 0px;
38+
}
39+
`
40+
41+
const FauxHeader = styled.div`
42+
display: flex;
43+
justify-content: space-between;
44+
width: 100%;
45+
padding: ${rem('32px')} ${rem('42px')} 0;
46+
`
47+
48+
const Nav = styled.nav`
49+
display: flex;
50+
flex-direction: column;
51+
justify-content: center;
52+
align-items: flex-start;
53+
height: 100%;
54+
width: 100%;
55+
56+
& a {
57+
color: #fff;
58+
text-decoration: none;
59+
margin-top: ${rem('8px')};
60+
font-size: ${rem('36px')};
61+
line-height: ${rem('60px')};
62+
63+
&:first-of-type {
64+
margin-top: 16px;
65+
}
66+
}
67+
`
68+
69+
const NavOverlay = React.forwardRef(
70+
({ id, isOpen, closeMenu, ...props }, ref) => {
71+
// Must check for process.browser; Without this, document is undefined because document is not available when nextjs renders this server side
72+
if (process.browser) {
73+
return ReactDOM.createPortal(
74+
<NavOverlayRoot id={id} isOpen={isOpen} {...props}>
75+
<FauxHeader>
76+
<Logo />
77+
<HamburgerMenu
78+
isOpen={isOpen}
79+
handleClick={closeMenu}
80+
ariaControlsId={id}
81+
ref={ref}
82+
className="close"
83+
/>
84+
</FauxHeader>
85+
<Nav>
86+
<Links />
87+
</Nav>
88+
</NavOverlayRoot>,
89+
document.querySelector('body'),
90+
)
91+
}
92+
93+
return null
94+
},
95+
)
96+
97+
NavOverlay.propTypes = {
98+
id: string,
99+
isOpen: bool,
100+
closeMenu: func,
101+
}
102+
103+
const Compact = () => {
104+
const [isOpen, setIsOpen] = useState(false)
105+
const [isCompactScreen, setIsCompactScreen] = useState(false)
106+
const OpenButtonRef = React.createRef()
107+
const CloseButtonRef = React.createRef()
108+
const prevIsOpen = usePrevious(isOpen)
109+
const prevIsCompactScreen = usePrevious(isCompactScreen)
110+
111+
const openMenu = () => {
112+
if (process.browser) {
113+
const Root = document.getElementById('__next')
114+
Root.setAttribute('hidden', 'true')
115+
}
116+
setIsOpen(true)
117+
}
118+
119+
const closeMenu = () => {
120+
if (process.browser) {
121+
const Root = document.getElementById('__next')
122+
Root.removeAttribute('hidden')
123+
}
124+
setIsOpen(false)
125+
}
126+
127+
// handle focus transfer between buttons when opening and closing
128+
useEffect(() => {
129+
if (prevIsOpen === undefined || prevIsOpen === isOpen) {
130+
// first render or update triggered that does not update isOpen state
131+
return
132+
}
133+
134+
if (isOpen) {
135+
CloseButtonRef.current && CloseButtonRef.current.focus()
136+
} else {
137+
OpenButtonRef.current && OpenButtonRef.current.focus()
138+
}
139+
}, [isOpen])
140+
141+
// setup listener for screen size changes
142+
useEffect(() => {
143+
const mediaWatcher = window.matchMedia('(max-width: 575px)')
144+
setIsCompactScreen(mediaWatcher.matches)
145+
146+
const handleSizeChange = (e) => {
147+
setIsCompactScreen(e.matches)
148+
}
149+
150+
if (mediaWatcher.addEventListener) {
151+
mediaWatcher.addEventListener('change', handleSizeChange)
152+
return function cleanup() {
153+
mediaWatcher.removeEventListener('change', handleSizeChange)
154+
}
155+
}
156+
157+
// Backwards compatibility for Safari versions prior to 14. See
158+
// https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList/addListener#browser_compatibility for details
159+
mediaWatcher.addListener(handleSizeChange)
160+
return function cleanup() {
161+
mediaWatcher.removeListener(handleSizeChange)
162+
}
163+
})
164+
165+
// if screen size changes from compact to standard when overlay is open, close overlay
166+
useEffect(() => {
167+
if (
168+
prevIsCompactScreen === undefined ||
169+
prevIsCompactScreen === isCompactScreen
170+
) {
171+
return
172+
}
173+
174+
if (!isCompactScreen && isOpen) {
175+
closeMenu()
176+
}
177+
}, [isCompactScreen])
178+
179+
return (
180+
<>
181+
<HamburgerMenu
182+
isOpen={isOpen}
183+
handleClick={openMenu}
184+
ariaControlsId={NAV_ID}
185+
ref={OpenButtonRef}
186+
className="open"
187+
/>
188+
<NavOverlay
189+
id={NAV_ID}
190+
isOpen={isOpen}
191+
closeMenu={closeMenu}
192+
ref={CloseButtonRef}
193+
/>
194+
</>
195+
)
196+
}
197+
198+
export default Compact
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { bool, func, string } from 'prop-types'
2+
import React from 'react'
3+
import styled, { css } from 'styled-components'
4+
5+
const NOOP = () => {}
6+
7+
const HamburgerButton = styled.button`
8+
display: none;
9+
position: relative;
10+
background: transparent;
11+
border: none;
12+
cursor: pointer;
13+
width: 48px;
14+
height: 48px;
15+
16+
@media only screen and (max-width: 575px) {
17+
display: inline-block;
18+
align-self: flex-end;
19+
}
20+
`
21+
22+
const HamburgerLines = styled.span`
23+
margin: 4px;
24+
top: 50%;
25+
display: block;
26+
margin-top: -1px;
27+
28+
&,
29+
&:after,
30+
&:before {
31+
position: absolute;
32+
width: 40px;
33+
height: 3px;
34+
border-radius: 3px;
35+
background-color: #ffffff;
36+
left: 0;
37+
}
38+
39+
&:after {
40+
bottom: -12px;
41+
content: '';
42+
}
43+
44+
&:before {
45+
top: -12px;
46+
content: '';
47+
}
48+
49+
${(p) =>
50+
p.active &&
51+
css`
52+
transform: rotate(45deg);
53+
54+
&:after {
55+
bottom: 0;
56+
transform: rotate(90deg);
57+
}
58+
59+
&:before {
60+
top: 0;
61+
opacity: 0;
62+
}
63+
`}
64+
`
65+
66+
const HamburgerMenu = React.forwardRef(
67+
({ isOpen, handleClick = NOOP, ariaControlsId, ...props }, ref) => (
68+
<HamburgerButton
69+
type="button"
70+
aria-label="menu"
71+
aria-controls={ariaControlsId}
72+
onClick={handleClick}
73+
ref={ref}
74+
{...props}
75+
>
76+
<HamburgerLines active={isOpen} />
77+
</HamburgerButton>
78+
),
79+
)
80+
81+
HamburgerMenu.propTypes = {
82+
isOpen: bool,
83+
handleClick: func,
84+
ariaControlsId: string,
85+
}
86+
87+
export default HamburgerMenu

components/site-header/index.js

Lines changed: 11 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,8 @@
11
import { rem } from 'polished'
22
import styled from 'styled-components'
3-
4-
const SiteHeader = () => (
5-
<StyledHeader>
6-
<img className="logo" src="/commit-logo.svg" alt="Commit Logo" />
7-
<nav>
8-
<a
9-
className="nav-link"
10-
href="https://blog.commit.dev/"
11-
target="_blank"
12-
rel="noreferrer"
13-
>
14-
Blog
15-
</a>
16-
</nav>
17-
</StyledHeader>
18-
)
3+
import CompactMenu from './compact'
4+
import Logo from './logo'
5+
import StandardMenu from './standard'
196

207
const StyledHeader = styled.header`
218
position: sticky;
@@ -34,18 +21,14 @@ const StyledHeader = styled.header`
3421
@media only screen and (max-width: 575px) {
3522
margin: ${rem('32px')} ${rem('42px')} 0;
3623
}
37-
38-
.logo {
39-
width: ${rem('212px')};
40-
@media only screen and (max-width: 575px) {
41-
width: ${rem('148px')};
42-
}
43-
}
44-
45-
.nav-link {
46-
color: #fff;
47-
text-decoration: none;
48-
}
4924
`
5025

26+
const SiteHeader = () => (
27+
<StyledHeader>
28+
<Logo />
29+
<StandardMenu />
30+
<CompactMenu />
31+
</StyledHeader>
32+
)
33+
5134
export default SiteHeader

components/site-header/links.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { rem } from 'polished'
2+
import styled from 'styled-components'
3+
4+
const Link = styled.a`
5+
color: #fff;
6+
text-decoration: none;
7+
margin-left: ${rem('32px')};
8+
`
9+
10+
// TODO: update links once pages exist
11+
const Standard = () => (
12+
<>
13+
<Link href="https://blog.commit.dev/" target="_blank" rel="noreferrer">
14+
About
15+
</Link>
16+
<Link href="https://blog.commit.dev/" target="_blank" rel="noreferrer">
17+
Blog
18+
</Link>
19+
<Link href="https://blog.commit.dev/" target="_blank" rel="noreferrer">
20+
Startups
21+
</Link>
22+
</>
23+
)
24+
25+
export default Standard

components/site-header/logo.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { rem } from 'polished'
2+
import styled from 'styled-components'
3+
4+
const LogoImg = styled.img`
5+
width: ${rem('212px')};
6+
7+
@media only screen and (max-width: 575px) {
8+
display: none;
9+
}
10+
`
11+
12+
const SmallLogoImg = styled.img`
13+
@media only screen and (min-width: 576px) {
14+
display: none;
15+
}
16+
`
17+
18+
const Logo = () => (
19+
<>
20+
<LogoImg src="/commit-logo.svg" alt="Commit Logo" />
21+
<SmallLogoImg src="/commit-logo-small.svg" alt="Commit Logo" />
22+
</>
23+
)
24+
25+
export default Logo

0 commit comments

Comments
 (0)