feat(web): floating CTA on mobile so Add/New buttons aren't a wasted row

Adds a `floatingAction` slot to PageShell. Desktop renders it inline
next to the H1 (same as before); mobile drops the entire header row
and floats the action as a fixed pill in the bottom-right corner —
the page now starts straight at content with no wasted vertical
space at the top when only an action exists.

Add Account / New Reminder buttons grow to size-12 circles on mobile
(easy thumb target) and keep the compact h-7 inline pill on desktop.
The action node is rendered twice in the tree — once inline, once
fixed — and switched via responsive utilities.

Bumps mobile bottom padding to pb-20 when a FAB is present so the
last card doesn't sit under the floating button.

Activity's "Clear history" still uses the regular `action` slot — it
keeps the inline header row on mobile because it isn't the page's
primary CTA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 15:27:32 +08:00
parent 9b13223966
commit 1d0d06d648
4 changed files with 108 additions and 27 deletions

View File

@ -184,15 +184,15 @@ export default async function RemindersPage({ searchParams }: PageProps) {
return ( return (
<PageShell <PageShell
title="Reminders" title="Reminders"
action={ floatingAction={
<Button <Button
asChild asChild
size="sm" size="sm"
className="rounded-full shadow-sm hover:shadow transition-shadow gap-1.5" className="rounded-full shadow-md hover:shadow-lg transition-shadow gap-1.5 sm:h-7 size-12 sm:size-auto sm:px-2.5"
> >
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/reminders/new" as any} aria-label="New reminder"> <Link href={"/reminders/new" as any} aria-label="New reminder">
<PlusIcon className="size-3.5" /> <PlusIcon className="size-5 sm:size-3.5" />
<span className="hidden sm:inline">New Reminder</span> <span className="hidden sm:inline">New Reminder</span>
</Link> </Link>
</Button> </Button>

View File

@ -33,15 +33,15 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
return ( return (
<PageShell <PageShell
title="Accounts" title="Accounts"
action={ floatingAction={
<Button <Button
asChild asChild
size="sm" size="sm"
className="rounded-full shadow-sm hover:shadow transition-shadow gap-1.5" className="rounded-full shadow-md hover:shadow-lg transition-shadow gap-1.5 sm:h-7 size-12 sm:size-auto sm:px-2.5"
> >
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={"/accounts/new" as any} aria-label="Add account"> <Link href={"/accounts/new" as any} aria-label="Add account">
<PlusIcon className="size-3.5" /> <PlusIcon className="size-5 sm:size-3.5" />
<span className="hidden sm:inline">Add Account</span> <span className="hidden sm:inline">Add Account</span>
</Link> </Link>
</Button> </Button>

View File

@ -12,28 +12,37 @@ describe("PageShell", () => {
expect(html).toMatch(/<h1[^>]*hidden sm:block[^>]*>Accounts<\/h1>/); expect(html).toMatch(/<h1[^>]*hidden sm:block[^>]*>Accounts<\/h1>/);
}); });
it("renders the action slot to the right of the H1", () => { it("renders the action slot in document order after the H1", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<PageShell title="Reminders" action={<button>Add</button>}> <PageShell title="Reminders" action={<button>Clear</button>}>
<p>body</p> <p>body</p>
</PageShell>, </PageShell>,
); );
// H1 must come before the action button in document order.
const h1Idx = html.indexOf("<h1"); const h1Idx = html.indexOf("<h1");
const buttonIdx = html.indexOf("<button"); const buttonIdx = html.indexOf("<button");
expect(h1Idx).toBeGreaterThan(-1); expect(h1Idx).toBeGreaterThan(-1);
expect(buttonIdx).toBeGreaterThan(h1Idx); expect(buttonIdx).toBeGreaterThan(h1Idx);
}); });
it("flexes the header row with right-alignment on mobile and space-between on desktop", () => { it("renders the header row on every breakpoint when an inline action is set", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<PageShell title="x" action={<span>cta</span>}> <PageShell title="Activity" action={<button>Clear history</button>}>
<p /> <p />
</PageShell>, </PageShell>,
); );
// The header row needs both responsive utilities so the action // Inline action must be visible on mobile too — the header row uses
// button sits on the right when the H1 is hidden. // `flex` not `hidden sm:flex`.
expect(html).toMatch(/justify-end sm:justify-between/); expect(html).toMatch(/class="flex items-center justify-end sm:justify-between/);
});
it("hides the header row on mobile when only a floatingAction is set", () => {
const html = renderToStaticMarkup(
<PageShell title="Reminders" floatingAction={<button>+</button>}>
<p />
</PageShell>,
);
// No inline action → desktop-only header row, FAB takes over on mobile.
expect(html).toMatch(/class="hidden sm:flex items-center justify-end sm:justify-between/);
}); });
it("renders children inside the standard wrapper width and padding", () => { it("renders children inside the standard wrapper width and padding", () => {
@ -43,12 +52,44 @@ describe("PageShell", () => {
</PageShell>, </PageShell>,
); );
expect(html).toMatch(/max-w-5xl/); expect(html).toMatch(/max-w-5xl/);
expect(html).toMatch(/px-4 py-4 sm:px-6 sm:py-8/); expect(html).toMatch(/px-4 py-4 pb-4 sm:px-6 sm:py-8/);
expect(html).toMatch(/space-y-4 sm:space-y-6/); expect(html).toMatch(/space-y-4 sm:space-y-6/);
expect(html).toContain('data-testid="body"'); expect(html).toContain('data-testid="body"');
}); });
it("works without an action slot — only the H1 + children render", () => { it("renders the FAB inside a fixed bottom-right wrapper, hidden on desktop", () => {
const html = renderToStaticMarkup(
<PageShell title="x" floatingAction={<button data-testid="fab">+</button>}>
<p />
</PageShell>,
);
expect(html).toMatch(/sm:hidden fixed bottom-4 right-4/);
// The FAB node renders twice — once inline (desktop) and once
// fixed (mobile). Both share the same data-testid.
const matches = html.match(/data-testid="fab"/g) ?? [];
expect(matches.length).toBe(2);
});
it("bumps mobile bottom padding to pb-20 when a FAB will overlap content", () => {
const html = renderToStaticMarkup(
<PageShell title="x" floatingAction={<button>+</button>}>
<p />
</PageShell>,
);
expect(html).toMatch(/pb-20/);
});
it("does NOT bump bottom padding when there's no FAB", () => {
const html = renderToStaticMarkup(
<PageShell title="x">
<p />
</PageShell>,
);
expect(html).toMatch(/pb-4/);
expect(html).not.toMatch(/pb-20/);
});
it("works without any action — only the H1 + children render", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<PageShell title="Settings"> <PageShell title="Settings">
<p>just a card</p> <p>just a card</p>
@ -64,8 +105,6 @@ describe("PageShell", () => {
<p>card</p> <p>card</p>
</PageShell>, </PageShell>,
); );
// Outer chrome stays at 5xl so the header keeps its alignment;
// an inner wrapper drops the body to 2xl.
expect(html).toMatch(/max-w-5xl/); expect(html).toMatch(/max-w-5xl/);
expect(html).toMatch(/max-w-2xl mx-auto/); expect(html).toMatch(/max-w-2xl mx-auto/);
}); });

View File

@ -4,13 +4,20 @@ interface PageShellProps {
/** Title shown in the desktop H1. The mobile top bar already shows /** Title shown in the desktop H1. The mobile top bar already shows
* the same string, so the H1 is hidden below `sm:`. */ * the same string, so the H1 is hidden below `sm:`. */
title: string; title: string;
/** Optional right-aligned action slot usually a primary CTA /** Inline action slot rendered next to the H1 on desktop AND on
* button. When omitted, the header row collapses to just the H1 * mobile (when there's no H1 to anchor it). Use this for actions
* on desktop and renders nothing on mobile. */ * that aren't the page's primary CTA (e.g. Activity's "Clear
* history"). For primary CTAs, prefer `floatingAction`. */
action?: ReactNode; action?: ReactNode;
/** Primary CTA. On desktop it sits next to the H1. On mobile it
* floats as a fixed pill in the bottom-right corner so the page
* doesn't waste a whole row on a single button. The page padding
* bumps `pb-20` on mobile so the last card doesn't sit under the
* FAB. */
floatingAction?: ReactNode;
/** Constrain the body to `max-w-2xl` for dense, single-column /** Constrain the body to `max-w-2xl` for dense, single-column
* content (Settings, etc). The header stays at the standard * content (Settings, etc). The header stays at the standard 5xl
* 5xl chrome so the title aligns with other tabs. */ * chrome so the title aligns with other tabs. */
narrow?: boolean; narrow?: boolean;
children: ReactNode; children: ReactNode;
} }
@ -21,21 +28,56 @@ interface PageShellProps {
* on mobile + optional action button on the right). Every tab uses * on mobile + optional action button on the right). Every tab uses
* this so the header pattern stays consistent without each page * this so the header pattern stays consistent without each page
* repeating the same wrapper markup. * repeating the same wrapper markup.
*
* Mobile note: the H1 is hidden because the top app bar already
* shows the page title. If only `floatingAction` is passed, the
* header row collapses entirely on mobile and the button floats
* the page starts straight at content with no wasted vertical
* space at the top.
*/ */
export function PageShell({ title, action, narrow, children }: PageShellProps) { export function PageShell({
title,
action,
floatingAction,
narrow,
children,
}: PageShellProps) {
// Mobile bottom padding only when a FAB will overlap the content.
const mobilePB = floatingAction ? "pb-20" : "pb-4";
// The header row needs to render on mobile only when an `action`
// is set (no FAB swallows that case). When only the H1 + a FAB
// exist, the row is desktop-only.
const headerVisibility =
action !== undefined ? "flex" : "hidden sm:flex";
return ( return (
<div className="px-4 py-4 sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-4 sm:space-y-6"> <div
<div className="flex items-center justify-end sm:justify-between gap-4"> className={`relative px-4 py-4 ${mobilePB} sm:px-6 sm:py-8 max-w-5xl mx-auto space-y-4 sm:space-y-6`}
>
<div
className={`${headerVisibility} items-center justify-end sm:justify-between gap-4`}
>
<h1 className="hidden sm:block text-2xl font-semibold tracking-tight"> <h1 className="hidden sm:block text-2xl font-semibold tracking-tight">
{title} {title}
</h1> </h1>
<div className="flex items-center gap-2">
{action} {action}
{/* Floating CTA inline on desktop keeps the same place as
the old `action` slot. On mobile this copy is hidden
and the fixed FAB below renders instead. */}
<div className="hidden sm:flex">{floatingAction}</div>
</div>
</div> </div>
{narrow ? ( {narrow ? (
<div className="max-w-2xl mx-auto space-y-4 sm:space-y-6">{children}</div> <div className="max-w-2xl mx-auto space-y-4 sm:space-y-6">{children}</div>
) : ( ) : (
children children
)} )}
{floatingAction && (
<div className="sm:hidden fixed bottom-4 right-4 z-40">
{floatingAction}
</div>
)}
</div> </div>
); );
} }