From 2601d0efa3e6865cacad442e3b3ae9af2e417743 Mon Sep 17 00:00:00 2001 From: Yasin Date: Thu, 4 Jun 2026 15:06:02 +0200 Subject: [PATCH 1/5] feat(nav): add about menu data source --- src/data/nav-menu.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/data/nav-menu.ts diff --git a/src/data/nav-menu.ts b/src/data/nav-menu.ts new file mode 100644 index 00000000..aa1a5d9f --- /dev/null +++ b/src/data/nav-menu.ts @@ -0,0 +1,37 @@ +import { organizations } from './organizations'; + +const BASE = import.meta.env.BASE_URL.replace(/\/$/, ''); + +export type AboutNode = { + nodeName: string; + universityShort: string; + href: string; + color: string; +}; + +export type SectionIcon = 'people' | 'cases' | 'impact' | 'publications'; + +export type AboutSection = { + name: string; + desc: string; + href: string; + icon: SectionIcon; +}; + +// Derived from organizations.ts so the menu can never drift from the org data. +// Object insertion order: bergen, oslo, tromso, trondheim, aas. +export const aboutNodes: AboutNode[] = Object.values(organizations).map((org) => ({ + nodeName: org.nodeName, + universityShort: org.universityShort, + href: `${BASE}/about/${org.slug}`, + color: org.color, +})); + +export const aboutSections: AboutSection[] = [ + { name: 'Everyone', desc: 'The people of ELIXIR Norway', href: `${BASE}/about/everyone`, icon: 'people' }, + { name: 'Case Studies', desc: 'Impact stories', href: `${BASE}/about/case-studies`, icon: 'cases' }, + { name: 'Political Impact', desc: 'Personalised medicine & data sharing', href: `${BASE}/about/political-impact`, icon: 'impact' }, + { name: 'Publications', desc: 'Papers & outputs', href: `${BASE}/about/publications`, icon: 'publications' }, +]; + +export const aboutOverviewHref = `${BASE}/about`; From a9cfcdd840a0e123289ec03d338664d2eeef018b Mon Sep 17 00:00:00 2001 From: Yasin Date: Thu, 4 Jun 2026 15:11:26 +0200 Subject: [PATCH 2/5] feat(nav): add shared about menu component --- src/components/nav-about-menu.tsx | 127 ++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 src/components/nav-about-menu.tsx diff --git a/src/components/nav-about-menu.tsx b/src/components/nav-about-menu.tsx new file mode 100644 index 00000000..8194a4c2 --- /dev/null +++ b/src/components/nav-about-menu.tsx @@ -0,0 +1,127 @@ +import { aboutNodes, aboutSections, aboutOverviewHref, type SectionIcon } from '../data/nav-menu'; + +const isActive = (pathname: string, href: string) => + pathname === href || pathname.startsWith(href + '/'); + +const SectionGlyph = ({ icon }: { icon: SectionIcon }) => { + const cls = 'h-[15px] w-[15px]'; + switch (icon) { + case 'people': + return ( + + ); + case 'cases': + return ( + + ); + case 'impact': + return ( + + ); + case 'publications': + return ( + + ); + default: + return null; + } +}; + +const ArrowIcon = () => ( + +); + +type Props = { + pathname: string; + variant: 'panel' | 'accordion'; + onNavigate?: () => void; +}; + +export default function NavAboutMenu({ pathname, variant, onNavigate }: Props) { + const panel = variant === 'panel'; + const rowBase = `group flex items-center gap-3 rounded-xl px-2.5 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${panel ? 'py-2' : 'py-3'}`; + const headCls = 'px-2.5 pb-2 pt-1 text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400'; + const rowState = (active: boolean) => + active ? 'bg-accent/10' : 'hover:bg-black/[0.04] dark:hover:bg-white/[0.06]'; + + const nodes = ( +
+

Our nodes

