Files
timetracker/ts/search_select.ts
T
lukas 541fb550ab Convert search_select.js to TypeScript (issue #17)
- Add ts/search_select.ts: typed port of the SearchSelect/FilterSelect widget.
  Exports SearchSelectOption / SearchSelectChangeDetail as the single source of
  truth for the "search-select:change" event contract
- add_purchase.ts now imports those types via `import type` (no runtime
  coupling), instead of redefining them locally
- Declare window.readSearchSelect in ts/globals.d.ts
- Point the SearchSelect component Media and every view/e2e/test reference at
  the compiled dist/search_select.js
- Update doc comments in common/components/search_select.py to name the TS source

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 13:34:06 +02:00

700 lines
27 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 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 (data-search-select-mode="filter", 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.
*
* Widgets are initialized via onSwap() (utils.js), which covers the initial
* page load and every htmx-swapped fragment, once per widget.
*
* 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.
*/
import { onSwap } from "./utils.js";
// 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;
}
(() => {
"use strict";
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("data-name") ?? "";
const searchUrl = container.getAttribute("data-search-url");
const isFilter = container.getAttribute("data-search-select-mode") === "filter";
const freeText = container.getAttribute("data-search-select-free-text") === "true";
const multi = container.getAttribute("data-multi") === "true";
const alwaysVisible = container.getAttribute("data-always-visible") === "true";
const prefetch = parseInt(container.getAttribute("data-prefetch") ?? "", 10) || 0;
const syncUrl = container.getAttribute("data-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 ──
document.addEventListener("click", (event) => {
if (!container.contains(event.target as Node)) hidePanel();
});
};
/** 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.
window.readSearchSelect = (form: HTMLElement) => {
form.querySelectorAll<HTMLElement>("[data-search-select]").forEach(container => {
const pills = container.querySelector<HTMLElement>("[data-search-select-pills]");
if (container.getAttribute("data-search-select-mode") === "filter") {
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));
});
};
onSwap("[data-search-select]", initWidget);
})();