From 9230d3b66cd6b74a82983a7ef2187e4d6b5b8324 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 01:56:28 +0000 Subject: [PATCH] fix(html): dedupe ARIA relationship tokens Co-Authored-By: Virgil --- node.go | 38 +++++++++++++++++++++++++++++++------- node_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/node.go b/node.go index 9cf5843..2177491 100644 --- a/node.go +++ b/node.go @@ -226,7 +226,7 @@ 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 { - if value := joinNonEmpty(ids...); value != "" { + if value := joinUniqueNonEmpty(ids...); value != "" { return Attr(n, "aria-describedby", value) } return n @@ -236,7 +236,7 @@ func AriaDescribedBy(n Node, ids ...string) 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 { - if value := joinNonEmpty(ids...); value != "" { + if value := joinUniqueNonEmpty(ids...); value != "" { return Attr(n, "aria-labelledby", value) } return n @@ -246,7 +246,7 @@ func AriaLabelledBy(n Node, ids ...string) 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 { - if value := joinNonEmpty(ids...); value != "" { + if value := joinUniqueNonEmpty(ids...); value != "" { return Attr(n, "aria-controls", value) } return n @@ -266,7 +266,7 @@ func AriaHasPopup(n Node, popup string) Node { // Example: AriaOwns(El("div"), "owned-panel"). // Multiple IDs are joined with spaces, matching the HTML attribute format. func AriaOwns(n Node, ids ...string) Node { - if value := joinNonEmpty(ids...); value != "" { + if value := joinUniqueNonEmpty(ids...); value != "" { return Attr(n, "aria-owns", value) } return n @@ -276,7 +276,7 @@ func AriaOwns(n Node, ids ...string) Node { // Example: AriaKeyShortcuts(El("button"), "Ctrl+S", "Meta+S"). // Multiple shortcuts are joined with spaces, matching the HTML attribute format. func AriaKeyShortcuts(n Node, shortcuts ...string) Node { - if value := joinNonEmpty(shortcuts...); value != "" { + if value := joinUniqueNonEmpty(shortcuts...); value != "" { return Attr(n, "aria-keyshortcuts", value) } return n @@ -334,7 +334,7 @@ func AriaDescription(n Node, description string) Node { // Example: AriaDetails(El("input"), "field-help"). // Multiple IDs are joined with spaces, matching the HTML attribute format. func AriaDetails(n Node, ids ...string) Node { - if value := joinNonEmpty(ids...); value != "" { + if value := joinUniqueNonEmpty(ids...); value != "" { return Attr(n, "aria-details", value) } return n @@ -344,7 +344,7 @@ func AriaDetails(n Node, ids ...string) Node { // Example: AriaErrorMessage(El("input"), "field-error"). // Multiple IDs are joined with spaces, matching the HTML attribute format. func AriaErrorMessage(n Node, ids ...string) Node { - if value := joinNonEmpty(ids...); value != "" { + if value := joinUniqueNonEmpty(ids...); value != "" { return Attr(n, "aria-errormessage", value) } return n @@ -637,6 +637,30 @@ func joinNonEmpty(parts ...string) string { return strings.Join(filtered, " ") } +func joinUniqueNonEmpty(parts ...string) string { + if len(parts) == 0 { + return "" + } + + seen := make(map[string]struct{}, len(parts)) + filtered := make([]string, 0, len(parts)) + for i := range parts { + part := strings.TrimSpace(parts[i]) + if part == "" { + continue + } + if _, ok := seen[part]; ok { + continue + } + seen[part] = struct{}{} + filtered = append(filtered, part) + } + if len(filtered) == 0 { + return "" + } + return strings.Join(filtered, " ") +} + func trimmedNonEmpty(value string) string { value = strings.TrimSpace(value) if value == "" { diff --git a/node_test.go b/node_test.go index 6df57fe..bf0fcdb 100644 --- a/node_test.go +++ b/node_test.go @@ -295,6 +295,16 @@ func TestAriaDescribedByHelper_IgnoresWhitespaceIDs(t *testing.T) { } } +func TestAriaDescribedByHelper_DeduplicatesIDs(t *testing.T) { + ctx := NewContext() + node := AriaDescribedBy(El("input"), "hint-1", "hint-1", "hint-2", "hint-2") + got := node.Render(ctx) + want := `` + if got != want { + t.Errorf("AriaDescribedBy() with duplicate IDs = %q, want %q", got, want) + } +} + func TestAriaLabelledByHelper(t *testing.T) { ctx := NewContext() node := AriaLabelledBy(El("input"), "label-1", "label-2") @@ -315,6 +325,16 @@ func TestAriaLabelledByHelper_IgnoresEmptyIDs(t *testing.T) { } } +func TestAriaLabelledByHelper_DeduplicatesIDs(t *testing.T) { + ctx := NewContext() + node := AriaLabelledBy(El("input"), "label-1", "label-1", "label-2", "label-2") + got := node.Render(ctx) + want := `` + if got != want { + t.Errorf("AriaLabelledBy() with duplicate IDs = %q, want %q", got, want) + } +} + func TestAriaControlsHelper(t *testing.T) { ctx := NewContext() node := AriaControls(El("button", Raw("menu")), "menu-panel", "shortcut-hints") @@ -335,6 +355,16 @@ func TestAriaControlsHelper_IgnoresEmptyIDs(t *testing.T) { } } +func TestAriaControlsHelper_DeduplicatesIDs(t *testing.T) { + ctx := NewContext() + node := AriaControls(El("button", Raw("menu")), "menu-panel", "menu-panel", "shortcut-hints") + got := node.Render(ctx) + want := `` + if got != want { + t.Errorf("AriaControls() with duplicate IDs = %q, want %q", got, want) + } +} + func TestAriaHasPopupHelper(t *testing.T) { ctx := NewContext() node := AriaHasPopup(El("button", Raw("menu")), "menu")