diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e36a4..d6aa542 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to Ikokuko are recorded in this file. ## [Unreleased] +- Rename `shouldShowErrors` to `isErrorReportingEnabled` to clarify that the flag controls + error reporting and form validity; reactive validation runs regardless of its value. +- Remove `onSubmit` from `Form` and require it at `submit(onInvalid = {}) { ... }`. + Old submit trailing lambdas handled invalid input; migrate them to named `onInvalid`. +- Give `onInvalid` the same `FormScope` receiver as `onSubmit` for reusable error handlers. +- Default `ValidationEffect.validators` to an empty list, matching `FormField`. +- Clarify reset and field lifetime while preserving the existing reactive validation behavior. +- Add multiline sample inputs with caller-owned counters and UTF-16 length validation, without truncation. + ## [0.2.0] - 2026-08-02 - Add strict field validation state and `Field.shouldDisplayError`. diff --git a/README.md b/README.md index c6c4de8..6b61075 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ dependencies { } ``` +The examples below target the unreleased API. Version 0.2.0 uses different `Form` and `submit` +signatures; see [Migrating to the next release](#migrating-to-the-next-release). +For the released API, use the [0.2.0 documentation](https://github.com/quantipixels/ikokuko/blob/release-0.2.0/README.md). + --- ### 2. [FormState](/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt) @@ -47,17 +51,18 @@ dependencies { It’s the single source of truth for the form’s current state. `FormState.isValid` follows the form's error-reporting state. It remains `true` while -`shouldShowErrors` is `false`. After error reporting is enabled, it is `false` while any field has +`isErrorReportingEnabled` is `false`. After error reporting is enabled, it is `false` while any field has a stored error. Individual `field.isValid` values remain strict regardless of error visibility. -> Optionally pass `shouldShowErrors` when creating the state to control its initial error visibility behavior. +> Set `isErrorReportingEnabled` to control when stored errors affect form validity and can appear +> on dirty fields. Validation runs reactively regardless of this flag. ```kotlin // Default: dirty-field errors are hidden until submit or a manual toggle val formState = remember { FormState() } // Dirty-field errors can be displayed immediately -val formState = remember { FormState(shouldShowErrors = true) } +val formState = remember { FormState(isErrorReportingEnabled = true) } ``` Use `rememberSaveableFormState()` when field values must survive supported platform state restoration: @@ -72,9 +77,9 @@ complete `Saver`. You can also use `FormState.saver(valuesSa saver for the complete `Map` of field names and values while Ikokuko saves the remaining form state. -#### shouldShowErrors -Controls whether stored errors can be displayed for dirty fields. A pristine field does not display -its error until it becomes dirty. `submit()` marks all initialized fields as dirty. +#### isErrorReportingEnabled +Controls whether stored errors affect form validity and can be displayed for dirty fields. +A pristine field does not display its error until it becomes dirty. `submit()` marks all initialized fields as dirty. |Value|Behaviour|Typical Use Case| |---|---|---| @@ -84,25 +89,28 @@ its error until it becomes dirty. `submit()` marks all initialized fields as dir You can toggle this flag at any time from either the FormState or inside the FormScope. ```kotlin // From FormState -formState.shouldShowErrors = true // Permit dirty-field errors -formState.shouldShowErrors = false // Hide errors again +formState.isErrorReportingEnabled = true // Permit dirty-field errors +formState.isErrorReportingEnabled = false // Hide errors again // From FormScope -Form(onSubmit = {}) { +Form { // ... - shouldShowErrors = true // Permit dirty-field errors - shouldShowErrors = false // Hide errors again + isErrorReportingEnabled = true // Permit dirty-field errors + isErrorReportingEnabled = false // Hide errors again } ``` #### Resetting the form -The form can be reset from either the FormState or inside the FormScope. +Reset clears values, errors, dirty flags, and error visibility. Mounted fields reinitialize on +recomposition using the current `initialValue` inputs, which may differ from their original values. +A submit before that recomposition sees no initialized fields. Unmounting a field clears its error +but retains its value and dirty flag; a different `FormState` replaces the form subtree. ```kotlin // From FormState formState.reset() // From FormScope -Form(onSubmit = {}) { +Form { // ... Button(onClick = ::reset) { Text("Reset Form") } } @@ -169,10 +177,11 @@ fun DemoForm() { You can connect fields to your FormState and enable validation in two ways: Use one `ValidationEffect` or `FormField` for each field in a form. +Both default `validators` to an empty list, so fields without rules can omit that argument. - **Manual setup** — call [ValidationEffect](/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt) directly to register and validate a field. ```kotlin -Form(onSubmit={ println("Email: ${EmailField.value}") }) { +Form { ValidationEffect( field = EmailField, initialValue = "", @@ -229,21 +238,28 @@ Normally, this value is updated automatically by `ValidationEffect` whenever val - Custom inline validation not covered by existing Validator classes. - Resetting or clearing errors programmatically. -#### Example: Manual error assignment +#### Example: Server error assignment ```kotlin -// Inside a FormScope - -// Assign error message -if (EmailField.value.endsWith("@test.com")) { - EmailField.error = "Test domains are not allowed" -} +// Inside a response handler or effect with access to FormScope +EmailField.error = "This email is already registered" +EmailField.isDirty = true +isErrorReportingEnabled = true -// Clear the error message +// Clear the error when appropriate EmailField.error = null ``` --- +### Multiline input and limits + +The signup sample reuses `TextInput` for an optional notes field. The adapter accepts +`minLines`, `maxLines`, and caller-owned `counterText`. The sample uses `MaxLengthValidator` +with a 240-unit notes limit and shows the counter alongside errors. +Both validation and the counter use Kotlin `String.length`: UTF-16 code units, not visible +characters. An emoji may count as two units. Input, including pasted text and newlines, is kept +intact; over-limit input is rejected by validation rather than silently truncated. + ### 6. Creating Reusable Form Components ìkọkúkọ’s [FormScope](/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt) lets you build reusable composable form components that automatically handle value binding, validation, and error display. @@ -261,7 +277,10 @@ fun FormScope.TextInput( initialValue: String = "", label: String = "", placeholder: String = "", - validators: List> = emptyList() + validators: List> = emptyList(), + minLines: Int = 1, + maxLines: Int = 1, + counterText: ((String) -> String)? = null ) { FormField(field, initialValue, validators) { Column(modifier = modifier) { @@ -275,9 +294,18 @@ fun FormScope.TextInput( color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f) ) }, - supportingText = field.error.takeIf { field.shouldDisplayError }?.let { { Text(it) } }, + supportingText = if (field.shouldDisplayError || counterText != null) { + { + Column { + if (field.shouldDisplayError) field.error?.let { Text(it) } + counterText?.let { Text(it(field.value)) } + } + } + } else null, onValueChange = { field.value = it }, - singleLine = true, + singleLine = maxLines == 1, + minLines = minLines, + maxLines = maxLines, modifier = Modifier.fillMaxWidth() ) Spacer(Modifier.height(12.dp)) @@ -314,11 +342,7 @@ val ConfirmPasswordField = Field.Text("confirm_password") fun SignUpForm() { val formState = remember { FormState() } - Form(state = formState, onSubmit = { - println("Email: ${EmailField.value}") - println("Password: ${PasswordField.value}") - println("Password Confirmation: ${ConfirmPasswordField.value}") - }) { + Form(state = formState) { Column { TextInput( field = EmailField, @@ -347,7 +371,12 @@ fun SignUpForm() { FieldEqualsValidator("passwords must match", PasswordField) ) ) - Button(onClick = ::submit, enabled = isValid) { + Button(onClick = { + submit(onInvalid = { println("Check the highlighted fields") }) { + println("Email: ${EmailField.value}") + // Send the current values to your application here. + } + }, enabled = isValid) { Text("Sign In") } } @@ -358,11 +387,15 @@ fun SignUpForm() { #### Notes - Uses the `TextInput` reusable component defined in the previous section. - `FormState` tracks and validates all registered fields automatically. -- The `onSubmit` callback executes only when all validations pass. +- The success lambda passed to `submit` runs only when the form has no stored errors after error reporting is enabled. - Cross-field validators declare the fields they read. A dependency value change revalidates the target field. - The form `isValid` property remains `true` until error reporting is enabled. After the first `submit()`, stored errors can disable UI elements such as this button. -- `submit()` uses the latest completed reactive validation cycle. Do not assign a field value and call `submit()` synchronously in the same callback. +- `submit()` uses the latest completed reactive validation cycle. Do not assign a field value + and call `submit()` synchronously in the same callback. +- External errors remain stored until a value, validator, dependency, or reset change triggers + reactive validation, or the application explicitly changes the error. + --- ### 8. Built-in [Validators](/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Validator.kt) @@ -449,6 +482,19 @@ Declare every field read during validation in `dependencies`. --- +## Migrating to the next release + +- Rename `shouldShowErrors` to `isErrorReportingEnabled` in state constructors, + `rememberSaveableFormState`, and property access. This controls error reporting, not validation execution. +- Remove `onSubmit` from `Form`; it now accepts only `state` and `content`. +- Supply success at the action: `submit { sendCurrentValues() }`, or + `submit(onInvalid = { showSummary() }) { sendCurrentValues() }`. +- Both callbacks have a `FormScope` receiver. Reusable handlers can read fields and errors directly; + wrap existing zero-argument handler variables as `onInvalid = { handler() }`. +- **Old `submit { ... }` trailing lambdas meant `onInvalid`. They now mean successful submission.** + Move old invalid handlers to the named `onInvalid` argument and add a success lambda. +- Replace `onClick = ::submit` with `onClick = { submit { /* success */ } }`. + ## Migrating from 0.1.0 - Change nullable field types to non-null types. `Field` now requires `T : Any`. @@ -457,8 +503,7 @@ Declare every field read during validation in `dependencies`. - Give each field a unique name within its `FormScope`. Same-name fields share one stored value. - Rename the `default` argument of `ValidationEffect` and `FormField` to `initialValue`. - Replace `field.markAsDirty()` with `field.isDirty = true`. The `isDirty` property is now writable. -- Remove explicit `null` arguments from `submit(onInvalid)`. Its fallback is now a non-null no-op - callback. +- Remove explicit `null` arguments from `onInvalid`; its default is a non-null no-op callback. - Use `field.shouldDisplayError` for rendering. `field.error` and `field.isValid` expose strict stored field state. `FormState.isValid` ignores stored errors until error reporting is enabled. - Update custom validators to implement `fun ValidationScope.validate(value: T)`. Declare each @@ -479,7 +524,8 @@ Declare every field read during validation in `dependencies`. - Use `CheckedValidator` for required Boolean fields. Use `InValidator`, `NotInValidator`, and `SelectionInValidator` for membership rules. - Account for reset behavior. `reset()` now clears values, errors, dirty state, and error visibility, - then revalidates initialized effects. + then reinitializes mounted effects on recomposition using their current initial inputs. + A submit before that recomposition sees no initialized fields. --- diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt index 84fdb2b..c20f2cc 100644 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/Form.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable @@ -24,18 +23,15 @@ import androidx.compose.runtime.setValue * * @param state The [FormState] instance backing this form. A different instance replaces the form * content subtree and disposes effects owned by the previous form. - * @param onSubmit Called when [FormScope.submit] is triggered and the form is valid. * @param content The form body, scoped to [FormScope]. */ @Composable fun Form( state: FormState = remember { FormState() }, - onSubmit: FormScope.() -> Unit, content: @Composable FormScope.() -> Unit ) { - val currentOnSubmit = rememberUpdatedState(onSubmit) val scope = remember(state) { - FormScope(state) { currentOnSubmit.value(this) } + FormScope(state) } key(state) { scope.content() @@ -48,10 +44,11 @@ fun Form( * Acts as the single source of truth for all form data and validation state. * Can be remembered in a composable or hoisted to persist across navigation. * - * @param shouldShowErrors Whether stored errors should affect form validity and be visible initially. + * @param isErrorReportingEnabled Whether stored errors initially affect form validity and can be shown + * for dirty fields. */ @Stable -class FormState(shouldShowErrors: Boolean = false) { +class FormState(isErrorReportingEnabled: Boolean = false) { /** * Holds the current values for all registered [Field]s in the form. * @@ -102,7 +99,7 @@ class FormState(shouldShowErrors: Boolean = false) { * Controls whether stored errors affect form validity and can be shown for dirty fields. * Validation still runs reactively regardless of this flag. */ - var shouldShowErrors by mutableStateOf(shouldShowErrors) + var isErrorReportingEnabled by mutableStateOf(isErrorReportingEnabled) /** * Indicates whether any [Field] in the form has been modified @@ -118,7 +115,7 @@ class FormState(shouldShowErrors: Boolean = false) { * returns `true` only when the form has no stored validation or external errors. */ val isValid: Boolean - get() = !shouldShowErrors || errors.isEmpty() + get() = !isErrorReportingEnabled || errors.isEmpty() /** * Clears all field values, errors, dirty state, and error visibility. @@ -127,13 +124,14 @@ class FormState(shouldShowErrors: Boolean = false) { * reinitialize their associated [Field]s to their provided initial values and mark them * as pristine on the next recomposition. * - * This fully restores the form to its initial state. + * Reinitialization uses the initial inputs supplied by that recomposition, not a saved + * copy of the original inputs. A submit before then sees no initialized fields. */ fun reset() { dirtyFields.clear() values.clear() errors.clear() - shouldShowErrors = false + isErrorReportingEnabled = false resetKey++ } @@ -179,7 +177,7 @@ private fun formStateSaver( buildList { add(savedValues) - add(state.shouldShowErrors) + add(state.isErrorReportingEnabled) add(state.dirtyFields.size) state.dirtyFields.forEach { add(it.name) } add(state.errors.size) @@ -194,7 +192,7 @@ private fun formStateSaver( @Suppress("UNCHECKED_CAST") val restoredValues = valuesSaver.restore(saved[index++] as Saveable) ?: return@listSaver null - val state = FormState(shouldShowErrors = saved[index++] as Boolean) + val state = FormState(isErrorReportingEnabled = saved[index++] as Boolean) restoredValues.forEach { (name, value) -> state.values[Field(name)] = value @@ -221,8 +219,8 @@ private fun formStateSaver( */ @Composable fun rememberSaveableFormState( - shouldShowErrors: Boolean = false, + isErrorReportingEnabled: Boolean = false, saver: Saver = FormState.Saver ): FormState = rememberSaveable(saver = saver) { - FormState(shouldShowErrors) + FormState(isErrorReportingEnabled) } diff --git a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt index cef9c4b..012bf81 100644 --- a/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt +++ b/ikokuko/src/commonMain/kotlin/com/quantipixels/ikokuko/FormScope.kt @@ -9,8 +9,7 @@ import androidx.compose.runtime.LaunchedEffect */ @Suppress("UNCHECKED_CAST") class FormScope internal constructor( - internal val state: FormState, - private val onSubmit: FormScope.() -> Unit + internal val state: FormState ) { internal val validationScope: ValidationScope = StateValidationScope(this) @@ -67,30 +66,36 @@ class FormScope internal constructor( /** Returns `true` when the UI must display this field's stored error. */ val Field<*>.shouldDisplayError: Boolean - get() = isDirty && state.shouldShowErrors && !isValid + get() = isDirty && state.isErrorReportingEnabled && !isValid /** Returns `true` when stored errors do not currently block the form. */ val isValid: Boolean get() = state.isValid /** Controls whether stored errors affect form validity and can be shown for dirty fields. */ - var shouldShowErrors: Boolean - get() = state.shouldShowErrors + var isErrorReportingEnabled: Boolean + get() = state.isErrorReportingEnabled set(value) { - state.shouldShowErrors = value + state.isErrorReportingEnabled = value } /** - * Marks all initialized fields as dirty and enables error display. - * Calls the form submit callback only when the form is valid. + * Enables error reporting and marks initialized fields dirty before checking stored errors. + * Validation runs reactively; this action uses its latest completed result. Do not change a + * field value and submit in the same callback, before reactive validation has processed it. + * Calls [onSubmit] when no stored errors remain, otherwise [onInvalid]. + * Both callbacks receive this form's scope so reusable handlers can inspect fields and errors. */ - fun submit(onInvalid: () -> Unit = {}) { + fun submit( + onInvalid: FormScope.() -> Unit = {}, + onSubmit: FormScope.() -> Unit + ) { fields.forEach { it.isDirty = true } - state.shouldShowErrors = true + state.isErrorReportingEnabled = true if (state.isValid) onSubmit() else onInvalid() } - /** Clears all state in this form. */ + /** Clears values, errors and visibility; mounted fields reinitialize on recomposition. */ fun reset() = state.reset() } @@ -109,7 +114,7 @@ private class StateValidationScope( fun FormScope.ValidationEffect( field: Field, initialValue: T, - validators: List> + validators: List> = emptyList() ) { if (!field.isInitialized) state.values[field] = initialValue diff --git a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt index d9a91c1..541d69b 100644 --- a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt +++ b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormRecompositionTest.kt @@ -35,7 +35,7 @@ class FormRecompositionTest { lateinit var formScope: FormScope setContent { - Form(onSubmit = {}) { + Form { ValidationEffect( field = field, initialValue = "", @@ -52,7 +52,7 @@ class FormRecompositionTest { assertFalse(field.isValid) assertTrue(isValid) - shouldShowErrors = true + isErrorReportingEnabled = true assertFalse(isValid) } @@ -68,7 +68,7 @@ class FormRecompositionTest { lateinit var formScope: FormScope setContent { - Form(onSubmit = {}) { + Form { val currentRecomposition = recompositionTrigger ValidationEffect( @@ -88,7 +88,7 @@ class FormRecompositionTest { runOnIdle { with(formScope) { field.isDirty = true - shouldShowErrors = true + isErrorReportingEnabled = true field.error = EXTERNAL_ERROR assertEquals("", field.value) @@ -118,7 +118,7 @@ class FormRecompositionTest { lateinit var currentScope: FormScope setContent { - Form(state = currentState, onSubmit = {}) { + Form(state = currentState) { ValidationEffect( field = field, initialValue = "", @@ -152,7 +152,7 @@ class FormRecompositionTest { lateinit var formScope: FormScope setContent { - Form(onSubmit = {}) { + Form { ValidationEffect(password, initialValue = "same", validators = emptyList()) ValidationEffect( confirmation, @@ -187,7 +187,7 @@ class FormRecompositionTest { lateinit var formScope: FormScope setContent { - Form(onSubmit = {}) { + Form { ValidationEffect( confirmation, initialValue = "confirmation", @@ -236,7 +236,7 @@ class FormRecompositionTest { } setContent { - Form(onSubmit = {}) { + Form { ValidationEffect( combined, initialValue = "AB", @@ -303,8 +303,8 @@ class FormRecompositionTest { lateinit var formScope: FormScope setContent { - Form(state = state, onSubmit = {}) { - ValidationEffect(field, initialValue = initialValue, validators = emptyList()) + Form(state = state) { + ValidationEffect(field, initialValue = initialValue) SideEffect { formScope = this } } } @@ -343,7 +343,7 @@ class FormRecompositionTest { lateinit var formScope: FormScope setContent { - Form(onSubmit = {}) { + Form { ValidationEffect( field, initialValue = "", @@ -377,7 +377,7 @@ class FormRecompositionTest { lateinit var formScope: FormScope setContent { - Form(onSubmit = {}) { + Form { ValidationEffect( field, initialValue = "", @@ -411,7 +411,7 @@ class FormRecompositionTest { lateinit var formScope: FormScope setContent { - Form(onSubmit = {}) { + Form { if (showField) { ValidationEffect( field, @@ -472,7 +472,7 @@ class FormRecompositionTest { } setContent { - Form(onSubmit = {}) { + Form { ValidationEffect(field, initialValue = "value", validators = listOf(validator)) } } diff --git a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt index 06cb344..004df5c 100644 --- a/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt +++ b/ikokuko/src/commonTest/kotlin/com/quantipixels/ikokuko/FormStateTest.kt @@ -16,7 +16,7 @@ class FormStateTest { fun form_validity_follows_error_reporting_while_field_validity_is_strict() { val field = Field.Text("email") val state = FormState() - val scope = FormScope(state) {} + val scope = FormScope(state) state.values[field] = "" with(scope) { @@ -30,7 +30,7 @@ class FormStateTest { field.isDirty = true assertFalse(field.shouldDisplayError) - shouldShowErrors = true + isErrorReportingEnabled = true assertTrue(field.shouldDisplayError) assertFalse(field.isValid) assertFalse(isValid) @@ -41,7 +41,7 @@ class FormStateTest { fun value_setter_marks_only_initialized_value_changes_dirty() { val field = Field.Text("name") val state = FormState() - val scope = FormScope(state) {} + val scope = FormScope(state) state.values[field] = "initial" with(scope) { @@ -62,8 +62,8 @@ class FormStateTest { @Test fun reset_clears_values_errors_dirty_state_and_visibility() { val field = Field.Text("email") - val state = FormState(shouldShowErrors = true) - val scope = FormScope(state) {} + val state = FormState(isErrorReportingEnabled = true) + val scope = FormScope(state) state.values[field] = "value" with(scope) { @@ -75,7 +75,7 @@ class FormStateTest { assertFalse(field in fields) assertFalse(field.isDirty) assertNull(field.error) - assertFalse(shouldShowErrors) + assertFalse(isErrorReportingEnabled) assertTrue(isValid) } } @@ -86,20 +86,24 @@ class FormStateTest { val state = FormState() var submitted = false var invalid = false - val scope = FormScope(state) { submitted = true } + val scope = FormScope(state) + val onInvalid: FormScope.() -> Unit = { + invalid = true + assertTrue(isErrorReportingEnabled) + assertTrue(field.shouldDisplayError) + } state.values[field] = "" with(scope) { field.error = "required" - submit { invalid = true } + submit(onInvalid = onInvalid) { submitted = true } assertFalse(submitted) assertTrue(invalid) assertTrue(field.isDirty) - assertTrue(field.shouldDisplayError) field.error = null - submit() + submit { submitted = true } assertTrue(submitted) } } @@ -108,21 +112,21 @@ class FormStateTest { fun default_saver_restores_values_dirty_errors_and_visibility() { val name = Field.Text("name") val age = Field("age") - val state = FormState(shouldShowErrors = true) + val state = FormState(isErrorReportingEnabled = true) state.values[name] = "Ada" state.values[age] = 37 state.dirtyFields.add(name) state.errors[name.name] = "invalid" val restored = FormState.Saver.roundTrip(state) - val scope = FormScope(restored) {} + val scope = FormScope(restored) with(scope) { assertEquals("Ada", name.value) assertEquals(37, age.value) assertTrue(name.isDirty) assertEquals("invalid", name.error) - assertTrue(shouldShowErrors) + assertTrue(isErrorReportingEnabled) assertFalse(isValid) } } @@ -142,7 +146,7 @@ class FormStateTest { ) val restored = FormState.saver(valuesSaver).roundTrip(state) - val scope = FormScope(restored) {} + val scope = FormScope(restored) with(scope) { assertEquals(Profile("Ada"), profile.value) diff --git a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt index d9cbe7c..d4befee 100644 --- a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt +++ b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/App.kt @@ -73,7 +73,10 @@ private fun SignUpSummaryDialog( onDismissRequest = onDismiss, title = { Text("Congratulations!") }, text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { Text("You’ve successfully completed the demo signup form.") HorizontalDivider() InfoLine("Phone number", data.phoneNumber) @@ -82,6 +85,7 @@ private fun SignUpSummaryDialog( InfoLine("Password confirmed?", if (data.password == data.confirmation) "Yes" else "No") InfoLine("Capacity", data.capacity.name) InfoLine("Projects", data.projects.joinToString(", ") { it.name }) + if (data.notes.isNotBlank()) InfoLine("Notes", data.notes) InfoLine("Terms accepted", if (data.terms) "Yes" else "No") Spacer(Modifier.height(12.dp)) Text( diff --git a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/FormInput.kt b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/FormInput.kt index d6ad72f..f696168 100644 --- a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/FormInput.kt +++ b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/FormInput.kt @@ -59,6 +59,9 @@ fun FormScope.TextInput( label: String = "", placeholder: String = "", isPassword: Boolean = false, + minLines: Int = 1, + maxLines: Int = 1, + counterText: ((String) -> String)? = null, validators: List> = emptyList(), trailingIcon: @Composable (() -> Unit)? = null, ) { @@ -74,13 +77,18 @@ fun FormScope.TextInput( color = MaterialTheme.colorScheme.secondary.copy(alpha = .7f) ) }, - supportingText = if (field.shouldDisplayError) { - field.error?.let { { Text(it) } } - } else { - null - }, + supportingText = if (field.shouldDisplayError || counterText != null) { + { + Column { + if (field.shouldDisplayError) field.error?.let { Text(it) } + counterText?.let { Text(it(field.value)) } + } + } + } else null, onValueChange = { field.value = it }, - singleLine = true, + singleLine = maxLines == 1, + minLines = minLines, + maxLines = maxLines, visualTransformation = if (isPassword) { PasswordVisualTransformation() } else { diff --git a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt index 0a40fc6..488dded 100644 --- a/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt +++ b/samples/composeApp/src/commonMain/kotlin/com/quantipixels/ikokuko/sample/SignUpForm.kt @@ -23,6 +23,7 @@ import com.quantipixels.ikokuko.Field import com.quantipixels.ikokuko.FieldEqualsValidator import com.quantipixels.ikokuko.Form import com.quantipixels.ikokuko.MatchPatternValidator +import com.quantipixels.ikokuko.MaxLengthValidator import com.quantipixels.ikokuko.MinLengthValidator import com.quantipixels.ikokuko.RequiredValidator import com.quantipixels.ikokuko.SelectionRangeValidator @@ -34,6 +35,7 @@ data class SignUpData( val confirmation: String, val capacity: Capacity, val projects: List, + val notes: String, val terms: Boolean ) @@ -43,8 +45,10 @@ private val PasswordField = Field.Text("password") private val ConfirmPasswordField = Field.Text("confirm_password") private val CapacityField = Field.Text("capacity") private val ProjectsField = Field.List("projects") +private val NotesField = Field.Text("notes") private val TermsField = Field.Boolean("terms") +private const val NotesLimit = 240 private const val SymbolPattern = "[^A-Za-z0-9 ]" private const val DigitPattern = "\\d" private const val UppercasePattern = "[A-Z]" @@ -65,22 +69,7 @@ fun SignUpForm( onSubmit: (SignUpData) -> Unit, modifier: Modifier = Modifier ) { - Form( - onSubmit = { - onSubmit( - SignUpData( - PhoneNumberField.value, - EmailField.value, - PasswordField.value, - ConfirmPasswordField.value, - enumValueOf(CapacityField.value), - ProjectsField.value, - TermsField.value - ) - ) - reset() - } - ) { + Form { var passwordHidden by remember { mutableStateOf(true) } var confirmationHidden by remember { mutableStateOf(true) } @@ -155,6 +144,16 @@ fun SignUpForm( ) ) ) + TextInput( + field = NotesField, + label = "Notes (optional)", + minLines = 3, + maxLines = 6, + counterText = { "${it.length} / $NotesLimit UTF-16 units" }, + validators = listOf( + MaxLengthValidator("Use at most $NotesLimit UTF-16 units", NotesLimit) + ) + ) CheckBox( field = TermsField, label = "I agree to the Terms & Conditions", @@ -169,7 +168,23 @@ fun SignUpForm( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Button( - onClick = ::submit, + onClick = { + submit { + onSubmit( + SignUpData( + phoneNumber = PhoneNumberField.value, + email = EmailField.value, + password = PasswordField.value, + confirmation = ConfirmPasswordField.value, + capacity = enumValueOf(CapacityField.value), + projects = ProjectsField.value, + notes = NotesField.value, + terms = TermsField.value + ) + ) + reset() + } + }, enabled = isValid, modifier = Modifier.weight(1f) ) {