Skip to content

Commit 3b309bd

Browse files
author
Jonah Williams
authored
Add a localization for counter text, separate into own semantic node (flutter#21029)
1 parent 1c2d3f3 commit 3b309bd

File tree

84 files changed

+889
-87
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

84 files changed

+889
-87
lines changed

packages/flutter/lib/src/material/input_decorator.dart

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1813,10 +1813,14 @@ class _InputDecoratorState extends State<InputDecorator> with TickerProviderStat
18131813
);
18141814

18151815
final Widget counter = decoration.counterText == null ? null :
1816-
new Text(
1817-
decoration.counterText,
1818-
style: _getHelperStyle(themeData).merge(decoration.counterStyle),
1819-
overflow: TextOverflow.ellipsis,
1816+
new Semantics(
1817+
container: true,
1818+
child: new Text(
1819+
decoration.counterText,
1820+
style: _getHelperStyle(themeData).merge(decoration.counterStyle),
1821+
overflow: TextOverflow.ellipsis,
1822+
semanticsLabel: decoration.semanticCounterText,
1823+
),
18201824
);
18211825

18221826
// The _Decoration widget and _RenderDecoration assume that contentPadding
@@ -1940,6 +1944,7 @@ class InputDecoration {
19401944
this.enabledBorder,
19411945
this.border,
19421946
this.enabled = true,
1947+
this.semanticCounterText,
19431948
}) : assert(enabled != null),
19441949
assert(!(prefix != null && prefixText != null), 'Declaring both prefix and prefixText is not allowed'),
19451950
assert(!(suffix != null && suffixText != null), 'Declaring both suffix and suffixText is not allowed'),
@@ -1983,7 +1988,8 @@ class InputDecoration {
19831988
focusedBorder = null,
19841989
focusedErrorBorder = null,
19851990
disabledBorder = null,
1986-
enabledBorder = null;
1991+
enabledBorder = null,
1992+
semanticCounterText = null;
19871993

19881994
/// An icon to show before the input field and outside of the decoration's
19891995
/// container.
@@ -2197,6 +2203,8 @@ class InputDecoration {
21972203
///
21982204
/// Rendered using [counterStyle]. Uses [helperStyle] if [counterStyle] is
21992205
/// null.
2206+
///
2207+
/// The semantic label can be replaced by providing a [semanticCounterText].
22002208
final String counterText;
22012209

22022210
/// The style to use for the [counterText].
@@ -2380,6 +2388,13 @@ class InputDecoration {
23802388
/// This property is true by default.
23812389
final bool enabled;
23822390

2391+
/// A semantic label for the [counterText].
2392+
///
2393+
/// Defaults to null.
2394+
///
2395+
/// If provided, this replaces the semantic label of the [counterText].
2396+
final String semanticCounterText;
2397+
23832398
/// Creates a copy of this input decoration with the given fields replaced
23842399
/// by the new values.
23852400
///
@@ -2416,6 +2431,7 @@ class InputDecoration {
24162431
InputBorder enabledBorder,
24172432
InputBorder border,
24182433
bool enabled,
2434+
String semanticCounterText,
24192435
}) {
24202436
return new InputDecoration(
24212437
icon: icon ?? this.icon,
@@ -2449,6 +2465,7 @@ class InputDecoration {
24492465
enabledBorder: enabledBorder ?? this.enabledBorder,
24502466
border: border ?? this.border,
24512467
enabled: enabled ?? this.enabled,
2468+
semanticCounterText: semanticCounterText ?? this.semanticCounterText,
24522469
);
24532470
}
24542471

@@ -2518,7 +2535,8 @@ class InputDecoration {
25182535
&& typedOther.disabledBorder == disabledBorder
25192536
&& typedOther.enabledBorder == enabledBorder
25202537
&& typedOther.border == border
2521-
&& typedOther.enabled == enabled;
2538+
&& typedOther.enabled == enabled
2539+
&& typedOther.semanticCounterText == semanticCounterText;
25222540
}
25232541

25242542
@override
@@ -2565,6 +2583,7 @@ class InputDecoration {
25652583
enabledBorder,
25662584
border,
25672585
enabled,
2586+
semanticCounterText,
25682587
),
25692588
);
25702589
}
@@ -2630,6 +2649,8 @@ class InputDecoration {
26302649
description.add('border: $border');
26312650
if (!enabled)
26322651
description.add('enabled: false');
2652+
if (semanticCounterText != null)
2653+
description.add('semanticCounterText: $semanticCounterText');
26332654
return 'InputDecoration(${description.join(', ')})';
26342655
}
26352656
}

