use dioxus::prelude::*;
use crate::{
cn,
uikit::{Container, Logo},
};
const HEADER_BASE: &str = "fixed top-0 left-0 w-full z-[60] border-b";
const HEADER_MARKETING: &str = "transition-all duration-500";
const HEADER_SCROLLED: &str = "bg-main-black/90 backdrop-blur-md border-main-mist/10 py-4";
const HEADER_TOP: &str = "bg-transparent border-transparent py-6";
const HEADER_COMPACT: &str = "h-16 bg-main-black/90 backdrop-blur-md border-main-mist/10";
const SCROLL_JS: &str = "let last = null; \
const update = () => { const s = window.scrollY > 50; if (s !== last) { last = s; dioxus.send(s); } }; \
window.addEventListener('scroll', update, { passive: true }); \
update();";
const OVERLAY_JS: &str = "document.body.style.overflow = 'hidden'; \
const done = () => { document.body.style.overflow = ''; document.removeEventListener('keydown', onKey); document.removeEventListener('click', onClick); dioxus.send(true); }; \
const onKey = (e) => { if (e.key === 'Escape') done(); }; \
const onClick = (e) => { if (e.target.closest && e.target.closest('[data-slot=header-mobile-overlay]') && e.target.closest('a,button')) done(); }; \
document.addEventListener('keydown', onKey); \
document.addEventListener('click', onClick);";
#[derive(Clone, Debug, PartialEq)]
pub struct HeaderNavItem {
pub label: String,
pub href: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum HeaderVariant {
#[default]
Marketing,
Compact,
}
#[component]
pub fn Header(
nav: Vec<HeaderNavItem>,
cta: Option<Element>,
mobile_cta: Option<Element>,
#[props(default = "Quy Nhon Fund".to_string())] tagline: String,
#[props(default = "/".to_string())] home_href: String,
#[props(default)] class: String,
#[props(default)]
variant: HeaderVariant,
#[props(default)]
hide_nav: bool,
) -> Element {
let mut scrolled = use_signal(|| false);
let mut menu_open = use_signal(|| false);
let compact = variant == HeaderVariant::Compact;
use_future(move || async move {
if compact {
return;
}
let mut scroll_flips = document::eval(SCROLL_JS);
while let Ok(s) = scroll_flips.recv::<bool>().await {
scrolled.set(s);
}
});
use_effect(move || {
if menu_open() {
spawn(async move {
let mut closed = document::eval(OVERLAY_JS);
let _ = closed.recv::<bool>().await;
menu_open.set(false);
});
}
});
use_drop(move || {
let _ = document::eval("document.body.style.overflow = '';");
});
rsx! {
header {
class: cn!(
HEADER_BASE,
if compact {
HEADER_COMPACT.to_string()
} else {
cn!(HEADER_MARKETING, if scrolled() { HEADER_SCROLLED } else { HEADER_TOP })
},
class
),
"data-slot": "header",
"data-variant": if compact { "compact" } else { "marketing" },
Container { class: "flex h-full items-center justify-between gap-4",
a { href: home_href, class: "flex items-center gap-3", aria_label: "EV Investment — home",
Logo { class: "w-10 h-10 text-white" }
div { class: "flex flex-col",
span { class: "font-serif-display font-bold text-lg tracking-wider text-white", "EV INVESTMENT" }
span { class: "text-[9px] font-mono-tech tracking-[0.3em] text-main-accent-t1 uppercase", {tagline} }
}
}
if !hide_nav {
nav { class: "hidden lg:flex items-center gap-6 font-mono-tech text-xs tracking-widest uppercase",
for item in nav.iter() {
a {
key: "{item.href}",
href: item.href.clone(),
class: "text-main-mist/80 hover:text-main-accent-t1 transition-colors",
{item.label.clone()}
}
}
}
}
div { class: "flex items-center gap-3",
{cta.clone()}
if !hide_nav {
div { class: "lg:hidden",
button {
r#type: "button",
aria_label: "Open menu",
"aria-expanded": if menu_open() { "true" } else { "false" },
"aria-haspopup": "menu",
class: "flex size-10 items-center justify-center text-white",
onclick: move |_| menu_open.set(true),
svg {
xmlns: "http://www.w3.org/2000/svg",
class: "size-6",
view_box: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
path { d: "M4 6h16M4 12h16M4 18h16" }
}
}
}
}
}
}
}
if menu_open() {
div {
class: "fixed inset-0 z-[70] flex flex-col bg-main-black px-6 pb-10 duration-200 animate-in fade-in lg:hidden",
"data-slot": "header-mobile-overlay",
div { class: "flex h-20 shrink-0 items-center justify-end",
button {
r#type: "button",
aria_label: "Close menu",
class: "flex size-10 items-center justify-center text-white",
svg {
xmlns: "http://www.w3.org/2000/svg",
class: "size-6",
view_box: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
path { d: "M18 6 6 18M6 6l12 12" }
}
}
}
nav { class: "flex flex-col font-mono-tech text-sm uppercase tracking-widest duration-300 ease-out animate-in fade-in slide-in-from-top-4",
for item in nav.iter() {
a {
key: "{item.href}",
href: item.href.clone(),
class: "border-b border-main-mist/10 py-4 text-main-mist/80 transition-colors hover:text-main-accent-t1",
{item.label.clone()}
}
}
}
if mobile_cta.is_some() || cta.is_some() {
div { class: "mt-8 w-full block", {mobile_cta.clone().or_else(|| cta.clone())} }
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::uikit::test_util::render;
fn nav() -> Vec<HeaderNavItem> {
vec![HeaderNavItem {
label: "Team".to_string(),
href: "/team".to_string(),
}]
}
#[test]
fn renders_lockup_nav_and_menu_trigger() {
fn app() -> Element {
rsx! {
Header { nav: nav() }
}
}
let html = render(app);
assert!(html.contains("data-slot=\"header\""), "{html}");
assert!(html.contains("EV INVESTMENT"), "{html}");
assert!(html.contains("Quy Nhon Fund"), "default tagline: {html}");
assert!(html.contains("aria-label=\"EV Investment — home\""), "{html}");
assert!(html.contains("href=\"/team\""), "{html}");
assert!(html.contains("Team"), "{html}");
assert!(html.contains("aria-label=\"Open menu\""), "{html}");
assert!(html.contains("bg-transparent"), "unscrolled by default: {html}");
assert!(!html.contains("header-mobile-overlay"), "menu closed by default: {html}");
}
#[test]
fn cta_slot_and_overrides_render() {
fn app() -> Element {
rsx! {
Header {
nav: nav(),
tagline: "Custom Fund",
home_href: "/home",
cta: rsx! {
button { "Investor Portal" }
},
}
}
}
let html = render(app);
assert!(html.contains("Investor Portal"), "{html}");
assert!(html.contains("Custom Fund"), "{html}");
assert!(!html.contains("Quy Nhon Fund"), "{html}");
assert!(html.contains("href=\"/home\""), "{html}");
}
#[test]
fn compact_variant_is_fixed_opaque_bar() {
fn app() -> Element {
rsx! {
Header { nav: nav(), variant: HeaderVariant::Compact }
}
}
let html = render(app);
assert!(html.contains("data-variant=\"compact\""), "{html}");
assert!(html.contains("h-16"), "fixed 4rem bar: {html}");
assert!(html.contains("bg-main-black/90"), "opaque by default: {html}");
assert!(!html.contains("bg-transparent"), "compact never goes transparent: {html}");
}
#[test]
fn hide_nav_drops_nav_and_menu_trigger() {
fn app() -> Element {
rsx! {
Header { nav: nav(), hide_nav: true }
}
}
let html = render(app);
assert!(html.contains("EV INVESTMENT"), "lockup stays: {html}");
assert!(!html.contains("href=\"/team\""), "desktop nav dropped: {html}");
assert!(!html.contains("aria-label=\"Open menu\""), "mobile trigger dropped: {html}");
}
}