fix(landing): keep the header from overflowing at mid widths (#791)
## What The Roadmap item pushed the landing header past the viewport on narrow laptops: the logo wrapped onto two lines and, in Spanish, the register button was clipped off screen (at 640px the nav needed 670px). Between `sm` and `lg` the nav is now icon-only and tighter: - Roadmap / Github / Discord keep their labels only from `lg` up. The label stays `sr-only` instead of `display:none`, so the icons keep their accessible name — before this they announced as "link", "link 1096", "link". The GitHub star count stays visible at every width. - Button padding, nav gap and the wordmark size all step down below `lg`, which is what buys back the room Spanish needs. At 640px the nav now ends on the 24px page padding instead of 10px past it, with 22px of air between logo and nav. - The logo gets `shrink-0` + `whitespace-nowrap`, so it can no longer wrap. ## Also - The logo links back to the landing page on every page but the landing itself (the page is detected by URL, so `?ref=` links behave). Before this, a phone visitor on `/roadmap` had no way back other than the browser's back button. - The mobile pill header drops its Github/Discord buttons and separator: they were `hidden … sm:flex` inside a `sm:hidden` header, so they could never render. - The three nav buttons collapse into a local `NavButton`, so the breakpoint lives in one place instead of three. ## QA Browser QA against the dev server on `/` and `/roadmap`, in Spanish (the longest of our strings) and English, at 390 / 640 / 672 / 1024 / 1280: - 640px es: no horizontal overflow, right padding intact, nothing clipped. - 1024px+: labels are back, layout unchanged from today. - 390px: the mobile pill is untouched. - Logo click on `/roadmap` navigates to `/`; on `/` it renders as a plain `div` in both headers. Unit tests cover the logo routing branch. The responsive band itself is visual-QA only — jsdom cannot evaluate Tailwind breakpoints, and a className assertion would be brittle. ## Demo <!-- PLACEHOLDER: drag the screenshots here (390 / 640 / 672 / 1280, Spanish) -->
This commit is contained in:
parent
df79f1b77d
commit
98f03db50c
|
|
@ -1,10 +1,11 @@
|
|||
import { dashboard } from '@/routes';
|
||||
import { dashboard, home } from '@/routes';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import Header from './header';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
url: '/roadmap',
|
||||
pageProps: {
|
||||
auth: {
|
||||
user: {
|
||||
|
|
@ -24,11 +25,12 @@ vi.mock('@inertiajs/react', () => ({
|
|||
children: React.ReactNode;
|
||||
href: string | { url: string };
|
||||
}) => <a href={typeof href === 'string' ? href : href.url}>{children}</a>,
|
||||
usePage: () => ({ props: mocks.pageProps }),
|
||||
usePage: () => ({ url: mocks.url, props: mocks.pageProps }),
|
||||
}));
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
mocks.url = '/roadmap';
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => new Promise(() => {})),
|
||||
|
|
@ -45,4 +47,26 @@ describe('Header', () => {
|
|||
expect(dashboardLinks).toHaveLength(2);
|
||||
expect(dashboardLinks[0]?.getAttribute('href')).toBe(dashboard().url);
|
||||
});
|
||||
|
||||
it('links the logo home on every page but the landing one', () => {
|
||||
render(<Header />);
|
||||
|
||||
const logoLinks = screen.getAllByRole('link', {
|
||||
name: 'Whisper Money',
|
||||
});
|
||||
|
||||
expect(logoLinks).toHaveLength(2);
|
||||
expect(logoLinks[0]?.getAttribute('href')).toBe(home().url);
|
||||
});
|
||||
|
||||
it('does not link the logo on the landing page', () => {
|
||||
mocks.url = '/?ref=newsletter';
|
||||
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.getAllByText('Whisper Money')).toHaveLength(2);
|
||||
expect(
|
||||
screen.queryByRole('link', { name: 'Whisper Money' }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { dashboard, login, roadmap } from '@/routes';
|
||||
import { cn, resolveUrl } from '@/lib/utils';
|
||||
import { dashboard, home, login, roadmap } from '@/routes';
|
||||
import { type SharedData } from '@/types';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { BirdIcon, Github, LogIn, MapIcon, StarIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import DiscordIcon from '../icons/DiscordIcon';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
|
|
@ -27,6 +28,56 @@ function useGitHubStars(): number | null {
|
|||
return stars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Below `lg` the nav only has room for the icons, so the label stays for
|
||||
* screen readers instead of disappearing with the text.
|
||||
*/
|
||||
function NavButton({
|
||||
label,
|
||||
badge,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
badge?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
className="cursor-pointer px-2 opacity-70 transition-all duration-200 hover:opacity-100 lg:px-4"
|
||||
>
|
||||
{children}
|
||||
<span className="sr-only lg:not-sr-only">{label}</span>
|
||||
{badge}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The logo links back to the landing page, except on the landing page itself.
|
||||
*/
|
||||
function Logo({ className }: { className?: string }) {
|
||||
const { url } = usePage();
|
||||
const isLandingPage = url.split('?')[0] === resolveUrl(home());
|
||||
const classes = cn('flex shrink-0 items-center font-mono', className);
|
||||
const content = (
|
||||
<>
|
||||
<BirdIcon className="size-5 text-[#1b1b18] dark:text-[#EDEDEC]" />
|
||||
<span className="font-medium whitespace-nowrap">Whisper Money</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isLandingPage) {
|
||||
return <div className={classes}>{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={home()} className={classes}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
canRegister?: boolean;
|
||||
hideExternalButtons?: boolean;
|
||||
|
|
@ -43,59 +94,8 @@ export default function Header({
|
|||
<>
|
||||
{/* Mobile pill header */}
|
||||
<header className="fixed top-4 right-4 left-4 z-50 flex items-center justify-between rounded-full border border-border/50 bg-background/70 px-4 py-3.5 shadow-lg shadow-black/10 backdrop-blur-xl sm:hidden dark:border-border/30 dark:shadow-black/30">
|
||||
<div className="flex shrink-0 items-center gap-2.5 font-mono">
|
||||
<BirdIcon className="size-5 text-[#1b1b18] dark:text-[#EDEDEC]" />
|
||||
<span className="text-sm font-medium whitespace-nowrap">
|
||||
Whisper Money
|
||||
</span>
|
||||
</div>
|
||||
<Logo className="gap-2.5 text-sm" />
|
||||
<nav className="flex items-center gap-2">
|
||||
{!hideExternalButtons && (
|
||||
<>
|
||||
<a
|
||||
href="https://github.com/whisper-money/whisper-money"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
className="hidden cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100 sm:flex"
|
||||
>
|
||||
<Github className="size-5" />
|
||||
<span className="hidden sm:inline">
|
||||
{__('Github')}
|
||||
</span>
|
||||
{stars !== null && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-xs font-medium">
|
||||
<StarIcon className="size-3 fill-amber-400 text-amber-400" />
|
||||
{stars}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/m8hUhx6D9D"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
className="hidden cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100 sm:flex"
|
||||
>
|
||||
<DiscordIcon className="size-5" />
|
||||
<span className="hidden sm:inline">
|
||||
Discord
|
||||
</span>
|
||||
</Button>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{!hideExternalButtons && (
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="hidden data-[orientation=vertical]:h-6 data-[orientation=vertical]:w-[1px] data-[orientation=vertical]:bg-border sm:block"
|
||||
/>
|
||||
)}
|
||||
{auth.user ? (
|
||||
<Link href={dashboard()}>
|
||||
<Button
|
||||
|
|
@ -135,59 +135,42 @@ export default function Header({
|
|||
{/* Desktop header */}
|
||||
<header className="fixed top-0 z-50 hidden w-full bg-background/5 backdrop-blur-lg sm:block">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4 lg:py-6">
|
||||
<div className="flex items-center gap-4 font-mono">
|
||||
<BirdIcon className="size-5 text-[#1b1b18] dark:text-[#EDEDEC]" />
|
||||
<span className="font-medium">Whisper Money</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-4">
|
||||
<Logo className="gap-2 text-sm lg:gap-4 lg:text-base" />
|
||||
<nav className="flex items-center gap-1 lg:gap-4">
|
||||
{!hideExternalButtons && (
|
||||
<>
|
||||
<Link href={roadmap()}>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
className="hidden cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100 sm:flex"
|
||||
>
|
||||
<NavButton label={__('Roadmap')}>
|
||||
<MapIcon className="size-5" />
|
||||
<span className="hidden sm:inline">
|
||||
{__('Roadmap')}
|
||||
</span>
|
||||
</Button>
|
||||
</NavButton>
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/whisper-money/whisper-money"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
className="hidden cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100 sm:flex"
|
||||
<NavButton
|
||||
label={__('Github')}
|
||||
badge={
|
||||
stars !== null && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-xs font-medium">
|
||||
<StarIcon className="size-3 fill-amber-400 text-amber-400" />
|
||||
{stars}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Github className="size-5" />
|
||||
<span className="hidden sm:inline">
|
||||
{__('Github')}
|
||||
</span>
|
||||
{stars !== null && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-xs font-medium">
|
||||
<StarIcon className="size-3 fill-amber-400 text-amber-400" />
|
||||
{stars}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</NavButton>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/m8hUhx6D9D"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
className="hidden cursor-pointer opacity-70 transition-all duration-200 hover:opacity-100 sm:flex"
|
||||
>
|
||||
<NavButton label={__('Discord')}>
|
||||
<DiscordIcon className="size-5" />
|
||||
<span className="hidden sm:inline">
|
||||
{__('Discord')}
|
||||
</span>
|
||||
</Button>
|
||||
</NavButton>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -208,7 +191,7 @@ export default function Header({
|
|||
<Link href={login()}>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
className="cursor-pointer"
|
||||
className="cursor-pointer px-3 lg:px-4"
|
||||
>
|
||||
{__('Log in')}
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Reference in New Issue