All files / src/components/ui table.tsx

100% Statements 31/31
100% Branches 14/14
100% Functions 8/8
100% Lines 31/31

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 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284                                                                                                                    65x     65x             65x           65x           65x                         65x   1606x                                                         65x   65x       1588x                             65x   65x       1596x   65x   65x       50x                     65x             65x   5612x                                     65x             65x   8234x 8234x                             65x             65x   21308x 21308x                           65x                       65x   1580x                   65x                        
import {
	createContext,
	forwardRef,
	type HTMLAttributes,
	type TableHTMLAttributes,
	type TdHTMLAttributes,
	type ThHTMLAttributes,
	useContext,
} from "react";
import { cn } from "../../lib/utils";
 
/**
 * The Job Book's ruled data table.
 *
 * The portal is the designer's working binder for the same materials the
 * marketplace's Shade Card sells: same ink, but RULED, DENSE AND TABULAR.
 * Figures and rows lead here, not photos. The vocabulary below is the Tailwind
 * translation of `.table` in `.context/mockups/_base.css` (§7.4):
 *
 * - **Hairline dividers, not boxes.** Every row carries one 1px
 *   `border-border-default` rule; the header carries the strong one. There is
 *   no outer box — the surrounding Card is the box.
 * - **Rows TINT on hover, they never lift.** 7% ink is correct on every ground
 *   in both themes (light ink is near-black, dark ink is near-white), which is
 *   exactly how the design system defines `--tint`. The −3px lift is reserved
 *   for draggable things, where it means "pick-up-able".
 * - **The Index Red Rule.** `isCurrent` marks the current row with a 3px inset
 *   bar (`--mark-w`) against its first cell and nothing else — never a filled
 *   block, which would spend index red's scarcity. Index red is never used for
 *   state; `status-badge` carries state.
 * - **Numbers are tabular and right-aligned.** `<TableCell numeric>` and
 *   `<TableHead numeric>` do that, so no caller has to remember.
 *
 * Density is the three control tiers — 40px comfortable / 32px dense / 24px
 * micro. `<Table density>` both sizes its own cells and sets the tier class, so
 * controls *inside* the rows read `--ctl-h` from the row they sit in. The 44px
 * touch floor is deliberately NOT in this file: it comes from the
 * `@media (pointer: coarse)` block in `@interioring/design-system`.
 *
 * Composes with, and does not replace, `sortable-header.tsx`: a hand-rolled
 * `<th>` dropped into `<TableHeader>` inherits the strong rule and the printed
 * `.label` typography (it keeps its own padding).
 *
 * **Why the marker is `--secondary-600` and not `--index`.** They are the same
 * ink: in the design system, `--secondary-600` is `#B3301A` — the Shade Card's
 * `--index-ink` — and the whole `secondary-*` scale *is* index red (see
 * `apps/marketplace/DESIGN.md`). `--index` itself is still on the
 * no-consumer-yet list that `src/styles.test.ts` guards, because
 * `styles/earthy-legacy.css` gives it no Earthy value and using it would be a
 * real pixel change before the reskin. `--secondary-600` has an Earthy value,
 * so the marker holds non-text contrast (1.4.11) today in both themes — 3.8:1
 * light, 6.4:1 dark — and resolves to index red the moment that one legacy
 * import is deleted. Nothing to change here when it is.
 */
 
export type TableDensity = "comfortable" | "dense" | "micro";
 
/** Set by `<Table>`; read by `<TableHead>` / `<TableCell>`. */
const TableDensityContext = createContext<TableDensity>("comfortable");
 
/** The tier class every control nested in a row inherits `--ctl-h` from. */
const DENSITY_TIER: Record<TableDensity, string> = {
	comfortable: "ctl-40",
	dense: "ctl-32",
	micro: "ctl-24",
};
 
/** 0.9375rem in the mockup; the nearest token-scale step is `text-sm`. */
const DENSITY_TEXT: Record<TableDensity, string> = {
	comfortable: "text-sm",
	dense: "text-sm",
	micro: "text-xs",
};
 
const HEAD_PADDING: Record<TableDensity, string> = {
	comfortable: "px-3 py-2",
	dense: "px-3 py-1.5",
	micro: "px-2 py-1",
};
 
const CELL_PADDING: Record<TableDensity, string> = {
	comfortable: "px-3 py-3",
	dense: "px-3 py-2",
	micro: "px-2 py-1",
};
 
export interface TableProps extends TableHTMLAttributes<HTMLTableElement> {
	/** 40px comfortable (default) / 32px dense / 24px micro. */
	density?: TableDensity;
	/** Classes for the horizontal-scroll wrapper around the table. */
	wrapperClassName?: string;
}
 
