mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 22:23:34 -06:00
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:
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user