Skip to content

NadeemIqbal/cmp-form

Repository files navigation

cmp-form

A simple but powerful form validator for Compose Multiplatform. One field + one rule is one line; the full feature set covers async server-side checks, cross-field rules, multi-step wizards, conditional fields, server-error injection, and i18n — without dragging in a heavy framework.

Maven Central License Kotlin Android iOS Desktop Web

Using a pure Android Compose project? You're covered.

cmp-form is published as a Kotlin Multiplatform library, but you don't need any KMP setup to use it. Drop it into a regular :app module's dependencies { ... } block and Gradle picks the Android variant automatically — same as Coil, Ktor, Paging, or any other modern KMP library.

// app/build.gradle.kts
dependencies {
    implementation("io.github.nadeemiqbal:cmp-form:0.1.0")
    implementation("io.github.nadeemiqbal:cmp-form-material3:0.1.0")
}

Requires minSdk >= 24 and a Compose version compatible with CMP 1.10 (androidx.compose 1.7+). The cmp-form-material3 artifact resolves to the standard androidx.compose.material3 on Android, so existing M3 imports work alongside it with no conflicts. See Android-only setup below for the full snippet.

Why this library

Forms in Compose are tedious: a tangle of MutableState, remember, focus-aware blur tracking, async lookups, and ad-hoc cross-field validation. cmp-form gives you a typed FieldState<T> and a builder DSL that own the boring parts so your screen code stays the size of a JSX form.

It ships in two artifacts so the validator/state core stays Material3-free for users who bind to a custom design system:

Artifact What you get Compose deps
cmp-form Validator<T>, ValidationResult, every built-in rule, FieldState<T>, FormState, the rememberFormState { ... } DSL, Modifier.formField() runtime + foundation + ui
cmp-form-material3 Drop-in FormTextField, FormCheckbox, FormSwitch, FormRadioGroup, FormSlider, FormDatePicker — wraps M3 components and wires the error state automatically. Transitively brings cmp-form. + material3

Platform support

Platform Supported Tested
Android ✅ (unit + sample APK)
iOS ✅ (compile)
Desktop ✅ (unit + sample)
Web ✅ (compile + sample)

Installation

Compose Multiplatform setup

gradle/libs.versions.toml:

[libraries]
cmp-form           = { module = "io.github.nadeemiqbal:cmp-form",            version = "0.1.0" }
cmp-form-material3 = { module = "io.github.nadeemiqbal:cmp-form-material3", version = "0.1.0" }

commonMain dependencies:

commonMain.dependencies {
    implementation(libs.cmp.form)               // core: validators + state + DSL
    implementation(libs.cmp.form.material3)     // optional: drop-in M3 composables
}

Android-only setup

If you're not using Compose Multiplatform — just a regular com.android.application / com.android.library module with Compose — add the dependencies straight to the Android module:

// app/build.gradle.kts
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("org.jetbrains.kotlin.plugin.compose")
}

android {
    defaultConfig {
        minSdk = 24       // cmp-form requires API 24+
    }
    buildFeatures { compose = true }
}

dependencies {
    implementation("io.github.nadeemiqbal:cmp-form:0.1.0")
    implementation("io.github.nadeemiqbal:cmp-form-material3:0.1.0")

    // Your existing Compose deps — cmp-form does NOT replace these.
    implementation(platform("androidx.compose:compose-bom:2024.10.00"))
    implementation("androidx.compose.material3:material3")
    implementation("androidx.activity:activity-compose:1.9.3")
}

That's it — every FormTextField, rememberFormState, validator function in this README works identically. Gradle resolves the Android variant of the KMP publication automatically; you don't need to apply the Kotlin Multiplatform plugin or use commonMain.

Requirements / things to verify:

  • minSdk >= 24 — bump your module's minSdk if you're on 21–23, otherwise the manifest merger will fail.
  • Compose compatibilitycmp-form is built against Compose Multiplatform 1.10.3, which is binary-compatible with androidx.compose 1.7+ (Compose BOM 2024.10 or later). If you're on an older BOM, upgrade.
  • Kotlin compatibility — built with Kotlin 2.3.21. Your project's Kotlin should be the same major version (the standard Compose-Kotlin compatibility matrix applies).
  • Transitive deps — adds kotlinx-datetime (~80 KB, used by date/age validators) and kotlinx-coroutines-core. No other runtime dependencies.
  • Material3 conflictscmp-form-material3 depends on org.jetbrains.compose.material3, which resolves to the same androidx.compose.material3:material3 artifact on Android. Same package names, no duplicate-class errors — import androidx.compose.material3.Button works alongside import io.github.nadeemiqbal.cmpform.material3.FormTextField.

If you only want the headless validator/state core (no Material3 composables), drop cmp-form-material3 and depend on cmp-form alone — you can then bind FieldState to any TextField (M2, M3, or your own design system) by wiring field.value, field::onValueChange, field.hasError, and field.errorText manually.

Quick start

val form = rememberFormState {
    field("email", "") { validator = required() and email() }
    field("password", "") { validator = required() and minLength(8) }
}

FormTextField(field = form.field("email"), label = { Text("Email") })
FormTextField(field = form.field("password"), label = { Text("Password") })

Button(onClick = {
    scope.launch {
        form.submit { values -> login(values["email"] as String, values["password"] as String) }
    }
}) { Text("Login") }

That's a complete, validated login form. required() rejects blanks, email() checks the format, minLength(8) enforces password length. The DSL hides the MutableState, focus listeners and "did the user touch the field yet?" bookkeeping.

What's built in

Presence & lengthrequired(), requiredNotBlank(), notNull<T>(), mustBeTrue(), nonEmpty<T>(), sizeRange<T>(), minLength(), maxLength(), exactLength(), lengthRange().

Character classesalphabetic(), numeric(), alphanumeric(), ascii(), noWhitespace(), noLeadingTrailingSpace(), containsDigit(), containsUppercase(), containsLowercase(), containsSpecialChar().

Formatsemail(), url(), phone(), ipv4(), ipv6(), hostname(), slug(), uuid(), hexColor(), pattern(regex), postalCode(country) (~25 countries bundled).

Numericmin(), max(), range() (any Comparable<T>), positive(), negative(), nonNegative(), nonPositive(), multipleOf() (Int/Long), integer(), decimal(), currency() (String-parseability).

ComparisonequalTo(), notEqualTo(), oneOf(...), notOneOf(...).

Dates (via kotlinx-datetime) — isoDate(), dateInRange(), minAge(), maxAge(), ageRange(), inFuture(), inPast(), weekday(), weekend().

FinancialcreditCard() with Luhn + brand restriction, iban() (mod-97), bic() (SWIFT).

Passwordspassword { minLength(8); upper(); lower(); digit(); special(); noCommon() } DSL aggregator, passwordStrength(Strong) for entropy thresholds, scorePassword() for an unbounded 0..4 strength meter.

Cross-fieldmatchesField("otherKey"), differentFromField("otherKey"), whenField("otherKey", predicate, then).

FilesFileSpec value-object + fileSize(), fileExtension(), mimeType() (with "image/*" wildcard support).

Combinatorsa and b (short-circuit), a or b, a andAll b (accumulate every failure), not(v), whenever(predicate, v), and a contramap(transform) operator for adapting a validator to a different input type.

Escape hatchescustom("code", "message") { predicate } for sync, customSuspend("code", "message") { suspendPredicate } for async, plus inline asyncValidator = ... in the DSL.

Async validation

field("username", "") {
    validator = required() and minLength(3) and slug()
    asyncValidator = customSuspend("taken", "Username is taken") {
        api.usernameAvailable(it)
    }
    debounce = 400.milliseconds
}

field.isValidating is observable, submit() waits for async checks to resolve before invoking the handler, and the debounce auto-cancels in-flight requests on new keystrokes.

Trigger modes

Per-field, configurable via triggerMode = ...:

Mode Behavior
OnSubmit Silent until form.submit() / validateAll().
OnBlur Validates when focus leaves.
OnChange Validates on every keystroke.
OnChangeAfterBlur (default) Silent until first blur, then live. The Yup/Formik default.
OnChangeAfterSubmit Silent until the first submit(), then live.
Manual Caller drives field.validate().

Cross-field rules

field("password", "") { validator = required() and minLength(8) }
field("confirm", "") { validator = required() and matchesField("password") }

Or a form-level rule that runs against the whole snapshot:

formRule("dates") {
    if ((get<Int>("start") ?: 0) > (get<Int>("end") ?: 0))
        invalid("order", "Start must be ≤ end")
    else valid()
}

Multi-step / wizard forms

val form = rememberFormState {
    step("account") { field("email", "") { ... } }
    step("personal") { field("name", "") { ... } }
}
form.nextStep()   // advances only when the current step is valid

enabledWhen { snapshot -> ... } on a field excludes it from validation and from values when the predicate is false — useful for fields like "Company" that only matter if "Employed" is checked.

Server errors

try { api.signup(form.values) }
catch (e: HttpException) {
    form.setServerErrors(e.fieldErrors)   // { "email" to "already registered", ... }
}

i18n / custom messages

object FrenchMessages : FormMessages {
    override fun messageFor(code: String, params: Map<String, Any?>) = when (code) {
        "required" -> "Champ obligatoire"
        "email" -> "Adresse e-mail invalide"
        else -> DefaultMessages.messageFor(code, params)
    }
}

CompositionLocalProvider(LocalFormMessages provides FrenchMessages) { SignupScreen() }

Each ValidationError also carries a code, so apps using stringResource() can map codes to platform-native string resources themselves.

State persistence (config changes)

rememberFormState is backed by rememberSaveable with a Saver that round-trips primitive field values automatically. Reference-typed fields fall back to in-memory (still survive recomposition, but not Activity recreation).

Sample app

A four-screen sample lives under sample/composeApp/: Login, Signup (with async username check), Checkout (credit-card + IBAN + postal-by-country) and a 3-step Wizard (with enabledWhen conditional fields).

Run it:

./gradlew :sample:desktopApp:run
./gradlew :sample:webApp:wasmJsBrowserDevelopmentRun
./gradlew :sample:androidApp:installDebug

Roadmap

See out-of-scope in the design doc for the v0.2+ wishlist. Highlights:

  • Flow<T> API surface for non-Compose consumers
  • compose-resources integration for FormMessages
  • Field arrays / repeatable fields (fieldArray<T>("phones"))
  • Input masking (format-as-you-type for credit cards / phone)
  • Pre-built cmp-form-test artifact with mock fixtures

Contributing

See CONTRIBUTING.md.

License

Apache 2.0 — see LICENSE.

About

A simple but powerful form validator for Compose Multiplatform — async checks, cross-field rules, multi-step wizards, conditional fields, server-error injection. Drop-in Material3 bindings included. Works in Android-only Compose projects too.

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages