From 1d0d06d6486f08d217e405c778ea440e5f345f10 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 15:27:32 +0800 Subject: [PATCH] feat(web): floating CTA on mobile so Add/New buttons aren't a wasted row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/app/reminders/page.tsx | 6 +- .../web/src/components/accounts-list-view.tsx | 6 +- apps/web/src/components/page-shell.test.tsx | 63 +++++++++++++++---- apps/web/src/components/page-shell.tsx | 60 +++++++++++++++--- 4 files changed, 108 insertions(+), 27 deletions(-) diff --git a/apps/web/src/app/reminders/page.tsx b/apps/web/src/app/reminders/page.tsx index 2bc0cef..3e5aab1 100644 --- a/apps/web/src/app/reminders/page.tsx +++ b/apps/web/src/app/reminders/page.tsx @@ -184,15 +184,15 @@ export default async function RemindersPage({ searchParams }: PageProps) { return ( {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - + New Reminder diff --git a/apps/web/src/components/accounts-list-view.tsx b/apps/web/src/components/accounts-list-view.tsx index 6ef27d4..41c2081 100644 --- a/apps/web/src/components/accounts-list-view.tsx +++ b/apps/web/src/components/accounts-list-view.tsx @@ -33,15 +33,15 @@ export function AccountsListView({ accounts }: AccountsListViewProps) { return ( {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - + Add Account diff --git a/apps/web/src/components/page-shell.test.tsx b/apps/web/src/components/page-shell.test.tsx index e9c61e5..d29dcfa 100644 --- a/apps/web/src/components/page-shell.test.tsx +++ b/apps/web/src/components/page-shell.test.tsx @@ -12,28 +12,37 @@ describe("PageShell", () => { expect(html).toMatch(/]*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( - Add}> + Clear}>

body

, ); - // H1 must come before the action button in document order. const h1Idx = html.indexOf(" { + it("renders the header row on every breakpoint when an inline action is set", () => { const html = renderToStaticMarkup( - cta}> + Clear history}>

, ); - // The header row needs both responsive utilities so the action - // button sits on the right when the H1 is hidden. - expect(html).toMatch(/justify-end sm:justify-between/); + // Inline action must be visible on mobile too — the header row uses + // `flex` not `hidden sm:flex`. + 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( + +}> +

+ , + ); + // 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", () => { @@ -43,12 +52,44 @@ describe("PageShell", () => { , ); 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).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( + +}> +

+ , + ); + 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( + +}> +

+ , + ); + expect(html).toMatch(/pb-20/); + }); + + it("does NOT bump bottom padding when there's no FAB", () => { + const html = renderToStaticMarkup( + +

+ , + ); + expect(html).toMatch(/pb-4/); + expect(html).not.toMatch(/pb-20/); + }); + + it("works without any action — only the H1 + children render", () => { const html = renderToStaticMarkup(

just a card

@@ -64,8 +105,6 @@ describe("PageShell", () => {

card

, ); - // 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-2xl mx-auto/); }); diff --git a/apps/web/src/components/page-shell.tsx b/apps/web/src/components/page-shell.tsx index dd369c3..f7de226 100644 --- a/apps/web/src/components/page-shell.tsx +++ b/apps/web/src/components/page-shell.tsx @@ -4,13 +4,20 @@ interface PageShellProps { /** Title shown in the desktop H1. The mobile top bar already shows * the same string, so the H1 is hidden below `sm:`. */ title: string; - /** Optional right-aligned action slot — usually a primary CTA - * button. When omitted, the header row collapses to just the H1 - * on desktop and renders nothing on mobile. */ + /** Inline action slot — rendered next to the H1 on desktop AND on + * mobile (when there's no H1 to anchor it). Use this for actions + * that aren't the page's primary CTA (e.g. Activity's "Clear + * history"). For primary CTAs, prefer `floatingAction`. */ 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 - * content (Settings, etc). The header stays at the standard - * 5xl chrome so the title aligns with other tabs. */ + * content (Settings, etc). The header stays at the standard 5xl + * chrome so the title aligns with other tabs. */ narrow?: boolean; children: ReactNode; } @@ -21,21 +28,56 @@ interface PageShellProps { * on mobile + optional action button on the right). Every tab uses * this so the header pattern stays consistent without each page * 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 ( -
-
+
+

{title}

- {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. */} +
{floatingAction}
+
{narrow ? (
{children}
) : ( children )} + {floatingAction && ( +
+ {floatingAction} +
+ )}
); }