diff --git a/app/components/AppShell.tsx b/app/components/AppShell.tsx index 1cb9424..096665b 100644 --- a/app/components/AppShell.tsx +++ b/app/components/AppShell.tsx @@ -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'; @@ -43,7 +44,7 @@ type NavGlyphProps = { }; const styles: Record = { - root: { display: 'flex', flex: 1, overflow: 'hidden', color: INK }, + root: { display: 'flex', flexGrow: 1, flexShrink: 0, color: INK }, sidebar: { width: SIDEBAR_WIDTH, flexShrink: 0, @@ -51,7 +52,6 @@ const styles: Record = { display: 'flex', flexDirection: 'column', padding: '0 0 20px', - position: 'relative', overflow: 'hidden', }, brand: { @@ -169,7 +169,9 @@ const styles: Record = { 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, @@ -180,8 +182,8 @@ const styles: Record = { 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) { @@ -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 @@ -646,53 +650,57 @@ type GlobalBannerProps = { dismissed: boolean; onDismiss: () => void; className?: string; + height: MotionValue; }; -function GlobalBanner({ dismissed, onDismiss, className }: GlobalBannerProps) { +function GlobalBanner({ dismissed, onDismiss, className, height }: GlobalBannerProps) { const reducedMotion = useReducedMotion(); const transition = reducedMotion ? { duration: 0 } : slideTransition; return ( - - {!dismissed && ( - -
-
-
- New! - EIP-8130: Accounts - - - Test on Vibenet - - -
-
- + + +
+
+ New! + EIP-8130: Accounts + + + Test on Vibenet + +
- - )} - +
+ +
+
); } 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 @@ -715,64 +723,45 @@ 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 ( -
+
{/* 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. */} setBannerDismissed(true)} - className="hidden md:block" + height={bannerHeight} + className="sticky top-0 z-50 hidden md:block" />
{/* Desktop sidebar */} - + {/* Mobile header (logo + hamburger) */} -
- {/* Static on touch: the morph is hover-driven, so base.org leaves its - mobile mark static too. */} - setMenuOpen(false)}> - - - -
- - {/* Mobile drawer (full-screen from right) */} - - {menuOpen && ( - + +
+ {/* Static on touch: the morph is hover-driven, so base.org leaves its + mobile mark static too. */} + setMenuOpen(false)}> + + + + + + + + +
+ + + + Menu -
- )} -
+ + + -
+
{/* Mobile: banner sits below the fixed header (which the top slot is hidden behind), so it's visible without scrolling. */} setBannerDismissed(true)} - className="block md:hidden" + height={bannerHeight} + className="sticky top-14 z-50 block md:hidden" /> -
+
{(() => { const slugMatch = pathname.match(/^\/upgrades\/changelog\/(.+)$/); @@ -842,8 +835,8 @@ export function AppShell({ children }: PropsWithChildren) { } return {title}; })()} -
-
+ +
{children}
diff --git a/app/components/ui/Button.tsx b/app/components/ui/Button.tsx index 5afca88..9d4bc82 100644 --- a/app/components/ui/Button.tsx +++ b/app/components/ui/Button.tsx @@ -1,4 +1,4 @@ -import type { ComponentPropsWithoutRef } from 'react'; +import { forwardRef, type ComponentPropsWithoutRef } from 'react'; import Link from 'next/link'; import { cn } from './cn'; @@ -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(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'; @@ -65,9 +68,9 @@ export function Button({ } return ( - ); -} +}); diff --git a/app/components/ui/FilterSelect.tsx b/app/components/ui/FilterSelect.tsx index 07e66b4..a5535b6 100644 --- a/app/components/ui/FilterSelect.tsx +++ b/app/components/ui/FilterSelect.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { Select } from '@base-ui/react/select'; -import { cn } from './cn'; +import { Button } from './Button'; type Option = { value: string; @@ -18,107 +18,54 @@ type FilterSelectProps = { }; export function FilterSelect({ value, onChange, ariaLabel, options, minDropdownWidth }: FilterSelectProps) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - const sizerRef = useRef(null); - const [dropdownW, setDropdownW] = useState(); - const selected = options.find((o) => o.value === value); - - useEffect(() => { - if (!sizerRef.current) return; - const spans = sizerRef.current.children; - let max = 0; - for (let i = 0; i < spans.length; i++) { - max = Math.max(max, (spans[i] as HTMLElement).offsetWidth); - } - setDropdownW(max + 24); - }, [options]); - - const toggle = useCallback(() => setOpen((o) => !o), []); - - const handleSelect = useCallback( - (v: string) => { - onChange(v); - setOpen(false); - }, - [onChange], - ); - - useEffect(() => { - if (!open) return; - const onDown = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setOpen(false); - }; - document.addEventListener('mousedown', onDown); - document.addEventListener('keydown', onKey); - return () => { - document.removeEventListener('mousedown', onDown); - document.removeEventListener('keydown', onKey); - }; - }, [open]); - return ( -
- - - - {options.map((o) => ( - {o.label} - ))} - - - {open && ( -
{ + if (next != null) onChange(next); + }} + items={options} + > + }> + + - {options.map((option) => ( - - ))} -
- )} -
+ + + + + + + + {options.map((option) => ( + + {option.label} + + ))} + + + + + ); } diff --git a/app/components/ui/Modal.tsx b/app/components/ui/Modal.tsx index df5145c..739caff 100644 --- a/app/components/ui/Modal.tsx +++ b/app/components/ui/Modal.tsx @@ -1,12 +1,11 @@ 'use client'; -import { useCallback, useEffect, useId, useRef } from 'react'; -import type { MouseEvent, ReactNode } from 'react'; -import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; +import type { ReactNode } from 'react'; +import { Dialog } from '@base-ui/react/dialog'; import { cn } from './cn'; import { CloseIcon } from './icons'; -import { Text } from './Text'; +import { textVariantClasses } from './Text'; type ModalProps = { open: boolean; @@ -19,104 +18,45 @@ type ModalProps = { className?: string; }; -const BACKDROP_TRANSITION = { duration: 0.15 } as const; -const PANEL_TRANSITION = { type: 'spring', bounce: 0, duration: 0.24 } as const; - -// Reusable centered modal: backdrop, spring-in panel, header with a close -// button, scrollable body, and an optional pinned footer. Extracted from the -// account demo's five in-page modals so any surface can reuse one open/close -// pattern. Backdrop click and Escape both close. +// Centered modal on Base UI Dialog: focus trap, restore-focus, Escape, +// dismiss-on-outside-click, and document scroll lock all come from the library. export function Modal({ open, onClose, title, children, footer, className }: ModalProps) { - const panelRef = useRef(null); - const titleId = useId(); - - // Callers pass an inline `onClose` (new identity every render). Keep the latest - // in a ref so the focus/scroll-lock effect can depend on `open` alone — keying - // it on `onClose` re-runs the effect on every parent re-render, and the cleanup - // (`previouslyFocused?.focus?.()`) then steals focus out of the panel's inputs - // after a single keystroke. - const onCloseRef = useRef(onClose); - onCloseRef.current = onClose; - - useEffect(() => { - if (!open) return; - // Remember what to return focus to, so closing doesn't dump the user at the - // top of the page. - const previouslyFocused = document.activeElement as HTMLElement | null; - const onKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') onCloseRef.current(); - }; - document.addEventListener('keydown', onKey); - // Lock background scroll while the modal is open. - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - // Move focus into the dialog so screen readers announce it (via aria-labelledby) - // and keyboard interaction starts inside the panel rather than behind it. - const raf = requestAnimationFrame(() => panelRef.current?.focus()); - return () => { - document.removeEventListener('keydown', onKey); - document.body.style.overflow = previousOverflow; - cancelAnimationFrame(raf); - previouslyFocused?.focus?.(); - }; - }, [open]); - - const stop = useCallback((event: MouseEvent) => event.stopPropagation(), []); - const reducedMotion = useReducedMotion(); - const panelTransition = reducedMotion ? { duration: 0.1 } : PANEL_TRANSITION; - return ( - - {open ? ( - { + if (!nextOpen) onClose(); + }} + > + + + - -
- - {title} - - +
+ + {title} + + + + +
+ +
{children}
+ + {footer ? ( +
+ {footer}
- -
{children}
- - {footer ? ( -
- {footer} -
- ) : null} - - - ) : null} - + ) : null} + + + ); } diff --git a/app/globals.css b/app/globals.css index de41cc0..46dc118 100644 --- a/app/globals.css +++ b/app/globals.css @@ -807,10 +807,6 @@ button { overflow: hidden; } - .mobile-content-offset { - padding-top: 56px; - } - /* iOS Safari auto-zooms the page when a focused input's font-size is < 16px, which shrinks the visual viewport and pushes fixed modals (and their close button) off-screen. Force >=16px on mobile so focusing a field never zooms. @@ -825,14 +821,6 @@ button { } } -/* Activity drawer: span the content scrollport without 100vw. 100vw includes - the scrollbar and is wider than the nested max-w-5xl column, so it created - a horizontal scrollbar. `cqw` is the .content-scroll column's client width. */ -.content-scroll { - container-type: inline-size; - container-name: content-scroll; -} - .activity-full-width { width: auto; margin-left: calc(50% - 50cqw); diff --git a/app/layout.tsx b/app/layout.tsx index 198ae59..bae4d28 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -88,7 +88,7 @@ export default function RootLayout({ children }: PropsWithChildren) { return ( diff --git a/app/upgrades/changelog/ChangelogClient.tsx b/app/upgrades/changelog/ChangelogClient.tsx index 6f758b9..3a9c009 100644 --- a/app/upgrades/changelog/ChangelogClient.tsx +++ b/app/upgrades/changelog/ChangelogClient.tsx @@ -3,14 +3,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ChangeEvent } from 'react'; import Link from 'next/link'; -import { AnimatePresence, motion, useReducedMotion } from 'motion/react'; +import { motion } from 'motion/react'; import { LinkCard } from '../../components/ui/Card'; import { FilterSelect } from '../../components/ui/FilterSelect'; -import { CloseIcon, VibenetIcon } from '../../components/ui/icons'; +import { VibenetIcon } from '../../components/ui/icons'; import { Text } from '../../components/ui/Text'; import { CategoryBadge, KindBadge, StatusPill } from '../components/Badges'; -import { FilterGroup } from '../components/FilterGroup'; import { UpgradeIllustration } from '../components/UpgradeIllustration'; import { changes } from '../data/changes'; import { getLifecycleForChange, getUpgradeById, getUpgradesReversed } from '../data/upgrades'; @@ -136,8 +135,6 @@ export function ChangelogClient() { [], ); - const [filtersOpen, setFiltersOpen] = useState(false); - const reducedMotion = useReducedMotion(); // Rows fade in when the filters change, but not on first paint: on mount that plays // over a just-removed loading skeleton, leaving the table blank for ~180ms. const mountedRef = useRef(false); @@ -145,29 +142,9 @@ export function ChangelogClient() { mountedRef.current = true; }, []); - useEffect(() => { - if (!filtersOpen) return; - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setFiltersOpen(false); }; - document.addEventListener('keydown', onKey); - return () => document.removeEventListener('keydown', onKey); - }, [filtersOpen]); - - useEffect(() => { - document.body.style.overflow = filtersOpen ? 'hidden' : ''; - return () => { document.body.style.overflow = ''; }; - }, [filtersOpen]); - - const activeFilterCount = [ - upgradeFilter !== 'all', - kindFilter !== 'all', - categoryFilter !== 'all', - lifecycleFilter !== 'all', - ].filter(Boolean).length; - return ( <> - {/* Desktop filter bar */} -
+
-
- - {/* Mobile search + filter button */} -
- -
- {/* Mobile full-screen filter sheet */} - - {filtersOpen && ( - <> - setFiltersOpen(false)} - /> - -
- Filters - -
-
- ({ value: u.id, label: u.name })), - ]} - value={upgradeFilter} - onChange={setUpgradeFilter} - /> - ({ value: k, label: kindLabel(k) })), - ]} - value={kindFilter} - onChange={handleKindChange} - /> - ({ value: c, label: CATEGORY_METADATA[c].label })), - ]} - value={categoryFilter} - onChange={handleCategoryChange} - /> - ({ value: s, label: LIFECYCLE_LABELS[s] })), - ]} - value={lifecycleFilter} - onChange={handleLifecycleChange} - /> -
- {activeFilterCount > 0 ? ( -
- -
- ) : null} -
- - )} -
-
diff --git a/app/upgrades/components/FilterGroup.tsx b/app/upgrades/components/FilterGroup.tsx deleted file mode 100644 index da8f6ac..0000000 --- a/app/upgrades/components/FilterGroup.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { cn } from '../../components/ui/cn'; -import { Text } from '../../components/ui/Text'; - -type FilterGroupProps = { - label: string; - options: { value: string; label: string }[]; - value: string; - onChange: (value: string) => void; -}; - -export function FilterGroup({ label, options, value, onChange }: FilterGroupProps) { - return ( -
- - {label} - -
- {options.map((o) => ( - - ))} -
-
- ); -} diff --git a/app/vibenet/faucet/page.tsx b/app/vibenet/faucet/page.tsx index 072c546..1e7e922 100644 --- a/app/vibenet/faucet/page.tsx +++ b/app/vibenet/faucet/page.tsx @@ -214,7 +214,7 @@ export default function FaucetPage() { : []; return ( -
+
{summaryBody}
@@ -282,7 +282,7 @@ export default function FaucetPage() { {addressChips.length > 0 ? ( -
+
{addressChips.map((chip, i) => ( +