Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/src/components/admin/AdminSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default function AdminSidebar({
];

return (
<aside className="w-full shrink-0 overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm lg:sticky lg:top-4 lg:w-64 lg:self-start">
<aside className="w-full shrink-0 overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm lg:w-64 lg:self-start">
<div className="border-b border-slate-100 bg-blue-medium px-4 py-4 text-white">
<p className="text-xs font-semibold uppercase tracking-normal text-yellow-dark">
KAC
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/profile/ProfileSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export default function ProfileSidebar({
}, [imageUrl]);

return (
<aside className="w-full shrink-0 rounded-[1.35rem] bg-white px-8 py-7 shadow-[6px_6px_0_var(--color-yellow-medium)] sm:w-[18.5rem] lg:sticky lg:top-8 lg:self-start">
<aside className="w-full shrink-0 rounded-[1.35rem] bg-white px-8 py-7 shadow-[6px_6px_0_var(--color-yellow-medium)] sm:w-[18.5rem] lg:self-start">
<div className="flex justify-center">
<div className="relative h-45 w-45">
<div className="h-full w-full overflow-hidden rounded-full bg-yellow-light ring-4 ring-yellow-medium">
Expand Down
7 changes: 4 additions & 3 deletions client/src/main/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
Outlet,
useLocation,
} from "react-router-dom";
import { useEffect } from "react";
import { useLayoutEffect } from "react";
import { AuthProvider } from "../auth/AuthProvider.tsx";
import Header from "./Header.tsx";
import Footer from "./Footer.tsx";
Expand Down Expand Up @@ -67,8 +67,9 @@ const Layout = () => {
const { pathname } = useLocation();

// Reset scroll position on every route change - BrowserRouter doesn't do
// this automatically the way a full-page navigation would.
useEffect(() => {
// this automatically the way a full-page navigation would. A layout effect
// runs before paint, so the new page never flashes at the old scroll offset.
useLayoutEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);

Expand Down
113 changes: 84 additions & 29 deletions client/src/main/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, animate, motion, useMotionValue } from "motion/react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
ChevronDown,
CircleUserRound,
Expand All @@ -20,6 +26,7 @@ import "../style/common.css";

const tabs = ["Home", "About", "Events", "Sponsors", "Contact", "Faq"];
const profileImageUpdatedEvent = "profile-image-updated";
const pillTransition = { type: "spring", bounce: 0, duration: 0.4 } as const;

// "Home" lives at "/" rather than "/home".
const routeForTab = (tab: string) => {
Expand All @@ -38,6 +45,14 @@ const Header = () => {
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [menuPath, setMenuPath] = useState(location.pathname);
const navRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<(HTMLAnchorElement | null)[]>([]);
const pillX = useMotionValue(0);
const pillWidth = useMotionValue(0);
const isPillPlaced = useRef(false);
const activeTabIndex = tabs.findIndex(
(tab) => routeForTab(tab) === location.pathname
);

// Navigating away (including via browser back/forward) closes the drawer.
if (menuPath !== location.pathname) {
Expand Down Expand Up @@ -114,6 +129,47 @@ const Header = () => {
setFailedProfileImageUrls(new Set());
}, [user?.id]);

// The nav pill is one persistent element positioned from the active link's
// offsetLeft/offsetWidth. Those are relative to the nav, so scroll position
// (which a sticky header's page offset depends on) never affects it. It
// slides only when the active link changes, and snaps into place on first
// paint, when re-appearing after a non-tab page, or on resize.
useLayoutEffect(() => {
const nav = navRef.current;
const activeTab = tabRefs.current[activeTabIndex];
if (!nav || !activeTab) {
isPillPlaced.current = false;
return;
}

let target = { left: -1, width: -1 };
const place = (shouldAnimate: boolean) => {
const left = activeTab.offsetLeft;
const width = activeTab.offsetWidth;
if (left === target.left && width === target.width) return;
target = { left, width };

if (shouldAnimate) {
animate(pillX, left, pillTransition);
animate(pillWidth, width, pillTransition);
} else {
pillX.jump(left);
pillWidth.jump(width);
}
};

place(isPillPlaced.current);
isPillPlaced.current = true;

const observer = new ResizeObserver(() => place(false));
observer.observe(nav);
for (const tab of tabRefs.current) {
if (tab) observer.observe(tab);
}

return () => observer.disconnect();
}, [activeTabIndex, pillX, pillWidth]);

useEffect(() => {
if (!isProfileMenuOpen) return;

Expand Down Expand Up @@ -142,7 +198,7 @@ const Header = () => {
}, [isProfileMenuOpen]);

return (
<div className="header flex items-center gap-2 px-[1.59rem] py-3 lg:p-6 bg-yellow-light w-full">
<div className="header sticky top-0 z-50 flex items-center gap-2 px-[1.59rem] py-3 lg:p-6 bg-yellow-light w-full">
{/** MOBILE ONLY - profile shortcut, balances the hamburger on the right */}
<div className="flex flex-1 justify-start lg:hidden">
{!loading &&
Expand Down Expand Up @@ -198,32 +254,31 @@ const Header = () => {
</div>
</div>

<div className="hidden lg:flex flex-1 justify-center gap-1 xl:gap-2 w-fit rounded-full bg-yellow-medium">
<AnimatePresence>
{tabs.map((tab) => {
const actualRoute = routeForTab(tab);
const isSelected = location.pathname === actualRoute;

return (
<Link
key={tab}
to={actualRoute}
className="px-4 xl:px-8 py-2 xl:py-3 rounded-full relative text-decoration-none col-blue-medium w-0.8"
>
<span className="relative z-10 uppercase text-base xl:text-xl">
{tab}
</span>
{isSelected && (
<motion.span
layoutId="pill-tab"
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
className="absolute inset-0 z-0 rounded-full bg-yellow-dark"
/>
)}
</Link>
);
})}
</AnimatePresence>
<div
className="hidden lg:flex flex-1 justify-center gap-1 xl:gap-2 w-fit rounded-full bg-yellow-medium relative"
ref={navRef}
>
{activeTabIndex !== -1 && (
<motion.span
aria-hidden="true"
className="absolute inset-y-0 left-0 z-0 rounded-full bg-yellow-dark"
style={{ x: pillX, width: pillWidth }}
/>
)}
{tabs.map((tab, index) => (
<Link
key={tab}
ref={(element) => {
tabRefs.current[index] = element;
}}
to={routeForTab(tab)}
className="px-4 xl:px-8 py-2 xl:py-3 rounded-full relative text-decoration-none col-blue-medium w-0.8"
>
<span className="relative z-10 uppercase text-base xl:text-xl">
{tab}
</span>
</Link>
))}
</div>

<div className="hidden lg:flex flex-1 pr-2 justify-end">
Expand Down
2 changes: 1 addition & 1 deletion client/src/pages/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const Profile = () => {
<div className="relative z-10 w-full pb-16">
{/** Title **/}
<PageTitle title="MY PROFILE" />
<div className="mb-40 px-4 py-6 sm:px-6 lg:px-8 flex justify-center items-center">
<div className="mb-40 px-4 py-6 sm:px-6 lg:px-8 flex justify-center items-start">
<div className="grid w-full max-w-240 gap-8 lg:grid-cols-[18.5rem_minmax(0,1fr)] lg:items-start lg:gap-14">
<ProfileSidebar
activeSection={activeSection}
Expand Down
Loading