From 62288adfa1ef5d2821b4b4de81dbd1ca53cf6ef1 Mon Sep 17 00:00:00 2001 From: OmarShehe Date: Fri, 23 Oct 2026 00:47:39 +0200 Subject: [PATCH 1/2] feat: group thousands in number fields with a configurable separator FormInputTextField gets groupThousands and groupingSeparator for NUMBER fields, and FormInputPriceField gets groupingSeparator. Grouping is display only, so the stored value stays plain, and both options default to today's behaviour. The separator may not be a digit or a dot. --- .claude/skills/use-forminput/SKILL.md | 2 +- .claude/skills/use-forminput/reference.md | 5 +- .../com/omarshehe/forminputs/DemoSections.kt | 12 +++- .../compose/ui/FormInputPriceField.kt | 5 +- .../compose/ui/FormInputTextField.kt | 14 ++++- .../forminput/compose/ui/utils/PriceFormat.kt | 9 ++- .../ui/utils/PriceAndPasswordHelpersTest.kt | 29 +++++++++ .../compose/ui/PriceAndPasswordFieldTest.kt | 6 ++ .../compose/ui/TextAndDropDownTest.kt | 63 +++++++++++++++++++ 9 files changed, 135 insertions(+), 10 deletions(-) diff --git a/.claude/skills/use-forminput/SKILL.md b/.claude/skills/use-forminput/SKILL.md index e9c72e9..8876d0a 100644 --- a/.claude/skills/use-forminput/SKILL.md +++ b/.claude/skills/use-forminput/SKILL.md @@ -52,7 +52,7 @@ var name by remember { mutableStateOf(FormInputTextFieldState(id = "name", type FormInputTextField(state = name, onValueChange = { name = it.copy(hasError = it.value.isBlank(), error = "Required") }) var price by remember { mutableStateOf(FormInputPriceState(id = "price", currency = "TSH", currencies = listOf("TSH", "USD"), label = "Price")) } -FormInputPriceField(state = price, onValueChange = { price = it }, groupThousands = true) // price.amount is the plain number +FormInputPriceField(state = price, onValueChange = { price = it }, groupThousands = true) // price.amount is the plain number; a NUMBER FormInputTextField takes groupThousands too; groupingSeparator = ' ' changes the character var pw by remember { mutableStateOf(FormInputPasswordState(id = "pw", label = "Password")) } var confirm by remember { mutableStateOf(FormInputPasswordState(id = "pw2", label = "Confirm password")) } diff --git a/.claude/skills/use-forminput/reference.md b/.claude/skills/use-forminput/reference.md index 67d3708..2497967 100644 --- a/.claude/skills/use-forminput/reference.md +++ b/.claude/skills/use-forminput/reference.md @@ -26,8 +26,9 @@ parameter that is not listed here (`forminput/src/commonMain/kotlin/com/omarsheh - **`FormInputTextField(state, onValueChange: (State) -> Unit)`** and an overload `(state, value: TextFieldValue, onValueChange: (TextFieldValue) -> Unit)`. Options: `textModifier`, `colors`, `shape`, `contentPadding`, `style`, `enabled`, `readOnly`, `textStyle`, `keyboardOptions`, `keyboardActions`, - `visualTransformation`, `leadingIcon`, `trailingIcon`. -- **`FormInputPriceField(state, onValueChange)`**: text-field options plus `currencyPlacement` (START/END), `groupThousands`, `currencyLabel: (String) -> String`, + `visualTransformation`, `leadingIcon`, `trailingIcon`, `groupThousands` (NUMBER only; shows 1234567 as 1,234,567, stored value stays plain), + `groupingSeparator` (default `,`; not a digit or a dot). +- **`FormInputPriceField(state, onValueChange)`**: text-field options plus `currencyPlacement` (START/END), `groupThousands`, `groupingSeparator`, `currencyLabel: (String) -> String`, `currencyContent`, `currencyTextStyle`, `menuShape`, `menuContainerColor`. - **`FormInputPasswordField(state, onValueChange)`**: text-field options plus `rules` (null = default four; `emptyList()` = none), `rulesVisibility` (ALWAYS, WHEN_FOCUSED, WHEN_NOT_EMPTY, NEVER), `rulesTitle`, `ruleContent`, `showStrength`, `strengthLabels`, `passwordColors`, `strengthOf`, diff --git a/app/src/main/java/com/omarshehe/forminputs/DemoSections.kt b/app/src/main/java/com/omarshehe/forminputs/DemoSections.kt index 1c1ffcc..8d1dea8 100644 --- a/app/src/main/java/com/omarshehe/forminputs/DemoSections.kt +++ b/app/src/main/java/com/omarshehe/forminputs/DemoSections.kt @@ -62,8 +62,14 @@ internal fun DemoSection(title: String, content: @Composable () -> Unit) { /** A text field of the demo: style and shape come from the surrounding `FormInputTheme`, the modifier from the look. */ @Composable -private fun DemoTextField(look: DemoLook, state: FormInputTextFieldState, enabled: Boolean = true, onChange: (FormInputTextFieldState) -> Unit = {}) { - FormInputTextField(state = state, onValueChange = onChange, modifier = look.fieldModifier, enabled = enabled) +private fun DemoTextField( + look: DemoLook, + state: FormInputTextFieldState, + enabled: Boolean = true, + groupThousands: Boolean = false, + onChange: (FormInputTextFieldState) -> Unit = {}, +) { + FormInputTextField(state = state, onValueChange = onChange, modifier = look.fieldModifier, enabled = enabled, groupThousands = groupThousands) } @Composable @@ -87,7 +93,7 @@ internal fun TextFieldsSection(look: DemoLook) = DemoSection("Text fields") { val required = tr("Required", "Obligatoire") DemoTextField(look, name.withLabel("Full name", "Nom complet")) { name = it.copy(hasError = it.value.isBlank(), error = required) } - DemoTextField(look, amount.withLabel("Amount (prefix)", "Montant (préfixe)")) { amount = it } + DemoTextField(look, amount.withLabel("Amount (prefix, grouped)", "Montant (préfixe, groupé)"), groupThousands = true) { amount = it } DemoTextField(look, weight.withLabel("Weight (suffix)", "Poids (suffixe)")) { weight = it } DemoTextField(look, phone.withLabel("Phone (digits and +)", "Téléphone (chiffres et +)")) { phone = it } DemoTextField(look, email.withLabel("Email", "E-mail")) { email = it } diff --git a/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputPriceField.kt b/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputPriceField.kt index 8b67cf5..e0b0769 100644 --- a/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputPriceField.kt +++ b/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputPriceField.kt @@ -56,7 +56,7 @@ public enum class CurrencyPlacement { START, END } * [style], [shape], [colors], [enabled] and the text options behave the same way. * * The amount keeps digits and one dot, at most [FormInputPriceState.maxIntegerDigits] whole digits and - * [FormInputPriceState.maxDecimals] decimals. [groupThousands] only changes how it is shown. With one currency the selector is + * [FormInputPriceState.maxDecimals] decimals. [groupThousands] only changes how it is shown, with [groupingSeparator] (not a digit or a dot) between the groups. With one currency the selector is * plain text. [currencyLabel] and [currencyContent] change how a currency is drawn, [menuShape] and [menuContainerColor] style * the list. */ @@ -76,6 +76,7 @@ public fun FormInputPriceField( keyboardActions: KeyboardActions = KeyboardActions.Default, currencyPlacement: CurrencyPlacement = CurrencyPlacement.START, groupThousands: Boolean = false, + groupingSeparator: Char = ',', currencyLabel: (String) -> String = { it }, currencyTextStyle: TextStyle? = null, menuShape: Shape? = null, @@ -183,7 +184,7 @@ public fun FormInputPriceField( keyboardType = if (state.maxDecimals > 0) KeyboardType.Decimal else KeyboardType.Number, ), keyboardActions = keyboardActions, - visualTransformation = if (groupThousands) ThousandsSeparatorTransformation() else null, + visualTransformation = if (groupThousands) ThousandsSeparatorTransformation(groupingSeparator) else null, leadingIcon = if (currencyPlacement == CurrencyPlacement.START) selector else null, trailingIcon = if (currencyPlacement == CurrencyPlacement.END) selector else null, ) diff --git a/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputTextField.kt b/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputTextField.kt index e94f135..31ddcb0 100644 --- a/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputTextField.kt +++ b/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputTextField.kt @@ -65,6 +65,7 @@ import com.omarshehe.forminput.compose.ui.model.FormInputTextFieldState import com.omarshehe.forminput.compose.ui.model.FormInputType import com.omarshehe.forminput.compose.ui.utils.Dimens import com.omarshehe.forminput.compose.ui.utils.Symbols +import com.omarshehe.forminput.compose.ui.utils.ThousandsSeparatorTransformation /** * Text field driven by [FormInputTextFieldState.value]. The cursor and selection are kept internally; use the @@ -77,6 +78,8 @@ private val HeaderActionIconSize = 14.dp * The library's text input, driven by [FormInputTextFieldState]; [onValueChange] receives the updated state. [style], [shape] * and [colors] fall back to the form-wide defaults from [FormInputTheme]. [textStyle] and [contentPadding] style the text and * the box, [textModifier] reaches the text field itself, and [leadingIcon] and [trailingIcon] replace the built-in icons. + * For a NUMBER field, [groupThousands] shows the whole part in groups of three ("1234567" as "1,234,567") with [groupingSeparator] + * (not a digit or a dot) between them; the stored value stays plain. A [visualTransformation] you pass wins over it. */ @Composable public fun FormInputTextField( @@ -95,6 +98,8 @@ public fun FormInputTextField( visualTransformation: VisualTransformation? = null, leadingIcon: (@Composable () -> Unit)? = null, trailingIcon: (@Composable () -> Unit)? = null, + groupThousands: Boolean = false, + groupingSeparator: Char = ',', onValueChange: (FormInputTextFieldState) -> Unit = {}, ) { var textValueState by remember { mutableStateOf(TextFieldValue(text = state.value)) } @@ -124,6 +129,8 @@ public fun FormInputTextField( visualTransformation = visualTransformation, leadingIcon = leadingIcon, trailingIcon = trailingIcon, + groupThousands = groupThousands, + groupingSeparator = groupingSeparator, ) } @@ -152,6 +159,8 @@ public fun FormInputTextField( visualTransformation: VisualTransformation? = null, leadingIcon: (@Composable () -> Unit)? = null, trailingIcon: (@Composable () -> Unit)? = null, + groupThousands: Boolean = false, + groupingSeparator: Char = ',', ) { val defaults = LocalFormInputDefaults.current val filled = (style ?: defaults.style) == FormInputFieldStyle.FILLED @@ -163,7 +172,10 @@ public fun FormInputTextField( var passwordVisible by remember { mutableStateOf(false) } val interactionSource = remember { MutableInteractionSource() } - val effectiveTransformation = visualTransformation ?: if (state.type == FormInputType.PASSWORD && !passwordVisible) { + val groupingTransformation = remember(groupThousands, groupingSeparator, state.type) { + if (groupThousands && state.type == FormInputType.NUMBER) ThousandsSeparatorTransformation(groupingSeparator) else null + } + val effectiveTransformation = visualTransformation ?: groupingTransformation ?: if (state.type == FormInputType.PASSWORD && !passwordVisible) { PasswordVisualTransformation() } else { VisualTransformation.None diff --git a/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/utils/PriceFormat.kt b/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/utils/PriceFormat.kt index a785a40..f956887 100644 --- a/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/utils/PriceFormat.kt +++ b/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/utils/PriceFormat.kt @@ -47,8 +47,15 @@ internal fun TextFieldValue.withCleanedText(cleaned: String): TextFieldValue { return copy(text = cleaned, selection = TextRange(moved(selection.start), moved(selection.end)), composition = null) } -/** Shows the whole part of an amount in groups of three ("1234567.5" as "1,234,567.5") without changing the stored text. */ +/** + * Shows the whole part of an amount in groups of three ("1234567.5" as "1,234,567.5") without changing the stored text. + * The [separator] may not be a digit or the decimal dot, which would make the shown text ambiguous. + */ internal class ThousandsSeparatorTransformation(private val separator: Char = ',') : VisualTransformation { + init { + require(!separator.isDigit() && separator != '.') { "The grouping separator may not be a digit or '.', but was '$separator'" } + } + override fun filter(text: AnnotatedString): TransformedText { val raw = text.text val dot = raw.indexOf('.').let { if (it < 0) raw.length else it } diff --git a/forminput/src/commonTest/kotlin/com/omarshehe/forminput/compose/ui/utils/PriceAndPasswordHelpersTest.kt b/forminput/src/commonTest/kotlin/com/omarshehe/forminput/compose/ui/utils/PriceAndPasswordHelpersTest.kt index 4315966..cb710d2 100644 --- a/forminput/src/commonTest/kotlin/com/omarshehe/forminput/compose/ui/utils/PriceAndPasswordHelpersTest.kt +++ b/forminput/src/commonTest/kotlin/com/omarshehe/forminput/compose/ui/utils/PriceAndPasswordHelpersTest.kt @@ -3,6 +3,7 @@ package com.omarshehe.forminput.compose.ui.utils import androidx.compose.ui.text.AnnotatedString import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull class PriceAndPasswordHelpersTest { @@ -53,6 +54,34 @@ class PriceAndPasswordHelpersTest { assertEquals(2, mapping.originalToTransformed(1)) } + @Test + fun customSeparatorIsUsed() { + assertEquals("1 234 567", ThousandsSeparatorTransformation(' ').filter(AnnotatedString("1234567")).text.text) + } + + @Test + fun separatorMayNotBeADotOrADigit() { + assertFailsWith { ThousandsSeparatorTransformation('.') } + assertFailsWith { ThousandsSeparatorTransformation('5') } + } + + @Test + fun emptyAndShortValuesGetNoSeparator() { + for (raw in listOf("", "1", "123", ".5", "0.5")) { + assertEquals(raw, ThousandsSeparatorTransformation().filter(AnnotatedString(raw)).text.text) + } + } + + @Test + fun cursorMappingRoundTripsWithCustomSeparator() { + val transformed = ThousandsSeparatorTransformation(' ').filter(AnnotatedString("1234567")) + val mapping = transformed.offsetMapping + assertEquals("1 234 567", transformed.text.text) + for (offset in 0..7) { + assertEquals(offset, mapping.transformedToOriginal(mapping.originalToTransformed(offset))) + } + } + private val rules = listOf( PasswordRules.upperCase("upper"), PasswordRules.special("special"), diff --git a/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/PriceAndPasswordFieldTest.kt b/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/PriceAndPasswordFieldTest.kt index a59215e..9926601 100644 --- a/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/PriceAndPasswordFieldTest.kt +++ b/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/PriceAndPasswordFieldTest.kt @@ -66,6 +66,12 @@ class PriceAndPasswordFieldTest { onNodeWithText("TSH!").assertExists() } + @Test + fun priceUsesTheCustomSeparator() = runComposeUiTest { + setContent { FormInputPriceField(state = price("1234567"), onValueChange = {}, groupThousands = true, groupingSeparator = ' ') } + onNodeWithText("1 234 567").assertExists() + } + @Test fun disabledPriceDoesNotOpenTheList() = runComposeUiTest { setContent { FormInputPriceField(state = price(), onValueChange = {}, enabled = false) } diff --git a/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/TextAndDropDownTest.kt b/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/TextAndDropDownTest.kt index 0aea404..85e62ce 100644 --- a/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/TextAndDropDownTest.kt +++ b/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/TextAndDropDownTest.kt @@ -65,6 +65,69 @@ class TextAndDropDownTest { assertEquals("12.34", state().value) } + private fun runGrouped(initial: FormInputTextFieldState, block: androidx.compose.ui.test.ComposeUiTest.(state: () -> FormInputTextFieldState) -> Unit) = + runComposeUiTest { + var state by mutableStateOf(initial) + setContent { FormInputTextField(state = state, onValueChange = { state = it }, groupThousands = true) } + block { state } + } + + @Test + fun groupedNumberShowsSeparatorsButStoresPlainDigits() = runGrouped(text(FormInputType.NUMBER, value = "1234567"), { + onNodeWithText("1,234,567").assertExists() + }) + + @Test + fun typingAGroupedNumberKeepsThePlainValue() = runGrouped(text(FormInputType.NUMBER), { state -> + onNodeWithText("Field").performTextInput("2000000") + assertEquals("2000000", state().value) + onNodeWithText("2,000,000").assertExists() + }) + + @Test + fun groupedDecimalsLeaveTheFractionAlone() = runGrouped(text(FormInputType.NUMBER, value = "1234567.891"), { + onNodeWithText("1,234,567.891").assertExists() + }) + + @Test + fun groupingIsIgnoredForOtherTypes() = runGrouped(text(FormInputType.PHONE, value = "1234567"), { + onNodeWithText("1234567").assertExists() + }) + + @Test + fun groupingIsOffByDefault() = runText(text(FormInputType.NUMBER, value = "1234567")) { + onNodeWithText("1234567").assertExists() + } + + @Test + fun customSeparatorOnANumberField() = runComposeUiTest { + setContent { + FormInputTextField(state = text(FormInputType.NUMBER, value = "1234567"), groupThousands = true, groupingSeparator = ' ') + } + onNodeWithText("1 234 567").assertExists() + } + + @Test + fun aCallerTransformationBeatsGrouping() = runComposeUiTest { + setContent { + FormInputTextField( + state = text(FormInputType.NUMBER, value = "1234567"), groupThousands = true, + visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None, + ) + } + onNodeWithText("1234567").assertExists() + } + + @Test + fun theTextFieldValueOverloadGroupsToo() = runComposeUiTest { + setContent { + FormInputTextField( + state = text(FormInputType.NUMBER), value = TextFieldValue("1234567"), onValueChange = {}, groupThousands = true, + ) + } + onNodeWithText("1,234,567").assertExists() + } + @Test fun phoneFieldKeepsDigitsAndPlus() = runText(text(FormInputType.PHONE)) { state -> onNodeWithText("Field").performTextInput("+255 7(12)") From d90483b8608fbe3d8697b05f66daf20b0005808d Mon Sep 17 00:00:00 2001 From: OmarShehe Date: Fri, 23 Oct 2026 00:53:11 +0200 Subject: [PATCH 2/2] fix: address review findings on number grouping Document the new parameters on the TextFieldValue overload, remember the price field's transformation like the text field does, and test that the cursor stays next to the typed digit in a grouped field. --- .../compose/ui/FormInputPriceField.kt | 6 ++++- .../compose/ui/FormInputTextField.kt | 2 ++ .../compose/ui/TextAndDropDownTest.kt | 22 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputPriceField.kt b/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputPriceField.kt index e0b0769..b4b8ac1 100644 --- a/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputPriceField.kt +++ b/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputPriceField.kt @@ -145,6 +145,10 @@ public fun FormInputPriceField( } } + val groupingTransformation = remember(groupThousands, groupingSeparator) { + if (groupThousands) ThousandsSeparatorTransformation(groupingSeparator) else null + } + val fieldState = FormInputTextFieldState( id = state.id, labelRes = state.labelRes, @@ -184,7 +188,7 @@ public fun FormInputPriceField( keyboardType = if (state.maxDecimals > 0) KeyboardType.Decimal else KeyboardType.Number, ), keyboardActions = keyboardActions, - visualTransformation = if (groupThousands) ThousandsSeparatorTransformation(groupingSeparator) else null, + visualTransformation = groupingTransformation, leadingIcon = if (currencyPlacement == CurrencyPlacement.START) selector else null, trailingIcon = if (currencyPlacement == CurrencyPlacement.END) selector else null, ) diff --git a/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputTextField.kt b/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputTextField.kt index 31ddcb0..0304740 100644 --- a/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputTextField.kt +++ b/forminput/src/commonMain/kotlin/com/omarshehe/forminput/compose/ui/FormInputTextField.kt @@ -138,6 +138,8 @@ public fun FormInputTextField( * Text field with a hoisted [TextFieldValue], so the caller owns the text, cursor and selection. Input is filtered * by the state's type, [FormInputTextFieldState.maxChar] and [FormInputTextFieldState.autoCapitalize] before * [onValueChange] is called; [FormInputTextFieldState.value] is not used by this overload. + * For a NUMBER field, [groupThousands] shows the whole part in groups of three with [groupingSeparator] (not a digit or a dot) between + * them; the [TextFieldValue] you receive and pass back stays plain, cursor included. A [visualTransformation] you pass wins over it. */ @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/TextAndDropDownTest.kt b/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/TextAndDropDownTest.kt index 85e62ce..4490cf9 100644 --- a/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/TextAndDropDownTest.kt +++ b/forminput/src/jvmTest/kotlin/com/omarshehe/forminput/compose/ui/TextAndDropDownTest.kt @@ -128,6 +128,28 @@ class TextAndDropDownTest { onNodeWithText("1,234,567").assertExists() } + @Test + fun typingIntoAGroupedFieldReportsAPlainValueWithTheCursorAfterTheLastDigit() = runComposeUiTest { + var value by mutableStateOf(TextFieldValue("")) + setContent { + FormInputTextField(state = text(FormInputType.NUMBER), value = value, onValueChange = { value = it }, groupThousands = true) + } + onNodeWithText("Field").performTextInput("2000000") + assertEquals("2000000", value.text) + assertEquals(TextRange(7), value.selection) + } + + @Test + fun anInsertInTheMiddleOfAGroupedNumberMovesTheCursorWithTheInsertedDigit() = runComposeUiTest { + var value by mutableStateOf(TextFieldValue("2000000", TextRange(4))) + setContent { + FormInputTextField(state = text(FormInputType.NUMBER), value = value, onValueChange = { value = it }, groupThousands = true) + } + onNodeWithText("2,000,000").performTextInput("9") + assertEquals("20009000", value.text) + assertEquals(TextRange(5), value.selection) + } + @Test fun phoneFieldKeepsDigitsAndPlus() = runText(text(FormInputType.PHONE)) { state -> onNodeWithText("Field").performTextInput("+255 7(12)")