Skip to content

Commit 757f9f3

Browse files
Material Design Teamdrchen
authored andcommitted
[DatePicker][A11y] Fix TAB keyboard trap and add DPAD month navigation.
This change fixes a keyboard trap in MaterialDatePicker where the TAB key focus was stuck within the month grid. TAB navigation is now limited to the days within the current month, allowing focus to move out of the picker. For navigating between months, this change introduces DPAD (left/right arrow key) navigation. When on the first or last valid day of the month, the arrow keys will navigate to the previous or next month. This CL also prevents keyboard focus from landing on disabled dates. Since GridView does not natively support skipping disabled items, custom logic has been added to find and focus on the nearest valid day during keyboard navigation. Finally, a bug that caused focus to incorrectly jump to a previous, non-visible month during TAB navigation has been fixed. This was caused by RecyclerView's view-recycling mechanism. The solution ensures that only the currently visible month is focusable, preventing focus from moving to off-screen months. PiperOrigin-RevId: 834271529
1 parent 32e9fb2 commit 757f9f3

File tree

7 files changed

+790
-39
lines changed

7 files changed

+790
-39
lines changed

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

Lines changed: 115 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ enum CalendarSelector {
9696
private View dayFrame;
9797
private MaterialButton monthDropSelect;
9898
private AccessibilityManager accessibilityManager;
99+
@Nullable private PagerSnapHelper pagerSnapHelper;
99100

100101
@NonNull
101102
public static <T> MaterialCalendar<T> newInstance(
@@ -228,6 +229,17 @@ public void onDayClick(long day) {
228229
}
229230
}
230231
}
232+
},
233+
new OnMonthNavigationListener() {
234+
@Override
235+
public boolean onMonthNavigationPrevious() {
236+
return handleNavigateToMonthForKeyboard(/* forward= */ false, themedContext);
237+
}
238+
239+
@Override
240+
public boolean onMonthNavigationNext() {
241+
return handleNavigateToMonthForKeyboard(/* forward= */ true, themedContext);
242+
}
231243
});
232244
recyclerView.setAdapter(monthsPagerAdapter);
233245

@@ -242,13 +254,13 @@ public void onDayClick(long day) {
242254
yearSelector.addItemDecoration(createItemDecoration());
243255
}
244256

257+
if (!MaterialDatePicker.isFullscreen(themedContext)) {
258+
pagerSnapHelper = new PagerSnapHelper();
259+
pagerSnapHelper.attachToRecyclerView(recyclerView);
260+
}
245261
if (root.findViewById(R.id.month_navigation_fragment_toggle) != null) {
246262
addActionsToMonthNavigation(root, monthsPagerAdapter);
247263
}
248-
249-
if (!MaterialDatePicker.isFullscreen(themedContext)) {
250-
new PagerSnapHelper().attachToRecyclerView(recyclerView);
251-
}
252264
recyclerView.scrollToPosition(monthsPagerAdapter.getPosition(current));
253265
setUpForAccessibility();
254266
updateAccessibilityPaneTitle(root);
@@ -334,6 +346,44 @@ CalendarConstraints getCalendarConstraints() {
334346
return calendarConstraints;
335347
}
336348

