www-new/components/OrganizedContent.tsx

348 lines
8.4 KiB
TypeScript
Raw Normal View History

2021-06-28 14:49:17 -04:00
import React, {
ReactNode,
ComponentType,
useState,
2021-06-28 16:10:10 -04:00
useRef,
2021-06-28 15:31:09 -04:00
useEffect,
2021-06-28 14:49:17 -04:00
} from "react";
2021-05-24 18:48:17 -04:00
import styles from "./OrganizedContent.module.css";
export interface LinkProps {
className?: string;
2021-06-09 21:12:44 -04:00
id: string;
children: ReactNode;
2021-05-24 18:48:17 -04:00
}
type Link = ComponentType<LinkProps>;
2021-06-09 21:12:44 -04:00
interface Section {
id: string;
title: string;
2021-06-09 21:12:44 -04:00
Content: ComponentType;
2021-05-24 18:48:17 -04:00
}
2021-06-09 21:12:44 -04:00
const READ_ALL_TITLE = "Read All";
export const READ_ALL_ID = "read-all";
2021-05-24 18:48:17 -04:00
interface Props {
2021-06-09 21:12:44 -04:00
sections: Section[];
currentId: string;
2021-07-04 16:24:09 -04:00
pageTitle: string;
2021-05-24 18:48:17 -04:00
link: Link;
}
2021-06-28 14:49:17 -04:00
2021-06-09 21:12:44 -04:00
export function OrganizedContent(props: Props) {
const sections = createSections(props.sections);
const currentIndex = sections.findIndex(({ id }) => id === props.currentId);
2021-06-28 14:49:17 -04:00
const [open, setOpen] = useState(false);
2021-06-09 21:12:44 -04:00
if (currentIndex < 0) {
throw new Error(`Section with ID ${props.currentId} was not found`);
}
2021-05-30 04:38:01 -04:00
2021-06-09 21:12:44 -04:00
const section = sections[currentIndex];
const isReadAll = section.id === READ_ALL_ID;
2021-05-24 20:28:23 -04:00
2021-06-28 15:31:09 -04:00
useEffect(() => {
open
? (document.body.style.overflow = "hidden")
: (document.body.style.overflow = "visible");
}, [open]);
2021-07-05 13:02:04 -04:00
const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref);
2021-05-30 04:38:01 -04:00
return (
2021-07-05 13:02:04 -04:00
<div className={styles.wrapper} ref={ref}>
2021-06-09 21:12:44 -04:00
<Nav sections={sections} currentIndex={currentIndex} link={props.link} />
<div className={styles.content}>
2021-05-24 21:29:45 -04:00
{isReadAll ? (
2021-06-09 21:12:44 -04:00
<section.Content />
2021-05-24 21:29:45 -04:00
) : (
2021-05-30 04:38:01 -04:00
<>
2021-06-09 21:12:44 -04:00
<div>
<h1>{section.title}</h1>
<section.Content />
</div>
<Footer
sections={sections}
currentIndex={currentIndex}
link={props.link}
/>
2021-05-30 04:38:01 -04:00
</>
2021-05-24 21:29:45 -04:00
)}
</div>
<MobileWrapper
open={open}
setOpen={setOpen}
sections={sections}
currentIndex={currentIndex}
link={props.link}
2021-07-04 16:24:09 -04:00
pageTitle={props.pageTitle}
2021-07-05 13:02:04 -04:00
componentIsVisible={isVisible}
/>
2021-05-30 04:38:01 -04:00
</div>
);
2021-06-09 21:12:44 -04:00
}
2021-05-24 21:29:45 -04:00
2021-06-09 21:12:44 -04:00
interface NavProps {
sections: Section[];
currentIndex: number;
link: Link;
2021-07-04 16:24:09 -04:00
pageTitle?: string;
2021-06-09 21:12:44 -04:00
}
2021-07-04 16:24:09 -04:00
function Nav({ sections, currentIndex, link: Link, pageTitle }: NavProps) {
2021-05-24 21:29:45 -04:00
return (
<div className={styles.nav}>
2021-07-04 16:24:09 -04:00
{pageTitle && <h1 className={styles.mobileNavTitle}>{pageTitle}</h1>}
2021-06-09 21:12:44 -04:00
{sections.map((section, index) => {
const classNames = [styles.navItem];
if (index === currentIndex) {
classNames.push(styles.selected);
}
if (section.id === READ_ALL_ID) {
classNames.push(styles.readAll);
}
return (
<Link
className={classNames.join(" ")}
id={section.id}
key={section.id}
2021-05-24 21:29:45 -04:00
>
2021-06-09 21:12:44 -04:00
<span className={styles.marker} />
<div>{section.title}</div>
</Link>
);
})}
2021-05-24 21:29:45 -04:00
</div>
);
2021-06-09 21:12:44 -04:00
}
2021-05-24 21:29:45 -04:00
2021-06-09 21:12:44 -04:00
interface FooterProps {
sections: Section[];
currentIndex: number;
link: Link;
}
function Footer({ sections, currentIndex, link: Link }: FooterProps) {
const prevSection =
currentIndex > 0 && sections[currentIndex - 1].id !== READ_ALL_ID
? sections[currentIndex - 1]
2021-05-24 21:29:45 -04:00
: undefined;
2021-06-09 21:12:44 -04:00
const nextSection =
currentIndex < sections.length - 1 &&
sections[currentIndex + 1].id !== READ_ALL_ID
? sections[currentIndex + 1]
2021-05-24 21:29:45 -04:00
: undefined;
return (
<div className={styles.footer}>
2021-06-09 21:12:44 -04:00
{prevSection && (
<Link className={styles.previous} id={prevSection.id}>
<Arrow direction="left" />
<div>
<div>Previous</div>
<div className={styles.arrowHeading}>{prevSection.title}</div>
2021-05-30 04:38:01 -04:00
</div>
</Link>
2021-05-24 21:29:45 -04:00
)}
2021-06-09 21:12:44 -04:00
{nextSection && (
<Link className={styles.next} id={nextSection.id}>
<div>
<div>Next</div>
<div className={styles.arrowHeading}>{nextSection.title}</div>
2021-05-30 04:38:01 -04:00
</div>
2021-06-09 21:12:44 -04:00
<Arrow direction="right" />
2021-05-30 04:38:01 -04:00
</Link>
2021-05-24 21:29:45 -04:00
)}
</div>
);
2021-06-09 21:12:44 -04:00
}
2021-06-28 14:49:17 -04:00
interface MobileProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
sections: Section[];
currentIndex: number;
link: Link;
2021-07-04 16:24:09 -04:00
pageTitle: string;
2021-07-05 13:02:04 -04:00
componentIsVisible: boolean;
}
function MobileWrapper(mobileProps: MobileProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
useOutsideAlerter(wrapperRef, mobileProps.setOpen);
return (
<div ref={wrapperRef}>
2021-07-05 13:02:04 -04:00
<Burger {...mobileProps} />
<Menu {...mobileProps} />
</div>
);
}
interface BurgerProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
2021-07-05 13:02:04 -04:00
componentIsVisible: boolean;
}
2021-07-05 13:02:04 -04:00
const Burger = ({ open, setOpen, componentIsVisible }: BurgerProps) => {
2021-06-28 16:36:27 -04:00
const [prevScrollPos, setPrevScrollPos] = useState(0);
2021-07-05 13:02:04 -04:00
const [burgerVisible, setBurgerVisible] = useState(true);
2021-06-28 16:47:48 -04:00
const debouncedPrevScrollPos = useDebounce<number>(prevScrollPos, 100);
2021-06-28 16:36:27 -04:00
useEffect(() => {
const handleScroll = () => {
// find current scroll position
const currentScrollPos = window.pageYOffset;
// set state based on location info (explained in more detail below)
2021-07-05 13:02:04 -04:00
setBurgerVisible(
componentIsVisible &&
((debouncedPrevScrollPos > currentScrollPos &&
debouncedPrevScrollPos - currentScrollPos > 70) ||
currentScrollPos < 10)
2021-06-28 16:36:27 -04:00
);
// set state to new scroll position
setPrevScrollPos(currentScrollPos);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
2021-07-05 13:02:04 -04:00
}, [componentIsVisible, debouncedPrevScrollPos, burgerVisible]);
2021-06-28 16:36:27 -04:00
2021-06-28 14:49:17 -04:00
return (
2021-06-28 16:36:27 -04:00
<div
2021-07-05 13:02:04 -04:00
className={
styles.burger + " " + (burgerVisible ? "" : styles.hiddenBurger)
}
2021-06-28 16:36:27 -04:00
onClick={() => setOpen(!open)}
>
2021-07-04 16:24:09 -04:00
<div>
<div />
<div />
<div />
</div>
2021-06-28 14:49:17 -04:00
</div>
);
};
interface MenuProps {
open: boolean;
sections: Section[];
currentIndex: number;
link: Link;
2021-07-04 16:24:09 -04:00
pageTitle: string;
2021-06-28 16:10:10 -04:00
}
2021-07-04 16:24:09 -04:00
const Menu = ({ open, sections, currentIndex, link, pageTitle }: MenuProps) => {
2021-06-28 14:49:17 -04:00
const mobileNav = open
? styles.mobileNav
: styles.mobileNav + " " + styles.mobileNavClosed;
return (
<div className={mobileNav}>
2021-07-04 16:24:09 -04:00
<Nav
sections={sections}
currentIndex={currentIndex}
link={link}
pageTitle={pageTitle}
/>
2021-06-28 14:49:17 -04:00
</div>
);
};
2021-06-28 16:10:10 -04:00
function useOutsideAlerter(
ref: React.RefObject<HTMLDivElement>,
setOpen: React.Dispatch<React.SetStateAction<boolean>>
) {
useEffect(() => {
const handleClickOutside = (event: Event) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref, setOpen]);
}
2021-06-28 16:47:48 -04:00
function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
2021-06-09 21:12:44 -04:00
function createSections(sections: Section[]) {
return [
{
id: READ_ALL_ID,
title: READ_ALL_TITLE,
Content() {
return (
<>
{sections.map(({ id, title, Content: SectionContent }) => (
<div key={id}>
<h1>{title}</h1>
<SectionContent />
</div>
))}
</>
);
},
},
...sections,
];
}
function Arrow({ direction }: { direction: "left" | "right" }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="9"
viewBox="0 0 14 9"
className={`${styles.arrow} ${
direction === "left" ? styles.prevArrow : styles.nextArrow
}`}
>
<path d="M6.24407 8.12713C6.64284 8.58759 7.35716 8.58759 7.75593 8.12713L13.3613 1.65465C13.9221 1.00701 13.4621 0 12.6053 0H1.39467C0.537918 0 0.0778675 1.00701 0.638743 1.65465L6.24407 8.12713Z" />
</svg>
);
}
2021-07-05 13:02:04 -04:00
function useOnScreen(ref: React.RefObject<HTMLDivElement>) {
const [isIntersecting, setIntersecting] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) =>
setIntersecting(entry.isIntersecting)
);
if (ref.current) {
observer.observe(ref.current);
}
// Remove the observer as soon as the component is unmounted
return () => {
observer.disconnect();
};
}, [ref]);
return isIntersecting;
}