diff --git a/src/web/routes.rs b/src/web/routes.rs index 3eb1e81..95a60ff 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -493,8 +493,6 @@ pub struct InstructorResponse { pub struct SearchResponse { courses: Vec, total_count: i32, - offset: i32, - limit: i32, } #[derive(Serialize, TS)] @@ -623,8 +621,6 @@ async fn search_courses( Ok(Json(SearchResponse { courses: course_responses, total_count: total_count as i32, - offset, - limit, })) } diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index d5f5217..b5883b7 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -49,8 +49,6 @@ describe("BannerApiClient", () => { const mockResponse = { courses: [], totalCount: 0, - offset: 0, - limit: 25, }; vi.mocked(fetch).mockResolvedValueOnce({ @@ -77,8 +75,6 @@ describe("BannerApiClient", () => { const mockResponse = { courses: [], totalCount: 0, - offset: 0, - limit: 25, }; vi.mocked(fetch).mockResolvedValueOnce({ diff --git a/web/src/lib/bindings/SearchResponse.ts b/web/src/lib/bindings/SearchResponse.ts index de23284..4e883ab 100644 --- a/web/src/lib/bindings/SearchResponse.ts +++ b/web/src/lib/bindings/SearchResponse.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CourseResponse } from "./CourseResponse"; -export type SearchResponse = { courses: Array, totalCount: number, offset: number, limit: number, }; +export type SearchResponse = { courses: Array, totalCount: number, }; diff --git a/web/src/lib/components/CourseTable.svelte b/web/src/lib/components/CourseTable.svelte index c444fcc..3420cde 100644 --- a/web/src/lib/components/CourseTable.svelte +++ b/web/src/lib/components/CourseTable.svelte @@ -46,6 +46,7 @@ import { } from "@tanstack/table-core"; import { ContextMenu, DropdownMenu, Tooltip } from "bits-ui"; import { flip } from "svelte/animate"; +import { cubicOut } from "svelte/easing"; import { fade, fly, slide } from "svelte/transition"; import CourseDetail from "./CourseDetail.svelte"; import SimpleTooltip from "./SimpleTooltip.svelte"; @@ -57,6 +58,7 @@ let { onSortingChange, manualSorting = false, subjectMap = {}, + limit = 25, }: { courses: CourseResponse[]; loading: boolean; @@ -64,12 +66,34 @@ let { onSortingChange?: (sorting: SortingState) => void; manualSorting?: boolean; subjectMap?: Record; + limit?: number; } = $props(); let expandedCrn: string | null = $state(null); let tableWrapper: HTMLDivElement = undefined!; +let tableElement: HTMLTableElement = undefined!; const clipboard = useClipboard(1000); +// Track previous row count so skeleton matches expected result size +let previousRowCount = $state(0); +$effect(() => { + if (courses.length > 0) { + previousRowCount = courses.length; + } +}); +let skeletonRowCount = $derived(previousRowCount > 0 ? previousRowCount : limit); + +// Animate container height via ResizeObserver +let contentHeight = $state(null); +$effect(() => { + if (!tableElement) return; + const observer = new ResizeObserver(([entry]) => { + contentHeight = entry.contentRect.height; + }); + observer.observe(tableElement); + return () => observer.disconnect(); +}); + // Collapse expanded row when the dataset changes to avoid stale detail rows // and FLIP position calculation glitches from lingering expanded content $effect(() => { @@ -305,10 +329,17 @@ const table = createSvelteTable({ -
+
- +
{#each table.getHeaderGroups() as headerGroup} {#if loading && courses.length === 0} - {#each Array(5) as _} + {#each Array(skeletonRowCount) as _} {#each table.getVisibleLeafColumns() as col} {/each} - {:else if courses.length === 0} + {:else if courses.length === 0 && !loading} void; } = $props(); @@ -88,7 +90,8 @@ const selectValue = $derived(String(currentPage)); border border-border bg-card text-foreground hover:bg-muted/50 active:bg-muted transition-colors cursor-pointer select-none outline-none - focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" + focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background + {loading ? 'animate-pulse' : ''}" aria-label="Page {currentPage} of {totalPages}, click to select page" > {currentPage} @@ -139,12 +142,13 @@ const selectValue = $derived(String(currentPage)); hover:bg-muted/50 hover:text-foreground active:bg-muted transition-colors cursor-pointer select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background - {isSlotVisible(page) ? '' : 'invisible pointer-events-none'}" + {!isSlotVisible(page) ? 'invisible' : loading ? 'opacity-40' : ''} + {!isSlotVisible(page) || loading ? 'pointer-events-none' : ''}" onclick={() => goToPage(page)} aria-label="Go to page {page}" aria-hidden={!isSlotVisible(page)} tabindex={isSlotVisible(page) ? 0 : -1} - disabled={!isSlotVisible(page)} + disabled={!isSlotVisible(page) || loading} use:slideIn={direction} > {page} diff --git a/web/src/lib/components/SearchStatus.svelte b/web/src/lib/components/SearchStatus.svelte index 196994b..790faff 100644 --- a/web/src/lib/components/SearchStatus.svelte +++ b/web/src/lib/components/SearchStatus.svelte @@ -3,6 +3,7 @@ import SimpleTooltip from "$lib/components/SimpleTooltip.svelte"; import { relativeTime } from "$lib/time"; import { formatNumber } from "$lib/utils"; import { onMount } from "svelte"; +import { fade } from "svelte/transition"; export interface SearchMeta { totalCount: number; @@ -10,7 +11,7 @@ export interface SearchMeta { timestamp: Date; } -let { meta }: { meta: SearchMeta | null } = $props(); +let { meta, loading = false }: { meta: SearchMeta | null; loading?: boolean } = $props(); let now = $state(new Date()); @@ -51,18 +52,23 @@ onMount(() => { }); - - - {countLabel} - {resultNoun} in - {durationLabel} - - + + {countLabel} + {resultNoun} in + {durationLabel} + + +{:else} + + +{/if} diff --git a/web/src/lib/components/SubjectCombobox.svelte b/web/src/lib/components/SubjectCombobox.svelte index 53eacc5..cfa9676 100644 --- a/web/src/lib/components/SubjectCombobox.svelte +++ b/web/src/lib/components/SubjectCombobox.svelte @@ -126,7 +126,7 @@ $effect(() => { > {#snippet child({ wrapperProps, props, open: isOpen })} {#if isOpen} -
+
{#each filteredSubjects as subject (subject.code)} diff --git a/web/src/lib/components/TermCombobox.svelte b/web/src/lib/components/TermCombobox.svelte index 4d5a8ee..8c6c398 100644 --- a/web/src/lib/components/TermCombobox.svelte +++ b/web/src/lib/components/TermCombobox.svelte @@ -96,7 +96,7 @@ $effect(() => { > {#snippet child({ wrapperProps, props, open: isOpen })} {#if isOpen} -
+
{#each filteredTerms as term, i (term.slug)} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index a452f9b..1936bb1 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -13,7 +13,7 @@ import Pagination from "$lib/components/Pagination.svelte"; import SearchFilters from "$lib/components/SearchFilters.svelte"; import SearchStatus, { type SearchMeta } from "$lib/components/SearchStatus.svelte"; import type { SortingState } from "@tanstack/table-core"; -import { untrack } from "svelte"; +import { tick, untrack } from "svelte"; let { data } = $props(); @@ -102,10 +102,24 @@ const THROTTLE_MS = { } as const; let searchTimeout: ReturnType | undefined; +let lastSearchKey = ""; + +function searchKey( + term: string, + subjects: string[], + q: string, + open: boolean, + off: number, + sort: SortingState +): string { + return `${term}|${subjects.join(",")}|${q}|${open}|${off}|${JSON.stringify(sort)}`; +} function scheduleSearch(source: keyof typeof THROTTLE_MS) { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { + const key = searchKey(selectedTerm, selectedSubjects, query, openOnly, offset, sorting); + if (key === lastSearchKey) return; performSearch(selectedTerm, selectedSubjects, query, openOnly, offset, sorting); }, THROTTLE_MS[source]); } @@ -168,9 +182,10 @@ async function performSearch( sort: SortingState ) { if (!term) return; + const key = searchKey(term, subjects, q, open, off, sort); + lastSearchKey = key; loading = true; error = null; - searchMeta = null; const sortBy = sort.length > 0 ? SORT_COLUMN_MAP[sort[0].id] : undefined; const sortDir: SortDirection | undefined = @@ -195,7 +210,7 @@ async function performSearch( const t0 = performance.now(); try { - searchResult = await client.searchCourses({ + const result = await client.searchCourses({ term, subjects: subjects.length > 0 ? subjects : undefined, q: q || undefined, @@ -205,11 +220,36 @@ async function performSearch( sort_by: sortBy, sort_dir: sortDir, }); - searchMeta = { - totalCount: searchResult.totalCount, - durationMs: performance.now() - t0, - timestamp: new Date(), + + const applyUpdate = () => { + searchResult = result; + searchMeta = { + totalCount: result.totalCount, + durationMs: performance.now() - t0, + timestamp: new Date(), + }; }; + + const tableEl = document.querySelector("[data-search-results]") as HTMLElement | null; + const scopedSupport = tableEl && "startViewTransition" in tableEl; + + if (scopedSupport) { + // Scoped transition — no top-layer issue, no need for filter-overlay workaround + const transition = (tableEl as any).startViewTransition(async () => { + applyUpdate(); + await tick(); + }); + await transition.finished; + } else if (document.startViewTransition) { + // Document-level fallback with z-index layering for filter overlays + const transition = document.startViewTransition(async () => { + applyUpdate(); + await tick(); + }); + await transition.finished; + } else { + applyUpdate(); + } } catch (e) { error = e instanceof Error ? e.message : "Search failed"; } finally { @@ -227,7 +267,7 @@ function handlePageChange(newOffset: number) {
- + {#if searchResult} {/if} diff --git a/web/src/routes/layout.css b/web/src/routes/layout.css index d4cf831..4a75764 100644 --- a/web/src/routes/layout.css +++ b/web/src/routes/layout.css @@ -240,3 +240,23 @@ body::-webkit-scrollbar { .animate-pulse { animation: pulse 2s ease-in-out infinite; } + +/* View Transitions: scope crossfade to search results table */ +::view-transition-group(search-results) { + z-index: 1; +} +::view-transition-old(search-results) { + animation-duration: 150ms; +} +::view-transition-new(search-results) { + animation-duration: 200ms; +} + +/* Keep filter overlays above view-transition snapshots */ +::view-transition-group(filter-overlay) { + z-index: 100; +} +::view-transition-old(filter-overlay), +::view-transition-new(filter-overlay) { + animation: none; +}
@@ -387,7 +418,7 @@ const table = createSvelteTable({