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