From ba34f2c7fc9cd55b4ad836a1efeb53587f129fb5 Mon Sep 17 00:00:00 2001 From: nhammami Date: Thu, 30 Oct 2025 10:49:02 +0100 Subject: [PATCH 1/5] fix: reorder the reading --- .../text_input/ouds_text_input.dart | 292 +++++++++--------- 1 file changed, 150 insertions(+), 142 deletions(-) diff --git a/ouds_core/lib/components/text_input/ouds_text_input.dart b/ouds_core/lib/components/text_input/ouds_text_input.dart index 7b86d7f6a..e00b88422 100644 --- a/ouds_core/lib/components/text_input/ouds_text_input.dart +++ b/ouds_core/lib/components/text_input/ouds_text_input.dart @@ -13,6 +13,7 @@ library; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:ouds_core/components/button/ouds_button.dart'; import 'package:ouds_core/components/text_input/internal/modifier/ouds_text_input_background_modifier.dart'; @@ -262,166 +263,173 @@ class _OudsTextInputState extends State { final l10n = OudsLocalizations.of(context); - return MergeSemantics( - child: Semantics( - textField: true, - label: l10n?.core_text_input_input_a11y, - hint: widget.decoration.hintText, + final contentText = widget.controller?.text; + final prefixText = contentText != null && contentText.isNotEmpty ? ", ${widget.decoration.prefix ?? ""}" : ""; + final suffixText = contentText != null && contentText.isNotEmpty ? ", ${widget.decoration.prefix ?? ""}" : ""; + final helperText = isError ? widget.decoration.errorText : widget.decoration.helperText ?? ""; + +//Talkback should read in order "role, label, prefix, content, suffix, helper text". + return Semantics( + value: "${l10n?.core_text_input_input_a11y}," + " ${widget.decoration.labelText ?? ""} " + "$prefixText $contentText $suffixText, $helperText", + // hint: widget.decoration.hintText ?? "", focused: effectiveFocusNode != null, focusable: true, enabled: widget.enabled, readOnly: widget.readOnly, - child: Container( - constraints: BoxConstraints( - minWidth: textInput.sizeMinWidth, - maxWidth: textInput.sizeMaxWidth, - minHeight: textInput.sizeMinHeight, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DecoratedBox( - decoration: BoxDecoration( - // Background color based on current state and error presence - color: inputTextBackgroundModifier.getBackgroundColor(state, isError, widget.decoration.style), - - /// Bottom border styling; full border if style is not default - border: inputTextBorderModifier.getBorder(state, isError, widget.decoration.style), - - // Border radius if enabled in theme configuration - borderRadius: inputTextBorderModifier.getBorderRadius(context, isBorderRadius), - ), - child: ConstrainedBox( - // Minimum height constraint for the input container - constraints: BoxConstraints(minHeight: textInput.sizeMinHeight), - - /// Padding inside the text input container - child: Padding( - padding: EdgeInsetsGeometry.directional( - start: textInput.spacePaddingInlineDefault, - end: (widget.decoration.suffixIcon != null || widget.decoration.errorText != null || widget.decoration.loader != null) ? textInput.spacePaddingInlineTrailingAction : textInput.spacePaddingInlineDefault, - top: textInput.spacePaddingBlockDefault, - bottom: textInput.spacePaddingBlockDefault, - ), - child: Row( - children: [ - /// Left block: prefix icon container - Container( - alignment: Alignment.center, - child: _buildPrefixIcon(context, state), - ), - - /// Center block: main text input - Expanded( - child: Container( + child: ExcludeSemantics( + child: Container( + constraints: BoxConstraints( + minWidth: textInput.sizeMinWidth, + maxWidth: textInput.sizeMaxWidth, + minHeight: textInput.sizeMinHeight, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DecoratedBox( + decoration: BoxDecoration( + // Background color based on current state and error presence + color: inputTextBackgroundModifier.getBackgroundColor(state, isError, widget.decoration.style), + + /// Bottom border styling; full border if style is not default + border: inputTextBorderModifier.getBorder(state, isError, widget.decoration.style), + + // Border radius if enabled in theme configuration + borderRadius: inputTextBorderModifier.getBorderRadius(context, isBorderRadius), + ), + child: ConstrainedBox( + // Minimum height constraint for the input container + constraints: BoxConstraints(minHeight: textInput.sizeMinHeight), + + /// Padding inside the text input container + child: Padding( + padding: EdgeInsetsGeometry.directional( + start: textInput.spacePaddingInlineDefault, + end: (widget.decoration.suffixIcon != null || widget.decoration.errorText != null || widget.decoration.loader != null) ? textInput.spacePaddingInlineTrailingAction : textInput.spacePaddingInlineDefault, + top: textInput.spacePaddingBlockDefault, + bottom: textInput.spacePaddingBlockDefault, + ), + child: Row( + children: [ + /// Left block: prefix icon container + Container( alignment: Alignment.center, - child: TextField( - cursorColor: inputTextTextModifier.getCursorTextColor(state, isError), - focusNode: effectiveFocusNode, - controller: widget.controller, - keyboardType: widget.keyboardType, - style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( - color: inputTextTextModifier.getTextColor(state, isError), - ), - enabled: widget.enabled, - readOnly: widget.readOnly ?? false, - decoration: InputDecoration( - border: InputBorder.none, - // Label text widget, shown if labelText is provided - label: widget.decoration.labelText != null - ? Container( - constraints: BoxConstraints( - maxHeight: textInput.sizeLabelMaxHeight - ), - child: Text( - maxLines: - InputUtils.getLabelMaxLines( - decoration : widget.decoration, - controller: widget.controller, - isFocused: effectiveIsFocused), - overflow: TextOverflow.ellipsis, - widget.decoration.labelText ?? "", - style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( + child: _buildPrefixIcon(context, state), + ), + + /// Center block: main text input + Expanded( + child: Container( + alignment: Alignment.center, + child: TextField( + cursorColor: inputTextTextModifier.getCursorTextColor(state, isError), + focusNode: effectiveFocusNode, + controller: widget.controller, + keyboardType: widget.keyboardType, + style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( color: inputTextTextModifier.getTextColor(state, isError), ), - ), - ) - : null, - - // Floating label behavior: always float if both labelText and hintText are provided - floatingLabelBehavior: (widget.decoration.labelText != null && widget.decoration.hintText != null) ? FloatingLabelBehavior.always : null, - - // Hint text widget, shown if hintText is provided - hint: widget.decoration.hintText != null - ? Text( - maxLines : 1, - overflow: TextOverflow.ellipsis, - widget.decoration.hintText!, - style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( - color: inputTextTextModifier.getHintTextColor(state), + enabled: widget.enabled, + readOnly: widget.readOnly ?? false, + decoration: InputDecoration( + border: InputBorder.none, + // Label text widget, shown if labelText is provided + label: widget.decoration.labelText != null + ? Container( + constraints: BoxConstraints( + maxHeight: textInput.sizeLabelMaxHeight + ), + child: Text( + maxLines: + InputUtils.getLabelMaxLines( + decoration : widget.decoration, + controller: widget.controller, + isFocused: effectiveIsFocused), + overflow: TextOverflow.ellipsis, + widget.decoration.labelText ?? "", + style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( + color: inputTextTextModifier.getTextColor(state, isError), + ), + ), + ) + : null, + + // Floating label behavior: always float if both labelText and hintText are provided + floatingLabelBehavior: (widget.decoration.labelText != null && widget.decoration.hintText != null) ? FloatingLabelBehavior.always : null, + + // Hint text widget, shown if hintText is provided + hint: widget.decoration.hintText != null + ? Text( + maxLines : 1, + overflow: TextOverflow.ellipsis, + widget.decoration.hintText!, + style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( + color: inputTextTextModifier.getHintTextColor(state), + ), + ) + : null, + + // Prefix widget displayed when prefix and labelText are both set + prefix: widget.decoration.prefix != null && widget.decoration.labelText != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.decoration.prefix!, + style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( + color: inputTextTextModifier.getSuffixPrefixTextColor(state), + ), ), - ) - : null, - - // Prefix widget displayed when prefix and labelText are both set - prefix: widget.decoration.prefix != null && widget.decoration.labelText != null - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.decoration.prefix!, - style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( - color: inputTextTextModifier.getSuffixPrefixTextColor(state), - ), - ), - SizedBox(width: textInput.spaceColumnGapInlineText), - ], - ) - : null, - - // Override default constraints to better fit OUDS design - prefixIconConstraints: BoxConstraints(minWidth: 0, minHeight: 0), - - // Suffix widget displayed when suffix and labelText are both set - suffix: widget.decoration.suffix != null && widget.decoration.labelText != null - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(width: textInput.spaceColumnGapInlineText), - Text( - widget.decoration.suffix!, - style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( - color: inputTextTextModifier.getSuffixPrefixTextColor(state), - ), - ), - ], - ) - : null, - isDense: true, + SizedBox(width: textInput.spaceColumnGapInlineText), + ], + ) + : null, + + // Override default constraints to better fit OUDS design + prefixIconConstraints: BoxConstraints(minWidth: 0, minHeight: 0), + + // Suffix widget displayed when suffix and labelText are both set + suffix: widget.decoration.suffix != null && widget.decoration.labelText != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: textInput.spaceColumnGapInlineText), + Text( + widget.decoration.suffix!, + style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( + color: inputTextTextModifier.getSuffixPrefixTextColor(state), + ), + ), + ], + ) + : null, + isDense: true, + ), ), ), ), - ), - - /// Right block: suffix icon container - Container( - alignment: Alignment.center, - child: _buildSuffixIcon(context, state), - ), - ], + + /// Right block: suffix icon container + Container( + alignment: Alignment.center, + child: _buildSuffixIcon(context, state), + ), + ], + ), ), ), ), - ), - /// Display helper text or error text if available - if (widget.decoration.helperText != null || widget.decoration.errorText != null) ...[ - _buildHelperOrErrorText(context, state, isError == true), + /// Display helper text or error text if available + if (widget.decoration.helperText != null || widget.decoration.errorText != null) ...[ + _buildHelperOrErrorText(context, state, isError == true), + ], ], - ], + ), ), ), - ), ); } From f47ef9e338adae013b3340a91b8b4c21ce881f5d Mon Sep 17 00:00:00 2001 From: nhammami Date: Thu, 30 Oct 2025 15:15:39 +0100 Subject: [PATCH 2/5] fix: exclude prefix icon role, add trailing icon button role and description --- app/CHANGELOG.md | 2 + .../gen/ouds_flutter_app_localizations.dart | 6 + .../ouds_flutter_app_localizations_ar.dart | 4 + .../ouds_flutter_app_localizations_en.dart | 4 + app/lib/l10n/ouds_flutter_ar.arb | 1 + app/lib/l10n/ouds_flutter_en.arb | 1 + .../text_input/text_input_demo_screen.dart | 2 + ouds_core/CHANGELOG.md | 2 + .../lib/components/button/ouds_button.dart | 6 +- ouds_core/lib/components/tag/ouds_tag.dart | 2 +- .../text_input/ouds_text_input.dart | 244 ++++++++++-------- .../lib/l10n/gen/ouds_localizations.dart | 19 +- .../lib/l10n/gen/ouds_localizations_ar.dart | 8 +- .../lib/l10n/gen/ouds_localizations_en.dart | 8 +- ouds_core/lib/l10n/ouds_flutter_ar.arb | 3 +- ouds_core/lib/l10n/ouds_flutter_en.arb | 4 +- 16 files changed, 184 insertions(+), 132 deletions(-) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 3a423b971..3963dd067 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Tool] Change the favicon to orange favicon in the documentation ([#371](https://github.com/Orange-OpenSource/ouds-flutter/issues/371)) ### Fixed +- [DemoApp][Library] `Text input` Trailing action button should have its own accessibility label ([#450](https://github.com/Orange-OpenSource/ouds-flutter/issues/450)) +- [Library] `Text input` Incorrect reading order ([#449](https://github.com/Orange-OpenSource/ouds-flutter/issues/449)) - [Library] `Checkbox item` + icon : wrong accessible name ([#392](https://github.com/Orange-OpenSource/ouds-flutter/issues/392)) - [Library] `Checkbox`: hint is missing on component ([#327](https://github.com/Orange-OpenSource/ouds-flutter/issues/391)) - [Library] Android `Switch` : remove the useless focus on switch ([#327](https://github.com/Orange-OpenSource/ouds-flutter/issues/364)) diff --git a/app/lib/l10n/gen/ouds_flutter_app_localizations.dart b/app/lib/l10n/gen/ouds_flutter_app_localizations.dart index d047603c0..45bc90b41 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations.dart @@ -842,6 +842,12 @@ abstract class AppLocalizations { /// **'This field can’t be empty.'** String get app_components_text_input_error_label; + /// No description provided for @app_components_textInput_trailingIcon_a11y. + /// + /// In en, this message translates to: + /// **'Trailing icon content description'** + String get app_components_textInput_trailingIcon_a11y; + /// No description provided for @app_about_name_label. /// /// In en, this message translates to: diff --git a/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart b/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart index c7ec0bd8d..267f2f90b 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart @@ -406,6 +406,10 @@ class AppLocalizationsAr extends AppLocalizations { String get app_components_text_input_error_label => 'لا يمكن أن يكون هذا الحقل فارغًا.'; + @override + String get app_components_textInput_trailingIcon_a11y => + 'وصف محتوى أيقونة النهاية'; + @override String get app_about_name_label => 'أداة نظام التصميم'; diff --git a/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart b/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart index 4f2b4519b..b4f5634b0 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart @@ -406,6 +406,10 @@ class AppLocalizationsEn extends AppLocalizations { String get app_components_text_input_error_label => 'This field can’t be empty.'; + @override + String get app_components_textInput_trailingIcon_a11y => + 'Trailing icon content description'; + @override String get app_about_name_label => 'Design System Toolbox'; diff --git a/app/lib/l10n/ouds_flutter_ar.arb b/app/lib/l10n/ouds_flutter_ar.arb index 88b42d38c..dfb7a7260 100644 --- a/app/lib/l10n/ouds_flutter_ar.arb +++ b/app/lib/l10n/ouds_flutter_ar.arb @@ -155,6 +155,7 @@ "app_components_text_input_placeholder_label": "العنصر النائب", "app_components_text_input_helperText_label": "نص مساعد", "app_components_text_input_error_label": "لا يمكن أن يكون هذا الحقل فارغًا.", + "app_components_textInput_trailingIcon_a11y": "وصف محتوى أيقونة النهاية", "@_about_screen": {}, "app_about_name_label": "أداة نظام التصميم", diff --git a/app/lib/l10n/ouds_flutter_en.arb b/app/lib/l10n/ouds_flutter_en.arb index 395c8dcf3..510e253e8 100644 --- a/app/lib/l10n/ouds_flutter_en.arb +++ b/app/lib/l10n/ouds_flutter_en.arb @@ -191,6 +191,7 @@ "app_components_text_input_placeholder_label": "Placeholder", "app_components_text_input_helperText_label": "Helper text", "app_components_text_input_error_label": "This field can’t be empty.", + "app_components_textInput_trailingIcon_a11y": "Trailing icon content description", "@_about_screen": {}, "app_about_name_label": "Design System Toolbox", diff --git a/app/lib/ui/components/text_input/text_input_demo_screen.dart b/app/lib/ui/components/text_input/text_input_demo_screen.dart index 87dc01bb3..01a9cc97b 100644 --- a/app/lib/ui/components/text_input/text_input_demo_screen.dart +++ b/app/lib/ui/components/text_input/text_input_demo_screen.dart @@ -138,6 +138,7 @@ class _TextInputDemoState extends State<_TextInputDemo> { focusNode: textInputFocus, enabled: customizationState.hasEnabled, readOnly: customizationState.hasReadOnly, + trailingIconContentDescription: context.l10n.app_components_textInput_trailingIcon_a11y, decoration: OudsInputDecoration( labelText: customizationState.labelText.isNotEmpty ? TextInputCustomizationUtils.getLabelText(customizationState) : null, helperText: customizationState.helperText.isNotEmpty ? TextInputCustomizationUtils.getHelperText(customizationState) : null, @@ -165,6 +166,7 @@ class _TextInputDemoState extends State<_TextInputDemo> { focusNode: textInputFocus, enabled: customizationState.hasEnabled, readOnly: customizationState.hasReadOnly, + trailingIconContentDescription: context.l10n.app_components_textInput_trailingIcon_a11y, decoration: OudsInputDecoration( labelText: customizationState.labelText.isNotEmpty ? TextInputCustomizationUtils.getLabelText(customizationState) : null, helperText: customizationState.helperText.isNotEmpty ? TextInputCustomizationUtils.getHelperText(customizationState) : null, diff --git a/ouds_core/CHANGELOG.md b/ouds_core/CHANGELOG.md index 8043fdc31..1e39e9ffe 100644 --- a/ouds_core/CHANGELOG.md +++ b/ouds_core/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Tool] Change the favicon to orange favicon in the documentation ([#371](https://github.com/Orange-OpenSource/ouds-flutter/issues/371)) ### Fixed +- [Library] `Text input` Trailing action button should have its own accessibility label ([#450](https://github.com/Orange-OpenSource/ouds-flutter/issues/450)) +- [Library] `Text input` Incorrect reading order ([#449](https://github.com/Orange-OpenSource/ouds-flutter/issues/449)) - [Library] `Checkbox item` + icon : wrong accessible name ([#392](https://github.com/Orange-OpenSource/ouds-flutter/issues/392)) - [Library] `Checkbox`: hint is missing on component ([#327](https://github.com/Orange-OpenSource/ouds-flutter/issues/391)) - [Library] Android `Switch` : remove the useless focus on switch ([#327](https://github.com/Orange-OpenSource/ouds-flutter/issues/364)) diff --git a/ouds_core/lib/components/button/ouds_button.dart b/ouds_core/lib/components/button/ouds_button.dart index 6addf3049..6fd994ed9 100644 --- a/ouds_core/lib/components/button/ouds_button.dart +++ b/ouds_core/lib/components/button/ouds_button.dart @@ -203,7 +203,7 @@ class _OudsButtonState extends State { switch (buttonState) { case OudsButtonControlState.loading: return Semantics( - label: OudsLocalizations.of(context)?.core_button_loading_a11y, + label: OudsLocalizations.of(context)?.core_common_loading_a11y, enabled: false, button: true, child: ExcludeSemantics( @@ -294,7 +294,7 @@ class _OudsButtonState extends State { switch (buttonState) { case OudsButtonControlState.loading: return Semantics( - label: OudsLocalizations.of(context)?.core_button_loading_a11y, + label: OudsLocalizations.of(context)?.core_common_loading_a11y, enabled: false, button: true, child: IconButton( @@ -343,7 +343,7 @@ class _OudsButtonState extends State { switch (buttonState) { case OudsButtonControlState.loading: return Semantics( - label: OudsLocalizations.of(context)?.core_button_loading_a11y, + label: OudsLocalizations.of(context)?.core_common_loading_a11y, enabled: false, button: true, child: ExcludeSemantics( diff --git a/ouds_core/lib/components/tag/ouds_tag.dart b/ouds_core/lib/components/tag/ouds_tag.dart index 97ddb3254..339e42ca3 100644 --- a/ouds_core/lib/components/tag/ouds_tag.dart +++ b/ouds_core/lib/components/tag/ouds_tag.dart @@ -195,7 +195,7 @@ class _OudsTagState extends State { width: widthAndHeightAssetsContainer[OudsTagDimensions.width.name], height: widthAndHeightAssetsContainer[OudsTagDimensions.height.name], child: Semantics( - label: l10n?.core_tag_loading_a11y, + label: l10n?.core_common_loading_a11y, child: CircularProgressIndicator( padding: tagSizeModifier.getAssetsPadding(widget.size, OudsTagLayout.textAndLoader), color: tagStatusModifier.getStatusTextAndLoaderColor(widget.status, widget.hierarchy), diff --git a/ouds_core/lib/components/text_input/ouds_text_input.dart b/ouds_core/lib/components/text_input/ouds_text_input.dart index e00b88422..41c5b360a 100644 --- a/ouds_core/lib/components/text_input/ouds_text_input.dart +++ b/ouds_core/lib/components/text_input/ouds_text_input.dart @@ -13,7 +13,6 @@ library; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:ouds_core/components/button/ouds_button.dart'; import 'package:ouds_core/components/text_input/internal/modifier/ouds_text_input_background_modifier.dart'; @@ -130,6 +129,7 @@ class OudsTextField extends OudsTextInput { /// - [readOnly]: Whether the input is read-only. /// - [keyboardType]: The type of keyboard to display. /// - [decoration]: An `OudsInputDecoration` object to configure label, +/// - [trailingIconContentDescription]: A semantic label for accessibility trailing icon. /// /// ## Simple example: /// @@ -150,6 +150,7 @@ class OudsTextInput extends StatefulWidget { final bool? readOnly; final TextInputType? keyboardType; final OudsInputDecoration decoration; + final String? trailingIconContentDescription; OudsTextInput({ super.key, @@ -159,6 +160,7 @@ class OudsTextInput extends StatefulWidget { this.readOnly = false, this.keyboardType, required this.decoration, + this.trailingIconContentDescription, }) : assert( !(decoration.loader == true && decoration.errorText != null), "Error status for Loading state is not relevant", @@ -263,23 +265,40 @@ class _OudsTextInputState extends State { final l10n = OudsLocalizations.of(context); - final contentText = widget.controller?.text; - final prefixText = contentText != null && contentText.isNotEmpty ? ", ${widget.decoration.prefix ?? ""}" : ""; - final suffixText = contentText != null && contentText.isNotEmpty ? ", ${widget.decoration.prefix ?? ""}" : ""; - final helperText = isError ? widget.decoration.errorText : widget.decoration.helperText ?? ""; + //needed for accessibility + final contentText = widget.controller?.text ?? ""; + final prefixText = contentText.isNotEmpty ? ", ${widget.decoration.prefix ?? ""}" : ""; + final suffixText = contentText.isNotEmpty ? ", ${widget.decoration.suffix ?? ""}" : ""; + final helperText = isError ? widget.decoration.errorText ?? "" : widget.decoration.helperText ?? ""; + + // Determine disabled/readOnly label + final isEnabled = widget.enabled ?? true; + final isReadOnly = widget.readOnly ?? false; + final statusLabel = !isEnabled + ? l10n?.core_common_disable_a11y ?? "" + : isReadOnly + ? l10n?.core_common_disable_a11y ?? "" + : ""; + + // Build Semantics value + final semanticsValue = [ + l10n?.core_text_input_input_a11y, + widget.decoration.labelText, + prefixText, + contentText, + suffixText, + helperText, + statusLabel, + ] + .where((s) => s != null && s.isNotEmpty) + .join(", "); -//Talkback should read in order "role, label, prefix, content, suffix, helper text". return Semantics( - value: "${l10n?.core_text_input_input_a11y}," - " ${widget.decoration.labelText ?? ""} " - "$prefixText $contentText $suffixText, $helperText", - // hint: widget.decoration.hintText ?? "", + value: semanticsValue, + hint: widget.decoration.hintText ?? "", focused: effectiveFocusNode != null, focusable: true, - enabled: widget.enabled, - readOnly: widget.readOnly, - child: ExcludeSemantics( - child: Container( + child: Container( constraints: BoxConstraints( minWidth: textInput.sizeMinWidth, maxWidth: textInput.sizeMaxWidth, @@ -314,98 +333,102 @@ class _OudsTextInputState extends State { child: Row( children: [ /// Left block: prefix icon container - Container( - alignment: Alignment.center, - child: _buildPrefixIcon(context, state), + ExcludeSemantics( + child: Container( + alignment: Alignment.center, + child: _buildPrefixIcon(context, state), + ), ), /// Center block: main text input Expanded( - child: Container( - alignment: Alignment.center, - child: TextField( - cursorColor: inputTextTextModifier.getCursorTextColor(state, isError), - focusNode: effectiveFocusNode, - controller: widget.controller, - keyboardType: widget.keyboardType, - style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( - color: inputTextTextModifier.getTextColor(state, isError), - ), - enabled: widget.enabled, - readOnly: widget.readOnly ?? false, - decoration: InputDecoration( - border: InputBorder.none, - // Label text widget, shown if labelText is provided - label: widget.decoration.labelText != null - ? Container( - constraints: BoxConstraints( - maxHeight: textInput.sizeLabelMaxHeight - ), - child: Text( - maxLines: - InputUtils.getLabelMaxLines( - decoration : widget.decoration, - controller: widget.controller, - isFocused: effectiveIsFocused), - overflow: TextOverflow.ellipsis, - widget.decoration.labelText ?? "", - style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( + child: ExcludeSemantics( + child: Container( + alignment: Alignment.center, + child: TextField( + cursorColor: inputTextTextModifier.getCursorTextColor(state, isError), + focusNode: effectiveFocusNode, + controller: widget.controller, + keyboardType: widget.keyboardType, + style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( color: inputTextTextModifier.getTextColor(state, isError), ), - ), - ) - : null, - - // Floating label behavior: always float if both labelText and hintText are provided - floatingLabelBehavior: (widget.decoration.labelText != null && widget.decoration.hintText != null) ? FloatingLabelBehavior.always : null, - - // Hint text widget, shown if hintText is provided - hint: widget.decoration.hintText != null - ? Text( - maxLines : 1, - overflow: TextOverflow.ellipsis, - widget.decoration.hintText!, - style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( - color: inputTextTextModifier.getHintTextColor(state), + enabled: widget.enabled, + readOnly: widget.readOnly ?? false, + decoration: InputDecoration( + border: InputBorder.none, + // Label text widget, shown if labelText is provided + label: widget.decoration.labelText != null + ? Container( + constraints: BoxConstraints( + maxHeight: textInput.sizeLabelMaxHeight + ), + child: Text( + maxLines: + InputUtils.getLabelMaxLines( + decoration : widget.decoration, + controller: widget.controller, + isFocused: effectiveIsFocused), + overflow: TextOverflow.ellipsis, + widget.decoration.labelText ?? "", + style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( + color: inputTextTextModifier.getTextColor(state, isError), + ), + ), + ) + : null, + + // Floating label behavior: always float if both labelText and hintText are provided + floatingLabelBehavior: (widget.decoration.labelText != null && widget.decoration.hintText != null) ? FloatingLabelBehavior.always : null, + + // Hint text widget, shown if hintText is provided + hint: widget.decoration.hintText != null + ? Text( + maxLines : 1, + overflow: TextOverflow.ellipsis, + widget.decoration.hintText!, + style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( + color: inputTextTextModifier.getHintTextColor(state), + ), + ) + : null, + + // Prefix widget displayed when prefix and labelText are both set + prefix: widget.decoration.prefix != null && widget.decoration.labelText != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.decoration.prefix!, + style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( + color: inputTextTextModifier.getSuffixPrefixTextColor(state), + ), ), - ) - : null, - - // Prefix widget displayed when prefix and labelText are both set - prefix: widget.decoration.prefix != null && widget.decoration.labelText != null - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.decoration.prefix!, - style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( - color: inputTextTextModifier.getSuffixPrefixTextColor(state), - ), - ), - SizedBox(width: textInput.spaceColumnGapInlineText), - ], - ) - : null, - - // Override default constraints to better fit OUDS design - prefixIconConstraints: BoxConstraints(minWidth: 0, minHeight: 0), - - // Suffix widget displayed when suffix and labelText are both set - suffix: widget.decoration.suffix != null && widget.decoration.labelText != null - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(width: textInput.spaceColumnGapInlineText), - Text( - widget.decoration.suffix!, - style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( - color: inputTextTextModifier.getSuffixPrefixTextColor(state), - ), - ), - ], - ) - : null, - isDense: true, + SizedBox(width: textInput.spaceColumnGapInlineText), + ], + ) + : null, + + // Override default constraints to better fit OUDS design + prefixIconConstraints: BoxConstraints(minWidth: 0, minHeight: 0), + + // Suffix widget displayed when suffix and labelText are both set + suffix: widget.decoration.suffix != null && widget.decoration.labelText != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: textInput.spaceColumnGapInlineText), + Text( + widget.decoration.suffix!, + style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( + color: inputTextTextModifier.getSuffixPrefixTextColor(state), + ), + ), + ], + ) + : null, + isDense: true, + ), ), ), ), @@ -414,7 +437,12 @@ class _OudsTextInputState extends State { /// Right block: suffix icon container Container( alignment: Alignment.center, - child: _buildSuffixIcon(context, state), + child: Semantics( + label: widget.decoration.suffixIcon != null && !isError ? widget.trailingIconContentDescription : "", + container: true, + button: true, + child: _buildSuffixIcon(context, state) + ), ), ], ), @@ -424,12 +452,11 @@ class _OudsTextInputState extends State { /// Display helper text or error text if available if (widget.decoration.helperText != null || widget.decoration.errorText != null) ...[ - _buildHelperOrErrorText(context, state, isError == true), + ExcludeSemantics(child: _buildHelperOrErrorText(context, state, isError == true)), ], ], ), ), - ), ); } @@ -509,7 +536,6 @@ class _OudsTextInputState extends State { children: [ SizedBox(width: textInput.spaceColumnGapDefault), OudsButton( - icon: 'assets/ic_heart.svg', hierarchy: OudsButtonHierarchy.minimal, loader: Loader(progress: null), onPressed: () {}, @@ -526,6 +552,7 @@ class _OudsTextInputState extends State { SizedBox(width: textInput.spaceColumnGapDefault), if (widget.decoration.errorText != null) ...[ SvgPicture.asset( + excludeFromSemantics: true, AppAssets.icons.importantAlert, package: theme.packageName, width: theme.componentsTokens(context).button.sizeIconOnly, @@ -537,10 +564,12 @@ class _OudsTextInputState extends State { ), SizedBox(width: textInput.spaceColumnGapTrailingErrorAction), ], - OudsButton( - hierarchy: OudsButtonHierarchy.minimal, - icon: widget.decoration.suffixIcon, - onPressed: ((widget.enabled ?? true) && !(widget.readOnly ?? false)) ? () {} : null, + ExcludeSemantics( + child: OudsButton( + hierarchy: OudsButtonHierarchy.minimal, + icon: widget.decoration.suffixIcon, + onPressed: ((widget.enabled ?? true) && !(widget.readOnly ?? false)) ? () {} : null, + ), ), ], ); @@ -557,6 +586,7 @@ class _OudsTextInputState extends State { theme.componentsTokens(context).button.spaceInsetIconOnly, ), child: SvgPicture.asset( + excludeFromSemantics: true, AppAssets.icons.importantAlert, package: theme.packageName, width: theme.componentsTokens(context).button.sizeIconOnly, diff --git a/ouds_core/lib/l10n/gen/ouds_localizations.dart b/ouds_core/lib/l10n/gen/ouds_localizations.dart index 9d57252df..268294f54 100644 --- a/ouds_core/lib/l10n/gen/ouds_localizations.dart +++ b/ouds_core/lib/l10n/gen/ouds_localizations.dart @@ -119,11 +119,17 @@ abstract class OudsLocalizations { /// **'Not selected'** String get core_common_not_selected_a11y; - /// No description provided for @core_button_loading_a11y. + /// No description provided for @core_common_loading_a11y. /// /// In en, this message translates to: /// **'Loading'** - String get core_button_loading_a11y; + String get core_common_loading_a11y; + + /// No description provided for @core_common_disable_a11y. + /// + /// In en, this message translates to: + /// **'Disable'** + String get core_common_disable_a11y; /// No description provided for @core_button_icon_only_a11y. /// @@ -239,14 +245,13 @@ abstract class OudsLocalizations { /// **'Indeterminate'** String get core_checkbox_indeterminate_a11y; - /// No description provided for @core_radioButton_radioButton_a11y. /// No description provided for @core_checkbox_action_a11y. /// /// In en, this message translates to: /// **'double tap to toggle'** String get core_checkbox_action_a11y; - /// No description provided for @core_switch_error_a11y. + /// No description provided for @core_radioButton_radioButton_a11y. /// /// In en, this message translates to: /// **'Radio button'** @@ -270,12 +275,6 @@ abstract class OudsLocalizations { /// **'Double tap to delete this item'** String get core_tag_tag_input_hint_a11y; - /// No description provided for @core_tag_loading_a11y. - /// - /// In en, this message translates to: - /// **'Loading'** - String get core_tag_loading_a11y; - /// No description provided for @core_text_input_input_a11y. /// /// In en, this message translates to: diff --git a/ouds_core/lib/l10n/gen/ouds_localizations_ar.dart b/ouds_core/lib/l10n/gen/ouds_localizations_ar.dart index 44e055733..d5cd26c63 100644 --- a/ouds_core/lib/l10n/gen/ouds_localizations_ar.dart +++ b/ouds_core/lib/l10n/gen/ouds_localizations_ar.dart @@ -21,7 +21,10 @@ class OudsLocalizationsAr extends OudsLocalizations { String get core_common_not_selected_a11y => 'غير محدد'; @override - String get core_button_loading_a11y => 'جاري التحميل'; + String get core_common_loading_a11y => 'جاري التحميل'; + + @override + String get core_common_disable_a11y => 'غير مفعّل'; @override String get core_button_icon_only_a11y => 'أيقونة'; @@ -97,9 +100,6 @@ class OudsLocalizationsAr extends OudsLocalizations { @override String get core_tag_tag_input_hint_a11y => 'انقر مرتين لحذف هذا العنصر'; - @override - String get core_tag_loading_a11y => 'جاري التحميل'; - @override String get core_text_input_input_a11y => 'حقل النص'; } diff --git a/ouds_core/lib/l10n/gen/ouds_localizations_en.dart b/ouds_core/lib/l10n/gen/ouds_localizations_en.dart index b16932551..32bb442ef 100644 --- a/ouds_core/lib/l10n/gen/ouds_localizations_en.dart +++ b/ouds_core/lib/l10n/gen/ouds_localizations_en.dart @@ -21,7 +21,10 @@ class OudsLocalizationsEn extends OudsLocalizations { String get core_common_not_selected_a11y => 'Not selected'; @override - String get core_button_loading_a11y => 'Loading'; + String get core_common_loading_a11y => 'Loading'; + + @override + String get core_common_disable_a11y => 'Disable'; @override String get core_button_icon_only_a11y => 'Icon'; @@ -97,9 +100,6 @@ class OudsLocalizationsEn extends OudsLocalizations { @override String get core_tag_tag_input_hint_a11y => 'Double tap to delete this item'; - @override - String get core_tag_loading_a11y => 'Loading'; - @override String get core_text_input_input_a11y => 'TextField'; } diff --git a/ouds_core/lib/l10n/ouds_flutter_ar.arb b/ouds_core/lib/l10n/ouds_flutter_ar.arb index 1a31c52c9..56a8db86a 100644 --- a/ouds_core/lib/l10n/ouds_flutter_ar.arb +++ b/ouds_core/lib/l10n/ouds_flutter_ar.arb @@ -3,9 +3,10 @@ "core_common_onError_a11y": "يوجد خطأ", "core_common_selected_a11y": "محدد", "core_common_not_selected_a11y": "غير محدد", + "core_common_loading_a11y": "جاري التحميل", + "core_common_disable_a11y": "غير مفعّل", "@_OUDS_BUTTON": {}, - "core_button_loading_a11y": "جاري التحميل", "core_button_icon_only_a11y": "أيقونة", "@_OUDS_BOTTOM_SHEETS": {}, diff --git a/ouds_core/lib/l10n/ouds_flutter_en.arb b/ouds_core/lib/l10n/ouds_flutter_en.arb index dd6bf6962..c7d0d7f98 100644 --- a/ouds_core/lib/l10n/ouds_flutter_en.arb +++ b/ouds_core/lib/l10n/ouds_flutter_en.arb @@ -3,9 +3,10 @@ "core_common_onError_a11y": "Is on error", "core_common_selected_a11y": "Selected", "core_common_not_selected_a11y": "Not selected", + "core_common_loading_a11y": "Loading", + "core_common_disable_a11y": "Disable", "@_OUDS_BUTTON": {}, - "core_button_loading_a11y": "Loading", "core_button_icon_only_a11y": "Icon", "@_OUDS_BOTTOM_SHEETS": {}, @@ -41,7 +42,6 @@ "core_tag_tag_input_a11y": "Tag Input", "core_tag_a11y": "Tag", "core_tag_tag_input_hint_a11y": "Double tap to delete this item", - "core_tag_loading_a11y": "Loading", "@_OUDS_TEXT_INPUT": {}, "core_text_input_input_a11y": "TextField" From a71098a191bcbe33e65b8a5ce87abcdf927b4f51 Mon Sep 17 00:00:00 2001 From: nhammami Date: Wed, 5 Nov 2025 08:54:35 +0100 Subject: [PATCH 3/5] review: accessibility --- ouds_core/lib/components/text_input/ouds_text_input.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ouds_core/lib/components/text_input/ouds_text_input.dart b/ouds_core/lib/components/text_input/ouds_text_input.dart index 41c5b360a..6a2ff5073 100644 --- a/ouds_core/lib/components/text_input/ouds_text_input.dart +++ b/ouds_core/lib/components/text_input/ouds_text_input.dart @@ -294,7 +294,8 @@ class _OudsTextInputState extends State { .join(", "); return Semantics( - value: semanticsValue, + label: semanticsValue, + value: isError ? l10n?.core_common_onError_a11y : null, hint: widget.decoration.hintText ?? "", focused: effectiveFocusNode != null, focusable: true, @@ -438,7 +439,7 @@ class _OudsTextInputState extends State { Container( alignment: Alignment.center, child: Semantics( - label: widget.decoration.suffixIcon != null && !isError ? widget.trailingIconContentDescription : "", + label: widget.decoration.suffixIcon != null && widget.decoration.loader == false ? widget.trailingIconContentDescription : "", container: true, button: true, child: _buildSuffixIcon(context, state) From 7ea6de966468495e184f5464b72c5882150aad81 Mon Sep 17 00:00:00 2001 From: nhammami Date: Wed, 5 Nov 2025 16:40:08 +0100 Subject: [PATCH 4/5] chore: update labels --- app/lib/l10n/gen/ouds_flutter_app_localizations.dart | 12 ++++++------ .../l10n/gen/ouds_flutter_app_localizations_ar.dart | 8 ++++---- .../l10n/gen/ouds_flutter_app_localizations_en.dart | 8 ++++---- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/lib/l10n/gen/ouds_flutter_app_localizations.dart b/app/lib/l10n/gen/ouds_flutter_app_localizations.dart index 3efbae7be..21d7cf930 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations.dart @@ -842,6 +842,12 @@ abstract class AppLocalizations { /// **'This field can’t be empty.'** String get app_components_text_input_error_label; + /// No description provided for @app_components_textInput_trailingIcon_a11y. + /// + /// In en, this message translates to: + /// **'Trailing icon content description'** + String get app_components_textInput_trailingIcon_a11y; + /// No description provided for @app_components_link_label. /// /// In en, this message translates to: @@ -866,12 +872,6 @@ abstract class AppLocalizations { /// **'Next'** String get app_components_link_nextLayout_label; - /// No description provided for @app_components_textInput_trailingIcon_a11y. - /// - /// In en, this message translates to: - /// **'Trailing icon content description'** - String get app_components_textInput_trailingIcon_a11y; - /// No description provided for @app_about_name_label. /// /// In en, this message translates to: diff --git a/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart b/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart index bc9063d72..8ddaf7d61 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart @@ -406,6 +406,10 @@ class AppLocalizationsAr extends AppLocalizations { String get app_components_text_input_error_label => 'لا يمكن أن يكون هذا الحقل فارغًا.'; + @override + String get app_components_textInput_trailingIcon_a11y => + 'وصف محتوى أيقونة النهاية'; + @override String get app_components_link_label => 'رابط'; @@ -419,10 +423,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get app_components_link_nextLayout_label => 'التالي'; - @override - String get app_components_textInput_trailingIcon_a11y => - 'وصف محتوى أيقونة النهاية'; - @override String get app_about_name_label => 'أداة نظام التصميم'; diff --git a/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart b/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart index 0d8a0027a..fd15fe8f8 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart @@ -406,6 +406,10 @@ class AppLocalizationsEn extends AppLocalizations { String get app_components_text_input_error_label => 'This field can’t be empty.'; + @override + String get app_components_textInput_trailingIcon_a11y => + 'Trailing icon content description'; + @override String get app_components_link_label => 'Link'; @@ -419,10 +423,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get app_components_link_nextLayout_label => 'Next'; - @override - String get app_components_textInput_trailingIcon_a11y => - 'Trailing icon content description'; - @override String get app_about_name_label => 'Design System Toolbox'; From c28db925b9c86f90eec4bd50b9eee66bd9420227 Mon Sep 17 00:00:00 2001 From: nhammami Date: Tue, 25 Nov 2025 17:05:10 +0100 Subject: [PATCH 5/5] chore: fix merge --- .../ouds_flutter_app_localizations_ar.dart | 4 - .../ouds_flutter_app_localizations_en.dart | 4 - .../form_input/ouds_text_input.dart | 11 +- .../text_input/ouds_text_input.dart | 635 ------------------ 4 files changed, 7 insertions(+), 647 deletions(-) delete mode 100644 ouds_core/lib/components/text_input/ouds_text_input.dart diff --git a/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart b/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart index 88cf1ea34..6455cee16 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_ar.dart @@ -422,10 +422,6 @@ class AppLocalizationsAr extends AppLocalizations { @override String get app_components_text_input_helperLinkText_label => 'رابط المساعدة'; - @override - String get app_components_textInput_trailingIcon_a11y => - 'وصف محتوى أيقونة النهاية'; - @override String get app_components_link_label => 'رابط'; diff --git a/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart b/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart index 227dafa58..88a106de6 100644 --- a/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart +++ b/app/lib/l10n/gen/ouds_flutter_app_localizations_en.dart @@ -422,10 +422,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get app_components_text_input_helperLinkText_label => 'Helper link'; - @override - String get app_components_textInput_trailingIcon_a11y => - 'Trailing icon content description'; - @override String get app_components_link_label => 'Link'; diff --git a/ouds_core/lib/components/form_input/ouds_text_input.dart b/ouds_core/lib/components/form_input/ouds_text_input.dart index cf8482994..756b9f3ef 100644 --- a/ouds_core/lib/components/form_input/ouds_text_input.dart +++ b/ouds_core/lib/components/form_input/ouds_text_input.dart @@ -240,8 +240,8 @@ class _OudsTextInputState extends State { final statusLabel = !isEnabled ? l10n?.core_common_disable_a11y ?? "" : isReadOnly - ? l10n?.core_common_disable_a11y ?? "" - : ""; + ? l10n?.core_common_disable_a11y ?? "" + : ""; // Build Semantics value final semanticsValue = [ @@ -252,12 +252,15 @@ class _OudsTextInputState extends State { suffixText, helperText, statusLabel, - ].where((s) => s != null && s.isNotEmpty).join(", "); + ] + .where((s) => s != null && s.isNotEmpty) + .join(", "); + return Semantics( - textField: true, label: semanticsValue, hint: widget.decoration.hintText, + value: isError ? l10n?.core_common_onError_a11y : null, focused: effectiveFocusNode != null, focusable: true, child: Container( diff --git a/ouds_core/lib/components/text_input/ouds_text_input.dart b/ouds_core/lib/components/text_input/ouds_text_input.dart deleted file mode 100644 index 8249414a6..000000000 --- a/ouds_core/lib/components/text_input/ouds_text_input.dart +++ /dev/null @@ -1,635 +0,0 @@ -// Software Name: OUDS Flutter -// SPDX-FileCopyrightText: Copyright (c) Orange SA -// SPDX-License-Identifier: MIT -// -// This software is distributed under the MIT license, -// the text of which is available at https://opensource.org/license/MIT/ -// or see the "LICENSE" file for more details. -// -// Software description: Flutter library of reusable graphical components -// - -/// OudsTextField -library; - -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:ouds_core/components/button/ouds_button.dart'; -import 'package:ouds_core/components/text_input/internal/modifier/ouds_text_input_background_modifier.dart'; -import 'package:ouds_core/components/text_input/internal/modifier/ouds_text_input_border_modifier.dart'; -import 'package:ouds_core/components/text_input/internal/modifier/ouds_text_input_foreground_modifier.dart'; -import 'package:ouds_core/components/text_input/internal/modifier/ouds_text_input_text_modifier.dart'; -import 'package:ouds_core/components/text_input/internal/ouds_text_input_control_state.dart'; -import 'package:ouds_core/components/utilities/app_assets.dart'; -import 'package:ouds_core/l10n/gen/ouds_localizations.dart'; -import 'package:ouds_theme_contract/config/ouds_theme_config_model.dart'; -import 'package:ouds_theme_contract/ouds_theme.dart'; -import 'package:ouds_core/components/utilities/input_utils.dart'; - -/// The [OudsTextInputStyle] defines the style visual behavior and feedback. -enum OudsTextInputStyle { - defaultStyle, - alternative, -} - -/// Configuration for decorating the [OudsTextInput] widget. -/// -/// Provides properties to customize labels, hints, icons, helper and error texts, -/// loading states, and styling. -/// -/// Parameters: -/// -/// - [labelText]: The main label text displayed above or inside the input field. -/// -/// - [helperText]: Additional information displayed below the input, -/// often used to guide or assist the user. -/// -/// - [hintText]: A short placeholder or hint shown inside the input when empty, -/// describing the expected input. -/// -/// - [suffixIcon]: A widget displayed at the end of the input field, -/// commonly used for actions like clearing or toggling visibility. -/// -/// - [prefixIcon]: The name or path of an icon displayed at the start of the input field, -/// typically to indicate the type or purpose of input. -/// -/// - [prefix]: A string displayed before the user's input, usually static text or units. -/// -/// - [suffix]: A string displayed after the user's input, often used for units or context. -/// -/// - [errorText]: Text shown below the input indicating an error state or invalid input. -/// -/// - [loader]: When true, displays a loading indicator inside the input. -/// -/// - [style]: The visual style of the input, e.g., default or alternative styles. -class OudsInputDecoration { - final String? labelText; - final String? helperText; - final String? hintText; - final String? suffixIcon; - final String? prefixIcon; - final String? prefix; - final String? suffix; - final String? errorText; - final bool? loader; - final OudsTextInputStyle? style; - - const OudsInputDecoration({ - this.labelText, - this.helperText, - this.hintText, - this.suffixIcon, - this.prefixIcon, - this.prefix, - this.suffix, - this.errorText, - this.loader, - this.style = OudsTextInputStyle.defaultStyle, - }); -} - -/// Alias class for [OudsTextInput]. -/// -/// This class provides a shorter and more convenient name, [OudsTextField], -/// which internally extends [OudsTextInput]. It inherits all properties and behaviors, -/// allowing you to use [OudsTextField] as a drop-in replacement for [OudsTextInput]. -class OudsTextField extends OudsTextInput { - /// Creates an instance of [OudsTextField], which is an alias for [OudsTextInput]. - /// - /// All parameters are forwarded to the superclass [OudsTextInput]. - /// - /// [enabled] and [readOnly] default to null, allowing the superclass to handle defaults. - OudsTextField({ - super.key, - super.controller, - super.focusNode, - super.enabled = null, - super.readOnly = null, - super.keyboardType, - required super.decoration, - }); -} - -// TODO: Add documentation URL once it is available -/// -/// `OudsTextInput` is a customizable text input field that allows users -/// to enter, edit, or read text. -/// -/// This version supports fully configurable styling, including prefix -/// and suffix icons, error states, loading states, and helper or error messages. -/// -/// Accessibility is supported: you can provide a **hint** for screen readers -/// to inform the user of possible actions, for example: -/// _"Placeholder"_. -/// -/// Parameters: -/// - [controller]: The text controller linked to this input. -/// - [focusNode]: The focus node to manage focus state. -/// - [enabled]: Whether the input is enabled. -/// - [readOnly]: Whether the input is read-only. -/// - [keyboardType]: The type of keyboard to display. -/// - [decoration]: An `OudsInputDecoration` object to configure label, -/// - [trailingIconContentDescription]: A semantic label for accessibility trailing icon. -/// -/// ## Simple example: -/// -/// ```dart -/// OudsTextField( -/// controller: myController, -/// decoration: OudsInputDecoration( -/// labelText: 'label', -/// hintText: 'Placeholder', // Accessibility hint -/// prefixIcon: 'assets/ic_heart.svg', -/// ), -/// ); -/// ``` -class OudsTextInput extends StatefulWidget { - final TextEditingController? controller; - final FocusNode? focusNode; - final bool? enabled; - final bool? readOnly; - final TextInputType? keyboardType; - final OudsInputDecoration decoration; - final String? trailingIconContentDescription; - - OudsTextInput({ - super.key, - this.controller, - this.focusNode, - this.enabled = true, - this.readOnly = false, - this.keyboardType, - required this.decoration, - this.trailingIconContentDescription, - }) : assert( - !(decoration.loader == true && decoration.errorText != null), - "Error status for Loading state is not relevant", - ); - - static Widget buildIcon( - BuildContext context, - String assetName, - OudsTextInputControlState controlTextInputState, - bool isError, - ) { - final inputTextForegroundModifier = OudsTextInputForegroundColorModifier(context); - final theme = OudsTheme.of(context); - return SvgPicture.asset( - assetName, - fit: BoxFit.contain, - height: theme.componentsTokens(context).textInput.sizeLeadingIcon, - width: theme.componentsTokens(context).textInput.sizeLeadingIcon, - colorFilter: ColorFilter.mode( - inputTextForegroundModifier.getIconColor(controlTextInputState), - BlendMode.srcIn, - ), - ); - } - - @override - State createState() => _OudsTextInputState(); -} - -class _OudsTextInputState extends State { - final bool _isHovered = false; - bool _isFocused = false; - FocusNode? _internalFocusNode; - - @override - void initState() { - super.initState(); - if (widget.focusNode == null) { - _internalFocusNode = FocusNode(); - _internalFocusNode!.addListener(_handleFocusChange); - } else { - widget.focusNode!.addListener(_handleFocusChange); - } - } - - void _handleFocusChange() { - setState(() { - _isFocused = (widget.focusNode ?? _internalFocusNode)!.hasFocus; - }); - } - - @override - void dispose() { - if (_internalFocusNode != null) { - _internalFocusNode!.removeListener(_handleFocusChange); - _internalFocusNode!.dispose(); - } else { - widget.focusNode?.removeListener(_handleFocusChange); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - // Determine which FocusNode to use: the external one provided via widget.focusNode, - // or the internal one (_internalFocusNode) created internally if no external node is given. - final effectiveFocusNode = widget.focusNode ?? _internalFocusNode; - - // Update the local _isFocused state by checking if the currently effective FocusNode has focus. - _isFocused = (widget.focusNode ?? _internalFocusNode)!.hasFocus; - - // If the input is read-only, override focus state to false to prevent showing focused styles. - // Otherwise, use the actual focus state. - final bool effectiveIsFocused = widget.readOnly ?? false ? false : _isFocused; - - // Determine the current control state (enabled, focused, hovered, loading) - final inputTextStateDeterminer = OudsTextInputControlStateDeterminer( - enabled: widget.enabled ?? true, - isFocused: effectiveIsFocused, - isHovered: _isHovered, - isLoading: widget.decoration.loader ?? false, - isReadOnly: widget.readOnly ?? false, - ); - - // Get the computed state (enabled, focused, hovered, error, etc.) - final state = inputTextStateDeterminer.determineControlState(); - - // Modifiers for background color, text color, and border based on state - final inputTextBackgroundModifier = OudsTextInputBackgroundColorModifier(context); - final inputTextTextModifier = OudsTextInputTextColorModifier(context); - final inputTextBorderModifier = OudsTextInputBorderModifier(context); - - // Theme tokens and reusable styles for text input - final textInput = OudsTheme.of(context).componentsTokens(context).textInput; - final theme = OudsTheme.of(context); - - // Check if the input is currently showing an error - final isError = widget.decoration.errorText != null; - - // Check if rounded borders are enabled in the theme config - final isBorderRadius = OudsThemeConfigModel.of(context)?.textInput?.rounded; - - final l10n = OudsLocalizations.of(context); - - //needed for accessibility - final contentText = widget.controller?.text ?? ""; - final prefixText = contentText.isNotEmpty ? ", ${widget.decoration.prefix ?? ""}" : ""; - final suffixText = contentText.isNotEmpty ? ", ${widget.decoration.suffix ?? ""}" : ""; - final helperText = isError ? widget.decoration.errorText ?? "" : widget.decoration.helperText ?? ""; - - // Determine disabled/readOnly label - final isEnabled = widget.enabled ?? true; - final isReadOnly = widget.readOnly ?? false; - final statusLabel = !isEnabled - ? l10n?.core_common_disable_a11y ?? "" - : isReadOnly - ? l10n?.core_common_disable_a11y ?? "" - : ""; - - // Build Semantics value - final semanticsValue = [ - l10n?.core_text_input_input_a11y, - widget.decoration.labelText, - prefixText, - contentText, - suffixText, - helperText, - statusLabel, - ] - .where((s) => s != null && s.isNotEmpty) - .join(", "); - - return Semantics( - label: semanticsValue, - value: isError ? l10n?.core_common_onError_a11y : null, - hint: widget.decoration.hintText ?? "", - focused: effectiveFocusNode != null, - focusable: true, - child: Container( - constraints: BoxConstraints( - minWidth: textInput.sizeMinWidth, - maxWidth: textInput.sizeMaxWidth, - minHeight: textInput.sizeMinHeight, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DecoratedBox( - decoration: BoxDecoration( - // Background color based on current state and error presence - color: inputTextBackgroundModifier.getBackgroundColor(state, isError, widget.decoration.style), - - /// Bottom border styling; full border if style is not default - border: inputTextBorderModifier.getBorder(state, isError, widget.decoration.style), - - // Border radius if enabled in theme configuration - borderRadius: inputTextBorderModifier.getBorderRadius(context, isBorderRadius), - ), - child: ConstrainedBox( - // Minimum height constraint for the input container - constraints: BoxConstraints(minHeight: textInput.sizeMinHeight), - - /// Padding inside the text input container - child: Padding( - padding: EdgeInsetsGeometry.directional( - start: textInput.spacePaddingInlineDefault, - end: (widget.decoration.suffixIcon != null || widget.decoration.errorText != null || widget.decoration.loader != null) ? textInput.spacePaddingInlineTrailingAction : textInput.spacePaddingInlineDefault, - top: textInput.spacePaddingBlockDefault, - bottom: textInput.spacePaddingBlockDefault, - ), - child: Row( - children: [ - /// Left block: prefix icon container - ExcludeSemantics( - child: Container( - alignment: Alignment.center, - child: _buildPrefixIcon(context, state), - ), - ), - - /// Center block: main text input - Expanded( - child: ExcludeSemantics( - child: Container( - alignment: Alignment.center, - child: TextField( - cursorColor: inputTextTextModifier.getCursorTextColor(state, isError), - focusNode: effectiveFocusNode, - controller: widget.controller, - keyboardType: widget.keyboardType, - style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( - color: inputTextTextModifier.getTextColor(state, isError), - ), - enabled: widget.enabled, - readOnly: widget.readOnly ?? false, - decoration: InputDecoration( - border: InputBorder.none, - // Label text widget, shown if labelText is provided - label: widget.decoration.labelText != null - ? Container( - constraints: BoxConstraints( - maxHeight: textInput.sizeLabelMaxHeight - ), - child: Text( - maxLines: - InputUtils.getLabelMaxLines( - decoration : widget.decoration, - controller: widget.controller, - isFocused: effectiveIsFocused), - overflow: TextOverflow.ellipsis, - widget.decoration.labelText ?? "", - style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( - color: inputTextTextModifier.getTextColor(state, isError), - ), - ), - ) - : null, - - // Floating label behavior: always float if both labelText and hintText are provided - floatingLabelBehavior: (widget.decoration.labelText != null && widget.decoration.hintText != null) ? FloatingLabelBehavior.always : null, - - // Hint text widget, shown if hintText is provided - hint: widget.decoration.hintText != null - ? Text( - maxLines : 1, - overflow: TextOverflow.ellipsis, - widget.decoration.hintText!, - style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( - color: inputTextTextModifier.getHintTextColor(state), - ), - ) - : null, - - // Prefix widget displayed when prefix and labelText are both set - prefix: widget.decoration.prefix != null && widget.decoration.labelText != null - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.decoration.prefix!, - style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( - color: inputTextTextModifier.getSuffixPrefixTextColor(state), - ), - ), - SizedBox(width: textInput.spaceColumnGapInlineText), - ], - ) - : null, - - // Override default constraints to better fit OUDS design - prefixIconConstraints: BoxConstraints(minWidth: 0, minHeight: 0), - - // Suffix widget displayed when suffix and labelText are both set - suffix: widget.decoration.suffix != null && widget.decoration.labelText != null - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(width: textInput.spaceColumnGapInlineText), - Text( - widget.decoration.suffix!, - style: theme.typographyTokens.typeLabelDefaultLarge(context).copyWith( - color: inputTextTextModifier.getSuffixPrefixTextColor(state), - ), - ), - ], - ) - : null, - isDense: true, - ), - ), - ), - ), - ), - - /// Right block: suffix icon container - Container( - alignment: Alignment.center, - child: Semantics( - label: widget.decoration.suffixIcon != null && widget.decoration.loader == false ? widget.trailingIconContentDescription : "", - container: true, - button: true, - child: _buildSuffixIcon(context, state) - ), - ), - ], - ), - ), - ), - ), - - /// Display helper text or error text if available - if (widget.decoration.helperText != null || widget.decoration.errorText != null) ...[ - ExcludeSemantics(child: _buildHelperOrErrorText(context, state, isError == true)), - ], - ], - ), - ), - ); - } - - /// Returns a Text widget displaying either the error text or the helper text. - /// - /// - Shows `widget.errorText` if [isError] is true. - /// - Otherwise, shows `widget.helperText`. - /// - Returns an empty widget if neither text is provided. - /// - /// Applies the appropriate style and color based on the input state. - /// - /// Param [context]: The BuildContext. - /// Param [state]: The current control state of the text input (focused, hovered, etc.). - /// Param [isError]: A boolean indicating whether the input is in an error state. - Widget _buildHelperOrErrorText(BuildContext context, OudsTextInputControlState state, bool isError) { - final theme = OudsTheme.of(context); - final textInput = theme.componentsTokens(context).textInput; - final inputTextTextModifier = OudsTextInputTextColorModifier(context); - - // Select the text to display: prioritize error text over helper text - final String? text = isError ? widget.decoration.errorText : widget.decoration.helperText; - - // Return an empty widget if no text is provided - if (text == null) return SizedBox.shrink(); - - // Return the Text widget with proper color and padding - return Padding( - padding: EdgeInsets.only( - top: textInput.spacePaddingBlockTopHelperText, - left: textInput.spacePaddingInlineDefault, - right: textInput.spacePaddingInlineDefault, - ), - child: Text( - text, - style: theme.typographyTokens.typeLabelDefaultMedium(context).copyWith( - color: inputTextTextModifier.getHelperTextColor(state, isError), - ), - ), - ); - } - - /// Builds the suffix widget for the text input field based on the current decoration state. - /// - /// This method determines what appears in the suffix position of the text input, - /// depending on the combination of `loader`, `suffixIcon`, and `errorText` properties - /// from [widget.decoration]. - /// - /// Cases handled: - /// - /// 1. **Loader active** (`loader == true`): - /// - Displays a minimal hierarchy [OudsButton] in loading style. - /// - Uses `suffixIcon` if provided; otherwise, reserves space with an empty 24×24 box. - /// - /// 2. **Suffix icon provided** (`suffixIcon != null`): - /// - Displays the suffix icon inside a minimal hierarchy [OudsButton]. - /// - If `errorText` is set, shows the error alert icon before the suffix button. - /// - /// 3. **Only error state** (`suffixIcon == null && errorText != null`): - /// - Shows the error alert icon inside a minimal hierarchy [OudsButton]. - /// - /// 4. **No suffix** (none of the above conditions match): - /// - Returns `null`, meaning no widget will be displayed in the suffix position. - /// - /// The color of icons adapts based on the current [OudsTextInputControlState]. - /// - /// Param [context] is used to retrieve theme tokens and style modifiers. - /// Param [state] determines visual styles depending on focus, hover, and enabled states. - Widget? _buildSuffixIcon(BuildContext context, OudsTextInputControlState state) { - final theme = OudsTheme.of(context); - final textInput = theme.componentsTokens(context).textInput; - final inputTextForegroundModifier = OudsTextInputForegroundColorModifier(context); - - // Case 1: loader active - if (widget.decoration.loader == true) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(width: textInput.spaceColumnGapDefault), - OudsButton( - hierarchy: OudsButtonHierarchy.minimal, - loader: Loader(progress: null), - onPressed: () {}, - ), - ], - ); - } - - // Case 2: display suffixIcon + optional error icon - if (widget.decoration.suffixIcon != null) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(width: textInput.spaceColumnGapDefault), - if (widget.decoration.errorText != null) ...[ - SvgPicture.asset( - excludeFromSemantics: true, - AppAssets.icons.importantAlert, - package: theme.packageName, - width: theme.componentsTokens(context).button.sizeIconOnly, - height: theme.componentsTokens(context).button.sizeIconOnly, - colorFilter: ColorFilter.mode( - inputTextForegroundModifier.getForegroundColor(state), - BlendMode.srcIn, - ), - ), - SizedBox(width: textInput.spaceColumnGapTrailingErrorAction), - ], - ExcludeSemantics( - child: OudsButton( - hierarchy: OudsButtonHierarchy.minimal, - icon: widget.decoration.suffixIcon, - onPressed: ((widget.enabled ?? true) && !(widget.readOnly ?? false)) ? () {} : null, - ), - ), - ], - ); - } - - // Case 3: only error present - if (widget.decoration.errorText != null) { - return Container( - constraints: BoxConstraints( - minWidth: theme.componentsTokens(context).button.sizeMinWidth, - minHeight: theme.componentsTokens(context).button.sizeMinHeight, - ), - padding: EdgeInsets.all( - theme.componentsTokens(context).button.spaceInsetIconOnly, - ), - child: SvgPicture.asset( - excludeFromSemantics: true, - AppAssets.icons.importantAlert, - package: theme.packageName, - width: theme.componentsTokens(context).button.sizeIconOnly, - height: theme.componentsTokens(context).button.sizeIconOnly, - colorFilter: ColorFilter.mode( - inputTextForegroundModifier.getForegroundColor(state), - BlendMode.srcIn, - ), - ), - ); - } - - // Default: no suffix - return null; - } - - /// Builds the prefix widget for the text input field based on the current decoration state. - /// - /// This method determines what appears in the prefix position of the text input, - /// from [widget.decoration]. - /// - /// The color of icons adapts based on the current [OudsTextInputControlState]. - /// - /// Param [context] is used to retrieve theme tokens and style modifiers. - /// Param [state] determines visual styles depending on focus, hover, and enabled states. - Widget? _buildPrefixIcon(BuildContext context, OudsTextInputControlState state) { - final theme = OudsTheme.of(context); - final textInput = theme.componentsTokens(context).textInput; - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.decoration.prefixIcon != null) ...[ - OudsTextInput.buildIcon( - context, - widget.decoration.prefixIcon!, - state, - false, - ), - SizedBox(width: textInput.spaceColumnGapDefault), - ], - ], - ); - } -}