Files
silo/web/src/components/items/ItemTable.tsx
Forbes cb88b3977c feat(web): add user-selectable density mode with compact/comfortable toggle
Implements #17, #18, #19, #20, #21

- Add CSS custom properties for density-dependent spacing (--d-* vars)
  in theme.css with comfortable (default) and compact modes
- Create useDensity hook with localStorage persistence and DOM attribute sync
- Add FOUC prevention in main.tsx (sync density before first paint)
- Create shared PageFooter component merging stats + pagination
- Refactor AppShell to flex layout with density toggle button (COM/CMP)
- Consolidate inline pagination from ItemsPage/AuditPage into PageFooter
- Delete FooterStats.tsx (replaced by PageFooter)
- Replace all hardcoded padding/font/gap values in ItemTable, AuditTable,
  ItemsToolbar, and AuditToolbar with var(--d-*) references

Comfortable mode is already tighter than the previous hardcoded values.
Compact mode reduces further for power-user density.
2026-02-08 18:35:25 -06:00

364 lines
11 KiB
TypeScript

import { useState, useCallback } from "react";
import type { Item } from "../../api/types";
import { ContextMenu, type ContextMenuItem } from "../ContextMenu";
export interface ColumnDef {
key: string;
label: string;
}
export const ALL_COLUMNS: ColumnDef[] = [
{ key: "part_number", label: "Part Number" },
{ key: "item_type", label: "Type" },
{ key: "description", label: "Description" },
{ key: "revision", label: "Rev" },
{ key: "projects", label: "Projects" },
{ key: "created", label: "Created" },
{ key: "actions", label: "Actions" },
];
export const DEFAULT_COLUMNS_H = [
"part_number",
"item_type",
"description",
"revision",
];
export const DEFAULT_COLUMNS_V = [
"part_number",
"item_type",
"description",
"revision",
"created",
"actions",
];
interface ItemTableProps {
items: Item[];
loading: boolean;
selectedPN: string | null;
onSelect: (pn: string) => void;
visibleColumns: string[];
onColumnsChange: (cols: string[]) => void;
onEdit?: (pn: string) => void;
onDelete?: (pn: string) => void;
sortKey: string;
sortDir: "asc" | "desc";
onSort: (key: string) => void;
}
const typeColors: Record<string, { bg: string; color: string }> = {
part: { bg: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" },
assembly: { bg: "rgba(166,227,161,0.2)", color: "var(--ctp-green)" },
document: { bg: "rgba(249,226,175,0.2)", color: "var(--ctp-yellow)" },
tooling: { bg: "rgba(243,139,168,0.2)", color: "var(--ctp-red)" },
};
function formatDate(s: string) {
if (!s) return "";
const d = new Date(s);
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
function copyPN(pn: string) {
void navigator.clipboard.writeText(pn);
}
export function ItemTable({
items,
loading,
selectedPN,
onSelect,
visibleColumns,
onColumnsChange,
onEdit,
onDelete,
sortKey,
sortDir,
onSort,
}: ItemTableProps) {
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const handleHeaderContext = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setCtxMenu({ x: e.clientX, y: e.clientY });
}, []);
const toggleColumn = useCallback(
(key: string) => {
if (key === "part_number") return; // always visible
const next = visibleColumns.includes(key)
? visibleColumns.filter((c) => c !== key)
: [...visibleColumns, key];
if (next.length > 0) onColumnsChange(next);
},
[visibleColumns, onColumnsChange],
);
const cols = ALL_COLUMNS.filter((c) => visibleColumns.includes(c.key));
const sortedItems = [...items].sort((a, b) => {
let av: string | number = "";
let bv: string | number = "";
switch (sortKey) {
case "part_number":
av = a.part_number;
bv = b.part_number;
break;
case "item_type":
av = a.item_type;
bv = b.item_type;
break;
case "description":
av = a.description;
bv = b.description;
break;
case "revision":
av = a.current_revision;
bv = b.current_revision;
break;
case "created":
av = a.created_at;
bv = b.created_at;
break;
default:
return 0;
}
if (av < bv) return sortDir === "asc" ? -1 : 1;
if (av > bv) return sortDir === "asc" ? 1 : -1;
return 0;
});
const thStyle: React.CSSProperties = {
padding: "var(--d-th-py) var(--d-th-px)",
textAlign: "left",
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
fontWeight: 600,
fontSize: "var(--d-th-font)",
textTransform: "uppercase",
letterSpacing: "0.05em",
cursor: "pointer",
userSelect: "none",
whiteSpace: "nowrap",
};
const tdStyle: React.CSSProperties = {
padding: "var(--d-td-py) var(--d-td-px)",
fontSize: "var(--d-td-font)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 300,
};
if (loading) {
return (
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)" }}>
Loading items...
</div>
);
}
return (
<>
<div style={{ overflow: "auto", height: "100%" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead onContextMenu={handleHeaderContext}>
<tr>
{cols.map((col) => (
<th
key={col.key}
style={thStyle}
onClick={() => col.key !== "actions" && onSort(col.key)}
>
{col.label}
{sortKey === col.key && (
<span style={{ marginLeft: 4 }}>
{sortDir === "asc" ? "▲" : "▼"}
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{sortedItems.map((item, idx) => {
const isSelected = item.part_number === selectedPN;
const rowBg = isSelected
? "var(--ctp-surface1)"
: idx % 2 === 0
? "var(--ctp-base)"
: "var(--ctp-surface0)";
return (
<tr
key={item.id}
onClick={() => onSelect(item.part_number)}
style={{
backgroundColor: rowBg,
cursor: "pointer",
borderBottom: "1px solid var(--ctp-surface0)",
}}
onMouseEnter={(e) => {
if (!isSelected)
e.currentTarget.style.backgroundColor =
"var(--ctp-surface0)";
}}
onMouseLeave={(e) => {
if (!isSelected)
e.currentTarget.style.backgroundColor = rowBg;
}}
>
{cols.map((col) => {
switch (col.key) {
case "part_number":
return (
<td key={col.key} style={tdStyle}>
<span
onClick={(e) => {
e.stopPropagation();
copyPN(item.part_number);
}}
title="Click to copy"
style={{
fontFamily: "'JetBrains Mono', monospace",
color: "var(--ctp-peach)",
cursor: "copy",
}}
>
{item.part_number}
</span>
</td>
);
case "item_type": {
const tc = typeColors[item.item_type] ?? {
bg: "var(--ctp-surface1)",
color: "var(--ctp-text)",
};
return (
<td key={col.key} style={tdStyle}>
<span
style={{
padding: "0.1rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.75rem",
fontWeight: 500,
backgroundColor: tc.bg,
color: tc.color,
}}
>
{item.item_type}
</span>
</td>
);
}
case "description":
return (
<td
key={col.key}
style={{ ...tdStyle, maxWidth: 400 }}
>
{item.description}
</td>
);
case "revision":
return (
<td key={col.key} style={tdStyle}>
Rev {item.current_revision}
</td>
);
case "projects":
return (
<td key={col.key} style={tdStyle}>
</td>
);
case "created":
return (
<td key={col.key} style={tdStyle}>
{formatDate(item.created_at)}
</td>
);
case "actions":
return (
<td key={col.key} style={tdStyle}>
<button
onClick={(e) => {
e.stopPropagation();
onEdit?.(item.part_number);
}}
style={actionBtnStyle}
>
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete?.(item.part_number);
}}
style={{
...actionBtnStyle,
color: "var(--ctp-red)",
}}
>
Del
</button>
</td>
);
default:
return <td key={col.key} style={tdStyle} />;
}
})}
</tr>
);
})}
{sortedItems.length === 0 && (
<tr>
<td
colSpan={cols.length}
style={{
padding: "2rem",
textAlign: "center",
color: "var(--ctp-subtext0)",
}}
>
No items found
</td>
</tr>
)}
</tbody>
</table>
</div>
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
items={ALL_COLUMNS.map(
(col): ContextMenuItem => ({
label: col.label,
checked: visibleColumns.includes(col.key),
onToggle: () => toggleColumn(col.key),
disabled: col.key === "part_number",
}),
)}
/>
)}
</>
);
}
const actionBtnStyle: React.CSSProperties = {
background: "none",
border: "none",
color: "var(--ctp-subtext1)",
cursor: "pointer",
fontSize: "0.8rem",
padding: "0.15rem 0.4rem",
borderRadius: "0.25rem",
};