initial port of the UI kit

This commit is contained in:
2026-07-13 01:58:29 -04:00
parent 261a7f2b4d
commit b02df48a66
51 changed files with 7507 additions and 154 deletions

147
go/webui/validation.go Normal file
View File

@@ -0,0 +1,147 @@
// Port of web/kit/Validation.ts.
package webui
import (
"regexp"
"strings"
"unicode/utf8"
)
// validationNonDigit matches every non-digit rune (the JS /\D/g).
var validationNonDigit = regexp.MustCompile(`\D`)
// validationDigits strips every non-digit character.
func validationDigits(s string) string {
return validationNonDigit.ReplaceAllString(s, "")
}
// Validation describes a single form field's validation inputs. Solid's reactive
// Accessor<T> values collapse to plain values here: the caller reads its signals
// at the call site and passes the current values. FieldBlur is optional (nil
// falls back to Field), as is IsValidFunc.
type Validation struct {
ID string // snake_case identifier that is "touched"
Name string // human-readable field name, used in messages
Required bool // whether an empty value is an error
Touched map[string]bool // which field IDs the user has interacted with
Field string // the live field value
FieldBlur *string // the value at last blur; nil uses Field
IsValidFunc func(string) bool
InvalidMsg string // custom "is invalid" message; empty uses a default
}
// CreateValidation computes the validation error message for a field, or "" when
// valid. This is the collapsed (non-reactive) form of the Solid createMemo: the
// caller re-invokes it whenever the underlying signals change.
func CreateValidation(v Validation) string {
field := strings.TrimSpace(v.Field)
// When no blur value is provided, fall back to the live value so the
// "has value but not blurred yet" guard is always false and validation
// runs against the live value instead.
fieldBlur := field
if v.FieldBlur != nil {
fieldBlur = strings.TrimSpace(*v.FieldBlur)
}
if !v.Touched[v.ID] || (field != "" && fieldBlur == "") || (v.IsValidFunc != nil && v.IsValidFunc(field)) {
return ""
}
if v.Required && field == "" {
return v.Name + " is required"
}
if v.IsValidFunc != nil && !v.IsValidFunc(fieldBlur) {
if v.InvalidMsg != "" {
return v.InvalidMsg
}
return v.Name + " is invalid"
}
return ""
}
// IsPhoneNumberValid reports whether phoneNumber has exactly 10 digits.
func IsPhoneNumberValid(phoneNumber string) bool {
return len(validationDigits(phoneNumber)) == 10
}
var validationEmailRegex = regexp.MustCompile("^[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~](\\.?[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\\.?[a-zA-Z0-9])*\\.[a-zA-Z](-?[a-zA-Z0-9])+$")
// IsEmailValid reports whether email is a syntactically valid email address.
func IsEmailValid(email string) bool {
if email == "" {
return false
}
emailParts := strings.Split(email, "@")
if len(emailParts) != 2 {
return false
}
account := emailParts[0]
address := emailParts[1]
if len(account) > 64 {
return false
} else if len(address) > 255 {
return false
}
for _, part := range strings.Split(address, ".") {
if len(part) > 63 {
return false
}
}
return validationEmailRegex.MatchString(email)
}
var validationURLRegex = regexp.MustCompile("[(http(s)?)://(www\\.)?a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)")
// IsURLValid reports whether url contains a substring that looks like a URL.
func IsURLValid(url string) bool {
return validationURLRegex.MatchString(url)
}
// IsZipCodeValid reports whether zip has exactly 5 or 9 digits.
func IsZipCodeValid(zip string) bool {
rawZip := validationDigits(zip)
return len(rawZip) == 5 || len(rawZip) == 9
}
// IsTaxIDValid reports whether id has exactly 9 digits.
func IsTaxIDValid(id string) bool {
return len(validationDigits(id)) == 9
}
// IsAtLeastMinChars reports whether input is at least minLen characters long.
func IsAtLeastMinChars(input string, minLen int) bool {
return utf8.RuneCountInString(input) >= minLen
}
// IsWithinMaxChars reports whether input is at most maxLen characters long.
func IsWithinMaxChars(input string, maxLen int) bool {
return utf8.RuneCountInString(input) <= maxLen
}
var validationNameRegex = regexp.MustCompile(`^[\p{L}]*[\p{L} '\-]*[\p{L}]$`)
// IsNameValid reports whether name consists only of letters, spaces, hyphens,
// and apostrophes (and begins/ends with a letter).
func IsNameValid(name string) bool {
return validationNameRegex.MatchString(name)
}
var validationUsernameRegex = regexp.MustCompile(`^[A-Za-z0-9]*$`)
// IsUsernameValid reports an error message when the username is invalid (5-50
// alphanumeric characters), or "" when valid.
func IsUsernameValid(username string) string {
if n := utf8.RuneCountInString(username); n < 5 || n > 50 {
return "Username must have 5-50 characters"
}
if !validationUsernameRegex.MatchString(username) {
return "Username must only contain alphanumeric characters"
}
return "" // Valid
}