Skip to content

Commit

Permalink
[MaterialDatePicker][a11y] Improve date input validation feedback
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
paulfthomas authored and dsn5ft committed Jan 5, 2023
1 parent 147463f commit f394903
Show file tree
Hide file tree
Showing 13 changed files with 250 additions and 41 deletions.
58 changes: 30 additions & 28 deletions docs/components/TextField.md

Large diffs are not rendered by default.

Expand Up @@ -100,6 +100,9 @@ public interface DateSelector<S> extends Parcelable {
@NonNull
String getSelectionContentDescription(@NonNull Context context);

@Nullable
String getError();

@StringRes
int getDefaultTitleResId();

Expand Down
Expand Up @@ -52,9 +52,11 @@
import androidx.annotation.StyleRes;
import androidx.annotation.VisibleForTesting;
import androidx.core.util.Pair;
import androidx.core.view.AccessibilityDelegateCompat;
import androidx.core.view.OnApplyWindowInsetsListener;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import com.google.android.material.dialog.InsetDialogOnTouchListener;
import com.google.android.material.internal.CheckableImageButton;
import com.google.android.material.internal.EdgeToEdgeUtils;
Expand Down Expand Up @@ -296,6 +298,16 @@ public void onClick(View v) {
dismiss();
}
});
ViewCompat.setAccessibilityDelegate(
confirmButton,
new AccessibilityDelegateCompat() {
@Override
public void onInitializeAccessibilityNodeInfo(
@NonNull View host, @NonNull AccessibilityNodeInfoCompat info) {
super.onInitializeAccessibilityNodeInfo(host, info);
info.setContentDescription(getDateSelector().getError());
}
});

Button cancelButton = root.findViewById(R.id.cancel_button);
cancelButton.setTag(CANCEL_BUTTON_TAG);
Expand Down
Expand Up @@ -23,6 +23,7 @@
import android.os.Parcel;
import android.os.Parcelable;
import android.text.InputType;
import android.text.TextUtils;
import android.util.DisplayMetrics;
import android.view.LayoutInflater;
import android.view.View;
Expand All @@ -34,6 +35,7 @@
import androidx.annotation.RestrictTo.Scope;
import androidx.core.util.Pair;
import androidx.core.util.Preconditions;
import androidx.core.view.ViewCompat;
import com.google.android.material.internal.ManufacturerUtils;
import com.google.android.material.resources.MaterialAttributes;
import com.google.android.material.textfield.TextInputLayout;
Expand All @@ -50,6 +52,7 @@
@RestrictTo(Scope.LIBRARY_GROUP)
public class RangeDateSelector implements DateSelector<Pair<Long, Long>> {

@Nullable private CharSequence error;
private String invalidRangeStartError;
// "" is not considered an error
private final String invalidRangeEndError = " ";
Expand Down Expand Up @@ -176,6 +179,12 @@ public String getSelectionContentDescription(@NonNull Context context) {
R.string.mtrl_picker_announce_current_range_selection, startPlaceholder, endPlaceholder);
}

@Nullable
@Override
public String getError() {
return TextUtils.isEmpty(error) ? null : error.toString();
}

@Override
public int getDefaultTitleResId() {
return R.string.mtrl_picker_range_header_title;
Expand All @@ -199,6 +208,8 @@ public View onCreateTextInputView(
final TextInputLayout startTextInput =
root.findViewById(R.id.mtrl_picker_text_input_range_start);
final TextInputLayout endTextInput = root.findViewById(R.id.mtrl_picker_text_input_range_end);
startTextInput.setErrorAccessibilityLiveRegion(ViewCompat.ACCESSIBILITY_LIVE_REGION_NONE);
endTextInput.setErrorAccessibilityLiveRegion(ViewCompat.ACCESSIBILITY_LIVE_REGION_NONE);
EditText startEditText = startTextInput.getEditText();
EditText endEditText = endTextInput.getEditText();
if (ManufacturerUtils.isDateInputKeyboardMissingSeparatorCharacters()) {
Expand Down Expand Up @@ -278,16 +289,25 @@ private void updateIfValidTextProposal(
if (proposedTextStart == null || proposedTextEnd == null) {
clearInvalidRange(startTextInput, endTextInput);
listener.onIncompleteSelectionChanged();
return;
}
if (isValidRange(proposedTextStart, proposedTextEnd)) {
} else if (isValidRange(proposedTextStart, proposedTextEnd)) {
selectedStartItem = proposedTextStart;
selectedEndItem = proposedTextEnd;
listener.onSelectionChanged(getSelection());
} else {
setInvalidRange(startTextInput, endTextInput);
listener.onIncompleteSelectionChanged();
}
updateError(startTextInput, endTextInput);
}

private void updateError(@NonNull TextInputLayout start, @NonNull TextInputLayout end) {
if (!TextUtils.isEmpty(start.getError())) {
error = start.getError();
} else if (!TextUtils.isEmpty(end.getError())) {
error = end.getError();
} else {
error = null;
}
}

private void clearInvalidRange(@NonNull TextInputLayout start, @NonNull TextInputLayout end) {
Expand Down
Expand Up @@ -23,6 +23,7 @@
import android.os.Parcel;
import android.os.Parcelable;
import android.text.InputType;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
Expand All @@ -32,6 +33,7 @@
import androidx.annotation.RestrictTo;
import androidx.annotation.RestrictTo.Scope;
import androidx.core.util.Pair;
import androidx.core.view.ViewCompat;
import com.google.android.material.internal.ManufacturerUtils;
import com.google.android.material.resources.MaterialAttributes;
import com.google.android.material.textfield.TextInputLayout;
Expand All @@ -47,6 +49,7 @@
@RestrictTo(Scope.LIBRARY_GROUP)
public class SingleDateSelector implements DateSelector<Long> {

@Nullable private CharSequence error;
@Nullable private Long selectedItem;
@Nullable private SimpleDateFormat textInputFormat;

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

TextInputLayout dateTextInput = root.findViewById(R.id.mtrl_picker_text_input_date);
dateTextInput.setErrorAccessibilityLiveRegion(ViewCompat.ACCESSIBILITY_LIVE_REGION_NONE);
EditText dateEditText = dateTextInput.getEditText();
if (ManufacturerUtils.isDateInputKeyboardMissingSeparatorCharacters()) {
// Using the URI variation places the '/' and '.' in more prominent positions
Expand Down Expand Up @@ -135,11 +139,13 @@ void onValidDate(@Nullable Long day) {
} else {
select(day);
}
error = null;
listener.onSelectionChanged(getSelection());
}

@Override
void onInvalidDate() {
error = dateTextInput.getError();
listener.onIncompleteSelectionChanged();
}
});
Expand Down Expand Up @@ -177,6 +183,12 @@ public String getSelectionContentDescription(@NonNull Context context) {
return res.getString(R.string.mtrl_picker_announce_current_selection, placeholder);
}

@Nullable
@Override
public String getError() {
return TextUtils.isEmpty(error) ? null : error.toString();
}

@Override
public int getDefaultTitleResId() {
return R.string.mtrl_picker_date_header_title;
Expand Down
Expand Up @@ -119,6 +119,7 @@ final class IndicatorViewController {
private boolean errorEnabled;
@Nullable private TextView errorView;
@Nullable private CharSequence errorViewContentDescription;
private int errorViewAccessibilityLiveRegion;
private int errorTextAppearance;
@Nullable private ColorStateList errorViewTextColor;

Expand Down Expand Up @@ -501,8 +502,8 @@ void setErrorEnabled(boolean enabled) {
setErrorTextAppearance(errorTextAppearance);
setErrorViewTextColor(errorViewTextColor);
setErrorContentDescription(errorViewContentDescription);
setErrorAccessibilityLiveRegion(errorViewAccessibilityLiveRegion);
errorView.setVisibility(View.INVISIBLE);
ViewCompat.setAccessibilityLiveRegion(errorView, ViewCompat.ACCESSIBILITY_LIVE_REGION_POLITE);
addIndicator(errorView, ERROR_INDEX);
} else {
hideError();
Expand Down Expand Up @@ -658,11 +659,22 @@ void setErrorContentDescription(@Nullable final CharSequence errorContentDescrip
}
}

void setErrorAccessibilityLiveRegion(final int accessibilityLiveRegion) {
this.errorViewAccessibilityLiveRegion = accessibilityLiveRegion;
if (errorView != null) {
ViewCompat.setAccessibilityLiveRegion(errorView, accessibilityLiveRegion);
}
}

@Nullable
CharSequence getErrorContentDescription() {
return errorViewContentDescription;
}

int getErrorAccessibilityLiveRegion() {
return errorViewAccessibilityLiveRegion;
}

@ColorInt
int getHelperTextViewCurrentTextColor() {
return helperTextView != null ? helperTextView.getCurrentTextColor() : -1;
Expand Down
Expand Up @@ -614,6 +614,10 @@ public TextInputLayout(@NonNull Context context, @Nullable AttributeSet attrs, i
a.getResourceId(R.styleable.TextInputLayout_errorTextAppearance, 0);
final CharSequence errorContentDescription =
a.getText(R.styleable.TextInputLayout_errorContentDescription);
final int errorAccessibilityLiveRegion =
a.getInt(
R.styleable.TextInputLayout_errorAccessibilityLiveRegion,
ViewCompat.ACCESSIBILITY_LIVE_REGION_POLITE);
final boolean errorEnabled = a.getBoolean(R.styleable.TextInputLayout_errorEnabled, false);

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

setErrorContentDescription(errorContentDescription);
setErrorAccessibilityLiveRegion(errorAccessibilityLiveRegion);

setCounterOverflowTextAppearance(counterOverflowTextAppearance);
setHelperTextTextAppearance(helperTextTextAppearance);
Expand Down Expand Up @@ -2071,6 +2076,25 @@ public CharSequence getErrorContentDescription() {
return indicatorViewController.getErrorContentDescription();
}

/**
* Sets an accessibility live region for the error message.
*
* @param errorAccessibilityLiveRegion Accessibility live region to set
* @attr ref com.google.android.material.R.styleable#TextInputLayout_errorAccessibilityLiveRegion
*/
public void setErrorAccessibilityLiveRegion(final int errorAccessibilityLiveRegion) {
indicatorViewController.setErrorAccessibilityLiveRegion(errorAccessibilityLiveRegion);
}

/**
* Returns the accessibility live region of the error message.
*
* @see #setErrorAccessibilityLiveRegion(int)
*/
public int getErrorAccessibilityLiveRegion() {
return indicatorViewController.getErrorAccessibilityLiveRegion();
}

/**
* Sets an error message that will be displayed below our {@link EditText}. If the {@code error}
* is {@code null}, the error message will be cleared.
Expand Down
Expand Up @@ -53,6 +53,7 @@
<public name="errorTextAppearance" type="attr"/>
<public name="errorTextColor" type="attr"/>
<public name="errorContentDescription" type="attr"/>
<public name="errorAccessibilityLiveRegion" type="attr"/>
<public name="errorIconDrawable" type="attr"/>
<public name="errorIconTint" type="attr"/>
<public name="errorIconTintMode" type="attr"/>
Expand Down
Expand Up @@ -92,6 +92,8 @@
Should be set when the error message has special characters that a
screen reader is not able to announce properly. -->
<attr name="errorContentDescription" format="string"/>
<!-- AccessibilityLiveRegion of any error message displayed. -->
<attr name="errorAccessibilityLiveRegion" format="integer"/>
<!-- End icon to be shown when an error is displayed. -->
<attr name="errorIconDrawable" format="reference"/>
<!-- Tint color to use for the error icon. -->
Expand Down
Expand Up @@ -305,6 +305,55 @@ public void getSelectionContentDescription_startNotEmpty_endNotEmpty_returnsStar
assertThat(contentDescription).isEqualTo(expected);
}

@Test
public void getError_emptyDates_isNull() {
assertThat(rangeDateSelector.getError()).isNull();
}

@Test
public void getError_validStartDate_isNull() {
View root = getRootView();
TextInputLayout startTextInput = root.findViewById(R.id.mtrl_picker_text_input_range_start);
activity.setContentView(root);
startTextInput.getEditText().setText("1/1/11");
ShadowLooper.runUiThreadTasksIncludingDelayedTasks();

assertThat(rangeDateSelector.getError()).isNull();
}

@Test
public void getError_validEndDate_isNull() {
View root = getRootView();
TextInputLayout endTextInput = root.findViewById(R.id.mtrl_picker_text_input_range_end);
activity.setContentView(root);
endTextInput.getEditText().setText("1/1/11");
ShadowLooper.runUiThreadTasksIncludingDelayedTasks();

assertThat(rangeDateSelector.getError()).isNull();
}

@Test
public void getError_invalidStartDate_isNotEmpty() {
View root = getRootView();
TextInputLayout startTextInput = root.findViewById(R.id.mtrl_picker_text_input_range_start);
activity.setContentView(root);
startTextInput.getEditText().setText("1/1/");
ShadowLooper.runUiThreadTasksIncludingDelayedTasks();

assertThat(rangeDateSelector.getError()).isNotEmpty();
}

@Test
public void getError_invalidEndDate_isNotEmpty() {
View root = getRootView();
TextInputLayout endTextInput = root.findViewById(R.id.mtrl_picker_text_input_range_end);
activity.setContentView(root);
endTextInput.getEditText().setText("1/1/");
ShadowLooper.runUiThreadTasksIncludingDelayedTasks();

assertThat(rangeDateSelector.getError()).isNotEmpty();
}

@Test
public void getSelectedRanges_fullRange() {
Calendar setToStart = UtcDates.getUtcCalendar();
Expand Down
Expand Up @@ -37,6 +37,7 @@
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.shadows.ShadowLooper;

@RunWith(RobolectricTestRunner.class)
public class SingleDateSelectorTest {
Expand Down Expand Up @@ -128,6 +129,33 @@ public void getSelectionContentDescription_notEmpty_returnsDate() {
assertThat(contentDescription).isEqualTo(expected);
}

@Test
public void getError_emptyDate_isNull() {
assertThat(singleDateSelector.getError()).isNull();
}

@Test
public void getError_validDate_isNull() {
View root = getRootView();
((ViewGroup) activity.findViewById(android.R.id.content)).addView(root);
TextInputLayout textInputLayout = root.findViewById(R.id.mtrl_picker_text_input_date);
textInputLayout.getEditText().setText("1/1/11");
ShadowLooper.runUiThreadTasksIncludingDelayedTasks();

assertThat(singleDateSelector.getError()).isNull();
}

@Test
public void getError_invalidDate_isNotEmpty() {
View root = getRootView();
((ViewGroup) activity.findViewById(android.R.id.content)).addView(root);
TextInputLayout textInputLayout = root.findViewById(R.id.mtrl_picker_text_input_date);
textInputLayout.getEditText().setText("1/1/");
ShadowLooper.runUiThreadTasksIncludingDelayedTasks();

assertThat(singleDateSelector.getError()).isNotEmpty();
}

@Test
public void getSelectedRanges_isEmpty() {
Calendar calendar = UtcDates.getUtcCalendar();
Expand Down

0 comments on commit f394903

Please sign in to comment.