From 4e0140693b00686e8a57561b0811fdf25a614e65 Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 31 Jan 2026 17:16:00 -0600 Subject: [PATCH] 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. --- web/bun.lock | 3 + web/package.json | 1 + .../lib/components/AttributesPopover.svelte | 85 ++---- web/src/lib/components/FilterPopover.svelte | 51 ++++ web/src/lib/components/MorePopover.svelte | 117 +++----- web/src/lib/components/RangeSlider.svelte | 269 +++++++++++++----- web/src/lib/components/SchedulePopover.svelte | 140 ++++----- web/src/lib/components/SearchFilters.svelte | 12 +- web/src/lib/components/StatusPopover.svelte | 106 +++---- web/src/routes/+page.svelte | 60 ++-- 10 files changed, 452 insertions(+), 392 deletions(-) create mode 100644 web/src/lib/components/FilterPopover.svelte diff --git a/web/bun.lock b/web/bun.lock index 3e7d6ae..8d9232e 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -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=="], diff --git a/web/package.json b/web/package.json index 316c5c7..4f9326e 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/lib/components/AttributesPopover.svelte b/web/src/lib/components/AttributesPopover.svelte index ea2a27d..eae6368 100644 --- a/web/src/lib/components/AttributesPopover.svelte +++ b/web/src/lib/components/AttributesPopover.svelte @@ -1,8 +1,6 @@ - - - {#if hasActiveFilters} - - {/if} - Attributes - - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
-
-
- {#each sections as { label, key, dataKey }, i (key)} - {#if i > 0} -
- {/if} -
- {label} -
- {#each referenceData[dataKey] as item (item.code)} - {@const selected = getSelected(key)} - - {/each} -
-
- {/each} -
-
-
+ + {#snippet content()} + {#each sections as { label, key, dataKey }, i (key)} + {#if i > 0} +
{/if} - {/snippet} -
-
+
+ {label} +
+ {#each referenceData[dataKey] as item (item.code)} + {@const selected = getSelected(key)} + + {/each} +
+
+ {/each} + {/snippet} + diff --git a/web/src/lib/components/FilterPopover.svelte b/web/src/lib/components/FilterPopover.svelte new file mode 100644 index 0000000..e91cf87 --- /dev/null +++ b/web/src/lib/components/FilterPopover.svelte @@ -0,0 +1,51 @@ + + + + + {#if active} + + {/if} + {label} + + + + {#snippet child({ wrapperProps, props, open })} + {#if open} +
+
+
+ {@render content()} +
+
+
+ {/if} + {/snippet} +
+
diff --git a/web/src/lib/components/MorePopover.svelte b/web/src/lib/components/MorePopover.svelte index 6a96276..ff667d1 100644 --- a/web/src/lib/components/MorePopover.svelte +++ b/web/src/lib/components/MorePopover.svelte @@ -1,22 +1,20 @@ - - - {#if hasActiveFilters} - - {/if} - More - - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
-
-
- + + {#snippet content()} + -
+
-
- - -
+
+ + +
-
+
- -
-
-
- {/if} - {/snippet} -
-
+ + {/snippet} + diff --git a/web/src/lib/components/RangeSlider.svelte b/web/src/lib/components/RangeSlider.svelte index f2c84e0..5939151 100644 --- a/web/src/lib/components/RangeSlider.svelte +++ b/web/src/lib/components/RangeSlider.svelte @@ -1,110 +1,223 @@ -
+
{label} {#if !isDefault} {#if dual} - {formatValue(internalLow)} – {formatValue(internalHigh)} + {formatValue(valueLow ?? min)} – {formatValue(valueHigh ?? max)} {:else} - ≤ {formatValue(internalHigh)} + ≤ {formatValue(value ?? max)} {/if} {/if}
- {#if dual} -
- + {#if dual} + commitLow(Number(e.currentTarget.value))} - class="flex-1 accent-primary h-1.5" - /> - commitHigh(Number(e.currentTarget.value))} - class="flex-1 accent-primary h-1.5" + {float} + {hoverable} + {springValues} + range + formatter={formatValue} + {...libProps} + on:change={handleDualChange} /> -
- {:else} - commitSingle(Number(e.currentTarget.value))} - class="w-full accent-primary h-1.5" - /> - {/if} + {:else} + + {/if} +
+ + diff --git a/web/src/lib/components/SchedulePopover.svelte b/web/src/lib/components/SchedulePopover.svelte index 3464041..5ffe199 100644 --- a/web/src/lib/components/SchedulePopover.svelte +++ b/web/src/lib/components/SchedulePopover.svelte @@ -1,7 +1,5 @@ - - - {#if hasActiveFilters} - - {/if} - Schedule - - - - {#snippet child({ wrapperProps, props, open })} - {#if open} -
-
-
-
- Days of week -
- {#each DAY_OPTIONS as { label, value } (value)} - - {/each} -
-
+ + {#snippet content()} +
+ Days of week +
+ {#each DAY_OPTIONS as { label, value } (value)} + + {/each} +
+
-
+
-
- Time range -
- { - 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" - /> - to - { - 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" - /> -
-
-
-
-
- {/if} - {/snippet} -
-
+
+ Time range +
+ { + 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" + /> + to + { + 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" + /> +
+
+ {/snippet} + diff --git a/web/src/lib/components/SearchFilters.svelte b/web/src/lib/components/SearchFilters.svelte index 7c2ca33..d4bef0c 100644 --- a/web/src/lib/components/SearchFilters.svelte +++ b/web/src/lib/components/SearchFilters.svelte @@ -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 }} /> diff --git a/web/src/lib/components/StatusPopover.svelte b/web/src/lib/components/StatusPopover.svelte index 7657e74..db8084d 100644 --- a/web/src/lib/components/StatusPopover.svelte +++ b/web/src/lib/components/StatusPopover.svelte @@ -1,7 +1,5 @@ - - - {#if hasActiveFilters} - + + {#snippet content()} +
+ Availability + +
+ +
+ + {#if waitCountMaxRange > 0} + (v === 0 ? "Off" : String(v))} + /> + {:else} +
+ Max waitlist + No waitlisted courses +
{/if} - Status - -
- - {#snippet child({ wrapperProps, props, open })} - {#if open} -
-
-
-
- Availability - -
- -
- - {#if waitCountMaxRange > 0} - v === 0 ? "Off" : String(v)} - /> - {:else} -
- Max waitlist - No waitlisted courses -
- {/if} -
-
-
- {/if} - {/snippet} -
-
+ {/snippet} + diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 08e863e..1e63de4 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -64,8 +64,8 @@ let attributes: string[] = $state(initialParams.getAll("attributes")); let creditHourMin = $state(parseNumParam("credit_hour_min")); let creditHourMax = $state(parseNumParam("credit_hour_max")); let instructor = $state(initialParams.get("instructor") ?? ""); -let courseNumberLow = $state(parseNumParam("course_number_low")); -let courseNumberHigh = $state(parseNumParam("course_number_high")); +let courseNumberMin = $state(parseNumParam("course_number_low")); +let courseNumberMax = $state(parseNumParam("course_number_high")); let searchOptions = $state(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 | 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; } @@ -669,17 +669,17 @@ function clearAllFilters() { onRemove={() => (instructor = "")} /> {/if} - {#if courseNumberLow !== null || courseNumberHigh !== null} + {#if courseNumberMin !== null || courseNumberMax !== null} { - 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 },