Skip to content

Combobox, tags & change handlers

Three things a generated form needs once it stops being a demo. A dropdown with two hundred countries in it should be typed into, not scrolled. A list of labels should be chips, not a repeater with reorder buttons on every word. And "when the country changes, clear the city" is neither a computed value nor a cascading lookup — it writes something the user is still free to change afterwards.

Live form
Talent profile

4 choices available

Type to filter. An answer that is not on the list is reported.

0 choices available

Loaded for the chosen country — and this one accepts anywhere, on the list or not.

Plan

Choosing a plan fills in the seats below — which you can then change.

Skills

Enter or a comma adds one. Backspace on an empty box removes the last.

View source
BormBuilder.For<TalentProfile>()

    // --- Combobox -----------------------------------------------------------------------------
    // Built on the browser's own <input list> + <datalist>, so filtering, keyboard navigation,
    // touch behaviour and screen-reader support are the platform's rather than a thousand lines of
    // hand-rolled ARIA. What the library adds is the one thing a bare datalist cannot do: map the
    // label the user reads onto the value the model stores.
    .Field(x => x.Country, f => f
        .Options(("fr", "France"), ("gb", "United Kingdom"), /* … 200 more … */)
        .AsCombobox()                 // closed: an answer on no list is reported
        .Clearable()
        .Required())

    // A combobox that is a shortcut rather than a constraint. The list proposes; anything is accepted.
    .Field(x => x.City, f => f
        .AsCombobox(allowCustom: true)
        .OptionsFrom(                 // and it cascades, exactly as a <select> does
            load: async ctx => await directory.GetCitiesAsync(ctx.Value("Country") as string),
            value: c => c, label: c => c,
            dependsOn: nameof(TalentProfile.Country)))

    // --- Change handler -----------------------------------------------------------------------
    // Neither Computed (which owns its field's value outright) nor a cascading dependency (which
    // reloads choices) can express "write something the user may then edit". TanStack Form calls
    // these listeners; Formly calls them hooks. Paths resolve relative to the owning object, so the
    // same handler on a repeater row means that row.
    .Field(x => x.Plan, f => f
        .AsRadio()
        .Options(("solo", "Solo"), ("team", "Team"), ("enterprise", "Enterprise"))
        .OnChange(ctx => ctx.SetSibling("Seats", ctx.Value switch
        {
            "solo" => 1,
            "team" => 5,
            _      => 25
        })))

    // --- Tags ---------------------------------------------------------------------------------
    // The same List<string> an ArrayOf(..., Text) holds. The difference is the affordance: a
    // repeater gives every entry a row with add, remove, duplicate and reorder buttons, which is
    // right for an invoice line and wrong for the word "urgent". Enter or a comma commits;
    // backspace on an empty box takes the last one back.
    .Field(x => x.Skills, f => f
        .AsTags(max: 8)
        .Help("Enter or comma to add. Backspace on an empty box removes the last one."))
    .Build();
An unhandled error has occurred. Reload 🗙