Files
pn-new-crm/src/components/shared/page-header.tsx
Matt 2f1e1b5f3f fix(layout): unblock tablet topbar trigger + un-crush 1024 dashboard title
Two Bucket 1 quick-fixes from the 2026-05-22 visual audit, both
1-2-line CSS changes with outsized visual impact.

PageHeader stack point: lg → xl
The earlier sm → lg revision (commit 6d665d0) fixed the 768 tablet
crush but introduced a SECOND crush at exactly 1024: that's where
the desktop shell mounts (sidebar = 256px) AND lg:flex-row kicks
in, leaving the title cell to compete with a 4-button action row
in only ~720px of content. Title degraded to "(" and "Last 30
days" wrapped three-deep ("Last / 30 / days"). Moving to xl
(1280) keeps the strip stacked through tablet AND the narrowest
desktop width. Verified via Playwright at 1024 — title now reads
cleanly with the action row stacked below.

Topbar tablet logo trigger:
AppShell mounts a logo button in Topbar's leadingSlot prop on
tablet (the design intent: click logo → sidebar Sheet slides in).
Live screenshot at 768 showed zero affordance — search bar started
at the very left edge of the visible viewport. Two root causes,
both fixed:
- center grid column was minmax(420px, 800px) which starved the
  left column to ~100px at 768 width (no sidebar present).
  Changed to minmax(280px, 800px) at base, minmax(420px, 800px)
  only at lg+.
- search container had unconditional sm:-translate-x-...
  shifting it 128px LEFT to compensate for a sidebar that isn't
  present at tablet, pulling the search input over the leading-
  slot. Gated the translate to lg: so it only kicks in when the
  sidebar is actually inline.

Verified via Playwright at 768 — hamburger icon now appears in
the top-left corner; search bar sits to its right without overlap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:02:57 +02:00

97 lines
3.7 KiB
TypeScript

import { type ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface PageHeaderProps {
title: string;
description?: string;
actions?: ReactNode;
className?: string;
/** Optional small uppercase label above the title. */
eyebrow?: string;
/** Optional one-line stats / KPI summary under the description. */
kpiLine?: ReactNode;
/** Render with the polished gradient-brand-soft background strip. */
variant?: 'plain' | 'gradient';
}
/**
* Consistent page-level header: title, optional description, KPI sub-line,
* eyebrow, and an action slot. Use `variant="gradient"` for hero strips on
* landing pages and detail headers; the plain variant remains the default so
* existing call-sites stay unchanged.
*
* Mobile-aware: below sm (640px) the title/eyebrow/description/gradient
* frame all collapse - the page title is already shown by the mobile topbar,
* so duplicating it in the body wastes scroll real estate. What remains is a
* flush right-aligned action row (or nothing if there are no actions). On sm+
* the full strip with title+description renders as before.
*/
export function PageHeader({
title,
description,
actions,
className,
eyebrow,
kpiLine,
variant = 'plain',
}: PageHeaderProps) {
const isGradient = variant === 'gradient';
return (
<>
{/* Mobile: actions only. Title/description are duplicated by the topbar. */}
{actions ? (
<div className={cn('sm:hidden flex flex-wrap items-center justify-end gap-2', className)}>
{actions}
</div>
) : null}
{/* Tablet + desktop: full strip with title, eyebrow, description, kpi line, actions.
Stacks vertically at sm (640px) up to xl (1280px). The earlier
revision moved the stack point from sm to lg, which fixed the
tablet (768) crush but introduced a SECOND crush at exactly
1024: that's where the desktop shell mounts (sidebar = 256px)
AND `lg:flex-row` kicks in, leaving the title cell to compete
with a four-button action row in only ~720px of content. Moving
to xl (1280) means the strip stays stacked through tablet AND
the narrowest desktop width; horizontal layout returns once
there's actual room. */}
<div
className={cn(
// Removed `sm:mb-6` - the parent shell already provides
// appropriate gap-y between header and the next section, and the
// double-spacing produced an oversized top margin on dashboards.
'hidden sm:flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between xl:gap-4',
isGradient &&
'rounded-xl border border-slate-200 bg-gradient-brand-soft px-5 py-4 shadow-xs',
className,
)}
// Render the title element as <h1> for accessibility / SEO so screen
// readers still get a heading on mobile from the topbar's <h1>.
aria-hidden={undefined}
>
<div className="min-w-0 flex-1">
{eyebrow ? (
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-brand">
{eyebrow}
</div>
) : null}
<h1 className="truncate text-xl font-bold tracking-tight text-foreground sm:text-2xl">
{title}
</h1>
{description ? (
<p className={cn('mt-1 text-sm text-muted-foreground')}>{description}</p>
) : null}
{kpiLine ? (
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
{kpiLine}
</div>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center gap-2 xl:flex-nowrap">{actions}</div>
) : null}
</div>
</>
);
}