diff --git a/src/shell/TopBar.test.tsx b/src/shell/TopBar.test.tsx new file mode 100644 index 0000000..b7766ad --- /dev/null +++ b/src/shell/TopBar.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@/test/render' +import { TopBar } from './TopBar' + +vi.mock('next/navigation', () => ({ + usePathname: () => '/explore', + useRouter: () => ({ push: vi.fn() }), +})) + +vi.mock('../wallet/WalletProvider', () => ({ + useWallet: () => ({ + connected: false, + address: null, + connecting: false, + isDemo: false, + disconnect: vi.fn(), + }), + shortAddress: (address: string) => address, +})) + +class MockIntersectionObserver { + observe() {} + disconnect() {} +} + +Object.defineProperty(window, 'IntersectionObserver', { + writable: true, + value: MockIntersectionObserver, +}) + +describe('TopBar theme toggle', () => { + it('announces the dark theme as pressed', async () => { + document.documentElement.dataset.theme = 'dark' + + render() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Switch to light' })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + }) + }) + + it('announces the light theme as not pressed', async () => { + document.documentElement.dataset.theme = 'light' + + render() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Switch to dark' })).toHaveAttribute( + 'aria-pressed', + 'false', + ) + }) + }) +}) diff --git a/src/shell/TopBar.tsx b/src/shell/TopBar.tsx index 695c2f3..d830366 100644 --- a/src/shell/TopBar.tsx +++ b/src/shell/TopBar.tsx @@ -1,516 +1,522 @@ -'use client' - -import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' -import Link from 'next/link' -import { usePathname, useRouter } from 'next/navigation' -import { useTranslations } from 'next-intl' -import { Button } from '../components' -import { Mark } from '../brand/Mark' -import { useLocaleSwitcher } from '../i18n/LocaleProvider' -import { useWallet, shortAddress } from '../wallet/WalletProvider' -import { useTheme } from '../theme/ThemeProvider' - -/** - * TopBar — persistent nav rendered by the root layout. Analemma mark + Explore / - * How it works / Learn / Creator, network status dot, theme toggle, language - * switcher, Connect (or the connected wallet pill). Active state derives from the - * route (and a scroll-spy for the landing anchors); connection from the wallet. - */ -const NAV = [ - { href: '/explore', key: 'explore' }, - { href: '/#how', key: 'how' }, - { href: '/#verify', key: 'learn' }, - { href: '/creator', key: 'creator' }, -] as const - -export function TopBar() { - const pathname = usePathname() - const router = useRouter() - const t = useTranslations('Nav') - const { locale, switchLocale } = useLocaleSwitcher() - const { connected, address, connecting, isDemo } = useWallet() - const { theme, toggle } = useTheme() - - // Theme state starts 'light' on server/first render (to avoid a hydration - // mismatch), so the toggle icon can't be trusted until after mount — a - // dark-mode user would briefly see the wrong icon. Gate it on `mounted`. - const [mounted, setMounted] = useState(false) - // eslint-disable-next-line react-hooks/set-state-in-effect - useEffect(() => setMounted(true), []) - - // Scroll-spy for the anchor nav items (How it works / Learn). usePathname() - // drops the hash, so observe the landing sections instead. SSR-safe. - const [activeHash, setActiveHash] = useState('') +'use client' + +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import Link from 'next/link' +import { usePathname, useRouter } from 'next/navigation' +import { useTranslations } from 'next-intl' +import { Button } from '../components' +import { Mark } from '../brand/Mark' +import { useLocaleSwitcher } from '../i18n/LocaleProvider' +import { useWallet, shortAddress } from '../wallet/WalletProvider' +import { useTheme } from '../theme/ThemeProvider' + +/** + * TopBar — persistent nav rendered by the root layout. Analemma mark + Explore / + * How it works / Learn / Creator, network status dot, theme toggle, language + * switcher, Connect (or the connected wallet pill). Active state derives from the + * route (and a scroll-spy for the landing anchors); connection from the wallet. + */ +const NAV = [ + { href: '/explore', key: 'explore' }, + { href: '/#how', key: 'how' }, + { href: '/#verify', key: 'learn' }, + { href: '/creator', key: 'creator' }, +] as const + +export function TopBar() { + const pathname = usePathname() + const router = useRouter() + const t = useTranslations('Nav') + const { locale, switchLocale } = useLocaleSwitcher() + const { connected, address, connecting, isDemo } = useWallet() + const { theme, toggle } = useTheme() + + // Theme state starts 'light' on server/first render (to avoid a hydration + // mismatch), so the toggle icon can't be trusted until after mount — a + // dark-mode user would briefly see the wrong icon. Gate it on `mounted`. + const [mounted, setMounted] = useState(false) + // eslint-disable-next-line react-hooks/set-state-in-effect + useEffect(() => setMounted(true), []) + + // Scroll-spy for the anchor nav items (How it works / Learn). usePathname() + // drops the hash, so observe the landing sections instead. SSR-safe. + const [activeHash, setActiveHash] = useState('') useEffect(() => { if (pathname !== '/') { // eslint-disable-next-line react-hooks/set-state-in-effect setActiveHash('') - return - } - const sections = ['how', 'verify'] - .map((id) => document.getElementById(id)) - .filter((el): el is HTMLElement => el !== null) - if (!sections.length) return - const observer = new IntersectionObserver( - (entries) => { - const top = entries - .filter((e) => e.isIntersecting) - .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0] - if (top) setActiveHash(`#${top.target.id}`) - }, - { rootMargin: '-45% 0px -50% 0px' }, - ) + return + } + const sections = ['how', 'verify'] + .map((id) => document.getElementById(id)) + .filter((el): el is HTMLElement => el !== null) + if (!sections.length) return + const observer = new IntersectionObserver( + (entries) => { + const top = entries + .filter((e) => e.isIntersecting) + .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0] + if (top) setActiveHash(`#${top.target.id}`) + }, + { rootMargin: '-45% 0px -50% 0px' }, + ) sections.forEach((el) => observer.observe(el)) return () => observer.disconnect() }, [pathname]) - return ( -
- - - - heliobond - - - - - -
- - - + const isDarkTheme = mounted && theme === 'dark' + const themeToggleLabel = isDarkTheme ? t('switchToLight') : t('switchToDark') + + return ( +
+ + + + heliobond + + + + + +
+ + + - - {connected && address ? ( - - ) : ( - - )} -
-
- ) -} - -const iconBtnStyle = { - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - width: 38, - height: 38, - borderRadius: 'var(--radius-pill)', - background: 'none', - border: 'none', - cursor: 'pointer', - color: 'var(--ink-60)', -} as const - -function ChevronDown() { - return ( - - ) -} -function MoonIcon() { - return ( - - ) -} -function SunIcon() { - return ( - - ) -} - -/** The connected wallet pill + its account menu (incl. Disconnect / sign out). */ -function WalletMenu({ address, isDemo }: { address: string; isDemo: boolean }) { - const t = useTranslations('Nav') - const router = useRouter() - const { disconnect } = useWallet() - const [open, setOpen] = useState(false) - const [copied, setCopied] = useState(false) - const ref = useRef(null) - const triggerRef = useRef(null) - const itemRefs = useRef<(HTMLButtonElement | HTMLAnchorElement | null)[]>([]) - - // Focus first item when menu opens; restore trigger focus when it closes. - const prevOpen = useRef(false) - useEffect(() => { - if (open && !prevOpen.current) { - setTimeout(() => itemRefs.current[0]?.focus(), 0) - } - if (!open && prevOpen.current) { - triggerRef.current?.focus() - } - prevOpen.current = open - }, [open]) - - useEffect(() => { - if (!open) return - const onDown = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) - } - document.addEventListener('mousedown', onDown) - return () => document.removeEventListener('mousedown', onDown) - }, [open]) - - const copy = () => { - try { - navigator.clipboard?.writeText(address) - } catch { - /* ignore */ - } - setCopied(true) - setTimeout(() => setCopied(false), 1400) - } - - const handleMenuKeyDown = (e: React.KeyboardEvent) => { - const items = itemRefs.current.filter( - (el): el is HTMLButtonElement | HTMLAnchorElement => el !== null, - ) - if (!items.length) return - const focused = document.activeElement - const idx = items.indexOf(focused as HTMLButtonElement) - - if (e.key === 'ArrowDown') { - e.preventDefault() - items[(idx + 1) % items.length].focus() - } else if (e.key === 'ArrowUp') { - e.preventDefault() - items[(idx - 1 + items.length) % items.length].focus() - } else if (e.key === 'Home') { - e.preventDefault() - items[0].focus() - } else if (e.key === 'End') { - e.preventDefault() - items[items.length - 1].focus() - } else if (e.key === 'Escape') { - setOpen(false) - } else if (e.key === 'Tab') { - setOpen(false) - } - } - - // Reset item refs array before each render so stale refs don't linger - // eslint-disable-next-line react-hooks/refs - itemRefs.current = [] - - return ( -
- - - {open && ( -
-
- - {shortAddress(address, 6, 6)} - - - {isDemo ? t('demoSession') : t('testnet')} - -
-
- { - itemRefs.current.push(el) - }} - onClick={() => { - setOpen(false) - router.push('/portfolio') - }} - > - {t('portfolio')} - - { - itemRefs.current.push(el) - }} - onClick={copy} - > - {copied ? t('copied') : t('copyAddress')} - - {!isDemo && ( - { - itemRefs.current.push(el) - }} - href={`https://stellar.expert/explorer/testnet/account/${address}`} - > - {t('viewOnExplorer')} - - )} - { - itemRefs.current.push(el) - }} - tone="ember" - onClick={() => { - disconnect() - setOpen(false) - router.push('/') - }} - > - {t('disconnect')} - -
- )} -
- ) -} - -const MenuItem = function MenuItem({ - children, - onClick, - tone, - ref: _ref, -}: { - children: ReactNode - onClick: () => void - tone?: 'ember' - ref?: ((el: HTMLButtonElement | null) => void) | null -}) { - const [hover, setHover] = useState(false) - return ( - - ) -} - -const MenuLink = function MenuLink({ - children, - href, - ref: _ref, -}: { - children: ReactNode - href: string - ref?: ((el: HTMLAnchorElement | null) => void) | null -}) { - const [hover, setHover] = useState(false) - return ( - setHover(true)} - onMouseLeave={() => setHover(false)} - style={{ - ...menuItemStyle, - textDecoration: 'none', - background: hover ? 'var(--ink-06)' : 'transparent', - color: 'var(--ink)', - }} - > - {children} ↗ - - ) -} - -const menuItemStyle: CSSProperties = { - display: 'block', - width: '100%', - textAlign: 'left', - border: 'none', - cursor: 'pointer', - padding: '9px 10px', - borderRadius: 'var(--radius-input)', - fontFamily: 'var(--font-body)', - fontSize: 14, - fontWeight: 500, -} + ...iconBtnStyle, + width: 'auto', + gap: 5, + padding: '0 6px', + fontFamily: 'var(--font-body)', + fontSize: 14, + color: 'var(--ink-60)', + }} + > + {locale.toUpperCase()} + + + + {connected && address ? ( + + ) : ( + + )} +
+
+ ) +} + +const iconBtnStyle = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 38, + height: 38, + borderRadius: 'var(--radius-pill)', + background: 'none', + border: 'none', + cursor: 'pointer', + color: 'var(--ink-60)', +} as const + +function ChevronDown() { + return ( + + ) +} +function MoonIcon() { + return ( + + ) +} +function SunIcon() { + return ( + + ) +} + +/** The connected wallet pill + its account menu (incl. Disconnect / sign out). */ +function WalletMenu({ address, isDemo }: { address: string; isDemo: boolean }) { + const t = useTranslations('Nav') + const router = useRouter() + const { disconnect } = useWallet() + const [open, setOpen] = useState(false) + const [copied, setCopied] = useState(false) + const ref = useRef(null) + const triggerRef = useRef(null) + const itemRefs = useRef<(HTMLButtonElement | HTMLAnchorElement | null)[]>([]) + + // Focus first item when menu opens; restore trigger focus when it closes. + const prevOpen = useRef(false) + useEffect(() => { + if (open && !prevOpen.current) { + setTimeout(() => itemRefs.current[0]?.focus(), 0) + } + if (!open && prevOpen.current) { + triggerRef.current?.focus() + } + prevOpen.current = open + }, [open]) + + useEffect(() => { + if (!open) return + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', onDown) + return () => document.removeEventListener('mousedown', onDown) + }, [open]) + + const copy = () => { + try { + navigator.clipboard?.writeText(address) + } catch { + /* ignore */ + } + setCopied(true) + setTimeout(() => setCopied(false), 1400) + } + + const handleMenuKeyDown = (e: React.KeyboardEvent) => { + const items = itemRefs.current.filter( + (el): el is HTMLButtonElement | HTMLAnchorElement => el !== null, + ) + if (!items.length) return + const focused = document.activeElement + const idx = items.indexOf(focused as HTMLButtonElement) + + if (e.key === 'ArrowDown') { + e.preventDefault() + items[(idx + 1) % items.length].focus() + } else if (e.key === 'ArrowUp') { + e.preventDefault() + items[(idx - 1 + items.length) % items.length].focus() + } else if (e.key === 'Home') { + e.preventDefault() + items[0].focus() + } else if (e.key === 'End') { + e.preventDefault() + items[items.length - 1].focus() + } else if (e.key === 'Escape') { + setOpen(false) + } else if (e.key === 'Tab') { + setOpen(false) + } + } + + // Reset item refs array before each render so stale refs don't linger + // eslint-disable-next-line react-hooks/refs + itemRefs.current = [] + + return ( +
+ + + {open && ( +
+
+ + {shortAddress(address, 6, 6)} + + + {isDemo ? t('demoSession') : t('testnet')} + +
+
+ { + itemRefs.current.push(el) + }} + onClick={() => { + setOpen(false) + router.push('/portfolio') + }} + > + {t('portfolio')} + + { + itemRefs.current.push(el) + }} + onClick={copy} + > + {copied ? t('copied') : t('copyAddress')} + + {!isDemo && ( + { + itemRefs.current.push(el) + }} + href={`https://stellar.expert/explorer/testnet/account/${address}`} + > + {t('viewOnExplorer')} + + )} + { + itemRefs.current.push(el) + }} + tone="ember" + onClick={() => { + disconnect() + setOpen(false) + router.push('/') + }} + > + {t('disconnect')} + +
+ )} +
+ ) +} + +const MenuItem = function MenuItem({ + children, + onClick, + tone, + ref: _ref, +}: { + children: ReactNode + onClick: () => void + tone?: 'ember' + ref?: ((el: HTMLButtonElement | null) => void) | null +}) { + const [hover, setHover] = useState(false) + return ( + + ) +} + +const MenuLink = function MenuLink({ + children, + href, + ref: _ref, +}: { + children: ReactNode + href: string + ref?: ((el: HTMLAnchorElement | null) => void) | null +}) { + const [hover, setHover] = useState(false) + return ( + setHover(true)} + onMouseLeave={() => setHover(false)} + style={{ + ...menuItemStyle, + textDecoration: 'none', + background: hover ? 'var(--ink-06)' : 'transparent', + color: 'var(--ink)', + }} + > + {children} ↗ + + ) +} + +const menuItemStyle: CSSProperties = { + display: 'block', + width: '100%', + textAlign: 'left', + border: 'none', + cursor: 'pointer', + padding: '9px 10px', + borderRadius: 'var(--radius-input)', + fontFamily: 'var(--font-body)', + fontSize: 14, + fontWeight: 500, +}