@@ -42,8 +42,11 @@ import androidx.compose.runtime.CompositionLocalProvider
4242import androidx.compose.runtime.LaunchedEffect
4343import androidx.compose.runtime.State
4444import androidx.compose.runtime.getValue
45+ import androidx.compose.runtime.mutableStateOf
4546import androidx.compose.runtime.remember
4647import androidx.compose.runtime.rememberUpdatedState
48+ import androidx.compose.runtime.saveable.rememberSaveable
49+ import androidx.compose.runtime.setValue
4750import androidx.compose.ui.Alignment
4851import androidx.compose.ui.Modifier
4952import androidx.compose.ui.graphics.Shape
@@ -70,24 +73,37 @@ import se.seb.gds.theme.GdsTheme
7073 * @property readOnly Whether the input field is read-only.
7174 * @property clearable Whether the input field includes a clear button to clear the text.
7275 * @property showInfoIcon Whether to display an info icon in the trailing content.
73- * @property maxCharacters Optional maximum character limit for the input field.
7476 * @property isError Whether the input field is in an error state.
7577 * @property errorMessage Optional error message displayed below the input field.
7678 * @property overrideTextDescription Optional text description to override the default accessibility description.
7779 * @property lineLimits Line limits for the input field, such as single-line or multi-line.
80+ * @property characterLimit Optional character limit configuration for the input field.
7881 */
7982data class BasicInputState (
8083 val enabled : Boolean = true ,
8184 val readOnly : Boolean = false ,
8285 val clearable : Boolean = true ,
8386 val showInfoIcon : Boolean = false ,
84- val maxCharacters : Int? = null ,
8587 val isError : Boolean = false ,
8688 val errorMessage : String? = null ,
8789 val overrideTextDescription : String? = null ,
8890 val lineLimits : TextFieldLineLimits = TextFieldLineLimits .Default ,
91+ val characterLimit : CharacterLimit ? = null ,
92+ ) {
93+ fun hasCharacterLimit (): Boolean = characterLimit != null
94+ fun hasHardCharacterLimit (): Boolean = characterLimit != null && characterLimit.type == CharacterLimitType .HARD
95+ }
96+
97+ data class CharacterLimit (
98+ val maxCharacters : Int ,
99+ val type : CharacterLimitType = CharacterLimitType .SOFT ,
89100)
90101
102+ enum class CharacterLimitType {
103+ SOFT ,
104+ HARD ,
105+ }
106+
91107/* *
92108 * A composable function that provides the basic structure for an input field. It provides accessibility description and focus management.
93109 * Integrates focus management, interaction handling, and accessibility features.
@@ -116,11 +132,12 @@ internal fun BasicInput(
116132 onInfoIconClick : () -> Unit = { },
117133 onValueChange : (String ) -> Unit = {},
118134 onInteraction : (Interaction ) -> Unit = {},
119- content : @Composable ((Boolean ) -> Unit ),
135+ content : @Composable ((isTextFieldFocused : Boolean , isCharacterLimitError : Boolean ) -> Unit ),
120136) {
121137 val focusManager = LocalFocusManager .current
122138 val textFieldIsFocused by interactionSource.collectIsFocusedAsState()
123139 val bringIntoViewRequester = remember { BringIntoViewRequester () }
140+ var characterLimitError by rememberSaveable { mutableStateOf(false ) }
124141
125142 LaunchedEffect (interactionSource) {
126143 interactionSource.interactions.collectLatest { value ->
@@ -133,7 +150,9 @@ internal fun BasicInput(
133150 }
134151
135152 LaunchedEffect (state.text) {
136- onValueChange(state.text.toString())
153+ val text = state.text.toString()
154+ characterLimitError = validate(text, inputState.characterLimit)
155+ onValueChange(text)
137156 }
138157
139158 val textFieldDescription = getAccessibilityDescription(
@@ -163,7 +182,7 @@ internal fun BasicInput(
163182 onClick(doubleTapToEditText, null )
164183 },
165184 ) {
166- content(textFieldIsFocused)
185+ content(textFieldIsFocused, characterLimitError )
167186 }
168187}
169188
@@ -209,25 +228,29 @@ fun InputContainer(
209228 CharSequence ,
210229 CharSequence ,
211230 ) -> Boolean = { _: CharSequence , _: CharSequence -> true },
231+ characterLimitError : Boolean = false,
212232) {
213233 val containerSize = style.getCurrentContainerShape()
214234
215235 val inputTransformationChain = inputTransformationChain
216236 .thenIfNotNull(
217- inputState.maxCharacters?.let { MaxCharacterInputTransformation (it) },
237+ inputState.characterLimit?.takeIf { inputState.hasHardCharacterLimit() }?.let {
238+ MaxCharacterInputTransformation (it.maxCharacters)
239+ },
218240 )
219241 .thenIfNotNull(CharacterWhitelistInputTransformation (characterWhitelistPredicate))
220242
243+ val isError = inputState.isError || characterLimitError
221244 val borderStroke = animateBorderStrokeAsState(
222245 style,
223- inputState. isError,
246+ isError,
224247 textFieldIsFocused,
225248 )
226249
227250 Box (
228251 modifier = modifier
229252 .borderIf(
230- style.showBorder || inputState. isError,
253+ style.showBorder || isError,
231254 borderStroke.value,
232255 containerSize.shape,
233256 )
@@ -290,6 +313,32 @@ fun InputContainer(
290313 }
291314}
292315
316+ /* *
317+ * Displays an error message below the input field if there is a character limit error or a general error state.
318+ *
319+ * @param isCharacterLimitError Whether the error is due to exceeding the character limit.
320+ * @param inputState The state of the input field, including error information and character limit configuration.
321+ * @param style The style configuration for the input field, including text styles and colors.
322+ */
323+ @Composable
324+ internal fun InputError (
325+ isCharacterLimitError : Boolean ,
326+ inputState : BasicInputState ,
327+ style : BasicInputStyle ,
328+ ) {
329+ if (isCharacterLimitError) {
330+ ErrorFooter (
331+ errorMessage = stringResource(
332+ R .string.text_field_character_limit_error,
333+ inputState.characterLimit?.maxCharacters ? : 0 ,
334+ ),
335+ style = style,
336+ )
337+ } else if (inputState.isError && ! inputState.errorMessage.isNullOrBlank()) {
338+ ErrorFooter (errorMessage = inputState.errorMessage, style = style)
339+ }
340+ }
341+
293342/* *
294343 * Displays an error message with an error icon. Used as a footer for input fields.
295344 *
@@ -418,7 +467,7 @@ private fun getAccessibilityDescription(
418467 if (textFieldIsFocused && ! inputState.readOnly) {
419468 descriptionBuilder.append(" , " )
420469 descriptionBuilder.append(stringResource(id = R .string.text_field_is_editing))
421- inputState.maxCharacters?.let {
470+ inputState.characterLimit?. maxCharacters?.let {
422471 descriptionBuilder.append(" , " )
423472 descriptionBuilder.append(
424473 stringResource(
@@ -479,6 +528,21 @@ fun clearText(state: TextFieldState) {
479528 }
480529}
481530
531+ /* *
532+ * Validates the input text against the provided character limit and updates the character limit error state.
533+ *
534+ * @param text The current text input to validate.
535+ * @param characterLimit The character limit configuration to validate against.
536+ * @return True if the text exceeds the character limit, false otherwise.
537+ */
538+ fun validate (
539+ text : CharSequence ,
540+ characterLimit : CharacterLimit ? ,
541+ ): Boolean {
542+ characterLimit ? : return false
543+ return text.length > characterLimit.maxCharacters
544+ }
545+
482546/* *
483547 * Returns true if the input field is multi-line based on line limits and current line count.
484548 *
0 commit comments