From 5ec943ebfffc81c70d0f6280646f1e4076460724 Mon Sep 17 00:00:00 2001 From: abrown38 Date: Fri, 23 Oct 2026 00:08:21 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 5 ++++- internal/web/static/assets/style.css | 2 +- internal/web/static/assets/widgets.js | 20 +++++++++++++++----- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccc0205..cb769e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,7 +117,10 @@ All notable changes to Housecarl are documented here. The format follows this version. - 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 - 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 "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 diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index fba6e88..5a193ba 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -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 > .list-editor { flex: 1; min-width: 0; } .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; } .builtins { margin-bottom: 10px; line-height: 1.6; } .policy { margin-top: 10px; } diff --git a/internal/web/static/assets/widgets.js b/internal/web/static/assets/widgets.js index 369767e..d990e92 100644 --- a/internal/web/static/assets/widgets.js +++ b/internal/web/static/assets/widgets.js @@ -67,13 +67,23 @@ export function listEditor(values, opts = {}) { // 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; -// "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) { - const mode = select([["", "any of"], ["not", "any but"]], not ? "not" : "", { class: "inline", "aria-label": "match these, or everything but these" }); - const box = h("div", { class: "negatable" + (not ? " negated" : "") }, mode, list); - mode.addEventListener("change", () => box.classList.toggle("negated", mode.value === "not")); + const mode = select([["", "any of"], ["not", "any but"]], not ? "not" : "", { class: "inline" }); + const box = h("div", { class: "negatable" }, mode, list); + 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.negated = () => mode.value === "not"; + box.negated = () => mode.value === "not" && list.value().length > 0; return box; }