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 (
|
||||
<PageShell
|
||||
title="Reminders"
|
||||
action={
|
||||
floatingAction={
|
||||
<Button
|
||||
asChild
|
||||
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 */}
|
||||
<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>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@ -33,15 +33,15 @@ export function AccountsListView({ accounts }: AccountsListViewProps) {
|
||||
return (
|
||||
<PageShell
|
||||
title="Accounts"
|
||||
action={
|
||||
floatingAction={
|
||||
<Button
|
||||
asChild
|
||||
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 */}
|
||||
<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>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@ -12,28 +12,37 @@ describe("PageShell", () => {
|
||||
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(
|
||||
<PageShell title="Reminders" action={<button>Add</button>}>
|
||||
<PageShell title="Reminders" action={<button>Clear</button>}>
|
||||
<p>body</p>
|
||||
</PageShell>,
|
||||
);
|
||||
// H1 must come before the action button in document order.
|
||||
const h1Idx = html.indexOf("<h1");
|
||||
const buttonIdx = html.indexOf("<button");
|
||||
expect(h1Idx).toBeGreaterThan(-1);
|
||||
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(
|
||||
<PageShell title="x" action={<span>cta</span>}>
|
||||
<PageShell title="Activity" action={<button>Clear history</button>}>
|
||||
<p />
|
||||
</PageShell>,
|
||||
);
|
||||
// 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(
|
||||
<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", () => {
|
||||
@ -43,12 +52,44 @@ describe("PageShell", () => {
|
||||
</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(
|
||||
<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(
|
||||
<PageShell title="Settings">
|
||||
<p>just a card</p>
|
||||
@ -64,8 +105,6 @@ describe("PageShell", () => {
|
||||
<p>card</p>
|
||||
</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-2xl mx-auto/);
|
||||
});
|
||||
|
||||
@ -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 (
|
||||
<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 className="flex items-center justify-end sm:justify-between gap-4">
|
||||
<div
|
||||
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">
|
||||
{title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{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>
|
||||
{narrow ? (
|
||||
<div className="max-w-2xl mx-auto space-y-4 sm:space-y-6">{children}</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{floatingAction && (
|
||||
<div className="sm:hidden fixed bottom-4 right-4 z-40">
|
||||
{floatingAction}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user