Contents

Tier 2 controls go beyond native HTML inputs: server-side search, tree/graph selection, rich text/code/markdown editing, file uploads with previews, drawing pads, and more. All controls live in github.com/zatrano/goui/forms, including the select family, phone input, data-driven pickers, editors, uploads, and visual controls.

import "github.com/zatrano/goui/forms"

Every field below embeds core.BaseComponent + forms.CommonAttrs + forms.FieldValidation (unless noted) and implements the shared Name()/RawValue()/SetRawValue(string)/Validate() bool contract described in 05-forms-tier1.md and 06-validation.md. All are server-rendered: the Go struct is the single source of truth, and the browser only owns the small slice of UI state noted explicitly below as "UI-only" (e.g. which month a calendar is currently showing).


The select family (forms)

All of these embed forms.BaseSelectField:

type SelectItem struct {
    Value    string
    Label    string
    Disabled bool
}

type BaseSelectField struct {
    core.BaseComponent
    forms.CommonAttrs
    forms.FieldValidation

    Items       []SelectItem
    Filtered    []SelectItem // last server-side filter pass
    Query       string
    Open        bool
    Value       string
    Values      []string
    FilterMode  FilterMode // FilterServer (default) | FilterClient
    MaxResults  int        // default 50
    Placeholder string

    OnChange func(value string)
    OnQuery  func(query string)
}

Server vs. UI-only: filtering is server-side by default (FilterServer) — every keystroke sends a query event, the server recomputes Filtered via forms.FilterItems (case-insensitive substring match on label/value, capped at MaxResults), and streams back the new <li> list as a patch. FilterMode: FilterClient exists for small, fixed lists but even then the server still owns selection state; nothing in forms filters the option DOM purely in JavaScript. The optional selectable.js client module reinforces this: it only adds keyboard highlight/Enter-to-select on top of whatever list the server already rendered — it does not filter options client-side.

Searchable Select

type SearchableSelect struct {
    BaseSelectField
    EventName string // prefix for events, e.g. "city" → city.query / city.select
}
city := forms.SearchableSelect{
    BaseSelectField: forms.BaseSelectField{
        CommonAttrs: forms.CommonAttrs{Name: "city", ID: "city"},
        Placeholder: "Select a city",
        Items: []forms.SelectItem{
            {Value: "ist", Label: "İstanbul"},
            {Value: "ank", Label: "Ankara"},
        },
    },
    EventName: "city",
}

HandleEvent actions (dispatched via <eventName>.<action>): toggle, open, close, query, select. No client module file of its own — its open/close/keyboard behavior is covered generically by selectable.js.

Multi Select

type MultiSelect struct {
    BaseSelectField
    EventName string
}

Same shape as SearchableSelect but tracks Values []string; renders selected items as removable <span class="goui-chip"> tags. Actions: toggle, open, close, query, select (toggles membership), remove.

cities := forms.MultiSelect{
    BaseSelectField: forms.BaseSelectField{
        CommonAttrs: forms.CommonAttrs{Name: "cities", ID: "cities"},
        Items:       cityItems,
    },
    EventName: "cities",
}

Combobox

type Combobox struct {
    BaseSelectField
    EventName      string
    RestrictToList bool // when true, rejects free text — Value only from Items
}

A text input that also opens a filtered suggestion panel. Unless RestrictToList is set, every keystroke also sets Value to the raw typed text (free text allowed); picking a suggestion sets Value to the item and Query to its label. Actions: toggle/open, close, query, select, commit.

role := forms.Combobox{
    BaseSelectField: forms.BaseSelectField{
        CommonAttrs: forms.CommonAttrs{Name: "role", ID: "role"},
        Items: []forms.SelectItem{{Value: "admin", Label: "Admin"}, {Value: "editor", Label: "Editor"}},
    },
    EventName: "role",
}

Autocomplete

type Autocomplete struct {
    BaseSelectField
    EventName string
}

Like Combobox but does not set Value while typing — only a suggestion pick (or commit with no pick, which falls back to the typed text) sets Value. Actions: query, select, commit, close.

suggest := forms.Autocomplete{
    BaseSelectField: forms.BaseSelectField{CommonAttrs: forms.CommonAttrs{Name: "suggest", ID: "suggest"}, Items: cityItems},
    EventName: "suggest",
}

Tag Input / Chips Input

