Files
timetracker/ts/elements/search-select.ts
T
lukas 82416e149d 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>
2026-06-20 14:22:59 +02:00

714 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);