mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 08:23:35 -06:00
refactor: extract reusable SimpleTooltip component and enhance UI hints
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
isTimeTBA,
|
||||
} from "$lib/course";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import SimpleTooltip from "./SimpleTooltip.svelte";
|
||||
import { Info, Copy, Check } from "@lucide/svelte";
|
||||
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
@@ -50,7 +51,8 @@ async function copyEmail(email: string, event: MouseEvent) {
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
class="bg-card text-card-foreground text-xs border border-border rounded-md px-3 py-2 shadow-md max-w-72"
|
||||
sideOffset={6}
|
||||
class="z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-3 py-2 shadow-md max-w-72"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<div class="font-medium">{instructor.displayName}</div>
|
||||
@@ -134,16 +136,9 @@ async function copyEmail(email: string, event: MouseEvent) {
|
||||
<h4 class="text-sm text-foreground mb-2">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Delivery
|
||||
<Tooltip.Root delayDuration={100}>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="size-3 text-muted-foreground/50" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
|
||||
>
|
||||
How the course is taught: in-person, online, hybrid, etc.
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<SimpleTooltip text="How the course is taught: in-person, online, hybrid, etc." delay={150} passthrough>
|
||||
<Info class="size-3 text-muted-foreground/50" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
</h4>
|
||||
<span class="text-foreground">
|
||||
@@ -168,34 +163,20 @@ async function copyEmail(email: string, event: MouseEvent) {
|
||||
<h4 class="text-sm text-foreground mb-2">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Attributes
|
||||
<Tooltip.Root delayDuration={100}>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="size-3 text-muted-foreground/50" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
|
||||
>
|
||||
Course flags for degree requirements, core curriculum, or special designations
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<SimpleTooltip text="Course flags for degree requirements, core curriculum, or special designations" delay={150} passthrough>
|
||||
<Info class="size-3 text-muted-foreground/50" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each course.attributes as attr}
|
||||
<Tooltip.Root delayDuration={100}>
|
||||
<Tooltip.Trigger>
|
||||
<span
|
||||
class="inline-flex text-xs font-medium bg-card border border-border rounded-md px-2 py-0.5 text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
|
||||
>
|
||||
{attr}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-64"
|
||||
<SimpleTooltip text="Course attribute code" delay={150} passthrough>
|
||||
<span
|
||||
class="inline-flex text-xs font-medium bg-card border border-border rounded-md px-2 py-0.5 text-muted-foreground hover:text-foreground hover:border-foreground/20 transition-colors"
|
||||
>
|
||||
Course attribute code
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{attr}
|
||||
</span>
|
||||
</SimpleTooltip>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,19 +188,12 @@ async function copyEmail(email: string, event: MouseEvent) {
|
||||
<h4 class="text-sm text-foreground mb-2">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Cross-list
|
||||
<Tooltip.Root delayDuration={100}>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="size-3 text-muted-foreground/50" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
|
||||
>
|
||||
Cross-listed sections share enrollment across multiple course numbers. Students in any linked section attend the same class.
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<SimpleTooltip text="Cross-listed sections share enrollment across multiple course numbers. Students in any linked section attend the same class." delay={150} passthrough>
|
||||
<Info class="size-3 text-muted-foreground/50" />
|
||||
</SimpleTooltip>
|
||||
</span>
|
||||
</h4>
|
||||
<Tooltip.Root delayDuration={100}>
|
||||
<Tooltip.Root delayDuration={150} disableHoverableContent>
|
||||
<Tooltip.Trigger>
|
||||
<span class="inline-flex items-center gap-1.5 text-foreground font-mono">
|
||||
<span class="bg-card border border-border rounded-md px-2 py-0.5 text-xs font-medium">
|
||||
@@ -233,7 +207,8 @@ async function copyEmail(email: string, event: MouseEvent) {
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
|
||||
sideOffset={6}
|
||||
class="z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
|
||||
>
|
||||
Group <span class="font-mono font-medium">{course.crossList}</span>
|
||||
{#if course.crossListCount != null && course.crossListCapacity != null}
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
type Updater,
|
||||
} from "@tanstack/table-core";
|
||||
import { ArrowUp, ArrowDown, ArrowUpDown, Columns3, Check, RotateCcw } from "@lucide/svelte";
|
||||
import { DropdownMenu, ContextMenu } from "bits-ui";
|
||||
import { DropdownMenu, ContextMenu, Tooltip } from "bits-ui";
|
||||
import SimpleTooltip from "./SimpleTooltip.svelte";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
|
||||
let {
|
||||
@@ -33,12 +34,14 @@ let {
|
||||
sorting = [],
|
||||
onSortingChange,
|
||||
manualSorting = false,
|
||||
subjectMap = {},
|
||||
}: {
|
||||
courses: CourseResponse[];
|
||||
loading: boolean;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: (sorting: SortingState) => void;
|
||||
manualSorting?: boolean;
|
||||
subjectMap?: Record<string, string>;
|
||||
} = $props();
|
||||
|
||||
let expandedCrn: string | null = $state(null);
|
||||
@@ -301,12 +304,24 @@ const table = createSvelteTable({
|
||||
<!-- 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>
|
||||
<Tooltip.Root delayDuration={150} disableHoverableContent>
|
||||
<Tooltip.Trigger>
|
||||
<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>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
class="z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md"
|
||||
>
|
||||
Show or hide table columns
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
class="z-50 min-w-[160px] rounded-md border border-border bg-card p-1 text-card-foreground shadow-lg"
|
||||
@@ -399,20 +414,31 @@ const table = createSvelteTable({
|
||||
{#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"}
|
||||
{@const subjectDesc = subjectMap[course.subject]}
|
||||
<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}
|
||||
<SimpleTooltip text={subjectDesc ? `${subjectDesc} ${course.courseNumber}` : `${course.subject} ${course.courseNumber}`} delay={200} side="bottom" passthrough>
|
||||
<span class="font-semibold">{course.subject} {course.courseNumber}</span>{#if course.sequenceNumber}<span class="text-muted-foreground">-{course.sequenceNumber}</span>{/if}
|
||||
</SimpleTooltip>
|
||||
</td>
|
||||
{:else if colId === "title"}
|
||||
<td class="py-2 px-2 font-medium max-w-[200px] truncate" title={course.title}>{course.title}</td>
|
||||
<td class="py-2 px-2 font-medium max-w-[200px] truncate">
|
||||
<SimpleTooltip text={course.title} delay={200} side="bottom" passthrough>
|
||||
<span class="block truncate">{course.title}</span>
|
||||
</SimpleTooltip>
|
||||
</td>
|
||||
{:else if colId === "instructor"}
|
||||
{@const primary = getPrimaryInstructor(course.instructors)}
|
||||
<td class="py-2 px-2 whitespace-nowrap">
|
||||
{primaryInstructorDisplay(course)}
|
||||
<SimpleTooltip text={primary?.displayName ?? "Staff"} delay={200} side="bottom" passthrough>
|
||||
<span>{primaryInstructorDisplay(course)}</span>
|
||||
</SimpleTooltip>
|
||||
{#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>
|
||||
<SimpleTooltip text="{r.rating.toFixed(1)}/5 ({r.count} ratings on RateMyProfessors)" delay={150} side="bottom" passthrough>
|
||||
<span
|
||||
class="ml-1 text-xs font-medium {ratingColor(r.rating)}"
|
||||
>{r.rating.toFixed(1)}★</span>
|
||||
</SimpleTooltip>
|
||||
{/if}
|
||||
</td>
|
||||
{:else if colId === "time"}
|
||||
@@ -442,11 +468,13 @@ const table = createSvelteTable({
|
||||
</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>
|
||||
<SimpleTooltip text="{openSeats(course)} of {course.maxEnrollment} seats open, {course.enrollment} enrolled{course.waitCount > 0 ? `, ${course.waitCount} waitlisted` : ''}" delay={200} side="left" passthrough>
|
||||
<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>
|
||||
</SimpleTooltip>
|
||||
</td>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Term, Subject } from "$lib/api";
|
||||
import SimpleTooltip from "./SimpleTooltip.svelte";
|
||||
|
||||
let {
|
||||
terms,
|
||||
@@ -45,8 +46,10 @@ let {
|
||||
class="border border-border bg-card text-foreground rounded-md px-3 py-1.5 text-sm flex-1 min-w-[200px]"
|
||||
/>
|
||||
|
||||
<label class="flex items-center gap-1.5 text-sm text-muted-foreground cursor-pointer">
|
||||
<input type="checkbox" bind:checked={openOnly} />
|
||||
Open only
|
||||
</label>
|
||||
<SimpleTooltip text="Show only courses with available seats" delay={200} passthrough>
|
||||
<label class="flex items-center gap-1.5 text-sm text-muted-foreground cursor-pointer">
|
||||
<input type="checkbox" bind:checked={openOnly} />
|
||||
Open only
|
||||
</label>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Tooltip } from "bits-ui";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
text,
|
||||
delay = 150,
|
||||
side = "top",
|
||||
passthrough = false,
|
||||
children,
|
||||
}: {
|
||||
text: string;
|
||||
delay?: number;
|
||||
side?: "top" | "bottom" | "left" | "right";
|
||||
passthrough?: boolean;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Tooltip.Root delayDuration={delay} disableHoverableContent={passthrough}>
|
||||
<Tooltip.Trigger>
|
||||
{@render children()}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
{side}
|
||||
sideOffset={6}
|
||||
class="z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
|
||||
>
|
||||
{text}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { tick } from "svelte";
|
||||
import { Moon, Sun } from "@lucide/svelte";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
import SimpleTooltip from "./SimpleTooltip.svelte";
|
||||
|
||||
/**
|
||||
* Theme toggle with View Transitions API circular reveal animation.
|
||||
@@ -42,25 +43,27 @@ async function handleToggle(event: MouseEvent) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => handleToggle(e)}
|
||||
aria-label={themeStore.isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
class="cursor-pointer border-none rounded-md flex items-center justify-center p-2 scale-125
|
||||
text-muted-foreground hover:bg-muted bg-transparent transition-colors"
|
||||
>
|
||||
<div class="relative size-[18px]">
|
||||
<Sun
|
||||
size={18}
|
||||
class="absolute inset-0 transition-all duration-300 {themeStore.isDark
|
||||
? 'rotate-90 scale-0 opacity-0'
|
||||
: 'rotate-0 scale-100 opacity-100'}"
|
||||
/>
|
||||
<Moon
|
||||
size={18}
|
||||
class="absolute inset-0 transition-all duration-300 {themeStore.isDark
|
||||
? 'rotate-0 scale-100 opacity-100'
|
||||
: '-rotate-90 scale-0 opacity-0'}"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<SimpleTooltip text={themeStore.isDark ? "Switch to light mode" : "Switch to dark mode"} delay={200} side="bottom" passthrough>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => handleToggle(e)}
|
||||
aria-label={themeStore.isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
class="cursor-pointer border-none rounded-md flex items-center justify-center p-2 scale-125
|
||||
text-muted-foreground hover:bg-muted bg-transparent transition-colors"
|
||||
>
|
||||
<div class="relative size-[18px]">
|
||||
<Sun
|
||||
size={18}
|
||||
class="absolute inset-0 transition-all duration-300 {themeStore.isDark
|
||||
? 'rotate-90 scale-0 opacity-0'
|
||||
: 'rotate-0 scale-100 opacity-100'}"
|
||||
/>
|
||||
<Moon
|
||||
size={18}
|
||||
class="absolute inset-0 transition-all duration-300 {themeStore.isDark
|
||||
? 'rotate-0 scale-100 opacity-100'
|
||||
: '-rotate-90 scale-0 opacity-0'}"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</SimpleTooltip>
|
||||
|
||||
@@ -51,6 +51,9 @@ function handleSortingChange(newSorting: SortingState) {
|
||||
|
||||
// Data state
|
||||
let subjects: Subject[] = $state([]);
|
||||
let subjectMap: Record<string, string> = $derived(
|
||||
Object.fromEntries(subjects.map((s) => [s.code, s.description]))
|
||||
);
|
||||
let searchResult: SearchResponse | null = $state(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
@@ -181,6 +184,7 @@ function handlePageChange(newOffset: number) {
|
||||
{sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
manualSorting={true}
|
||||
{subjectMap}
|
||||
/>
|
||||
|
||||
{#if searchResult}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
WifiOff,
|
||||
XCircle,
|
||||
} from "@lucide/svelte";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
|
||||
import { type ServiceStatus, type ServiceInfo, type StatusResponse, client } from "$lib/api";
|
||||
import { relativeTime } from "$lib/time";
|
||||
|
||||
@@ -290,20 +290,13 @@ onMount(() => {
|
||||
<Clock size={13} />
|
||||
<span class="text-sm text-muted-foreground">Last Updated</span>
|
||||
</div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<abbr
|
||||
class="cursor-pointer underline decoration-dotted decoration-border underline-offset-[6px]"
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">{relativeLastFetch}</span>
|
||||
</abbr>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md"
|
||||
<SimpleTooltip text="as of {lastFetch.toLocaleTimeString()}" delay={150} passthrough>
|
||||
<abbr
|
||||
class="cursor-pointer underline decoration-dotted decoration-border underline-offset-[6px]"
|
||||
>
|
||||
as of {lastFetch.toLocaleTimeString()}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<span class="text-sm text-muted-foreground">{relativeLastFetch}</span>
|
||||
</abbr>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--accent: oklch(0.96 0 0);
|
||||
--accent-foreground: oklch(0.145 0 0);
|
||||
|
||||
--status-green: oklch(0.65 0.2 145);
|
||||
--status-red: oklch(0.63 0.2 25);
|
||||
@@ -28,6 +30,8 @@
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--border: oklch(0.269 0 0);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
|
||||
--status-green: oklch(0.72 0.19 145);
|
||||
--status-red: oklch(0.7 0.19 25);
|
||||
@@ -44,6 +48,8 @@
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-ring: var(--ring);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-status-green: var(--status-green);
|
||||
--color-status-red: var(--status-red);
|
||||
--color-status-orange: var(--status-orange);
|
||||
|
||||
Reference in New Issue
Block a user