feat(ui): establish table and list density contract
This commit is contained in:
@@ -51,6 +51,7 @@ import { useI18n } from "../i18n/I18nProvider";
|
||||
import { JobApplication } from "../types";
|
||||
import { useViewResource } from "../hooks/useViewResource";
|
||||
import { getWorkflowAction } from "../jobWorkflowSignals";
|
||||
import type { ContentDensity } from "../theme";
|
||||
|
||||
interface PagedResult<T> {
|
||||
items: T[];
|
||||
@@ -179,6 +180,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
const [jobs, setJobs] = useState<JobApplication[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(() => queryPage(location.search));
|
||||
// Presentation only: not an account preference, saved-view field or API input.
|
||||
const [density, setDensity] = useState<ContentDensity>("comfortable");
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [search, setSearch] = useState(() => new URLSearchParams(location.search).get("q") ?? "");
|
||||
const debouncedSearch = useDebouncedValue(search, 250);
|
||||
@@ -456,6 +459,15 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
const statusOptions = ["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const;
|
||||
const visibleDesktopColumns = 6 + Number(columns.status) + Number(columns.dateApplied) + Number(columns.daysSince) + Number(columns.jobUrl);
|
||||
const selectedCompanyIsLoading = companyFilterId !== "All" && !companies.some((company) => company.id === companyFilterId);
|
||||
const densityControl = (
|
||||
<FormControl size="small" sx={{ minWidth: 150, width: { xs: "100%", sm: "auto" } }}>
|
||||
<InputLabel id="job-table-density-label">{t("listDensity")}</InputLabel>
|
||||
<Select labelId="job-table-density-label" label={t("listDensity")} value={density} onChange={(event) => setDensity(event.target.value as ContentDensity)}>
|
||||
<MenuItem value="comfortable">{t("densityComfortable")}</MenuItem>
|
||||
<MenuItem value="compact">{t("densityCompact")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -537,6 +549,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
{t("jobTableColumns")}
|
||||
</Button>
|
||||
</Box>
|
||||
{densityControl}
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
@@ -589,6 +602,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
{mode === "jobs" ? <FormControlLabel control={<Checkbox checked={includeDeleted} onChange={(e) => changeIncludeDeleted(e.target.checked)} />} label={t("jobTableShowDeleted")} sx={{ mr: 0 }} /> : null}
|
||||
<SavedViewsMenu current={{ q: search.trim() || undefined, status: statusFilter !== "All" ? statusFilter : undefined, companyId: companyFilterId === "All" ? undefined : (companyFilterId as number), location: locationFilter.trim() || undefined, needsFollowUp: needsFollowUpOnly ? true : undefined, readiness: readinessFilter === "all" ? undefined : readinessFilter }} onApply={applySavedView} />
|
||||
{!isMobile ? <Tooltip title={t("jobTableColumns")}><IconButton aria-label={t("jobTableColumns")} onClick={(e) => setColumnsAnchor(e.currentTarget)}><ViewColumnIcon /></IconButton></Tooltip> : null}
|
||||
{densityControl}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
@@ -605,8 +619,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
|
||||
<Menu anchorEl={columnsAnchor} open={Boolean(columnsAnchor)} onClose={() => setColumnsAnchor(null)}>
|
||||
{([ ["status", t("settingsColumnStatus")], ["dateApplied", t("settingsColumnDateApplied")], ["daysSince", t("settingsColumnDays")], ["jobUrl", t("settingsColumnJobUrl")] ] as const).map(([key, label]) => (
|
||||
<MenuItem key={key} onClick={() => onColumnsChange({ ...columns, [key]: !columns[key] })}>
|
||||
<Checkbox checked={columns[key]} />
|
||||
<MenuItem key={key} aria-label={label} onClick={() => onColumnsChange({ ...columns, [key]: !columns[key] })}>
|
||||
<Checkbox checked={columns[key]} inputProps={{ "aria-label": label }} />
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
@@ -629,16 +643,17 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
) : null}
|
||||
|
||||
<Paper
|
||||
data-content-density={density}
|
||||
sx={{
|
||||
mt: 2,
|
||||
overflow: "hidden",
|
||||
borderRadius: 4,
|
||||
border: "none",
|
||||
boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)",
|
||||
boxShadow: "var(--customShadows-section, none)",
|
||||
}}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Stack spacing={1.25} sx={{ p: 1.25 }}>
|
||||
<Stack spacing="var(--app-list-gap)" sx={{ p: 1.25 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, px: 0.5 }}>
|
||||
<FormControlLabel control={<Checkbox checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} />} label={t("jobTableSelectAll")} sx={{ mr: 0 }} />
|
||||
</Box>
|
||||
@@ -652,7 +667,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
data-job-row-id={job.id}
|
||||
role={mode === "jobs" && !job.isDeleted ? "link" : undefined}
|
||||
tabIndex={mode === "jobs" && !job.isDeleted ? 0 : undefined}
|
||||
aria-label={mode === "jobs" && !job.isDeleted ? `Open ${job.jobTitle} at ${job.company?.name ?? "company"}` : undefined}
|
||||
aria-label={mode === "jobs" && !job.isDeleted ? t("jobTableOpenJob", { title: job.jobTitle, company: job.company?.name ?? t("jobTableCompany") }) : undefined}
|
||||
onClick={(event) => {
|
||||
if (mode === "jobs" && !job.isDeleted && !isInteractiveTarget(event.target)) openJob(job.id);
|
||||
}}
|
||||
@@ -663,20 +678,20 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
p: "var(--app-list-item-padding)",
|
||||
borderRadius: 3.5,
|
||||
cursor: mode === "jobs" && !job.isDeleted ? "pointer" : "default",
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.03),
|
||||
backgroundColor: isSelected ? "action.selected" : alpha(theme.palette.primary.main, 0.03),
|
||||
borderColor: alpha(theme.palette.primary.main, 0.08),
|
||||
boxShadow: `0 10px 24px ${alpha(theme.palette.common.black, theme.palette.mode === "dark" ? 0.18 : 0.06)}`,
|
||||
"&:hover": mode === "jobs" && !job.isDeleted ? { backgroundColor: "action.hover" } : undefined,
|
||||
"&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 },
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.25}>
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 1 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1, minWidth: 0, flex: 1 }}>
|
||||
<Checkbox checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} />
|
||||
<Stack spacing="var(--app-list-gap)">
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1, minWidth: 0, flex: "1 1 180px" }}>
|
||||
<Checkbox inputProps={{ "aria-label": t("jobTableSelectJob", { title: job.jobTitle }) }} checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} sx={{ mt: -0.5, ml: -1 }} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>
|
||||
{job.company?.name ?? t("jobTableCompany")}
|
||||
@@ -686,7 +701,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{columns.status ? <Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800 }} /> : null}
|
||||
{columns.status ? <Chip label={statusLabel(t, job.status)} size="small" color={toneName as any} sx={{ fontWeight: 800, maxWidth: "100%", height: "auto", minHeight: 24, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }} /> : null}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
|
||||
@@ -767,8 +782,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box sx={{ overflowX: "auto" }}>
|
||||
<Table size="small" sx={{ minWidth: 980 }}>
|
||||
<Box role="region" aria-label={t("jobTableScrollRegion")} tabIndex={0} sx={{ overflowX: "auto", "&:focus-visible": { outlineOffset: -3 } }}>
|
||||
<Table aria-label={t(mode === "trash" ? "trash" : "jobApplications")} size="small" sx={{ minWidth: 980 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox"><Checkbox inputProps={{ "aria-label": t("jobTableSelectAll") }} checked={selectedAllOnPage} indeterminate={selectedIds.length > 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /></TableCell>
|
||||
@@ -792,8 +807,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
key={job.id}
|
||||
data-job-row-id={job.id}
|
||||
hover={mode === "jobs" && !job.isDeleted}
|
||||
selected={isSelected}
|
||||
tabIndex={mode === "jobs" && !job.isDeleted ? 0 : undefined}
|
||||
aria-label={mode === "jobs" && !job.isDeleted ? `Open ${job.jobTitle} at ${job.company?.name ?? "company"}` : undefined}
|
||||
aria-label={mode === "jobs" && !job.isDeleted ? t("jobTableOpenJob", { title: job.jobTitle, company: job.company?.name ?? t("jobTableCompany") }) : undefined}
|
||||
onClick={(event) => {
|
||||
if (mode === "jobs" && !job.isDeleted && !isInteractiveTarget(event.target)) openJob(job.id);
|
||||
}}
|
||||
@@ -811,7 +827,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
>
|
||||
<TableCell padding="checkbox"><Checkbox inputProps={{ "aria-label": t("jobTableSelectJob", { title: job.jobTitle }) }} checked={isSelected} onChange={(e) => toggleSelected(job.id, e.target.checked)} /></TableCell>
|
||||
<TableCell sx={{ minWidth: 140, fontWeight: 700 }}>{job.company?.name ?? ""}</TableCell>
|
||||
<TableCell>
|
||||
<TableCell sx={{ minWidth: 260 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Typography component="span" sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{job.jobTitle}</Typography>
|
||||
{actionSignals.map((signal) => (
|
||||
@@ -825,7 +841,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
sx={{ fontWeight: 800, cursor: "pointer" }}
|
||||
clickable
|
||||
onClick={signal.onClick}
|
||||
aria-label={`${job.jobTitle} — ${signal.label} signal`}
|
||||
aria-label={t("jobTableWorkflowSignal", { title: job.jobTitle, signal: signal.label })}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
@@ -853,7 +869,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
<TablePagination component="div" count={total} page={page} onPageChange={(_, next) => { setPage(next); updateListRoute({ page: next > 0 ? String(next + 1) : null }); }} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); updateListRoute({ page: null }); }} rowsPerPageOptions={[15, 20, 25]} />
|
||||
<TablePagination component="div" labelRowsPerPage={t("settingsRowsPerPage")} labelDisplayedRows={({ from, to, count }) => t("tableDisplayedRows", { from, to, count })} getItemAriaLabel={(type) => t(type === "next" ? "tableNextPage" : type === "previous" ? "tablePreviousPage" : type === "first" ? "tableFirstPage" : "tableLastPage")} count={total} page={page} onPageChange={(_, next) => { setPage(next); updateListRoute({ page: next > 0 ? String(next + 1) : null }); }} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); updateListRoute({ page: null }); }} rowsPerPageOptions={[15, 20, 25]} />
|
||||
</Paper>
|
||||
|
||||
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((token) => token + 1)} />
|
||||
|
||||
Reference in New Issue
Block a user