Match fields: "any of"/"any but" only with entries, never negate empty

The choice shows once the list has a row (empty is "any"), an empty
list is never sent as negated, a negated list is marked by a red bar
instead of red inputs (which read as errors), and the field's own label
names the choice for screen readers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-23 00:08:21 +01:00
1 parent 4676d17aea
commit 5ec943ebff
3 files changed
+20 -7

No files matched your search

+4 -1
View File
@@ -117,7 +117,10 @@ All notable changes to Housecarl are documented here. The format follows
this version. this version.
- Negating a match is said first: the zone, From and To fields (rules - Negating a match is said first: the zone, From and To fields (rules
and NAT) start with "any of" / "any but" instead of a checkbox under and NAT) start with "any of" / "any but" instead of a checkbox under
the list, and an "any but" list is tinted red. the list, shown once the list has an entry, and an "any but" list is
marked with a red bar. An empty list is never saved as negated (it
matched everything anyway, but would have negated what was added to
it later).
- The rule dialog shows only the zone fields its chain can use: no - The rule dialog shows only the zone fields its chain can use: no
"Going out on" for input and early, no "Coming in on" for output "Going out on" for input and early, no "Coming in on" for output
(they could only be refused). A zone left in a field the chain hides (they could only be refused). A zone left in a field the chain hides
+1 -1
View File
@@ -382,7 +382,7 @@ pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; }
.negatable { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; } .negatable { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; }
.negatable > .list-editor { flex: 1; min-width: 0; } .negatable > .list-editor { flex: 1; min-width: 0; }
.negatable.negated > select { color: var(--err); border-color: var(--err); font-weight: 600; } .negatable.negated > select { color: var(--err); border-color: var(--err); font-weight: 600; }
.negatable.negated .list-row input { background: var(--err-soft); color: var(--del-text); border-color: var(--err); } .negatable.negated .list-rows { border-left: 3px solid var(--err); padding-left: 8px; }
input.short { width: 7em; } input.short { width: 7em; }
.builtins { margin-bottom: 10px; line-height: 1.6; } .builtins { margin-bottom: 10px; line-height: 1.6; }
.policy { margin-top: 10px; } .policy { margin-top: 10px; }
+15 -5
View File
@@ -67,13 +67,23 @@ export function listEditor(values, opts = {}) {
// negatable puts "any of" / "any but" in front of a list editor, so a // negatable puts "any of" / "any but" in front of a list editor, so a
// field says first whether it matches its entries or everything else; // field says first whether it matches its entries or everything else;
// "any but" tints the list. value() is the list, negated() the choice. // "any but" marks the list. The choice shows once the list has a row
// (empty is "any"), and an empty list is never negated. value() is the
// list, negated() the choice.
function negatable(list, not) { function negatable(list, not) {
const mode = select([["", "any of"], ["not", "any but"]], not ? "not" : "", { class: "inline", "aria-label": "match these, or everything but these" }); const mode = select([["", "any of"], ["not", "any but"]], not ? "not" : "", { class: "inline" });
const box = h("div", { class: "negatable" + (not ? " negated" : "") }, mode, list); const box = h("div", { class: "negatable" }, mode, list);
mode.addEventListener("change", () => box.classList.toggle("negated", mode.value === "not")); const rows = () => list.querySelector(".list-rows").children.length;
const update = () => {
mode.classList.toggle("hidden", rows() === 0);
box.classList.toggle("negated", mode.value === "not" && rows() > 0);
};
mode.addEventListener("change", update);
// Add and remove act on their own click first; this runs after.
list.addEventListener("click", update);
update();
box.value = list.value; box.value = list.value;
box.negated = () => mode.value === "not"; box.negated = () => mode.value === "not" && list.value().length > 0;
return box; return box;
} }