Mobile Organized Content (#79)
continuous-integration/drone/push Build is passing Details

Co-authored-by: William Tran <william.tran1@uwaterloo.ca>
Co-authored-by: Aditya Thakral <a3thakra@csclub.uwaterloo.ca>
Reviewed-on: #79
Reviewed-by: Aditya Thakral <a3thakra@csclub.uwaterloo.ca>
Co-authored-by: w25tran <w25tran@localhost>
Co-committed-by: w25tran <w25tran@localhost>
This commit is contained in:
William Tran 2021-08-27 15:18:55 -04:00
parent 82c311e642
commit 4237da76e3
7 changed files with 277 additions and 16 deletions

View File

@ -49,7 +49,7 @@
} }
.selected { .selected {
background-color: var(--primary-accent-dim); background-color: var(--primary-accent-lightest);
color: var(--primary-accent); color: var(--primary-accent);
font-weight: 700; font-weight: 700;
} }
@ -124,7 +124,48 @@
text-decoration: none; text-decoration: none;
} }
.burger {
display: none;
}
.mobileNavTitle {
display: none;
}
@media only screen and (max-width: calc(768rem / 16)) { @media only screen and (max-width: calc(768rem / 16)) {
.burger {
display: flex;
position: fixed;
border: none;
bottom: calc(32rem / 16);
left: calc(16rem / 16);
width: calc(62rem / 16);
height: calc(57rem / 16);
cursor: pointer;
z-index: 9;
background: var(--primary-accent-light);
border-radius: calc(5rem / 16);
transition: transform 0.3s ease-in-out;
transform: translateY(calc(94rem / 16));
padding: calc(11rem / 16) calc(9rem / 16);
}
.burgerVisible {
transform: translateY(0);
}
.burger > svg {
width: 100%;
height: 100%;
stroke: var(--primary-accent);
}
.navItem {
width: auto;
padding: 0 calc(16rem / 16);
}
.content h1 { .content h1 {
font-size: calc(18rem / 16); font-size: calc(18rem / 16);
} }
@ -138,6 +179,52 @@
} }
.nav { .nav {
display: none; position: fixed;
top: 0;
left: 0;
overflow-y: auto;
z-index: 30;
margin: 0;
background: var(--primary-accent-lighter);
width: calc(288rem / 16);
transform: translateX(-100vw);
transition: 0.5s;
}
.navMobileBackground {
position: fixed;
visibility: hidden;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 20;
background-color: var(--navbar-page-overlay);
opacity: 0;
transition: 0.5s;
}
.mobileNavOpen {
transform: translateX(0);
}
.mobileNavTitle {
display: flex;
font-size: calc(24rem / 16);
font-weight: 700;
margin: calc(14rem / 16);
margin-top: calc(39rem / 16);
}
.show.navMobileBackground {
visibility: visible;
opacity: 100%;
} }
} }

View File

