Skip to content
Merged
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
179 changes: 86 additions & 93 deletions app/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import { CSSProperties, MouseEvent as ReactMouseEvent, PropsWithChildren, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';
import { Dialog } from '@base-ui/react/dialog';
import { AnimatePresence, motion, useMotionTemplate, useMotionValue, useReducedMotion, type MotionValue } from 'motion/react';
import { Toaster } from 'sonner';

import { getActiveParent, isChildActive, isTopNavActive, navActiveParent, navHighlightPath, NAV_ITEMS, NavIcon, titleForPath } from '../navigation';
Expand Down Expand Up @@ -43,15 +44,14 @@ type NavGlyphProps = {
};

const styles: Record<string, CSSProperties> = {
root: { display: 'flex', flex: 1, overflow: 'hidden', color: INK },
root: { display: 'flex', flexGrow: 1, flexShrink: 0, color: INK },
sidebar: {
width: SIDEBAR_WIDTH,
flexShrink: 0,
borderRight: `1px solid ${SELECTED}`,
display: 'flex',
flexDirection: 'column',
padding: '0 0 20px',
position: 'relative',
overflow: 'hidden',
},
brand: {
Expand Down Expand Up @@ -169,7 +169,9 @@ const styles: Record<string, CSSProperties> = {
color: 'var(--bds-gray-50)',
},
switchThumbOn: { transform: 'translateX(14px)', color: BRAND_BLUE },
main: { flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' },
main: { flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 },
// Grow on short pages so the activity drawer can sit at the bottom; don't
// shrink, or tall pages compress instead of letting the document scroll.
topbar: {
height: 65,
flexShrink: 0,
Expand All @@ -180,8 +182,8 @@ const styles: Record<string, CSSProperties> = {
padding: '0 28px',
},
topbarTitle: { fontSize: 16, fontWeight: 500 },
content: { flex: 1, overflowY: 'auto', overflowX: 'hidden', display: 'flex', flexDirection: 'column' as const, minWidth: 0 },
contentInner: { width: '100%', maxWidth: 1280, margin: '0 auto', padding: '24px 28px 80px', flex: 1, display: 'flex', flexDirection: 'column' as const, minWidth: 0 },
content: { flexGrow: 1, flexShrink: 0, display: 'flex', flexDirection: 'column' as const, minWidth: 0 },
contentInner: { width: '100%', maxWidth: 1280, margin: '0 auto', padding: '24px 28px 80px', flexGrow: 1, flexShrink: 0, display: 'flex', flexDirection: 'column' as const, minWidth: 0 },
};

function NavGlyph({ name }: NavGlyphProps) {
Expand Down Expand Up @@ -375,6 +377,8 @@ const slideVariants = {
};

const slideTransition = { duration: 0.2, ease: [0.23, 1, 0.32, 1] as const };
/** Matches `h-9` / theme(spacing.9). */
const APP_BANNER_HEIGHT = '2.25rem';

// How long the nav trusts a tapped href before falling back to the router. Only
// reached if a navigation never commits (aborted, failed, or a modified click that
Expand Down Expand Up @@ -646,53 +650,57 @@ type GlobalBannerProps = {
dismissed: boolean;
onDismiss: () => void;
className?: string;
height: MotionValue<string>;
};

function GlobalBanner({ dismissed, onDismiss, className }: GlobalBannerProps) {
function GlobalBanner({ dismissed, onDismiss, className, height }: GlobalBannerProps) {
const reducedMotion = useReducedMotion();
const transition = reducedMotion ? { duration: 0 } : slideTransition;

return (
<AnimatePresence initial={false}>
{!dismissed && (
<motion.div
key="global-banner"
className={cn(className, 'relative overflow-clip after:inset-x-0 after:absolute after:bottom-0 after:border-b after:border-bds-gray-10 bg-bds-gray-5')}
initial={false}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={transition}
>
<div className="relative flex w-full items-center justify-center border-b border-transparent py-2 pl-4 pr-10 sm:px-10">
<div className="mx-auto flex w-full max-w-6xl flex-col items-center justify-center min-[860px]:flex-row min-[860px]:gap-5">
<div className="flex min-w-0 flex-wrap items-center justify-center gap-x-2 gap-y-1 min-[860px]:flex-nowrap">
<Text as="span" variant="label.medium" className="whitespace-nowrap">New!</Text>
<Text as="span" variant="label.medium" className="whitespace-nowrap">EIP-8130: Accounts</Text>
<span className="inline-block h-3.5 w-px shrink-0 bg-bds-gray-20"></span>
<Link href="/vibenet/demos/account" className="group flex shrink-0 items-center gap-1 no-underline">
<Text as="span" variant="label.medium" className="text-base-blue">Test on Vibenet</Text>
<AnimatedArrowIcon size={14} strokeWidth={2} className="text-base-blue transition-transform duration-200 ease-out group-hover:translate-x-[3px]" />
</Link>
</div>
</div>
<button
type="button"
onClick={onDismiss}
className="absolute right-4 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center text-bds-gray-40 transition-colors hover:text-foreground"
aria-label="Dismiss banner"
>
<CloseIcon size={10} />
</button>
<motion.div
className={cn(className, 'overflow-clip after:pointer-events-none after:inset-x-0 after:absolute after:bottom-0 after:border-b after:border-bds-gray-10 bg-bds-gray-5')}
style={{ height }}
initial={false}
animate={{ height: dismissed ? '0rem' : APP_BANNER_HEIGHT }}
transition={transition}
>
<motion.div
className="relative flex w-full shrink-0 items-center justify-center border-b border-transparent pl-4 pr-10 sm:px-10"
initial={false}
animate={{ opacity: dismissed ? 0 : 1 }}
transition={transition}
style={{ height: APP_BANNER_HEIGHT }}
>
<div className="mx-auto flex w-full max-w-6xl flex-col items-center justify-center min-[860px]:flex-row min-[860px]:gap-5">
<div className="flex min-w-0 flex-wrap items-center justify-center gap-x-2 gap-y-1 min-[860px]:flex-nowrap">
<Text as="span" variant="label.medium" className="whitespace-nowrap">New!</Text>
<Text as="span" variant="label.medium" className="whitespace-nowrap">EIP-8130: Accounts</Text>
<span className="inline-block h-3.5 w-px shrink-0 bg-bds-gray-20"></span>
<Link href="/vibenet/demos/account" className="group flex shrink-0 items-center gap-1 no-underline">
<Text as="span" variant="label.medium" className="text-base-blue">Test on Vibenet</Text>
<AnimatedArrowIcon size={14} strokeWidth={2} className="text-base-blue transition-transform duration-200 ease-out group-hover:translate-x-[3px]" />
</Link>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<button
type="button"
onClick={onDismiss}
className="absolute right-4 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center text-bds-gray-40 transition-colors hover:text-foreground"
aria-label="Dismiss banner"
>
<CloseIcon size={10} />
</button>
</motion.div>
</motion.div>
);
}

export function AppShell({ children }: PropsWithChildren) {
const pathname = usePathname() || '/';
const title = titleForPath(pathname);
const bannerHeight = useMotionValue(APP_BANNER_HEIGHT);
const sidebarHeight = useMotionTemplate`calc(100dvh - ${bannerHeight})`;
const [menuOpen, setMenuOpen] = useState(false);
const [bannerDismissed, setBannerDismissed] = useState(false);
// Starts false on both server and client so the first render matches; the
Expand All @@ -715,84 +723,69 @@ export function AppShell({ children }: PropsWithChildren) {
}
};

useEffect(() => {
if (!menuOpen) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMenuOpen(false); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [menuOpen]);

useEffect(() => {
document.body.style.overflow = menuOpen ? 'hidden' : '';
return () => { document.body.style.overflow = ''; };
}, [menuOpen]);

return (
<div className="flex h-screen flex-col overflow-hidden">
<div className="flex min-h-dvh flex-col">
{/* Desktop: banner spans the full width above the shell. On mobile it is
rendered below the fixed header instead (see below), so it isn't hidden
behind it. */}
<GlobalBanner
dismissed={bannerDismissed}
onDismiss={() => setBannerDismissed(true)}
className="hidden md:block"
height={bannerHeight}
className="sticky top-0 z-50 hidden md:block"
/>
<div style={styles.root}>
{/* Desktop sidebar */}
<aside className="sidebar-desktop" style={styles.sidebar}>
<motion.aside
className="sidebar-desktop sticky self-start"
style={{ ...styles.sidebar, top: bannerHeight, height: sidebarHeight }}
>
<SidebarContent dark={dark} onToggleTheme={toggleTheme} />
</aside>
</motion.aside>

{/* Mobile header (logo + hamburger) */}
<header className="mobile-header">
{/* Static on touch: the morph is hover-driven, so base.org leaves its
mobile mark static too. */}
<Link href="/" aria-label="Base home" style={styles.brandLink} onClick={() => setMenuOpen(false)}>
<BaseMark size={MOBILE_BRAND_MARK_SIZE} />
</Link>
<button
className="hamburger-btn"
onClick={() => setMenuOpen(!menuOpen)}
aria-label="Toggle menu"
aria-expanded={menuOpen}
>
<svg width={20} height={20} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round">
<line className={`hamburger-line hamburger-top ${menuOpen ? 'open' : ''}`} x1="4" y1="10" x2="16" y2="10" />
<line className={`hamburger-line hamburger-bottom ${menuOpen ? 'open' : ''}`} x1="4" y1="10" x2="16" y2="10" />
</svg>
</button>
</header>

{/* Mobile drawer (full-screen from right) */}
<AnimatePresence>
{menuOpen && (
<motion.aside
className="drawer"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
>
<Dialog.Root open={menuOpen} onOpenChange={setMenuOpen}>
<header className="mobile-header">
{/* Static on touch: the morph is hover-driven, so base.org leaves its
mobile mark static too. */}
<Link href="/" aria-label="Base home" style={styles.brandLink} onClick={() => setMenuOpen(false)}>
<BaseMark size={MOBILE_BRAND_MARK_SIZE} />
</Link>
<Dialog.Trigger className="hamburger-btn" aria-label={menuOpen ? 'Close menu' : 'Open menu'}>
<svg width={20} height={20} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round">
<line className={`hamburger-line hamburger-top ${menuOpen ? 'open' : ''}`} x1="4" y1="10" x2="16" y2="10" />
<line className={`hamburger-line hamburger-bottom ${menuOpen ? 'open' : ''}`} x1="4" y1="10" x2="16" y2="10" />
</svg>
</Dialog.Trigger>
</header>

<Dialog.Portal>
<Dialog.Popup className="drawer outline-none [transform:translate3d(0,0,0)] transition-transform duration-300 ease-out data-[ending-style]:[transform:translate3d(100%,0,0)] data-[starting-style]:[transform:translate3d(100%,0,0)] motion-reduce:transition-none">
<Dialog.Title className="sr-only">Menu</Dialog.Title>
<SidebarContent
dark={dark}
onToggleTheme={toggleTheme}
onNavigate={() => setMenuOpen(false)}
hideBrand
layoutScope="mobile"
/>
</motion.aside>
)}
</AnimatePresence>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>

<div className="mobile-content-offset" style={styles.main}>
<div className="pt-14 md:pt-0" style={styles.main}>
{/* Mobile: banner sits below the fixed header (which the top slot is
hidden behind), so it's visible without scrolling. */}
<GlobalBanner
dismissed={bannerDismissed}
onDismiss={() => setBannerDismissed(true)}
className="block md:hidden"
height={bannerHeight}
className="sticky top-14 z-50 block md:hidden"
/>
<header className="topbar-desktop" style={{ ...styles.topbar, position: 'relative', zIndex: 40 }}>
<motion.header
className="topbar-desktop sticky z-40 bg-background"
style={{ ...styles.topbar, top: bannerHeight }}
>
<div id="topbar-actions-slot" className="absolute right-7 top-1/2 z-10 flex -translate-y-1/2 items-center gap-2" />
{(() => {
const slugMatch = pathname.match(/^\/upgrades\/changelog\/(.+)$/);
Expand Down Expand Up @@ -842,8 +835,8 @@ export function AppShell({ children }: PropsWithChildren) {
}
return <Text as="span" variant="headline">{title}</Text>;
})()}
</header>
<main className="content-scroll" style={styles.content}>
</motion.header>
<main className="[container-type:inline-size]" style={styles.content}>
<div style={styles.contentInner}>{children}</div>
</main>
</div>
Expand Down
33 changes: 18 additions & 15 deletions app/components/ui/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ComponentPropsWithoutRef } from 'react';
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import Link from 'next/link';

import { cn } from './cn';
Expand All @@ -25,18 +25,21 @@ const variantClasses = {
'text-foreground bg-transparent border border-bds-gray-10 hover:bg-bds-gray-5 dark:border-white/[.12] dark:hover:bg-white/[.06]',
} as const;

export function Button({
className = '',
type = 'button',
variant = 'primary',
size = 'default',
arrow = false,
href,
target,
rel,
children,
...props
}: ButtonProps) {
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
className = '',
type = 'button',
variant = 'primary',
size = 'default',
arrow = false,
href,
target,
rel,
children,
...props
},
ref,
) {
let sizeClasses: string;
if (size === 'sm') {
sizeClasses = 'h-[34px] px-3 gap-1 pb-px';
Expand Down Expand Up @@ -65,9 +68,9 @@ export function Button({
}

return (
<button type={type === 'submit' ? 'submit' : 'button'} className={classes} {...props}>
<button ref={ref} type={type === 'submit' ? 'submit' : 'button'} className={classes} {...props}>
{children}
{arrow && <AnimatedArrowIcon className="transition-transform duration-200 ease-out group-hover:translate-x-[3px]" />}
</button>
);
}
});
Loading
Loading