feat: spun off dropdown into reusable component, other tweaks and changes

This commit is contained in:
gitlimes
2023-09-19 12:25:34 +02:00
parent a28cda1820
commit e831bc69c7
13 changed files with 591 additions and 282 deletions

View File

@@ -1,5 +1,5 @@
import '../styles/globals.css';
import Header from '@/components/Header/Header';
import Header from '@/components/Header/HeaderWrapper';
import Footer from '@/components/Footer/Footer';
import Script from 'next/script';
@@ -14,7 +14,7 @@ const poppins = Poppins({
export default function RootLayout({ children }) {
return (
<html className={`${poppins.variable}`}>
<body style={{ paddingTop: '36px' }}>
<body style={{ paddingTop: '48px' }}>
<Header />
{children}
<Footer />

View File

@@ -0,0 +1,142 @@
'use client';
import { useEffect, useRef, useMemo } from 'react';
import classNames from 'classnames';
import styles from './Dropdown.module.css';
/**
*
* A reusable object that renders a dropdown.
*
* @param {React.useState} openState: a state object [state, setState],
* where state is an int which refers to the index of the dropdownContent to render + 1
* (having it be +1 lets us use 0 as a "do not render" magic number)
*
* @param {Array} dropdownContents: an array of objects, structured as
* follows:
* [{ el: <El1 />, tr: tr1 }]
* where <El1 /> is the content to be rendered when the state is set to
* the corresponding state, and tr1 is the reference to the element that triggered
* the dropdown in the first place
*
* @param {React.useRef} boundaryRef: a ref to the object to use as a boundary (the
* dropdown will always be rendered to be [horizontally] inside of the element)
*
* @param {Number} boundaryTolerance: the amount of pixels by which the dropdown can poke out of the boundary.
*
* @example
* <Dropdown
* dropdownContents={[
* { el: <El1 />, tr: tr1 },
* { el: <El2 />, tr: tr2 },
* ]}
* openState={[open, setOpen]}
* boundaryRef={navbarRef}
* boundaryTolerance={20}
* />
*/
export default function Dropdown(ctx) {
const [open, setOpen] = ctx.openState;
const { boundaryRef, boundaryTolerance } = ctx;
const dropdownElements = useMemo(() => {
return [null];
}, []);
const dropdownTriggers = useMemo(() => {
return [null];
}, []);
ctx.dropdownContents.forEach((c) => {
dropdownElements.push(c.el);
dropdownTriggers.push(c.tr);
});
const dropdownContent = useRef(null);
const dropdownArrow = useRef(null);
const dropdown = useRef(null);
// animation business. Don't even bother.
useEffect(() => {
const dropdownContentRect = dropdownContent?.current?.getBoundingClientRect();
const dropdownRect = dropdownContent?.current?.getBoundingClientRect();
const pxToInt = (px) => parseInt(px.replace('px', ''));
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
if (dropdownContentRect && open) {
const sectionWidth = pxToInt(getComputedStyle(boundaryRef.current).width);
const dropdownWidth = dropdownRect.width;
function getRealCenter(trRef) {
const style = getComputedStyle(trRef.current);
const rightPad = pxToInt(style.paddingRight);
const leftPad = pxToInt(style.paddingLeft);
const elWidth = pxToInt(style.width);
return (elWidth - rightPad - leftPad) / 2;
}
function getTriggerOffset(trRef) {
const style = getComputedStyle(trRef.current);
const leftPad = pxToInt(style.paddingLeft);
return (
trRef.current.getBoundingClientRect().left -
boundaryRef.current.getBoundingClientRect().left +
leftPad
);
}
function getLeftOffset(trRef) {
return getTriggerOffset(trRef) + getRealCenter(trRef);
}
const arrowOffset = getLeftOffset(dropdownTriggers[open]);
const leftOffset = getLeftOffset(dropdownTriggers[open]) - dropdownWidth / 2;
const leftBoundary = 0 - boundaryTolerance;
const rightBoundary = sectionWidth - dropdownWidth + boundaryTolerance;
const maxLeftOffset = clamp(leftOffset, leftBoundary, rightBoundary);
dropdown.current.style.opacity = 1;
dropdown.current.style.width = `${dropdownContentRect.width}px`;
dropdown.current.style.height = `${dropdownContentRect.height}px`;
dropdown.current.style.left = `${maxLeftOffset}px`;
dropdownArrow.current.style.left = `${arrowOffset}px`;
} else {
dropdown.current.style.opacity = 0;
//dropdown.current.style.width = 0;
dropdown.current.style.height = 0;
}
}, [open, dropdownTriggers, boundaryTolerance, boundaryRef]);
return (
<>
<div
className={styles.dropdown}
onMouseEnter={() => setOpen(open)}
onPointerEnter={() => setOpen(open)}
onMouseLeave={() => setOpen(0)}
onPointerLeave={() => setOpen(0)}
ref={dropdown}
style={{
border: open ? '' : 'none',
}}
>
<div ref={dropdownContent} className={styles.dropdownContent}>
{dropdownElements[open]}
</div>
</div>
<div
className={classNames(styles.dropdownArrow, {
[styles.arrowAppear]: Boolean(open),
[styles.arrowDisappear]: !Boolean(open),
})}
ref={dropdownArrow}
/>
</>
);
}

View File

@@ -0,0 +1,115 @@
.dropdown {
position: absolute;
background: var(--bg-shade-2);
bottom: -0.75rem;
left: 0;
transform: translate(0, 100%);
border-radius: 0.5rem;
border: 1px solid var(--bg-shade-3);
width: 0;
height: 0;
transition: width 200ms ease-out, height 100ms ease-out, opacity 500ms ease-out 100ms, left 150ms ease-out;
}
.dropdownArrow {
position: absolute;
bottom: calc(-0.75rem - 6px);
left: 0;
width: 12px;
height: 12px;
background: var(--bg-shade-2);
border: 1px solid var(--bg-shade-3);
border-bottom: 0;
border-right: 0;
transform: translateX(-50%) rotate(45deg);
pointer-events: none;
}
.dropdownArrow.arrowAppear {
transform: scale(1) translateX(-50%) rotate(45deg);
transition: transform 250ms ease-out 200ms;
}
.dropdownArrow.arrowDisappear {
transform: scale(0) translateX(-50%) rotate(45deg);
transition: transform 50ms ease-in;
}
.dropdown::before {
content: '';
position: absolute;
display: block;
height: 1.4rem;
top: -1.4rem;
left: 0;
width: 100%;
}
.dropdown .dropdownContent {
width: max-content;
height: max-content;
padding: 1.25rem;
}
.dropdownContent .link {
position: relative;
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 1rem;
padding: 1.25rem;
padding-right: 5rem;
border-radius: 6px;
max-width: 90vw;
min-width: 24rem;
}
.dropdownContent .link .iconWrapper {
background: var(--bg-shade-0);
color: var(--accent-shade-2);
display: flex;
padding: 12px;
align-items: center;
justify-content: center;
border-radius: 6px;
}
.dropdownContent .link p {
margin: 0;
margin-top: 4px;
font-weight: 300;
color: var(--text-shade-3);
}
.dropdownContent .link .arrow {
display: none;
position: absolute;
right: 1.5rem;
color: var(--accent-shade-1);
}
.dropdownContent,
.dropdownContent .link {
animation: appear 0.75s;
}
@keyframes appear {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.dropdownContent .link:hover {
background: var(--bg-shade-3);
}
.dropdownContent .link:hover .iconWrapper {
color: var(--accent-shade-1);
}
.dropdownContent .link:hover .arrow {
display: block;
}

View File

@@ -1,25 +1,116 @@
import Logo from '@/components/Logo/Logo';
import Section from '@/components/Section/Section';
import Nav from './Nav';
'use client';
import { getLocale } from '@/utils/locale';
/*
* This is a separate component because it needs to be rendered
* clientside, while the main header needs to be rendered on
* the server to be able to fetch the locale. Very cool.
*/
import Link from 'next/link';
import { Heart, Translate, UserCircle } from '@phosphor-icons/react';
import { useState, useRef } from 'react';
import styles from './Header.module.css';
export default function Header(ctx) {
const { locale } = getLocale('TODO');
import Section from '../Section/Section';
import Logo from '../Logo/Logo';
import Dropdown from '../Dropdown/Dropdown';
import HeaderDropdown1 from './HeaderDropdowns/HeaderDropdown1';
import HeaderDropdown2 from './HeaderDropdowns/HeaderDropdown2';
import HeaderDropdown3 from './HeaderDropdowns/HeaderDropdown3';
import HeaderDropdown4 from './HeaderDropdowns/HeaderDropdown4';
export default function Header({ locale }) {
const [open, setOpen] = useState(0);
const headerInnerSection = useRef(null);
const trigger1 = useRef(null);
const trigger2 = useRef(null);
const trigger3 = useRef(null);
const trigger4 = useRef(null);
const DropdownTrigger = ({ n, refForEl, children }) => {
return (
<button
ref={refForEl}
onMouseEnter={() => setOpen(n)}
onPointerEnter={() => setOpen(n)}
onMouseLeave={() => setOpen(0)}
onPointerLeave={() => setOpen(0)}
style={{
color: open === n && 'var(--text-shade-3)',
}}
>
{children}
</button>
);
};
return (
<header id="header">
<Section compact className={styles.headerWrapper} contentClassName={styles.header}>
<Link href="/" aria-label="Homepage" title="Homepage">
<Logo text size={42} className={styles.logo} />
</Link>
<Section
compact
className={styles.headerWrapper}
contentClassName={styles.header}
contentRef={headerInnerSection}
>
<Link href="/" aria-label="Homepage" title="Homepage">
<Logo text size={42} className={styles.logo} />
</Link>
<Nav locale={locale} />
</Section>
</header>
<div className={styles.navWrapper}>
<nav className={styles.nav}>
<ul className={styles.dropdownTriggers}>
<li>
<DropdownTrigger n={1} refForEl={trigger1}>
{locale.nav.about}
</DropdownTrigger>
</li>
<li>
<DropdownTrigger n={2} refForEl={trigger2}>
{locale.nav.docs}
</DropdownTrigger>
</li>
</ul>
<ul>
<li>
<Link href="/progress">
<button>{locale.nav.progress}</button>
</Link>
</li>
<li className={styles.upgrade}>
<Link href="/upgrade">
<button>
<Heart size={16} weight="fill" alt="" />
<span>{locale.nav.donate}</span>
</button>
</Link>
</li>
</ul>
</nav>
<div className={styles.rightIconsWrapper}>
<DropdownTrigger n={3} refForEl={trigger3}>
<Translate size={24} />
</DropdownTrigger>
<DropdownTrigger n={4} refForEl={trigger4}>
<UserCircle size={32} />
</DropdownTrigger>
</div>
<Dropdown
dropdownContents={[
{ el: <HeaderDropdown1 key={1} locale={locale} />, tr: trigger1 },
{ el: <HeaderDropdown2 key={2} locale={locale} />, tr: trigger2 },
{ el: <HeaderDropdown3 key={3} locale={locale} />, tr: trigger3 },
{ el: <HeaderDropdown4 key={4} locale={locale} />, tr: trigger4 },
]}
openState={[open, setOpen]}
boundaryRef={headerInnerSection}
boundaryTolerance={24}
/>
</div>
</Section>
);
}

View File

@@ -2,8 +2,9 @@
padding: 1rem 0 !important;
background: rgba(19, 23, 51, 0.9);
position: fixed !important;
width: 100%;
width: 100vw;
top: 0;
left: 0;
z-index: 99;
backdrop-filter: blur(2rem);
border-bottom: 1px solid var(--bg-shade-2);
@@ -21,6 +22,14 @@
margin-left: -8px;
}
.navWrapper {
display: flex;
flex-flow: row nowrap;
width: 100%;
justify-content: space-between;
align-items: center;
}
.nav {
display: flex;
align-items: center;
@@ -51,7 +60,8 @@
padding-left: 1rem;
}
.nav li button {
.nav li button,
.rightIconsWrapper button {
appearance: none;
border: none;
padding: 0;
@@ -83,96 +93,9 @@
color: var(--text-shade-3);
}
.dropdown {
position: absolute;
background: var(--bg-shade-2);
top: 3rem;
left: 50%;
transform: translateX(-50%);
border-radius: 0.5rem;
border: 1px solid var(--bg-shade-3);
width: fit-content;
height: fit-content;
transition: width 150ms ease-out, height 150ms ease-out;
}
.dropdown::before {
content: '';
position: absolute;
display: block;
height: 1.6rem;
top: -1.6rem;
left: 0;
width: 100%;
}
.dropdown > div {
width: max-content;
height: max-content;
padding: 1.25rem;
}
.dropdown > div,
.dropdown .link {
animation: appear 0.75s;
}
@keyframes appear {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.dropdown .link {
position: relative;
.rightIconsWrapper {
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 1rem;
padding: 1.25rem;
padding-right: 5rem;
border-radius: 6px;
max-width: 90vw;
min-width: 24rem;
}
.dropdown .link .iconWrapper {
background: var(--bg-shade-0);
color: var(--accent-shade-2);
display: flex;
padding: 12px;
align-items: center;
justify-content: center;
border-radius: 6px;
}
.dropdown .link p {
margin: 0;
margin-top: 4px;
font-weight: 300;
color: var(--text-shade-3);
}
.dropdown .link .arrow {
display: none;
position: absolute;
right: 1.5rem;
color: var(--accent-shade-1);
}
.dropdown .link:hover {
background: var(--bg-shade-3);
}
.dropdown .link:hover .iconWrapper {
color: var(--accent-shade-1);
}
.dropdown .link:hover .arrow {
display: block;
}

View File

@@ -0,0 +1,62 @@
.dropdownContent .link {
position: relative;
display: flex;
flex-flow: row nowrap;
align-items: center;
gap: 1rem;
padding: 1.25rem;
padding-right: 5rem;
border-radius: 6px;
max-width: 90vw;
min-width: 24rem;
}
.dropdownContent .link .iconWrapper {
background: var(--bg-shade-0);
color: var(--accent-shade-2);
display: flex;
padding: 12px;
align-items: center;
justify-content: center;
border-radius: 6px;
}
.dropdownContent .link p {
margin: 0;
margin-top: 4px;
font-weight: 300;
color: var(--text-shade-3);
}
.dropdownContent .link .arrow {
display: none;
position: absolute;
right: 1.5rem;
color: var(--accent-shade-1);
}
.dropdownContent,
.dropdownContent .link {
animation: appear 0.75s;
}
@keyframes appear {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.dropdownContent .link:hover {
background: var(--bg-shade-3);
}
.dropdownContent .link:hover .iconWrapper {
color: var(--accent-shade-1);
}
.dropdownContent .link:hover .arrow {
display: block;
}

View File

@@ -0,0 +1,56 @@
import { ArrowRight, Users, Info, SealQuestion, NewspaperClipping } from '@phosphor-icons/react';
import Link from 'next/link';
import Title from '@/components/Title/Title';
import styles from './HeaderDropdown.module.css';
export default function HeaderDropdown1({ locale }) {
return (
<div className={styles.dropdownContent}>
<Link className={styles.link} href="/#credits">
<div className={styles.iconWrapper}>
<Users weight="fill" size={32} />
</div>
<div>
<Title element="h4">{locale.nav.credits}</Title>
<p>{locale.nav.dropdown.captions.credits}</p>
</div>
<ArrowRight size={28} className={styles.arrow} />
</Link>
<Link className={styles.link} href="/#about">
<div className={styles.iconWrapper}>
<Info weight="fill" size={32} />
</div>
<div>
<Title element="h4">{locale.nav.about}</Title>
<p>{locale.nav.dropdown.captions.about}</p>
</div>
<ArrowRight size={28} className={styles.arrow} />
</Link>
<Link className={styles.link} href="/#faq">
<div className={styles.iconWrapper}>
<SealQuestion weight="fill" size={32} />
</div>
<div>
<Title element="h4">{locale.nav.faq}</Title>
<p>{locale.nav.dropdown.captions.faq}</p>
</div>
<ArrowRight size={28} className={styles.arrow} />
</Link>
<Link className={styles.link} href="/blog">
<div className={styles.iconWrapper}>
<NewspaperClipping weight="fill" size={32} />
</div>
<div>
<Title element="h4">{locale.nav.blog}</Title>
<p>{locale.nav.dropdown.captions.blog}</p>
</div>
<ArrowRight size={28} className={styles.arrow} />
</Link>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { ArrowRight, Wrench, SealWarning } from '@phosphor-icons/react';
import Link from 'next/link';
import Title from '@/components/Title/Title';
import styles from './HeaderDropdown.module.css';
export default function HeaderDropdown2({ locale }) {
return (
<div className={styles.dropdownContent}>
<Link className={styles.link} href="/docs">
<div className={styles.iconWrapper}>
<Wrench weight="fill" size={32} />
</div>
<div>
<Title element="h4">{locale.docs.quickLinks.links[0].header}</Title>
<p>{locale.docs.quickLinks.links[0].caption}</p>
</div>
<ArrowRight size={28} className={styles.arrow} />
</Link>
<Link className={styles.link} href="/docs/search">
<div className={styles.iconWrapper}>
<SealWarning weight="fill" size={32} />
</div>
<div>
<Title element="h4">{locale.docs.quickLinks.links[1].header}</Title>
<p>{locale.docs.quickLinks.links[1].caption}</p>
</div>
<ArrowRight size={28} className={styles.arrow} />
</Link>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { ArrowRight, Wrench, SealWarning } from '@phosphor-icons/react';
import Link from 'next/link';
import Title from '@/components/Title/Title';
import styles from './HeaderDropdown.module.css';
export default function HeaderDropdown3({ locale }) {
return (
<div className={styles.dropdownContent}>
<Link className={styles.link} href="/docs">
<div className={styles.iconWrapper}>
<Wrench weight="fill" size={32} />
</div>
<div>
<Title element="h4">TESTTTTT</Title>
<p>{locale.docs.quickLinks.links[0].caption}</p>
</div>
<ArrowRight size={28} className={styles.arrow} />
</Link>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { ArrowRight, Wrench, SealWarning } from '@phosphor-icons/react';
import Link from 'next/link';
import Title from '@/components/Title/Title';
import styles from './HeaderDropdown.module.css';
export default function HeaderDropdown4({ locale }) {
return (
<div className={styles.dropdownContent}>
<Link className={styles.link} href="/docs">
<div className={styles.iconWrapper}>
<Wrench weight="fill" size={32} />
</div>
<div>
<Title element="h4">Bees</Title>
<p>{locale.docs.quickLinks.links[0].caption}</p>
</div>
<ArrowRight size={28} className={styles.arrow} />
</Link>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import Header from './Header';
import { getLocale } from '@/utils/locale';
export default function HeaderWrapper() {
const { locale } = getLocale('TODO');
return (
<header id="header">
<Header locale={locale} />
</header>
);
}

View File

@@ -1,174 +0,0 @@
'use client';
/*
* This is a separate component because it needs to be rendered
* clientside, while the main header needs to be rendered on
* the server to be able to fetch the locale. Very cool.
*/
import Link from 'next/link';
import {
ArrowRight,
Heart,
Users,
Info,
SealQuestion,
NewspaperClipping,
Wrench,
SealWarning,
} from '@phosphor-icons/react';
import styles from './Header.module.css';
import { useState, useEffect, useRef } from 'react';
import Title from '../Title/Title';
export default function Nav({ locale }) {
const [open, setOpen] = useState(0);
const dropdown = useRef(null);
const dropdownWrap = useRef(null);
useEffect(() => {
let w = 0;
let h = 0;
const clR = dropdown?.current?.getBoundingClientRect();
if (clR && open) {
w = clR.width;
h = clR.height;
}
dropdownWrap.current.style.width = `${w}px`;
dropdownWrap.current.style.height = `${h}px`;
}, [open]);
const DropdownTrigger = ({ n, children }) => {
return (
<button
onMouseEnter={() => setOpen(n)}
onMouseLeave={() => setOpen(0)}
style={{
color: open === n && 'var(--text-shade-3)',
}}
>
{children}
</button>
);
};
const Dropdown1 = () => {
return (
<>
<Link className={styles.link} href="/#credits">
<div className={styles.iconWrapper}>
<Users weight="fill" size={32} />
</div>
<div>
<Title element="h4">{locale.nav.credits}</Title>
<p>{locale.nav.dropdown.captions.credits}</p>
</div>
<ArrowRight size={28} className={styles.arrow} />
</Link>
<Link className={styles.link} href="/#about">
<div className={styles.iconWrapper}>
<Info weight="fill" size={32} />
</div>
<div>
<Title element="h4">{locale.nav.about}</Title>
<p>{locale.nav.dropdown.captions.about}</p>
</div>
<ArrowRight size={28} className={styles.arrow} />
</Link>
<Link className={styles.link} href="/#faq">
<div className={styles.iconWrapper}>
<SealQuestion weight="fill" size={32} />
</div>
<div>
<Title element="h4">{locale.nav.faq}</Title>
<p>{locale.nav.dropdown.captions.faq}</p>
</div>
<ArrowRight size={28} className={styles.arrow} />
</Link>
<Link className={styles.link} href="/blog">
<div className={styles.iconWrapper}>
<NewspaperClipping weight="fill" size={32} />
</div>
<div>
<Title element="h4">{locale.nav.blog}</Title>
<p>{locale.nav.dropdown.captions.blog}</p>
</div>
<ArrowRight size={28} className={styles.arrow} />
</Link>
</>
);
};
const Dropdown2 = () => {
return (
<>
<Link className={styles.link} href="/docs">
<div className={styles.iconWrapper}>
<Wrench weight="fill" size={32} />
</div>
<div>
<Title element="h4">{locale.docs.quickLinks.links[0].header}</Title>
<p>{locale.docs.quickLinks.links[0].caption}</p>
</div>
<ArrowRight size={28} className={styles.arrow} />
</Link>
<Link className={styles.link} href="/docs/search">
<div className={styles.iconWrapper}>
<SealWarning weight="fill" size={32} />
</div>
<div>
<Title element="h4">{locale.docs.quickLinks.links[1].header}</Title>
<p>{locale.docs.quickLinks.links[1].caption}</p>
</div>
<ArrowRight size={28} className={styles.arrow} />
</Link>
</>
);
};
return (
<nav className={styles.nav}>
<ul className={styles.dropdownTriggers}>
<li>
<DropdownTrigger n={1}>{locale.nav.about}</DropdownTrigger>
</li>
<li>
<DropdownTrigger n={2}>{locale.nav.docs}</DropdownTrigger>
</li>
<div
className={styles.dropdown}
onMouseEnter={() => setOpen(open)}
onMouseLeave={() => setOpen(0)}
ref={dropdownWrap}
style={{
border: open ? '' : 'none',
}}
>
<div ref={dropdown}>{open === 1 ? <Dropdown1 /> : open === 2 ? <Dropdown2 /> : null}</div>
</div>
</ul>
<ul>
<li>
<Link href="/progress">
<button>{locale.nav.progress}</button>
</Link>
</li>
<li className={styles.upgrade}>
<Link href="/upgrade">
<button>
<Heart size={16} weight="fill" alt="" />
<span>{locale.nav.donate}</span>
</button>
</Link>
</li>
</ul>
</nav>
);
}

View File

@@ -19,7 +19,7 @@ import styles from './Section.module.css';
*/
export default function Section(ctx) {
const { id, style, children, className, compact, contentClassName } = ctx;
const { id, style, children, className, compact, contentClassName, contentRef } = ctx;
return (
<section
@@ -27,7 +27,9 @@ export default function Section(ctx) {
id={id}
style={style}
>
<div className={classNames(styles.content, contentClassName)}>{children}</div>
<div className={classNames(styles.content, contentClassName)} ref={contentRef}>
{children}
</div>
</section>
);
}