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:
1 parent
d56031f334
commit
a20741ccc9
6 files changed
+187
-27
No files matched your search
@@ -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
@@ -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(",");
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user