How to design Reusable UI Components #432
Replies: 11 comments 23 replies
-
I am also very interested in this. It is not only useful for htmx but for making reusable ui components in general. For example for a button: templ Button(attrs templ.Attributes) {
<button type="button" {attrs...} >
{children...}
</button>
} If we want to be able to add a value to the type attribute we have to add another required argument or just use the templ.Attributes which is not typesafe and ??there is no way to add default values?? Additionally I will say that to be able to make reusable components for public use (which is also something I was thinking about) if we were to make something inspired by shadcn using tailwindcss we would need to port tailwind merge to golang. Otherwise we can have style conflicts. |
Beta Was this translation helpful? Give feedback.
-
I've been thinking about this too and started writing how a @button() component might work. package button
type style map[string]string
const defaultVariant = "bg-primary text-primary-foreground hover:bg-primary/90"
const destructive = "bg-destructive text-destructive-foreground hover:bg-destructive/90"
const outline = "border border-input hover:bg-accent hover:text-accent-foreground"
const secondary = "bg-secondary text-secondary-foreground hover:bg-secondary/80"
const ghost = "hover:bg-accent hover:text-accent-foreground"
const link = "underline-offset-4 hover:underline text-primary"
var variants = style{
"defaultStyle": defaultVariant,
"destructive": destructive,
"outline": outline,
"secondary": secondary,
"ghost": ghost,
"link": link,
}
const defaultSize = "h-10 py-2 px-4"
const sm = "h-9 px-3 rounded-md"
const lg = "h-11 px-8 rounded-md"
const icon = "h-10 w-10"
var sizes = style{
"defaultSize": defaultSize,
"sm": sm,
"lg": lg,
"icon": icon,
}
var variant = defaultVariant
var size = defaultSize
templ Button(params ...string) {
for _, param := range params {
desiredVariant, ok := variants[param]
if ok {
variant = desiredVariant
continue
}
desiredSize, ok := sizes[param]
if ok {
size = desiredSize
continue
}
}
<button type="button" class={fmt.Sprintf("%s %s", variant, size)}>{ children... }</button>
} With that API, a button could work like this: Seems reasonable to me. Go's spread operator does a nice job accepting a variable number of parameters. Of course this requires that parameter names are unique but I think that tradeoff would probably be okay. Especially since the 'default' cases are the defaults. This pattern could pretty easily be extended to handle other attributes. For example, I deliberately left the HTMX params off this component so that it could be used in different contexts. I agree with @Oudwins that components are better left generic. That way, a form is welcome to implement its own submit handler. And if I want this Button to link somewhere I could just wrap it in a tag. The first roadblock I ran into is that the this is not a valid Templ component. Templ renders the |
Beta Was this translation helpful? Give feedback.
-
@swetjen Thats very interesting! You managed to get defaults working but at the cost of typesafety & autocomplete. I think this is something that could definitely work. And be acceptable for many. I there are a few drawbacks I see though:
Personally the way I decided to implement this for myself is like this, it gives up defaults in exchange for autocomplete/typesafety package ui;
var buttonvariants = variants{
variantdefault: "bg-primary text-primary-foreground hover:bg-primary/90",
variantdestructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
variantoutline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
variantsecondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
variantghost: "hover:bg-accent hover:text-accent-foreground",
variantlink: "text-primary underline-offset-4 hover:underline",
}
var buttonsizes = sizes{
sizedefault: "h-10 px-4 py-2",
sizesm: "h-9 rounded-md px-3",
sizelg: "h-11 rounded-md px-8",
sizeicon: "h-10 w-10",
}
templ button(v variant, s size, attrs templ.attributes) {
<button type="button" class={ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", buttonvariants[v], buttonsizes[s] } { attrs... }>
{ children... }
</button>
} Sizes & variants are enums package ui
type Variant int
const (
VariantDefault Variant = iota
VariantDestructive
VariantOutline
VariantSecondary
VariantGhost
VariantLink
)
type Variants map[Variant]string
type Size int
const (
SizeDefault Size = iota
SizeSm
SizeLg
SizeIcon
)
type Sizes map[Size]string |
Beta Was this translation helpful? Give feedback.
-
As far as support for library devs on the templ side I think some kind of function to handle conflict resolution for attributes might be the way to go. Providing a hook at ??a component level??? that allows you to pick which attributes to keep (or to merge them) when there are multiple of the same type (i.e merge them if two class or style attributes but ignore the first if two type attributes). This would solve the issue that I posed in my first message where if I set a default value for an attribute it cannot be replaced with a value from templ.Attributes. But would not solve the problem if autocomplete/typesafety... |
Beta Was this translation helpful? Give feedback.
-
I agree that merging attributes is important. I could see an implementation in which one passes a value and an update function for each attribute with a set of standard update functions (e.g., append, replace, replace if empty, etc.) and the ability to pass custom functions. The question is how do you do it in a type-safe way that doesn't make for a very messy API -- there are well over 100 html element attributes. For my usecase (styling with tailwind), class is the most important attribute to merge, but it is also the most complicated to merge correctly. For that reason, I wrote go-tailwind-merge to merge classes intelligently. It is similar in spirit to the typescript package, tailwind-merge, which is used by shadcn-ui and all of it's framework ports, but this version has a different implementation. @Oudwins, you might find it useful. |
Beta Was this translation helpful? Give feedback.
-
I'm also playing around with the idea of building reusable components where I want HTMX for buttons, input fields etc and after reading this I got the idea that maybe a builder pattern could be used to support the wish of having something that is typesafe and hints from the LSP's I haven't made any POC on it yet but I'm going to play around with the idea this week. I wonder if something like this could support the various use cases for both css classes and htmx?
The same idea could potentially be applied to the CSS stuff related to different variation of buttons and styles. I wonder if it will end up becoming too verbose and you would have to maintain the builders as well. Semantically it looks more intuitive when I have to use the different components, even tho it puts a burden on the component maintenance of keeping the builders up to date etc. Anyhow I think I'm gonna give it a try the coming days, at least for my HTMX use case and see how it will work out. |
Beta Was this translation helpful? Give feedback.
-
I've also been thinking about how to design reusable components.
package badge
import (
"strings"
"github.com/a-h/templ"
)
type Props struct {
Attrs templ.Attributes
ClassName string
Color string
Href string
Size string
Theme *Theme
buf *strings.Builder
}
func (p Props) className() string {
p.buf = &strings.Builder{}
if p.Theme == nil {
p.Theme = NewTheme()
}
switch p.Color {
case "default":
p.buf.WriteString(p.Theme.Root.Color.Default)
default:
p.buf.WriteString(p.Theme.Root.Color.Default)
}
p.buf.WriteString(" ")
switch p.Size {
case "xs":
p.buf.WriteString(p.Theme.Root.Size.Xs)
case "sm":
p.buf.WriteString(p.Theme.Root.Size.Sm)
default:
p.buf.WriteString(p.Theme.Root.Size.Xs)
}
p.buf.WriteString(" ")
if p.ClassName != "" {
p.buf.WriteString(p.ClassName)
}
return p.buf.String()
}
package badge
templ Badge(p *Props) {
<span class={ p.className() } { p.Attrs... }>{ children... }</span>
}
package badge
type Theme struct {
Root
}
type Root struct {
Color
Size
}
type Color struct {
Default string
}
type Size struct {
Xs string
Sm string
}
func NewTheme() *Theme {
return &Theme{
Root: Root{
Color: Color{
Default: "bg-blue-100 text-blue-800 font-medium me-2 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300",
},
Size: Size{
Xs: "text-xs",
Sm: "text-sm",
},
},
}
}
package main
import (
"fmt"
"net/http"
"github.com/a-h/templ"
)
func main() {
http.Handle("/", templ.Handler(Main()))
fmt.Println("Listening on :4000")
http.ListenAndServe("127.0.0.1:4000", nil)
}
package main
import "flowbite-templ/components/badge"
func getProps() *badge.Props {
theme := badge.NewTheme()
theme.Root.Size.Xs = "text-lg"
props := &badge.Props{
ClassName: "foo bar",
Attrs: templ.Attributes{"disabled": true, "hello": "world"},
Theme: theme,
}
return props
}
templ Main() {
<div>
@badge.Badge(getProps()) { Default }
</div>
} Output: <div><span class="foo bar bg-blue-100 text-blue-800 font-medium me-2 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300 text-xs" disabled hello="world">Default</span></div> Can also be simplified if you don't need to modify the theme: package main
import "flowbite-templ/components/badge"
templ Main() {
<div>
@badge.Badge(&badge.Props{ClassName: "foo bar", Attrs: templ.Attributes{"disabled": true, "hello": "world"}}) {
Default
}
</div>
} The one thing that's missing is something that works like tailwind-merge in Go. I currently hacked it up with v8go, but that's obviously not ideal. |
Beta Was this translation helpful? Give feedback.
-
I think the best way to do it is to separate the functional logic from the styling as much as possible. @tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind variants;
@layer components {
.btn-accent {
@apply rounded-md border-2 border-primary-light/70 bg-accent-dark px-4 py-2
font-semibold text-surface-dark shadow-md shadow-accent-light/70
hover:bg-accent-light hover:text-surface hover:shadow-md hover:shadow-accent-dark
focus:outline-none focus:ring focus:ring-accent-dark focus:ring-opacity-75;
}
.link {
@apply font-semibold text-sky-600 visited:text-pink-800 hover:text-sky-700;
}
} This eliminates (for the most part) the need to have a dedicated templ component for a button, since an anchor tag could be a button too if you just add the The same logic could be applied to creating templ components. You can create a templ button component which contains only styles and children, and then you can use the @button component by passing in the children that contain the logic like href's or htmx attrs. |
Beta Was this translation helpful? Give feedback.
-
@eikster-dk Personally I am really against building abstractions when they only provide minimum DX improvements. Someone will have to maintain those abstractions up to date with HTMX, CSS, etc @RafaelZasas the creators of tailwind recommend against doing this. And it is, objectively bad. Specially for writing something like a component library since users won't be able to override your styles. It might seem like a cleaner way of doing it & one that generates code that is easier to minify but both ideas are actually incorrect. Checkout this video by theo that goes a little more in depth into why this is the case -> https://www.youtube.com/watch?v=yGBjXsrwK4M (there is another one that is even better but I can't find it right now) |
Beta Was this translation helpful? Give feedback.
-
Currently the best solution I have found is doing things like this func BtnBase() string {
return btnBaseCSS
}
func BtnVariant(args ...Variant) string {
switch args[0] {
case VariantDestructive:
return "bg-destructive text-destructive-foreground hover:bg-destructive/90"
case VariantOutline:
return "border border-input bg-background hover:bg-accent hover:text-accent-foreground"
case VariantSecondary:
return "bg-secondary text-secondary-foreground hover:bg-secondary/80"
case VariantGhost:
return "hover:bg-accent hover:text-accent-foreground"
case VariantLink:
return "text-primary underline-offset-4 hover:underline"
default:
return "bg-primary text-primary-foreground hover:bg-primary/90"
}
}
func BtnSize(args ...Size) string {
switch args[0] {
case Sm:
return "h-9 px-3"
case Lg:
return "h-11 px-8"
case Icon:
return "h-10 w-10"
default:
return "h-10 px-4 py-2"
}
} Then in the html/tmpl file <button class={BtnBase(), BtnVariant(), BtnSize(Sizes.Sm)} > Syntax is not pretty but it allows the use of default values & high composability. Main problem is that this doesn't work for custom components, autocompletion.... |
Beta Was this translation helpful? Give feedback.
-
Iam trying to build some components for my daily usage based on patternfly html (not react) components. I try to make each button/label/input field as much general as possible
but then mainly follow the design patterns of the framework which forces me e.g. to build a form group when using input fields. (which is good, as alot of qualified designer worked on that part)
etc... The templ.Attributes still allow me to attach any specific attributes if needed (especially using htmx, as I try to avoid any js overhead) another idea is, e.g. for buttons, to add a basic struc, and init basic values via the public constructor, as the options/variants a nearly impossible to cover, you can still expose a "AddClass" method to attach more css classes if needed. (css classes are defined somewhere as constants)
Additionally, public interface could be helpful , e.g.
thus a formgroup could have something like
Finally, I wrap everything which is used often in a general composer (e.g. create full page,create main menu,toast alerts, etc), but additional, every view/usecase/domain gets their custom composer, e.g. createUserModel, createUserTable etc... such that the handler stays quite simple and "clean":
there is still alot of space to improve...and I havent figured out 100% my final structure, as especially using htmx with error handling can get abit crazy (e.g. have a modal, form,submit and save, handle input error (inside the form) if can not safe (within the modal) handle that error, close it if successful, reload table if successful, show another error if this refresh does not work etc etc...) |
Beta Was this translation helpful? Give feedback.
-
To continue the topic Issue: How to design Reusable UI Components. The issue here was in the context of a single project but can be generalized as how to design reusable UI components with templ for public use.
I am a fan of tailwind CSS and recently come across HTMX. I personally think that building most common web projects can be done very quickly using Go, templ, tailwind CSS, HTMX as HTMX and tailwind gives the UI utilities and templ now allows us to build components in Go itself!
Now looking at my experience (see the issue for more details) with development of this particular stack, If I were to build components which is possible when you are building in the context of single project. To elaborate, I can create a login button or Submit button where the actions/api are known before the components.
Now, to make this for public use, I won't know what actions / JS attributes will be used for a specific component, hence I need to expect everything.
One way to do this is to add all JS attributes as arguments, but then HTMX and JS arguments will be different, not all will be used, the attributes will have to follow all the new releases particularly in case of utilities like HTMX. We can see this is hard to maintain.
Another way is to use Spread Attributes feature with templ.Attributes but as I mentioned in the issue, LSP kind of fails here. Now we could update LSP to work in this scenario but is this the best approach I wonder.
Now taking a step back, one might wonder why do of all this and not write just CSS and JS. My answer to this is if you look at the current popular way allowing the community to build their own components rather than giving a rigid library is much better way of UI development. Example for this is shadcn.
The use of popular utility like tailwind css and code availability to update are the reasons why I think it's very popular. So, if we were to follow the same philosophy here, extending it to HTMX as well, it might lead to being more useful.
So, my question is how one should go about designing this, are templ.Attributes way to go, or can we do something which will allow us to use HTMX or JS attributes in components usage which seems to be more natural. Ex -
Beta Was this translation helpful? Give feedback.
All reactions