Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplifying the use of Templ components with HTMX #229

Closed
gungun974 opened this issue Oct 12, 2023 · 21 comments
Closed

Simplifying the use of Templ components with HTMX #229

gungun974 opened this issue Oct 12, 2023 · 21 comments

Comments

@gungun974
Copy link
Contributor

I was working on an HTMX project with Templ and I was wondering if there was a simpler way to use Templ components with HTMX.

In my application, to avoid reinventing my button everywhere, I created a Component like this :

type AppButtonParams struct {
  Variant string
  Size    string
  Class   string
}

templ AppButton(params AppButtonParams) {
  <button
    class={ 
      templ.SafeClass(appButtonVariants(params.Variant, params.Size)),
      templ.SafeClass(params.Class),
    }
  >
    { children... }
  </button>
}

The challenge now is that I want to use an hx-get attribute for a specific button. With the current syntax of these components, I can't transparently pass new attributes to my AppButton component since it's a function.

Is the only way to pass props to a Templ component by passing values into the function, or is there a syntax sugar I can use to simplify this code ?

type HTMXParams struct {
  Get    string
  Target string
  Swap   string
}

type AppButtonParams struct {
  Variant string
  Size    string
  Class   string
  HTMX    HTMXParams
}

templ AppButton(params AppButtonParams) {
  <button
    class={ 
      templ.SafeClass(appButtonVariants(params.Variant, params.Size)),
      templ.SafeClass(params.Class),
    }
    if !helpers.IsEmptyOrWhitespace(params.HTMX.Get) {
      hx-get={ params.HTMX.Get }
    }
    if !helpers.IsEmptyOrWhitespace(params.HTMX.Target) {
      hx-target={ params.HTMX.Target }
    }
    if !helpers.IsEmptyOrWhitespace(params.HTMX.Swap) {
      hx-swap={ params.HTMX.Swap }
    }
  >
    { children... }
  </button>
}
@scerickson
Copy link

scerickson commented Oct 12, 2023

Hey @gungun974, I'm just getting to know Templ, but seems you could separate out the attribute rendering and define your own types for HTMX params... Haven't tested this, but would it work for to do the following?:

type HTMXParams struct {
  Get    string
  Target string
  Swap   string
}

type AppButtonParams struct {
  Variant string
  Size    string
  Class   string
  HTMXParams           // Embedded
}

func renderAttributes(attrs map[string]string) string {
  var result []string
  for key, val := range attrs {
    if val != "" {
      result = append(result, fmt.Sprintf("%s=\"%s\"", key, val))
    }
  }
  return strings.Join(result, " ")
}

templ AppButton(params AppButtonParams) {
  <button
    class={ 
      templ.SafeClass(appButtonVariants(params.Variant, params.Size)),
      templ.SafeClass(params.Class),
    }
    { renderAttributes(map[string]string{
        "hx-get": params.Get,
        "hx-target": params.Target,
        "hx-swap": params.Swap,
      }) }
  >
    { children... }
  </button>
}

@gungun974
Copy link
Contributor Author

Hey @gungun974, I'm just getting to know Templ, but seems you could separate out the attribute rendering and define your own types for HTMX params... Haven't tested this, but would it work for to do the following?:

type HTMXParams struct {
  Get    string
  Target string
  Swap   string
}

type AppButtonParams struct {
  Variant string
  Size    string
  Class   string
  HTMXParams           // Embedded
}

func renderAttributes(attrs map[string]string) string {
  var result []string
  for key, val := range attrs {
    if val != "" {
      result = append(result, fmt.Sprintf("%s=\"%s\"", key, val))
    }
  }
  return strings.Join(result, " ")
}

templ AppButton(params AppButtonParams) {
  <button
    class={ 
      templ.SafeClass(appButtonVariants(params.Variant, params.Size)),
      templ.SafeClass(params.Class),
    }
    { renderAttributes(map[string]string{
        "hx-get": params.Get,
        "hx-target": params.Target,
        "hx-swap": params.Swap,
      }) }
  >
    { children... }
  </button>
}

Thanks for the suggestion.
I was wondering if they could be a way like this where we can pass directly a string in an element but Templ don't implement such thing.

After maybe this is something that can be added to Templ. But not in this form.

