diff --git a/README.md b/README.md index 356179a..447dde0 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,32 @@ renders: ``` +### Functional Attributes + +There is a slight overhead to this approach, it's down to personal taste. + +```go +import ( + "github.com/accentdesign/gtml" + "github.com/accentdesign/gtml/attrs" +) + +field = gtml.Div( + gtml.NA, + gtml.Label(attrs.For("email"), gtml.Text("Email")), + gtml.Input(attrs.Merge( + attrs.Class("form-input"), + attrs.ID("email"), + attrs.Placeholder("email@example.com"), + attrs.Required(true), + attrs.Type("email"), + )), + gtml.P(attrs.Class("help-text"), gtml.Text("Your email address.")), +) + +field.Render(context.Background(), os.Stdout) +``` + ### Templ gtml is designed to work with [templ](https://templ.guide). It implements the `templ.Component` interface. diff --git a/attrs/attrs.go b/attrs/attrs.go new file mode 100644 index 0000000..2545dab --- /dev/null +++ b/attrs/attrs.go @@ -0,0 +1,303 @@ +package attrs + +import ( + "github.com/a-h/templ" + "github.com/accentdesign/gtml" +) + +func Accept(value string) gtml.Attrs { + return gtml.Attrs{"accept": value} +} + +func AccessKey(value string) gtml.Attrs { + return gtml.Attrs{"accesskey": value} +} + +func Action(value string) gtml.Attrs { + return gtml.Attrs{"action": value} +} + +func Alt(value string) gtml.Attrs { + return gtml.Attrs{"alt": value} +} + +func Aria(attr, value string) gtml.Attrs { + return gtml.Attrs{"aria-" + attr: value} +} + +func AutoComplete(value string) gtml.Attrs { + return gtml.Attrs{"autocomplete": value} +} + +func AutoFocus(value bool) gtml.Attrs { + return gtml.Attrs{"autofocus": value} +} + +func Charset(value string) gtml.Attrs { + return gtml.Attrs{"charset": value} +} + +func Checked(value bool) gtml.Attrs { + return gtml.Attrs{"checked": value} +} + +func Class(values ...string) gtml.Attrs { + return gtml.Attrs{"class": templ.Classes(values).String()} +} + +func ColSpan(value string) gtml.Attrs { + return gtml.Attrs{"colspan": value} +} + +func Data(value string) gtml.Attrs { + return gtml.Attrs{"data": value} +} + +func Dir(value string) gtml.Attrs { + return gtml.Attrs{"dir": value} +} + +func Disabled(value bool) gtml.Attrs { + return gtml.Attrs{"disabled": value} +} + +func EncType(value string) gtml.Attrs { + return gtml.Attrs{"enctype": value} +} + +func For(value string) gtml.Attrs { + return gtml.Attrs{"for": value} +} + +func Form(value string) gtml.Attrs { + return gtml.Attrs{"form": value} +} + +func Height(value string) gtml.Attrs { + return gtml.Attrs{"height": value} +} + +func Hidden(value bool) gtml.Attrs { + return gtml.Attrs{"hidden": value} +} + +func Href(value string) gtml.Attrs { + return gtml.Attrs{"href": value} +} + +func HxBoost(value string) gtml.Attrs { + return gtml.Attrs{"hx-boost": value} +} + +func HxConfirm(value string) gtml.Attrs { + return gtml.Attrs{"hx-confirm": value} +} + +func HxDelete(value string) gtml.Attrs { + return gtml.Attrs{"hx-delete": value} +} + +func HxGet(value string) gtml.Attrs { + return gtml.Attrs{"hx-get": value} +} + +func HxInclude(value string) gtml.Attrs { + return gtml.Attrs{"hx-include": value} +} + +func HxInherit(value string) gtml.Attrs { + return gtml.Attrs{"hx-inherit": value} +} + +func HxPost(value string) gtml.Attrs { + return gtml.Attrs{"hx-post": value} +} + +func HxPushUrl(value string) gtml.Attrs { + return gtml.Attrs{"hx-push-url": value} +} + +func HxReplaceUrl(value string) gtml.Attrs { + return gtml.Attrs{"hx-replace-url": value} +} + +func HxSelect(value string) gtml.Attrs { + return gtml.Attrs{"hx-select": value} +} + +func HxSelectOob(value string) gtml.Attrs { + return gtml.Attrs{"hx-select-oob": value} +} + +func HxSwap(value string) gtml.Attrs { + return gtml.Attrs{"hx-swap": value} +} + +func HxSwapOob(value string) gtml.Attrs { + return gtml.Attrs{"hx-swap-oob": value} +} + +func HxTarget(value string) gtml.Attrs { + return gtml.Attrs{"hx-target": value} +} + +func HxTrigger(value string) gtml.Attrs { + return gtml.Attrs{"hx-trigger": value} +} + +func ID(value string) gtml.Attrs { + return gtml.Attrs{"id": value} +} + +func Lang(value string) gtml.Attrs { + return gtml.Attrs{"lang": value} +} + +func Max(value string) gtml.Attrs { + return gtml.Attrs{"max": value} +} + +func MaxLength(value string) gtml.Attrs { + return gtml.Attrs{"maxlength": value} +} + +func Media(value string) gtml.Attrs { + return gtml.Attrs{"media": value} +} + +func Method(value string) gtml.Attrs { + return gtml.Attrs{"method": value} +} + +func Min(value string) gtml.Attrs { + return gtml.Attrs{"min": value} +} + +func MinLength(value string) gtml.Attrs { + return gtml.Attrs{"minlength": value} +} + +func Multiple(value bool) gtml.Attrs { + return gtml.Attrs{"multiple": value} +} + +func Name(value string) gtml.Attrs { + return gtml.Attrs{"name": value} +} + +func NoValidate(value bool) gtml.Attrs { + return gtml.Attrs{"novalidate": value} +} + +func Pattern(value string) gtml.Attrs { + return gtml.Attrs{"pattern": value} +} + +func Placeholder(value string) gtml.Attrs { + return gtml.Attrs{"placeholder": value} +} + +func ReadOnly(value bool) gtml.Attrs { + return gtml.Attrs{"readonly": value} +} + +func Rel(value string) gtml.Attrs { + return gtml.Attrs{"rel": value} +} + +func Required(value bool) gtml.Attrs { + return gtml.Attrs{"required": value} +} + +func Role(value string) gtml.Attrs { + return gtml.Attrs{"role": value} +} + +func RowSpan(value string) gtml.Attrs { + return gtml.Attrs{"rowspan": value} +} + +func Selected(value bool) gtml.Attrs { + return gtml.Attrs{"selected": value} +} + +func Size(value string) gtml.Attrs { + return gtml.Attrs{"size": value} +} + +func Sizes(value string) gtml.Attrs { + return gtml.Attrs{"sizes": value} +} + +func Span(value string) gtml.Attrs { + return gtml.Attrs{"span": value} +} + +func SpellCheck(value string) gtml.Attrs { + return gtml.Attrs{"spellcheck": value} +} + +func Src(value string) gtml.Attrs { + return gtml.Attrs{"src": value} +} + +func SrcSet(value string) gtml.Attrs { + return gtml.Attrs{"srcset": value} +} + +func Step(value string) gtml.Attrs { + return gtml.Attrs{"step": value} +} + +func Style(value string) gtml.Attrs { + return gtml.Attrs{"style": value} +} + +func TabIndex(value string) gtml.Attrs { + return gtml.Attrs{"tabindex": value} +} + +func Target(value string) gtml.Attrs { + return gtml.Attrs{"target": value} +} + +func Title(value string) gtml.Attrs { + return gtml.Attrs{"title": value} +} + +func Translate(value string) gtml.Attrs { + return gtml.Attrs{"translate": value} +} + +func Type(value string) gtml.Attrs { + return gtml.Attrs{"type": value} +} + +func Value(value string) gtml.Attrs { + return gtml.Attrs{"value": value} +} + +func Width(value string) gtml.Attrs { + return gtml.Attrs{"width": value} +} + +func Empty() gtml.Attrs { + return gtml.Attrs{} +} + +func If(cond bool, attrs gtml.Attrs) gtml.Attrs { + if cond { + return attrs + } + return Empty() +} + +func Merge(attrs ...gtml.Attrs) gtml.Attrs { + var res = gtml.Attrs{} + for _, attr := range attrs { + for k, v := range attr { + res[k] = v + } + } + return res +} diff --git a/attrs/attrs_test.go b/attrs/attrs_test.go new file mode 100644 index 0000000..c0450dc --- /dev/null +++ b/attrs/attrs_test.go @@ -0,0 +1,204 @@ +package attrs + +import ( + "bytes" + "context" + "github.com/accentdesign/gtml" + "testing" +) + +func TestStringAttributes(t *testing.T) { + tests := []struct { + name string + fnc func(string) gtml.Attrs + expectedAttr string + expectedValue string + }{ + {"Accept", Accept, "accept", "image/*"}, + {"AccessKey", AccessKey, "accesskey", "a"}, + {"Action", Action, "action", "a"}, + {"Alt", Alt, "alt", "a"}, + {"AutoComplete", AutoComplete, "autocomplete", "on"}, + {"Charset", Charset, "charset", "utf-8"}, + {"ColSpan", ColSpan, "colspan", "2"}, + {"Data", Data, "data", "d"}, + {"Dir", Dir, "dir", "ltr"}, + {"EncType", EncType, "enctype", "multipart/form-data"}, + {"For", For, "for", "i"}, + {"Form", Form, "form", "f"}, + {"Height", Height, "height", "100"}, + {"Href", Href, "href", "/home"}, + {"HxBoost", HxBoost, "hx-boost", "true"}, + {"HxConfirm", HxConfirm, "hx-confirm", "Are you sure?"}, + {"HxDelete", HxDelete, "hx-delete", "/home"}, + {"HxGet", HxGet, "hx-get", "/home"}, + {"HxInclude", HxInclude, "hx-include", "#id"}, + {"HxInherit", HxInherit, "hx-inherit", "hx-target"}, + {"HxPost", HxPost, "hx-post", "/home"}, + {"HxPushUrl", HxPushUrl, "hx-push-url", "true"}, + {"HxReplaceUrl", HxReplaceUrl, "hx-replace-url", "true"}, + {"HxSelect", HxSelect, "hx-select", "#id"}, + {"HxSelectOob", HxSelectOob, "hx-select-oob", "#id"}, + {"HxSwap", HxSwap, "hx-swap", "outerHTML"}, + {"HxSwapOob", HxSwapOob, "hx-swap-oob", "true"}, + {"hxTarget", HxTarget, "hx-target", "#id"}, + {"HxTrigger", HxTrigger, "hx-trigger", "click"}, + {"ID", ID, "id", "i"}, + {"Lang", Lang, "lang", "en"}, + {"Max", Max, "max", "10"}, + {"MaxLength", MaxLength, "maxlength", "10"}, + {"Media", Media, "media", "screen"}, + {"Method", Method, "method", "get"}, + {"Min", Min, "min", "10"}, + {"MinLength", MinLength, "minlength", "10"}, + {"Name", Name, "name", "foo"}, + {"Pattern", Pattern, "pattern", "w{3,16}"}, + {"Placeholder", Placeholder, "placeholder", "foo"}, + {"Rel", Rel, "rel", "stylesheet"}, + {"Role", Role, "role", "button"}, + {"RowSpan", RowSpan, "rowspan", "2"}, + {"Size", Size, "size", "10"}, + {"Sizes", Sizes, "sizes", "(max-width: 600px) 480px, 800px"}, + {"Span", Span, "span", "2"}, + {"SpellCheck", SpellCheck, "spellcheck", "true"}, + {"Src", Src, "src", "image.png"}, + {"SrcSet", SrcSet, "srcset", "elva-fairy-480w.jpg 480w, elva-fairy-800w.jpg 800w"}, + {"Step", Step, "step", "10"}, + {"Style", Style, "style", "width:100%"}, + {"TabIndex", TabIndex, "tabindex", "1"}, + {"Target", Target, "target", "_blank"}, + {"Title", Title, "title", "t"}, + {"Translate", Translate, "translate", "yes"}, + {"Type", Type, "type", "text"}, + {"Name", Name, "name", "foo"}, + {"Value", Value, "value", "foo"}, + {"Width", Width, "width", "100"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + attr := test.fnc(test.expectedValue) + if attr[test.expectedAttr] != test.expectedValue { + t.Errorf("Expected %s to be %s, got %s", test.expectedAttr, test.expectedValue, attr[test.expectedAttr]) + } + }) + } +} + +func TestVariadicAttributes(t *testing.T) { + tests := []struct { + name string + fnc func(...string) gtml.Attrs + params []string + expectedAttr string + expectedValue string + }{ + {"Class", Class, []string{"foo", "bar"}, "class", "foo bar"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + attr := test.fnc(test.params...) + if attr[test.expectedAttr] != test.expectedValue { + t.Errorf("Expected %s to be %s, got %s", test.expectedAttr, test.expectedValue, attr[test.expectedAttr]) + } + }) + } +} + +func TestBooleanAttributes(t *testing.T) { + tests := []struct { + name string + fnc func(bool) gtml.Attrs + expectedAttr string + expectedValue bool + }{ + {"AutoFocus", AutoFocus, "autofocus", true}, + {"Checked", Checked, "checked", true}, + {"Disabled", Disabled, "disabled", true}, + {"Hidden", Hidden, "hidden", true}, + {"Multiple", Multiple, "multiple", true}, + {"NoValidate", NoValidate, "novalidate", true}, + {"ReadOnly", ReadOnly, "readonly", true}, + {"Required", Required, "required", true}, + {"Selected", Selected, "selected", true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + attr := test.fnc(test.expectedValue) + if attr[test.expectedAttr] != test.expectedValue { + t.Errorf("Expected %s to be %v, got %v", test.expectedAttr, test.expectedValue, attr[test.expectedAttr]) + } + }) + } +} + +func TestAriaAttributes(t *testing.T) { + t.Run("Aria prefixes attributes with 'aria-'", func(t *testing.T) { + res := Aria("selected", "true") + if res["aria-selected"] != "true" { + t.Errorf("Expected aria-selected to be 'true', got %s", res["aria-selected"]) + } + }) +} + +func TestEmpty(t *testing.T) { + a := Empty() + if len(a) != 0 { + t.Errorf("Expected no attributes, got %v", a) + } +} + +func TestIf(t *testing.T) { + tests := []struct { + name string + condition bool + expected gtml.Attrs + }{ + {"true", true, gtml.Attrs{"foo": "bar"}}, + {"false", false, gtml.Attrs{}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + a := If(test.condition, gtml.Attrs{"foo": "bar"}) + if len(a) != len(test.expected) { + t.Errorf("Expected %d attributes, got %d", len(test.expected), len(a)) + } + for k, v := range test.expected { + if a[k] != v { + t.Errorf("Expected %s to be %s, got %s", k, v, a[k]) + } + } + }) + } +} + +func TestMerge(t *testing.T) { + a := Merge(ID("some-id"), Class("foo", "bar")) + if a["id"] != "some-id" { + t.Errorf("Expected id to be 'some-id', got %s", a["id"]) + } + if a["class"] != "foo bar" { + t.Errorf("Expected class to be 'foo bar', got %s", a["class"]) + } +} + +func BenchmarkBasicAttrRender(b *testing.B) { + for i := 0; i < b.N; i++ { + node := gtml.Div(gtml.Attrs{"id": "div", "disabled": true}, gtml.Text("Hello")) + var buf bytes.Buffer + err := node.Render(context.Background(), &buf) + if err != nil { + b.Fatalf("Render failed: %v", err) + } + } +} + +func BenchmarkFuncAttrRender(b *testing.B) { + for i := 0; i < b.N; i++ { + node := gtml.Div(Merge(ID("id"), Disabled(true)), gtml.Text("Hello")) + var buf bytes.Buffer + err := node.Render(context.Background(), &buf) + if err != nil { + b.Fatalf("Render failed: %v", err) + } + } +} diff --git a/examples/attributes/main.go b/examples/attributes/main.go new file mode 100644 index 0000000..ff8b7a0 --- /dev/null +++ b/examples/attributes/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "fmt" + "github.com/accentdesign/gtml" + "github.com/accentdesign/gtml/attrs" + "os" + "time" +) + +var field = gtml.Div( + attrs.Class("field"), + gtml.Label(attrs.For("id_field"), gtml.Text("Field")), + gtml.Input(attrs.Merge( + attrs.Class("input"), + attrs.ID("id_field"), + attrs.Placeholder("Enter something..."), + attrs.Required(true), + attrs.Type("text"), + )), + gtml.P(attrs.Class("help"), gtml.Text("Help text.")), +) + +func main() { + defer func(start time.Time) { + fmt.Println("") + fmt.Println(time.Since(start)) + }(time.Now()) + + _ = field.Render(context.Background(), os.Stdout) +}