From 2ce8876cb5b4324e4655d9580a230503f4a95f99 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 18:59:24 +0000 Subject: [PATCH] fix(html): ignore empty helper tokens Drop empty strings from join-based accessibility helpers and class names so generated attributes stay clean. Co-Authored-By: Virgil --- node.go | 38 ++++++++++++++++++++++++++++++++++---- node_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/node.go b/node.go index 2587011..93aae39 100644 --- a/node.go +++ b/node.go @@ -187,21 +187,30 @@ func AriaLabel(n Node, label string) Node { // Example: AriaDescribedBy(El("input"), "help-text", "error-text"). // Multiple IDs are joined with spaces, matching the HTML attribute format. func AriaDescribedBy(n Node, ids ...string) Node { - return Attr(n, "aria-describedby", strings.Join(ids, " ")) + if value := joinNonEmpty(ids...); value != "" { + return Attr(n, "aria-describedby", value) + } + return n } // node.go: AriaLabelledBy sets the aria-labelledby attribute on an element node. // Example: AriaLabelledBy(El("section"), "section-title"). // Multiple IDs are joined with spaces, matching the HTML attribute format. func AriaLabelledBy(n Node, ids ...string) Node { - return Attr(n, "aria-labelledby", strings.Join(ids, " ")) + if value := joinNonEmpty(ids...); value != "" { + return Attr(n, "aria-labelledby", value) + } + return n } // node.go: AriaControls sets the aria-controls attribute on an element node. // Example: AriaControls(El("button"), "menu-panel"). // Multiple IDs are joined with spaces, matching the HTML attribute format. func AriaControls(n Node, ids ...string) Node { - return Attr(n, "aria-controls", strings.Join(ids, " ")) + if value := joinNonEmpty(ids...); value != "" { + return Attr(n, "aria-controls", value) + } + return n } // node.go: AriaCurrent sets the aria-current attribute on an element node. @@ -278,7 +287,10 @@ func AltText(n Node, text string) Node { // Example: Class(El("div"), "card", "card--primary"). // Multiple class tokens are joined with spaces. func Class(n Node, classes ...string) Node { - return Attr(n, "class", strings.Join(classes, " ")) + if value := joinNonEmpty(classes...); value != "" { + return Attr(n, "class", value) + } + return n } // node.go: AriaHidden sets the aria-hidden attribute on an element node. @@ -338,6 +350,24 @@ func AutoFocus(n Node) Node { return Attr(n, "autofocus", "autofocus") } +func joinNonEmpty(parts ...string) string { + if len(parts) == 0 { + return "" + } + + var filtered []string + for i := range parts { + if parts[i] == "" { + continue + } + filtered = append(filtered, parts[i]) + } + if len(filtered) == 0 { + return "" + } + return strings.Join(filtered, " ") +} + func (n *elNode) Render(ctx *Context) string { if n == nil { return "" diff --git a/node_test.go b/node_test.go index 4e0ccc3..bad25f9 100644 --- a/node_test.go +++ b/node_test.go @@ -265,6 +265,16 @@ func TestAriaDescribedByHelper(t *testing.T) { } } +func TestAriaDescribedByHelper_IgnoresEmptyIDs(t *testing.T) { + ctx := NewContext() + node := AriaDescribedBy(El("input"), "", "hint-1", "", "hint-2") + got := node.Render(ctx) + want := `` + if got != want { + t.Errorf("AriaDescribedBy() with empty IDs = %q, want %q", got, want) + } +} + func TestAriaLabelledByHelper(t *testing.T) { ctx := NewContext() node := AriaLabelledBy(El("input"), "label-1", "label-2") @@ -275,6 +285,16 @@ func TestAriaLabelledByHelper(t *testing.T) { } } +func TestAriaLabelledByHelper_IgnoresEmptyIDs(t *testing.T) { + ctx := NewContext() + node := AriaLabelledBy(El("input"), "", "label-1", "", "label-2") + got := node.Render(ctx) + want := `` + if got != want { + t.Errorf("AriaLabelledBy() with empty IDs = %q, want %q", got, want) + } +} + func TestAriaControlsHelper(t *testing.T) { ctx := NewContext() node := AriaControls(El("button", Raw("menu")), "menu-panel", "shortcut-hints") @@ -285,6 +305,16 @@ func TestAriaControlsHelper(t *testing.T) { } } +func TestAriaControlsHelper_IgnoresEmptyIDs(t *testing.T) { + ctx := NewContext() + node := AriaControls(El("button", Raw("menu")), "", "menu-panel", "") + got := node.Render(ctx) + want := `` + if got != want { + t.Errorf("AriaControls() with empty IDs = %q, want %q", got, want) + } +} + func TestAriaCurrentHelper(t *testing.T) { ctx := NewContext() node := AriaCurrent(El("a", Raw("Home")), "page") @@ -412,6 +442,16 @@ func TestClassHelper(t *testing.T) { } } +func TestClassHelper_IgnoresEmptyClasses(t *testing.T) { + ctx := NewContext() + node := Class(El("div", Raw("content")), "", "card", "", "card--primary") + got := node.Render(ctx) + want := `
content
` + if got != want { + t.Errorf("Class() with empty classes = %q, want %q", got, want) + } +} + func TestAriaHiddenHelper(t *testing.T) { ctx := NewContext()