It would be very strange to permit inject raw string into an element like this.

A better and simple well would permit to passe in a map[string]string in the element directly.

The Parser will have to recognize this pattern and make the generator generate a simple for range with templ.EscapeString() like it does for simple property. "This should be easy to add"

So this is my suggestion of feature. If you think it's a good idea let me know.
If I'm not the only person interested, I will make a PR.

@xgalaxy
Copy link

xgalaxy commented Oct 16, 2023

I was thinking a little bit about this today and I thought something like an element attribute serializer would be kind of nice.

Given your example, we could specify to the serializer how our structure should be serialized into the element:

type HtmxParams struct {
  Get    string `attr:"hx-get,omitempty"`
  Target string `attr:"hx-target,omitempty"`
  Swap   string `attr:"hx-swap,omitempty"`
}

And then in our templ function we could write:

templ AppButton(params HtmxParams) {
  <button { params... } />
}

And templ could desugar this into something that invokes an attribute serializer to write the elements into the button position. I haven't looked at the templ code in detail so here would be a naive interpretation:

// elements == "hx-get=\"/example\" hx-target=\"this\" hx-swap=\"outerHTML\"
var elements string = templ.ElementsSerializer(params)
fmt.Sprintf("<button %s>", elements)

You might even be able to extend this further to encompass the class attribute as well as handle nested structures by coming up with a more sophisticated tagging system. For example using a tag like class:"safe" could eliminate the need to markup the template with templ.SafeClass wrappers.

I think this system could even be used to eliminate the need for templ.KV by virtue of having the field be a boolean. For example:

SomeParams {
  // renders `is-primary` into the element if true
  IsPrimary bool `attr: "is-primary"`
}

Alternatively to all of this. Templ already supports css structures that get generated into CSSClass types and templ.KV can handle key/value pairs that contain CSSClass. Maybe we could have a new structure in templates specified via attr {} that get generated into Attribute types and templ.KV could handle map[Attribute]bool. I don't necessarily think this is as flexible of a solution though.

@joerdav
Copy link
Collaborator

joerdav commented Oct 17, 2023

I can see this working, but I usually avoid struct tags like the plague, it would make more sense to me to be a new type that can be used.

I don't think it would remove the need for templ.KV as you still need to discriminate between <div is-primary />, <div is-primary="false" />, <div is-primary="true" /> and <div />

A possibility would be for there to be a new type type Attributes map[string]string, which can be spread as you mentioned into an element:

templ AppButton(attrs templ.Attributes) {
  <button { attrs... } />
}

@gungun974
Copy link
Contributor Author

I can see this working, but I usually avoid struct tags like the plague, it would make more sense to me to be a new type that can be used.

I don't think it would remove the need for templ.KV as you still need to discriminate between <div is-primary />, <div is-primary="false" />, <div is-primary="true" /> and <div />

A possibility would be for there to be a new type type Attributes map[string]string, which can be spread as you mentioned into an element:

templ AppButton(attrs templ.Attributes) {
  <button { attrs... } />
}

I really like this syntax because it's solve HTMX attribute but also generic attribute and is simple and clean.

I can easily imagine write something like this with your code :

@AppButton(templ.Attributes{"data-id": "my-custom-id"}) {
  Click
}

I also think it's could be very simple to implement since we just need to loop the map and do some edit to the parser.

