feat(html): add responsive variant selector helper
All checks were successful
Security Scan / security (push) Successful in 16s
Test / test (push) Successful in 1m4s

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-31 19:44:30 +00:00
parent c1852f86aa
commit c63f0a2cbe
5 changed files with 65 additions and 3 deletions

View file

@ -165,6 +165,8 @@ html.NewResponsive().
Each variant renders inside a `<div data-variant="name">` container. Variants render in insertion order. CSS media queries or JavaScript can target these containers for show/hide logic.
`VariantSelector(name)` returns a CSS attribute selector for a specific responsive variant, making stylesheet targeting less error-prone than hand-writing the attribute selector repeatedly.
`Responsive` implements `Node`, so it can be passed to `Render()` or `Imprint()`. The `Variant()` method accepts `*Layout` specifically, not arbitrary `Node` values.
Each variant maintains independent block ID namespaces -- nesting a layout inside a responsive variant does not conflict with the same layout structure in another variant.

View file

@ -105,7 +105,7 @@ These are not regressions; they are design choices or deferred work recorded for
5. **TypeScript definitions not generated.** `codegen.GenerateBundle()` produces JS only. A `.d.ts` companion would benefit TypeScript consumers of the generated Web Components.
6. **No CSS scoping helper.** Responsive variants are identified by `data-variant` attributes. Targeting them from CSS requires knowledge of the attribute name. An optional utility for generating scoped CSS selectors is deferred.
6. **CSS scoping helper added.** `VariantSelector(name)` returns a reusable `data-variant` attribute selector for stylesheet targeting. The `Responsive` rendering model remains unchanged.
7. **Browser polyfill matrix not documented.** Closed Shadow DOM is well-supported but older browsers require polyfills. The support matrix is not documented.
@ -115,5 +115,6 @@ These items were captured during the WASM size reduction work and expert review
- **TypeScript type definitions** alongside `GenerateBundle()` for typed Web Component consumers.
- **Accessibility helpers**`aria-label` builder, `alt` text helpers, and focus management helpers (`TabIndex`, `AutoFocus`). The layout has semantic HTML and ARIA roles but no API for fine-grained accessibility attributes beyond `Attr()`.
- **Responsive CSS helpers**`VariantSelector(name)` makes `data-variant` targeting explicit and reusable in stylesheets.
- **Layout variant validation** — return a warning or sentinel error from `NewLayout` when the variant string contains unrecognised slot characters.
- **Daemon mode for codegen** — watch mode for regenerating the JS bundle when slot config changes, for development workflows.

View file

@ -41,7 +41,7 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`<header
|------|---------|
| `node.go` | `Node` interface and all node types: `El`, `Text`, `Raw`, `If`, `Unless`, `Each`, `EachSeq`, `Switch`, `Entitled`, plus `AriaLabel`, `AltText`, `TabIndex`, and `AutoFocus` helpers |
| `layout.go` | HLCRF compositor with semantic HTML elements and ARIA roles |
| `responsive.go` | Multi-variant breakpoint wrapper (`data-variant` containers) |
| `responsive.go` | Multi-variant breakpoint wrapper (`data-variant` containers) and CSS selector helper |
| `context.go` | Rendering context: identity, locale, entitlements, i18n service |
| `render.go` | `Render()` convenience function |
| `path.go` | `ParseBlockID()` for decoding `data-block` path attributes |
@ -56,7 +56,7 @@ This builds a Header-Content-Footer layout with semantic HTML elements (`<header
**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`.
**Responsive variants** -- `Responsive` wraps multiple `Layout` instances with named breakpoints (e.g. `"desktop"`, `"mobile"`). Each variant renders inside a `<div data-variant="name">` container for CSS or JavaScript targeting.
**Responsive variants** -- `Responsive` wraps multiple `Layout` instances with named breakpoints (e.g. `"desktop"`, `"mobile"`). Each variant renders inside a `<div data-variant="name">` container for CSS or JavaScript targeting. `VariantSelector(name)` returns a ready-made attribute selector for styling these containers from CSS.
**Grammar pipeline** -- Server-side only. `Imprint()` renders a node tree to HTML, strips tags, tokenises the plain text via `go-i18n/reversal`, and returns a `GrammarImprint` for semantic analysis. `CompareVariants()` computes pairwise similarity scores across responsive variants.

View file

@ -1,5 +1,10 @@
package html
import (
"strconv"
"strings"
)
// Compile-time interface check.
var _ Node = (*Responsive)(nil)
@ -56,3 +61,41 @@ func (r *Responsive) Render(ctx *Context) string {
}
return b.String()
}
// VariantSelector returns a CSS attribute selector for a responsive variant.
// Usage example: selector := VariantSelector("desktop")
func VariantSelector(name string) string {
return `[data-variant="` + escapeCSSString(name) + `"]`
}
func escapeCSSString(s string) string {
if s == "" {
return ""
}
var b strings.Builder
for _, r := range s {
switch r {
case '\\', '"':
b.WriteByte('\\')
b.WriteRune(r)
case '\n':
b.WriteString(`\A `)
case '\r':
b.WriteString(`\D `)
case '\f':
b.WriteString(`\C `)
case '\t':
b.WriteString(`\9 `)
default:
if r < 0x20 || r == 0x7f {
b.WriteByte('\\')
b.WriteString(strings.ToUpper(strconv.FormatInt(int64(r), 16)))
b.WriteByte(' ')
continue
}
b.WriteRune(r)
}
}
return b.String()
}

View file

@ -110,3 +110,19 @@ func TestResponsive_Render_NilContext_Good(t *testing.T) {
t.Fatalf("responsive.Render(nil) = %q, want %q", got, want)
}
}
func TestVariantSelector_Good(t *testing.T) {
got := VariantSelector("desktop")
want := `[data-variant="desktop"]`
if got != want {
t.Fatalf("VariantSelector(%q) = %q, want %q", "desktop", got, want)
}
}
func TestVariantSelector_Escapes_Good(t *testing.T) {
got := VariantSelector("desk\"top\\wide")
want := `[data-variant="desk\"top\\wide"]`
if got != want {
t.Fatalf("VariantSelector escaping = %q, want %q", got, want)
}
}