refactor(web): extract FilterPopover component and upgrade range sliders

Replace basic HTML range inputs with svelte-range-slider-pips library
for better UX. Create shared FilterPopover component to eliminate
duplicate popover structure across Attributes, Schedule, Status, and
More filter components.
This commit is contained in:
2026-01-31 17:16:00 -06:00
parent e9209684eb
commit 4e0140693b
10 changed files with 452 additions and 392 deletions
+3
View File
@@ -33,6 +33,7 @@
"jsdom": "^26.1.0",
"svelte": "^5.49.1",
"svelte-check": "^4.3.5",
"svelte-range-slider-pips": "^4.1.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
@@ -640,6 +641,8 @@
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
"svelte-range-slider-pips": ["svelte-range-slider-pips@4.1.0", "", { "peerDependencies": { "svelte": "^4.2.7 || ^5.0.0" } }, "sha512-2Zw7MngIuPeqdyJ3ueEp7jPSx0hce+Sx8r1eteCeUPxEWlNavKhBtqJyuoAdpvh5csPPFVZJ4TJ4MX9s4G70uw=="],
"svelte-toolbelt": ["svelte-toolbelt@0.7.1", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.23.2", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
+1
View File
@@ -31,6 +31,7 @@
"jsdom": "^26.1.0",
"svelte": "^5.49.1",
"svelte-check": "^4.3.5",
"svelte-range-slider-pips": "^4.1.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
+29 -56
View File
@@ -1,8 +1,6 @@
<script lang="ts">
import type { CodeDescription } from "$lib/bindings";
import { ChevronDown } from "@lucide/svelte";
import { Popover } from "bits-ui";
import { fly } from "svelte/transition";
import FilterPopover from "./FilterPopover.svelte";
let {
instructionalMethod = $bindable<string[]>([]),
@@ -62,57 +60,32 @@ function toggle(key: "instructionalMethod" | "campus" | "partOfTerm" | "attribut
}
</script>
<Popover.Root>
<Popover.Trigger
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors cursor-pointer
{hasActiveFilters
? 'border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
: 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
>
{#if hasActiveFilters}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
Attributes
<ChevronDown class="size-3" />
</Popover.Trigger>
<Popover.Content
class="z-50 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg w-80 max-h-96 overflow-y-auto"
sideOffset={4}
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
<div class="flex flex-col gap-3">
{#each sections as { label, key, dataKey }, i (key)}
{#if i > 0}
<div class="h-px bg-border"></div>
{/if}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground">{label}</span>
<div class="flex flex-wrap gap-1">
{#each referenceData[dataKey] as item (item.code)}
{@const selected = getSelected(key)}
<button
type="button"
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer
{selected.includes(item.code)
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => toggle(key, item.code)}
title={item.description}
>
{item.description}
</button>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
<FilterPopover label="Attributes" active={hasActiveFilters} width="w-80 max-h-96 overflow-y-auto">
{#snippet content()}
{#each sections as { label, key, dataKey }, i (key)}
{#if i > 0}
<div class="h-px bg-border"></div>
{/if}
{/snippet}
</Popover.Content>
</Popover.Root>
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground">{label}</span>
<div class="flex flex-wrap gap-1">
{#each referenceData[dataKey] as item (item.code)}
{@const selected = getSelected(key)}
<button
type="button"
aria-pressed={selected.includes(item.code)}
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer
{selected.includes(item.code)
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => toggle(key, item.code)}
title={item.description}
>
{item.description}
</button>
{/each}
</div>
</div>
{/each}
{/snippet}
</FilterPopover>
@@ -0,0 +1,51 @@
<script lang="ts">
import { ChevronDown } from "@lucide/svelte";
import { Popover } from "bits-ui";
import type { Snippet } from "svelte";
import { fly } from "svelte/transition";
let {
label,
active = false,
width = "w-72",
content,
}: {
label: string;
active?: boolean;
width?: string;
content: Snippet;
} = $props();
</script>
<Popover.Root>
<Popover.Trigger
aria-label="{label} filters"
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors cursor-pointer
{active
? 'border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
: 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
>
{#if active}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
{label}
<ChevronDown class="size-3" />
</Popover.Trigger>
<Popover.Content
class="z-50 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg {width}"
sideOffset={4}
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
<div class="flex flex-col gap-3">
{@render content()}
</div>
</div>
</div>
{/if}
{/snippet}
</Popover.Content>
</Popover.Root>
+47 -70
View File
@@ -1,22 +1,20 @@
<script lang="ts">
import { ChevronDown } from "@lucide/svelte";
import { Popover } from "bits-ui";
import { fly } from "svelte/transition";
import FilterPopover from "./FilterPopover.svelte";
import RangeSlider from "./RangeSlider.svelte";
let {
creditHourMin = $bindable<number | null>(null),
creditHourMax = $bindable<number | null>(null),
instructor = $bindable(""),
courseNumberLow = $bindable<number | null>(null),
courseNumberHigh = $bindable<number | null>(null),
courseNumberMin = $bindable<number | null>(null),
courseNumberMax = $bindable<number | null>(null),
ranges,
}: {
creditHourMin: number | null;
creditHourMax: number | null;
instructor: string;
courseNumberLow: number | null;
courseNumberHigh: number | null;
courseNumberMin: number | null;
courseNumberMax: number | null;
ranges: { courseNumber: { min: number; max: number }; creditHours: { min: number; max: number } };
} = $props();
@@ -24,73 +22,52 @@ const hasActiveFilters = $derived(
creditHourMin !== null ||
creditHourMax !== null ||
instructor !== "" ||
courseNumberLow !== null ||
courseNumberHigh !== null
courseNumberMin !== null ||
courseNumberMax !== null
);
</script>
<Popover.Root>
<Popover.Trigger
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors cursor-pointer
{hasActiveFilters
? 'border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
: 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
>
{#if hasActiveFilters}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
More
<ChevronDown class="size-3" />
</Popover.Trigger>
<Popover.Content
class="z-50 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg w-72"
sideOffset={4}
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
<div class="flex flex-col gap-3">
<RangeSlider
min={ranges.creditHours.min}
max={ranges.creditHours.max}
step={1}
bind:valueLow={creditHourMin}
bind:valueHigh={creditHourMax}
label="Credit hours"
/>
<FilterPopover label="More" active={hasActiveFilters}>
{#snippet content()}
<RangeSlider
min={ranges.creditHours.min}
max={ranges.creditHours.max}
step={1}
bind:valueLow={creditHourMin}
bind:valueHigh={creditHourMax}
label="Credit hours"
pips
all="label"
/>
<div class="h-px bg-border"></div>
<div class="h-px bg-border"></div>
<div class="flex flex-col gap-1.5">
<label for="instructor-input" class="text-xs font-medium text-muted-foreground">
Instructor
</label>
<input
id="instructor-input"
type="text"
placeholder="Search by name..."
bind:value={instructor}
class="h-8 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="instructor-input" class="text-xs font-medium text-muted-foreground">
Instructor
</label>
<input
id="instructor-input"
type="text"
placeholder="Search by name..."
autocomplete="off"
bind:value={instructor}
class="h-8 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</div>
<div class="h-px bg-border"></div>
<div class="h-px bg-border"></div>
<RangeSlider
min={ranges.courseNumber.min}
max={ranges.courseNumber.max}
step={100}
bind:valueLow={courseNumberLow}
bind:valueHigh={courseNumberHigh}
label="Course number"
/>
</div>
</div>
</div>
{/if}
{/snippet}
</Popover.Content>
</Popover.Root>
<RangeSlider
min={ranges.courseNumber.min}
max={ranges.courseNumber.max}
step={100}
bind:valueLow={courseNumberMin}
bind:valueHigh={courseNumberMax}
label="Course number"
pips
pipstep={10}
/>
{/snippet}
</FilterPopover>
+191 -78
View File
@@ -1,110 +1,223 @@
<script lang="ts">
let {
min,
max,
step = 1,
valueLow = $bindable<number | null>(null),
valueHigh = $bindable<number | null>(null),
label,
formatValue = (v: number) => String(v),
dual = true,
}: {
import type { ComponentProps } from "svelte";
import LibRangeSlider from "svelte-range-slider-pips";
type LibProps = ComponentProps<LibRangeSlider>;
/**
* Two modes:
* - `dual` (default): bind `valueLow` and `valueHigh` for a two-thumb range.
* - `dual={false}`: bind `value` for a single-thumb slider. `valueLow`/`valueHigh` are ignored.
*
* All three are null when at their default (boundary) position.
*/
type Props = Omit<
LibProps,
| "values"
| "value"
| "formatter"
| "range"
| "min"
| "max"
| "float"
| "hoverable"
| "springValues"
> & {
min: number;
max: number;
step?: number;
valueLow: number | null;
valueHigh: number | null;
label: string;
formatValue?: (v: number) => string;
dual?: boolean;
} = $props();
float?: boolean;
hoverable?: boolean;
springValues?: { stiffness?: number; damping?: number };
valueLow?: number | null;
valueHigh?: number | null;
value?: number | null;
};
// Internal slider values — full range when filter is null (inactive)
let internalLow = $state(0);
let internalHigh = $state(0);
let {
min,
max,
valueLow = $bindable(null),
valueHigh = $bindable(null),
value = $bindable(null),
label,
formatValue = (v: number) => String(v),
dual = true,
float = true,
hoverable = true,
// Intentionally snappier than library defaults (0.15/0.4)
springValues = { stiffness: 0.3, damping: 0.7 },
...libProps
}: Props = $props();
// Sync external → internal when props change (e.g., reset)
let internalValues = $state<number[]>([min, max]);
let internalValue = $state(max);
if (import.meta.env.DEV) {
$effect(() => {
if (min >= max) {
console.warn(`RangeSlider "${label}": min (${min}) must be less than max (${max})`);
}
});
}
// Sync external -> internal (equality guards prevent loops)
$effect(() => {
internalLow = valueLow ?? min;
internalHigh = valueHigh ?? max;
if (dual) {
const nextLow = valueLow ?? min;
const nextHigh = valueHigh ?? max;
if (internalValues[0] !== nextLow || internalValues[1] !== nextHigh) {
internalValues = [nextLow, nextHigh];
}
} else {
const next = value ?? max;
if (internalValue !== next) {
internalValue = next;
}
}
});
// Whether the slider is at its default (full range) position
const isDefault = $derived(internalLow === min && internalHigh === max);
const isDefault = $derived(dual ? valueLow === null && valueHigh === null : value === null);
function commitLow(value: number) {
internalLow = value;
// At full range = no filter
if (value === min && internalHigh === max) {
valueLow = null;
valueHigh = null;
} else {
valueLow = value;
if (valueHigh === null) valueHigh = internalHigh;
}
function handleDualChange(event: CustomEvent<{ values: number[] }>) {
const [low, high] = event.detail.values;
const nextLow = low === min && high === max ? null : low;
const nextHigh = low === min && high === max ? null : high;
if (nextLow === valueLow && nextHigh === valueHigh) return;
valueLow = nextLow;
valueHigh = nextHigh;
}
function commitHigh(value: number) {
internalHigh = value;
if (internalLow === min && value === max) {
valueLow = null;
valueHigh = null;
} else {
valueHigh = value;
if (valueLow === null) valueLow = internalLow;
}
}
function commitSingle(value: number) {
internalHigh = value;
valueHigh = value === 0 ? null : value;
function handleSingleChange(event: CustomEvent<{ value: number }>) {
const next = event.detail.value === max ? null : event.detail.value;
if (next === value) return;
value = next;
}
</script>
<div class="flex flex-col gap-1.5">
<div class="range-slider-wrapper flex flex-col gap-1.5" role="group" aria-label={label}>
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-muted-foreground">{label}</span>
{#if !isDefault}
<span class="text-xs text-muted-foreground">
{#if dual}
{formatValue(internalLow)} {formatValue(internalHigh)}
{formatValue(valueLow ?? min)} {formatValue(valueHigh ?? max)}
{:else}
{formatValue(internalHigh)}
{formatValue(value ?? max)}
{/if}
</span>
{/if}
</div>
{#if dual}
<div class="flex items-center gap-2">
<input
type="range"
<div class="pt-0.5">
{#if dual}
<LibRangeSlider
bind:values={internalValues}
{min}
max={internalHigh}
{step}
value={internalLow}
oninput={(e) => commitLow(Number(e.currentTarget.value))}
class="flex-1 accent-primary h-1.5"
/>
<input
type="range"
min={internalLow}
{max}
{step}
value={internalHigh}
oninput={(e) => commitHigh(Number(e.currentTarget.value))}
class="flex-1 accent-primary h-1.5"
{float}
{hoverable}
{springValues}
range
formatter={formatValue}
{...libProps}
on:change={handleDualChange}
/>
</div>
{:else}
<input
type="range"
min={0}
{max}
{step}
value={internalHigh}
oninput={(e) => commitSingle(Number(e.currentTarget.value))}
class="w-full accent-primary h-1.5"
/>
{/if}
{:else}
<LibRangeSlider
bind:value={internalValue}
{min}
{max}
{float}
{hoverable}
{springValues}
formatter={formatValue}
{...libProps}
on:change={handleSingleChange}
/>
{/if}
</div>
</div>
<style>
/* Theme color mapping */
.range-slider-wrapper :global(.rangeSlider) {
--range-slider: var(--border);
--range-handle-inactive: var(--muted-foreground);
--range-handle: var(--muted-foreground);
--range-handle-focus: var(--foreground);
--range-handle-border: var(--muted-foreground);
--range-range-inactive: var(--muted-foreground);
--range-range: var(--foreground);
--range-range-hover: var(--foreground);
--range-range-press: var(--foreground);
--range-float-inactive: var(--card);
--range-float: var(--card);
--range-float-text: var(--card-foreground);
--range-range-limit: var(--muted);
font-size: 0.75rem;
margin: 0.5em;
height: 0.375em;
}
/* Smaller handles, plain circles */
.range-slider-wrapper :global(.rangeSlider .rangeHandle) {
height: 1em;
width: 1em;
}
.range-slider-wrapper :global(.rangeSlider.rsRange:not(.rsMin):not(.rsMax) .rangeNub) {
border-radius: 9999px;
}
.range-slider-wrapper :global(.rangeSlider.rsRange .rangeHandle .rangeNub) {
transform: none;
}
/* Hover / press effects */
.range-slider-wrapper :global(.rangeSlider.rsHoverable:not(.rsDisabled) .rangeHandle:hover::before) {
box-shadow: 0 0 0 6px var(--handle-border);
opacity: 0.15;
}
.range-slider-wrapper :global(.rangeSlider.rsHoverable:not(.rsDisabled) .rangeHandle.rsPress::before),
.range-slider-wrapper :global(.rangeSlider.rsHoverable:not(.rsDisabled) .rangeHandle.rsPress:hover::before) {
box-shadow: 0 0 0 8px var(--handle-border);
opacity: 0.25;
}
/* Track bar */
.range-slider-wrapper :global(.rangeSlider .rangeBar),
.range-slider-wrapper :global(.rangeSlider .rangeLimit) {
height: 0.375em;
}
/* Float label */
.range-slider-wrapper :global(.rangeSlider .rangeFloat) {
font-size: 0.7em;
font-weight: 400;
line-height: 1;
padding: 0.25em 0.4em 0.35em;
border-radius: 0.375em;
border: 1px solid var(--border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
/* Pip labels */
.range-slider-wrapper :global(.rangeSlider .rangePip .pipVal) {
color: var(--muted-foreground);
font-size: 0.6em;
font-weight: 400;
}
/* Pip spacing */
.range-slider-wrapper :global(.rangeSlider.rsPips) {
margin-bottom: 1.2em;
}
.range-slider-wrapper :global(.rangeSlider.rsPipLabels) {
margin-bottom: 2em;
}
</style>
+55 -85
View File
@@ -1,7 +1,5 @@
<script lang="ts">
import { ChevronDown } from "@lucide/svelte";
import { Popover } from "bits-ui";
import { fly } from "svelte/transition";
import FilterPopover from "./FilterPopover.svelte";
let {
days = $bindable<string[]>([]),
@@ -33,12 +31,10 @@ function toggleDay(day: string) {
}
}
/** Convert "10:00 AM" or "14:30" input to 24h string like "1000" or "1430" */
function parseTimeInput(input: string): string | null {
const trimmed = input.trim();
if (trimmed === "") return null;
// Try HH:MM AM/PM format
const ampmMatch = trimmed.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
if (ampmMatch) {
let hours = parseInt(ampmMatch[1], 10);
@@ -49,7 +45,6 @@ function parseTimeInput(input: string): string | null {
return String(hours).padStart(2, "0") + String(minutes).padStart(2, "0");
}
// Try HH:MM 24h format
const militaryMatch = trimmed.match(/^(\d{1,2}):(\d{2})$/);
if (militaryMatch) {
const hours = parseInt(militaryMatch[1], 10);
@@ -60,7 +55,6 @@ function parseTimeInput(input: string): string | null {
return null;
}
/** Convert 24h string like "1000" to "10:00 AM" for display */
function formatTime(time: string | null): string {
if (time === null || time.length !== 4) return "";
const hours = parseInt(time.slice(0, 2), 10);
@@ -71,83 +65,59 @@ function formatTime(time: string | null): string {
}
</script>
<Popover.Root>
<Popover.Trigger
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors cursor-pointer
{hasActiveFilters
? 'border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
: 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
>
{#if hasActiveFilters}
<span class="size-1.5 rounded-full bg-primary"></span>
{/if}
Schedule
<ChevronDown class="size-3" />
</Popover.Trigger>
<Popover.Content
class="z-50 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg w-72"
sideOffset={4}
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground">Days of week</span>
<div class="flex gap-1">
{#each DAY_OPTIONS as { label, value } (value)}
<button
type="button"
class="flex items-center justify-center rounded-md px-2 py-1 text-xs font-medium transition-colors cursor-pointer min-w-[2rem]
{days.includes(value)
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => toggleDay(value)}
aria-label={value.charAt(0).toUpperCase() + value.slice(1)}
aria-pressed={days.includes(value)}
>
{label}
</button>
{/each}
</div>
</div>
<FilterPopover label="Schedule" active={hasActiveFilters}>
{#snippet content()}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground">Days of week</span>
<div class="flex gap-1">
{#each DAY_OPTIONS as { label, value } (value)}
<button
type="button"
aria-label={value.charAt(0).toUpperCase() + value.slice(1)}
aria-pressed={days.includes(value)}
class="flex items-center justify-center rounded-md px-2 py-1 text-xs font-medium transition-colors cursor-pointer min-w-[2rem]
{days.includes(value)
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => toggleDay(value)}
>
{label}
</button>
{/each}
</div>
</div>
<div class="h-px bg-border"></div>
<div class="h-px bg-border"></div>
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground">Time range</span>
<div class="flex items-center gap-2">
<input
type="text"
placeholder="10:00 AM"
value={formatTime(timeStart)}
onchange={(e) => {
timeStart = parseTimeInput(e.currentTarget.value);
e.currentTarget.value = formatTime(timeStart);
}}
class="h-8 w-24 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
<span class="text-xs text-muted-foreground">to</span>
<input
type="text"
placeholder="3:00 PM"
value={formatTime(timeEnd)}
onchange={(e) => {
timeEnd = parseTimeInput(e.currentTarget.value);
e.currentTarget.value = formatTime(timeEnd);
}}
class="h-8 w-24 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</div>
</div>
</div>
</div>
</div>
{/if}
{/snippet}
</Popover.Content>
</Popover.Root>
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground">Time range</span>
<div class="flex items-center gap-2">
<input
type="text"
placeholder="10:00 AM"
autocomplete="off"
value={formatTime(timeStart)}
onchange={(e) => {
timeStart = parseTimeInput(e.currentTarget.value);
e.currentTarget.value = formatTime(timeStart);
}}
class="h-8 w-24 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
<span class="text-xs text-muted-foreground">to</span>
<input
type="text"
placeholder="3:00 PM"
autocomplete="off"
value={formatTime(timeEnd)}
onchange={(e) => {
timeEnd = parseTimeInput(e.currentTarget.value);
e.currentTarget.value = formatTime(timeEnd);
}}
class="h-8 w-24 border border-border bg-card text-foreground rounded-md px-2 text-sm
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</div>
</div>
{/snippet}
</FilterPopover>
+6 -6
View File
@@ -25,8 +25,8 @@ let {
creditHourMin = $bindable(),
creditHourMax = $bindable(),
instructor = $bindable(),
courseNumberLow = $bindable(),
courseNumberHigh = $bindable(),
courseNumberMin = $bindable(),
courseNumberMax = $bindable(),
referenceData,
ranges,
}: {
@@ -47,8 +47,8 @@ let {
creditHourMin: number | null;
creditHourMax: number | null;
instructor: string;
courseNumberLow: number | null;
courseNumberHigh: number | null;
courseNumberMin: number | null;
courseNumberMax: number | null;
referenceData: {
instructionalMethods: CodeDescription[];
campuses: CodeDescription[];
@@ -95,8 +95,8 @@ let {
bind:creditHourMin
bind:creditHourMax
bind:instructor
bind:courseNumberLow
bind:courseNumberHigh
bind:courseNumberMin
bind:courseNumberMax
ranges={{ courseNumber: ranges.courseNumber, creditHours: ranges.creditHours }}
/>
</div>
+39 -67
View File
@@ -1,7 +1,5 @@
<script lang="ts">
import { ChevronDown } from "@lucide/svelte";
import { Popover } from "bits-ui";
import { fly } from "svelte/transition";
import FilterPopover from "./FilterPopover.svelte";
import RangeSlider from "./RangeSlider.svelte";
let {
@@ -14,71 +12,45 @@ let {
waitCountMaxRange: number;
} = $props();
let _dummyLow = $state<number | null>(null);
const hasActiveFilters = $derived(openOnly || waitCountMax !== null);
</script>
<Popover.Root>
<Popover.Trigger
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors cursor-pointer
{hasActiveFilters
? 'border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
: 'border-border bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground'}"
>
{#if hasActiveFilters}
<span class="size-1.5 rounded-full bg-primary"></span>
<FilterPopover label="Status" active={hasActiveFilters} width="w-64">
{#snippet content()}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground">Availability</span>
<button
type="button"
aria-pressed={openOnly}
class="inline-flex items-center justify-center rounded-full px-3 py-1 text-xs font-medium transition-colors cursor-pointer
{openOnly
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => (openOnly = !openOnly)}
>
Open only
</button>
</div>
<div class="h-px bg-border"></div>
{#if waitCountMaxRange > 0}
<RangeSlider
min={0}
max={waitCountMaxRange}
step={5}
bind:value={waitCountMax}
label="Max waitlist"
dual={false}
pips
pipstep={2}
formatValue={(v) => (v === 0 ? "Off" : String(v))}
/>
{:else}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground">Max waitlist</span>
<span class="text-xs text-muted-foreground">No waitlisted courses</span>
</div>
{/if}
Status
<ChevronDown class="size-3" />
</Popover.Trigger>
<Popover.Content
class="z-50 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg w-64"
sideOffset={4}
forceMount
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div {...props} transition:fly={{ duration: 150, y: -4 }}>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground">Availability</span>
<button
type="button"
class="inline-flex items-center justify-center rounded-full px-3 py-1 text-xs font-medium transition-colors cursor-pointer
{openOnly
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
onclick={() => (openOnly = !openOnly)}
>
Open only
</button>
</div>
<div class="h-px bg-border"></div>
{#if waitCountMaxRange > 0}
<RangeSlider
min={0}
max={waitCountMaxRange}
step={10}
bind:valueLow={_dummyLow}
bind:valueHigh={waitCountMax}
label="Max waitlist"
dual={false}
formatValue={(v) => v === 0 ? "Off" : String(v)}
/>
{:else}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground">Max waitlist</span>
<span class="text-xs text-muted-foreground">No waitlisted courses</span>
</div>
{/if}
</div>
</div>
</div>
{/if}
{/snippet}
</Popover.Content>
</Popover.Root>
{/snippet}
</FilterPopover>
+30 -30
View File
@@ -64,8 +64,8 @@ let attributes: string[] = $state(initialParams.getAll("attributes"));
let creditHourMin = $state<number | null>(parseNumParam("credit_hour_min"));
let creditHourMax = $state<number | null>(parseNumParam("credit_hour_max"));
let instructor = $state(initialParams.get("instructor") ?? "");
let courseNumberLow = $state<number | null>(parseNumParam("course_number_low"));
let courseNumberHigh = $state<number | null>(parseNumParam("course_number_high"));
let courseNumberMin = $state<number | null>(parseNumParam("course_number_low"));
let courseNumberMax = $state<number | null>(parseNumParam("course_number_high"));
let searchOptions = $state<SearchOptionsResponse | null>(null);
@@ -171,8 +171,8 @@ const THROTTLE_MS = {
creditHourMin: 300,
creditHourMax: 300,
instructor: 300,
courseNumberLow: 300,
courseNumberHigh: 300,
courseNumberMin: 300,
courseNumberMax: 300,
} as const;
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
@@ -198,8 +198,8 @@ function buildSearchKey(): string {
creditHourMin,
creditHourMax,
instructor,
courseNumberLow,
courseNumberHigh,
courseNumberMin,
courseNumberMax,
].join("|");
}
@@ -318,14 +318,14 @@ $effect(() => {
});
$effect(() => {
courseNumberLow;
scheduleSearch("courseNumberLow");
courseNumberMin;
scheduleSearch("courseNumberMin");
return () => clearTimeout(searchTimeout);
});
$effect(() => {
courseNumberHigh;
scheduleSearch("courseNumberHigh");
courseNumberMax;
scheduleSearch("courseNumberMax");
return () => clearTimeout(searchTimeout);
});
@@ -347,8 +347,8 @@ function buildFilterKey(): string {
creditHourMin,
creditHourMax,
instructor,
courseNumberLow,
courseNumberHigh,
courseNumberMin,
courseNumberMax,
].join("|");
}
@@ -394,8 +394,8 @@ async function performSearch() {
if (creditHourMin !== null) params.set("credit_hour_min", String(creditHourMin));
if (creditHourMax !== null) params.set("credit_hour_max", String(creditHourMax));
if (instructor) params.set("instructor", instructor);
if (courseNumberLow !== null) params.set("course_number_low", String(courseNumberLow));
if (courseNumberHigh !== null) params.set("course_number_high", String(courseNumberHigh));
if (courseNumberMin !== null) params.set("course_number_low", String(courseNumberMin));
if (courseNumberMax !== null) params.set("course_number_high", String(courseNumberMax));
// Include term in URL only when it differs from the default or other params are active
const hasOtherParams = params.size > 0;
@@ -439,8 +439,8 @@ async function performSearch() {
creditHourMin: creditHourMin ?? undefined,
creditHourMax: creditHourMax ?? undefined,
instructor: instructor || undefined,
courseNumberLow: courseNumberLow ?? undefined,
courseNumberHigh: courseNumberHigh ?? undefined,
courseNumberLow: courseNumberMin ?? undefined,
courseNumberHigh: courseNumberMax ?? undefined,
});
const applyUpdate = () => {
@@ -548,7 +548,7 @@ let activeFilterCount = $derived(
(attributes.length > 0 ? 1 : 0) +
(creditHourMin !== null || creditHourMax !== null ? 1 : 0) +
(instructor !== "" ? 1 : 0) +
(courseNumberLow !== null || courseNumberHigh !== null ? 1 : 0)
(courseNumberMin !== null || courseNumberMax !== null ? 1 : 0)
);
function removeSubject(code: string) {
@@ -569,8 +569,8 @@ function clearAllFilters() {
creditHourMin = null;
creditHourMax = null;
instructor = "";
courseNumberLow = null;
courseNumberHigh = null;
courseNumberMin = null;
courseNumberMax = null;
}
</script>
@@ -669,17 +669,17 @@ function clearAllFilters() {
onRemove={() => (instructor = "")}
/>
{/if}
{#if courseNumberLow !== null || courseNumberHigh !== null}
{#if courseNumberMin !== null || courseNumberMax !== null}
<FilterChip
label={courseNumberLow !== null &&
courseNumberHigh !== null
? `Course ${courseNumberLow}${courseNumberHigh}`
: courseNumberLow !== null
? `Course ${courseNumberLow}`
: `Course ${courseNumberHigh}`}
label={courseNumberMin !== null &&
courseNumberMax !== null
? `Course ${courseNumberMin}${courseNumberMax}`
: courseNumberMin !== null
? `Course ${courseNumberMin}`
: `Course ${courseNumberMax}`}
onRemove={() => {
courseNumberLow = null;
courseNumberHigh = null;
courseNumberMin = null;
courseNumberMax = null;
}}
/>
{/if}
@@ -801,8 +801,8 @@ function clearAllFilters() {
bind:creditHourMin
bind:creditHourMax
bind:instructor
bind:courseNumberLow
bind:courseNumberHigh
bind:courseNumberMin
bind:courseNumberMax
{referenceData}
ranges={{
courseNumber: { min: ranges.courseNumberMin, max: ranges.courseNumberMax },