refactor(web): consolidate tooltip implementations with shared components

This commit is contained in:
2026-01-31 12:26:31 -06:00
parent 7f0f08725a
commit d91f7ab342
6 changed files with 283 additions and 177 deletions
+14 -17
View File
@@ -13,7 +13,7 @@ import {
rmpUrl,
} from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { cn, formatNumber, tooltipContentClass } from "$lib/utils";
import { formatNumber } from "$lib/utils";
import {
Calendar,
Check,
@@ -24,7 +24,7 @@ import {
Star,
Triangle,
} from "@lucide/svelte";
import { Tooltip } from "bits-ui";
import RichTooltip from "./RichTooltip.svelte";
import SimpleTooltip from "./SimpleTooltip.svelte";
let { course }: { course: CourseResponse } = $props();
@@ -40,8 +40,8 @@ const clipboard = useClipboard();
{#if course.instructors.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each course.instructors as instructor}
<Tooltip.Root delayDuration={200}>
<Tooltip.Trigger>
<RichTooltip delay={200} contentClass="px-3 py-2">
{#snippet children()}
<span
class="inline-flex items-center gap-1.5 text-sm font-medium bg-card border border-border rounded-md px-2.5 py-1 text-foreground hover:border-foreground/20 hover:bg-card/80 transition-colors"
>
@@ -71,11 +71,8 @@ const clipboard = useClipboard();
</span>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
sideOffset={6}
class={cn(tooltipContentClass, "px-3 py-2")}
>
{/snippet}
{#snippet content()}
<div class="space-y-1.5">
<div class="font-medium">
{instructor.displayName}
@@ -126,8 +123,8 @@ const clipboard = useClipboard();
</button>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
{/snippet}
</RichTooltip>
{/each}
</div>
{:else}
@@ -272,8 +269,8 @@ const clipboard = useClipboard();
</SimpleTooltip>
</span>
</h4>
<Tooltip.Root delayDuration={150} disableHoverableContent>
<Tooltip.Trigger>
<RichTooltip passthrough>
{#snippet children()}
<span
class="inline-flex items-center gap-1.5 text-foreground font-mono"
>
@@ -288,8 +285,8 @@ const clipboard = useClipboard();
</span>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content sideOffset={6} class={tooltipContentClass}>
{/snippet}
{#snippet content()}
Group <span class="font-mono font-medium"
>{course.crossList}</span
>
@@ -297,8 +294,8 @@ const clipboard = useClipboard();
{formatNumber(course.crossListCount)} enrolled across {formatNumber(course.crossListCapacity)}
shared seats
{/if}
</Tooltip.Content>
</Tooltip.Root>
{/snippet}
</RichTooltip>
</div>
{/if}
+12 -16
View File
@@ -24,7 +24,7 @@ import {
seatsDotColor,
} from "$lib/course";
import { themeStore } from "$lib/stores/theme.svelte";
import { cn, formatNumber, tooltipContentClass } from "$lib/utils";
import { formatNumber } from "$lib/utils";
import {
ArrowDown,
ArrowUp,
@@ -43,11 +43,12 @@ import {
getCoreRowModel,
getSortedRowModel,
} from "@tanstack/table-core";
import { ContextMenu, DropdownMenu, Tooltip } from "bits-ui";
import { ContextMenu, DropdownMenu } from "bits-ui";
import { flip } from "svelte/animate";
import { cubicOut } from "svelte/easing";
import { fade, slide } from "svelte/transition";
import CourseDetail from "./CourseDetail.svelte";
import RichTooltip from "./RichTooltip.svelte";
import SimpleTooltip from "./SimpleTooltip.svelte";
let {
@@ -532,10 +533,12 @@ const table = createSvelteTable({
{@const lowConfidence =
ratingData.count <
RMP_CONFIDENCE_THRESHOLD}
<Tooltip.Root
delayDuration={150}
<RichTooltip
side="bottom"
sideOffset={6}
contentClass="px-2.5 py-1.5"
>
<Tooltip.Trigger>
{#snippet children()}
<span
class="ml-1 text-xs font-medium inline-flex items-center gap-0.5"
style={ratingStyle(
@@ -556,15 +559,8 @@ const table = createSvelteTable({
/>
{/if}
</span>
</Tooltip.Trigger>
<Tooltip.Content
side="bottom"
sideOffset={6}
class={cn(
tooltipContentClass,
"px-2.5 py-1.5",
)}
>
{/snippet}
{#snippet content()}
<span
class="inline-flex items-center gap-1.5 text-xs"
>
@@ -592,8 +588,8 @@ const table = createSvelteTable({
</a>
{/if}
</span>
</Tooltip.Content>
</Tooltip.Root>
{/snippet}
</RichTooltip>
{/if}
</td>
{:else if colId === "time"}
+163 -136
View File
@@ -1,105 +1,116 @@
<script lang="ts">
import { formatNumber } from "$lib/utils";
import { ChevronDown, ChevronUp } from "@lucide/svelte";
import { Select } from "bits-ui";
import type { Action } from "svelte/action";
import { formatNumber } from "$lib/utils";
import { ChevronDown, ChevronUp } from "@lucide/svelte";
import { Select } from "bits-ui";
import type { Action } from "svelte/action";
const slideIn: Action<HTMLElement, number> = (node, direction) => {
if (direction !== 0) {
node.animate(
[
{ transform: `translateX(${direction * 20}px)`, opacity: 0 },
{ transform: "translateX(0)", opacity: 1 },
],
{ duration: 200, easing: "ease-out" }
const slideIn: Action<HTMLElement, number> = (node, direction) => {
if (direction !== 0) {
node.animate(
[
{
transform: `translateX(${direction * 20}px)`,
opacity: 0,
},
{ transform: "translateX(0)", opacity: 1 },
],
{ duration: 200, easing: "ease-out" },
);
}
};
let {
totalCount,
offset,
limit,
loading = false,
onPageChange,
}: {
totalCount: number;
offset: number;
limit: number;
loading?: boolean;
onPageChange: (newOffset: number) => void;
} = $props();
const currentPage = $derived(Math.floor(offset / limit) + 1);
const totalPages = $derived(Math.ceil(totalCount / limit));
const start = $derived(offset + 1);
const end = $derived(Math.min(offset + limit, totalCount));
// Track direction for slide animation
let direction = $state(0);
// 5 page slots: current-2, current-1, current, current+1, current+2
const pageSlots = $derived(
[-2, -1, 0, 1, 2].map((delta) => currentPage + delta),
);
}
};
let {
totalCount,
offset,
limit,
loading = false,
onPageChange,
}: {
totalCount: number;
offset: number;
limit: number;
loading?: boolean;
onPageChange: (newOffset: number) => void;
} = $props();
function isSlotVisible(page: number): boolean {
return page >= 1 && page <= totalPages;
}
const currentPage = $derived(Math.floor(offset / limit) + 1);
const totalPages = $derived(Math.ceil(totalCount / limit));
const start = $derived(offset + 1);
const end = $derived(Math.min(offset + limit, totalCount));
function goToPage(page: number) {
direction = page > currentPage ? 1 : -1;
onPageChange((page - 1) * limit);
}
// Track direction for slide animation
let direction = $state(0);
// Build items array for the Select dropdown
const pageItems = $derived(
Array.from({ length: totalPages }, (_, i) => ({
value: String(i + 1),
label: String(i + 1),
})),
);
// 5 page slots: current-2, current-1, current, current+1, current+2
const pageSlots = $derived([-2, -1, 0, 1, 2].map((delta) => currentPage + delta));
function isSlotVisible(page: number): boolean {
return page >= 1 && page <= totalPages;
}
function goToPage(page: number) {
direction = page > currentPage ? 1 : -1;
onPageChange((page - 1) * limit);
}
// Build items array for the Select dropdown
const pageItems = $derived(
Array.from({ length: totalPages }, (_, i) => ({
value: String(i + 1),
label: String(i + 1),
}))
);
const selectValue = $derived(String(currentPage));
const selectValue = $derived(String(currentPage));
</script>
{#if totalCount > 0 && totalPages > 1}
<div class="flex items-start text-xs -mt-3 pl-2">
<!-- Left zone: result count -->
<div class="flex-1">
<span class="text-muted-foreground">
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(totalCount)} courses
</span>
</div>
<div class="flex items-start text-xs mt-2 pl-2">
<!-- Left zone: result count -->
<div class="flex-1">
<span class="text-muted-foreground">
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(
totalCount,
)} courses
</span>
</div>
<!-- Center zone: page buttons -->
<div class="flex items-center gap-1">
{#key currentPage}
{#each pageSlots as page, i (i)}
{#if i === 2}
<!-- Center slot: current page with dropdown trigger -->
<Select.Root
type="single"
value={selectValue}
onValueChange={(v) => {
if (v) goToPage(Number(v));
}}
items={pageItems}
>
<Select.Trigger
class="inline-flex items-center justify-center gap-1 w-auto min-w-9 h-9 px-2.5
<!-- Center zone: page buttons -->
<div class="flex items-center gap-1">
{#key currentPage}
{#each pageSlots as page, i (i)}
{#if i === 2}
<!-- Center slot: current page with dropdown trigger -->
<Select.Root
type="single"
value={selectValue}
onValueChange={(v) => {
if (v) goToPage(Number(v));
}}
items={pageItems}
>
<Select.Trigger
class="inline-flex items-center justify-center gap-1 w-auto min-w-9 h-9 px-2.5
rounded-md text-sm font-medium tabular-nums
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
{loading ? 'animate-pulse' : ''}"
aria-label="Page {currentPage} of {totalPages}, click to select page"
>
<span use:slideIn={direction}>{currentPage}</span>
<ChevronUp class="size-3 text-muted-foreground" />
</Select.Trigger>
<Select.Portal>
<Select.Content
class="border border-border bg-card shadow-md outline-hidden z-50
aria-label="Page {currentPage} of {totalPages}, click to select page"
>
<span use:slideIn={direction}
>{currentPage}</span
>
<ChevronUp
class="size-3 text-muted-foreground"
/>
</Select.Trigger>
<Select.Portal>
<Select.Content
class="border border-border bg-card shadow-md outline-hidden z-50
max-h-72 min-w-16 w-auto
select-none rounded-md p-1
data-[state=open]:animate-in data-[state=closed]:animate-out
@@ -107,65 +118,81 @@ const selectValue = $derived(String(currentPage));
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
data-[side=top]:slide-in-from-bottom-2
data-[side=bottom]:slide-in-from-top-2"
side="top"
sideOffset={6}
>
<Select.ScrollUpButton class="flex w-full items-center justify-center py-0.5">
<ChevronUp class="size-3.5 text-muted-foreground" />
</Select.ScrollUpButton>
<Select.Viewport class="p-0.5">
{#each pageItems as item (item.value)}
<Select.Item
class="rounded-sm outline-hidden flex h-8 w-full select-none items-center
side="top"
sideOffset={6}
>
<Select.ScrollUpButton
class="flex w-full items-center justify-center py-0.5"
>
<ChevronUp
class="size-3.5 text-muted-foreground"
/>
</Select.ScrollUpButton>
<Select.Viewport class="p-0.5">
{#each pageItems as item (item.value)}
<Select.Item
class="rounded-sm outline-hidden flex h-8 w-full select-none items-center
justify-center px-3 text-sm tabular-nums
data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground
data-[selected]:font-semibold"
value={item.value}
label={item.label}
>
{item.label}
</Select.Item>
{/each}
</Select.Viewport>
<Select.ScrollDownButton class="flex w-full items-center justify-center py-0.5">
<ChevronDown class="size-3.5 text-muted-foreground" />
</Select.ScrollDownButton>
</Select.Content>
</Select.Portal>
</Select.Root>
{:else}
<!-- Side slot: navigable page button or invisible placeholder -->
<button
class="inline-flex items-center justify-center w-9 h-9
value={item.value}
label={item.label}
>
{item.label}
</Select.Item>
{/each}
</Select.Viewport>
<Select.ScrollDownButton
class="flex w-full items-center justify-center py-0.5"
>
<ChevronDown
class="size-3.5 text-muted-foreground"
/>
</Select.ScrollDownButton>
</Select.Content>
</Select.Portal>
</Select.Root>
{:else}
<!-- Side slot: navigable page button or invisible placeholder -->
<button
class="inline-flex items-center justify-center w-9 h-9
rounded-md text-sm tabular-nums
text-muted-foreground
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' : 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) || loading}
use:slideIn={direction}
>
{page}
</button>
{/if}
{/each}
{/key}
</div>
{!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) || loading}
use:slideIn={direction}
>
{page}
</button>
{/if}
{/each}
{/key}
</div>
<!-- Right zone: spacer for centering -->
<div class="flex-1"></div>
</div>
<!-- Right zone: spacer for centering -->
<div class="flex-1"></div>
</div>
{:else if totalCount > 0}
<!-- Single page: just show the count, no pagination controls -->
<div class="flex items-start text-xs -mt-3 pl-2">
<span class="text-muted-foreground">
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(totalCount)} courses
</span>
</div>
<!-- Single page: just show the count, no pagination controls -->
<div class="flex items-start text-xs mt-2 pl-2">
<span class="text-muted-foreground">
Showing {formatNumber(start)}&ndash;{formatNumber(end)} of {formatNumber(
totalCount,
)} courses
</span>
</div>
{/if}
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import { cn, tooltipContentClass } from "$lib/utils";
import { Tooltip } from "bits-ui";
import type { Snippet } from "svelte";
let {
delay,
side = "top",
sideOffset = 6,
passthrough = false,
triggerClass = "",
contentClass = "",
portal = true,
avoidCollisions = true,
collisionPadding = 8,
children,
content,
}: {
delay?: number;
side?: "top" | "bottom" | "left" | "right";
sideOffset?: number;
passthrough?: boolean;
triggerClass?: string;
contentClass?: string;
portal?: boolean;
avoidCollisions?: boolean;
collisionPadding?: number;
children: Snippet;
content: Snippet;
} = $props();
</script>
<Tooltip.Root delayDuration={delay} disableHoverableContent={passthrough}>
<Tooltip.Trigger>
{#snippet child({ props })}
<span class={triggerClass} {...props}>
{@render children()}
</span>
{/snippet}
</Tooltip.Trigger>
{#if portal}
<Tooltip.Portal>
<Tooltip.Content
{side}
{sideOffset}
{avoidCollisions}
{collisionPadding}
class={cn(tooltipContentClass, contentClass)}
>
{@render content()}
</Tooltip.Content>
</Tooltip.Portal>
{:else}
<Tooltip.Content
{side}
{sideOffset}
{avoidCollisions}
{collisionPadding}
class={cn(tooltipContentClass, contentClass)}
>
{@render content()}
</Tooltip.Content>
{/if}
</Tooltip.Root>
+29 -7
View File
@@ -11,6 +11,9 @@ let {
triggerClass = "",
contentClass = "",
sideOffset = 6,
portal = true,
avoidCollisions = true,
collisionPadding = 8,
children,
}: {
text: string;
@@ -20,6 +23,9 @@ let {
triggerClass?: string;
contentClass?: string;
sideOffset?: number;
portal?: boolean;
avoidCollisions?: boolean;
collisionPadding?: number;
children: Snippet;
} = $props();
</script>
@@ -32,11 +38,27 @@ let {
</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content
{side}
{sideOffset}
class={cn("z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-sm whitespace-pre-line max-w-max text-left", contentClass)}
>
{text}
</Tooltip.Content>
{#if portal}
<Tooltip.Portal>
<Tooltip.Content
{side}
{sideOffset}
{avoidCollisions}
{collisionPadding}
class={cn("z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-sm whitespace-pre-line max-w-max text-left", contentClass)}
>
{text}
</Tooltip.Content>
</Tooltip.Portal>
{:else}
<Tooltip.Content
{side}
{sideOffset}
{avoidCollisions}
{collisionPadding}
class={cn("z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-sm whitespace-pre-line max-w-max text-left", contentClass)}
>
{text}
</Tooltip.Content>
{/if}
</Tooltip.Root>
+1 -1
View File
@@ -39,7 +39,7 @@ onMount(() => {
});
</script>
<Tooltip.Provider>
<Tooltip.Provider delayDuration={150} skipDelayDuration={50}>
<div class="relative flex min-h-screen flex-col">
<!-- pointer-events-none so the navbar doesn't block canvas interactions;
NavBar re-enables pointer-events on its own container. -->