diff --git a/app/(main)/(auth)/applications/[id]/loading.tsx b/app/(main)/(auth)/applications/[id]/loading.tsx index fb9c13ef..9a2eb187 100644 --- a/app/(main)/(auth)/applications/[id]/loading.tsx +++ b/app/(main)/(auth)/applications/[id]/loading.tsx @@ -1,26 +1,19 @@ import { AnswersCardSkeleton } from '@/components/features/application-answers-skeleton'; +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; import { Skeleton } from '@/components/ui/skeleton'; export default function MyApplicationDetailLoading() { return (
- {/* Back link skeleton */} - -
-
-
- - -
- -
-
- - -
-
- + +
diff --git a/app/(main)/(auth)/applications/loading.tsx b/app/(main)/(auth)/applications/loading.tsx index 1f05817a..9c5c0286 100644 --- a/app/(main)/(auth)/applications/loading.tsx +++ b/app/(main)/(auth)/applications/loading.tsx @@ -1,50 +1,21 @@ -import { Card, CardContent } from '@/components/ui/card'; -import { Skeleton } from '@/components/ui/skeleton'; +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; +import { + DataTableSkeleton, + type DataTableSkeletonColumn, +} from '@/components/ui/data-table-skeleton'; + +const COLUMNS: DataTableSkeletonColumn[] = [ + { head: 'w-20', cell: 'w-40', mobile: 'primary' }, + { head: 'w-16', shape: 'badge', mobile: 'trailing' }, + { head: 'w-24', mobile: 'line' }, + { head: 'w-16', shape: 'action', cell: 'w-20', mobile: 'lineTrailing' }, +]; export default function MyApplicationsLoading() { return (
-
- - -
- - - -
-
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
- - - - -
-
- ))} -
- -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
- - -
-
- - -
-
- ))} -
-
-
+ +
); } diff --git a/app/(main)/(auth)/emails/loading.tsx b/app/(main)/(auth)/emails/loading.tsx index 9d132ff2..539236bf 100644 --- a/app/(main)/(auth)/emails/loading.tsx +++ b/app/(main)/(auth)/emails/loading.tsx @@ -1,37 +1,17 @@ import { EmailFailureStripSkeleton } from '@/components/features/email-failure-strip'; -import { EmailLogTableSkeleton } from '@/components/features/email-log-table-skeleton'; +import { EmailLogResultsSkeleton } from '@/components/features/email-log-results'; +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; +import { DataTableToolbarSkeleton } from '@/components/ui/data-table-toolbar'; import { Skeleton } from '@/components/ui/skeleton'; export default function EmailsLoading() { return (
- {/* Header skeleton */} -
- - -
- + - - {/* Toolbar skeleton — Status Select + Template Select + Search Input */} -
-
- - -
-
- - -
-
- - -
-
- - - - + + +
); } diff --git a/app/(main)/(auth)/emails/page.tsx b/app/(main)/(auth)/emails/page.tsx index ecbc9726..707123a1 100644 --- a/app/(main)/(auth)/emails/page.tsx +++ b/app/(main)/(auth)/emails/page.tsx @@ -12,8 +12,10 @@ import { EmailFailureStrip, EmailFailureStripSkeleton, } from '@/components/features/email-failure-strip'; -import { EmailLogResults } from '@/components/features/email-log-results'; -import { EmailLogTableSkeleton } from '@/components/features/email-log-table-skeleton'; +import { + EmailLogResults, + EmailLogResultsSkeleton, +} from '@/components/features/email-log-results'; import { EmailLogToolbar } from '@/components/features/email-log-toolbar'; import { PageHeader } from '@/components/layouts/page-header'; @@ -66,7 +68,7 @@ export default async function EmailsPage({ searchParams }: EmailsPageProps) { } + fallback={} > - {/* PageHeader skeleton */} -
-
- - -
- -
- - {/* Table skeleton */} - - -
-
- - - - - -
- {Array.from({ length: 3 }).map((_, i) => ( -
- - - - - -
- - -
-
- ))} -
- -
- {Array.from({ length: 3 }).map((_, i) => ( -
-
- - -
-
- - -
-
- - -
-
- ))} -
-
-
+ +
); } diff --git a/app/(main)/(auth)/manage/applications/[id]/loading.tsx b/app/(main)/(auth)/manage/applications/[id]/loading.tsx index 619c6f6b..aee08d21 100644 --- a/app/(main)/(auth)/manage/applications/[id]/loading.tsx +++ b/app/(main)/(auth)/manage/applications/[id]/loading.tsx @@ -1,4 +1,5 @@ import { AnswersCardSkeleton } from '@/components/features/application-answers-skeleton'; +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; import { SectionCardSkeleton } from '@/components/ui/section-card'; import { Skeleton } from '@/components/ui/skeleton'; @@ -6,22 +7,14 @@ export default function ApplicationDetailLoading() { return (
- {/* Back link skeleton */} - -
-
-
- - -
- -
-
- - -
-
- + +
diff --git a/app/(main)/(auth)/manage/applications/loading.tsx b/app/(main)/(auth)/manage/applications/loading.tsx index bfc4c24e..7db6aa46 100644 --- a/app/(main)/(auth)/manage/applications/loading.tsx +++ b/app/(main)/(auth)/manage/applications/loading.tsx @@ -1,36 +1,15 @@ -import { ApplicationsTableSkeleton } from '@/components/features/applications-table-skeleton'; -import { Skeleton } from '@/components/ui/skeleton'; +import { ApplicationsResultsSkeleton } from '@/components/features/applications-results'; +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; +import { DataTableToolbarSkeleton } from '@/components/ui/data-table-toolbar'; export default function ApplicationsLoading() { return (
- {/* Header skeleton */} -
- - -
- - {/* Toolbar skeleton — Position Select + Applicant Select + Status Select + Search Input */} -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - + + +
); } diff --git a/app/(main)/(auth)/manage/applications/page.tsx b/app/(main)/(auth)/manage/applications/page.tsx index b6327bf7..f542d647 100644 --- a/app/(main)/(auth)/manage/applications/page.tsx +++ b/app/(main)/(auth)/manage/applications/page.tsx @@ -16,8 +16,10 @@ import { } from '@/lib/constants'; import type { ApplicationFilters } from '@/lib/types'; -import { ApplicationsResults } from '@/components/features/applications-results'; -import { ApplicationsTableSkeleton } from '@/components/features/applications-table-skeleton'; +import { + ApplicationsResults, + ApplicationsResultsSkeleton, +} from '@/components/features/applications-results'; import { ApplicationsToolbar } from '@/components/features/applications-toolbar'; import { PageHeader } from '@/components/layouts/page-header'; @@ -105,7 +107,7 @@ export default async function ApplicationsPage({ } + fallback={} > -
-
- - -
- +
+
+ +
- - {Array.from({ length: 3 }).map((_, i) => ( - - -
- - -
-
-
+ {Array.from({ length: count }).map((_, i) => ( + ))}
); } + +export default function ManagePositionsLoading() { + return ( +
+ +
+ + +
+
+ ); +} diff --git a/app/(main)/(auth)/positions/[id]/apply/loading.tsx b/app/(main)/(auth)/positions/[id]/apply/loading.tsx index ae649d40..45912231 100644 --- a/app/(main)/(auth)/positions/[id]/apply/loading.tsx +++ b/app/(main)/(auth)/positions/[id]/apply/loading.tsx @@ -1,13 +1,15 @@ +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; +import { Skeleton } from '@/components/ui/skeleton'; + export default function ApplyLoading() { return (
-
-
-
+
+
{[0, 1, 2].map((i) => ( -
+ ))}
diff --git a/app/(main)/(auth)/users/loading.tsx b/app/(main)/(auth)/users/loading.tsx index 0eb16a94..eb8c97d2 100644 --- a/app/(main)/(auth)/users/loading.tsx +++ b/app/(main)/(auth)/users/loading.tsx @@ -1,85 +1,33 @@ -import { Card, CardContent } from '@/components/ui/card'; -import { Skeleton } from '@/components/ui/skeleton'; +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; +import { + DataTableSkeleton, + type DataTableSkeletonColumn, +} from '@/components/ui/data-table-skeleton'; +import { DataTableToolbarSkeleton } from '@/components/ui/data-table-toolbar'; + +const COLUMNS: DataTableSkeletonColumn[] = [ + { head: 'w-24', cell: 'w-32', subCell: 'w-44', mobile: 'primary' }, + { head: 'w-16', shape: 'badge', mobile: 'trailing' }, + { head: 'w-20', mobile: 'hidden' }, + { head: 'w-8', mobile: 'hidden' }, + { head: 'w-20', shape: 'badge', mobile: 'line' }, + { + head: 'w-24', + shape: 'action', + headClassName: 'text-right', + mobile: 'lineTrailing', + }, +]; export default function UsersLoading() { return (
- {/* Header skeleton */} -
- - -
- - {/* Toolbar skeleton — role/position filters + search input + count line */} -
-
- - -
-
- - -
-
- - -
- -
- - {/* Table skeleton */} - - -
-
- - - - - - -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
- - -
- - - - -
- - -
-
- ))} -
- -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
-
- - -
- -
- -
- - -
-
- ))} -
-
-
+ + +
); } diff --git a/app/(main)/loading.tsx b/app/(main)/loading.tsx index f9c91e30..4f613c52 100644 --- a/app/(main)/loading.tsx +++ b/app/(main)/loading.tsx @@ -1,17 +1,9 @@ -import { SectionCardSkeleton } from '@/components/ui/section-card'; -import { Skeleton } from '@/components/ui/skeleton'; +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; export default function HomeLoading() { return (
- {/* Heading */} -
- - -
- - - +
); } diff --git a/app/(main)/positions/[id]/loading.tsx b/app/(main)/positions/[id]/loading.tsx index 902d82eb..e324a902 100644 --- a/app/(main)/positions/[id]/loading.tsx +++ b/app/(main)/positions/[id]/loading.tsx @@ -4,12 +4,12 @@ export default function PositionDetailLoading() { return (
- -
+ +
- +
- +
diff --git a/app/(main)/positions/loading.tsx b/app/(main)/positions/loading.tsx index d5da2c89..5426525b 100644 --- a/app/(main)/positions/loading.tsx +++ b/app/(main)/positions/loading.tsx @@ -1,24 +1,21 @@ -import { Card, CardHeader } from '@/components/ui/card'; +import { PositionCardSkeleton } from '@/components/features/position-card'; +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; import { Skeleton } from '@/components/ui/skeleton'; export default function PositionsLoading() { return (
-
- - -
+
- {Array.from({ length: 4 }).map((_, i) => ( - - -
- - -
-
-
- ))} +
+ + +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
); diff --git a/app/(main)/profile/loading.tsx b/app/(main)/profile/loading.tsx index 56da7dec..1bbb4f84 100644 --- a/app/(main)/profile/loading.tsx +++ b/app/(main)/profile/loading.tsx @@ -1,14 +1,11 @@ +import { PageHeaderSkeleton } from '@/components/layouts/page-header'; import { Skeleton } from '@/components/ui/skeleton'; export default function ProfileLoading() { return (
- {/* PageHeader skeleton */} -
- - -
+ {/* Edit toggle skeleton */}
diff --git a/components/features/answer-display.tsx b/components/features/answer-display.tsx index 0cc1211e..5c51f509 100644 --- a/components/features/answer-display.tsx +++ b/components/features/answer-display.tsx @@ -1,3 +1,4 @@ +import { formatShortAnswerValue } from '@/lib/constants'; import type { AnswerQuestion, QuestionFileTarget } from '@/lib/types'; import { answerFieldIds, partitionAnswerValue } from '@/lib/utils'; @@ -27,6 +28,7 @@ export function AnswerDisplay({ id={noticeId} values={orphaned} questionType={question.type} + format={question.format} /> )} @@ -46,7 +48,9 @@ export function AnswerDisplay({ ))}
) : ( -

{value[0]}

+

+ {formatShortAnswerValue(value[0] ?? '', question.format)} +

)} ); diff --git a/components/features/answer-field.tsx b/components/features/answer-field.tsx index 735c3e4b..51d2bb1a 100644 --- a/components/features/answer-field.tsx +++ b/components/features/answer-field.tsx @@ -82,6 +82,7 @@ export function AnswerField({ id={noticeId} values={orphaned} questionType={question.type} + format={question.format} /> ); diff --git a/components/features/answer-mismatch-notice.tsx b/components/features/answer-mismatch-notice.tsx index a43cc648..21aee36e 100644 --- a/components/features/answer-mismatch-notice.tsx +++ b/components/features/answer-mismatch-notice.tsx @@ -1,11 +1,17 @@ import type { QuestionType } from '@/prisma/client'; +import { + type ShortAnswerFormatValue, + formatShortAnswerValue, +} from '@/lib/constants'; + import { WarningCallout } from '@/components/ui/warning-callout'; interface AnswerMismatchNoticeProps { id: string; values: string[]; questionType: QuestionType; + format: ShortAnswerFormatValue | null; } // Callers wire `id` to the control's `aria-describedby`. @@ -13,6 +19,7 @@ export function AnswerMismatchNotice({ id, values, questionType, + format, }: AnswerMismatchNoticeProps) { if (values.length === 0) return null; @@ -39,7 +46,7 @@ export function AnswerMismatchNotice({
    {values.map((v, i) => (
  • - {v} + {formatShortAnswerValue(v, format)}
  • ))}
diff --git a/components/features/application-answers-list.tsx b/components/features/application-answers-list.tsx index 36dcd265..67b17e1b 100644 --- a/components/features/application-answers-list.tsx +++ b/components/features/application-answers-list.tsx @@ -1,3 +1,4 @@ +import { formatShortAnswerValue } from '@/lib/constants'; import { type ApplicationReviewAnswer } from '@/lib/types'; import { AnswerFileLink } from '@/components/features/answer-file-link'; @@ -75,10 +76,16 @@ function AnswerValue({ case 'short_answer': return answer.value.length > 1 ? (
- + + formatShortAnswerValue(v, answer.format), + )} + />
) : ( -
{answer.value[0]}
+
+ {formatShortAnswerValue(answer.value[0] ?? '', answer.format)} +
); default: { const exhaustiveCheck: never = answer.type; diff --git a/components/features/applications-results.tsx b/components/features/applications-results.tsx index 549b229d..d5d733f8 100644 --- a/components/features/applications-results.tsx +++ b/components/features/applications-results.tsx @@ -11,6 +11,7 @@ import { } from '@/prisma/data/applications'; import { APPLICATIONS_PAGE_SIZE } from '@/lib/constants'; +import { DATA_TABLE_RESULTS_CLASS } from '@/lib/data-table'; import { STATE_ICONS } from '@/lib/icons'; import type { ApplicationFilters, @@ -20,6 +21,11 @@ import type { import { buildApplicationsHref, getPaginationBounds } from '@/lib/utils'; import { ApplicationsTable } from '@/components/features/applications-table'; +import { + DataTableSkeleton, + type DataTableSkeletonColumn, +} from '@/components/ui/data-table-skeleton'; +import { Skeleton } from '@/components/ui/skeleton'; import { TablePagination } from '@/components/ui/table-pagination'; interface ApplicationsResultsProps { @@ -99,7 +105,7 @@ export async function ApplicationsResults({ ); return ( -
+

You can see who started an application, not what they've written. @@ -148,7 +154,7 @@ export async function ApplicationsResults({ paginateRows(merged, page); return ( -

+
+
({ ...a, isDraft: false as const }))} hasActiveFilters={hasActiveFilters} @@ -189,3 +195,42 @@ export async function ApplicationsResults({
); } + +interface ApplicationsResultsSkeletonProps { + isDraftView?: boolean; +} + +export function ApplicationsResultsSkeleton({ + isDraftView = false, +}: ApplicationsResultsSkeletonProps) { + const columns: DataTableSkeletonColumn[] = [ + ...(isDraftView + ? [] + : ([ + { + head: 'w-10', + shape: 'checkbox', + headClassName: 'w-10', + cellClassName: 'w-10', + mobile: 'leading', + }, + ] satisfies DataTableSkeletonColumn[])), + { head: 'w-24', cell: 'w-36', subCell: 'w-48', mobile: 'primary' }, + { head: 'w-20', cell: 'w-28' }, + { head: 'w-16', cell: 'w-20', shape: 'badge', mobile: 'trailing' }, + { head: 'w-24', cell: 'w-20' }, + ]; + + return ( +
+ {isDraftView && } + + {/* Single-page summary line only — multi-page controls row height is unknowable until fetch. */} + +
+ ); +} diff --git a/components/features/applications-table-skeleton.tsx b/components/features/applications-table-skeleton.tsx deleted file mode 100644 index 469a7ed1..00000000 --- a/components/features/applications-table-skeleton.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Card, CardContent } from '@/components/ui/card'; -import { Skeleton } from '@/components/ui/skeleton'; - -interface ApplicationsTableSkeletonProps { - // Off for the drafts view, whose table renders no checkbox column. - showSelection?: boolean; -} - -export function ApplicationsTableSkeleton({ - showSelection = true, -}: ApplicationsTableSkeletonProps = {}) { - return ( - - -
-
- {showSelection && } - - - - -
- {Array.from({ length: 5 }).map((_, i) => ( -
- {showSelection && } -
- - -
- - - -
- ))} -
- -
- {Array.from({ length: 4 }).map((_, i) => ( -
- {showSelection && ( - - )} -
-
- - -
- - - -
-
- ))} -
-
-
- ); -} diff --git a/components/features/email-log-results.tsx b/components/features/email-log-results.tsx index f94a87a8..b819932e 100644 --- a/components/features/email-log-results.tsx +++ b/components/features/email-log-results.tsx @@ -3,10 +3,16 @@ import 'server-only'; import { getEmailLogs, getEmailLogsCount } from '@/prisma/data/emails'; import { EMAIL_LOG_PAGE_SIZE } from '@/lib/constants'; +import { DATA_TABLE_RESULTS_CLASS } from '@/lib/data-table'; import type { EmailLogFilters } from '@/lib/types'; import { buildEmailLogHref, getPaginationBounds } from '@/lib/utils'; import { EmailLogTable } from '@/components/features/email-log-table'; +import { + DataTableSkeleton, + type DataTableSkeletonColumn, +} from '@/components/ui/data-table-skeleton'; +import { Skeleton } from '@/components/ui/skeleton'; import { TablePagination } from '@/components/ui/table-pagination'; interface EmailLogResultsProps { @@ -37,7 +43,7 @@ export async function EmailLogResults({ : initialRows; return ( -
+
buildEmailLogHref(filters, p)} @@ -52,3 +58,24 @@ export async function EmailLogResults({
); } + +const EMAIL_LOG_SKELETON_COLUMNS: DataTableSkeletonColumn[] = [ + { head: 'w-32', cell: 'w-48', subCell: 'w-32', mobile: 'primary' }, + { head: 'w-24', mobile: 'hidden' }, + { head: 'w-40', mobile: 'line' }, + { head: 'w-20', shape: 'badge', mobile: 'trailing' }, + { head: 'w-24', mobile: 'line' }, +]; + +export function EmailLogResultsSkeleton() { + return ( +
+ + {/* Single-page summary line only — multi-page controls row height is unknowable until fetch. */} + +
+ ); +} diff --git a/components/features/email-log-table-skeleton.tsx b/components/features/email-log-table-skeleton.tsx deleted file mode 100644 index 06df7b96..00000000 --- a/components/features/email-log-table-skeleton.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Card, CardContent } from '@/components/ui/card'; -import { Skeleton } from '@/components/ui/skeleton'; - -export function EmailLogTableSkeleton() { - return ( - - -
-
- - - - - -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
- - -
- - - - -
- ))} -
- -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
- - -
- - -
- ))} -
-
-
- ); -} diff --git a/components/features/position-card.tsx b/components/features/position-card.tsx index ab5e0b2a..82ee7765 100644 --- a/components/features/position-card.tsx +++ b/components/features/position-card.tsx @@ -23,6 +23,7 @@ import { import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Markdown } from '@/components/ui/markdown'; +import { Skeleton } from '@/components/ui/skeleton'; interface PositionCardProps { position: PositionWithQuestions; @@ -245,3 +246,83 @@ export function PositionCard({ ); } + +interface PositionCardSkeletonProps { + hasStats?: boolean; + actions?: number; +} + +// Mirrors PositionStatCluster's total tile + 2x2 grid — update alongside it. +function PositionStatClusterSkeleton() { + return ( +
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ ); +} + +export function PositionCardSkeleton({ + hasStats = false, + actions = 2, +}: PositionCardSkeletonProps) { + return ( + +
+
+ +
+
+ +
+ +
+ +
+ + +
+ + + +
+ +
+ {Array.from({ length: actions }).map((_, i) => ( + + ))} +
+
+
+ + {hasStats && ( +
+ +
+ )} +
+
+ ); +} diff --git a/components/layouts/page-header.tsx b/components/layouts/page-header.tsx index be7f5ece..62deb69c 100644 --- a/components/layouts/page-header.tsx +++ b/components/layouts/page-header.tsx @@ -2,8 +2,17 @@ import Link from 'next/link'; import type { ReactNode } from 'react'; import { ACTION_ICONS } from '@/lib/icons'; +import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; + +const WRAPPER_CLASS = 'flex flex-col gap-1'; +const ROW_CLASS = + 'flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'; +const TITLE_ROW_CLASS = 'flex flex-wrap items-center gap-2'; +const DESCRIPTION_CLASS = 'text-muted-foreground mt-1 text-sm'; +const ACTIONS_CLASS = 'flex shrink-0 flex-wrap items-center gap-2'; interface PageHeaderProps { title: string; @@ -23,7 +32,7 @@ export function PageHeader({ backLabel = 'Back', }: PageHeaderProps) { return ( -
+
{backHref && ( )} -
+
-
+

{title}

{titleAdornment}
- {description && ( -

{description}

- )} + {description &&

{description}

} +
+ {actions &&
{actions}
} +
+
+ ); +} + +interface PageHeaderSkeletonProps { + titleWidth?: string; + hasDescription?: boolean; + hasAdornment?: boolean; + hasBack?: boolean; + actions?: string[]; + actionSize?: 'sm' | 'default'; +} + +export function PageHeaderSkeleton({ + titleWidth = 'w-48', + hasDescription = true, + hasAdornment = false, + hasBack = false, + actions, + actionSize = 'default', +}: PageHeaderSkeletonProps) { + return ( +
+ {hasBack && } +
+
+
+ + {hasAdornment && } +
+ {hasDescription && }
- {actions && ( -
- {actions} + {actions && actions.length > 0 && ( +
+ {actions.map((widthClass, i) => ( + + ))}
)}
diff --git a/components/ui/data-table-skeleton.tsx b/components/ui/data-table-skeleton.tsx new file mode 100644 index 00000000..fdbaba58 --- /dev/null +++ b/components/ui/data-table-skeleton.tsx @@ -0,0 +1,257 @@ +import { + DATA_TABLE_DESKTOP_CLASS, + DATA_TABLE_MOBILE_CLASS, + DATA_TABLE_SHELL_CLASS, + DATA_TABLE_STACK_CLASS, +} from '@/lib/data-table'; +import { cn } from '@/lib/utils'; + +import { Card } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +type DataTableSkeletonShape = 'text' | 'badge' | 'action' | 'checkbox'; +type DataTableSkeletonMobileRole = + | 'leading' + | 'primary' + | 'trailing' + | 'line' + | 'lineTrailing' + | 'hidden'; + +export interface DataTableSkeletonColumn { + head: string; + cell?: string; + subCell?: string; + shape?: DataTableSkeletonShape; + headClassName?: string; + cellClassName?: string; + mobile?: DataTableSkeletonMobileRole; +} + +type DataTableSkeletonGap = 'gap-0' | 'gap-1' | 'gap-2' | 'gap-3'; + +interface DataTableSkeletonProps { + columns: DataTableSkeletonColumn[]; + rows?: number; + mobileRows?: number; + hasReorderHandle?: boolean; + // Vertical gap between stacked lines inside a mobile card — match the real table's mobile card. + mobileGap?: DataTableSkeletonGap; + // Gap between the leading column/handle and the card body, when either is present. + mobileRowGap?: DataTableSkeletonGap; +} + +function shapeSkeleton( + shape: DataTableSkeletonShape | undefined, + widthClass: string, + { mobile = false }: { mobile?: boolean } = {}, +) { + const resolved: DataTableSkeletonShape = shape ?? 'text'; + switch (resolved) { + case 'checkbox': + return ; + case 'badge': + return ; + case 'action': + return ( + + ); + case 'text': + return ; + default: { + const exhaustiveCheck: never = resolved; + return exhaustiveCheck; + } + } +} + +// Resolves each column's mobile role: explicit wins; otherwise the first +// column defaults to `primary` and the first `badge` column to `trailing`. +function resolveMobileRoles( + columns: DataTableSkeletonColumn[], +): DataTableSkeletonMobileRole[] { + let trailingAssigned = false; + return columns.map((column, index) => { + if (column.mobile) { + if (column.mobile === 'trailing') trailingAssigned = true; + return column.mobile; + } + if (index === 0) return 'primary'; + if (column.shape === 'badge' && !trailingAssigned) { + trailingAssigned = true; + return 'trailing'; + } + return 'line'; + }); +} + +function DataTableSkeletonMobileRow({ + columns, + roles, + hasReorderHandle, + mobileGap, + mobileRowGap, +}: { + columns: DataTableSkeletonColumn[]; + roles: DataTableSkeletonMobileRole[]; + hasReorderHandle: boolean; + mobileGap: DataTableSkeletonGap; + mobileRowGap: DataTableSkeletonGap; +}) { + const leading = columns.filter((_, i) => roles[i] === 'leading'); + const primaryColumn = columns.find((_, i) => roles[i] === 'primary'); + const trailingColumn = columns.find((_, i) => roles[i] === 'trailing'); + const lineItems: { + main: DataTableSkeletonColumn; + trailing?: DataTableSkeletonColumn; + }[] = []; + columns.forEach((column, i) => { + if (roles[i] === 'line') lineItems.push({ main: column }); + else if (roles[i] === 'lineTrailing') { + const last = lineItems.at(-1); + if (last) last.trailing = column; + else lineItems.push({ main: column }); + } + }); + + const body = ( +
+ {(primaryColumn || trailingColumn) && ( +
+ {primaryColumn ? ( +
+ {shapeSkeleton( + primaryColumn.shape, + primaryColumn.cell ?? primaryColumn.head, + )} + {primaryColumn.subCell && ( + + )} +
+ ) : ( +
+ )} + {trailingColumn && + shapeSkeleton( + trailingColumn.shape, + trailingColumn.cell ?? trailingColumn.head, + { mobile: true }, + )} +
+ )} + {lineItems.map(({ main, trailing }, i) => + trailing ? ( +
+ {shapeSkeleton(main.shape, main.cell ?? main.head)} + {shapeSkeleton(trailing.shape, trailing.cell ?? trailing.head, { + mobile: true, + })} +
+ ) : ( +
{shapeSkeleton(main.shape, main.cell ?? main.head)}
+ ), + )} +
+ ); + + if (!hasReorderHandle && leading.length === 0) + return
{body}
; + + return ( +
+ {hasReorderHandle && ( + + )} + {leading.map((column, i) => ( +
+ {shapeSkeleton(column.shape, column.cell ?? column.head)} +
+ ))} + {body} +
+ ); +} + +export function DataTableSkeleton({ + columns, + rows = 5, + mobileRows = 3, + hasReorderHandle = false, + mobileGap = 'gap-2', + mobileRowGap = 'gap-2', +}: DataTableSkeletonProps) { + const mobileRoles = resolveMobileRoles(columns); + + return ( +
+ +
+ + + + {hasReorderHandle && ( + + Reorder + + )} + {columns.map((column, i) => ( + + + + ))} + + + + {Array.from({ length: rows }).map((_, rowIndex) => ( + + {hasReorderHandle && ( + + + + )} + {columns.map((column, i) => ( + + {column.subCell ? ( +
+ + +
+ ) : ( + shapeSkeleton(column.shape, column.cell ?? column.head) + )} +
+ ))} +
+ ))} +
+
+
+ +
+ {Array.from({ length: mobileRows }).map((_, rowIndex) => ( + + ))} +
+
+
+ ); +} diff --git a/components/ui/data-table-toolbar.tsx b/components/ui/data-table-toolbar.tsx index f1a8cc78..cad1881d 100644 --- a/components/ui/data-table-toolbar.tsx +++ b/components/ui/data-table-toolbar.tsx @@ -3,17 +3,18 @@ import type { ReactNode } from 'react'; import { cn } from '@/lib/utils'; import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; + +const WRAPPER_CLASS = + 'flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end'; +const FIELD_CLASS = 'flex w-full flex-col gap-1.5 sm:w-48'; interface DataTableToolbarProps { children: ReactNode; } export function DataTableToolbar({ children }: DataTableToolbarProps) { - return ( -
- {children} -
- ); + return
{children}
; } interface DataTableToolbarFieldProps { @@ -30,9 +31,34 @@ export function DataTableToolbarField({ children, }: DataTableToolbarFieldProps) { return ( -
+
{children}
); } + +interface DataTableToolbarSkeletonProps { + // Per-field width utility, e.g. 'sm:w-48' — mirrors DataTableToolbarField's className. + fields: string[]; + hasTrailingCount?: boolean; +} + +export function DataTableToolbarSkeleton({ + fields, + hasTrailingCount = false, +}: DataTableToolbarSkeletonProps) { + return ( +
+ {fields.map((widthClass, i) => ( +
+ + +
+ ))} + {hasTrailingCount && ( + + )} +
+ ); +} diff --git a/components/ui/data-table.tsx b/components/ui/data-table.tsx index c5be5666..582b8bc6 100644 --- a/components/ui/data-table.tsx +++ b/components/ui/data-table.tsx @@ -6,6 +6,10 @@ import { useCallback, useMemo } from 'react'; import { parseAsStringEnum, parseAsStringLiteral, useQueryStates } from 'nuqs'; import { + DATA_TABLE_DESKTOP_CLASS, + DATA_TABLE_MOBILE_CLASS, + DATA_TABLE_SHELL_CLASS, + DATA_TABLE_STACK_CLASS, type DataTableColumn, type SortDirection, type SortState, @@ -289,15 +293,15 @@ export function DataTable({ const columnCount = columns.length + (showReorderColumn ? 1 : 0); return ( -
+
{showReorderColumn && !sortedByOrder && (

{reorder.sortHint}

)} {/* overflow-hidden clips the header hover highlight to the card's rounded corners */} - + {/* DndContext must wrap this div, not nest inside (a11y live region renders as a sibling). */} ({ {/* Mobile stacked cards — sort order from sortedRows reflects active sort */} ` + skeleton for loading, `error.tsx`/inline error for failure, a designed empty state (icon + one line + primary action) for zero items. Secondary cards on a detail page may opt into `SectionCardEmpty`'s compact variant (one muted line, no icon/action) instead — the roomy form stays the default for dashboards. +- **Skeleton adjacency.** A skeleton lives beside the component it mirrors and shares that component's layout constants (`SectionCard`, `PageHeader`, `PositionCard`, `DataTable`) — never hand-drawn markup that happens to look similar. A `'use client'` component's skeleton lives in a sibling **server** module, and both sides import their shared class constants from the feature's `lib/` module — a server `loading.tsx` importing a client module would pull its bundle into that route and still couldn't read its constants, since Next turns every export of a client module into a client reference. +- **Skeleton row counts:** tables default to **5 rows desktop / 3 mobile**; flat card lists to **3 cards**; grouped card lists to **2 groups (2 cards, then 1)**; `SectionCardSkeleton` keeps its own 3-row default. Override only where a surface is known to be shorter. - **Focus & overlays:** never `outline-none` without a visible replacement; rely on Radix focus trapping in dialogs/sheets — don't break it with custom wrappers. - **Section sub-nav.** A page section opts into the sidebar's in-page nav by giving its outermost `
` an `id` plus `data-section-nav="