+ {aboutNodes.map((node) => { + const active = isActive(pathname, node.href); + return ( + + + ); + })} +
+ ); + + const sections = ( +
+

Explore

+ {aboutSections.map((section) => { + const active = isActive(pathname, section.href); + return ( + + + + {section.name} + {section.desc} + + + ); + })} +
+ ); + + return ( +
+
+ {nodes} + {sections} +
+
+ + About ELIXIR Norway — overview + + +
+
+ ); +} From a872a28972c4f1e0d84e755103778b5314d18a7d Mon Sep 17 00:00:00 2001 From: Yasin Date: Thu, 4 Jun 2026 15:24:31 +0200 Subject: [PATCH 3/5] feat(nav): add magic-pill motion and about mega-menu --- src/components/navigation.tsx | 262 +++++++++++++++++++++++++++------- 1 file changed, 211 insertions(+), 51 deletions(-) diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index 9d613194..9a01ddd3 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -1,7 +1,8 @@ import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' -import React, { Fragment, useEffect, useState, useCallback } from "react"; +import { Fragment, useEffect, useLayoutEffect, useState, useCallback, useRef } from "react"; import CommandPalette from "./command-palette.tsx"; import ThemeToggle from "./theme-toggle.tsx"; +import NavAboutMenu from "./nav-about-menu.tsx"; const SearchIcon = ({ className }: { className?: string }) => ( ); +const ChevronIcon = ({ className }: { className?: string }) => ( + +); + const BASE = import.meta.env.BASE_URL.replace(/\/$/, ''); const navigation = [ @@ -21,6 +28,14 @@ const navigation = [ { href: `${BASE}/news`, name: "News" }, ]; +// useLayoutEffect warns during SSR; this island is server-rendered then hydrated. +const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +const isActivePath = (pathname: string, href: string) => + pathname === href || pathname.startsWith(href + '/'); + +type Glider = { left: number; top: number; width: number; height: number }; + const useScrolled = (threshold = 20) => { const [scrolled, setScrolled] = useState(false); useEffect(() => { @@ -34,11 +49,52 @@ const useScrolled = (threshold = 20) => { export const Navigation = ({ pathname }: { pathname: string }) => { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [aboutMobileOpen, setAboutMobileOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); + const [aboutOpen, setAboutOpen] = useState(false); + const [hoveredIndex, setHoveredIndex] = useState(null); + const [glider, setGlider] = useState(null); + const [hoverCapable, setHoverCapable] = useState(false); const scrolled = useScrolled(); const shouldReduceMotion = useReducedMotion(); - const closeMobile = useCallback(() => setMobileMenuOpen(false), []); + const linkRefs = useRef<(HTMLElement | null)[]>([]); + const aboutWrapRef = useRef(null); + const aboutChevronRef = useRef(null); + const closeTimer = useRef | null>(null); + + const closeMobile = useCallback(() => { setMobileMenuOpen(false); setAboutMobileOpen(false); }, []); + + // Magic-pill target: hovered link, else the active link (or hidden if neither). + const activeIndex = navigation.findIndex((item) => isActivePath(pathname, item.href)); + const targetIndex = hoveredIndex ?? (activeIndex >= 0 ? activeIndex : null); + + const measureGlider = useCallback((index: number | null) => { + if (index == null) { setGlider(null); return; } + const el = linkRefs.current[index]; + if (!el) { setGlider(null); return; } + setGlider({ left: el.offsetLeft, top: el.offsetTop, width: el.offsetWidth, height: el.offsetHeight }); + }, []); + + useIsomorphicLayoutEffect(() => { measureGlider(targetIndex); }, [targetIndex, measureGlider]); + + useEffect(() => { + const onResize = () => measureGlider(targetIndex); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [targetIndex, measureGlider]); + + // Re-measure once the web font (Space Grotesk) has loaded — link widths shift. + useEffect(() => { + if (typeof document === 'undefined' || !('fonts' in document)) return; + let cancelled = false; + document.fonts.ready.then(() => { if (!cancelled) measureGlider(targetIndex); }); + return () => { cancelled = true; }; + }, [targetIndex, measureGlider]); + + useEffect(() => { + setHoverCapable(window.matchMedia('(hover: hover) and (pointer: fine)').matches); + }, []); useEffect(() => { document.body.style.overflow = mobileMenuOpen ? 'hidden' : ''; @@ -53,6 +109,29 @@ export const Navigation = ({ pathname }: { pathname: string }) => { return () => document.removeEventListener('keydown', onKey); }, [mobileMenuOpen, closeMobile]); + // About dropdown: Esc closes + returns focus to the chevron; outside-click closes. + useEffect(() => { + if (!aboutOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { setAboutOpen(false); aboutChevronRef.current?.focus(); } + }; + const onDown = (e: MouseEvent) => { + if (aboutWrapRef.current && !aboutWrapRef.current.contains(e.target as Node)) setAboutOpen(false); + }; + document.addEventListener('keydown', onKey); + document.addEventListener('mousedown', onDown); + return () => { document.removeEventListener('keydown', onKey); document.removeEventListener('mousedown', onDown); }; + }, [aboutOpen]); + + const clearCloseTimer = useCallback(() => { + if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } + }, []); + const scheduleCloseAbout = useCallback(() => { + clearCloseTimer(); + closeTimer.current = setTimeout(() => setAboutOpen(false), 150); + }, [clearCloseTimer]); + useEffect(() => clearCloseTimer, [clearCloseTimer]); + return ( @@ -64,46 +143,99 @@ export const Navigation = ({ pathname }: { pathname: string }) => { : 'bg-white/40 dark:bg-dark-background/40 backdrop-blur-md border border-white/40 dark:border-white/10' }`} > -