Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 | 8x 8x 8x 8x 81x 81x 81x 81x 81x 77x 77x 77x 77x 77x 77x 77x 8x 88x 88x 88x 88x 6x 196x 2x 19x 8x | import { ChevronLeft, ChevronRight } from "lucide-react";
import { forwardRef, type HTMLAttributes } from "react";
import { cn } from "../../lib/utils";
/**
* The job book's pager.
*
* A `<nav aria-label="Pagination">` landmark wrapping an ordered strip of
* page buttons, with the printed status line ("PAGE 2 OF 7 · 1,284 TOTAL")
* that the ruled, figure-led world wants beside it — tabular, so the digits
* do not jitter as the page changes.
*
* - **Real `aria-current="page"`** on the current page button, and that button
* is marked with a 3px index-red inset bar — the Index Red Rule: index red
* marks the current *item*, never state, and never as a filled block. A page
* number is a position, so it earns the marker; a facet is a filter, so it
* does not (see `.facet` in `.context/mockups/_base.css` §7.6).
* - Three control tiers: 40px comfortable / 32px dense / 24px micro. The 44px
* touch floor is **not** in this component — `min-h-[var(--ctl-h)]` is how
* the `@media (pointer: coarse)` block in `@interioring/design-system`
* reaches these buttons without this file knowing the number.
* - Keyboard-complete by construction: every control is a real `<button>`, so
* Tab reaches it and Enter/Space fires it. Skipped ranges are `aria-hidden`
* because an ellipsis is not actionable.
*
* The marker ink is `--secondary-600`, which *is* index red — see the note in
* `table.tsx` for why that spelling and not `--index`.
*/
export type PaginationDensity = "comfortable" | "dense" | "micro";
/** The tier class, so nested controls inherit `--ctl-h`. */
const DENSITY_TIER: Record<PaginationDensity, string> = {
comfortable: "ctl-40",
dense: "ctl-32",
micro: "ctl-24",
};
/**
* 40 / 32 / 24 as token-scale heights. `min-h-[var(--ctl-h)]` can only ever
* raise them — which is precisely the coarse-pointer floor, applied from the
* stylesheet rather than from here.
*/
const CONTROL_TIER: Record<PaginationDensity, string> = {
comfortable: "h-10 min-w-10 gap-1.5 px-3 text-sm",
dense: "h-8 min-w-8 gap-1 px-2 text-sm",
micro: "h-6 min-w-6 gap-1 px-1.5 text-xs",
};
const CONTROL_BASE =
"inline-flex min-h-[var(--ctl-h)] items-center justify-center rounded-sm font-semibold whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:pointer-events-none disabled:opacity-50";
const CONTROL_QUIET =
"text-foreground-muted hover:bg-foreground-default/7 hover:text-foreground-default";
export type PaginationItem = number | "ellipsis-start" | "ellipsis-end";
/**
* The window of page numbers to print: always the first and last page, the
* current page with `siblingCount` neighbours either side, and one ellipsis
* standing in for each skipped range.
*
* `getPageItems(5, 10)` → `[1, "ellipsis-start", 4, 5, 6, "ellipsis-end", 10]`
*
* The two gaps are named rather than both being `"ellipsis"` so each item in
* the returned list is its own React key — there is no array index in the
* render below.
*/
export function getPageItems(
page: number,
totalPages: number,
siblingCount = 1,
): PaginationItem[] {
const last = Math.max(1, Math.floor(totalPages));
const current = Math.min(Math.max(1, Math.floor(page)), last);
const siblings = Math.max(0, Math.floor(siblingCount));
const items: PaginationItem[] = [1];
if (last === 1) return items;
const start = Math.max(2, current - siblings);
const end = Math.min(last - 1, current + siblings);
if (start > 2) items.push("ellipsis-start");
for (let p = start; p <= end; p++) items.push(p);
if (end < last - 1) items.push("ellipsis-end");
items.push(last);
return items;
}
export interface PaginationProps
extends Omit<HTMLAttributes<HTMLElement>, "children"> {
/** 1-based. */
page: number;
totalPages: number;
onPageChange: (page: number) => void;
/** Total item count, for the status line. Formatted en-IN (1,28,400). */
total?: number;
/** Print "PAGE 2 OF 7 · 1,284 TOTAL". Default true. */
showStatus?: boolean;
/** Print the numbered buttons. False leaves a Previous/Next pager. */
showPageNumbers?: boolean;
/** Neighbours printed either side of the current page. Default 1. */
siblingCount?: number;
density?: PaginationDensity;
/** The nav landmark's accessible name. Default "Pagination". */
label?: string;
}
const Pagination = forwardRef<HTMLElement, PaginationProps>(
(
{
className,
page,
totalPages,
onPageChange,
total,
showStatus = true,
showPageNumbers = true,
siblingCount = 1,
density = "comfortable",
label = "Pagination",
...props
},
ref,
) => {
const last = Math.max(1, Math.floor(totalPages));
const current = Math.min(Math.max(1, Math.floor(page)), last);
const control = cn(CONTROL_BASE, CONTROL_TIER[density]);
return (
<nav
ref={ref}
aria-label={label}
className={cn(
"flex flex-wrap items-center justify-between gap-3",
DENSITY_TIER[density],
className,
)}
{...props}
>
{showStatus && (
<p className="text-xs font-semibold tracking-wider text-foreground-muted uppercase tabular-nums">
Page {current} of {last}
{total !== undefined && ` · ${total.toLocaleString("en-IN")} total`}
</p>
)}
<ul className="flex items-center gap-0.5">
<li>
<button
type="button"
onClick={() => onPageChange(Math.max(1, current - 1))}
disabled={current <= 1}
aria-label="Previous page"
className={cn(control, CONTROL_QUIET)}
>
<ChevronLeft className="size-4 shrink-0" aria-hidden="true" />
<span className="hidden sm:inline">Previous</span>
</button>
</li>
{showPageNumbers &&
getPageItems(current, last, siblingCount).map((item) =>
typeof item === "number" ? (
<li key={item}>
<button
type="button"
onClick={() => onPageChange(item)}
aria-current={item === current ? "page" : undefined}
aria-label={`Page ${item}`}
className={cn(
control,
"tabular-nums",
item === current
? // The marker: 3px (--mark-w) of index red under the
// current page, and nothing else. An inset shadow,
// so marking a page shifts no label by a pixel.
"text-foreground-default shadow-[inset_0_-3px_0_var(--secondary-600)]"
: CONTROL_QUIET,
)}
>
{item}
</button>
</li>
) : (
<li
key={item}
aria-hidden="true"
className="px-1 text-sm text-foreground-subtle select-none"
>
…
</li>
),
)}
<li>
<button
type="button"
onClick={() => onPageChange(Math.min(last, current + 1))}
disabled={current >= last}
aria-label="Next page"
className={cn(control, CONTROL_QUIET)}
>
<span className="hidden sm:inline">Next</span>
<ChevronRight className="size-4 shrink-0" aria-hidden="true" />
</button>
</li>
</ul>
</nav>
);
},
);
Pagination.displayName = "Pagination";
export { Pagination };
|