Files
readeck/components/forms/forms.templ
T
Olivier Meunier 150596399f Templ sunrise
- Updated makefile so "make serve" watches for .templ files and
    rebuild files upon changes
- Added a "templ" make target
- Added a top level "components" module
- Added a "components/forms" module
- Added server.RenderComponent method
- Added server.RenderTurboStreamComponent
- Preferences lazy loading

We're now ready to roll!
2026-05-04 11:23:29 +02:00

407 lines
9.9 KiB
Plaintext

// SPDX-FileCopyrightText: © 2026 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package forms
import (
"maps"
"slices"
"codeberg.org/readeck/readeck/pkg/forms"
. "codeberg.org/readeck/readeck/components"
)
templ Errors(f forms.Binder) {
if !f.IsValid() {
<div class="max-w-std" role="alert">
@Message(WithMessageType("error")) {
<strong class="text-red-800">{ L(ctx).Gettext("Errors") }</strong>
if errors := f.Errors(); len(errors) > 0 {
for _, err := range errors {
<p>{ err.Error() }</p>
}
} else {
<p>{ L(ctx).Gettext("Please check your form for errors.") }</p>
}
}
</div>
}
}
templ (f *fieldWidget) component() {
{{
f.classes = slices.Insert(f.classes, 0, "field")
if len(f.Errors()) > 0 {
f.classes = append(f.classes, "field--err with-errors")
}
}}
<div class={ f.classes }>
if f.label != "" {
<label for={ f.name }>
{ f.label }
if f.required {
<span
title={ L(ctx).Gettext("Required") }
aria-hidden="true"
class="text-red-600"
>&#x2009;•</span>
<span aria-hidden="true" class="sr-only">{ L(ctx).Gettext("Required") }</span>
}
</label>
}
<div { f.controllAttrs... }>
{ children... }
if errors := f.Errors(); len(errors) > 0 {
<ul id={ "errors-" + f.name } class="field--errors">
for _, e := range errors {
<li>{ e.Error() }</li>
}
</ul>
}
if f.help != "" {
<span id={ "description-" + f.name } class="field--help">{ f.help }</span>
}
</div>
</div>
}
templ (f *textField) component() {
{{
f.classes = slices.Insert(f.classes, 0, "field--text")
if len(f.inputClasses) == 0 {
f.inputClasses = templ.Classes("form-input w-full")
}
if f.required {
f.inputAttrs["required"] = true
}
if f.inputType != "password" {
f.inputAttrs["value"] = f.value
} else {
f.inputAttrs["value"] = ""
}
parent := f.fieldWidget.component
}}
@parent() {
<input
type={ f.inputType }
id={ f.name }
name={ f.name }
class={ f.inputClasses }
{ f.inputAttrs... }
{ f.ariaAttrs()... }
/>
{ children... }
}
}
templ (f *textAreaField) component() {
{{
f.classes = slices.Insert(f.classes, 0, "field--textarea")
if len(f.inputClasses) == 0 {
f.inputClasses = templ.Classes("form-textarea w-full")
}
if _, ok := f.inputAttrs["rows"]; !ok {
f.inputAttrs["rows"] = 3
}
if f.value == nil {
f.value = f.String()
}
if f.required {
f.inputAttrs["required"] = true
}
parent := f.fieldWidget.component
}}
@parent() {
<textarea
id={ f.name }
name={ f.name }
class={ f.inputClasses }
{ f.inputAttrs... }
{ f.ariaAttrs()... }
>{ S("%s", f.value) }</textarea>
}
}
templ (f *checkboxField) component() {
{{
f.classes = slices.Insert(f.classes, 0, "field", "field--checkbox")
if len(f.Errors()) > 0 {
f.classes = append(f.classes, "field--err with-errors")
}
if v, ok := f.value.(bool); ok && v {
f.inputAttrs["checked"] = "checked"
}
}}
<div class={ f.classes }>
<span class="field--spacer"></span>
<div>
<input
type="checkbox"
id={ f.name }
name={ f.name }
class="form-checkbox"
value="t"
{ f.inputAttrs... }
{ f.ariaAttrs()... }
/>
// end with false so the field is bound
<input type="hidden" name={ f.name } value="f"/>
<label for={ f.name }>{ f.label }</label>
if errors := f.Errors(); len(errors) > 0 {
<ul id={ "errors-" + f.name } class="field--errors">
for _, e := range errors {
<li>{ e.Error() }</li>
}
</ul>
}
if f.help != "" {
<span id={ "description-" + f.name } class="field--help">{ f.help }</span>
}
</div>
</div>
}
templ (f *selectField[T]) component() {
{{
choices := forms.ValueChoices[T]{}
if fc, ok := f.Field.(interface{ Choices() forms.ValueChoices[T] }); ok {
choices = fc.Choices()
}
if fc, ok := f.inputAttrs["choices"]; ok {
delete(f.inputAttrs, "choices")
if fc, ok := fc.(interface{ Choices() forms.ValueChoices[T] }); ok {
choices = fc.Choices()
}
}
f.classes = slices.Insert(f.classes, 0, "field--select")
if len(f.inputClasses) == 0 {
f.inputClasses = templ.Classes("form-select w-full")
}
if f.required {
f.inputAttrs["required"] = true
}
parent := f.fieldWidget.component
}}
@parent() {
<select
id={ f.name }
name={ f.name }
class={ f.inputClasses }
{ f.inputAttrs... }
{ f.ariaAttrs()... }
>
for _, c := range choices {
<option value={ S("%s", c.Value) } selected?={ f.Field.(forms.TypedField[T]).V() == c.Value }>{ c.Name }</option>
}
</select>
}
}
templ (f *multiSelectField[T]) component() {
{{
f.classes = slices.Insert(f.classes, 0, "field", "field--multiselect")
if len(f.Errors()) > 0 {
f.classes = append(f.classes, "field--err with-errors")
}
choices := forms.ValueChoices[T]{}
if fc, ok := f.Field.(interface{ Choices() forms.ValueChoices[T] }); ok {
choices = fc.Choices()
}
if fc, ok := f.inputAttrs["choices"]; ok {
delete(f.inputAttrs, "choices")
if fc, ok := fc.(interface{ Choices() forms.ValueChoices[T] }); ok {
choices = fc.Choices()
}
}
}}
<div class={ f.classes }>
<label>{ f.label }</label>
<div>
// null value to bind the field
<input type="hidden" name={ f.name } value="&#xff00"/>
<ul class="field--choices">
{{
inputType := "checkbox"
inputClasses := append(f.inputClasses, "form-checkbox")
if _, ok := f.Field.Value().(T); ok {
inputType = "radio"
inputClasses = append(f.inputClasses, "form-radio")
}
}}
for _, c := range choices {
{{
checked := false
if v, ok := f.Field.Value().(T); ok {
checked = v == c.Value
} else if v, ok := f.Field.Value().([]T); ok {
checked = slices.Contains(v, c.Value)
}
}}
<li>
<input
type={ inputType }
id={ S("%s_%s", f.name, c.Value) }
name={ f.name }
value={ S("%s", c.Value) }
checked?={ checked }
class={ inputClasses }
aria-label={ f.label + ": " + c.Name }
/>
<label for={ S("%s_%s", f.name, c.Value) }>{ c.Name }</label>
</li>
}
</ul>
if errors := f.Errors(); len(errors) > 0 {
<ul id={ "errors-" + f.name } class="field--errors">
for _, e := range errors {
<li>{ e.Error() }</li>
}
</ul>
}
if f.help != "" {
<span id={ "description-" + f.name } class="field--help">{ f.help }</span>
}
</div>
</div>
}
templ (f *passwordField) component() {
{{
f.classes = slices.Insert(f.classes, 0, "field--text field--composed field--password")
if len(f.inputClasses) == 0 {
f.inputClasses = templ.Classes("form-input w-full")
}
f.controllAttrs["data-controller"] = "pass-reveal"
if f.required {
f.inputAttrs["required"] = true
}
f.inputAttrs["data-pass-reveal-target"] = "field"
parent := f.fieldWidget.component
}}
@parent() {
<div class={ f.inputClasses }>
<input
type="password"
id={ f.name }
name={ f.name }
{ f.inputAttrs... }
{ f.ariaAttrs()... }
/>
<button type="button" data-pass-reveal-target="btn" data-action="pass-reveal#toggle"></button>
<template data-pass-reveal-target="show">
@Icon("o-show")
</template>
<template data-pass-reveal-target="hide">
@Icon("o-hide")
</template>
</div>
}
}
templ (f *timeTokenField) component() {
{{
f.classes = slices.Insert(f.classes, 0, "field--text field--composed field--timetoken")
f.inputClasses = slices.Insert(f.inputClasses, 0, "form-input w-full")
f.inputAttrs["data-timetoken-target"] = "field"
if f.required {
f.inputAttrs["required"] = true
}
parent := f.fieldWidget.component
}}
@parent() {
<div
class={ f.inputClasses }
data-controller="timetoken"
data-timetoken-hidden-class="hidden"
>
<input
type="text"
id={ f.name }
name={ f.name }
value={ f.String() }
{ f.inputAttrs... }
/>
<button type="button" data-timetoken-target="btn">
@Icon("o-calendar")
</button>
<template data-timetoken-target="template">
<div class="timetoken">
<label>{ L(ctx).Gettext("The previous") }</label>
<div>
<input
type="number"
size="3"
min="0"
class="form-input"
data-timetoken-target="value"
data-action="timetoken#update"
/>
<select
class="form-select"
data-action="timetoken#update"
data-timetoken-target="unit"
>
<option value="d">{ L(ctx).Gettext("Day(s)") }</option>
<option value="w">{ L(ctx).Gettext("Week(s)") }</option>
<option value="m">{ L(ctx).Gettext("Month(s)") }</option>
<option value="y">{ L(ctx).Gettext("Year(s)") }</option>
</select>
</div>
<p class="my-2">- { L(ctx).Pgettext("word", "or") } -</p>
<input type="date" class="form-input" data-action="timetoken#update" data-timetoken-target="absolute"/>
</div>
</template>
</div>
}
}
templ FiledDropField(field forms.Field, options ...FieldOption) {
{{
f := textField{widget(field, options...)}
f.inputType = "file"
f.classes = slices.Insert(f.classes, 0, "field--filedrop")
f.inputClasses = slices.Insert(f.inputClasses, 0, "js:hidden")
maps.Copy(f.controllAttrs, templ.Attributes{
"data-controller": "dropzone",
"data-dropzone-hidden-class": "hidden",
"data-dropzone-focus-class": "bg-primary-100",
})
parent := f.component
}}
@parent() {
<p data-dropzone-target="zone">
<button
type="button"
class="mx-auto link"
data-dropzone-target="placeholder"
data-action="click->dropzone#select"
>{ L(ctx).Gettext("Select or drop file") }</button>
<span class="max-w-full wrap-anywhere font-semibold" data-dropzone-target="fileinfo"></span>
<button
type="button"
class="hidden text-btn-danger hf:text-btn-danger-hover"
data-dropzone-target="clearbtn"
data-action="dropzone#clear:stop:prevent"
>
{ L(ctx).Gettext("remove") }
</button>
</p>
}
}