mirror of
https://codeberg.org/readeck/readeck.git
synced 2026-05-19 11:00:36 +00:00
150596399f
- 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!
407 lines
9.9 KiB
Plaintext
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"
|
|
> •</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="＀"/>
|
|
<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>
|
|
}
|
|
}
|