src/features/inbox/compact-list.tsx
Linked repository file from this build.
"use client";
import { useEffect, useId, useState, type ReactNode } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useAppearance } from "@/components/appearance-provider";
export function inboxPageSize(viewportHeight: number, fontSize = 16): number {
// Rows use 3rem plus a divider; the header and pager use 4.75rem plus borders.
const chrome = 4.75 * fontSize + 2;
const row = 3 * fontSize + 1;
return Math.min(100, Math.max(1, Math.floor((viewportHeight / 2 - chrome) / row)));
}
export function useInboxPageSize(initialSize?: number) {
const { appearance } = useAppearance();
const fontSize = appearance.fontSize;
const [viewport, setViewport] = useState({
pageSize: initialSize ?? 6,
ready: Boolean(initialSize),
});
useEffect(() => {
const resize = () => {
const pageSize = inboxPageSize(window.visualViewport?.height ?? window.innerHeight, fontSize);
document.cookie = `qc-inbox-page-size=${pageSize}; path=/; SameSite=Lax`;
setViewport((current) =>
current.ready && current.pageSize === pageSize ? current : { pageSize, ready: true },
);
};
resize();
window.addEventListener("resize", resize);
window.visualViewport?.addEventListener("resize", resize);
return () => {
window.removeEventListener("resize", resize);
window.visualViewport?.removeEventListener("resize", resize);
};
}, [fontSize]);
return viewport;
}
export function CompactList<T>({
title,
icon,
rows,
total,
page,
pageSize,
pending,
onPage,
rowKey,
render,
empty,
}: {
title: string;
icon?: ReactNode;
rows: T[];
total: number;
page: number;
pageSize: number;
pending: boolean;
onPage(page: number): void;
rowKey(row: T): string;
render(row: T): ReactNode;
empty: string;
}) {
const heading = useId();
const pages = Math.max(1, Math.ceil(total / pageSize));
return (
<section
aria-labelledby={heading}
aria-busy={pending}
className="min-w-0 overflow-hidden rounded-lg border bg-card shadow-xs"
>
<div className="flex h-10 items-center justify-between gap-2 border-b bg-muted/40 px-3">
<h2 id={heading} className="flex min-w-0 items-center gap-2 truncate text-sm font-semibold">
{icon && <span className="text-muted-foreground">{icon}</span>}
<span className="truncate">{title}</span>
</h2>
<span className="rounded-full bg-background px-2 py-0.5 text-sm font-medium tabular-nums text-muted-foreground shadow-xs">
{total}
</span>
</div>
{rows.length ? (
<ul className="divide-y">
{rows.map((row) => (
<li key={rowKey(row)} className="min-h-8 min-w-0">
{render(row)}
</li>
))}
</ul>
) : (
<p className="px-3 py-5 text-center text-xs text-muted-foreground">{empty}</p>
)}
{pages > 1 && (
<div className="flex h-9 items-center justify-between gap-1 border-t bg-muted/20 px-2">
<p className="text-[0.7rem] tabular-nums text-muted-foreground" aria-live="polite">
{page * pageSize + 1}–{Math.min(total, (page + 1) * pageSize)} of {total}
</p>
<div className="flex items-center gap-0.5">
<button
type="button"
aria-label={`Previous page of ${title}`}
disabled={pending || page === 0}
onClick={() => onPage(page - 1)}
className="inline-flex size-7 items-center justify-center rounded-md hover:bg-accent disabled:opacity-40"
>
<ChevronLeft className="size-4" aria-hidden />
</button>
<button
type="button"
aria-label={`Next page of ${title}`}
disabled={pending || page >= pages - 1}
onClick={() => onPage(page + 1)}
className="inline-flex size-7 items-center justify-center rounded-md hover:bg-accent disabled:opacity-40"
>
<ChevronRight className="size-4" aria-hidden />
</button>
</div>
</div>
)}
</section>
);
}