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:
parent
9b13223966
commit
1d0d06d648
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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/);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user