const Table = forwardRef<HTMLTableElement, TableProps>(
	({ className, density = "comfortable", wrapperClassName, ...props }, ref) => (
		<TableDensityContext.Provider value={density}>
			{/*
			 * The scroll container is a tab stop on purpose: a region that
			 * scrolls has to be reachable from the keyboard (WCAG 2.1.1), and a
			 * wide job-book table scrolls on every phone.
			 */}
			<div
				// biome-ignore lint/a11y/noNoninteractiveTabindex: a scrollable region must be reachable from the keyboard (WCAG 2.1.1) — this is the fix, not a stray tab stop
				tabIndex={0}
				className={cn(
					"w-full overflow-x-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500",
					wrapperClassName,
				)}
			>
				<table
					ref={ref}
					data-density={density}
					className={cn(
						"w-full caption-bottom border-collapse text-left",
						DENSITY_TEXT[density],
						DENSITY_TIER[density],
						className,
					)}
					{...props}
				/>
			</div>
		</TableDensityContext.Provider>
	),
);
Table.displayName = "Table";
 
const TableHeader = forwardRef<
	HTMLTableSectionElement,
	HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
	<thead
		ref={ref}
		className={cn(
			// The column rule is the strong hairline, printed on the cells so a
			// hand-rolled <th> (SortableHeader) is ruled like a TableHead.
			"[&_th]:border-b [&_th]:border-border-strong",
			// `.label`: a column head is a printed code, not sentence case.
			"[&_th]:text-xs [&_th]:font-semibold [&_th]:uppercase [&_th]:tracking-wider [&_th]:whitespace-nowrap [&_th]:text-foreground-muted",
			// A header row is not a data row: it does not tint.
			"[&_tr]:hover:bg-transparent",
			className,
		)}
		{...props}
	/>
));
TableHeader.displayName = "TableHeader";
 
const TableBody = forwardRef<
	HTMLTableSectionElement,
	HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
	<tbody ref={ref} className={cn(className)} {...props} />
));
TableBody.displayName = "TableBody";
 
const TableFooter = forwardRef<
	HTMLTableSectionElement,
	HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
	<tfoot
		ref={ref}
		className={cn(
			// The totals strip: ruled off with the strong hairline, and quiet —
			// a figure row is not hoverable.
			"border-t border-border-strong font-semibold [&>tr]:border-0 [&>tr:hover]:bg-transparent",
			className,
		)}
		{...props}
	/>
));
TableFooter.displayName = "TableFooter";
 
export interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> {
	/** The row the page is currently about: a tint plus the one 3px index-red bar. */
	isCurrent?: boolean;
}
 
const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
	({ className, isCurrent, ...props }, ref) => (
		<tr
			ref={ref}
			aria-current={isCurrent ? "true" : undefined}
			data-current={isCurrent ? "true" : undefined}
			className={cn(
				"border-b border-border-default transition-colors last:border-0",
				// Rows tint; they never lift. 7% ink === the design system's --tint.
				"hover:bg-foreground-default/7",
				isCurrent &&
					// The Index Red Rule: 3px inset bar (--mark-w), nothing else. An
					// inset shadow rather than a border, so marking a row shifts no
					// pixel of its content.
					"bg-foreground-default/7 [&>td:first-child]:shadow-[inset_3px_0_0_var(--secondary-600)]",
				className,
			)}
			{...props}
		/>
	),
);
TableRow.displayName = "TableRow";
 
export interface TableHeadProps extends ThHTMLAttributes<HTMLTableCellElement> {
	/** Right-align and set tabular figures — a count, a rupee amount, a date. */
	numeric?: boolean;
}
 
const TableHead = forwardRef<HTMLTableCellElement, TableHeadProps>(
	({ className, numeric, scope = "col", ...props }, ref) => {
		const density = useContext(TableDensityContext);
		return (
			<th
				ref={ref}
				scope={scope}
				className={cn(
					"border-b border-border-strong align-middle text-left text-xs font-semibold uppercase tracking-wider whitespace-nowrap text-foreground-muted",
					HEAD_PADDING[density],
					numeric && "text-right tabular-nums",
					className,
				)}
				{...props}
			/>
		);
	},
);
TableHead.displayName = "TableHead";
 
export interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> {
	/** Right-align and set tabular figures — a count, a rupee amount, a date. */
	numeric?: boolean;
}
 
const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
	({ className, numeric, ...props }, ref) => {
		const density = useContext(TableDensityContext);
		return (
			<td
				ref={ref}
				className={cn(
					"align-middle",
					CELL_PADDING[density],
					numeric && "text-right tabular-nums",
					className,
				)}
				{...props}
			/>
		);
	},
);
TableCell.displayName = "TableCell";
 
export interface TableCaptionProps
	extends HTMLAttributes<HTMLTableCaptionElement> {
	/**
	 * Print the caption. Default is screen-reader-only: every job-book table
	 * already sits under a page or panel title, and the mockups caption
	 * `<caption class="sr-only">` for exactly that reason.
	 */
	visible?: boolean;
}
 
const TableCaption = forwardRef<HTMLTableCaptionElement, TableCaptionProps>(
	({ className, visible, ...props }, ref) => (
		<caption
			ref={ref}
			className={cn(
				visible ? "py-2 text-left text-sm text-foreground-muted" : "sr-only",
				className,
			)}
			{...props}
		/>
	),
);
TableCaption.displayName = "TableCaption";
 
export {
	Table,
	TableBody,
	TableCaption,
	TableCell,
	TableFooter,
	TableHead,
	TableHeader,
	TableRow,
};