mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 22:23:34 -06:00
feat(web): implement smooth view transitions for search results
This commit is contained in:
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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, };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"> </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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user