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
+182 -22

No files matched your search

+19 -1
View File
@@ -59,11 +59,29 @@ templ FilterBuilderRows(countNoun string) {
</span> </span>
</template> </template>
<template x-if="hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')"> <template x-if="hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')">
<span class="fb-value-cell relative flex items-center gap-1"> <span class="fb-value-cell relative flex items-center gap-1" :class="isIdValueRow(row.path) ? 'fb-chipbox flex-wrap' : ''">
<template x-if="isIdValueRow(row.path)">
<span class="contents">
<!-- picked values as removable chips: the box
wraps and grows instead of a 9rem input
you can't read past the first entry -->
<template x-for="chip in idChipsFor(row.path)" :key="chip.id">
<span class="fb-chip" :title="chip.label">
<span class="fb-chip-label" x-text="chip.label"></span>
<button type="button" class="fb-chip-x" :aria-label="'Remove ' + chip.label" @click="removeIdChip(row.path, chip.id)">×</button>
</span>
</template>
<input type="text" class="fb-value fb-chip-input" :value="idDraftFor(row.path)" @input.debounce.250ms="onValueCellInput(row.path, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" @keydown="chipInputKeydown(row.path, $event)" :placeholder="valuePlaceholderFor(row.rule)" :aria-label="fieldLabelOf(row.rule.field) + ' values'"/>
</span>
</template>
<template x-if="!isIdValueRow(row.path)">
<span class="contents">
<input type="text" class="input fb-value" :value="valueCellText(row.path)" @input.debounce.250ms="onValueCellInput(row.path, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" :placeholder="valuePlaceholderFor(row.rule)"/> <input type="text" class="input fb-value" :value="valueCellText(row.path)" @input.debounce.250ms="onValueCellInput(row.path, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" :placeholder="valuePlaceholderFor(row.rule)"/>
<template x-if="fieldIsDays(row.rule.field)"> <template x-if="fieldIsDays(row.rule.field)">
<span class="text-xs whitespace-nowrap" style="color: var(--text-secondary)" x-text="'days'"></span> <span class="text-xs whitespace-nowrap" style="color: var(--text-secondary)" x-text="'days'"></span>
</template> </template>
</span>
</template>
<!-- field-aware value menu: server JSON (the other <!-- field-aware value menu: server JSON (the other
rules bound), or the field's fixed options. rules bound), or the field's fixed options.
Clicks anywhere inside the value cell never close Clicks anywhere inside the value cell never close
+2 -2
View File
@@ -35,14 +35,14 @@ func FilterBuilderRows(countNoun string) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"'margin-left:' + (row.depth * 14) + 'px'\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"'margin-left:' + ((row.depth + 1) * 14) + 'px'\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"toggleConnective(row)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"valueCellText(row.path)\" @input.debounce.250ms=\"onValueCellInput(row.path, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"valuePlaceholderFor(row.rule)\"><template x-if=\"fieldIsDays(row.rule.field)\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware value menu: server JSON (the other\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trules bound), or the field's fixed options.\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tClicks anywhere inside the value cell never close\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tit — the opening focus-click must not race the\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\toutside handler. --><div class=\"fb-menu fb-menu-value\" x-show=\"suggestionShown(row.key)\" @click.outside=\"dismissSuggestions($event)\"><template x-for=\"s in (suggestions[row.key] || [])\" :key=\"s.value\"><button type=\"button\" class=\"fb-menu-item\" @click=\"pickSuggestion(row.key, row.path, s)\"><spaLine truncated templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"'margin-left:' + (row.depth * 14) + 'px'\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"'margin-left:' + ((row.depth + 1) * 14) + 'px'\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"toggleConnective(row)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\" :class=\"isIdValueRow(row.path) ? 'fb-chipbox flex-wrap' : ''\"><template x-if=\"isIdValueRow(row.path)\"><span class=\"contents\"><!-- picked values as removable chips: the box\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\twraps and grows instead of a 9rem input\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tyou can't read past the first entry --><template x-for=\"chip in idChipsFor(row.path)\" :key=\"chip.id\"><span class=\"fb-chip\" :title=\"chip.label\"><span class=\"fb-chip-label\" x-text=\"chip.label\"></span> <button type=\"button\" class=\"fb-chip-x\" :aria-label=\"'Remove ' + chip.label\" @click=\"removeIdChip(row.path, chip.id)\">×</button></span></template><input type=\"text\" class=\"fb-value fb-chip-input\" :value=\"idDraftFor(row.path)\" @input.debounce.250ms=\"onValueCellInput(row.path, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @keydown=\"chipInputKeydown(row.path, $event)\" :placeholder=\"valuePlaceholderFor(row.rule)\" :aria-label=\"fieldLabelOf(row.rule.field) + ' values'\"></span></template><template x-ifLine truncated
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("matchCountLoading ? 'counting…' : (matchCount === 1 ? '1 " + countNoun + " matches' : matchCount + ' " + countNoun + "s match')") templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("matchCountLoading ? 'counting…' : (matchCount === 1 ? '1 " + countNoun + " matches' : matchCount + ' " + countNoun + "s match')")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `filter_builder.templ`, Line: 101, Col: 238} return templ.Error{Err: templ_7745c5c3_Err, FileName: `filter_builder.templ`, Line: 119, Col: 238}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+49 -7
View File
@@ -380,7 +380,47 @@ describe("id-kind value cells", () => {
children: [{ field: "library", op: "in", values: ["lib-1"] }], children: [{ field: "library", op: "in", values: ["lib-1"] }],
}; };
c.idLabels["lib-1"] = "Books"; 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", () => { 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", op: "in",
values: ["lib-1"], 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", () => { 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.onValueCellInput([0], "com");
c.pickSuggestion("0", [0], { value: "lib-2", display: "Comics", count: 3 }); 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.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 () => { 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")); await vi.waitFor(() => expect(c.idLabels["lib-9"]).toBe("Ebooks"));
// the stored id rides as the typed lookup // the stored id rides as the typed lookup
expect(url).toContain("library=lib-9"); 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 // label resolution never opens a menu
expect(c.suggestionsOpen).toBe(""); expect(c.suggestionsOpen).toBe("");
vi.unstubAllGlobals(); vi.unstubAllGlobals();
@@ -472,7 +513,8 @@ describe("id-kind value cells", () => {
c.idLabels["lib-1"] = "Books"; c.idLabels["lib-1"] = "Books";
c.pickSuggestion("0", [0], { value: "lib-2", display: "Comics", count: 2 }); 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.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", () => { 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 as unknown as { $el: HTMLElement }).$el = el;
c.init(); c.init();
expect(c.filterTree.children).toHaveLength(1); 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(); 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 // could not reach this component), and the cell shows the embedded
// label, not the raw id. // label, not the raw id.
expect(c.filterTree.children).toEqual([{ field: "library", op: "is", values: ["lib-1"] }]); 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(); expect(fetchSpy).not.toHaveBeenCalled();
}); });
}); });
+51 -11
View File
@@ -768,22 +768,62 @@ export function filterBuilderData() {
return FIELD_BY_KEY.get(node.field)?.valueKind === "id"; return FIELD_BY_KEY.get(node.field)?.valueKind === "id";
}, },
// valueCellText — what the value INPUT displays. Id-kind rules keep // idChipsFor — the chipbox's chips: the rule's joined values split
// the ids in rule.values (the clause matches ids); the cell shows // and labeled through idLabels (raw id fallback while resolution is
// their LABELS, or the name text being typed. // in flight or impossible).
valueCellText(path: number[]): string { idChipsFor(path: number[]): { id: string; label: string }[] {
const node = this.nodeAt(path); const node = this.nodeAt(path);
if (!node || !isRule(node)) return ""; if (!node || !isRule(node)) return [];
if (this.isIdValueRow(path)) {
const draft = this.idDrafts[path.join("-")];
if (draft !== undefined) return draft;
return node.values return node.values
.join(",") .join(",")
.split(",") .split(",")
.filter(Boolean) .filter(Boolean)
.map((v) => this.idLabels[v] ?? v) .map((v) => ({ id: v, label: this.idLabels[v] ?? v }));
.join(", "); },
}
// 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 "";
return node.values.join(","); return node.values.join(",");
}, },
+60
View File
@@ -1958,6 +1958,66 @@
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.3rem 0.5rem; padding: 0.3rem 0.5rem;
} }
/* chip-style multi-value box (id-kind cells): picked values render as
removable tags, the box wraps and grows instead of a 9rem input;
the inline input stays for typing/searching */
.fb-chipbox {
flex: 1 1 12rem;
min-width: 0;
max-width: 100%;
min-height: 2.25rem;
padding: 0.2rem 0.4rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
background-color: var(--bg-primary);
cursor: text;
}
.fb-chipbox:focus-within {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
.fb-chip {
display: inline-flex;
align-items: center;
gap: 0.1rem;
max-width: 100%;
padding: 0.05rem 0.1rem 0.05rem 0.5rem;
border-radius: 9999px;
background-color: var(--accent-muted);
color: var(--accent);
font-size: 0.78rem;
line-height: 1.5;
}
.fb-chip-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fb-chip-x {
display: inline-grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
border: 0;
border-radius: 9999px;
background-color: transparent;
color: inherit;
font-size: 0.9rem;
line-height: 1;
cursor: pointer;
}
.fb-chip-x:hover {
background-color: color-mix(in srgb, var(--text-primary) 12%, transparent);
}
.fb-chip-input {
flex: 1 1 6ch;
min-width: 6ch;
width: auto;
border: 0;
background-color: transparent;
padding: 0.2rem 0.25rem;
outline: none;
}
.fb-menu { .fb-menu {
position: absolute; position: absolute;
z-index: 60; z-index: 60;
+1 -1
View File
@@ -1,2 +1,2 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated /*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated