feat(filters): replace RangeSlider with Stash-style NumberFilter (#85) (#86)
Django CI/CD / build-and-push (push) Has been cancelled
Django CI/CD / test (push) Has been cancelled

Numeric range filters could only express BETWEEN/GREATER_THAN/LESS_THAN
via the RangeSlider widget — no way to match NULL/missing values (the
original ask in #32) or exact/not-between. The criteria backend already
supported all 8 numeric modifiers + value2, so this is a UI swap.

- Add NumberFilter component, modeled 1:1 on StringFilter: an 8-modifier
  radio grid plus two number inputs, with the second input revealed only
  for BETWEEN/NOT_BETWEEN and both disabled for IS_NULL/NOT_NULL. Initial
  state is server-rendered so the widget never flashes.
- Migrate all 17 numeric range fields (game/session/purchase/playevent)
  to NumberFilter; drop the now-dead min/max aggregate queries.
- filter-bar.ts: serialize numberFields by modifier (mirroring textFields)
  and wire the modifier radios via event delegation on the persistent
  custom element so they survive htmx swaps of the inner bar body. Apply
  the same delegation fix to the existing string filters.
- Remove RangeSlider entirely: component, range-slider.ts, its custom
  element registration/props, and the range-slider e2e tests.

Backward compatible: old slider filters stored {value, value2, modifier},
the same JSON shape NumberFilter reads, so saved presets keep working.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 21:31:10 +02:00
committed by GitHub
parent 34563b26d2
commit 9960a8fc3e
16 changed files with 648 additions and 913 deletions
+60 -25
View File
@@ -23,13 +23,6 @@ interface DeselectableRadio extends HTMLInputElement {
wasChecked?: boolean;
}
interface RangeField {
prefix: string;
key: string;
ignoreZeroZero?: boolean;
convert?: (value: number) => number;
}
function criterion(value: unknown, value2: unknown, modifier: string): Criterion {
const result: Criterion = { value, modifier };
if (value2 !== null && value2 !== undefined && value2 !== "") {
@@ -166,7 +159,7 @@ function buildFilterJSON(form: HTMLElement): Record<string, unknown> {
}
});
const rangeFields: RangeField[] = [
const numberFields = [
{ prefix: "filter-year", key: "year_released" },
{ prefix: "filter-original-year", key: "original_year_released" },
{ prefix: "filter-session-count", key: "session_count" },
@@ -183,19 +176,25 @@ function buildFilterJSON(form: HTMLElement): Record<string, unknown> {
{ prefix: "filter-purchase-price-total", key: "purchase_price_total" },
{ prefix: "filter-purchase-price-any", key: "purchase_price_any" },
{ prefix: "filter-days-to-finish", key: "days_to_finish" },
{ prefix: "filter-playtime-hours", key: "playtime_hours", ignoreZeroZero: true },
{ prefix: "filter-playtime-hours", key: "playtime_hours" },
];
rangeFields.forEach((rangeField) => {
let valueMin = numberValue(form, rangeField.prefix + "-min");
let valueMax = numberValue(form, rangeField.prefix + "-max");
if (rangeField.convert) {
if (valueMin !== "") valueMin = rangeField.convert(valueMin);
if (valueMax !== "") valueMax = rangeField.convert(valueMax);
numberFields.forEach((numberField) => {
const modifierElement = form.querySelector<HTMLInputElement>(
`[name="${numberField.prefix}-modifier"]:checked`,
);
const modifier = modifierElement ? modifierElement.value : "EQUALS";
if (modifier === "IS_NULL" || modifier === "NOT_NULL") {
filter[numberField.key] = { modifier };
return;
}
if (rangeField.ignoreZeroZero && valueMin === 0 && valueMax === 0) return;
const result = buildRangeCriterion(valueMin, valueMax);
if (result !== null) filter[rangeField.key] = result;
const value = numberValue(form, numberField.prefix);
if (modifier === "BETWEEN" || modifier === "NOT_BETWEEN") {
const value2 = numberValue(form, numberField.prefix + "-value2");
if (value !== "") filter[numberField.key] = criterion(value, value2, modifier);
return;
}
if (value !== "") filter[numberField.key] = criterion(value, null, modifier);
});
const dateRangeFields = [
@@ -279,13 +278,48 @@ function toggleStringFilterInput(radio: HTMLInputElement): void {
}
function setupStringFilters(root: HTMLElement): void {
root
.querySelectorAll<HTMLInputElement>("input[data-string-modifier-radio]")
.forEach((radio) => {
radio.addEventListener("change", function (this: HTMLInputElement) {
toggleStringFilterInput(this);
});
});
// Delegated on the persistent custom element (see setupNumberFilters) so the
// modifier radios keep working after an htmx swap of the inner #filter-bar.
root.addEventListener("change", (event) => {
const target = event.target as Element;
if (target.matches("input[data-string-modifier-radio]")) {
toggleStringFilterInput(target as HTMLInputElement);
}
});
}
function toggleNumberFilterInput(radio: HTMLInputElement): void {
const container = radio.closest(".flex-col");
if (!container) return;
const inputs = container.querySelectorAll<HTMLInputElement>('input[type="number"]');
const value2 = container.querySelector<HTMLInputElement>("[data-number-value2]");
const checkedRadio = container.querySelector<HTMLInputElement>('input[type="radio"]:checked');
const modifier = checkedRadio ? checkedRadio.value : "";
const isPresence = modifier === "IS_NULL" || modifier === "NOT_NULL";
const isBetween = modifier === "BETWEEN" || modifier === "NOT_BETWEEN";
inputs.forEach((input) => {
if (isPresence) {
input.disabled = true;
input.value = "";
input.classList.add("opacity-50", "cursor-not-allowed");
} else {
input.disabled = false;
input.classList.remove("opacity-50", "cursor-not-allowed");
}
});
if (value2) value2.classList.toggle("hidden", isPresence || !isBetween);
}
function setupNumberFilters(root: HTMLElement): void {
// Delegated on the persistent custom element so the modifier radios keep
// working after the inner #filter-bar body is htmx-swapped (connectedCallback
// does not re-run for inner swaps — a direct per-radio listener would be lost).
root.addEventListener("change", (event) => {
const target = event.target as Element;
if (target.matches("input[data-number-modifier-radio]")) {
toggleNumberFilterInput(target as HTMLInputElement);
}
});
}
function setupPresetDeleteHandlers(container: HTMLElement): void {
@@ -442,6 +476,7 @@ class FilterBarElement extends HTMLElement {
injectSearchInput(form);
setupDeselectableRadios(this);
setupStringFilters(this);
setupNumberFilters(this);
if (presetListUrl) loadPresets(this, presetListUrl);
}
}
-240
View File
@@ -1,240 +0,0 @@
/**
* Range slider — custom draggable handles (no native <input type=range>).
*
* Supports two modes, toggled via the .range-mode-toggle button:
* range (default) — two handles, min ≤ max constraint
* point — single handle, sets both number inputs to the same value
*
* Handles track-fill positioning and sync between handles and the connected
* number inputs (linked via data-target attributes on the handles).
* Behavior is wired in connectedCallback; the typed props (min, max, step, mode)
* come from the server via readRangeSliderProps.
*/
import { readRangeSliderProps } from "../generated/props.js";
class RangeSliderElement extends HTMLElement {
private onMouseMove: ((event: MouseEvent) => void) | null = null;
private onMouseUp: (() => void) | null = null;
connectedCallback(): void {
const { min: dataMin, max: dataMax, step, mode: initialMode } =
readRangeSliderProps(this);
let mode = initialMode;
const track = this.querySelector<HTMLElement>("[data-range-track]");
const trackFill = this.querySelector<HTMLElement>(".range-track-fill");
const minHandle = this.querySelector<HTMLElement>(".range-handle-min");
const maxHandle = this.querySelector<HTMLElement>(".range-handle-max");
if (!track || !minHandle || !maxHandle) return;
const minTarget = document.getElementById(
minHandle.getAttribute("data-target") ?? ""
) as HTMLInputElement | null;
const maxTarget = document.getElementById(
maxHandle.getAttribute("data-target") ?? ""
) as HTMLInputElement | null;
// ── Helpers ──
function valueToPercent(value: number): number {
return ((value - dataMin) / (dataMax - dataMin)) * 100;
}
function percentToValue(percent: number): number {
const raw = dataMin + (percent / 100) * (dataMax - dataMin);
return Math.round(raw / step) * step;
}
function clamp(value: number, low: number, high: number): number {
return Math.max(low, Math.min(high, value));
}
function getTargetValue(
target: HTMLInputElement | null,
defaultValue: number
): number {
if (!target || target.value === "") return defaultValue;
const parsed = parseInt(target.value, 10);
return isNaN(parsed) ? defaultValue : parsed;
}
function setTargetValue(
target: HTMLInputElement | null,
value: number | string
): void {
if (target) target.value = String(value);
}
// ── Track fill positioning ──
function updateTrackFill(): void {
if (!trackFill) return;
const minValue = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
const maxValue = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
if (mode === "point") {
trackFill.style.left = "0%";
trackFill.style.width = valueToPercent(maxValue) + "%";
} else {
let leftPercent = valueToPercent(minValue);
let rightPercent = valueToPercent(maxValue);
if (leftPercent > rightPercent) {
const temp = leftPercent;
leftPercent = rightPercent;
rightPercent = temp;
}
const widthPercent = rightPercent - leftPercent;
trackFill.style.left = leftPercent + "%";
trackFill.style.width = widthPercent + "%";
}
}
function updateHandles(): void {
const minValue = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
const maxValue = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
minHandle!.style.left = valueToPercent(minValue) + "%";
maxHandle!.style.left = valueToPercent(maxValue) + "%";
updateTrackFill();
}
// ── Dragging ──
const makeDraggable = (handle: HTMLElement, isMin: boolean): void => {
handle.addEventListener("mousedown", (event) => {
event.preventDefault();
const rect = track.getBoundingClientRect();
const onMove = (moveEvent: MouseEvent): void => {
const percent = ((moveEvent.clientX - rect.left) / rect.width) * 100;
const value = percentToValue(clamp(percent, 0, 100));
if (mode === "point") {
setTargetValue(minTarget, value);
setTargetValue(maxTarget, value);
if (minTarget) minTarget.dispatchEvent(new Event("input", { bubbles: true }));
if (maxTarget) maxTarget.dispatchEvent(new Event("input", { bubbles: true }));
} else if (isMin) {
setTargetValue(
minTarget,
clamp(value, dataMin, getTargetValue(maxTarget, dataMax))
);
if (minTarget) minTarget.dispatchEvent(new Event("input", { bubbles: true }));
} else {
setTargetValue(
maxTarget,
clamp(value, getTargetValue(minTarget, dataMin), dataMax)
);
if (maxTarget) maxTarget.dispatchEvent(new Event("input", { bubbles: true }));
}
updateHandles();
};
const onUp = (): void => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
this.onMouseMove = null;
this.onMouseUp = null;
};
this.onMouseMove = onMove;
this.onMouseUp = onUp;
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
onMove(event);
});
};
makeDraggable(minHandle, true);
makeDraggable(maxHandle, false);
// ── Sync from number inputs back to handles ──
function syncFromInputs(event?: Event): void {
if (mode === "point") {
const source =
(event?.target as HTMLInputElement | null) || minTarget || maxTarget;
const value = source ? source.value : "";
setTargetValue(minTarget, value);
setTargetValue(maxTarget, value);
} else if (event && event.target) {
const minValue = getTargetValue(minTarget, dataMin);
const maxValue = getTargetValue(maxTarget, dataMax);
if (event.target === minTarget) {
if (minValue > maxValue) {
setTargetValue(maxTarget, minValue);
}
} else if (event.target === maxTarget) {
if (maxValue < minValue) {
setTargetValue(minTarget, maxValue);
}
}
}
updateHandles();
}
function enforceStrictBounds(event: Event): void {
const target = event.target as HTMLInputElement | null;
if (target) {
const value = parseInt(target.value, 10);
if (!isNaN(value)) {
const clamped = clamp(value, dataMin, dataMax);
if (clamped !== value) {
setTargetValue(target, clamped);
target.dispatchEvent(new Event("input", { bubbles: true }));
}
}
}
}
if (minTarget) {
minTarget.addEventListener("input", syncFromInputs);
minTarget.addEventListener("change", enforceStrictBounds);
}
if (maxTarget) {
maxTarget.addEventListener("input", syncFromInputs);
maxTarget.addEventListener("change", enforceStrictBounds);
}
// ── Mode toggle ──
const toggleButton = this.querySelector<HTMLElement>(".range-mode-toggle");
if (toggleButton) {
toggleButton.addEventListener("click", () => {
const newMode = mode === "range" ? "point" : "range";
this.setAttribute("mode", newMode);
// Swap toggle icons
const iconRange = toggleButton.querySelector(".range-mode-icon-range");
const iconPoint = toggleButton.querySelector(".range-mode-icon-point");
if (iconRange) iconRange.classList.toggle("hidden");
if (iconPoint) iconPoint.classList.toggle("hidden");
const dashSpan = this.querySelector(".range-dash");
if (newMode === "point") {
minHandle.style.display = "none";
setTargetValue(minTarget, maxTarget ? maxTarget.value : "");
if (minTarget) minTarget.classList.add("hidden");
if (dashSpan) dashSpan.classList.add("hidden");
} else {
minHandle.style.display = "";
if (minTarget) minTarget.classList.remove("hidden");
if (dashSpan) dashSpan.classList.remove("hidden");
}
mode = newMode;
updateHandles();
});
}
// ── Initial position ──
updateHandles();
}
disconnectedCallback(): void {
if (this.onMouseMove) {
document.removeEventListener("mousemove", this.onMouseMove);
this.onMouseMove = null;
}
if (this.onMouseUp) {
document.removeEventListener("mouseup", this.onMouseUp);
this.onMouseUp = null;
}
}
}
customElements.define("range-slider", RangeSliderElement);