feat(filter-builder): multi-pick values render as removable chips

The id-kind value cells (feed/library/collection/added_by) rendered
their picks as one comma-joined string inside a 9rem-wide input — you
saw the first label and nothing else, and reading 3-5 picks meant
scrolling inside a tiny box (worst on mobile, user request 2026-11-01).

Id-kind cells now render a chipbox: each picked value is a chip (label
truncated with a hover title, removable via an X button with a finger-
sized tap target), the box flex-wraps and grows vertically instead of
clipping, and the inline input stays at the end for typing/searching —
including the Backspace-on-empty-removes-last-chip affordance. Text/
number/date cells keep today's single input; the value menu is
unchanged (it never offers an already-picked value, so the chip X is
the faster removal path, not a new one).

Presentation only: the stored wire is still one comma-joined value per
rule; matching, menus, apply, and label resolution are untouched. Pins
moved from valueCellText (now text-cells-only) to the chip model
(idChipsFor/idDraftFor) and cover removal order, last-chip removal, and
Backspace; 187 web tests green.
This commit is contained in:
John O'Keefe committed 2026-11-01 13:33:34 -05:00
1 parent d56031f334
commit a20741ccc9
6 files changed
+187 -27

No files matched your search

+49 -7
View File
@@ -380,7 +380,47 @@ describe("id-kind value cells", () => {
children: [{ field: "library", op: "in", values: ["lib-1"] }],
};
c.idLabels["lib-1"] = "Books";
expect(c.valueCellText([0])).toBe("Books");
expect(c.idChipsFor([0])).toEqual([{ id: "lib-1", label: "Books" }]);
});
it("removing a chip drops exactly that id, order preserved", () => {
const c = filterBuilderData();
c.filterTree = {
mode: "all",
children: [{ field: "library", op: "in", values: ["lib-1,lib-2,lib-3"] }],
};
c.idLabels["lib-1"] = "Books";
c.idLabels["lib-2"] = "Comics";
c.idLabels["lib-3"] = "Manga";
c.removeIdChip([0], "lib-2");
expect(c.filterTree.children[0] as FilterRuleState).toEqual({
field: "library",
op: "in",
values: ["lib-1,lib-3"],
});
expect(c.idChipsFor([0]).map((ch) => ch.label)).toEqual(["Books", "Manga"]);
// removing the LAST chip leaves the rule with no values (the menu
// toggle-off behaves the same way)
c.removeIdChip([0], "lib-1");
c.removeIdChip([0], "lib-3");
expect((c.filterTree.children[0] as FilterRuleState).values).toEqual([]);
expect(c.idChipsFor([0])).toEqual([]);
});
it("Backspace on an empty chip input removes the last chip", () => {
const c = filterBuilderData();
c.filterTree = {
mode: "all",
children: [{ field: "library", op: "in", values: ["lib-1,lib-2"] }],
};
const empty = { key: "Backspace", target: { value: "" } } as unknown as KeyboardEvent;
const typing = { key: "Backspace", target: { value: "com" } } as unknown as KeyboardEvent;
c.chipInputKeydown([0], typing);
expect((c.filterTree.children[0] as FilterRuleState).values).toEqual(["lib-1,lib-2"]);
c.chipInputKeydown([0], empty);
expect((c.filterTree.children[0] as FilterRuleState).values).toEqual(["lib-1"]);
c.chipInputKeydown([0], empty);
expect((c.filterTree.children[0] as FilterRuleState).values).toEqual([]);
});
it("typing drafts a NAME filter and never writes into the rule's ids", () => {
@@ -396,7 +436,7 @@ describe("id-kind value cells", () => {
op: "in",
values: ["lib-1"],
});
expect(c.valueCellText([0])).toBe("boo");
expect(c.idDraftFor([0])).toBe("boo");
});
it("picking toggles the id and flips the cell back to labels", () => {
@@ -409,7 +449,8 @@ describe("id-kind value cells", () => {
c.onValueCellInput([0], "com");
c.pickSuggestion("0", [0], { value: "lib-2", display: "Comics", count: 3 });
expect((c.filterTree.children[0] as FilterRuleState).values).toEqual(["lib-1,lib-2"]);
expect(c.valueCellText([0])).toBe("Books, Comics");
expect(c.idChipsFor([0]).map((ch) => ch.label)).toEqual(["Books", "Comics"]);
expect(c.idDraftFor([0])).toBe("");
});
it("a freshly ingested tree resolves stored ids to labels (the Apply reload)", async () => {
@@ -433,7 +474,7 @@ describe("id-kind value cells", () => {
await vi.waitFor(() => expect(c.idLabels["lib-9"]).toBe("Ebooks"));
// the stored id rides as the typed lookup
expect(url).toContain("library=lib-9");
expect(c.valueCellText([0])).toBe("Ebooks");
expect(c.idChipsFor([0]).map((ch) => ch.label)).toEqual(["Ebooks"]);
// label resolution never opens a menu
expect(c.suggestionsOpen).toBe("");
vi.unstubAllGlobals();
@@ -472,7 +513,8 @@ describe("id-kind value cells", () => {
c.idLabels["lib-1"] = "Books";
c.pickSuggestion("0", [0], { value: "lib-2", display: "Comics", count: 2 });
expect((c.filterTree.children[0] as FilterRuleState).values).toEqual(["lib-1,lib-2"]);
expect(c.valueCellText([0])).toBe("Books, Comics");
expect(c.idChipsFor([0]).map((ch) => ch.label)).toEqual(["Books", "Comics"]);
expect(c.idDraftFor([0])).toBe("");
});
it("the id-cell placeholder is uniform — one name per field, no per-op distinction", () => {
@@ -541,7 +583,7 @@ describe("init: embedded labels + form hydration", () => {
(c as unknown as { $el: HTMLElement }).$el = el;
c.init();
expect(c.filterTree.children).toHaveLength(1);
expect(c.valueCellText([0])).toBe("Comics");
expect(c.idChipsFor([0]).map((ch) => ch.label)).toEqual(["Comics"]);
expect(fetchSpy).not.toHaveBeenCalled();
});
@@ -566,7 +608,7 @@ describe("init: embedded labels + form hydration", () => {
// could not reach this component), and the cell shows the embedded
// label, not the raw id.
expect(c.filterTree.children).toEqual([{ field: "library", op: "is", values: ["lib-1"] }]);
expect(c.valueCellText([0])).toBe("Comics");
expect(c.idChipsFor([0]).map((ch) => ch.label)).toEqual(["Comics"]);
expect(fetchSpy).not.toHaveBeenCalled();
});
});
+53 -13
View File
@@ -768,22 +768,62 @@ export function filterBuilderData() {
return FIELD_BY_KEY.get(node.field)?.valueKind === "id";
},
// valueCellText — what the value INPUT displays. Id-kind rules keep
// the ids in rule.values (the clause matches ids); the cell shows
// their LABELS, or the name text being typed.
// idChipsFor — the chipbox's chips: the rule's joined values split
// and labeled through idLabels (raw id fallback while resolution is
// in flight or impossible).
idChipsFor(path: number[]): { id: string; label: string }[] {
const node = this.nodeAt(path);
if (!node || !isRule(node)) return [];
return node.values
.join(",")
.split(",")
.filter(Boolean)
.map((v) => ({ id: v, label: this.idLabels[v] ?? v }));
},
// idDraftFor — the chipbox's inner input shows ONLY the typing
// draft; labels live on the chips.
idDraftFor(path: number[]): string {
return this.idDrafts[path.join("-")] ?? "";
},
// removeIdChip — drop one picked id from the rule's joined values
// (order preserved) and refresh the match count. The suggestion
// menu never offers a picked value; the chip × is the faster
// affordance.
removeIdChip(path: number[], id: string): void {
const node = this.nodeAt(path);
if (!node || !isRule(node)) return;
const rest = node.values
.join(",")
.split(",")
.filter((v) => v && v !== id);
node.values = rest.length ? [rest.join(",")] : [];
this._touch();
},
// chipInputKeydown — Backspace on an EMPTY chip input removes the
// last chip (standard chip-box affordance); any typing keeps the
// normal draft flow.
chipInputKeydown(path: number[], event: KeyboardEvent): void {
if (event.key !== "Backspace") return;
const el = event.target as HTMLInputElement | null;
if (el && el.value !== "") return;
const node = this.nodeAt(path);
if (!node || !isRule(node)) return;
const ids = node.values.join(",").split(",").filter(Boolean);
if (ids.length === 0) return;
ids.pop();
node.values = ids.length ? [ids.join(",")] : [];
this._touch();
},
// valueCellText — what a NON-id value input displays (text/number
// cells write straight through). Id-kind cells render chips
// (idChipsFor) — their input carries only the typing draft.
valueCellText(path: number[]): string {
const node = this.nodeAt(path);
if (!node || !isRule(node)) return "";
if (this.isIdValueRow(path)) {
const draft = this.idDrafts[path.join("-")];
if (draft !== undefined) return draft;
return node.values
.join(",")
.split(",")
.filter(Boolean)
.map((v) => this.idLabels[v] ?? v)
.join(", ");
}
return node.values.join(",");
},