fix(html): ignore empty helper tokens
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

Drop empty strings from join-based accessibility helpers and class names so generated attributes stay clean.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-03 18:59:24 +00:00
parent 511a10f54b
commit 2ce8876cb5
2 changed files with 74 additions and 4 deletions

38
node.go
View file

@ -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 ""

View file

@ -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 := `<input aria-describedby="hint-1 hint-2">`
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 := `<input aria-labelledby="label-1 label-2">`
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 := `<button aria-controls="menu-panel">menu</button>`
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 := `<div class="card card--primary">content</div>`
if got != want {
t.Errorf("Class() with empty classes = %q, want %q", got, want)
}
}
func TestAriaHiddenHelper(t *testing.T) {
ctx := NewContext()