fix(filters): value menu flash-on-click, empty state, sticky field filter (user report)

- The flash was a race: on localhost the suggestion fetch resolved
  between the focus (mousedown) and the click event, so the just-opened
  menu was closed by its own click-outside handler — the input sits
  outside the menu div. Clicks anywhere inside the value cell now
  never close the menu; outside clicks do.
- The menu opens immediately in a loading state; an empty browse
  renders 'No matching values' instead of nothing. Plain free-input
  fields (page_count etc.) open no menu at all.
- Field menu: the filter input moved outside the scroll region — it
  sticks while the grouped list scrolls beneath it.
This commit is contained in:
John O'Keefe committed 2026-10-20 15:36:31 -04:00
1 parent c747f0a76c
commit 4c03116ec7
5 files changed
+65 -26

No files matched your search

+16 -5
View File
@@ -441,6 +441,7 @@ export function filterBuilderData() {
fieldFilter: "" as string, // the field menu's filter input
suggestions: {} as Record<string, Suggestion[]>,
suggestionsOpen: "" as string, // rowKey whose value menu is open
suggestionsLoading: "" as string, // rowKey currently fetching
idLabels: {} as Record<string, string>, // id → display, from suggestion fetches
_suggestTimer: null as number | null,
_countTimer: null as number | null,
@@ -700,6 +701,10 @@ export function filterBuilderData() {
if (typed.length === 1) return;
const token = localStorage.getItem("token");
if (!token) return;
// Open the menu NOW (loading state) — never a blank flash.
this.suggestions[rowKey] = [];
this.suggestionsLoading = rowKey;
this.suggestionsOpen = rowKey;
// Symmetry: the tree sent EXCLUDES the rule being edited — the
// menu is computed under every OTHER COMPLETED rule (incomplete
// rules are not filters; sending them would be a 400).
@@ -720,6 +725,7 @@ export function filterBuilderData() {
});
if (!response.ok) {
console.warn(`suggestion fetch failed (${response.status})`);
this.suggestions[rowKey] = [];
return;
}
const data = (await response.json()) as { results?: Suggestion[] };
@@ -727,14 +733,17 @@ export function filterBuilderData() {
if (s.display) this.idLabels[s.value] = s.display;
}
this.suggestions[rowKey] = data.results ?? [];
this.suggestionsOpen = rowKey;
} catch (error) {
console.warn("suggestion fetch failed", error);
this.suggestions[rowKey] = [];
} finally {
if (this.suggestionsLoading === rowKey) this.suggestionsLoading = "";
}
},
openValueMenu(rowKey: string, path: number[], rule: FilterRuleState): void {
const meta = FIELD_BY_KEY.get(rule.field);
if (meta?.valueKind === "fixed") {
if (!meta) return;
if (meta.valueKind === "fixed") {
// Static options: no fetch.
this.suggestions[rowKey] = (meta.options ?? []).map((v) => ({
value: v,
@@ -743,14 +752,16 @@ export function filterBuilderData() {
this.suggestionsOpen = rowKey;
return;
}
if (meta?.suggest) {
if (meta.suggest) {
void this.fetchSuggestions(rowKey, path, rule);
return;
}
this.suggestionsOpen = rowKey;
// Plain free-input field: no value menu.
},
suggestionShown(rowKey: string): boolean {
return this.suggestionsOpen === rowKey && (this.suggestions[rowKey]?.length ?? 0) > 0;
// Open for the whole life of the interaction — an empty result
// renders the empty state, it never flashes the menu closed.
return this.suggestionsOpen === rowKey;
},
// ── live count ──────────────────────────────────────────────────