diff --git a/web/src/components/ui/PageHeader.tsx b/web/src/components/ui/PageHeader.tsx new file mode 100644 index 00000000..51d5108d --- /dev/null +++ b/web/src/components/ui/PageHeader.tsx @@ -0,0 +1,75 @@ +import type { ReactNode } from 'react'; + +/** + * The header strip every list page starts with: icon + title, filter pills, + * an optional search box and optional right-hand actions. + * + * It exists because the pattern had been copy-pasted per page as a single + * non-wrapping flex row. That row reaches ~750px once the pills and the + * search box are in it, so on a 412px viewport the *page itself* scrolled + * sideways and every filter past the third was unreachable. + * + * The single row returns at `lg`, not `md`. The desktop shell also spends 56px + * on the nav rail, so a 768px viewport leaves ~664px of content — less than the + * widest header needs (Notifications: nine pills plus two actions). Even `lg` + * is not enough for that one, which is why the filter strip keeps + * `overflow-x-auto` at every width: a header that outgrows its container + * scrolls inside itself instead of spilling into the controls after it. + * + * Nothing here is reordered with CSS. Below `lg` the actions are rendered in + * the title row and the desktop copy is dropped from the DOM (and vice versa), + * so tab order follows what is on screen at both sizes — a flex `order` swap + * would move them visually while leaving the keyboard to step through every + * filter and the search box first. + */ +export function PageHeader({ icon, title, filters, search, actions }: { + icon?: ReactNode; + title: ReactNode; + /** Filter pills. Laid out by the caller; scrolled horizontally here. */ + filters?: ReactNode; + search?: ReactNode; + actions?: ReactNode; +}) { + return ( +
+ {/* Row one below `lg`: the title, with the page's primary buttons pinned + opposite it. With an icon, desktop keeps the 16px icon-to-title gap + and the 24px run-out to the filters the per-page headers used. */} +
+ {icon} +

{title}

+ {actions && ( +
{actions}
+ )} +
+ + {filters && ( + // The negative margins let the strip run to both screen edges, so a + // half-visible pill signals "there is more this way" instead of looking + // like a clipped layout. The width has to grow by the same 2rem the + // margins take back — `w-full` alone would only shift the strip left + // and leave it stopping 32px short of the right edge. +
+
{filters}
+
+ )} + + {search && ( +
{search}
+ )} + + {/* Desktop copy of the actions: last in the DOM so `ml-auto` pins it to + the right edge without dragging the filters and search along with it. */} + {actions && ( +
+ {actions} +
+ )} +
+ ); +} diff --git a/web/src/pages/NotificationsPage.tsx b/web/src/pages/NotificationsPage.tsx index e9acee93..bfdc892d 100644 --- a/web/src/pages/NotificationsPage.tsx +++ b/web/src/pages/NotificationsPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Bell, X, CheckCheck, EyeOff, Check, XCircle, Moon, BellOff, Trash2, Plus, RotateCw, Clock } from 'lucide-react'; import { useNotificationStore, type Notification, type Silence } from '../stores/notificationStore'; +import { PageHeader } from '../components/ui/PageHeader'; const STATUS_STYLES: Record = { pending: 'bg-yellow-400/10 text-hue-yellow border-yellow-400/20', @@ -450,68 +451,70 @@ export function NotificationsPage() { return (
-
- -

Notifications

- - {/* Status filters */} -
- {STATUS_FILTERS.map(f => ( - - ))} -
- - {/* Type filters */} -
- {TYPE_FILTERS.map(f => ( - - ))} -
- - {/* Silences toggle */} - - - {/* Dismiss All */} - {pendingCount > 0 && ( + } + title="Notifications" + filters={ + <> + {/* Status filters */} + {STATUS_FILTERS.map(f => ( + + ))} + {/* Type filters — separated by a rule rather than the old ml-1, + which read as one undifferentiated run of pills once they + shared a scroller. */} +
+ {/* Dismiss All */} + {pendingCount > 0 && ( + + )} + + } + />
{showSilences && } diff --git a/web/src/pages/PlansPage.tsx b/web/src/pages/PlansPage.tsx index 790c8890..11fa6607 100644 --- a/web/src/pages/PlansPage.tsx +++ b/web/src/pages/PlansPage.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { Lightbulb } from 'lucide-react'; import { usePlanStore, type Plan } from '../stores/planStore'; +import { PageHeader } from '../components/ui/PageHeader'; const STATUS_STYLES: Record = { pending: 'bg-yellow-400/10 text-hue-yellow border-yellow-400/20', @@ -64,25 +65,23 @@ export function PlansPage() { return (
-
- -

Plans

-
- {FILTERS.map(f => ( - - ))} -
-
+ } + title="Plans" + filters={FILTERS.map(f => ( + + ))} + />
{loading ? ( diff --git a/web/src/pages/TasksPage.tsx b/web/src/pages/TasksPage.tsx index 934625d0..646ebf80 100644 --- a/web/src/pages/TasksPage.tsx +++ b/web/src/pages/TasksPage.tsx @@ -12,6 +12,7 @@ import { BoardFilterBar } from '../components/Tasks/Board/BoardFilterBar'; import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; import { isModalOpen } from '../components/ui/modalStack'; import type { ShortcutDef } from '../utils/keyboard'; +import { PageHeader } from '../components/ui/PageHeader'; const SORT_OPTIONS: { value: TaskSort; label: string }[] = [ { value: 'deadline', label: 'Deadline' }, @@ -128,32 +129,34 @@ export function TasksPage() { return (
-
-
-

Tasks

+ +
+ {VIEW_OPTIONS.map(({ value, label, Icon }) => ( + + ))} +
-
- {VIEW_OPTIONS.map(({ value, label, Icon }) => ( - - ))} -
- - {/* Status pills are the list's filter; on the board every status - is already a lane, so they'd only hide columns. */} - {!isBoard && } - -
+ {/* Status pills are the list's filter; on the board every status + is already a lane, so they'd only hide columns. */} + {!isBoard && } + + } + search={ +
)}
-
-
- {!isSearching && !isBoard && ( - - )} - - -
-
+ } + actions={ + <> + {!isSearching && !isBoard && ( + // The "Sort by" label costs more than it explains once space is + // tight; the select still names itself via title/aria-label. + + )} + + + + } + /> {isBoard && }