feat(html): add aria details helpers
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 00:13:10 +00:00
parent ad01f04a51
commit 5b69aec575
4 changed files with 64 additions and 2 deletions

View file

@ -45,6 +45,8 @@ Accessibility-oriented helpers are also provided for common attribute patterns:
- `AriaBusy(node, busy)`
- `AriaLive(node, live)`
- `AriaDescription(node, description)`
- `AriaDetails(node, ids...)`
- `AriaErrorMessage(node, ids...)`
- `AriaRoleDescription(node, description)`
- `Role(node, role)`
- `Lang(node, locale)`

View file

@ -39,7 +39,7 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`<header
| Path | Purpose |
|------|---------|
| `node.go` | `Node` interface and all node types: `El`, `Text`, `Raw`, `If`, `Unless`, `Each`, `EachSeq`, `Switch`, `Entitled`, plus `AriaLabel`, `AriaControls`, `AriaHasPopup`, `AriaOwns`, `AriaKeyShortcuts`, `Alt`/`AltText`, `AriaBusy`, `AriaLive`, `AriaDescription`, `AriaRoleDescription`, `AriaDisabled`, `AriaModal`, `AriaChecked`, `AriaInvalid`, `AriaRequired`, `AriaPressed`, `AriaSelected`, `Hidden`, `TabIndex`, and `AutoFocus` helpers |
| `node.go` | `Node` interface and all node types: `El`, `Text`, `Raw`, `If`, `Unless`, `Each`, `EachSeq`, `Switch`, `Entitled`, plus `AriaLabel`, `AriaControls`, `AriaHasPopup`, `AriaOwns`, `AriaKeyShortcuts`, `Alt`/`AltText`, `AriaBusy`, `AriaLive`, `AriaDescription`, `AriaDetails`, `AriaErrorMessage`, `AriaRoleDescription`, `AriaDisabled`, `AriaModal`, `AriaChecked`, `AriaInvalid`, `AriaRequired`, `AriaPressed`, `AriaSelected`, `Hidden`, `TabIndex`, and `AutoFocus` helpers |
| `layout.go` | HLCRF compositor with semantic HTML elements and ARIA roles |
| `responsive.go` | Multi-variant breakpoint wrapper (`data-variant` containers, CSS scoping helpers) |
| `context.go` | Rendering context: identity, locale, entitlements, i18n service |
@ -52,7 +52,7 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`<header
## Key Concepts
**Node tree** -- All renderable units implement `Node`, a single-method interface: `Render(ctx *Context) string`. The library composes nodes into trees using `El()` for elements, `Text()` for translated text, and control-flow constructors (`If`, `Unless`, `Each`, `Switch`, `Entitled`), plus accessibility and visibility helpers such as `AriaLabel()`, `AriaControls()`, `AriaHasPopup()`, `AriaOwns()`, `AriaKeyShortcuts()`, `AriaCurrent()`, `AriaBusy()`, `AriaLive()`, `AriaDescription()`, `AriaRoleDescription()`, `AriaHidden()`, `Hidden()`, `AriaDisabled()`, `AriaModal()`, `AriaChecked()`, `AriaInvalid()`, `AriaRequired()`, `AriaReadOnly()`, and `TabIndex()`.
**Node tree** -- All renderable units implement `Node`, a single-method interface: `Render(ctx *Context) string`. The library composes nodes into trees using `El()` for elements, `Text()` for translated text, and control-flow constructors (`If`, `Unless`, `Each`, `Switch`, `Entitled`), plus accessibility and visibility helpers such as `AriaLabel()`, `AriaControls()`, `AriaHasPopup()`, `AriaOwns()`, `AriaKeyShortcuts()`, `AriaCurrent()`, `AriaBusy()`, `AriaLive()`, `AriaDescription()`, `AriaDetails()`, `AriaErrorMessage()`, `AriaRoleDescription()`, `AriaHidden()`, `Hidden()`, `AriaDisabled()`, `AriaModal()`, `AriaChecked()`, `AriaInvalid()`, `AriaRequired()`, `AriaReadOnly()`, and `TabIndex()`.
**HLCRF Layout** -- A five-slot compositor that maps to semantic HTML: `<header>` (H), `<aside>` (L/R), `<main>` (C), `<footer>` (F). The variant string controls which slots render: `"HLCRF"` for all five, `"HCF"` for three, `"C"` for content only. Layouts nest: placing a `Layout` inside another layout's slot produces hierarchical `data-block` paths like `L-0-C-0`.

20
node.go
View file

@ -299,6 +299,26 @@ func AriaDescription(n Node, description string) Node {
return n
}
// node.go: AriaDetails sets the aria-details attribute on an element 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 != "" {
return Attr(n, "aria-details", value)
}
return n
}
// node.go: AriaErrorMessage sets the aria-errormessage attribute on an element 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 != "" {
return Attr(n, "aria-errormessage", value)
}
return n
}
// node.go: AriaRoleDescription sets the aria-roledescription attribute on an
// element node.
// Example: AriaRoleDescription(El("section"), "carousel").

View file

@ -494,6 +494,46 @@ func TestAriaDescriptionHelper_IgnoresWhitespace(t *testing.T) {
}
}
func TestAriaDetailsHelper(t *testing.T) {
ctx := NewContext()
node := AriaDetails(El("input"), "field-help", "usage-help")
got := node.Render(ctx)
want := `<input aria-details="field-help usage-help">`
if got != want {
t.Errorf("AriaDetails() = %q, want %q", got, want)
}
}
func TestAriaDetailsHelper_IgnoresWhitespaceIDs(t *testing.T) {
ctx := NewContext()
node := AriaDetails(El("input"), "", "field-help", " ", "usage-help")
got := node.Render(ctx)
want := `<input aria-details="field-help usage-help">`
if got != want {
t.Errorf("AriaDetails() with whitespace IDs = %q, want %q", got, want)
}
}
func TestAriaErrorMessageHelper(t *testing.T) {
ctx := NewContext()
node := AriaErrorMessage(El("input"), "field-error")
got := node.Render(ctx)
want := `<input aria-errormessage="field-error">`
if got != want {
t.Errorf("AriaErrorMessage() = %q, want %q", got, want)
}
}
func TestAriaErrorMessageHelper_IgnoresWhitespaceIDs(t *testing.T) {
ctx := NewContext()
node := AriaErrorMessage(El("input"), " ", "field-error", "")
got := node.Render(ctx)
want := `<input aria-errormessage="field-error">`
if got != want {
t.Errorf("AriaErrorMessage() with whitespace IDs = %q, want %q", got, want)
}
}
func TestAriaRoleDescriptionHelper(t *testing.T) {
ctx := NewContext()
node := AriaRoleDescription(El("section", Raw("content")), "carousel")