type TagInput struct {
    core.BaseComponent
    forms.CommonAttrs
    forms.FieldValidation

    Values      []string
    Draft       string
    Placeholder string
    EventName   string
    OnChange    func(tags []string)
}

// ChipsInput is an alias highlighting chip presentation of TagInput values.
type ChipsInput = TagInput

Not built on BaseSelectField — it's free-text tag collection, not a picker over a fixed Items list. Deduplicates case-insensitively. Actions: draft (as-you-type buffer), add/commit (comma-separated input supported — "go, rust" adds both), remove.

skills := forms.TagInput{
    CommonAttrs: forms.CommonAttrs{Name: "skills", ID: "skills"},
    Placeholder: "Add a tag (Enter/blur)",
    EventName:   "skills",
}

Tree Select

type TreeNode struct {
    Value    string
    Label    string
    Disabled bool
    Children []TreeNode
}

type TreeSelect struct {
    BaseSelectField
    Nodes     []TreeNode
    Expanded  map[string]bool
    EventName string
}

Mount lazily allocates Expanded. Renders a nested <ul> with expand/collapse toggles per branch node. Actions: toggle (panel open/close), close, expand (toggle a node's expanded state), select.

dept := forms.TreeSelect{
    BaseSelectField: forms.BaseSelectField{CommonAttrs: forms.CommonAttrs{Name: "dept", ID: "dept"}},
    EventName:       "dept",
    Nodes: []forms.TreeNode{
        {Value: "eng", Label: "Engineering", Children: []forms.TreeNode{
            {Value: "be", Label: "Backend"}, {Value: "fe", Label: "Frontend"},
        }},
    },
}

Cascader

type CascaderLevel struct {
    Items    []SelectItem
    Selected string
}

type Cascader struct {
    BaseSelectField
    EventName    string
    Levels       []CascaderLevel
    LoadChildren func(level int, parentValue string) []SelectItem
}

Multi-column drill-down where each pick loads the next column server-side via your LoadChildren callback. Mount seeds Levels[0] from Items if Levels is empty. RawValue() joins every level's selection with /. Action: pick (payload carries both value and level); picking clears any deeper levels and either appends a new column (LoadChildren returned items) or commits (OnChange) if there are no children.

loc := forms.Cascader{
    BaseSelectField: forms.BaseSelectField{
        CommonAttrs: forms.CommonAttrs{Name: "loc", ID: "loc"},
        Items:       []forms.SelectItem{{Value: "tr", Label: "Türkiye"}, {Value: "de", Label: "Almanya"}},
    },
    EventName: "loc",
    LoadChildren: func(level int, parent string) []forms.SelectItem {
        if level == 0 && parent == "tr" {
            return []forms.SelectItem{{Value: "ist", Label: "İstanbul"}, {Value: "ank", Label: "Ankara"}}
        }
        return nil
    },
}

Dual Listbox

type DualListbox struct {
    BaseSelectField
    EventName      string
    SelectedQuery  string
    SelectedFilter []SelectItem
}

Two independently-searchable columns ("available" / "selected") with move actions. Both sides are filtered server-side (ApplyAvailableQuery, ApplySelectedQuery). Actions: query_left/query (available side), query_right (selected side), add, remove, add_all, remove_all.

perms := forms.DualListbox{
    BaseSelectField: forms.BaseSelectField{CommonAttrs: forms.CommonAttrs{Name: "perms", ID: "perms"}, Items: permItems},
    EventName: "perms",
}

Phone Input (forms)

type PhoneInput struct {
    forms.CommonAttrs
    forms.FieldValidation

    Dial   SearchableSelect // dial code
    Number forms.TextInput  // national number
}

func NewPhoneInput(name string) *PhoneInput

Not a new control family — a composition helper wiring a SearchableSelect (dial code, preloaded from forms.DialCodeItems(), default +90) next to a forms.TextInput (type=tel). RawValue() returns an E.164-ish "<dial> <number>" string. HandleEvent dispatches by matching event prefix to whichever sub-field it belongs to.

phone := forms.NewPhoneInput("phone") // *PhoneInput

Country / Language / Timezone / Currency Picker (forms)

These are not separate struct types — they are SearchableSelect factory functions preloaded with curated []SelectItem data (forms.CountryItems(), LanguageItems(), TimezoneItems(), CurrencyItems()):

func NewCountryPicker(name, event string) SearchableSelect
func NewLanguagePicker(name, event string) SearchableSelect
func NewTimezonePicker(name, event string) SearchableSelect
func NewCurrencyPicker(name, event string) SearchableSelect
country := forms.NewCountryPicker("country", "country")   // SearchableSelect
language := forms.NewLanguagePicker("lang", "lang")
tz := forms.NewTimezonePicker("tz", "tz")
currency := forms.NewCurrencyPicker("cur", "cur")

Everything documented above for SearchableSelect (server-side filter, selectable.js keyboard nav only, no client-side filtering) applies unchanged.


Emoji / Icon / Font Picker (forms)

Same pattern as the pickers above — SearchableSelect factories over curated item sets (EmojiItems(), IconItems(), FontItems()):

func NewEmojiPicker(name, event string) SearchableSelect
func NewIconPicker(name, event string) SearchableSelect
func NewFontPicker(name, event string) SearchableSelect
emoji := forms.NewEmojiPicker("emoji", "emoji")
icon := forms.NewIconPicker("icon", "icon")
font := forms.NewFontPicker("font", "font")

FontItems() returns full CSS font-family stacks as Value (e.g. "Georgia, serif") so you can apply Value directly as an inline style="font-family:..." for a live preview, as examples/misc-controls does.

forms.MentionUsers() — a small curated []SelectItem directory of sample users — lives in the same file and is meant to seed MentionUser lists for forms.MentionTextarea (below), not to be rendered as a picker itself.


Currency Input (forms)

type CurrencyInput struct {
    core.BaseComponent
    CommonAttrs
    FieldValidation

    Value     float64
    Currency  string // ISO code, default TRY
    Locale    string // default "tr"
    Decimals  int    // default 2
    Draft     string // raw text while typing
    EventName string
    OnChange  func(value float64)
}

Stores a float64; all display formatting is server-side (forms.NumberFormat/forms.ParseLocalizedNumbertr uses 1.234,56-style grouping, en uses 1,234.56). While typing, raw text is held in Draft and only committed to Value on blur/change if it parses; otherwise the unparsed Draft stays visible so the user can fix a typo.

price := forms.CurrencyInput{
    CommonAttrs: forms.CommonAttrs{Name: "price", ID: "price"},
    Currency:    "TRY",
    Locale:      "tr",
    Value:       1250.5,
    EventName:   "price",
}

Percentage Input (forms)

type PercentageInput struct {
    core.BaseComponent
    CommonAttrs
    FieldValidation

    Value     float64 // percentage points, e.g. 45.5 means 45,5%
    Locale    string
    Decimals  int      // default 1
    Min, Max  *float64
    Draft     string
    EventName string
    OnChange  func(value float64)
}

Same draft/commit/locale-formatting pattern as CurrencyInput, with optional Min/Max clamping.

max, min := 100.0, 0.0
vat := forms.PercentageInput{
    CommonAttrs: forms.CommonAttrs{Name: "vat", ID: "vat"},
    Value: 20, Min: &min, Max: &max, EventName: "vat",
}

Rating (forms)

type Rating struct {
    core.BaseComponent
    CommonAttrs
    FieldValidation

    Value     int // 0..Max
    Max       int // default 5
    Icon      string // default ★
    EmptyIcon string // default ☆
    EventName string
    OnChange  func(value int)
}

Renders Max <button> icons; clicking the currently-selected star toggles it back to 0 (allows "un-rating"). No client module — pure g-click per star with data-goui-value.

score := forms.Rating{CommonAttrs: forms.CommonAttrs{Name: "score", ID: "score"}, Value: 3, Max: 5, EventName: "score"}

Date Range / Time Range Picker (forms)

type DateRangePicker struct {
    core.BaseComponent
    CommonAttrs
    FieldValidation
    Start, End, Min, Max string
    EventName             string
    OnChange              func(start, end string)
}

type TimeRangePicker struct {
    core.BaseComponent
    CommonAttrs
    FieldValidation
    Start, End, Min, Max, Step string
    EventName                   string
    OnChange                    func(start, end string)
}

Two native <input type="date">/<input type="time"> elements side by side; Validate() adds an extra error (forms.date_range.invalid / forms.time_range.invalid) when End < Start. No client module — both are plain native inputs with g-change.

leave := forms.DateRangePicker{
    CommonAttrs: forms.CommonAttrs{Name: "leave", ID: "leave"},
    Start: "2026-07-10", End: "2026-07-15", EventName: "leave",
}
shift := forms.TimeRangePicker{
    CommonAttrs: forms.CommonAttrs{Name: "shift", ID: "shift"},
    Start: "09:00", End: "17:30", EventName: "shift",
}

Calendar Date Picker (forms)

type CalendarDatePicker struct {
    core.BaseComponent
    CommonAttrs
    FieldValidation
    Value, Min, Max string
    Open            bool
    Placeholder     string
    EventName       string
    OnChange        func(value string)
}

Server vs. UI-only: the selected Value (YYYY-MM-DD), Min/Max bounds, and open/closed state are server-owned. Month/year navigation is client-only — the / header buttons in client/modules/calendar.js (enhanceCalendar) move a local view variable and re-render the grid purely in the browser, with no network round-trip per month change. Only the final day click sends a g-click (data-goui-value="<ymd>") back to the server, via the event name in data-select-event on the panel. This is why the server-rendered panel is just a placeholder (<div class="goui-calendar-placeholder">Loading…</div>) until calendar.js mounts and takes over — it purposefully never re-renders that subtree from the server.

day := forms.CalendarDatePicker{
    CommonAttrs: forms.CommonAttrs{Name: "day", ID: "day"},
    Value: "2026-07-16", Placeholder: "Pick a date", EventName: "day",
}

Client module: client/modules/calendar.js (enhanceCalendar(root)).


OTP / PIN Input (forms)

type OTPInput struct {
    core.BaseComponent
    CommonAttrs
    FieldValidation
    Length    int // default 6
    Value     string
    Masked    bool // password-style cells (PIN)
    EventName string
    OnChange  func(value string)
}

// PINInput is an alias; set Masked: true for PIN UX.
type PINInput = OTPInput

Renders Length single-character <input> cells (type=password when Masked). The full code lives server-side in Value; per-cell edits use action digit (payload carries index + value), full replace uses commit/paste/change/input. Validate() adds forms.otp.incomplete when the collected length doesn't match Length.

otp := forms.OTPInput{CommonAttrs: forms.CommonAttrs{Name: "otp", ID: "otp"}, Length: 6, EventName: "otp"}
pin := forms.PINInput{CommonAttrs: forms.CommonAttrs{Name: "pin", ID: "pin"}, Length: 4, Masked: true, EventName: "pin"}

Client module: client/modules/otp.js (enhanceOTP) — UI-only auto-advance-to-next-cell, backspace-to-previous, arrow-key navigation, and paste-splits-across-cells. It fires native input events per cell so the existing g-input delegation still ships every digit to the server; it does not itself talk to the WebSocket.


Rich Text Editor (forms)

type RichTextEditor struct {
    core.BaseComponent
    CommonAttrs
    FieldValidation
    Value      string // HTML
    EventName  string
    DebounceMS int    // default 350
    OnChange   func(value string)
}

Server vs. UI-only: Value (the HTML content) is authoritative on the server, but the editing surface is entirely client-owned — a Quill instance loaded from a CDN (client/modules/richtext.js, enhanceRichText/mountQuill). The rendered markup carries data-goui-ignore on the wrapper so the diff-patch client never reconciles into it (see applyPatch's isGoUIIgnored check in client/goui.js) — patching Quill's live DOM would tear down cursor position, undo history, and selection.

Content sync works through a hidden <textarea class="goui-editor-sync"> that Quill writes into on every text-change and dispatches a synthetic input event on, debounced by g-debounce. Server-side, HandleEvent's sync action updates Value without calling MarkDirty() — and the corresponding demo additionally returns core.ErrSkipRender from the parent component's HandleEvent for this control, so no render frame is ever sent back for rich-text sync events:

case strings.HasPrefix(event, "rt."):
    _ = d.Rich.HandleEvent(ctx, event, payload)
    // Quill owns the DOM — patching would remount and double-escape HTML.
    return core.ErrSkipRender
rich := forms.RichTextEditor{CommonAttrs: forms.CommonAttrs{Name: "rt", ID: "rt"}, Value: "<p>Hello</p>", EventName: "rt"}

Client module: client/modules/richtext.js.

Markdown Editor (forms)

type MarkdownEditor struct {
    core.BaseComponent
    CommonAttrs
    FieldValidation
    Value       string
    PreviewHTML string
    Rows        int    // default 10
    Placeholder string
    EventName   string
    DebounceMS  int    // default 250
    OnChange    func(value string)
}

Server vs. UI-only: the source <textarea> is a normal server-rendered Tier-1-style control (no client module, no data-goui-ignore) — every keystroke round-trips through g-input/sync like a regular Textarea. The live preview pane is rendered entirely on the server using goldmark via the exported helper:

func RenderMarkdown(source string) string

Mount and every sync HandleEvent call refreshPreview(), which sets PreviewHTML = RenderMarkdown(Value); Render() emits that HTML directly into a <div class="goui-markdown-preview">. Because this is a normal (non-ignored) subtree, the diff engine happily patches it like any other server-rendered HTML.

md := forms.MarkdownEditor{
    CommonAttrs: forms.CommonAttrs{Name: "md", ID: "md"},
    Value:       "# Hello\n\n**Markdown** rendered server-side.",
    Rows:        12,
    EventName:   "md",
}

Code Editor (forms)

type CodeEditor struct {
    core.BaseComponent
    CommonAttrs
    FieldValidation
    Value      string
    Language   string // e.g. javascript, go, htmlmixed — default javascript
    EventName  string
    DebounceMS int    // default 350
    OnChange   func(value string)
}

Server vs. UI-only: identical pattern to RichTextEditor — a CodeMirror 5 instance from a CDN (client/modules/codeeditor.js, enhanceCodeEditor/mountCM) owns the editing surface, marked data-goui-ignore so patches never touch it, and syncs through a hidden <textarea class="goui-editor-sync"> debounced by g-debounce. Just like rich text, the parent component's HandleEvent should return core.ErrSkipRender for code.* sync events:

case strings.HasPrefix(event, "code."):
    _ = d.Code.HandleEvent(ctx, event, payload)
    return core.ErrSkipRender
code := forms.CodeEditor{
    CommonAttrs: forms.CommonAttrs{Name: "code", ID: "code"},
    Value:       "function hello() {\n  return 'GoUI';\n}\n",
    Language:    "javascript",
    EventName:   "code",
}

Client module: client/modules/codeeditor.js.


Drag & Drop Upload / Image Upload (forms)

type UploadedRef struct {
    ID          string
    Name        string
    URL         string
    ContentType string
    Size        int64
}

type DragDropUpload struct {
    core.BaseComponent
    CommonAttrs
    FieldValidation
    Files      []UploadedRef
    Accept     string
    Multiple   bool
    ShowThumbs bool
    UploadURL  string // default /goui/upload
    EventName  string
    OnChange   func(files []UploadedRef)
}

// ImageUpload preset: Accept "image/*", ShowThumbs true.
func NewImageUpload(name, event string) DragDropUpload

Server vs. UI-only: binary bytes never travel over the WebSocket. client/modules/upload.js (enhanceUpload) intercepts drag/drop and file input change, POSTs the raw file to data-upload-url (via your adapter's Store option or upload.Mount, which writes to an upload.Storage — e.g. upload.LocalStore — and returns JSON Meta), then synthesizes a click on a hidden <button class="goui-upload-carrier" g-click="<event>.uploaded"> carrying the metadata as data-goui-* attributes so the existing g-click/collectPayload delegation ships an event frame with id, name, url, size, contentTypeonly the small JSON reference travels over the socket. Server-side, action uploaded appends/replaces a forms.UploadedRef; action remove drops one by ID.

docs := forms.DragDropUpload{
    CommonAttrs: forms.CommonAttrs{Name: "docs", ID: "docs"},
    Multiple:    true,
    Accept:      ".pdf,.txt,.png,.jpg",
    ShowThumbs:  true,
    EventName:   "docs",
}
images := forms.NewImageUpload("images", "images") // DragDropUpload preset

Register the HTTP side once per app:

store, err := upload.NewLocalStore("./.goui-uploads", "/goui/files", 8<<20)
gouifiber.Register(app, gouifiber.Options{Server: server, Store: store})
// POST /goui/upload, GET /goui/files/:id

Client module: client/modules/upload.js (also imported by avatar.js and signature.js below for their own postFile/notifyUploaded calls).

Avatar Upload + Image Cropper (forms)

type AvatarUpload struct {
    core.BaseComponent
    CommonAttrs
    FieldValidation
    Avatar    UploadedRef
    UploadURL string
    EventName string
    OnChange  func(ref UploadedRef)
}

Server vs. UI-only: the final stored Avatar ref is server state; the crop interaction itself is entirely client-side. client/modules/avatar.js (enhanceAvatar) opens a <canvas> overlay on file pick, lets the user pan (pointerdown/pointermove) a 1:1 square, and on "Kırp & Yükle" (Crop & Upload) calls canvas.toBlob(...) to rasterize the crop client-side into a PNG Blob, uploads that with the same postFile/notifyUploaded helpers upload.js exposes, then hides the overlay. The server never sees uncropped pixels or crop coordinates — only the final cropped PNG file reference (action: "uploaded") or a "clear" action to remove it.

avatar := forms.AvatarUpload{CommonAttrs: forms.CommonAttrs{Name: "avatar", ID: "avatar"}, EventName: "avatar"}

Client module: client/modules/avatar.js.

Signature Pad (forms)

type SignaturePad struct {
    core.BaseComponent
    CommonAttrs
    FieldValidation
    File      UploadedRef
    UploadURL string
    EventName string
    OnChange  func(ref UploadedRef)
}

Server vs. UI-only: drawing itself (pointerdown/pointermove strokes on a <canvas>) is 100% client-side (client/modules/signature.js, enhanceSignature/mountPad). Clicking "Kaydet" (Save) rasterizes the canvas to a PNG blob and uploads it exactly like AvatarUpload does, firing action: "uploaded" on success; "Temizle" (local clear) only clears the canvas pixels without any server round-trip; a separate server-bound "Kaydı sil" button (rendered only once File.ID is set) sends action: "clear" to drop the stored reference.

sig := forms.SignaturePad{CommonAttrs: forms.CommonAttrs{Name: "sig", ID: "sig"}, EventName: "sig"}

Client module: client/modules/signature.js.


Mention (forms)

type MentionUser struct {
    ID    string
    Label string
}

type MentionTextarea struct {
    core.BaseComponent
    CommonAttrs
    FieldValidation
    Value       string
    Placeholder string
    Rows        int // default 4
    Users       []MentionUser // full directory
    Filtered    []MentionUser
    Query       string        // text after @
    Open        bool
    EventName   string
    OnChange    func(value string)
}

A <textarea> that detects an unfinished @fragment at the cursor position (via mentionQuery, string-based — looks at the whole Value, not real cursor position, so it's a simplified "last @" heuristic) and opens a server-filtered suggestion list (filterUsers, substring match on ID/label, capped at 8). Picking a suggestion (pick action) replaces the @fragment with @<id> . No client module — a plain Textarea-style control with a conditionally-rendered <ul> after it.

mention := forms.MentionTextarea{
    CommonAttrs: forms.CommonAttrs{Name: "mention", ID: "mention"},
    Placeholder: "Tag someone with @...",
    Users:       []forms.MentionUser{{ID: "ayse", Label: "Ayşe Yılmaz"}},
    EventName:   "mention",
}

Color (Swatch) Picker (forms)

type SwatchColorPicker struct {
    core.BaseComponent
    CommonAttrs
    FieldValidation
    Value     string // #rrggbb
    Swatches  []string
    EventName string
    OnChange  func(value string)
}

An advanced alternative to the native forms.ColorInput from Tier 1: a row of preset swatch buttons plus a free-text hex field. Defaults to 10 preset swatches if Swatches is empty. Actions: pick/select (from a swatch), hex/change/input (from the text field, normalized via normalizeHex — lower-cased, #-prefixed).

color := forms.SwatchColorPicker{
    CommonAttrs: forms.CommonAttrs{Name: "color", ID: "color"},
    Value:       "#2563eb",
    EventName:   "color",
}

Gradient Picker (forms)

type GradientPicker struct {
    core.BaseComponent
    CommonAttrs
    FieldValidation
    From, To, Angle string // e.g. Angle "135deg"
    EventName       string
    OnChange        func(css string)
}

func (g *GradientPicker) CSS() string // "linear-gradient(<angle>, <from>, <to>)"

Two native <input type="color"> swatches plus a free-text angle field; Render() shows a live preview <div> and the generated CSS as <code>. Actions: from, to, angle.

grad := forms.GradientPicker{
    CommonAttrs: forms.CommonAttrs{Name: "grad", ID: "grad"},
    From: "#2563eb", To: "#db2777", Angle: "135deg", EventName: "grad",
}

Character Counter (ShowCharCount)

Not a separate struct — a field on existing Tier 1 controls: forms.TextInput.ShowCharCount and forms.Textarea.ShowCharCount. When true, Render() appends a <p class="goui-char-count"> showing len(value) / MaxLength (rune-counted), colored as an error once the count exceeds MaxLength. Setting either flag also defaults g-debounce to 100 if you haven't set DebounceMS yourself, so the counter updates responsively without spamming events on every keystroke.

bio := forms.Textarea{
    CommonAttrs:   forms.CommonAttrs{Name: "bio", ID: "bio"},
    Rows:          4,
    MaxLength:     120,
    ShowCharCount: true,
    HelperText:    "Up to 120 characters",
}

Password Strength (ShowStrength)

Also a field on forms.TextInput: ShowStrength bool, which only renders when Type == "password". Scoring is a small server-side heuristic (forms.PasswordStrength, 0–4: length ≥8/≥12, character-class diversity) exposed as:

type PasswordStrengthLevel int
const (
    StrengthEmpty PasswordStrengthLevel = iota
    StrengthWeak
    StrengthFair
    StrengthGood
    StrengthStrong
)
func PasswordStrength(password string) PasswordStrengthLevel

Render() appends a <div class="goui-password-strength <level>"> bar (width = level*25%) plus a translated label (forms.password_strength.* i18n keys — see 03-i18n.md).

pw := forms.TextInput{
    CommonAttrs:  forms.CommonAttrs{Name: "pw", ID: "pw"},
    Type:         "password",
    ShowStrength: true,
}

Summary table

Control Package Struct Client module Notes
Searchable Select forms SearchableSelect — (uses selectable.js) server-side filter
Multi Select forms MultiSelect — (uses selectable.js) chips of Values
Combobox forms Combobox — (uses selectable.js) free text unless RestrictToList
Autocomplete forms Autocomplete — (uses selectable.js) Value only set on pick/commit
Tag Input / Chips Input forms TagInput / ChipsInput (alias) dedupe, comma-split
Tree Select forms TreeSelect server-owned Expanded map
Cascader forms Cascader LoadChildren callback
Dual Listbox forms DualListbox two independently-filtered sides
Phone forms PhoneInput composes SearchableSelect + TextInput
Country/Language/Timezone/Currency Picker forms SearchableSelect (via NewXPicker) curated SelectItem data
Emoji/Icon/Font Picker forms SearchableSelect (via NewXPicker) curated SelectItem data
Currency Input forms CurrencyInput server locale formatting
Percentage Input forms PercentageInput server locale formatting
Rating forms Rating pure g-click
Date Range forms DateRangePicker two native <input type=date>
Time Range forms TimeRangePicker two native <input type=time>
Calendar forms CalendarDatePicker calendar.js month nav is client-only
OTP / PIN forms OTPInput / PINInput (alias) otp.js UI-only auto-advance/paste
Rich Text forms RichTextEditor richtext.js Quill; ErrSkipRender + data-goui-ignore
Markdown forms MarkdownEditor server-rendered via goldmark
Code Editor forms CodeEditor codeeditor.js CodeMirror; ErrSkipRender + data-goui-ignore
DragDrop Upload forms DragDropUpload upload.js binary via HTTP, ref via WS
Image Upload forms DragDropUpload (via NewImageUpload) upload.js preset: image/* + thumbnails
Avatar Upload forms AvatarUpload avatar.js includes crop overlay
Image Cropper forms (part of AvatarUpload) avatar.js client-side canvas crop
Color (Swatch) forms SwatchColorPicker swatches + hex field
Gradient forms GradientPicker two colors + angle
Signature forms SignaturePad signature.js canvas draw → PNG upload
Mention forms MentionTextarea server-filtered @ suggestions
Character Counter forms TextInput.ShowCharCount / Textarea.ShowCharCount field flag, not a struct
Password Strength forms TextInput.ShowStrength field flag, requires Type: "password"

For fully wired, runnable versions of every control above, see the examples/ directory (ports and mapping documented in 01-getting-started.md) — in particular searchable-select (3002), numeric-controls (3003), field-meta (3004), date-controls (3005), identity-inputs (3006), editors (3007), media-upload (3008), and misc-controls (3009).