Skip to content

Commit 6bb8baf

Browse files
Add character limit soft and hard logic
1 parent 1ce30e3 commit 6bb8baf

File tree

9 files changed

+212
-86
lines changed

9 files changed

+212
-86
lines changed

components/src/main/kotlin/se/seb/gds/atoms/input/BasicInput.kt

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,11 @@ import androidx.compose.runtime.CompositionLocalProvider
4242
import androidx.compose.runtime.LaunchedEffect
4343
import androidx.compose.runtime.State
4444
import androidx.compose.runtime.getValue
45+
import androidx.compose.runtime.mutableStateOf
4546
import androidx.compose.runtime.remember
4647
import androidx.compose.runtime.rememberUpdatedState
48+
import androidx.compose.runtime.saveable.rememberSaveable
49+
import androidx.compose.runtime.setValue
4750
import androidx.compose.ui.Alignment
4851
import androidx.compose.ui.Modifier
4952
import 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
*/
7982
data 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
*

components/src/main/kotlin/se/seb/gds/atoms/input/GdsInputContained.kt

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,15 @@ fun GdsInputContained(
109109
label = label,
110110
inputState = inputState,
111111
interactionSource = interactionSource,
112-
onValueChange = onValueChange,
112+
onValueChange = {
113+
validate(it, inputState.characterLimit)
114+
onValueChange(it)
115+
},
113116
onInteraction = onInteraction,
114117
onInfoIconClick = onInfoIconClick,
115-
) { textFieldIsFocused ->
118+
) { isTextFieldFocused, isCharacterLimitError ->
116119
val labelAnimationProgress by animateFloatAsState(
117-
targetValue = if (textFieldIsFocused || state.text.isNotEmpty()) 1f else 0f,
120+
targetValue = if (isTextFieldFocused || state.text.isNotEmpty()) 1f else 0f,
118121
)
119122

120123
val labelTextStyle by remember(labelAnimationProgress) {
@@ -129,10 +132,11 @@ fun GdsInputContained(
129132

130133
InputContainer(
131134
contentPadding = containerContentPadding(isLandscape()),
132-
textFieldIsFocused = textFieldIsFocused,
135+
textFieldIsFocused = isTextFieldFocused,
133136
style = style.basicInputStyle,
134137
state = state,
135138
inputState = inputState,
139+
characterLimitError = isCharacterLimitError,
136140
scrollState = scrollState,
137141
interactionSource = interactionSource,
138142
inputTransformationChain = inputTransformation,
@@ -154,8 +158,8 @@ fun GdsInputContained(
154158
},
155159
trailingContent = {
156160
val isMultiLine = isMultiLine(inputState.lineLimits, textLineCount)
157-
val hasCounter = inputState.maxCharacters != null
158-
val showCounterContainer = (hasCounter && (isMultiLine || textFieldIsFocused)) ||
161+
val hasCounter = inputState.hasCharacterLimit()
162+
val showCounterContainer = (hasCounter && (isMultiLine || isTextFieldFocused)) ||
159163
(!hasCounter && isMultiLine)
160164

161165
val trailingModifier = if (showCounterContainer) {
@@ -168,7 +172,7 @@ fun GdsInputContained(
168172
modifier = trailingModifier,
169173
showCounterContainer = showCounterContainer,
170174
inputState = inputState,
171-
textFieldIsFocused = textFieldIsFocused,
175+
textFieldIsFocused = isTextFieldFocused,
172176
onInfoIconClick = onInfoIconClick,
173177
style = style.basicInputStyle,
174178
state = state,
@@ -177,9 +181,11 @@ fun GdsInputContained(
177181
},
178182
onTextLayoutResult = { lineCount -> textLineCount = lineCount },
179183
)
180-
if (inputState.isError && !inputState.errorMessage.isNullOrBlank()) {
181-
ErrorFooter(errorMessage = inputState.errorMessage, style = style.basicInputStyle)
182-
}
184+
InputError(
185+
isCharacterLimitError = isCharacterLimitError,
186+
inputState = inputState,
187+
style = style.basicInputStyle,
188+
)
183189
}
184190
}
185191

@@ -214,12 +220,12 @@ private fun InputContainedTrailing(
214220
horizontalAlignment = Alignment.End,
215221
) {
216222
if (showCounterContainer) {
217-
val alpha = if (textFieldIsFocused && inputState.maxCharacters != null) 1f else 0f
223+
val alpha = if (textFieldIsFocused && inputState.hasCharacterLimit()) 1f else 0f
218224
CharacterAmountIndicator(
219225
modifier = Modifier.alpha(alpha = alpha),
220226
textStyle = style.characterCounter,
221227
color = style.colors.floatingLabelColor,
222-
maxCharacters = inputState.maxCharacters,
228+
maxCharacters = inputState.characterLimit?.maxCharacters,
223229
currentCharacters = state.text.length,
224230
)
225231
}

components/src/main/kotlin/se/seb/gds/atoms/input/GdsInputDefault.kt

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,12 @@ fun GdsInputDefault(
9494
inputState = inputState,
9595
interactionSource = interactionSource,
9696
onInfoIconClick = onInfoIconClick,
97-
onValueChange = onValueChange,
97+
onValueChange = {
98+
validate(it, inputState.characterLimit)
99+
onValueChange(it)
100+
},
98101
onInteraction = onInteraction,
99-
) { textFieldIsFocused ->
102+
) { isTextFieldFocused, isCharacterLimitError ->
100103
InputDefaultHeader(
101104
label = label,
102105
supportLabel = supportLabel,
@@ -106,9 +109,10 @@ fun GdsInputDefault(
106109
)
107110
InputContainer(
108111
contentPadding = containerContentPadding,
109-
textFieldIsFocused = textFieldIsFocused,
112+
textFieldIsFocused = isTextFieldFocused,
110113
style = style.basicInputStyle,
111114
inputState = inputState,
115+
characterLimitError = isCharacterLimitError,
112116
state = state,
113117
scrollState = scrollState,
114118
interactionSource = interactionSource,
@@ -119,7 +123,7 @@ fun GdsInputDefault(
119123
trailingContent = {
120124
val isMultiLine = isMultiLine(inputState.lineLimits, textLineCount)
121125
val trailingModifier = Modifier
122-
.alpha(if (textFieldIsFocused) 1f else 0f)
126+
.alpha(if (isTextFieldFocused) 1f else 0f)
123127
.let {
124128
if (isMultiLine) {
125129
it.padding(containerContentPadding)
@@ -129,19 +133,22 @@ fun GdsInputDefault(
129133
}
130134
InputDefaultTrailing(
131135
modifier = trailingModifier,
132-
textFieldIsFocused = textFieldIsFocused,
136+
textFieldIsFocused = isTextFieldFocused,
133137
clearable = inputState.clearable,
134138
style = style.basicInputStyle,
135-
maxCharacters = inputState.maxCharacters,
139+
maxCharacters = inputState.characterLimit?.maxCharacters,
136140
state = state,
137141
clearText = { clearText(state) },
138142
)
139143
},
140144
onTextLayoutResult = { lineCount -> textLineCount = lineCount },
141145
)
142-
if (inputState.isError && !inputState.errorMessage.isNullOrBlank()) {
143-
ErrorFooter(errorMessage = inputState.errorMessage, style = style.basicInputStyle)
144-
}
146+
147+
InputError(
148+
isCharacterLimitError = isCharacterLimitError,
149+
inputState = inputState,
150+
style = style.basicInputStyle,
151+
)
145152
}
146153
}
147154

@@ -301,7 +308,7 @@ private fun TextFieldPreview() {
301308
supportLabel = "Support Label",
302309
inputState = BasicInputState(
303310
showInfoIcon = true,
304-
maxCharacters = 50,
311+
characterLimit = CharacterLimit(50),
305312
),
306313
)
307314
Spacer(Modifier.height(GdsTheme.dimensions.spacing.SpaceM))
@@ -314,6 +321,7 @@ private fun TextFieldPreview() {
314321
inputState = BasicInputState(
315322
errorMessage = "Error message.",
316323
isError = true,
324+
characterLimit = CharacterLimit(50, CharacterLimitType.HARD),
317325
),
318326
)
319327
}

0 commit comments

Comments
 (0)