fix(html): dedupe ARIA relationship tokens
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-04 01:56:28 +00:00
parent daaae16493
commit 9230d3b66c
2 changed files with 61 additions and 7 deletions

38
node.go
View file

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

View file

@ -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 := `<input aria-describedby="hint-1 hint-2">`
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 := `<input aria-labelledby="label-1 label-2">`
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 := `<button aria-controls="menu-panel shortcut-hints">menu</button>`
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")