I can create the PR if you want since I really enjoy to but before what should happend if an attribute already exist ?

  • Should this spread override existing attribute ?
  • Should this spread merge in someway existing attribute ?
  • Should this spread just add an other attribute even if one exist ? (So in this case it's the developer responsibility to prevent conflict)

@xgalaxy
Copy link

xgalaxy commented Oct 17, 2023

@joerdav Good points. I could see solutions with the tags but as you've said they can become annoying and they aren't in spirit with how the rest of templ looks and functions.

Brief review of how CSS works

Rendering attributes is closely related to how CSS classes are rendered. So I think it's worth taking a brief look at how it works in templ currently.

type SafeCSS string

type CSSClass interface {
   ClassName() string
}

// implements CSSClass
type ComponentCSSClass struct {
   ID string
   Class SafeCSS
}

// implements CSSClass
type ConstantCSSClass string

templ.CSSClass{ templ.ComponentCSSClass{ ID: "foo", Class: ".className{background-color:white;}" } }
// renders: ".className{background-color:white;}"
// identifiable by: "foo"

You can use templ.RenderCSSItems to render the templ.ComponentCSSClass instances.

I wouldn't necessarily call the CSS API easy to use manually. The documentation doesn't even state
how you could even use this API manually. The real power comes from the CSS template:

css className() {
   // your css here...
   background-color: #ffffff;
   // etc...
}

// gets parsed into this structure:
type CSSTemplate struct {
   Name Expression
   Properties []CSSProperty
}

You then must manually place it into the class attribute for your elements:

// Take note of the function call syntax here. Related to how we should implement Attributes.
<button class={ className() }>

Attributes

I propose that we could offer similar template syntax for attributes as we do for CSS in addition to offering a friendly manual API.

Manual Usage
// Basic case:
templ AppButton(attrs templ.Attributes) {
  <button { attrs... } />
}

// More than one on same element:
templ AppButton(attrs templ.Attributes, moreAttrs templ.Attributes) {
  <button { attrs... } { moreAttrs... } />
  
  // NOTE: maybe this could be simplified to: { attrs..., moreAttrs... }
  //   but the , is visually non-distinct from the spread
  
  // Another option: { attrs... moreAttrs... }
}

// More than one on different elements:
templ AppButton(attrs templ.Attributes, moreAttrs templ.Attributes) {
  <button { attrs... } />
  <div { moreAttrs... } >
}

// Can also reuse:
templ AppButton(attrs templ.Attributes) {
  <button { attrs... } />
  <div { attrs... } >
}
Template Usage
// Multi line props
// rendered: "hx-get=\"/example\" hx-target=\"this\" hx-swap=\"outerHTML\"
attrs HtmxPropsMulti() {
   hx-get="/example"
   hx-target="this"
   hx-swap="outerHTML"
}

// Single line props
// NOTE: allowing more than one attribute on a single line will make it more difficult
//   to eliminate duplicates. Perhaps we don't allow this style or enforce a `;`
//   between each attribute?

// rendered: "hx-get=\"/example\" hx-target=\"this\" hx-swap=\"outerHTML\"
attrs HtmxPropsSingle() {
   hx-get="/example" hx-target="this" hx-swap="outerHTML"
}

// Private props
// rendered: "autofocus"
attrs propsArePrivate() {
   autofocus
}

templ AppButton() {
   <button { HtmxPropsMulti() } { propsArePrivate() } />
   <button { HtmxPropsSingle() } />
}

One major benefit of this style is that you free up the AppButton function signature.
And also allowing for private attributes for a particular templ body.

I'm not necessarily a fan that it is different syntax within the templ body from the manual API.
So an alternative style proposal could be to drop the parenthesis and make use of the spread once again.

This would bring it inline with the manual api usage but differ in how the CSS template syntax works:

templ AppButton() {
   <button { HtmxPropsMulti... } { propsArePrivate... } />
   <button { HtmxPropsSingle... } />
}

There is precedence in templ for both usages:

  • children... uses spread ...
  • css works via function call syntax: yourCss()

I noticed there is a PR for allowing parameterizing the css which means those functions could take arguments potentially. Perhaps there is a technical reason for allowing both function style and spread style for both CSS and Attributes. If there are no arguments you could simply use the spread style for both CSS and Attributes. This would bring them both inline with each other.

Technical considerations

Proposed answers to some of the questions brought up by @gungun974

Should this spread override existing attribute ?

I'm not aware of how browsers handle precedence for attributes. I'm not even sure there is a standard behavior here.
I propose that values that come last take precendece over values that come before.

For example:

attrs InputPropsA {
   value="First"
}

attrs InputPropsB {
   value="Second"
}

templ FooExample() {
  <input type="text" { InputPropsA() } { InputPropsB() } >
  // renders: <input type="text" value="Second">
}

Should this spread merge in someway existing attribute ?

I think this is a similar question to the previous one. Merging should be possible and any overlapping attributes
between two attribute sets resolve by using the last specified one.

For example:

attrs InputPropsA {
   type="text"
   value="First"
}

attrs InputPropsB {
   value="Second"
}

templ FooExample() {
  <input { InputPropsA() } { InputPropsB() } >
  // renders: <input type="text" value="Second">
}

Should this spread just add an other attribute even if one exist ?

Again. I think this is similar to the first two questions. The last most specified attribute should be the
one that takes precedence. This would imply that the renderer walks the attribute sets and eliminates
duplicates.

What about attributes that are specified inline?

I'm not sure what the current templ parser does for inline attributes on elements.
If it doesn't already parse out the inline attributes I think it would be a difficult
lift to implement that, although not impossible.

Therefore, if the parser does not already parse out the various attributes then I propose
that we do not try to eliminate or merge with existing inline attributes. This would then
be considered a programming error for the person designing the templates.

Example:

attrs InputProps {
   value="First"
}

templ FooExample() {
  <input type="text" value="First" { InputProps() } >
  // renders: <input type="text" value="First" value="First">
}

@gungun974
Copy link
Contributor Author

Thanks for this suggestion @xgalaxy but I don't understand how your new keyword attrs works from the first glance.

With the example you show it's like a map of string but it's not really a map cause it's call like a function ???

I think it's maybe too complex and sophisticated for Go and Templ. I think add this syntax will just make Templ more complex to maintain for not a lot of benefits against a regular map that everyone know (or templ.Attributes).

For the CSS templ class stuff I'm not qualify to speak or even understand since I don't use that. (Regular CSS class work great for me)

For my question I didn't think first about multiple spread attribute but I think it's a good idea (I said that cause I think it's will be easy to have multiple than only one in the parser)

For the merge conflict of attribute override seem to be the more logical solution but for what i see of the go generator code. We just write a plain text of attribute without really know what the previous was. That could impact runtime performance if we need to replace those plain text to a map and do some override with the spread.

But this is an implementation detail so I guess I will begin to implement a prototype of this feature in a draft PR.

@xgalaxy
Copy link

xgalaxy commented Oct 17, 2023

The syntax is derived from how templ already does CSS components. I personally don't think its too complex. See: https://templ.guide/syntax-and-usage/css-style-management#css-components

@gungun974
Copy link
Contributor Author

The syntax is derived from how templ already does CSS components. I personally don't think its too complex. See: https://templ.guide/syntax-and-usage/css-style-management#css-components

Yes it's true templ support that but I don't see how this syntax is superior to a regular struct well supported by golang compiler.

Templ is a superset of go. We should really a maximum of what golang give to us.

And for the css keyword. I think it's was made for the first place to be able to write CSS with LSP and code completion since we can't add this into a map or struct but we don't need that for an attribute since we can't known what element will get this attribute.

Finally I don't think a sugar like attrs give enough DX over the complexity to make the parser recognize this than the generator generate a map[string]string.

@gungun974 gungun974 mentioned this issue Oct 17, 2023
3 tasks
@gungun974
Copy link
Contributor Author

I've finished creating my draft pull request.

It was simpler than I imagined to create the parser given the current project structure.

However, as for the generator, I have no real idea how to overwrite an attribute that was previously written since the generator knows neither the past nor the future, and it only writes text.

We would need to completely move the attribute system into the runtime when a spread attribute is used, but this is costly and very specific case.

If you have technical ideas or suggestion, please say that in PR #237.

If you think it's impossible or too costly at runtime, we could skip this kind of verification and revisit it later.

@joerdav
Copy link
Collaborator

joerdav commented Oct 18, 2023

Agreed, I think it's up to the developer to not write duplicates, and up to the browser to handle the cases where duplicates are present.

@xgalaxy I have to agree with @gungun974 here that the attrs syntax is potentially a bit too much sugar in this case. One thing I like about templ is how familiar it is to writing Go code, so I think using patterns from Go where possible is ideal.

@a-h
Copy link
Owner

a-h commented Oct 23, 2023

Sorry, I haven't had a chance to really absorb the comments and thinking on this yet. From a quick look at the PR, I can see that the templ.Attributes is a map[string]string.

Doesn't this mean that we can only spread constant attributes, when we might also want to spread Go expression attributes, CSS scripts, etc.?

The full set of allowed attributes is at

func (attributeParser) Parse(in *parse.Input) (out Attribute, ok bool, err error) {
if out, ok, err = boolExpressionAttributeParser.Parse(in); err != nil || ok {
return
}
if out, ok, err = expressionAttributeParser.Parse(in); err != nil || ok {
return
}
if out, ok, err = conditionalAttributeParser.Parse(in); err != nil || ok {
return
}
if out, ok, err = boolConstantAttributeParser.Parse(in); err != nil || ok {
return
}
if out, ok, err = constantAttributeParser.Parse(in); err != nil || ok {
return
}
return
}

@a-h
Copy link
Owner

a-h commented Oct 23, 2023

Other than that, I think I can state the requirement as being that we want a templ component to be able to accept "rest parameters" which are applied to one or more children.

templ Component(name string, attrs ...templ.Attribute) {
  <div { attrs... }>
     { name }
  </div>
}

This probably works OK at a single level of nesting, but if I want to pass conditional attributes from a parent to child components, there's no syntax in templ to help, so it would be this monstrosity.

templ Component(name string, attrs ...templ.Attribute) {
  <div { attrs... }>
     { name }
  </div>
}

templ Higher(name string, a, b int) {
  <div>
     @component(name, templ.ConditionalAttribute{Expression: templ.Expression{ Value: "a < b" }, Then: templ.ConstantAttribute("title", name)})
  </div>
}

This makes me think that we'd have to update the @component syntax to have a HTML style implementation to make this workable.

templ Component(name string, attrs ...templ.Attribute) {
  <div { attrs... }>
     { name }
     { ...children }
  </div>
}

templ Higher(name string, a, b int) {
  <div>
     <Component name={ name } 
          if a < b {
                title= {name }
          }
      >
            <div>Inside the "Component".</div>
     </Component>
  </div>
}

This adds a bit of complexity because in this view, the attribute "name" becomes an input to the "Component" function. The LSP could be updated to work that out.

All attribute names in the templ component would need to be mapped to function inputs (name, a, b). I think this would need to be quite strict to ensure that function parameter ordering is not a problem.

@gungun974
Copy link
Contributor Author

gungun974 commented Oct 23, 2023

If I understand correctly what you said @a-h :

You'd rather handle dynamic attributes using a slice instead of a map (and then we can utilize some variadic functions).
And your Slice is not an array of key-value pairs, but instead an instance like ExpressionAttribute ?

Personally, I believe it's not a good idea to create an array-like structure of ExpressionAttribute since we never expose any Expression or ConditionalAttribute to the runtime.

If we were to proceed in this manner, within the generator, we would need to implement a loop to check if an element is a ConditionalAttribute, ExpressionAttribute, ConstantAttribute, or BoolExpressionAttribute.
However, the generator should only produce the absolute minimum and shouldn't maintain the parsing tree.


templ Component(name string, attrs ...templ.Attribute) {
  <div { attrs... }>
     { name }
  </div>
}

templ Higher(name string, a, b int) {
  <div>
     @component(name, templ.ConditionalAttribute{Expression: templ.Expression{ Value: "a < b" }, Then: templ.ConstantAttribute("title", name)})
  </div>
}

After for this code, the issue is not about a spread operator or passing attribute but rather, templ component are go function and we can't put in a go function parameter a if statement or ternary (RIP ternary)

If I wanted to write "hoi" in name if a is superior to b. I can't in the current version of go and templ.


templ Component(name string, attrs ...templ.Attribute) {
  <div { attrs... }>
     { name }
     { ...children }
  </div>
}

templ Higher(name string, a, b int) {
  <div>
     <Component name={ name } 
          if a < b {
                title= {name }
          }
      >
            <div>Inside the "Component".</div>
     </Component>
  </div>
}

Finally the idea of using a pure HTML attribute rather than @component syntax is interesting.
It could be a breaking change and also strange since behind the scene component are go function.
But this is a debate for an other issue.


I think for this issue what we need is something more simple and more go idiomatic.

If you look into #237 and the doc I wrote :

Spread attributes

Use the { attrMap... } syntax in the open tag of an element to append a dynamic map of attributes, where attrMap is of type templ.Attributes.

templ.Attributes is a map[string]string type definition.

templ component(attrs templ.Attributes) {
  <p { attrs... }></p>
}

templ usage() {
  @component(templ.Attributes{"data-testid": "paragraph"}) 
}
<p data-testid="paragraph">Text</p>

The idea here is to make templ just spread a simple go map (but we can also change this to a slice).
For what is inside of this map or not it's not to templ to decide but to what value is pass to my function.

The example you showed before about conditional attribute is possible but using real go code to create a map that correspond of what you want to do.

func getAttributes(a int, b int) templ.Attributes {
   if a < b {
       return templ.Attributes{"title": "name"}
   }
   return templ.Attributes{}
} 

templ Component(name string, attrs templ.Attributes) {
  <div { attrs... }>
     { name }
  </div>
}

templ Higher(name string, a, b int) {
  <div>
     @component(name, getAttributes(a, b))
  </div>
}

@joerdav
Copy link
Collaborator

joerdav commented Oct 23, 2023

@a-h I'm also not sure I follow on why you would pass a conditional attribute this way, or a bool expression. If you wanted to conditionally add something this way you wouldn't add it to the map/slice, is what I was thinking.

As a parallel, with react props I believe you would reconcile any expressions before passing as props, rather than a conditional that is reconciled every time it's used.

I may be missing the point!

@a-h
Copy link
Owner

a-h commented Oct 23, 2023

Ah, OK, I think I understand now, and why it makes sense that it's a map! Thanks.

@xgalaxy
Copy link

xgalaxy commented Oct 23, 2023

You should add @gungun974 conditional attribute example as part of the documentation to show people how they can use it with conditionals.

@gungun974
Copy link
Contributor Author

You should add @gungun974 conditional attribute example as part of the documentation to show people how they can use it with conditionals.

The example I show is a pattern that templ force me to do. Contrary in JS with JSX, we can't write go code in the rendering function.

We can't for example jus't create a variable in the templ element and reuse it. So in this case I got two possibility.

  1. Externalize it and pass to my component argument.
  2. Create inside the same package (file) a function that compute and return exactly what I want.

So if we need to add something in the docs related to this, it's more about this pattern to remember in templ files, we can write Go code like function ^^

(But then I want to say it would be simpler and more obvious for people if we could directly insert go code in templ elements like in JSX. Normally given the code generated by the generator this should not be impossible to make Expression insert raw go code but this is the subject for another issue...)

@filipweidemann
Copy link

Literally ran into this exact issue when I created my first component a few minutes ago, also with HTMX hx-* attributes!

#237 is a great solution as it aligns with the way of many (probably all) existing JS frameworks and that makes it fairly easy to adapt to Templ when coming from JS frameworks. attrs spreading is the way to go imho.

I'd offer help but I am just starting out with Go and am probably more of a blocker right now, however, I really wanted to say thanks to all of you for working on this, it looks great, and can't wait until the PR gets merged!

Looking forward to shill Templ even more in the future :)

@gungun974
Copy link
Contributor Author

#237 is a great solution as it aligns with the way of many (probably all) existing JS frameworks and that makes it fairly easy to adapt to Templ when coming from JS frameworks. attrs spreading is the way to go imho.

Thanks the proposal change of my PR is great. I don't want to rush anyone to merge it but it's true I'm hype to get some news from a-h.

I'd offer help but I am just starting out with Go and am probably more of a blocker right now, however, I really wanted to say thanks to all of you for working on this, it looks great, and can't wait until the PR gets merged!

I just want to tell you something about this. Don't believe you are a blocker before even trying !
People don't believe enough to themself or have the envy to learn new thing the hard way.
It's not cause you had just started with Go you can't read the Templ code, in fact when I made my first contribution to this project. I only knew golang for 3 days !
Okay maybe you could failed to contribute but there is not really a looser. You try your best and learn something new. The author maybe lost a tiny amount of time reading your code for nothing but even here. You show how you care about this project to the author and the willing of helping. This is something I think beautiful.
That's why I help some FOSS project, I really want to help them build what could be the best for they project !

Okay I write a lot of text, and maybe it's a little strange but it's important as a person to always try and I wanted to share this here !

@a-h
Copy link
Owner

a-h commented Dec 30, 2023

Time to close this now the PR has been merged. Thanks for all the input on this!

@a-h a-h closed this as completed Dec 30, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants