Skip to content

Commit f394903

Browse files
paulfthomasdsn5ft
authored andcommitted
[MaterialDatePicker][a11y] Improve date input validation feedback
Resolves #2223 Add `TextInputLayout.setErrorAccessibilityLiveRegion` and `TextInputLayout.getErrorAccessibilityLiveRegion` to allow controlling the way the TextInputLayout error is announced. Example: ``` textInputLayout.setErrorAccessibilityLiveRegion(ViewCompat.ACCESSIBILITY_LIVE_REGION_NONE); ``` PiperOrigin-RevId: 497323465 (cherry picked from commit e1688f3)
1 parent 147463f commit f394903

File tree

13 files changed

+250
-41
lines changed

13 files changed

+250
-41
lines changed

docs/components/TextField.md

Lines changed: 30 additions & 28 deletions
Large diffs are not rendered by default.

lib/java/com/google/android/material/datepicker/DateSelector.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ public interface DateSelector<S> extends Parcelable {
100100
@NonNull
101101
String getSelectionContentDescription(@NonNull Context context);
102102

103+
@Nullable
104+
String getError();
105+
103106
@StringRes
104107
int getDefaultTitleResId();
105108

lib/java/com/google/android/material/datepicker/MaterialDatePicker.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,11 @@
5252
import androidx.annotation.StyleRes;
5353
import androidx.annotation.VisibleForTesting;
5454
import androidx.core.util.Pair;
55+
import androidx.core.view.AccessibilityDelegateCompat;
5556
import androidx.core.view.OnApplyWindowInsetsListener;
5657
import androidx.core.view.ViewCompat;
5758
import androidx.core.view.WindowInsetsCompat;
59+
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
5860
import com.google.android.material.dialog.InsetDialogOnTouchListener;
5961
import com.google.android.material.internal.CheckableImageButton;
6062
import com.google.android.material.internal.EdgeToEdgeUtils;
@@ -296,6 +298,16 @@ public void onClick(View v) {
296298
dismiss();
297299
}
298300
});
301+
ViewCompat.setAccessibilityDelegate(
302+
confirmButton,
303+
new AccessibilityDelegateCompat() {
304+
@Override
305+
public void onInitializeAccessibilityNodeInfo(
306+
@NonNull View host, @NonNull AccessibilityNodeInfoCompat info) {
307+
super.onInitializeAccessibilityNodeInfo(host, info);
308+
info.setContentDescription(getDateSelector().getError());
309+
}
310+
});
299311

300312
Button cancelButton = root.findViewById(R.id.cancel_button);
301313
cancelButton.setTag(CANCEL_BUTTON_TAG);

lib/java/com/google/android/material/datepicker/RangeDateSelector.java

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import android.os.Parcel;
2424
import android.os.Parcelable;
2525
import android.text.InputType;
26+
import android.text.TextUtils;
2627
import android.util.DisplayMetrics;
2728
import android.view.LayoutInflater;
2829
import android.view.View;
@@ -34,6 +35,7 @@
3435
import androidx.annotation.RestrictTo.Scope;
3536
import androidx.core.util.Pair;
3637
import androidx.core.util.Preconditions;
38+
import androidx.core.view.ViewCompat;
3739
import com.google.android.material.internal.ManufacturerUtils;
3840
import com.google.android.material.resources.MaterialAttributes;
3941
import com.google.android.material.textfield.TextInputLayout;
@@ -50,6 +52,7 @@
5052
@RestrictTo(Scope.LIBRARY_GROUP)
5153
public class RangeDateSelector implements DateSelector<Pair<Long, Long>> {
5254

55+
@Nullable private CharSequence error;
5356
private String invalidRangeStartError;
5457
// "" is not considered an error
5558
private final String invalidRangeEndError = " ";
@@ -176,6 +179,12 @@ public String getSelectionContentDescription(@NonNull Context context) {
176179
R.string.mtrl_picker_announce_current_range_selection, startPlaceholder, endPlaceholder);
177180
}
178181

182+
@Nullable
183+
@Override
184+
public String getError() {
185+
return TextUtils.isEmpty(error) ? null : error.toString();
186+
}
187+
179188
@Override
180189
public int getDefaultTitleResId() {
181190
return R.string.mtrl_picker_range_header_title;
@@ -199,6 +208,8 @@ public View onCreateTextInputView(
199208
final TextInputLayout startTextInput =
200209
root.findViewById(R.id.mtrl_picker_text_input_range_start);
201210
final TextInputLayout endTextInput = root.findViewById(R.id.mtrl_picker_text_input_range_end);
211+
startTextInput.setErrorAccessibilityLiveRegion(ViewCompat.ACCESSIBILITY_LIVE_REGION_NONE);
212+
endTextInput.setErrorAccessibilityLiveRegion(ViewCompat.ACCESSIBILITY_LIVE_REGION_NONE);
202213
EditText startEditText = startTextInput.getEditText();
203214
EditText endEditText = endTextInput.getEditText();
204215
if (ManufacturerUtils.isDateInputKeyboardMissingSeparatorCharacters()) {
@@ -278,16 +289,25 @@ private void updateIfValidTextProposal(
278289
if (proposedTextStart == null || proposedTextEnd == null) {
279290
clearInvalidRange(startTextInput, endTextInput);
280291
listener.onIncompleteSelectionChanged();
281-
return;
282-
}
283-
if (isValidRange(proposedTextStart, proposedTextEnd)) {
292+
} else if (isValidRange(proposedTextStart, proposedTextEnd)) {
284293
selectedStartItem = proposedTextStart;
285294
selectedEndItem = proposedTextEnd;
286295
listener.onSelectionChanged(getSelection());
287296
} else {
288297
setInvalidRange(startTextInput, endTextInput);
289298
listener.onIncompleteSelectionChanged();
290299
}
300+
updateError(startTextInput, endTextInput);
301+
}
302+
303+
private void updateError(@NonNull TextInputLayout start, @NonNull TextInputLayout end) {
304+
if (!TextUtils.isEmpty(start.getError())) {
305+
error = start.getError();
306+
} else if (!TextUtils.isEmpty(end.getError())) {
307+
error = end.getError();
308+
} else {
309+
error = null;
310+
}
291311
}
292312

293313
private void clearInvalidRange(@NonNull TextInputLayout start, @NonNull TextInputLayout end) {

lib/java/com/google/android/material/datepicker/SingleDateSelector.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import android.os.Parcel;
2424
import android.os.Parcelable;
2525
import android.text.InputType;
26+
import android.text.TextUtils;
2627
import android.view.LayoutInflater;
2728
import android.view.View;
2829
import android.view.ViewGroup;
@@ -32,6 +33,7 @@
3233
import androidx.annotation.RestrictTo;
3334
import androidx.annotation.RestrictTo.Scope;
3435
import androidx.core.util.Pair;
36+
import androidx.core.view.ViewCompat;
3537
import com.google.android.material.internal.ManufacturerUtils;
3638
import com.google.android.material.resources.MaterialAttributes;
3739
import com.google.android.material.textfield.TextInputLayout;
@@ -47,6 +49,7 @@
4749
@RestrictTo(Scope.LIBRARY_GROUP)
4850
public class SingleDateSelector implements DateSelector<Long> {
4951

52+
@Nullable private CharSequence error;
5053
@Nullable private Long selectedItem;
5154
@Nullable private SimpleDateFormat textInputFormat;
5255

@@ -106,6 +109,7 @@ public View onCreateTextInputView(
106109
View root = layoutInflater.inflate(R.layout.mtrl_picker_text_input_date, viewGroup, false);
107110

108111
TextInputLayout dateTextInput = root.findViewById(R.id.mtrl_picker_text_input_date);
112+
dateTextInput.setErrorAccessibilityLiveRegion(ViewCompat.ACCESSIBILITY_LIVE_REGION_NONE);
109113
EditText dateEditText = dateTextInput.getEditText();
110114
if (ManufacturerUtils.isDateInputKeyboardMissingSeparatorCharacters()) {
111115
// Using the URI variation places the '/' and '.' in more prominent positions
@@ -135,11 +139,13 @@ void onValidDate(@Nullable Long day) {
135139
} else {
136140
select(day);
137141
}
142+
error = null;
138143
listener.onSelectionChanged(getSelection());
139144
}
140145

141146
@Override
142147
void onInvalidDate() {
148+
error = dateTextInput.getError();
143149
listener.onIncompleteSelectionChanged();
144150
}
145151
});
@@ -177,6 +183,12 @@ public String getSelectionContentDescription(@NonNull Context context) {
177183
return res.getString(R.string.mtrl_picker_announce_current_selection, placeholder);
178184
}
179185

186+
@Nullable
187+
@Override
188+
public String getError() {
189+
return TextUtils.isEmpty(error) ? null : error.toString();
190+
}
191+
180192
@Override
181193
public int getDefaultTitleResId() {
182194
return R.string.mtrl_picker_date_header_title;

lib/java/com/google/android/material/textfield/IndicatorViewController.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ final class IndicatorViewController {
119119
private boolean errorEnabled;
120120
@Nullable private TextView errorView;
121121
@Nullable private CharSequence errorViewContentDescription;
122+
private int errorViewAccessibilityLiveRegion;
122123
private int errorTextAppearance;
123124
@Nullable private ColorStateList errorViewTextColor;
124125

@@ -501,8 +502,8 @@ void setErrorEnabled(boolean enabled) {
501502
setErrorTextAppearance(errorTextAppearance);
502503
setErrorViewTextColor(errorViewTextColor);
503504
setErrorContentDescription(errorViewContentDescription);
505+
setErrorAccessibilityLiveRegion(errorViewAccessibilityLiveRegion);
504506
errorView.setVisibility(View.INVISIBLE);
505-
ViewCompat.setAccessibilityLiveRegion(errorView, ViewCompat.ACCESSIBILITY_LIVE_REGION_POLITE);
506507
addIndicator(errorView, ERROR_INDEX);
507508
} else {
508509
hideError();
@@ -658,11 +659,22 @@ void setErrorContentDescription(@Nullable final CharSequence errorContentDescrip
658659
}
659660
}
660661

662+
void setErrorAccessibilityLiveRegion(final int accessibilityLiveRegion) {
663+
this.errorViewAccessibilityLiveRegion = accessibilityLiveRegion;
664+
if (errorView != null) {
665+
ViewCompat.setAccessibilityLiveRegion(errorView, accessibilityLiveRegion);
666+
}
667+
}
668+
661669
@Nullable
662670
CharSequence getErrorContentDescription() {
663671
return errorViewContentDescription;
664672
}
665673

674+
int getErrorAccessibilityLiveRegion() {
675+
return errorViewAccessibilityLiveRegion;
676+
}
677+
666678
@ColorInt
667679
int getHelperTextViewCurrentTextColor() {
668680
return helperTextView != null ? helperTextView.getCurrentTextColor() : -1;

lib/java/com/google/android/material/textfield/TextInputLayout.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,10 @@ public TextInputLayout(@NonNull Context context, @Nullable AttributeSet attrs, i
614614
a.getResourceId(R.styleable.TextInputLayout_errorTextAppearance, 0);
615615
final CharSequence errorContentDescription =
616616
a.getText(R.styleable.TextInputLayout_errorContentDescription);
617+
final int errorAccessibilityLiveRegion =
618+
a.getInt(
619+
R.styleable.TextInputLayout_errorAccessibilityLiveRegion,
620+
ViewCompat.ACCESSIBILITY_LIVE_REGION_POLITE);
617621
final boolean errorEnabled = a.getBoolean(R.styleable.TextInputLayout_errorEnabled, false);
618622

619623
final int helperTextTextAppearance =
@@ -636,6 +640,7 @@ public TextInputLayout(@NonNull Context context, @Nullable AttributeSet attrs, i
636640
a.getInt(R.styleable.TextInputLayout_boxBackgroundMode, BOX_BACKGROUND_NONE));
637641

638642
setErrorContentDescription(errorContentDescription);
643+
setErrorAccessibilityLiveRegion(errorAccessibilityLiveRegion);
639644

640645
setCounterOverflowTextAppearance(counterOverflowTextAppearance);
641646
setHelperTextTextAppearance(helperTextTextAppearance);
@@ -2071,6 +2076,25 @@ public CharSequence getErrorContentDescription() {
20712076
return indicatorViewController.getErrorContentDescription();
20722077
}
20732078

2079+
/**
2080+
* Sets an accessibility live region for the error message.
2081+
*
2082+
* @param errorAccessibilityLiveRegion Accessibility live region to set
2083+
* @attr ref com.google.android.material.R.styleable#TextInputLayout_errorAccessibilityLiveRegion
2084+
*/
2085+
public void setErrorAccessibilityLiveRegion(final int errorAccessibilityLiveRegion) {
2086+
indicatorViewController.setErrorAccessibilityLiveRegion(errorAccessibilityLiveRegion);
2087+
}
2088+
2089+
/**
2090+
* Returns the accessibility live region of the error message.
2091+
*
2092+
* @see #setErrorAccessibilityLiveRegion(int)
2093+
*/
2094+
public int getErrorAccessibilityLiveRegion() {
2095+
return indicatorViewController.getErrorAccessibilityLiveRegion();
2096+
}
2097+
20742098
/**
20752099
* Sets an error message that will be displayed below our {@link EditText}. If the {@code error}
20762100
* is {@code null}, the error message will be cleared.

lib/java/com/google/android/material/textfield/res-public/values/public.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
<public name="errorTextAppearance" type="attr"/>
5454
<public name="errorTextColor" type="attr"/>
5555
<public name="errorContentDescription" type="attr"/>
56+
<public name="errorAccessibilityLiveRegion" type="attr"/>
5657
<public name="errorIconDrawable" type="attr"/>
5758
<public name="errorIconTint" type="attr"/>
5859
<public name="errorIconTintMode" type="attr"/>

lib/java/com/google/android/material/textfield/res/values/attrs.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@
9292
Should be set when the error message has special characters that a
9393
screen reader is not able to announce properly. -->
9494
<attr name="errorContentDescription" format="string"/>
95+
<!-- AccessibilityLiveRegion of any error message displayed. -->
96+
<attr name="errorAccessibilityLiveRegion" format="integer"/>
9597
<!-- End icon to be shown when an error is displayed. -->
9698
<attr name="errorIconDrawable" format="reference"/>
9799
<!-- Tint color to use for the error icon. -->

lib/javatests/com/google/android/material/datepicker/RangeDateSelectorTest.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,55 @@ public void getSelectionContentDescription_startNotEmpty_endNotEmpty_returnsStar
305305
assertThat(contentDescription).isEqualTo(expected);
306306
}
307307

308+
@Test
309+
public void getError_emptyDates_isNull() {
310+
assertThat(rangeDateSelector.getError()).isNull();
311+
}
312+
313+
@Test
314+
public void getError_validStartDate_isNull() {
315+
View root = getRootView();
316+
TextInputLayout startTextInput = root.findViewById(R.id.mtrl_picker_text_input_range_start);
317+
activity.setContentView(root);
318+
startTextInput.getEditText().setText("1/1/11");
319+
ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
320+
321+
assertThat(rangeDateSelector.getError()).isNull();
322+
}
323+
324+
@Test
325+
public void getError_validEndDate_isNull() {
326+
View root = getRootView();
327+
TextInputLayout endTextInput = root.findViewById(R.id.mtrl_picker_text_input_range_end);
328+
activity.setContentView(root);
329+
endTextInput.getEditText().setText("1/1/11");
330+
ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
331+
332+
assertThat(rangeDateSelector.getError()).isNull();
333+
}
334+
335+
@Test
336+
public void getError_invalidStartDate_isNotEmpty() {
337+
View root = getRootView();
338+
TextInputLayout startTextInput = root.findViewById(R.id.mtrl_picker_text_input_range_start);
339+
activity.setContentView(root);
340+
startTextInput.getEditText().setText("1/1/");
341+
ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
342+
343+
assertThat(rangeDateSelector.getError()).isNotEmpty();
344+
}
345+
346+
@Test
347+
public void getError_invalidEndDate_isNotEmpty() {
348+
View root = getRootView();
349+
TextInputLayout endTextInput = root.findViewById(R.id.mtrl_picker_text_input_range_end);
350+
activity.setContentView(root);
351+
endTextInput.getEditText().setText("1/1/");
352+
ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
353+
354+
assertThat(rangeDateSelector.getError()).isNotEmpty();
355+
}
356+
308357
@Test
309358
public void getSelectedRanges_fullRange() {
310359
Calendar setToStart = UtcDates.getUtcCalendar();

0 commit comments

Comments
 (0)