feat: implement interactive data table with sorting and column control

Replaces static course table with TanStack Table featuring sortable
columns, column visibility management, and server-side sort handling.
Adds reusable data-table primitives adapted for Svelte 5 runes.
This commit is contained in:
2026-01-29 01:04:18 -06:00
parent d108a41f91
commit 67d7c81ef4
13 changed files with 812 additions and 149 deletions
+3
View File
@@ -12,6 +12,7 @@
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.0.0",
"@tanstack/table-core": "^8.21.3",
"@types/node": "^25.1.0",
"bits-ui": "^1.3.7",
"clsx": "^2.1.1",
@@ -227,6 +228,8 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
+1
View File
@@ -19,6 +19,7 @@
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.0.0",
"@tanstack/table-core": "^8.21.3",
"@types/node": "^25.1.0",
"bits-ui": "^1.3.7",
"clsx": "^2.1.1",
+7
View File
@@ -44,6 +44,9 @@ export interface MetricsResponse {
}
// Client-side only — not generated from Rust
export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats";
export type SortDirection = "asc" | "desc";
export interface SearchParams {
term: string;
subject?: string;
@@ -51,6 +54,8 @@ export interface SearchParams {
open_only?: boolean;
limit?: number;
offset?: number;
sort_by?: SortColumn;
sort_dir?: SortDirection;
}
export class BannerApiClient {
@@ -92,6 +97,8 @@ export class BannerApiClient {
if (params.open_only) query.set("open_only", "true");
if (params.limit !== undefined) query.set("limit", String(params.limit));
if (params.offset !== undefined) query.set("offset", String(params.offset));
if (params.sort_by) query.set("sort_by", params.sort_by);
if (params.sort_dir) query.set("sort_dir", params.sort_dir);
return this.request<SearchResponse>(`/courses/search?${query.toString()}`);
}
+382 -90
View File
@@ -10,11 +10,51 @@ import {
isTimeTBA,
} from "$lib/course";
import CourseDetail from "./CourseDetail.svelte";
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
import {
getCoreRowModel,
getSortedRowModel,
type ColumnDef,
type SortingState,
type VisibilityState,
type Updater,
} from "@tanstack/table-core";
import { ArrowUp, ArrowDown, ArrowUpDown, Columns3, Check, RotateCcw } from "@lucide/svelte";
import { DropdownMenu, ContextMenu } from "bits-ui";
import { fade, fly } from "svelte/transition";
let { courses, loading }: { courses: CourseResponse[]; loading: boolean } = $props();
let {
courses,
loading,
sorting = [],
onSortingChange,
manualSorting = false,
}: {
courses: CourseResponse[];
loading: boolean;
sorting?: SortingState;
onSortingChange?: (sorting: SortingState) => void;
manualSorting?: boolean;
} = $props();
let expandedCrn: string | null = $state(null);
// Column visibility state
let columnVisibility: VisibilityState = $state({});
const DEFAULT_VISIBILITY: VisibilityState = {};
function resetColumnVisibility() {
columnVisibility = { ...DEFAULT_VISIBILITY };
}
function handleVisibilityChange(updater: Updater<VisibilityState>) {
const newVisibility = typeof updater === "function" ? updater(columnVisibility) : updater;
columnVisibility = newVisibility;
}
// visibleColumnIds and hasCustomVisibility derived after column definitions below
function toggleRow(crn: string) {
expandedCrn = expandedCrn === crn ? null : crn;
}
@@ -60,101 +100,353 @@ function timeIsTBA(course: CourseResponse): boolean {
const mt = course.meetingTimes[0];
return isMeetingTimeTBA(mt) && isTimeTBA(mt);
}
// Column definitions
const columns: ColumnDef<CourseResponse, unknown>[] = [
{
id: "crn",
accessorKey: "crn",
header: "CRN",
enableSorting: false,
},
{
id: "course_code",
accessorFn: (row) => `${row.subject} ${row.courseNumber}`,
header: "Course",
enableSorting: true,
},
{
id: "title",
accessorKey: "title",
header: "Title",
enableSorting: true,
},
{
id: "instructor",
accessorFn: (row) => primaryInstructorDisplay(row),
header: "Instructor",
enableSorting: true,
},
{
id: "time",
accessorFn: (row) => {
if (row.meetingTimes.length === 0) return "";
const mt = row.meetingTimes[0];
return `${formatMeetingDays(mt)} ${formatTime(mt.begin_time)}`;
},
header: "Time",
enableSorting: true,
},
{
id: "location",
accessorFn: (row) => formatLocation(row) ?? "",
header: "Location",
enableSorting: false,
},
{
id: "seats",
accessorFn: (row) => openSeats(row),
header: "Seats",
enableSorting: true,
},
];
/** Column IDs that are currently visible */
let visibleColumnIds = $derived(
columns.map((c) => c.id!).filter((id) => columnVisibility[id] !== false)
);
let hasCustomVisibility = $derived(Object.values(columnVisibility).some((v) => v === false));
function handleSortingChange(updater: Updater<SortingState>) {
const newSorting = typeof updater === "function" ? updater(sorting) : updater;
onSortingChange?.(newSorting);
}
const table = createSvelteTable({
get data() {
return courses;
},
columns,
state: {
get sorting() {
return sorting;
},
get columnVisibility() {
return columnVisibility;
},
},
onSortingChange: handleSortingChange,
onColumnVisibilityChange: handleVisibilityChange,
getCoreRowModel: getCoreRowModel(),
get getSortedRowModel() {
return manualSorting ? undefined : getSortedRowModel<CourseResponse>();
},
get manualSorting() {
return manualSorting;
},
enableSortingRemoval: true,
});
</script>
{#snippet columnVisibilityItems(variant: "dropdown" | "context")}
{#if variant === "dropdown"}
<DropdownMenu.Group>
<DropdownMenu.GroupHeading class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Toggle columns
</DropdownMenu.GroupHeading>
{#each columns as col}
{@const id = col.id!}
{@const label = typeof col.header === "string" ? col.header : id}
<DropdownMenu.CheckboxItem
checked={columnVisibility[id] !== false}
closeOnSelect={false}
onCheckedChange={(checked) => {
columnVisibility = { ...columnVisibility, [id]: checked };
}}
class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
>
{#snippet children({ checked })}
<span class="flex size-4 items-center justify-center rounded-sm border border-border">
{#if checked}
<Check class="size-3" />
{/if}
</span>
{label}
{/snippet}
</DropdownMenu.CheckboxItem>
{/each}
</DropdownMenu.Group>
{#if hasCustomVisibility}
<DropdownMenu.Separator class="mx-1 my-1 h-px bg-border" />
<DropdownMenu.Item
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
onSelect={resetColumnVisibility}
>
<RotateCcw class="size-3.5" />
Reset to default
</DropdownMenu.Item>
{/if}
{:else}
<ContextMenu.Group>
<ContextMenu.GroupHeading class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Toggle columns
</ContextMenu.GroupHeading>
{#each columns as col}
{@const id = col.id!}
{@const label = typeof col.header === "string" ? col.header : id}
<ContextMenu.CheckboxItem
checked={columnVisibility[id] !== false}
closeOnSelect={false}
onCheckedChange={(checked) => {
columnVisibility = { ...columnVisibility, [id]: checked };
}}
class="relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
>
{#snippet children({ checked })}
<span class="flex size-4 items-center justify-center rounded-sm border border-border">
{#if checked}
<Check class="size-3" />
{/if}
</span>
{label}
{/snippet}
</ContextMenu.CheckboxItem>
{/each}
</ContextMenu.Group>
{#if hasCustomVisibility}
<ContextMenu.Separator class="mx-1 my-1 h-px bg-border" />
<ContextMenu.Item
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer select-none outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
onSelect={resetColumnVisibility}
>
<RotateCcw class="size-3.5" />
Reset to default
</ContextMenu.Item>
{/if}
{/if}
{/snippet}
<!-- Toolbar: View columns button -->
<div class="flex items-center justify-end pb-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"
>
<Columns3 class="size-3.5" />
View
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
class="z-50 min-w-[160px] rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
align="end"
sideOffset={4}
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div {...props} transition:fly={{ duration: 150, y: -10 }}>
{@render columnVisibilityItems("dropdown")}
</div>
</div>
{/if}
{/snippet}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
<!-- Table with context menu on header -->
<div class="overflow-x-auto">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="border-b border-border text-left text-muted-foreground">
<th class="py-2 px-2 font-medium">CRN</th>
<th class="py-2 px-2 font-medium">Course</th>
<th class="py-2 px-2 font-medium">Title</th>
<th class="py-2 px-2 font-medium">Instructor</th>
<th class="py-2 px-2 font-medium">Time</th>
<th class="py-2 px-2 font-medium">Location</th>
<th class="py-2 px-2 font-medium text-right">Seats</th>
</tr>
</thead>
<tbody>
{#if loading && courses.length === 0}
{#each Array(5) as _}
<tr class="border-b border-border">
<td class="py-2.5 px-2"><div class="h-4 w-10 bg-muted rounded animate-pulse"></div></td>
<td class="py-2.5 px-2"><div class="h-4 w-24 bg-muted rounded animate-pulse"></div></td>
<td class="py-2.5 px-2"><div class="h-4 w-40 bg-muted rounded animate-pulse"></div></td>
<td class="py-2.5 px-2"><div class="h-4 w-20 bg-muted rounded animate-pulse"></div></td>
<td class="py-2.5 px-2"><div class="h-4 w-28 bg-muted rounded animate-pulse"></div></td>
<td class="py-2.5 px-2"><div class="h-4 w-16 bg-muted rounded animate-pulse"></div></td>
<td class="py-2.5 px-2"><div class="h-4 w-14 bg-muted rounded animate-pulse ml-auto"></div></td>
</tr>
{/each}
{:else if courses.length === 0}
<tr>
<td colspan="7" class="py-12 text-center text-muted-foreground">
No courses found. Try adjusting your filters.
</td>
</tr>
{:else}
{#each courses as course (course.crn)}
<tr
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn === course.crn ? 'bg-muted/30' : ''}"
onclick={() => toggleRow(course.crn)}
>
<td class="py-2 px-2 font-mono text-xs text-muted-foreground/70">{course.crn}</td>
<td class="py-2 px-2 whitespace-nowrap">
<span class="font-semibold">{course.subject} {course.courseNumber}</span>{#if course.sequenceNumber}<span class="text-muted-foreground">-{course.sequenceNumber}</span>{/if}
</td>
<td class="py-2 px-2 font-medium">{course.title}</td>
<td class="py-2 px-2 whitespace-nowrap">
{primaryInstructorDisplay(course)}
{#if primaryRating(course)}
{@const r = primaryRating(course)!}
<span
class="ml-1 text-xs font-medium {ratingColor(r.rating)}"
title="{r.rating.toFixed(1)}/5 ({r.count} ratings)"
>{r.rating.toFixed(1)}</span>
{/if}
</td>
<td class="py-2 px-2 whitespace-nowrap">
{#if timeIsTBA(course)}
<span class="text-xs text-muted-foreground/60">TBA</span>
{:else}
{@const mt = course.meetingTimes[0]}
{#if !isMeetingTimeTBA(mt)}
<span class="font-mono font-medium">{formatMeetingDays(mt)}</span>
{" "}
<ContextMenu.Root>
<ContextMenu.Trigger class="contents">
<table class="w-full border-collapse text-sm">
<thead>
{#each table.getHeaderGroups() as headerGroup}
<tr class="border-b border-border text-left text-muted-foreground">
{#each headerGroup.headers as header}
{#if header.column.getIsVisible()}
<th
class="py-2 px-2 font-medium {header.id === 'seats' ? 'text-right' : ''}"
class:cursor-pointer={header.column.getCanSort()}
class:select-none={header.column.getCanSort()}
onclick={header.column.getToggleSortingHandler()}
>
{#if header.column.getCanSort()}
<span class="inline-flex items-center gap-1">
{#if typeof header.column.columnDef.header === "string"}
{header.column.columnDef.header}
{:else}
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
{/if}
{#if header.column.getIsSorted() === "asc"}
<ArrowUp class="size-3.5" />
{:else if header.column.getIsSorted() === "desc"}
<ArrowDown class="size-3.5" />
{:else}
<ArrowUpDown class="size-3.5 text-muted-foreground/40" />
{/if}
</span>
{:else if typeof header.column.columnDef.header === "string"}
{header.column.columnDef.header}
{:else}
<FlexRender content={header.column.columnDef.header} context={header.getContext()} />
{/if}
</th>
{/if}
{#if !isTimeTBA(mt)}
<span class="text-muted-foreground">{formatTime(mt.begin_time)}&ndash;{formatTime(mt.end_time)}</span>
{:else}
<span class="text-xs text-muted-foreground/60">TBA</span>
{/if}
{/if}
</td>
<td class="py-2 px-2 whitespace-nowrap">
{#if formatLocation(course)}
<span class="text-muted-foreground">{formatLocation(course)}</span>
{:else}
<span class="text-xs text-muted-foreground/50"></span>
{/if}
</td>
<td class="py-2 px-2 text-right whitespace-nowrap">
<span class="inline-flex items-center gap-1.5">
<span class="size-1.5 rounded-full {seatsDotColor(course)} shrink-0"></span>
<span class="{seatsColor(course)} font-medium tabular-nums">{#if openSeats(course) === 0}Full{:else}{openSeats(course)} open{/if}</span>
<span class="text-muted-foreground/60 tabular-nums">{course.enrollment}/{course.maxEnrollment}{#if course.waitCount > 0} · WL {course.waitCount}/{course.waitCapacity}{/if}</span>
</span>
</td>
</tr>
{#if expandedCrn === course.crn}
{/each}
</tr>
{/each}
</thead>
<tbody>
{#if loading && courses.length === 0}
{#each Array(5) as _}
<tr class="border-b border-border">
{#each table.getVisibleLeafColumns() as col}
<td class="py-2.5 px-2">
<div class="h-4 bg-muted rounded animate-pulse {col.id === 'seats' ? 'w-14 ml-auto' : col.id === 'title' ? 'w-40' : col.id === 'crn' ? 'w-10' : 'w-20'}"></div>
</td>
{/each}
</tr>
{/each}
{:else if courses.length === 0}
<tr>
<td colspan="7" class="p-0">
<CourseDetail {course} />
<td colspan={visibleColumnIds.length} class="py-12 text-center text-muted-foreground">
No courses found. Try adjusting your filters.
</td>
</tr>
{:else}
{#each table.getRowModel().rows as row (row.id)}
{@const course = row.original}
<tr
class="border-b border-border cursor-pointer hover:bg-muted/50 transition-colors whitespace-nowrap {expandedCrn === course.crn ? 'bg-muted/30' : ''}"
onclick={() => toggleRow(course.crn)}
>
{#each row.getVisibleCells() as cell (cell.id)}
{@const colId = cell.column.id}
{#if colId === "crn"}
<td class="py-2 px-2 font-mono text-xs text-muted-foreground/70">{course.crn}</td>
{:else if colId === "course_code"}
<td class="py-2 px-2 whitespace-nowrap">
<span class="font-semibold">{course.subject} {course.courseNumber}</span>{#if course.sequenceNumber}<span class="text-muted-foreground">-{course.sequenceNumber}</span>{/if}
</td>
{:else if colId === "title"}
<td class="py-2 px-2 font-medium">{course.title}</td>
{:else if colId === "instructor"}
<td class="py-2 px-2 whitespace-nowrap">
{primaryInstructorDisplay(course)}
{#if primaryRating(course)}
{@const r = primaryRating(course)!}
<span
class="ml-1 text-xs font-medium {ratingColor(r.rating)}"
title="{r.rating.toFixed(1)}/5 ({r.count} ratings)"
>{r.rating.toFixed(1)}</span>
{/if}
</td>
{:else if colId === "time"}
<td class="py-2 px-2 whitespace-nowrap">
{#if timeIsTBA(course)}
<span class="text-xs text-muted-foreground/60">TBA</span>
{:else}
{@const mt = course.meetingTimes[0]}
{#if !isMeetingTimeTBA(mt)}
<span class="font-mono font-medium">{formatMeetingDays(mt)}</span>
{" "}
{/if}
{#if !isTimeTBA(mt)}
<span class="text-muted-foreground">{formatTime(mt.begin_time)}&ndash;{formatTime(mt.end_time)}</span>
{:else}
<span class="text-xs text-muted-foreground/60">TBA</span>
{/if}
{/if}
</td>
{:else if colId === "location"}
<td class="py-2 px-2 whitespace-nowrap">
{#if formatLocation(course)}
<span class="text-muted-foreground">{formatLocation(course)}</span>
{:else}
<span class="text-xs text-muted-foreground/50"></span>
{/if}
</td>
{:else if colId === "seats"}
<td class="py-2 px-2 text-right whitespace-nowrap">
<span class="inline-flex items-center gap-1.5">
<span class="size-1.5 rounded-full {seatsDotColor(course)} shrink-0"></span>
<span class="{seatsColor(course)} font-medium tabular-nums">{#if openSeats(course) === 0}Full{:else}{openSeats(course)} open{/if}</span>
<span class="text-muted-foreground/60 tabular-nums">{course.enrollment}/{course.maxEnrollment}{#if course.waitCount > 0} · WL {course.waitCount}/{course.waitCapacity}{/if}</span>
</span>
</td>
{/if}
{/each}
</tr>
{#if expandedCrn === course.crn}
<tr>
<td colspan={visibleColumnIds.length} class="p-0">
<CourseDetail {course} />
</td>
</tr>
{/if}
{/each}
{/if}
{/each}
{/if}
</tbody>
</table>
</tbody>
</table>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content
class="z-50 min-w-[160px] rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div {...props} in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}>
{@render columnVisibilityItems("context")}
</div>
</div>
{/if}
{/snippet}
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
</div>
@@ -0,0 +1,118 @@
import {
type RowData,
type TableOptions,
type TableOptionsResolved,
type TableState,
createTable,
} from "@tanstack/table-core";
/**
* Creates a reactive TanStack table for Svelte 5 using runes.
*
* Adapted from shadcn-svelte's data-table wrapper — uses `$state` and
* `$effect.pre` instead of Svelte stores for reactivity.
*/
export function createSvelteTable<TData extends RowData>(options: TableOptions<TData>) {
const resolvedOptions: TableOptionsResolved<TData> = mergeObjects(
{
state: {},
onStateChange() {},
renderFallbackValue: null,
mergeOptions: (
defaultOptions: TableOptions<TData>,
options: Partial<TableOptions<TData>>
) => {
return mergeObjects(defaultOptions, options);
},
},
options
);
const table = createTable(resolvedOptions);
let state = $state<Partial<TableState>>(table.initialState);
function updateOptions() {
table.setOptions((prev) => {
return mergeObjects(prev, options, {
state: mergeObjects(state, options.state || {}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onStateChange: (updater: any) => {
if (updater instanceof Function) state = updater(state);
else state = mergeObjects(state, updater as Partial<TableState>);
options.onStateChange?.(updater);
},
});
});
}
updateOptions();
$effect.pre(() => {
updateOptions();
});
return table;
}
type MaybeThunk<T extends object> = T | (() => T | null | undefined);
type Intersection<T extends readonly unknown[]> = (T extends [infer H, ...infer R]
? H & Intersection<R>
: unknown) & {};
/**
* Lazily merges several objects (or thunks) while preserving
* getter semantics from every source. Proxy-based.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function mergeObjects<Sources extends readonly MaybeThunk<any>[]>(
...sources: Sources
): Intersection<{ [K in keyof Sources]: Sources[K] }> {
const resolve = <T extends object>(src: MaybeThunk<T>): T | undefined =>
typeof src === "function" ? (src() ?? undefined) : src;
const findSourceWithKey = (key: PropertyKey) => {
for (let i = sources.length - 1; i >= 0; i--) {
const obj = resolve(sources[i]);
if (obj && key in obj) return obj;
}
return undefined;
};
return new Proxy(Object.create(null), {
get(_, key) {
const src = findSourceWithKey(key);
return src?.[key as never];
},
has(_, key) {
return !!findSourceWithKey(key);
},
ownKeys(): (string | symbol)[] {
const all = new Set<string | symbol>();
for (const s of sources) {
const obj = resolve(s);
if (obj) {
for (const k of Reflect.ownKeys(obj) as (string | symbol)[]) {
all.add(k);
}
}
}
return [...all];
},
getOwnPropertyDescriptor(_, key) {
const src = findSourceWithKey(key);
if (!src) return undefined;
return {
configurable: true,
enumerable: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: (src as any)[key],
writable: true,
};
},
}) as Intersection<{ [K in keyof Sources]: Sources[K] }>;
}
@@ -0,0 +1,54 @@
<script lang="ts" module>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type FlexRenderProps<TProps = any> = {
content: unknown;
context: TProps;
};
</script>
<script lang="ts">
import { isRenderComponentConfig, isRenderSnippetConfig, mountComponent } from "./render-helpers.js";
let { content, context }: FlexRenderProps = $props();
function renderAction(node: HTMLElement, contentVal: typeof content) {
let cleanup: (() => void) | undefined;
function render(c: typeof content) {
cleanup?.();
node.textContent = "";
if (isRenderComponentConfig(c)) {
cleanup = mountComponent(c.component, node, c.props as Record<string, unknown>);
}
}
render(contentVal);
return {
update(newContent: typeof content) {
render(newContent);
},
destroy() {
cleanup?.();
},
};
}
</script>
{#if isRenderSnippetConfig(content)}
{@render content.snippet(content.props)}
{:else if isRenderComponentConfig(content)}
<div use:renderAction={content}></div>
{:else if typeof content === "function"}
{@const result = content(context)}
{#if isRenderComponentConfig(result)}
<div use:renderAction={result}></div>
{:else if isRenderSnippetConfig(result)}
{@render result.snippet(result.props)}
{:else if typeof result === "string" || typeof result === "number"}
{result}
{/if}
{:else if typeof content === "string" || typeof content === "number"}
{content}
{/if}
@@ -0,0 +1,3 @@
export { default as FlexRender } from "./flex-render.svelte";
export { renderComponent, renderSnippet } from "./render-helpers.js";
export { createSvelteTable } from "./data-table.svelte.js";
@@ -0,0 +1,67 @@
import { type Component, type Snippet, mount, unmount } from "svelte";
/**
* Wraps a Svelte component so TanStack Table can render it as a column
* header or cell. Returns a `RenderComponentConfig` that `FlexRender`
* picks up.
*/
export function renderComponent<
TProps extends Record<string, unknown>,
TComp extends Component<TProps>,
>(component: TComp, props: TProps) {
return {
component,
props,
[RENDER_COMPONENT_SYMBOL]: true,
} as const;
}
/**
* Wraps a Svelte 5 raw snippet for use in TanStack Table column defs.
*/
export function renderSnippet<TProps>(snippet: Snippet<[TProps]>, props: TProps) {
return {
snippet,
props,
[RENDER_SNIPPET_SYMBOL]: true,
} as const;
}
// Symbols for FlexRender to detect render types
export const RENDER_COMPONENT_SYMBOL = Symbol("renderComponent");
export const RENDER_SNIPPET_SYMBOL = Symbol("renderSnippet");
export type RenderComponentConfig<
TProps extends Record<string, unknown> = Record<string, unknown>,
> = {
component: Component<TProps>;
props: TProps;
[RENDER_COMPONENT_SYMBOL]: true;
};
export type RenderSnippetConfig<TProps = unknown> = {
snippet: Snippet<[TProps]>;
props: TProps;
[RENDER_SNIPPET_SYMBOL]: true;
};
export function isRenderComponentConfig(value: unknown): value is RenderComponentConfig {
return typeof value === "object" && value !== null && RENDER_COMPONENT_SYMBOL in value;
}
export function isRenderSnippetConfig(value: unknown): value is RenderSnippetConfig {
return typeof value === "object" && value !== null && RENDER_SNIPPET_SYMBOL in value;
}
/**
* Mount a Svelte component imperatively into a target element.
* Used by FlexRender for component-type cells.
*/
export function mountComponent<TProps extends Record<string, unknown>>(
component: Component<TProps>,
target: HTMLElement,
props: TProps
) {
const instance = mount(component, { target, props });
return () => unmount(instance);
}
+58 -5
View File
@@ -1,7 +1,14 @@
<script lang="ts">
import { untrack } from "svelte";
import { goto } from "$app/navigation";
import { type Subject, type SearchResponse, client } from "$lib/api";
import {
type Subject,
type SearchResponse,
type SortColumn,
type SortDirection,
client,
} from "$lib/api";
import type { SortingState } from "@tanstack/table-core";
import SearchFilters from "$lib/components/SearchFilters.svelte";
import CourseTable from "$lib/components/CourseTable.svelte";
import Pagination from "$lib/components/Pagination.svelte";
@@ -19,6 +26,29 @@ let openOnly = $state(initialParams.get("open") === "true");
let offset = $state(Number(initialParams.get("offset")) || 0);
const limit = 25;
// Sorting state — maps TanStack column IDs to server sort params
const SORT_COLUMN_MAP: Record<string, SortColumn> = {
course_code: "course_code",
title: "title",
instructor: "instructor",
time: "time",
seats: "seats",
};
let sorting: SortingState = $state(
(() => {
const sortBy = initialParams.get("sort_by");
const sortDir = initialParams.get("sort_dir");
if (!sortBy) return [];
return [{ id: sortBy, desc: sortDir === "desc" }];
})()
);
function handleSortingChange(newSorting: SortingState) {
sorting = newSorting;
offset = 0;
}
// Data state
let subjects: Subject[] = $state([]);
let searchResult: SearchResponse | null = $state(null);
@@ -45,10 +75,11 @@ $effect(() => {
const q = query;
const open = openOnly;
const off = offset;
const sort = sorting;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
performSearch(term, subject, q, open, off);
performSearch(term, subject, q, open, off, sort);
}, 300);
return () => clearTimeout(searchTimeout);
@@ -64,11 +95,23 @@ $effect(() => {
prevFilters = key;
});
async function performSearch(term: string, subject: string, q: string, open: boolean, off: number) {
async function performSearch(
term: string,
subject: string,
q: string,
open: boolean,
off: number,
sort: SortingState
) {
if (!term) return;
loading = true;
error = null;
// Derive server sort params from TanStack sorting state
const sortBy = sort.length > 0 ? SORT_COLUMN_MAP[sort[0].id] : undefined;
const sortDir: SortDirection | undefined =
sort.length > 0 ? (sort[0].desc ? "desc" : "asc") : undefined;
// Sync URL
const params = new URLSearchParams();
params.set("term", term);
@@ -76,6 +119,8 @@ async function performSearch(term: string, subject: string, q: string, open: boo
if (q) params.set("q", q);
if (open) params.set("open", "true");
if (off > 0) params.set("offset", String(off));
if (sortBy) params.set("sort_by", sortBy);
if (sortDir && sortBy) params.set("sort_dir", sortDir);
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
try {
@@ -86,6 +131,8 @@ async function performSearch(term: string, subject: string, q: string, open: boo
open_only: open || undefined,
limit,
offset: off,
sort_by: sortBy,
sort_dir: sortDir,
});
} catch (e) {
error = e instanceof Error ? e.message : "Search failed";
@@ -121,14 +168,20 @@ function handlePageChange(newOffset: number) {
<div class="text-center py-8">
<p class="text-status-red">{error}</p>
<button
onclick={() => performSearch(selectedTerm, selectedSubject, query, openOnly, offset)}
onclick={() => performSearch(selectedTerm, selectedSubject, query, openOnly, offset, sorting)}
class="mt-2 text-sm text-muted-foreground hover:underline"
>
Retry
</button>
</div>
{:else}
<CourseTable courses={searchResult?.courses ?? []} {loading} />
<CourseTable
courses={searchResult?.courses ?? []}
{loading}
{sorting}
onSortingChange={handleSortingChange}
manualSorting={true}
/>
{#if searchResult}
<Pagination