Critical fixes: - C-01: DatePicker isMobile now actually used for bottom sheet positioning - C-02: Calendar title always visible (text-sm on mobile, text-lg on sm+) - C-03: Mobile card text-[10px] → text-xs (meets 12px minimum) Warning fixes: - W-01: useMediaQuery SSR-safe (typeof window guard) - W-02: KanbanBoard TouchSensor added (was lost during branch ops) - W-03: Removed duplicate isMobile query, derived from !isDesktop - W-04: Search restored on mobile for Calendar/Reminders/Projects (w-32 sm:w-52) - W-05: SheetClose added to CalendarSidebar mobile Sheet - W-06: Button icon uses min-h/min-w for touch targets instead of h-11 Suggestion fixes: - S-01: Removed deprecated WebkitOverflowScrolling from KanbanBoard - S-02: Added role/tabIndex/onKeyDown to EntityTable mobile card wrappers - S-03: Added overflow-y-auto to mobile event detail panel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
249 lines
7.5 KiB
TypeScript
249 lines
7.5 KiB
TypeScript
import React from 'react';
|
|
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
|
import type { VisibilityMode } from '@/hooks/useTableVisibility';
|
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
|
|
|
export interface ColumnDef<T> {
|
|
key: string;
|
|
label: string;
|
|
render: (item: T) => React.ReactNode;
|
|
sortable?: boolean;
|
|
visibilityLevel: VisibilityMode;
|
|
}
|
|
|
|
export interface RowGroup<T> {
|
|
label: string;
|
|
rows: T[];
|
|
}
|
|
|
|
interface EntityTableProps<T extends { id: number }> {
|
|
columns: ColumnDef<T>[];
|
|
groups: RowGroup<T>[];
|
|
pinnedRows: T[];
|
|
pinnedLabel: string;
|
|
showPinned: boolean;
|
|
selectedId: number | null;
|
|
onRowClick: (id: number) => void;
|
|
sortKey: string;
|
|
sortDir: 'asc' | 'desc';
|
|
onSort: (key: string) => void;
|
|
visibilityMode: VisibilityMode;
|
|
loading?: boolean;
|
|
mobileCardRender?: (item: T) => React.ReactNode;
|
|
}
|
|
|
|
const LEVEL_ORDER: VisibilityMode[] = ['essential', 'filtered', 'all'];
|
|
|
|
function isVisible(colLevel: VisibilityMode, mode: VisibilityMode): boolean {
|
|
return LEVEL_ORDER.indexOf(colLevel) <= LEVEL_ORDER.indexOf(mode);
|
|
}
|
|
|
|
function SkeletonRow({ colCount }: { colCount: number }) {
|
|
return (
|
|
<tr className="border-b border-border/50">
|
|
{Array.from({ length: colCount }).map((_, i) => (
|
|
<td key={i} className="px-3 py-2.5">
|
|
<div className="animate-pulse rounded-md bg-muted h-4 w-full" />
|
|
</td>
|
|
))}
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
function SortIcon({
|
|
sortKey,
|
|
sortDir,
|
|
colKey,
|
|
}: {
|
|
sortKey: string;
|
|
sortDir: 'asc' | 'desc';
|
|
colKey: string;
|
|
}) {
|
|
if (sortKey !== colKey) return <ArrowUpDown className="h-3.5 w-3.5 ml-1 opacity-40" />;
|
|
return sortDir === 'asc' ? (
|
|
<ArrowUp className="h-3.5 w-3.5 ml-1" />
|
|
) : (
|
|
<ArrowDown className="h-3.5 w-3.5 ml-1" />
|
|
);
|
|
}
|
|
|
|
function DataRow<T extends { id: number }>({
|
|
item,
|
|
visibleColumns,
|
|
selectedId,
|
|
onRowClick,
|
|
}: {
|
|
item: T;
|
|
visibleColumns: ColumnDef<T>[];
|
|
selectedId: number | null;
|
|
onRowClick: (id: number) => void;
|
|
}) {
|
|
return (
|
|
<tr
|
|
className={`border-b border-border/50 cursor-pointer hover:bg-card-elevated transition-colors duration-150 outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
|
selectedId === item.id ? 'bg-accent/10' : ''
|
|
}`}
|
|
onClick={() => onRowClick(item.id)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onRowClick(item.id);
|
|
}
|
|
}}
|
|
tabIndex={0}
|
|
role="row"
|
|
aria-selected={selectedId === item.id}
|
|
>
|
|
{visibleColumns.map((col) => (
|
|
<td key={col.key} className="px-3 py-2.5 text-sm">
|
|
{col.render(item)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
function SectionHeader({ label, colCount }: { label: string; colCount: number }) {
|
|
return (
|
|
<tr>
|
|
<td
|
|
colSpan={colCount}
|
|
className="px-3 pt-4 pb-1.5 text-[11px] uppercase tracking-wider text-muted-foreground font-medium"
|
|
>
|
|
{label}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
export function EntityTable<T extends { id: number }>({
|
|
columns,
|
|
groups,
|
|
pinnedRows,
|
|
pinnedLabel,
|
|
showPinned,
|
|
selectedId,
|
|
onRowClick,
|
|
sortKey,
|
|
sortDir,
|
|
onSort,
|
|
visibilityMode,
|
|
loading = false,
|
|
mobileCardRender,
|
|
}: EntityTableProps<T>) {
|
|
const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode));
|
|
const colCount = visibleColumns.length;
|
|
const showPinnedSection = showPinned && pinnedRows.length > 0;
|
|
const isMobile = useMediaQuery('(max-width: 767px)');
|
|
|
|
if (isMobile && mobileCardRender) {
|
|
return (
|
|
<div className="space-y-2">
|
|
{loading ? (
|
|
Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="animate-pulse rounded-lg bg-card border border-border p-4 h-20" />
|
|
))
|
|
) : (
|
|
<>
|
|
{showPinnedSection && (
|
|
<>
|
|
<p className="text-[11px] uppercase tracking-wider text-muted-foreground font-medium pt-2">{pinnedLabel}</p>
|
|
{pinnedRows.map((item) => (
|
|
<div key={item.id} onClick={() => onRowClick(item.id)} className="cursor-pointer" role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onRowClick(item.id); } }}>
|
|
{mobileCardRender(item)}
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
{groups.map((group) => (
|
|
<React.Fragment key={group.label}>
|
|
{group.rows.length > 0 && (
|
|
<>
|
|
<p className="text-[11px] uppercase tracking-wider text-muted-foreground font-medium pt-2">{group.label}</p>
|
|
{group.rows.map((item) => (
|
|
<div key={item.id} onClick={() => onRowClick(item.id)} className="cursor-pointer" role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onRowClick(item.id); } }}>
|
|
{mobileCardRender(item)}
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full">
|
|
<table className="w-full border-collapse">
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
{visibleColumns.map((col) => (
|
|
<th
|
|
key={col.key}
|
|
className="px-3 py-2 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium"
|
|
>
|
|
{col.sortable ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => onSort(col.key)}
|
|
aria-label={`Sort by ${col.label}`}
|
|
className="flex items-center hover:text-foreground transition-colors duration-150"
|
|
>
|
|
{col.label}
|
|
<SortIcon sortKey={sortKey} sortDir={sortDir} colKey={col.key} />
|
|
</button>
|
|
) : (
|
|
col.label
|
|
)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
Array.from({ length: 6 }).map((_, i) => <SkeletonRow key={i} colCount={colCount} />)
|
|
) : (
|
|
<>
|
|
{showPinnedSection && (
|
|
<>
|
|
<SectionHeader label={pinnedLabel} colCount={colCount} />
|
|
{pinnedRows.map((item) => (
|
|
<DataRow
|
|
key={item.id}
|
|
item={item}
|
|
visibleColumns={visibleColumns}
|
|
selectedId={selectedId}
|
|
onRowClick={onRowClick}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
{groups.map((group) => (
|
|
<React.Fragment key={group.label}>
|
|
{group.rows.length > 0 && (
|
|
<>
|
|
<SectionHeader label={group.label} colCount={colCount} />
|
|
{group.rows.map((item) => (
|
|
<DataRow
|
|
key={item.id}
|
|
item={item}
|
|
visibleColumns={visibleColumns}
|
|
selectedId={selectedId}
|
|
onRowClick={onRowClick}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|