@ -1,5 +1,12 @@
import NextLink from "next/link"; import NextLink from "next/link";
import React, { ReactNode, ComponentType } from "react"; import React, {
ReactNode,
ComponentType,
useState,
useRef,
useEffect,
useCallback,
} from "react";
import styles from "./OrganizedContent.module.css"; import styles from "./OrganizedContent.module.css";
@ -17,6 +24,7 @@ interface Props {
sections: Section[]; sections: Section[];
id: string; id: string;
children: ReactNode; children: ReactNode;
pageTitle: string;
link: Link; link: Link;
} }
@ -24,8 +32,10 @@ export function OrganizedContent({
sections, sections,
id, id,
children, children,
pageTitle,
link: Link, link: Link,
}: Props) { }: Props) {
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const currentIndex = sections.findIndex( const currentIndex = sections.findIndex(
({ id: sectionId }) => sectionId === id ({ id: sectionId }) => sectionId === id
); );
@ -36,10 +46,34 @@ export function OrganizedContent({
const section = sections[currentIndex]; const section = sections[currentIndex];
const isReadAll = section.id === READ_ALL_ID; const isReadAll = section.id === READ_ALL_ID;
const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref.current);
const burgerVisible = useBurger(isVisible);
useEffect(() => {
mobileNavOpen
? (document.body.style.overflow = "hidden")
: (document.body.style.overflow = "visible");
}, [mobileNavOpen]);
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper} ref={ref}>
<Nav sections={sections} currentIndex={currentIndex} link={Link} /> <div
className={
mobileNavOpen
? `${styles.navMobileBackground} ${styles.show}`
: styles.navMobileBackground
}
onClick={() => setMobileNavOpen(false)}
/>
<Nav
sections={sections}
currentIndex={currentIndex}
link={Link}
pageTitle={pageTitle}
mobileNavOpen={mobileNavOpen}
setMobileNavOpen={setMobileNavOpen}
/>
<div className={styles.content}> <div className={styles.content}>
{isReadAll ? ( {isReadAll ? (
children children
@ -57,6 +91,14 @@ export function OrganizedContent({
</> </>
)} )}
</div> </div>
<button
className={`${styles.burger} ${
burgerVisible ? styles.burgerVisible : ""
}`}
onClick={() => setMobileNavOpen(!mobileNavOpen)}
>
<Burger />
</button>
</div> </div>
); );
} }
@ -65,11 +107,29 @@ interface NavProps {
sections: Section[]; sections: Section[];
currentIndex: number; currentIndex: number;
link: Link; link: Link;
pageTitle: string;
mobileNavOpen: boolean;
setMobileNavOpen: (mobileNavOpen: boolean) => void;
} }
function Nav({ sections, currentIndex, link: Link }: NavProps) { function Nav({
sections,
currentIndex,
link: Link,
pageTitle,
mobileNavOpen,
setMobileNavOpen,
}: NavProps) {
const navStyles = mobileNavOpen
? [styles.nav, styles.mobileNavOpen]
: [styles.nav];
return ( return (
<nav className={styles.nav}> <nav
className={navStyles.join(" ")}
onClick={(event) => event.stopPropagation()}
>
<h1 className={styles.mobileNavTitle}>{pageTitle}</h1>
{sections.map((section, index) => { {sections.map((section, index) => {
const classNames = [styles.navItem]; const classNames = [styles.navItem];
@ -82,14 +142,17 @@ function Nav({ sections, currentIndex, link: Link }: NavProps) {
} }
return ( return (
<Link <div
className={classNames.join(" ")} onClick={() => {
id={section.id} setMobileNavOpen(false);
}}
key={section.id} key={section.id}
> >
<span className={styles.marker} /> <Link className={classNames.join(" ")} id={section.id}>
<div>{section.title}</div> <span className={styles.marker} />
</Link> <div>{section.title}</div>
</Link>
</div>
); );
})} })}
</nav> </nav>
@ -137,6 +200,20 @@ function Footer({ sections, currentIndex, link: Link }: FooterProps) {
); );
} }
function useDebounce(func: () => void, delay = 300) {
const timerRef = useRef<number | undefined>(undefined);
return useCallback(() => {
if (timerRef.current != null) {
return;
}
timerRef.current = window.setTimeout(() => {
func();
timerRef.current = undefined;
}, delay);
}, [func, delay]);
}
export interface SectionWithContent { export interface SectionWithContent {
section: Section; section: Section;
Content: ComponentType; Content: ComponentType;
@ -216,3 +293,91 @@ function Arrow({ direction }: { direction: "left" | "right" }) {
</svg> </svg>
); );
} }
function useOnScreen(element: HTMLDivElement | null) {
const [isIntersecting, setIntersecting] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) =>
setIntersecting(entry.isIntersecting)
);
if (element) {
observer.observe(element);
}
// Remove the observer as soon as the component is unmounted
return () => {
observer.disconnect();
};
}, [element]);
return isIntersecting;
}
function useBurger(componentIsVisible: boolean): boolean {
const [prevScrollPos, setPrevScrollPos] = useState(0);
const [burgerVisible, setBurgerVisible] = useState(true);
const handleScroll = useDebounce(() => {
// find current scroll position
const currentScrollPos = window.pageYOffset;
setBurgerVisible(
componentIsVisible &&
((prevScrollPos > currentScrollPos &&
prevScrollPos - currentScrollPos > 70) ||
currentScrollPos < 10)
);
// set state to new scroll position
setPrevScrollPos(currentScrollPos);
});
useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
return burgerVisible;
}
// Inlining this svg because we want to fill in colors using css variables
function Burger() {
return (
<svg
width="30"
height="23"
viewBox="0 0 30 23"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="28"
y1="2"
x2="2"
y2="2"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<line
x1="28"
y1="11.375"
x2="2"
y2="11.375"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<line
x1="28"
y1="20.75"
x2="2"
y2="20.75"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@ -69,6 +69,7 @@ export function createReadAllPage({
readAllSection.section, readAllSection.section,
...sections.map(({ section }) => section), ...sections.map(({ section }) => section),
]} ]}
pageTitle={title}
link={Link} link={Link}
> >
<readAllSection.Content /> <readAllSection.Content />

View File

@ -39,6 +39,7 @@ export function createSectionPage({
<OrganizedContent <OrganizedContent
sections={sections} sections={sections}
id={sections[current].id} id={sections[current].id}
pageTitle={title}
link={Link} link={Link}
> >
<MDXRemote {...content} /> <MDXRemote {...content} />

View File

@ -22,7 +22,8 @@ export const PALETTE_NAMES = [
"--primary-accent", "--primary-accent",
"--primary-accent-soft", "--primary-accent-soft",
"--primary-accent-light", "--primary-accent-light",
"--primary-accent-dim", "--primary-accent-lighter",
"--primary-accent-lightest",
"--secondary-accent", "--secondary-accent",
"--secondary-accent-light", "--secondary-accent-light",

View File

@ -238,7 +238,12 @@ export function OrganizedContentDemo() {
)!.Content; )!.Content;
return ( return (
<OrganizedContent sections={sections} id={id} link={FakeLink}> <OrganizedContent
sections={sections}
id={id}
link={FakeLink}
pageTitle="Playground"
>
<Content /> <Content />
</OrganizedContent> </OrganizedContent>
); );

View File

@ -10,7 +10,8 @@ body {
--primary-accent: #1482e3; --primary-accent: #1482e3;
--primary-accent-soft: #5caff9; --primary-accent-soft: #5caff9;
--primary-accent-light: #c4e0f8; --primary-accent-light: #c4e0f8;
--primary-accent-dim: #f7fbff; --primary-accent-lighter: #e1eefa;
--primary-accent-lightest: #f7fbff;
--secondary-accent: #4ed4b2; --secondary-accent: #4ed4b2;
--secondary-accent-light: #dcf6f0; --secondary-accent-light: #dcf6f0;