Convert onSwap widgets to custom elements (issue #18)
Replaces the four onSwap-based widgets with TypeScript custom elements following the pattern from PR #16. Each widget gets a class extending HTMLElement with connectedCallback/disconnectedCallback, typed props via register_element + gen_element_types codegen, and lives in ts/elements/. - range-slider: RangeSliderElement; Python uses _RangeSlider builder - date-range-picker: DateRangePickerElement; Python uses _DateRangePicker builder - search-select: SearchSelectElement; Python uses _SearchSelect builder; data-* attrs become plain attrs (data-name -> name, data-search-url -> search-url, etc.) - filter-bar: FilterBarElement; props carry preset URLs; onclick/onsubmit attrs replaced with data-filter-bar-* sentinel attrs; all window.* globals removed Deletes ts/range_slider.ts, ts/search_select.ts, ts/date_range_picker.ts, ts/filter_bar.ts. Updates all tests and e2e pages to use the new element selectors and script paths (dist/elements/<tag>.js). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* DateRangePicker — custom element wrapping the vanilla TS implementation.
|
||||
*
|
||||
* Drives the DateRangePicker component (common/components/date_range_picker.py):
|
||||
*
|
||||
* - DateRangeField: segmented manual entry. Each date part (DD/MM/YYYY) is its
|
||||
* own input; digits fill the placeholder from the right (YYYY → YYY1 → YY19
|
||||
* → Y198 → 1987), full parts auto-advance to the next one, and
|
||||
* Backspace/Delete reverts the active part to its placeholder.
|
||||
* - DateRangeCalendar: popup month grid with a preset column and a
|
||||
* Cancel / Clear / Select footer. Picking works anchor-style: the first
|
||||
* pick becomes the StartDate anchor, the second pick sets the EndDate and
|
||||
* moves the anchor there so further picks adjust the StartDate. Picking on
|
||||
* the wrong side of the anchor clears the range and restarts from the
|
||||
* clicked date.
|
||||
*
|
||||
* The committed value lives in the two hidden ISO inputs ({prefix}-min /
|
||||
* {prefix}-max) that filter_bar.ts serializes into a DateCriterion.
|
||||
*
|
||||
* NB: class strings below are emitted verbatim so the Tailwind scanner picks
|
||||
* them up — keep them as plain literals.
|
||||
*/
|
||||
|
||||
type Anchor = "" | "start" | "end";
|
||||
|
||||
interface CalendarState {
|
||||
open: boolean;
|
||||
viewYear: number;
|
||||
viewMonth: number;
|
||||
startIso: string;
|
||||
endIso: string;
|
||||
// The anchor is the fixed endpoint: "start" while picking the EndDate,
|
||||
// "end" once the range is complete (further picks move the StartDate).
|
||||
anchor: Anchor;
|
||||
hoverIso: string;
|
||||
// True while showing a committed range the user has not edited yet —
|
||||
// the track renders muted until the first pick.
|
||||
readOnly: boolean;
|
||||
refreshFromField: () => void;
|
||||
}
|
||||
|
||||
const WEEKDAY_LABELS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
||||
|
||||
const WEEKDAY_CLASS =
|
||||
"w-8 h-6 flex items-center justify-center text-xs text-body select-none";
|
||||
const DAY_BASE_CLASS =
|
||||
"date-range-day w-8 h-8 flex items-center justify-center text-sm " +
|
||||
"text-heading cursor-pointer hover:bg-neutral-tertiary-medium";
|
||||
const DAY_ROUNDED_CLASS = "rounded-base";
|
||||
const DAY_OUTSIDE_MONTH_CLASS = "opacity-40";
|
||||
const DAY_SELECTED_CLASS = "bg-brand text-white hover:bg-brand-strong";
|
||||
const DAY_ANCHOR_CLASS =
|
||||
"bg-brand text-white ring-2 ring-inset ring-brand-strong hover:bg-brand-strong";
|
||||
// The three visual states of the date range track (the days between the
|
||||
// two endpoints): outlined while picking the second date, filled once both
|
||||
// are picked, muted when showing an already-committed range read-only.
|
||||
const TRACK_OUTLINED_CLASS = "border-y border-brand/70 bg-brand/10";
|
||||
const TRACK_FILLED_CLASS = "bg-brand/30";
|
||||
const TRACK_MUTED_CLASS = "bg-brand/15";
|
||||
|
||||
// ── Date helpers (all local-time; values are ISO YYYY-MM-DD strings) ──
|
||||
|
||||
function padNumber(value: number, width: number): string {
|
||||
let text = String(value);
|
||||
while (text.length < width) text = "0" + text;
|
||||
return text;
|
||||
}
|
||||
|
||||
function isoFromDate(dateObject: Date): string {
|
||||
return (
|
||||
padNumber(dateObject.getFullYear(), 4) +
|
||||
"-" +
|
||||
padNumber(dateObject.getMonth() + 1, 2) +
|
||||
"-" +
|
||||
padNumber(dateObject.getDate(), 2)
|
||||
);
|
||||
}
|
||||
|
||||
function dateFromIso(isoString: string): Date {
|
||||
const pieces = isoString.split("-");
|
||||
return new Date(
|
||||
parseInt(pieces[0], 10),
|
||||
parseInt(pieces[1], 10) - 1,
|
||||
parseInt(pieces[2], 10)
|
||||
);
|
||||
}
|
||||
|
||||
function addDays(dateObject: Date, dayCount: number): Date {
|
||||
const copy = new Date(dateObject.getTime());
|
||||
copy.setDate(copy.getDate() + dayCount);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Validate a (year, month, day) triple as a real calendar date. */
|
||||
function isoFromParts(year: number, month: number, day: number): string {
|
||||
const candidate = new Date(year, month - 1, day);
|
||||
if (
|
||||
candidate.getFullYear() !== year ||
|
||||
candidate.getMonth() !== month - 1 ||
|
||||
candidate.getDate() !== day
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
return isoFromDate(candidate);
|
||||
}
|
||||
|
||||
function presetRange(presetName: string): [Date, Date] | null {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const yesterday = addDays(today, -1);
|
||||
const year = today.getFullYear();
|
||||
const month = today.getMonth();
|
||||
switch (presetName) {
|
||||
case "today":
|
||||
return [today, today];
|
||||
case "yesterday":
|
||||
return [yesterday, yesterday];
|
||||
case "last_7_days":
|
||||
return [addDays(today, -6), today];
|
||||
case "last_30_days":
|
||||
return [addDays(today, -29), today];
|
||||
case "this_month":
|
||||
return [new Date(year, month, 1), new Date(year, month + 1, 0)];
|
||||
case "last_month":
|
||||
return [new Date(year, month - 1, 1), new Date(year, month, 0)];
|
||||
case "this_year":
|
||||
return [new Date(year, 0, 1), new Date(year, 11, 31)];
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── DateRangeField: segmented manual entry ──────────────────────────────
|
||||
|
||||
function segmentBuffer(segment: HTMLInputElement): string {
|
||||
return segment.dataset.typedDigits || "";
|
||||
}
|
||||
|
||||
function setSegmentBuffer(segment: HTMLInputElement, buffer: string): void {
|
||||
segment.dataset.typedDigits = buffer;
|
||||
if (buffer === "") {
|
||||
segment.value = "";
|
||||
return;
|
||||
}
|
||||
const placeholder = segment.getAttribute("placeholder") ?? "";
|
||||
// Fill the placeholder from the right: typing 19 into YYYY shows YY19.
|
||||
segment.value = placeholder.slice(0, placeholder.length - buffer.length) + buffer;
|
||||
}
|
||||
|
||||
function segmentsForSide(picker: HTMLElement, side: string): HTMLInputElement[] {
|
||||
return Array.from(
|
||||
picker.querySelectorAll<HTMLInputElement>(`input[data-date-side="${side}"]`)
|
||||
);
|
||||
}
|
||||
|
||||
/** Recompute one hidden ISO input from its side's segment buffers. */
|
||||
function syncHiddenFromSegments(picker: HTMLElement, side: string): boolean {
|
||||
const hidden = picker.querySelector<HTMLInputElement>(
|
||||
`input[data-date-range-hidden="${side}"]`
|
||||
)!;
|
||||
const partValues: Record<string, string> = {};
|
||||
let complete = true;
|
||||
segmentsForSide(picker, side).forEach((segment) => {
|
||||
const buffer = segmentBuffer(segment);
|
||||
if (buffer.length !== parseInt(segment.getAttribute("maxlength") ?? "", 10)) {
|
||||
complete = false;
|
||||
}
|
||||
partValues[segment.dataset.datePart ?? ""] = buffer;
|
||||
});
|
||||
const previousValue = hidden.value;
|
||||
if (complete) {
|
||||
hidden.value = isoFromParts(
|
||||
parseInt(partValues.year, 10),
|
||||
parseInt(partValues.month, 10),
|
||||
parseInt(partValues.day, 10)
|
||||
);
|
||||
} else {
|
||||
hidden.value = "";
|
||||
}
|
||||
return hidden.value !== previousValue;
|
||||
}
|
||||
|
||||
/** Push an ISO value (or "") into a side's segments and hidden input. */
|
||||
function setSideValue(picker: HTMLElement, side: string, isoString: string): void {
|
||||
const hidden = picker.querySelector<HTMLInputElement>(
|
||||
`input[data-date-range-hidden="${side}"]`
|
||||
)!;
|
||||
hidden.value = isoString;
|
||||
let partValues: Record<string, string> = { year: "", month: "", day: "" };
|
||||
if (isoString) {
|
||||
const pieces = isoString.split("-");
|
||||
partValues = { year: pieces[0], month: pieces[1], day: pieces[2] };
|
||||
}
|
||||
segmentsForSide(picker, side).forEach((segment) => {
|
||||
setSegmentBuffer(segment, partValues[segment.dataset.datePart ?? ""]);
|
||||
});
|
||||
}
|
||||
|
||||
function initField(picker: HTMLElement, calendarState: CalendarState): void {
|
||||
const field = picker.querySelector<HTMLElement>("[data-date-range-field]")!;
|
||||
const segments = Array.from(
|
||||
picker.querySelectorAll<HTMLInputElement>("input[data-date-part]")
|
||||
);
|
||||
|
||||
// Adopt server-rendered values (prefilled filter) as typed buffers.
|
||||
segments.forEach((segment) => {
|
||||
if (segment.value) setSegmentBuffer(segment, segment.value);
|
||||
});
|
||||
|
||||
// Clicking anywhere in the container that is not a date part activates
|
||||
// the first date part.
|
||||
field.addEventListener("mousedown", (event) => {
|
||||
const target = event.target as Element;
|
||||
if (target.closest("input[data-date-part]")) return;
|
||||
if (target.closest("[data-date-range-calendar-toggle]")) return;
|
||||
event.preventDefault();
|
||||
segments[0].focus();
|
||||
});
|
||||
|
||||
segments.forEach((segment, segmentIndex) => {
|
||||
segment.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Tab") return; // native Tab / Shift+Tab navigation
|
||||
if (event.key === "Enter") return; // let the filter form submit
|
||||
if (event.key === "Backspace" || event.key === "Delete") {
|
||||
event.preventDefault();
|
||||
setSegmentBuffer(segment, "");
|
||||
syncHiddenFromSegments(picker, segment.dataset.dateSide ?? "");
|
||||
return;
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
event.preventDefault();
|
||||
if (!/^[0-9]$/.test(event.key)) return; // only numbers can be typed
|
||||
const maximumLength = parseInt(segment.getAttribute("maxlength") ?? "", 10);
|
||||
let buffer = segmentBuffer(segment);
|
||||
// Typing into an already-full part starts it over.
|
||||
buffer = buffer.length >= maximumLength ? event.key : buffer + event.key;
|
||||
setSegmentBuffer(segment, buffer);
|
||||
syncHiddenFromSegments(picker, segment.dataset.dateSide ?? "");
|
||||
if (buffer.length === maximumLength && segmentIndex + 1 < segments.length) {
|
||||
segments[segmentIndex + 1].focus();
|
||||
}
|
||||
});
|
||||
// Swallow any input that bypassed keydown (e.g. IME/paste).
|
||||
segment.addEventListener("input", () => {
|
||||
setSegmentBuffer(segment, segmentBuffer(segment));
|
||||
});
|
||||
segment.addEventListener("focus", () => {
|
||||
if (calendarState) calendarState.refreshFromField();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── DateRangeCalendar: popup month grid ────────────────────────────────
|
||||
|
||||
function createCalendarState(
|
||||
picker: HTMLElement
|
||||
): { state: CalendarState; cleanup: () => void } {
|
||||
const popup = picker.querySelector<HTMLElement>("[data-date-range-calendar]")!;
|
||||
const grid = popup.querySelector<HTMLElement>("[data-date-range-grid]")!;
|
||||
const monthLabel = popup.querySelector<HTMLElement>("[data-date-range-month-label]")!;
|
||||
|
||||
const today = new Date();
|
||||
|
||||
function hiddenValue(side: string): string {
|
||||
return picker.querySelector<HTMLInputElement>(
|
||||
`input[data-date-range-hidden="${side}"]`
|
||||
)!.value;
|
||||
}
|
||||
|
||||
const state: CalendarState = {
|
||||
open: false,
|
||||
viewYear: today.getFullYear(),
|
||||
viewMonth: today.getMonth(),
|
||||
startIso: "",
|
||||
endIso: "",
|
||||
anchor: "",
|
||||
hoverIso: "",
|
||||
readOnly: false,
|
||||
refreshFromField() {
|
||||
if (state.open) return;
|
||||
state.startIso = hiddenValue("min");
|
||||
state.endIso = hiddenValue("max");
|
||||
},
|
||||
};
|
||||
|
||||
function syncSelectionToField(): void {
|
||||
setSideValue(picker, "min", state.startIso);
|
||||
setSideValue(picker, "max", state.endIso);
|
||||
}
|
||||
|
||||
function openPopup(): void {
|
||||
state.startIso = hiddenValue("min");
|
||||
state.endIso = hiddenValue("max");
|
||||
state.anchor = state.startIso && state.endIso ? "end" : state.startIso ? "start" : "";
|
||||
state.readOnly = Boolean(state.startIso && state.endIso);
|
||||
state.hoverIso = "";
|
||||
const focusDate = state.startIso ? dateFromIso(state.startIso) : new Date();
|
||||
state.viewYear = focusDate.getFullYear();
|
||||
state.viewMonth = focusDate.getMonth();
|
||||
state.open = true;
|
||||
popup.classList.remove("hidden");
|
||||
render();
|
||||
}
|
||||
|
||||
function closePopup(): void {
|
||||
state.open = false;
|
||||
state.hoverIso = "";
|
||||
popup.classList.add("hidden");
|
||||
}
|
||||
|
||||
function clearSelection(): void {
|
||||
state.startIso = "";
|
||||
state.endIso = "";
|
||||
state.anchor = "";
|
||||
state.hoverIso = "";
|
||||
state.readOnly = false;
|
||||
syncSelectionToField();
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor-style picking:
|
||||
* - no selection: the pick becomes the StartDate anchor
|
||||
* - anchor=start (picking EndDate): a pick on/after the StartDate
|
||||
* completes the range and moves the anchor to the EndDate; a pick
|
||||
* before it clears the range and restarts
|
||||
* - anchor=end (adjusting StartDate): a pick on/before the EndDate
|
||||
* moves the StartDate (extend/shorten); a pick after it clears the
|
||||
* range and restarts from the clicked date
|
||||
*/
|
||||
function pickDate(isoString: string): void {
|
||||
state.readOnly = false;
|
||||
if (!state.startIso) {
|
||||
state.startIso = isoString;
|
||||
state.anchor = "start";
|
||||
} else if (state.anchor === "start" && !state.endIso) {
|
||||
if (isoString >= state.startIso) {
|
||||
state.endIso = isoString;
|
||||
state.anchor = "end";
|
||||
} else {
|
||||
state.startIso = isoString;
|
||||
state.endIso = "";
|
||||
state.anchor = "start";
|
||||
}
|
||||
} else {
|
||||
if (isoString <= state.endIso) {
|
||||
state.startIso = isoString;
|
||||
} else {
|
||||
state.startIso = isoString;
|
||||
state.endIso = "";
|
||||
state.anchor = "start";
|
||||
}
|
||||
}
|
||||
syncSelectionToField();
|
||||
render();
|
||||
}
|
||||
|
||||
function applyPreset(presetName: string): void {
|
||||
const range = presetRange(presetName);
|
||||
if (!range) return;
|
||||
state.startIso = isoFromDate(range[0]);
|
||||
state.endIso = isoFromDate(range[1]);
|
||||
state.anchor = "end";
|
||||
state.readOnly = false;
|
||||
state.viewYear = range[0].getFullYear();
|
||||
state.viewMonth = range[0].getMonth();
|
||||
syncSelectionToField();
|
||||
render();
|
||||
}
|
||||
|
||||
/** The (inclusive-exclusive of endpoints) track between the two range
|
||||
* ends; while picking the second date the hovered day acts as the
|
||||
* provisional other end. */
|
||||
function trackBounds(): [string, string, string] | null {
|
||||
if (state.startIso && state.endIso) {
|
||||
return [
|
||||
state.startIso,
|
||||
state.endIso,
|
||||
state.readOnly ? TRACK_MUTED_CLASS : TRACK_FILLED_CLASS,
|
||||
];
|
||||
}
|
||||
if (state.startIso && state.hoverIso && state.hoverIso !== state.startIso) {
|
||||
const lower = state.hoverIso < state.startIso ? state.hoverIso : state.startIso;
|
||||
const upper = state.hoverIso < state.startIso ? state.startIso : state.hoverIso;
|
||||
return [lower, upper, TRACK_OUTLINED_CLASS];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dayCellClass(isoString: string, inViewMonth: boolean): string {
|
||||
const classes = [DAY_BASE_CLASS];
|
||||
const isStart = isoString === state.startIso;
|
||||
const isEnd = isoString === state.endIso;
|
||||
const isAnchor =
|
||||
(state.anchor === "start" && isStart) || (state.anchor === "end" && isEnd);
|
||||
const track = trackBounds();
|
||||
const inTrack = track !== null && isoString > track[0] && isoString < track[1];
|
||||
if (inTrack) {
|
||||
classes.push(track![2]);
|
||||
} else {
|
||||
classes.push(DAY_ROUNDED_CLASS);
|
||||
}
|
||||
if (isAnchor && !state.readOnly) {
|
||||
classes.push(DAY_ANCHOR_CLASS);
|
||||
} else if (isStart || isEnd) {
|
||||
classes.push(DAY_SELECTED_CLASS);
|
||||
} else if (!inViewMonth) {
|
||||
classes.push(DAY_OUTSIDE_MONTH_CLASS);
|
||||
}
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
monthLabel.textContent = new Date(
|
||||
state.viewYear,
|
||||
state.viewMonth,
|
||||
1
|
||||
).toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||
|
||||
grid.textContent = "";
|
||||
WEEKDAY_LABELS.forEach((weekdayLabel) => {
|
||||
const headerCell = document.createElement("span");
|
||||
headerCell.className = WEEKDAY_CLASS;
|
||||
headerCell.textContent = weekdayLabel;
|
||||
grid.appendChild(headerCell);
|
||||
});
|
||||
|
||||
const firstOfMonth = new Date(state.viewYear, state.viewMonth, 1);
|
||||
// Monday-first offset of the leading overflow days.
|
||||
const leadingDays = (firstOfMonth.getDay() + 6) % 7;
|
||||
let cellDate = addDays(firstOfMonth, -leadingDays);
|
||||
for (let cellIndex = 0; cellIndex < 42; cellIndex++) {
|
||||
const isoString = isoFromDate(cellDate);
|
||||
const dayButton = document.createElement("button");
|
||||
dayButton.type = "button";
|
||||
dayButton.setAttribute("data-date", isoString);
|
||||
dayButton.className = dayCellClass(
|
||||
isoString,
|
||||
cellDate.getMonth() === state.viewMonth
|
||||
);
|
||||
dayButton.textContent = String(cellDate.getDate());
|
||||
grid.appendChild(dayButton);
|
||||
cellDate = addDays(cellDate, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wiring ──
|
||||
picker
|
||||
.querySelector<HTMLElement>("[data-date-range-calendar-toggle]")!
|
||||
.addEventListener("click", () => {
|
||||
if (state.open) closePopup();
|
||||
else openPopup();
|
||||
});
|
||||
|
||||
grid.addEventListener("click", (event) => {
|
||||
const dayButton = (event.target as Element).closest("button[data-date]");
|
||||
if (dayButton) pickDate(dayButton.getAttribute("data-date") ?? "");
|
||||
});
|
||||
|
||||
grid.addEventListener("mouseover", (event) => {
|
||||
if (!state.startIso || state.endIso) return;
|
||||
const dayButton = (event.target as Element).closest("button[data-date]");
|
||||
if (!dayButton) return;
|
||||
const hoveredIso = dayButton.getAttribute("data-date") ?? "";
|
||||
if (hoveredIso === state.hoverIso) return;
|
||||
state.hoverIso = hoveredIso;
|
||||
render();
|
||||
});
|
||||
|
||||
popup
|
||||
.querySelector<HTMLElement>("[data-date-range-prev]")!
|
||||
.addEventListener("click", () => {
|
||||
state.viewMonth -= 1;
|
||||
if (state.viewMonth < 0) {
|
||||
state.viewMonth = 11;
|
||||
state.viewYear -= 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
popup
|
||||
.querySelector<HTMLElement>("[data-date-range-next]")!
|
||||
.addEventListener("click", () => {
|
||||
state.viewMonth += 1;
|
||||
if (state.viewMonth > 11) {
|
||||
state.viewMonth = 0;
|
||||
state.viewYear += 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
popup.querySelectorAll<HTMLElement>("[data-date-range-preset]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
applyPreset(button.getAttribute("data-date-range-preset") ?? "");
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel: close the popup and clear the selected dates.
|
||||
popup
|
||||
.querySelector<HTMLElement>("[data-date-range-cancel]")!
|
||||
.addEventListener("click", () => {
|
||||
clearSelection();
|
||||
closePopup();
|
||||
});
|
||||
|
||||
// Clear: clear the selected dates but keep the popup open.
|
||||
popup
|
||||
.querySelector<HTMLElement>("[data-date-range-clear]")!
|
||||
.addEventListener("click", () => {
|
||||
clearSelection();
|
||||
render();
|
||||
});
|
||||
|
||||
// Select: close the popup, keeping the selected dates.
|
||||
popup
|
||||
.querySelector<HTMLElement>("[data-date-range-select]")!
|
||||
.addEventListener("click", () => {
|
||||
closePopup();
|
||||
});
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === "Escape" && state.open) closePopup();
|
||||
};
|
||||
const onMouseDown = (event: MouseEvent): void => {
|
||||
if (state.open && !picker.contains(event.target as Node)) closePopup();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
|
||||
return {
|
||||
state,
|
||||
cleanup() {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.removeEventListener("mousedown", onMouseDown);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function initPicker(picker: HTMLElement): () => void {
|
||||
const { state: calendarState, cleanup } = createCalendarState(picker);
|
||||
initField(picker, calendarState);
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
class DateRangePickerElement extends HTMLElement {
|
||||
private cleanup: (() => void) | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
this.cleanup = initPicker(this);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.cleanup?.();
|
||||
this.cleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("date-range-picker", DateRangePickerElement);
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* FilterBar — custom element wrapping the collapsible filter bar.
|
||||
*
|
||||
* Handles form submission (building filter JSON + URL navigation), preset
|
||||
* loading/saving, and string-filter input toggling. Props (preset_list_url,
|
||||
* preset_save_url) are read from the element's typed attributes via codegen.
|
||||
*/
|
||||
import { readFilterBarProps } from "../generated/props.js";
|
||||
import { readSearchSelect } from "./search-select.js";
|
||||
|
||||
interface Criterion {
|
||||
value: unknown;
|
||||
modifier: string;
|
||||
value2?: unknown;
|
||||
}
|
||||
|
||||
interface PillEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
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 !== "") {
|
||||
result.value2 = value2;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function numberValue(form: HTMLElement, name: string): number | "" {
|
||||
const element = form.querySelector<HTMLInputElement>(`[name="${name}"]`);
|
||||
if (!element || element.value === "") return "";
|
||||
const value = parseFloat(element.value);
|
||||
return isNaN(value) ? "" : value;
|
||||
}
|
||||
|
||||
function stringValue(form: HTMLElement, name: string): string {
|
||||
const element = form.querySelector<HTMLInputElement>(`[name="${name}"]`);
|
||||
return element ? element.value : "";
|
||||
}
|
||||
|
||||
function buildRangeCriterion(
|
||||
valueMin: number | string,
|
||||
valueMax: number | string,
|
||||
): Criterion | null {
|
||||
if (valueMin !== "" && valueMax !== "") return criterion(valueMin, valueMax, "BETWEEN");
|
||||
if (valueMin !== "") return criterion(valueMin, null, "GREATER_THAN");
|
||||
if (valueMax !== "") return criterion(valueMax, null, "LESS_THAN");
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJSONAttr<T>(element: Element, attr: string): T[] {
|
||||
const raw = element.getAttribute(attr);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function baseUrl(): string {
|
||||
return window.location.pathname;
|
||||
}
|
||||
|
||||
function presetMode(): string {
|
||||
const path = window.location.pathname;
|
||||
if (path.indexOf("session") !== -1) return "sessions";
|
||||
if (path.indexOf("purchase") !== -1) return "purchases";
|
||||
if (path.indexOf("device") !== -1) return "devices";
|
||||
if (path.indexOf("platform") !== -1) return "platforms";
|
||||
if (path.indexOf("playevent") !== -1) return "playevents";
|
||||
return "games";
|
||||
}
|
||||
|
||||
function getCsrfToken(): string {
|
||||
const cookie = document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith("csrftoken="));
|
||||
if (cookie) return cookie.split("=")[1];
|
||||
const element = document.querySelector<HTMLInputElement>('input[name="csrfmiddlewaretoken"]');
|
||||
return element ? element.value : "";
|
||||
}
|
||||
|
||||
function buildFilterJSON(form: HTMLElement): Record<string, unknown> {
|
||||
const filter: Record<string, unknown> = {};
|
||||
|
||||
const searchInput = form.querySelector<HTMLInputElement>('[name="filter-search"]');
|
||||
if (searchInput && searchInput.value.trim()) {
|
||||
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
|
||||
}
|
||||
|
||||
readSearchSelect(form);
|
||||
const widgets = form.querySelectorAll<HTMLElement>('search-select[filter-mode="true"]');
|
||||
widgets.forEach((widget) => {
|
||||
const field = widget.getAttribute("name");
|
||||
if (!field) return;
|
||||
const included = parseJSONAttr<PillEntry>(widget, "data-included");
|
||||
const excluded = parseJSONAttr<PillEntry>(widget, "data-excluded");
|
||||
const modifier = widget.getAttribute("data-modifier");
|
||||
const isPresence = modifier === "NOT_NULL" || modifier === "IS_NULL";
|
||||
if (isPresence) {
|
||||
filter[field] = { modifier };
|
||||
} else if (included.length > 0 || excluded.length > 0) {
|
||||
filter[field] = {
|
||||
value: included.map((item) => ({ id: item.id, label: item.label })),
|
||||
excludes: excluded.map((item) => ({ id: item.id, label: item.label })),
|
||||
modifier: modifier || "INCLUDES",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const textFields = [
|
||||
{ name: "filter-price_currency", key: "price_currency" },
|
||||
{ name: "filter-converted_currency", key: "converted_currency" },
|
||||
{ name: "filter-name", key: "name" },
|
||||
{ name: "filter-group", key: "group" },
|
||||
{ name: "filter-playevent_note", key: "playevent_note" },
|
||||
{ name: "filter-note", key: "note" },
|
||||
];
|
||||
textFields.forEach((textField) => {
|
||||
const modifierElement = form.querySelector<HTMLInputElement>(
|
||||
`[name="${textField.name}-modifier"]:checked`,
|
||||
);
|
||||
const modifier = modifierElement ? modifierElement.value : "EQUALS";
|
||||
const isPresence = modifier === "IS_NULL" || modifier === "NOT_NULL";
|
||||
if (isPresence) {
|
||||
filter[textField.key] = { modifier };
|
||||
} else {
|
||||
const element = form.querySelector<HTMLInputElement>(`[name="${textField.name}"]`);
|
||||
if (element && element.value.trim()) {
|
||||
filter[textField.key] = { value: element.value.trim(), modifier };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const booleanFields = [
|
||||
{ name: "filter-mastered", key: "mastered" },
|
||||
{ name: "filter-emulated", key: "emulated" },
|
||||
{ name: "filter-active", key: "is_active" },
|
||||
{ name: "filter-refunded", key: "is_refunded" },
|
||||
{ name: "filter-infinite", key: "infinite" },
|
||||
{ name: "filter-needs-price-update", key: "needs_price_update" },
|
||||
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
|
||||
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
|
||||
{ name: "filter-session-emulated", key: "session_emulated" },
|
||||
];
|
||||
booleanFields.forEach((booleanField) => {
|
||||
const element = form.querySelector<HTMLInputElement>(
|
||||
`[name="${booleanField.name}"]:checked`,
|
||||
);
|
||||
if (element) {
|
||||
const value = element.value === "true";
|
||||
filter[booleanField.key] = criterion(value, null, "EQUALS");
|
||||
}
|
||||
});
|
||||
|
||||
const rangeFields: RangeField[] = [
|
||||
{ prefix: "filter-year", key: "year_released" },
|
||||
{ prefix: "filter-original-year", key: "original_year_released" },
|
||||
{ prefix: "filter-session-count", key: "session_count" },
|
||||
{ prefix: "filter-session-average", key: "session_average" },
|
||||
{ prefix: "filter-purchase-count", key: "purchase_count" },
|
||||
{ prefix: "filter-playevent-count", key: "playevent_count" },
|
||||
{ prefix: "filter-duration-total-hours", key: "duration_total_hours" },
|
||||
{ prefix: "filter-duration-manual-hours", key: "duration_manual_hours" },
|
||||
{ prefix: "filter-duration-calculated-hours", key: "duration_calculated_hours" },
|
||||
{ prefix: "filter-manual-playtime-hours", key: "manual_playtime_hours" },
|
||||
{ prefix: "filter-calculated-playtime-hours", key: "calculated_playtime_hours" },
|
||||
{ prefix: "filter-num-purchases", key: "num_purchases" },
|
||||
{ prefix: "filter-price", key: "price" },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
if (rangeField.ignoreZeroZero && valueMin === 0 && valueMax === 0) return;
|
||||
const result = buildRangeCriterion(valueMin, valueMax);
|
||||
if (result !== null) filter[rangeField.key] = result;
|
||||
});
|
||||
|
||||
const dateRangeFields = [
|
||||
{ prefix: "filter-date-purchased", key: "date_purchased" },
|
||||
{ prefix: "filter-date-refunded", key: "date_refunded" },
|
||||
];
|
||||
dateRangeFields.forEach((dateField) => {
|
||||
const valueMin = stringValue(form, dateField.prefix + "-min");
|
||||
const valueMax = stringValue(form, dateField.prefix + "-max");
|
||||
const result = buildRangeCriterion(valueMin, valueMax);
|
||||
if (result !== null) filter[dateField.key] = result;
|
||||
});
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
function injectSearchInput(form: HTMLElement): void {
|
||||
if (form.querySelector('[name="filter-search"]')) return;
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.name = "filter-search";
|
||||
input.placeholder = "Search…";
|
||||
input.className =
|
||||
"block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
|
||||
const hidden = form.querySelector<HTMLInputElement>('[name="filter"]');
|
||||
if (hidden && hidden.parentNode) {
|
||||
try {
|
||||
const existing = JSON.parse(hidden.value || "{}");
|
||||
if (existing.search && existing.search.value) {
|
||||
input.value = existing.search.value;
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed existing filter JSON
|
||||
}
|
||||
hidden.parentNode.insertBefore(input, hidden.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
function setupDeselectableRadios(root: HTMLElement): void {
|
||||
root.querySelectorAll<DeselectableRadio>('input[type="radio"]').forEach((radio) => {
|
||||
radio.addEventListener("click", function (this: DeselectableRadio) {
|
||||
if (this.wasChecked) {
|
||||
this.checked = false;
|
||||
this.wasChecked = false;
|
||||
this.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
} else {
|
||||
const name = this.getAttribute("name");
|
||||
if (name) {
|
||||
root
|
||||
.querySelectorAll<DeselectableRadio>(`input[type="radio"][name="${name}"]`)
|
||||
.forEach((other) => {
|
||||
other.wasChecked = false;
|
||||
});
|
||||
}
|
||||
this.wasChecked = true;
|
||||
}
|
||||
});
|
||||
if (radio.checked) {
|
||||
radio.wasChecked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleStringFilterInput(radio: HTMLInputElement): void {
|
||||
const container = radio.closest(".flex-col");
|
||||
if (!container) return;
|
||||
const textInput = container.querySelector<HTMLInputElement>('input[type="text"]');
|
||||
if (!textInput) return;
|
||||
const checkedRadio = container.querySelector<HTMLInputElement>('input[type="radio"]:checked');
|
||||
const value = checkedRadio ? checkedRadio.value : "";
|
||||
if (value === "IS_NULL" || value === "NOT_NULL") {
|
||||
textInput.disabled = true;
|
||||
textInput.value = "";
|
||||
textInput.classList.add("opacity-50", "cursor-not-allowed");
|
||||
} else {
|
||||
textInput.disabled = false;
|
||||
textInput.classList.remove("opacity-50", "cursor-not-allowed");
|
||||
}
|
||||
}
|
||||
|
||||
function setupStringFilters(root: HTMLElement): void {
|
||||
root
|
||||
.querySelectorAll<HTMLInputElement>("input[data-string-modifier-radio]")
|
||||
.forEach((radio) => {
|
||||
radio.addEventListener("change", function (this: HTMLInputElement) {
|
||||
toggleStringFilterInput(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupPresetDeleteHandlers(container: HTMLElement): void {
|
||||
const deleteLinks = container.querySelectorAll<HTMLAnchorElement>("[data-delete-preset]");
|
||||
deleteLinks.forEach((link) => {
|
||||
link.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
const deleteUrl = link.getAttribute("href");
|
||||
if (!deleteUrl) return;
|
||||
if (!confirm("Delete this preset?")) return;
|
||||
fetch(deleteUrl, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "X-CSRFToken": getCsrfToken() },
|
||||
})
|
||||
.then(() => {
|
||||
const listItem = link.closest("li");
|
||||
if (listItem) listItem.remove();
|
||||
const list = container.querySelector("ul");
|
||||
if (list && list.querySelectorAll("li").length === 0) {
|
||||
list.innerHTML =
|
||||
'<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>';
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Delete failed:", error);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadPresets(root: HTMLElement, presetListUrl: string): void {
|
||||
const dropdown = root.querySelector<HTMLElement>("#preset-dropdown");
|
||||
if (!dropdown) return;
|
||||
|
||||
const mode = presetMode();
|
||||
let query = "";
|
||||
if (presetListUrl.indexOf("mode=") === -1) {
|
||||
query = (presetListUrl.indexOf("?") !== -1 ? "&" : "?") + "mode=" + mode;
|
||||
}
|
||||
|
||||
fetch(presetListUrl + query, { credentials: "same-origin" })
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error("Failed to load presets");
|
||||
return response.text();
|
||||
})
|
||||
.then((html) => {
|
||||
dropdown.innerHTML = html;
|
||||
setupPresetDeleteHandlers(dropdown);
|
||||
})
|
||||
.catch((error) => {
|
||||
dropdown.innerHTML =
|
||||
'<span class="text-sm text-body italic">Presets unavailable</span>';
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
function showPresetNameInput(root: HTMLElement): void {
|
||||
const input = root.querySelector<HTMLElement>("[data-filter-bar-preset-name]");
|
||||
const saveButton = root.querySelector<HTMLElement>("[data-filter-bar-save]");
|
||||
const confirmButton = root.querySelector<HTMLElement>("[data-filter-bar-confirm-save]");
|
||||
if (input) input.classList.remove("hidden");
|
||||
if (saveButton) saveButton.classList.add("hidden");
|
||||
if (confirmButton) confirmButton.classList.remove("hidden");
|
||||
if (input instanceof HTMLElement) input.focus();
|
||||
}
|
||||
|
||||
function savePreset(
|
||||
form: HTMLElement,
|
||||
presetSaveUrl: string,
|
||||
presetListUrl: string,
|
||||
root: HTMLElement,
|
||||
): void {
|
||||
const input = root.querySelector<HTMLInputElement>("[data-filter-bar-preset-name]");
|
||||
const name = input ? input.value.trim() : "";
|
||||
if (!name) {
|
||||
if (input) input.classList.add("border-red-500");
|
||||
return;
|
||||
}
|
||||
|
||||
const filterObject = buildFilterJSON(form);
|
||||
const body = new URLSearchParams();
|
||||
body.append("name", name);
|
||||
body.append("mode", presetMode());
|
||||
body.append("filter", JSON.stringify(filterObject));
|
||||
|
||||
fetch(presetSaveUrl, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-CSRFToken": getCsrfToken(),
|
||||
},
|
||||
body: body.toString(),
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error("Save failed");
|
||||
if (input) {
|
||||
input.value = "";
|
||||
input.classList.add("hidden");
|
||||
input.classList.remove("border-red-500");
|
||||
}
|
||||
const saveButton = root.querySelector<HTMLElement>("[data-filter-bar-save]");
|
||||
const confirmButton = root.querySelector<HTMLElement>("[data-filter-bar-confirm-save]");
|
||||
if (saveButton) saveButton.classList.remove("hidden");
|
||||
if (confirmButton) confirmButton.classList.add("hidden");
|
||||
loadPresets(root, presetListUrl);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to save preset:", error);
|
||||
});
|
||||
}
|
||||
|
||||
class FilterBarElement extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
const { presetListUrl, presetSaveUrl } = readFilterBarProps(this);
|
||||
const form = this.querySelector<HTMLFormElement>("form");
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const filter = buildFilterJSON(form);
|
||||
const filterString = JSON.stringify(filter);
|
||||
let url = baseUrl();
|
||||
if (filterString && filterString !== "{}") {
|
||||
url += "?filter=" + encodeURIComponent(filterString);
|
||||
}
|
||||
window.location.href = url;
|
||||
});
|
||||
|
||||
this.querySelector("[data-filter-bar-clear]")?.addEventListener("click", () => {
|
||||
form.reset();
|
||||
window.location.href = baseUrl();
|
||||
});
|
||||
|
||||
this.querySelector("[data-filter-bar-save]")?.addEventListener("click", () => {
|
||||
showPresetNameInput(this);
|
||||
});
|
||||
|
||||
this.querySelector("[data-filter-bar-confirm-save]")?.addEventListener("click", () => {
|
||||
savePreset(form, presetSaveUrl, presetListUrl, this);
|
||||
});
|
||||
|
||||
injectSearchInput(form);
|
||||
setupDeselectableRadios(this);
|
||||
setupStringFilters(this);
|
||||
if (presetListUrl) loadPresets(this, presetListUrl);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("filter-bar", FilterBarElement);
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 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);
|
||||
@@ -0,0 +1,713 @@
|
||||
/**
|
||||
* SearchSelect — custom element wrapping the search-select widget.
|
||||
*
|
||||
* A search box paired with a dropdown of options. Multi-select renders chosen
|
||||
* items as removable pills (inline with the search box), each backed by a
|
||||
* hidden <input>. Single-select renders no pill: the committed label lives
|
||||
* inside the search box (which doubles as a combobox — focus clears it to
|
||||
* search, picking an option fills it), with a lone hidden <input> carrying the
|
||||
* value. Both keep hidden inputs so Django validation works.
|
||||
*
|
||||
* Filter mode (filter-mode="true", rendered by FilterSelect): value rows carry
|
||||
* +/− buttons that add include (✓) / exclude (✗) pills, plus pinned modifier
|
||||
* pseudo-options ((Any)/(None)) that are mutually exclusive with value pills.
|
||||
* Filter widgets have no hidden inputs; readSearchSelect serialises their state
|
||||
* into data-included / data-excluded / data-modifier for the filter bar.
|
||||
*
|
||||
* Dynamically-added rows and pills are cloned from hidden <template> elements
|
||||
* the server renders with the same Python components (Pill / SearchSelect /
|
||||
* FilterSelect). The JS only fills in the label slot ([data-search-select-label]),
|
||||
* value, and data-* attributes — so all markup and Tailwind class strings live
|
||||
* in one place (the Python components), never duplicated here.
|
||||
*/
|
||||
|
||||
// The contract for the "search-select:change" CustomEvent this widget emits.
|
||||
// Consumers (e.g. add_purchase.ts) import these types — never redefine them.
|
||||
export interface SearchSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
data: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SearchSelectChangeDetail {
|
||||
name: string;
|
||||
values: string[];
|
||||
last: SearchSelectOption | null;
|
||||
}
|
||||
|
||||
// The widget stashes per-instance state directly on its DOM elements.
|
||||
interface SearchSelectContainer extends HTMLElement {
|
||||
_searchSelectLabel?: string;
|
||||
_searchSelectDirty?: boolean;
|
||||
}
|
||||
|
||||
interface OptionRow extends HTMLElement {
|
||||
_searchSelectOption?: SearchSelectOption;
|
||||
}
|
||||
|
||||
interface FilterPillEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 100;
|
||||
|
||||
// Must match Python common/components/filters.py:_PRESENCE_MODIFIERS.
|
||||
// These modifiers are mutually exclusive with value pills — selecting
|
||||
// one clears all value pills. Non-presence modifiers (INCLUDES_ALL,
|
||||
// INCLUDES_ONLY) coexist with value pills.
|
||||
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
|
||||
|
||||
const initWidget = (containerElement: Element) => {
|
||||
const container = containerElement as SearchSelectContainer;
|
||||
const search = container.querySelector<HTMLInputElement>("[data-search-select-search]");
|
||||
const options = container.querySelector<HTMLElement>("[data-search-select-options]");
|
||||
const pills = container.querySelector<HTMLElement>("[data-search-select-pills]");
|
||||
if (!search || !options || !pills) return;
|
||||
|
||||
const name = container.getAttribute("name") ?? "";
|
||||
const searchUrl = container.getAttribute("search-url");
|
||||
const isFilter = container.getAttribute("filter-mode") === "true";
|
||||
const freeText = container.getAttribute("free-text") === "true";
|
||||
const multi = container.getAttribute("multi") === "true";
|
||||
const alwaysVisible = container.getAttribute("always-visible") === "true";
|
||||
const prefetch = parseInt(container.getAttribute("prefetch") ?? "", 10) || 0;
|
||||
const syncUrl = container.getAttribute("sync-url") === "true";
|
||||
|
||||
const noResults = options.querySelector<HTMLElement>("[data-search-select-no-results]");
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingRequest: AbortController | null = null; // in-flight, so newer queries win
|
||||
let hasPrefetched = false;
|
||||
|
||||
const hasVisibleContent = () => {
|
||||
const optionRows = options.querySelectorAll<HTMLElement>("[data-search-select-option]");
|
||||
for (let i = 0; i < optionRows.length; i++) {
|
||||
if (optionRows[i].style.display !== "none") return true;
|
||||
}
|
||||
if (noResults && !noResults.classList.contains("hidden")) return true;
|
||||
if (options.querySelector("[data-search-select-modifier-option]")) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const showPanel = () => {
|
||||
if (alwaysVisible || hasVisibleContent()) {
|
||||
options.classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
const hidePanel = () => {
|
||||
if (!alwaysVisible) options.classList.add("hidden");
|
||||
};
|
||||
|
||||
const setNoResults = (visible: boolean) => {
|
||||
if (!noResults) return;
|
||||
noResults.classList.toggle("hidden", !visible);
|
||||
if (visible) showPanel();
|
||||
};
|
||||
|
||||
// ── Highlight tracking (filter mode) ──
|
||||
let highlightedRow: HTMLElement | null = null;
|
||||
|
||||
const highlightOption = (row: HTMLElement | null) => {
|
||||
clearHighlight();
|
||||
if (!row) return;
|
||||
row.setAttribute("data-search-select-highlighted", "");
|
||||
highlightedRow = row;
|
||||
row.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
|
||||
const clearHighlight = () => {
|
||||
if (highlightedRow) {
|
||||
highlightedRow.removeAttribute("data-search-select-highlighted");
|
||||
highlightedRow = null;
|
||||
}
|
||||
};
|
||||
|
||||
const getVisibleOptions = (): HTMLElement[] => {
|
||||
const all = options.querySelectorAll<HTMLElement>("[data-search-select-option]");
|
||||
return Array.from(all).filter(row => row.style.display !== "none");
|
||||
};
|
||||
|
||||
const autoHighlight = (query: string) => {
|
||||
const visible = getVisibleOptions();
|
||||
if (visible.length === 0) {
|
||||
clearHighlight();
|
||||
return;
|
||||
}
|
||||
const lower = query.toLowerCase();
|
||||
// 1. Starts-with match
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const label = (visible[i].getAttribute("data-label") || "").toLowerCase();
|
||||
if (lower && label.startsWith(lower)) {
|
||||
highlightOption(visible[i]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 2. Substring match (fuzzy-lite)
|
||||
for (let j = 0; j < visible.length; j++) {
|
||||
const subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase();
|
||||
if (lower && subLabel.includes(lower)) {
|
||||
highlightOption(visible[j]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 3. Fallback: first visible option
|
||||
highlightOption(visible[0]);
|
||||
};
|
||||
|
||||
// Get active values in both form and filter modes
|
||||
const getSelectedValues = (): Set<string> => {
|
||||
const values = new Set<string>();
|
||||
pills.querySelectorAll<HTMLInputElement>('input[type="hidden"]').forEach(input => {
|
||||
values.add(input.value);
|
||||
});
|
||||
pills.querySelectorAll<HTMLElement>("[data-pill]").forEach(pill => {
|
||||
const value = pill.getAttribute("data-value");
|
||||
if (value) values.add(value);
|
||||
});
|
||||
return values;
|
||||
};
|
||||
|
||||
// ── Render server-fetched rows into the panel ──
|
||||
const renderRows = (items: SearchSelectOption[]) => {
|
||||
const selectedValues = getSelectedValues();
|
||||
const preservedOptions: SearchSelectOption[] = [];
|
||||
|
||||
// Extract existing option data for currently selected values before removing
|
||||
options.querySelectorAll<HTMLElement>("[data-search-select-option]").forEach(row => {
|
||||
const value = row.getAttribute("data-value");
|
||||
if (value && selectedValues.has(value)) {
|
||||
preservedOptions.push(optionFromRow(row));
|
||||
}
|
||||
row.remove();
|
||||
});
|
||||
|
||||
const renderedValues = new Set<string>();
|
||||
|
||||
// Render preserved options first (to keep them at the top)
|
||||
preservedOptions.forEach(option => {
|
||||
options.insertBefore(buildRow(option), noResults || null);
|
||||
renderedValues.add(String(option.value));
|
||||
});
|
||||
|
||||
// Render newly fetched items (excluding already rendered preserved ones)
|
||||
// Fix DOM-limit vs fetch mismatch: Do not slice the items, render all returned items.
|
||||
items.forEach(item => {
|
||||
if (!renderedValues.has(String(item.value))) {
|
||||
options.insertBefore(buildRow(item), noResults || null);
|
||||
renderedValues.add(String(item.value));
|
||||
}
|
||||
});
|
||||
|
||||
showPanel();
|
||||
};
|
||||
|
||||
// ── Clone a server-rendered <template> prototype by name. The server emits
|
||||
// the mode-appropriate prototypes, so the JS never names a class. ──
|
||||
const cloneTemplate = (templateName: string): HTMLElement | null => {
|
||||
const template = container.querySelector<HTMLTemplateElement>(
|
||||
`template[data-search-select-template="${templateName}"]`
|
||||
);
|
||||
const clone = template?.content.firstElementChild?.cloneNode(true);
|
||||
return (clone as HTMLElement) ?? null;
|
||||
};
|
||||
|
||||
const setLabel = (node: Element, label: string) => {
|
||||
const slot = node.querySelector("[data-search-select-label]");
|
||||
if (slot) slot.textContent = label;
|
||||
};
|
||||
|
||||
const applyData = (node: Element, data: Record<string, string> = {}) => {
|
||||
Object.keys(data).forEach(key => {
|
||||
node.setAttribute(`data-${key}`, data[key]);
|
||||
});
|
||||
};
|
||||
|
||||
// Build an option row by cloning the "row" template (the same prototype the
|
||||
// server renders, so fetched and pre-rendered rows are identical).
|
||||
const buildRow = (option: SearchSelectOption): HTMLElement | Comment => {
|
||||
const row = cloneTemplate("row") as OptionRow | null;
|
||||
if (!row) return document.createComment("ss-row");
|
||||
row.setAttribute("data-value", option.value);
|
||||
row.setAttribute("data-label", option.label);
|
||||
applyData(row, option.data);
|
||||
setLabel(row, option.label);
|
||||
row._searchSelectOption = option;
|
||||
return row;
|
||||
};
|
||||
|
||||
// ── Client-side filter of the currently loaded rows. Returns the number of
|
||||
// visible rows so the caller decides whether to show the no-results node. ──
|
||||
const filterRows = (query: string): number => {
|
||||
const lower = query.toLowerCase();
|
||||
let visibleCount = 0;
|
||||
options.querySelectorAll<HTMLElement>("[data-search-select-option]").forEach(item => {
|
||||
const label = (item.getAttribute("data-label") || "").toLowerCase();
|
||||
const match = label.includes(lower);
|
||||
item.style.display = match ? "" : "none";
|
||||
if (match) visibleCount += 1;
|
||||
});
|
||||
return visibleCount;
|
||||
};
|
||||
|
||||
// ── Fetch matching rows from the server. The previous in-flight request is
|
||||
// aborted so a slower earlier response can never overwrite a newer one. ──
|
||||
const fetchFromServer = (query: string) => {
|
||||
if (pendingRequest) pendingRequest.abort();
|
||||
pendingRequest = new AbortController();
|
||||
let url = `${searchUrl}?q=${encodeURIComponent(query)}`;
|
||||
if (prefetch && !query) url += `&limit=${prefetch}`;
|
||||
fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
|
||||
.then(response => response.json())
|
||||
.then((items: SearchSelectOption[]) => {
|
||||
pendingRequest = null;
|
||||
renderRows(items);
|
||||
// Re-apply the live query: the box may hold more text than was sent.
|
||||
setNoResults(filterRows(search.value.trim()) === 0);
|
||||
autoHighlight(search.value.trim());
|
||||
})
|
||||
.catch(error => {
|
||||
if (error?.name === "AbortError") return; // superseded
|
||||
pendingRequest = null;
|
||||
setNoResults(true);
|
||||
});
|
||||
};
|
||||
|
||||
// In free-text mode the typed text is the value itself: there is no
|
||||
// backing list, so we rebuild a single ephemeral option row reflecting the
|
||||
// current query so the +/− buttons (or Enter) can commit it as a pill.
|
||||
const rebuildFreeTextRow = (query: string) => {
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(row => row.remove());
|
||||
if (!query) {
|
||||
setNoResults(false);
|
||||
clearHighlight();
|
||||
return;
|
||||
}
|
||||
const row = buildRow({ value: query, label: query, data: {} });
|
||||
options.insertBefore(row, noResults || null);
|
||||
setNoResults(false);
|
||||
highlightOption(row as HTMLElement);
|
||||
};
|
||||
|
||||
// Called on every keystroke. With a search_url, filter the loaded window
|
||||
// instantly (zero latency) and debounce a server request for the rest;
|
||||
// no-results stays hidden until the response decides it, to avoid a flash
|
||||
// over an incomplete window. Without a search_url the loaded set is complete,
|
||||
// so the client-side filter is authoritative.
|
||||
const runSearch = () => {
|
||||
const query = search.value.trim();
|
||||
if (freeText) {
|
||||
rebuildFreeTextRow(query);
|
||||
showPanel();
|
||||
return;
|
||||
}
|
||||
if (searchUrl) {
|
||||
filterRows(query);
|
||||
setNoResults(false);
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
fetchFromServer(query);
|
||||
}, DEBOUNCE_MS);
|
||||
} else {
|
||||
setNoResults(filterRows(query) === 0);
|
||||
}
|
||||
autoHighlight(query);
|
||||
showPanel();
|
||||
};
|
||||
|
||||
// ── Single-select combobox: the search box shows the committed label;
|
||||
// focusing clears it to search, blurring restores it (or deselects). ──
|
||||
if (!multi) container._searchSelectLabel = search.value;
|
||||
|
||||
search.addEventListener("focus", () => {
|
||||
if (!multi) {
|
||||
// Hide the committed label so the box becomes a fresh search field.
|
||||
search.value = "";
|
||||
container._searchSelectDirty = false;
|
||||
}
|
||||
if (freeText) {
|
||||
rebuildFreeTextRow(search.value.trim());
|
||||
} else if (searchUrl) {
|
||||
if (prefetch && !hasPrefetched) {
|
||||
// Seed the window immediately on first open (not debounced).
|
||||
hasPrefetched = true;
|
||||
fetchFromServer("");
|
||||
} else {
|
||||
// Show whatever is already loaded; the server decides no-results.
|
||||
filterRows(search.value.trim());
|
||||
setNoResults(false);
|
||||
autoHighlight(search.value.trim());
|
||||
}
|
||||
} else {
|
||||
setNoResults(filterRows(search.value.trim()) === 0);
|
||||
autoHighlight(search.value.trim());
|
||||
}
|
||||
showPanel();
|
||||
});
|
||||
|
||||
search.addEventListener("input", () => {
|
||||
clearHighlight();
|
||||
if (!multi) {
|
||||
if (!container._searchSelectDirty) {
|
||||
const label = container._searchSelectLabel || "";
|
||||
if (search.value.startsWith(label)) {
|
||||
search.value = search.value.slice(label.length);
|
||||
}
|
||||
container._searchSelectDirty = true;
|
||||
}
|
||||
}
|
||||
runSearch();
|
||||
});
|
||||
|
||||
if (!multi) {
|
||||
search.addEventListener("blur", () => {
|
||||
// Defer so an option click (which fires before blur settles) wins.
|
||||
setTimeout(() => {
|
||||
if (container._searchSelectDirty && search.value.trim() === "") {
|
||||
// User intentionally cleared the box → deselect.
|
||||
pills.innerHTML = "";
|
||||
container._searchSelectLabel = "";
|
||||
emitChange(null);
|
||||
} else {
|
||||
// Focused-and-left, or typed a partial query without picking →
|
||||
// restore the committed label (no-op right after a selection).
|
||||
search.value = container._searchSelectLabel || "";
|
||||
}
|
||||
}, 120);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Keyboard navigation (both form and filter modes) ──
|
||||
search.addEventListener("keydown", (event) => {
|
||||
const { key } = event;
|
||||
|
||||
if (!multi && key === "Backspace" && !container._searchSelectDirty) {
|
||||
event.preventDefault();
|
||||
search.value = "";
|
||||
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(key)) return;
|
||||
const visible = getVisibleOptions();
|
||||
if (visible.length === 0) {
|
||||
if (key === "Escape") hidePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
showPanel();
|
||||
const downIndex = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||
highlightOption(visible[(downIndex + 1) % visible.length]);
|
||||
} else if (key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
showPanel();
|
||||
const upIndex = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||
highlightOption(visible[(upIndex - 1 + visible.length) % visible.length]);
|
||||
} else if (key === "Enter") {
|
||||
if (highlightedRow) {
|
||||
event.preventDefault();
|
||||
const option = optionFromRow(highlightedRow);
|
||||
if (isFilter) {
|
||||
addFilterPill(option, "include");
|
||||
search.value = "";
|
||||
} else {
|
||||
selectOption(option);
|
||||
}
|
||||
clearHighlight();
|
||||
hidePanel();
|
||||
}
|
||||
} else if (key === "Escape") {
|
||||
clearHighlight();
|
||||
hidePanel();
|
||||
}
|
||||
});
|
||||
|
||||
// Clicking an option must not blur the input before the click selects.
|
||||
options.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// ── Option click → select (form mode) or include/exclude (filter mode) ──
|
||||
options.addEventListener("click", (event) => {
|
||||
if (isFilter) {
|
||||
handleFilterOptionClick(event);
|
||||
return;
|
||||
}
|
||||
const row = (event.target as Element).closest<HTMLElement>("[data-search-select-option]");
|
||||
if (!row) return;
|
||||
selectOption(optionFromRow(row));
|
||||
});
|
||||
|
||||
const handleFilterOptionClick = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
|
||||
const modifierRow = target.closest<HTMLElement>("[data-search-select-modifier-option]");
|
||||
if (modifierRow) {
|
||||
setModifier(
|
||||
modifierRow.getAttribute("data-search-select-modifier-option") ?? "",
|
||||
modifierRow.getAttribute("data-label") ?? ""
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Include / exclude button on a value row.
|
||||
const button = target.closest<HTMLElement>("[data-search-select-action]");
|
||||
if (button) {
|
||||
const row = button.closest<HTMLElement>("[data-search-select-option]");
|
||||
if (!row) return;
|
||||
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action") ?? "include");
|
||||
return;
|
||||
}
|
||||
// Click on the option row itself → include.
|
||||
const optionRow = target.closest<HTMLElement>("[data-search-select-option]");
|
||||
if (optionRow) {
|
||||
addFilterPill(optionFromRow(optionRow), "include");
|
||||
}
|
||||
};
|
||||
|
||||
// Add (or re-type) an include/exclude pill for a value. Selecting any value
|
||||
// clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive
|
||||
// with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
|
||||
// persist alongside value pills.
|
||||
const addFilterPill = (option: SearchSelectOption, kind: string) => {
|
||||
const modifierPill = pills.querySelector("[data-search-select-modifier]");
|
||||
if (modifierPill) {
|
||||
const modifierValue = modifierPill.getAttribute("data-search-select-modifier") ?? "";
|
||||
if (PRESENCE_MODIFIERS.includes(modifierValue)) {
|
||||
clearModifier();
|
||||
}
|
||||
}
|
||||
const existing = pills.querySelector(
|
||||
`[data-pill][data-value="${cssEscape(option.value)}"]`
|
||||
);
|
||||
if (existing) existing.remove();
|
||||
pills.appendChild(buildFilterValuePill(option, kind));
|
||||
search.value = "";
|
||||
emitChange(null);
|
||||
};
|
||||
|
||||
const buildFilterValuePill = (option: SearchSelectOption, kind: string): HTMLElement => {
|
||||
const pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude")!;
|
||||
pill.setAttribute("data-value", option.value);
|
||||
pill.setAttribute("data-label", option.label);
|
||||
applyData(pill, option.data);
|
||||
setLabel(pill, option.label);
|
||||
return pill;
|
||||
};
|
||||
|
||||
// Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all
|
||||
// value pills — they are mutually exclusive. Non-presence modifiers
|
||||
// (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills.
|
||||
const setModifier = (modifierValue: string, label: string) => {
|
||||
// Remove any existing modifier pill to avoid duplicates.
|
||||
clearModifierPill();
|
||||
if (PRESENCE_MODIFIERS.includes(modifierValue)) {
|
||||
pills.innerHTML = "";
|
||||
}
|
||||
const pill = cloneTemplate("pill-modifier")!;
|
||||
pill.setAttribute("data-search-select-modifier", modifierValue);
|
||||
setLabel(pill, label);
|
||||
pills.insertBefore(pill, pills.firstChild);
|
||||
container.setAttribute("data-modifier", modifierValue);
|
||||
hidePanel();
|
||||
emitChange(null);
|
||||
};
|
||||
|
||||
// Remove the modifier pill and its container attribute. Safe to call when
|
||||
// there is no modifier pill (no-op). Does not touch value pills.
|
||||
const clearModifierPill = () => {
|
||||
const modifierPill = pills.querySelector("[data-search-select-modifier]");
|
||||
if (modifierPill) modifierPill.remove();
|
||||
container.removeAttribute("data-modifier");
|
||||
};
|
||||
|
||||
const clearModifier = () => {
|
||||
clearModifierPill();
|
||||
};
|
||||
|
||||
const optionFromRow = (row: HTMLElement): SearchSelectOption => {
|
||||
const optionRow = row as OptionRow;
|
||||
if (optionRow._searchSelectOption) return optionRow._searchSelectOption;
|
||||
const data: Record<string, string> = {};
|
||||
Object.keys(row.dataset).forEach(key => {
|
||||
if (key !== "value" && key !== "label" && key !== "ssOption") {
|
||||
data[key] = row.dataset[key] ?? "";
|
||||
}
|
||||
});
|
||||
return {
|
||||
value: row.getAttribute("data-value") ?? "",
|
||||
label: row.getAttribute("data-label") ?? "",
|
||||
data,
|
||||
};
|
||||
};
|
||||
|
||||
const selectOption = (option: SearchSelectOption) => {
|
||||
if (multi) {
|
||||
if (!pills.querySelector(`input[value="${cssEscape(option.value)}"]`)) {
|
||||
addPill(option);
|
||||
}
|
||||
search.value = "";
|
||||
} else {
|
||||
// Single-select: no pill — show the label in the search box and keep a
|
||||
// lone hidden input under [data-search-select-pills] for submission.
|
||||
pills.innerHTML = "";
|
||||
pills.appendChild(buildHidden(option.value));
|
||||
search.value = option.label;
|
||||
container._searchSelectLabel = option.label;
|
||||
container._searchSelectDirty = false;
|
||||
hidePanel();
|
||||
}
|
||||
emitChange(option);
|
||||
};
|
||||
|
||||
const addPill = (option: SearchSelectOption) => {
|
||||
const pill = buildPill(option);
|
||||
if (pill) pills.appendChild(pill);
|
||||
pills.appendChild(buildHidden(option.value));
|
||||
};
|
||||
|
||||
const buildPill = (option: SearchSelectOption): HTMLElement | null => {
|
||||
const pill = cloneTemplate("pill");
|
||||
if (!pill) return null;
|
||||
pill.setAttribute("data-value", option.value);
|
||||
applyData(pill, option.data);
|
||||
setLabel(pill, option.label);
|
||||
return pill;
|
||||
};
|
||||
|
||||
const buildHidden = (value: string): HTMLInputElement => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
return input;
|
||||
};
|
||||
|
||||
// ── Pill × → remove ──
|
||||
pills.addEventListener("click", (event) => {
|
||||
const removeButton = (event.target as Element).closest("[data-pill-remove]");
|
||||
if (!removeButton) return;
|
||||
const pill = removeButton.closest("[data-pill]");
|
||||
if (!pill) return;
|
||||
if (isFilter) {
|
||||
// Filter pills have no hidden input.
|
||||
if (pill.hasAttribute("data-search-select-modifier")) {
|
||||
clearModifierPill();
|
||||
} else {
|
||||
pill.remove();
|
||||
}
|
||||
emitChange(null);
|
||||
return;
|
||||
}
|
||||
const value = pill.getAttribute("data-value");
|
||||
pill.remove();
|
||||
const hidden = pills.querySelector(`input[value="${cssEscape(value)}"]`);
|
||||
if (hidden) hidden.remove();
|
||||
emitChange(null);
|
||||
});
|
||||
|
||||
const currentValues = (): string[] => {
|
||||
return Array.from(
|
||||
pills.querySelectorAll<HTMLInputElement>('input[type="hidden"]')
|
||||
).map(input => input.value);
|
||||
};
|
||||
|
||||
const emitChange = (last: SearchSelectOption | null) => {
|
||||
const values = currentValues();
|
||||
if (syncUrl) syncToUrl(values);
|
||||
container.dispatchEvent(
|
||||
new CustomEvent<SearchSelectChangeDetail>("search-select:change", {
|
||||
bubbles: true,
|
||||
detail: { name, values, last },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const syncToUrl = (values: string[]) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete(name);
|
||||
values.forEach(value => {
|
||||
params.append(name, value);
|
||||
});
|
||||
const queryString = params.toString();
|
||||
history.replaceState(null, "", queryString ? `?${queryString}` : window.location.pathname);
|
||||
};
|
||||
|
||||
// On init, restore from URL params if the server supplied no selected pills.
|
||||
if (syncUrl && !pills.querySelector("[data-pill]")) {
|
||||
const initial = new URLSearchParams(window.location.search).getAll(name);
|
||||
initial.forEach(value => {
|
||||
addPill({ value, label: value, data: {} });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Close panel on outside click ──
|
||||
const onDocumentClick = (event: MouseEvent) => {
|
||||
if (!container.contains(event.target as Node)) hidePanel();
|
||||
};
|
||||
document.addEventListener("click", onDocumentClick);
|
||||
return onDocumentClick;
|
||||
};
|
||||
|
||||
/** Minimal escape for use inside an attribute-value selector. */
|
||||
const cssEscape = (value: string | null): string => String(value).replace(/["\\]/g, "\\$&");
|
||||
|
||||
// Serialise each widget's current state onto data-* attributes for the caller.
|
||||
// Form widgets expose data-values (the submitted hidden-input values); filter
|
||||
// widgets expose data-included / data-excluded / data-modifier for the filter
|
||||
// bar to read.
|
||||
export function readSearchSelect(form: HTMLElement): void {
|
||||
form.querySelectorAll<HTMLElement>("search-select").forEach(container => {
|
||||
const pills = container.querySelector<HTMLElement>("[data-search-select-pills]");
|
||||
if (container.getAttribute("filter-mode") === "true") {
|
||||
const included: FilterPillEntry[] = [];
|
||||
const excluded: FilterPillEntry[] = [];
|
||||
let modifier = "";
|
||||
if (pills) {
|
||||
pills.querySelectorAll<HTMLElement>("[data-pill]").forEach(pill => {
|
||||
const pillModifier = pill.getAttribute("data-search-select-modifier");
|
||||
if (pillModifier) {
|
||||
modifier = pillModifier; // last modifier pill wins
|
||||
return; // skip value extraction for this pill
|
||||
}
|
||||
const value = pill.getAttribute("data-value") ?? "";
|
||||
const label = pill.getAttribute("data-label") || "";
|
||||
if (pill.getAttribute("data-search-select-type") === "exclude") {
|
||||
excluded.push({ id: value, label });
|
||||
} else {
|
||||
included.push({ id: value, label });
|
||||
}
|
||||
});
|
||||
}
|
||||
container.setAttribute("data-included", JSON.stringify(included));
|
||||
container.setAttribute("data-excluded", JSON.stringify(excluded));
|
||||
if (modifier) container.setAttribute("data-modifier", modifier);
|
||||
else container.removeAttribute("data-modifier");
|
||||
return;
|
||||
}
|
||||
const values = pills
|
||||
? Array.from(pills.querySelectorAll<HTMLInputElement>('input[type="hidden"]')).map(input => input.value)
|
||||
: [];
|
||||
container.setAttribute("data-values", JSON.stringify(values));
|
||||
});
|
||||
}
|
||||
|
||||
// Keep as window global for filter_bar.ts until it is converted to a custom element.
|
||||
window.readSearchSelect = readSearchSelect;
|
||||
|
||||
class SearchSelectElement extends HTMLElement {
|
||||
private onDocumentClick: ((event: MouseEvent) => void) | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
this.onDocumentClick = initWidget(this) as ((event: MouseEvent) => void) | null;
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
if (this.onDocumentClick) {
|
||||
document.removeEventListener("click", this.onDocumentClick);
|
||||
this.onDocumentClick = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("search-select", SearchSelectElement);
|
||||
@@ -2,7 +2,7 @@ import { readSelectionFieldsProps, SelectionFieldsProps } from "../generated/pro
|
||||
|
||||
/**
|
||||
* Renders one form field per selected item of a source SearchSelect (matched by
|
||||
* its data-name). Reacts to the SearchSelect's "search-select:change" event and
|
||||
* its name attribute). Reacts to the SearchSelect's "search-select:change" event and
|
||||
* to its own "active" attribute. Typed values are preserved (keyed by item id)
|
||||
* across selection changes and active toggling.
|
||||
*/
|
||||
@@ -24,7 +24,7 @@ class SelectionFieldsElement extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.props = readSelectionFieldsProps(this);
|
||||
this.source = document.querySelector<HTMLElement>(
|
||||
`[data-search-select][data-name="${this.props.source}"]`,
|
||||
`search-select[name="${this.props.source}"]`,
|
||||
);
|
||||
document.addEventListener("search-select:change", this.onSourceChange);
|
||||
this.render();
|
||||
|
||||
Reference in New Issue
Block a user