packages/flutter/lib/src/material/material_localizations.dart

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,9 @@ abstract class MaterialLocalizations {
307307
/// The semantics hint to describe the tap action on a collapsed [ExpandIcon].
308308
String get collapsedIconTapHint => 'Expand';
309309

310+
/// The label for the [TextField]'s character counter.
311+
String remainingTextFieldCharacterCount(int remaining);
312+
310313
/// The `MaterialLocalizations` from the closest [Localizations] instance
311314
/// that encloses the given context.
312315
///
@@ -709,4 +712,16 @@ class DefaultMaterialLocalizations implements MaterialLocalizations {
709712
///
710713
/// [MaterialApp] automatically adds this value to [MaterialApp.localizationsDelegates].
711714
static const LocalizationsDelegate<MaterialLocalizations> delegate = _MaterialLocalizationsDelegate();
715+
716+
@override
717+
String remainingTextFieldCharacterCount(int remaining) {
718+
switch (remaining) {
719+
case 0:
720+
return 'No characters remaining';
721+
case 1:
722+
return '1 character remaining';
723+
default:
724+
return '$remaining characters remaining';
725+
}
726+
}
712727
}

packages/flutter/lib/src/material/text_field.dart

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import 'feedback.dart';
1414
import 'ink_well.dart' show InteractiveInkFeature;
1515
import 'input_decorator.dart';
1616
import 'material.dart';
17+
import 'material_localizations.dart';
1718
import 'text_selection.dart';
1819
import 'theme.dart';
1920

@@ -379,6 +380,7 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
379380
&& widget.decoration.counterText == null;
380381

381382
InputDecoration _getEffectiveDecoration() {
383+
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
382384
final InputDecoration effectiveDecoration = (widget.decoration ?? const InputDecoration())
383385
.applyDefaults(Theme.of(context).inputDecorationTheme)
384386
.copyWith(
@@ -388,17 +390,24 @@ class _TextFieldState extends State<TextField> with AutomaticKeepAliveClientMixi
388390
if (!needsCounter)
389391
return effectiveDecoration;
390392

391-
final String counterText = '${_effectiveController.value.text.runes.length}/${widget.maxLength}';
393+
final int currentLength = _effectiveController.value.text.runes.length;
394+
final String counterText = '$currentLength/${widget.maxLength}';
395+
final int remaining = (widget.maxLength - currentLength).clamp(0, widget.maxLength);
396+
final String semanticCounterText = localizations.remainingTextFieldCharacterCount(remaining);
392397
if (_effectiveController.value.text.runes.length > widget.maxLength) {
393398
final ThemeData themeData = Theme.of(context);
394399
return effectiveDecoration.copyWith(
395400
errorText: effectiveDecoration.errorText ?? '',
396401
counterStyle: effectiveDecoration.errorStyle
397402
?? themeData.textTheme.caption.copyWith(color: themeData.errorColor),
398403
counterText: counterText,
404+
semanticCounterText: semanticCounterText,
399405
);
400406
}
401-
return effectiveDecoration.copyWith(counterText: counterText);
407+
return effectiveDecoration.copyWith(
408+
counterText: counterText,
409+
semanticCounterText: semanticCounterText,
410+
);
402411
}
403412

404413
@override

packages/flutter/test/material/text_field_test.dart

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2580,11 +2580,11 @@ void main() {
25802580
child: new TextField(
25812581
key: key,
25822582
controller: controller,
2583+
maxLength: 10,
25832584
decoration: const InputDecoration(
25842585
labelText: 'label',
25852586
hintText: 'hint',
25862587
helperText: 'helper',
2587-
counterText: 'counter',
25882588
),
25892589
),
25902590
),
@@ -2593,7 +2593,7 @@ void main() {
25932593
expect(semantics, hasSemantics(new TestSemantics.root(
25942594
children: <TestSemantics>[
25952595
new TestSemantics.rootChild(
2596-
label: 'label\nhelper\ncounter',
2596+
label: 'label\nhelper',
25972597
id: 1,
25982598
textDirection: TextDirection.ltr,
25992599
actions: <SemanticsAction>[
@@ -2602,6 +2602,13 @@ void main() {
26022602
flags: <SemanticsFlag>[
26032603
SemanticsFlag.isTextField,
26042604
],
2605+
children: <TestSemantics>[
2606+
new TestSemantics(
2607+
id: 2,
2608+
label: '10 characters remaining',
2609+
textDirection: TextDirection.ltr,
2610+
),
2611+
],
26052612
),
26062613
],
26072614
), ignoreTransform: true, ignoreRect: true));
@@ -2612,7 +2619,7 @@ void main() {
26122619
expect(semantics, hasSemantics(new TestSemantics.root(
26132620
children: <TestSemantics>[
26142621
new TestSemantics.rootChild(
2615-
label: 'hint\nhelper\ncounter',
2622+
label: 'hint\nhelper',
26162623
id: 1,
26172624
textDirection: TextDirection.ltr,
26182625
textSelection: const TextSelection(baseOffset: 0, extentOffset: 0),
@@ -2625,6 +2632,13 @@ void main() {
26252632
SemanticsFlag.isTextField,
26262633
SemanticsFlag.isFocused,
26272634
],
2635+
children: <TestSemantics>[
2636+
new TestSemantics(
2637+
id: 2,
2638+
label: '10 characters remaining',
2639+
textDirection: TextDirection.ltr,
2640+
),
2641+
],
26282642
),
26292643
],
26302644
), ignoreTransform: true, ignoreRect: true));
@@ -2634,4 +2648,49 @@ void main() {
26342648
semantics.dispose();
26352649
});
26362650

2651+
testWidgets('InputDecoration counterText can have a semanticCounterText', (WidgetTester tester) async {
2652+
final SemanticsTester semantics = new SemanticsTester(tester);
2653+
final TextEditingController controller = new TextEditingController();
2654+
final Key key = new UniqueKey();
2655+
2656+
await tester.pumpWidget(
2657+
overlay(
2658+
child: new TextField(
2659+
key: key,
2660+
controller: controller,
2661+
decoration: const InputDecoration(
2662+
labelText: 'label',
2663+
hintText: 'hint',
2664+
helperText: 'helper',
2665+
counterText: '0/10',
2666+
semanticCounterText: '0 out of 10',
2667+
),
2668+
),
2669+
),
2670+
);
2671+
2672+
expect(semantics, hasSemantics(new TestSemantics.root(
2673+
children: <TestSemantics>[
2674+
new TestSemantics.rootChild(
2675+
label: 'label\nhelper',
2676+
id: 1,
2677+
textDirection: TextDirection.ltr,
2678+
actions: <SemanticsAction>[
2679+
SemanticsAction.tap,
2680+
],
2681+
flags: <SemanticsFlag>[
2682+
SemanticsFlag.isTextField,
2683+
],
2684+
children: <TestSemantics>[
2685+
new TestSemantics(
2686+
label: '0 out of 10',
2687+
textDirection: TextDirection.ltr,
2688+
),
2689+
],
2690+
),
2691+
],
2692+
), ignoreTransform: true, ignoreRect: true, ignoreId: true));
2693+
2694+
semantics.dispose();
2695+
});
26372696
}

0 commit comments

Comments
 (0)