349+
/**
350+
* Handles navigating to the adjacent month in response to keyboard navigation.
351+
*
352+
* <p>This method pages horizontally to switch months in non-fullscreen mode. In fullscreen mode,
353+
* this method returns {@code false} because months are scrolled vertically.
354+
*
355+
* @param forward {@code true} to navigate to the next month, {@code false} to navigate to the
356+
* previous month.
357+
* @param context The context.
358+
* @return {@code true} if the event was handled.
359+
*/
360+
private boolean handleNavigateToMonthForKeyboard(boolean forward, @NonNull Context context) {
361+
if (MaterialDatePicker.isFullscreen(context)) {
362+
return false;
363+
}
364+
365+
// Do not navigate if scroll is in progress. Return true to indicate the event was handled,
366+
// but in practice navigation is ignored during scroll.
367+
if (recyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) {
368+
return true;
369+
}
370+
371+
MonthsPagerAdapter adapter = (MonthsPagerAdapter) recyclerView.getAdapter();
372+
if (adapter == null || current == null) {
373+
return false;
374+
}
375+
376+
int currentItem = adapter.getPosition(current);
377+
int newItem = currentItem + (forward ? 1 : -1);
378+
379+
if (newItem >= 0 && newItem < adapter.getItemCount()) {
380+
adapter.setKeyboardFocusDirection(forward ? View.FOCUS_FORWARD : View.FOCUS_BACKWARD);
381+
setCurrentMonth(adapter.getPageMonth(newItem));
382+
return true;
383+
}
384+
return false;
385+
}
386+
337387
/**
338388
* Changes the currently displayed month to {@code moveTo}.
339389
*
@@ -361,9 +411,22 @@ void setCurrentMonth(Month moveTo) {
361411
postSmoothRecyclerViewScroll(moveToPosition);
362412
}
363413
}
414+
updateCurrentVisibleMonth();
364415
updateNavigationButtonsEnabled(moveToPosition);
365416
}
366417

418+
private void updateCurrentVisibleMonth() {
419+
Context context = getContext();
420+
if (context == null || MaterialDatePicker.isFullscreen(context)) {
421+
return;
422+
}
423+
424+
MonthsPagerAdapter adapter = (MonthsPagerAdapter) recyclerView.getAdapter();
425+
if (adapter != null) {
426+
adapter.setVisibleMonth(current);
427+
}
428+
}
429+
367430
@Nullable
368431
@Override
369432
public DateSelector<S> getDateSelector() {
@@ -379,6 +442,20 @@ interface OnDayClickListener {
379442
void onDayClick(long day);
380443
}
381444

445+
/**
446+
* Listener for month navigation events.
447+
*
448+
* <p>This listener is used by {@link MaterialCalendarGridView} to signal when keyboard navigation
449+
* reaches the start or end of a month, allowing the calendar to scroll to the previous or next
450+
* month.
451+
*/
452+
interface OnMonthNavigationListener {
453+
454+
boolean onMonthNavigationPrevious();
455+
456+
boolean onMonthNavigationNext();
457+
}
458+
382459
/** Returns the pixel height of each {@link android.view.View} representing a day. */
383460
@Px
384461
static int getDayHeight(@NonNull Context context) {
@@ -472,18 +549,34 @@ public void onInitializeAccessibilityNodeInfo(
472549
new OnScrollListener() {
473550
@Override
474551
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
475-
int currentItem;
476-
if (dx < 0) {
477-
currentItem = getLayoutManager().findFirstVisibleItemPosition();
478-
} else {
479-
currentItem = getLayoutManager().findLastVisibleItemPosition();
552+
int position =
553+
dx < 0
554+
? getLayoutManager().findFirstVisibleItemPosition()
555+
: getLayoutManager().findLastVisibleItemPosition();
556+
if (pagerSnapHelper == null) {
557+
current = monthsPagerAdapter.getPageMonth(position);
558+
}
559+
monthDropSelect.setText(monthsPagerAdapter.getPageTitle(position));
560+
updateNavigationButtonsEnabled(position);
561+
}
562+
563+
@Override
564+
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
565+
if (newState != RecyclerView.SCROLL_STATE_IDLE || pagerSnapHelper == null) {
566+
return;
480567
}
481-
Month moveToMonth = monthsPagerAdapter.getPageMonth(currentItem);
482-
current = moveToMonth;
483-
monthDropSelect.setText(monthsPagerAdapter.getPageTitle(currentItem));
484568

485-
int currentMonthPosition = monthsPagerAdapter.getPosition(moveToMonth);
486-
updateNavigationButtonsEnabled(currentMonthPosition);
569+
// If horizontal mode, find the snapped view and set it as the current month.
570+
View snapView = pagerSnapHelper.findSnapView(getLayoutManager());
571+
if (snapView != null) {
572+
int snapPosition = recyclerView.getChildAdapterPosition(snapView);
573+
if (snapPosition != RecyclerView.NO_POSITION) {
574+
current = monthsPagerAdapter.getPageMonth(snapPosition);
575+
monthDropSelect.setText(monthsPagerAdapter.getPageTitle(snapPosition));
576+
updateNavigationButtonsEnabled(snapPosition);
577+
}
578+
}
579+
updateCurrentVisibleMonth();
487580
}
488581
});
489582

@@ -500,6 +593,7 @@ public void onClick(View view) {
500593
@Override
501594
public void onClick(View view) {
502595
int currentItem = getLayoutManager().findFirstVisibleItemPosition();
596+
monthsPagerAdapter.setKeyboardFocusDirection(View.FOCUS_FORWARD);
503597
setCurrentMonth(monthsPagerAdapter.getPageMonth(currentItem + 1));
504598
}
505599
});
@@ -508,6 +602,7 @@ public void onClick(View view) {
508602
@Override
509603
public void onClick(View view) {
510604
int currentItem = getLayoutManager().findLastVisibleItemPosition();
605+
monthsPagerAdapter.setKeyboardFocusDirection(View.FOCUS_BACKWARD);
511606
setCurrentMonth(monthsPagerAdapter.getPageMonth(currentItem - 1));
512607
}
513608
});
@@ -517,8 +612,12 @@ public void onClick(View view) {
517612
}
518613

519614
private void updateNavigationButtonsEnabled(int currentMonthPosition) {
520-
monthNext.setEnabled(currentMonthPosition + 1 < recyclerView.getAdapter().getItemCount());
521-
monthPrev.setEnabled(currentMonthPosition - 1 >= 0);
615+
if (monthNext != null) {
616+
monthNext.setEnabled(currentMonthPosition + 1 < recyclerView.getAdapter().getItemCount());
617+
}
618+
if (monthPrev != null) {
619+
monthPrev.setEnabled(currentMonthPosition - 1 >= 0);
620+
}
522621
}
523622

524623
private void postSmoothRecyclerViewScroll(final int position) {

0 commit comments

Comments
 (0)