feat(web): implement smooth view transitions for search results

This commit is contained in:
2026-01-31 09:33:09 -06:00
parent 5134ae9388
commit 5729a821d5
10 changed files with 140 additions and 43 deletions
-4
View File
@@ -493,8 +493,6 @@ pub struct InstructorResponse {
pub struct SearchResponse {
courses: Vec<CourseResponse>,
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,
}))
}
-4
View File
@@ -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({
+1 -1
View File
@@ -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<CourseResponse>, totalCount: number, offset: number, limit: number, };
export type SearchResponse = { courses: Array<CourseResponse>, totalCount: number, };
+38 -5
View File
@@ -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<string, string>;
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<number | null>(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({
</div>
<!-- Table with context menu on header -->
<div bind:this={tableWrapper} class="overflow-x-auto">
<div
bind:this={tableWrapper}
class="overflow-x-auto overflow-y-hidden transition-[height] duration-200"
style:height={contentHeight != null ? `${contentHeight}px` : undefined}
style:view-transition-name="search-results"
style:contain="layout"
data-search-results
>
<ContextMenu.Root>
<ContextMenu.Trigger class="contents">
<table class="w-full min-w-160 border-collapse text-sm">
<table bind:this={tableElement} class="w-full min-w-160 border-collapse text-sm">
<thead>
{#each table.getHeaderGroups() as headerGroup}
<tr
@@ -368,7 +399,7 @@ const table = createSvelteTable({
</thead>
{#if loading && courses.length === 0}
<tbody>
{#each Array(5) as _}
{#each Array(skeletonRowCount) as _}
<tr class="border-b border-border">
{#each table.getVisibleLeafColumns() as col}
<td class="py-2.5 px-2">
@@ -387,7 +418,7 @@ const table = createSvelteTable({
</tr>
{/each}
</tbody>
{:else if courses.length === 0}
{:else if courses.length === 0 && !loading}
<tbody>
<tr>
<td
@@ -403,10 +434,12 @@ const table = createSvelteTable({
{#each table.getRowModel().rows as row, i (row.id)}
{@const course = row.original}
<tbody
class="transition-opacity duration-200 {loading ? 'opacity-45 pointer-events-none' : ''}"
animate:flip={{ duration: 300 }}
in:fade={{
duration: 200,
delay: Math.min(i * 20, 400),
delay: Math.min(i * 25, 300),
easing: cubicOut,
}}
>
<tr
+7 -3
View File
@@ -20,11 +20,13 @@ let {
totalCount,
offset,
limit,
loading = false,
onPageChange,
}: {
totalCount: number;
offset: number;
limit: number;
loading?: boolean;
onPageChange: (newOffset: number) => 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"
>
<span use:slideIn={direction}>{currentPage}</span>
@@ -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}
+21 -15
View File
@@ -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(() => {
});
</script>
<SimpleTooltip
text={tooltipText}
contentClass="whitespace-nowrap text-[12px] px-2 py-1"
triggerClass="self-start"
sideOffset={0}
>
<span
class="pl-1 text-xs transition-opacity duration-200"
style:opacity={meta ? 1 : 0}
{#if meta}
<SimpleTooltip
text={tooltipText}
contentClass="whitespace-nowrap text-[12px] px-2 py-1"
triggerClass="self-start"
sideOffset={0}
>
<span class="text-muted-foreground/70">{countLabel}</span>
<span class="text-muted-foreground/35">{resultNoun} in</span>
<span class="text-muted-foreground/70">{durationLabel}</span>
</span>
</SimpleTooltip>
<span
class="pl-1 text-xs transition-opacity duration-200 {loading ? 'opacity-40' : ''}"
in:fade={{ duration: 300 }}
>
<span class="text-muted-foreground/70">{countLabel}</span>
<span class="text-muted-foreground/35">{resultNoun} in</span>
<span class="text-muted-foreground/70">{durationLabel}</span>
</span>
</SimpleTooltip>
{:else}
<!-- Invisible placeholder to maintain layout height -->
<span class="pl-1 text-xs opacity-0 pointer-events-none" aria-hidden="true">&nbsp;</span>
{/if}
@@ -126,7 +126,7 @@ $effect(() => {
>
{#snippet child({ wrapperProps, props, open: isOpen })}
{#if isOpen}
<div {...wrapperProps}>
<div {...wrapperProps} style:view-transition-name="filter-overlay">
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
<Combobox.Viewport class="p-0.5">
{#each filteredSubjects as subject (subject.code)}
+1 -1
View File
@@ -96,7 +96,7 @@ $effect(() => {
>
{#snippet child({ wrapperProps, props, open: isOpen })}
{#if isOpen}
<div {...wrapperProps}>
<div {...wrapperProps} style:view-transition-name="filter-overlay">
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
<Combobox.Viewport class="p-0.5">
{#each filteredTerms as term, i (term.slug)}
+51 -9
View File
@@ -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<typeof setTimeout> | 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) {
<!-- Search status + Filters -->
<div class="flex flex-col gap-1.5">
<SearchStatus meta={searchMeta} />
<SearchStatus meta={searchMeta} {loading} />
<!-- Filters -->
<SearchFilters
terms={data.terms}
@@ -258,13 +298,15 @@ function handlePageChange(newOffset: number) {
onSortingChange={handleSortingChange}
manualSorting={true}
{subjectMap}
{limit}
/>
{#if searchResult}
<Pagination
totalCount={searchResult.totalCount}
offset={searchResult.offset}
{offset}
{limit}
{loading}
onPageChange={handlePageChange}
/>
{/if}
+20
View File
@@ -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;
}