From cc36608f14957cd8162a5813741b88aba752c28a Mon Sep 17 00:00:00 2001 From: Darren Austin Date: Mon, 22 Jun 2020 17:08:03 -0700 Subject: [PATCH] Keyboard navigation for the Material Date Picker grid (#59586) --- .../pickers/calendar_date_picker.dart | 383 +++++++++++++----- .../material/pickers/date_picker_dialog.dart | 46 ++- .../lib/src/material/pickers/date_utils.dart | 16 +- .../test/material/date_picker_test.dart | 165 +++++++- 4 files changed, 494 insertions(+), 116 deletions(-) diff --git a/packages/flutter/lib/src/material/pickers/calendar_date_picker.dart b/packages/flutter/lib/src/material/pickers/calendar_date_picker.dart index d1214bc5c50..b92eaf87ad4 100644 --- a/packages/flutter/lib/src/material/pickers/calendar_date_picker.dart +++ b/packages/flutter/lib/src/material/pickers/calendar_date_picker.dart @@ -283,11 +283,9 @@ class _CalendarDatePickerState extends State { Widget build(BuildContext context) { return Stack( children: [ - SingleChildScrollView( - child: SizedBox( - height: _maxDayPickerHeight, - child: _buildPicker(), - ), + SizedBox( + height: _subHeaderHeight + _maxDayPickerHeight, + child: _buildPicker(), ), // Put the mode toggle button on top so that it won't be covered up by the _MonthPicker _DatePickerModeToggleButton( @@ -476,12 +474,17 @@ class _MonthPicker extends StatefulWidget { } class _MonthPickerState extends State<_MonthPicker> { + final GlobalKey _pageViewKey = GlobalKey(); DateTime _currentMonth; DateTime _nextMonthDate; DateTime _previousMonthDate; PageController _pageController; MaterialLocalizations _localizations; TextDirection _textDirection; + Map _shortcutMap; + Map> _actionMap; + FocusNode _dayGridFocus; + DateTime _focusedDay; @override void initState() { @@ -490,6 +493,18 @@ class _MonthPickerState extends State<_MonthPicker> { _previousMonthDate = utils.addMonthsToMonthDate(_currentMonth, -1); _nextMonthDate = utils.addMonthsToMonthDate(_currentMonth, 1); _pageController = PageController(initialPage: utils.monthDelta(widget.firstDate, _currentMonth)); + _shortcutMap = { + LogicalKeySet(LogicalKeyboardKey.arrowLeft): const DirectionalFocusIntent(TraversalDirection.left), + LogicalKeySet(LogicalKeyboardKey.arrowRight): const DirectionalFocusIntent(TraversalDirection.right), + LogicalKeySet(LogicalKeyboardKey.arrowDown): const DirectionalFocusIntent(TraversalDirection.down), + LogicalKeySet(LogicalKeyboardKey.arrowUp): const DirectionalFocusIntent(TraversalDirection.up), + }; + _actionMap = >{ + NextFocusIntent: CallbackAction(onInvoke: _handleGridNextFocus), + PreviousFocusIntent: CallbackAction(onInvoke: _handleGridPreviousFocus), + DirectionalFocusIntent: CallbackAction(onInvoke: _handleDirectionFocus), + }; + _dayGridFocus = FocusNode(debugLabel: 'Day Grid'); } @override @@ -502,19 +517,58 @@ class _MonthPickerState extends State<_MonthPicker> { @override void dispose() { _pageController?.dispose(); + _dayGridFocus.dispose(); super.dispose(); } - void _handleMonthPageChanged(int monthPage) { - final DateTime monthDate = utils.addMonthsToMonthDate(widget.firstDate, monthPage); - if (_currentMonth.year != monthDate.year || _currentMonth.month != monthDate.month) { - _currentMonth = DateTime(monthDate.year, monthDate.month); - _previousMonthDate = utils.addMonthsToMonthDate(_currentMonth, -1); - _nextMonthDate = utils.addMonthsToMonthDate(_currentMonth, 1); - widget.onDisplayedMonthChanged?.call(_currentMonth); - } + void _handleDateSelected(DateTime selectedDate) { + _focusedDay = selectedDate; + widget.onChanged?.call(selectedDate); } + void _handleMonthPageChanged(int monthPage) { + setState(() { + final DateTime monthDate = utils.addMonthsToMonthDate(widget.firstDate, monthPage); + if (!utils.isSameMonth(_currentMonth, monthDate)) { + _currentMonth = DateTime(monthDate.year, monthDate.month); + _previousMonthDate = utils.addMonthsToMonthDate(_currentMonth, -1); + _nextMonthDate = utils.addMonthsToMonthDate(_currentMonth, 1); + widget.onDisplayedMonthChanged?.call(_currentMonth); + if (_focusedDay != null && !utils.isSameMonth(_focusedDay, _currentMonth)) { + // We have navigated to a new month with the grid focused, but the + // focused day is not in this month. Choose a new one trying to keep + // the same day of the month. + _focusedDay = _focusableDayForMonth(_currentMonth, _focusedDay.day); + } + } + }); + } + + /// Returns a focusable date for the given month. + /// + /// If the preferredDay is available in the month it will be returned, + /// otherwise the first selectable day in the month will be returned. If + /// no dates are selectable in the month, then it will return null. + DateTime _focusableDayForMonth(DateTime month, int preferredDay) { + final int daysInMonth = utils.getDaysInMonth(month.year, month.month); + + // Can we use the preferred day in this month? + if (preferredDay <= daysInMonth) { + final DateTime newFocus = DateTime(month.year, month.month, preferredDay); + if (_isSelectable(newFocus)) + return newFocus; + } + + // Start at the 1st and take the first selectable date. + for (int day = 1; day <= daysInMonth; day++) { + final DateTime newFocus = DateTime(month.year, month.month, day); + if (_isSelectable(newFocus)) + return newFocus; + } + return null; + } + + /// Navigate to the next month. void _handleNextMonth() { if (!_isDisplayingLastMonth) { SemanticsService.announce( @@ -528,6 +582,7 @@ class _MonthPickerState extends State<_MonthPicker> { } } + /// Navigate to the previous month. void _handlePreviousMonth() { if (!_isDisplayingFirstMonth) { SemanticsService.announce( @@ -541,6 +596,15 @@ class _MonthPickerState extends State<_MonthPicker> { } } + /// Navigate to the given month. + void _showMonth(DateTime month) { + final int monthPage = utils.monthDelta(widget.firstDate, month); + _pageController.animateToPage(monthPage, + duration: _monthScrollDuration, + curve: Curves.ease + ); + } + /// True if the earliest allowable month is displayed. bool get _isDisplayingFirstMonth { return !_currentMonth.isAfter( @@ -555,13 +619,96 @@ class _MonthPickerState extends State<_MonthPicker> { ); } + /// Handler for when the overall day grid obtains or loses focus. + void _handleGridFocusChange(bool focused) { + setState(() { + if (focused && _focusedDay == null) { + if (utils.isSameMonth(widget.selectedDate, _currentMonth)) { + _focusedDay = widget.selectedDate; + } else if (utils.isSameMonth(widget.currentDate, _currentMonth)) { + _focusedDay = _focusableDayForMonth(_currentMonth, widget.currentDate.day); + } else { + _focusedDay = _focusableDayForMonth(_currentMonth, 1); + } + } + }); + } + + /// Move focus to the next element after the day grid. + void _handleGridNextFocus(NextFocusIntent intent) { + _dayGridFocus.requestFocus(); + _dayGridFocus.nextFocus(); + } + + /// Move focus to the previous element before the day grid. + void _handleGridPreviousFocus(PreviousFocusIntent intent) { + _dayGridFocus.requestFocus(); + _dayGridFocus.previousFocus(); + } + + /// Move the internal focus date in the direction of the given intent. + /// + /// This will attempt to move the focused day to the next selectable day in + /// the given direction. If the new date is not in the current month, then + /// the page view will be scrolled to show the new date's month. + /// + /// For horizontal directions, it will move forward or backward a day (depending + /// on the current [TextDirection]). For vertical directions it will move up and + /// down a week at a time. + void _handleDirectionFocus(DirectionalFocusIntent intent) { + assert(_focusedDay != null); + setState(() { + final DateTime nextDate = _nextDateInDirection(_focusedDay, intent.direction); + if (nextDate != null) { + _focusedDay = nextDate; + if (!utils.isSameMonth(_focusedDay, _currentMonth)) { + _showMonth(_focusedDay); + } + } + }); + } + + static const Map _directionOffset = { + TraversalDirection.up: Duration(days: -DateTime.daysPerWeek), + TraversalDirection.right: Duration(days: 1), + TraversalDirection.down: Duration(days: DateTime.daysPerWeek), + TraversalDirection.left: Duration(days: -1), + }; + + Duration _dayDirectionOffset(TraversalDirection traversalDirection, TextDirection textDirection) { + // Swap left and right if the text direction if RTL + if (textDirection == TextDirection.rtl) { + if (traversalDirection == TraversalDirection.left) + traversalDirection = TraversalDirection.right; + else if (traversalDirection == TraversalDirection.right) + traversalDirection = TraversalDirection.left; + } + return _directionOffset[traversalDirection]; + } + + DateTime _nextDateInDirection(DateTime date, TraversalDirection direction) { + final TextDirection textDirection = Directionality.of(context); + DateTime nextDate = date.toUtc().add(_dayDirectionOffset(direction, textDirection)); + while (!nextDate.isBefore(widget.firstDate) && !nextDate.isAfter(widget.lastDate)) { + if (_isSelectable(nextDate)) { + return nextDate; + } + nextDate = nextDate.add(_dayDirectionOffset(direction, textDirection)); + } + return null; + } + + bool _isSelectable(DateTime date) { + return widget.selectableDayPredicate == null || widget.selectableDayPredicate.call(date); + } + Widget _buildItems(BuildContext context, int index) { final DateTime month = utils.addMonthsToMonthDate(widget.firstDate, index); return _DayPicker( key: ValueKey(month), selectedDate: widget.selectedDate, currentDate: widget.currentDate, - onChanged: widget.onChanged, + onChanged: _handleDateSelected, firstDate: widget.firstDate, lastDate: widget.lastDate, displayedMonth: month, @@ -599,14 +746,26 @@ class _MonthPickerState extends State<_MonthPicker> { ], ), ), - _DayHeaders(), Expanded( - child: PageView.builder( - controller: _pageController, - itemBuilder: _buildItems, - itemCount: utils.monthDelta(widget.firstDate, widget.lastDate) + 1, - scrollDirection: Axis.horizontal, - onPageChanged: _handleMonthPageChanged, + child: FocusableActionDetector( + shortcuts: _shortcutMap, + actions: _actionMap, + focusNode: _dayGridFocus, + onFocusChange: _handleGridFocusChange, + child: _FocusedDate( + date: _dayGridFocus.hasFocus ? _focusedDay : null, + child: Container( + color: _dayGridFocus.hasFocus ? Theme.of(context).focusColor : null, + child: PageView.builder( + key: _pageViewKey, + controller: _pageController, + itemBuilder: _buildItems, + itemCount: utils.monthDelta(widget.firstDate, widget.lastDate) + 1, + scrollDirection: Axis.horizontal, + onPageChanged: _handleMonthPageChanged, + ), + ), + ), ), ), ], @@ -615,11 +774,35 @@ class _MonthPickerState extends State<_MonthPicker> { } } +/// InheritedWidget indicating what the current focused date is for its children. +/// +/// This is used by the [_MonthPicker] to let its children [_DayPicker]s know +/// what the currently focused date (if any) should be. +class _FocusedDate extends InheritedWidget { + const _FocusedDate({ + Key key, + Widget child, + this.date + }) : super(key: key, child: child); + + final DateTime date; + + @override + bool updateShouldNotify(_FocusedDate oldWidget) { + return !utils.isSameDay(date, oldWidget.date); + } + + static DateTime of(BuildContext context) { + final _FocusedDate focusedDate = context.dependOnInheritedWidgetOfExactType<_FocusedDate>(); + return focusedDate?.date; + } +} + /// Displays the days of a given month and allows choosing a day. /// /// The days are arranged in a rectangular grid with one column for each day of /// the week. -class _DayPicker extends StatelessWidget { +class _DayPicker extends StatefulWidget { /// Creates a day picker. _DayPicker({ Key key, @@ -668,11 +851,82 @@ class _DayPicker extends StatelessWidget { /// Optional user supplied predicate function to customize selectable days. final SelectableDayPredicate selectableDayPredicate; + @override + _DayPickerState createState() => _DayPickerState(); +} + +class _DayPickerState extends State<_DayPicker> { + + /// List of [FocusNode]s, one for each day of the month. + List _dayFocusNodes; + + @override + void initState() { + super.initState(); + final int daysInMonth = utils.getDaysInMonth(widget.displayedMonth.year, widget.displayedMonth.month); + _dayFocusNodes = List.generate( + daysInMonth, + (int index) => FocusNode(skipTraversal: true, debugLabel: 'Day ${index + 1}') + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Check to see if the focused date is in this month, if so focus it. + final DateTime focusedDate = _FocusedDate.of(context); + if (focusedDate != null && utils.isSameMonth(widget.displayedMonth, focusedDate)) { + _dayFocusNodes[focusedDate.day - 1].requestFocus(); + } + } + + @override + void dispose() { + for (final FocusNode node in _dayFocusNodes) { + node.dispose(); + } + super.dispose(); + } + + /// Builds widgets showing abbreviated days of week. The first widget in the + /// returned list corresponds to the first day of week for the current locale. + /// + /// Examples: + /// + /// ``` + /// ┌ Sunday is the first day of week in the US (en_US) + /// | + /// S M T W T F S <-- the returned list contains these widgets + /// _ _ _ _ _ 1 2 + /// 3 4 5 6 7 8 9 + /// + /// ┌ But it's Monday in the UK (en_GB) + /// | + /// M T W T F S S <-- the returned list contains these widgets + /// _ _ _ _ 1 2 3 + /// 4 5 6 7 8 9 10 + /// ``` + List _dayHeaders(TextStyle headerStyle, MaterialLocalizations localizations) { + final List result = []; + for (int i = localizations.firstDayOfWeekIndex; true; i = (i + 1) % 7) { + final String weekday = localizations.narrowWeekdays[i]; + result.add(ExcludeSemantics( + child: Center(child: Text(weekday, style: headerStyle)), + )); + if (i == (localizations.firstDayOfWeekIndex - 1) % 7) + break; + } + return result; + } + @override Widget build(BuildContext context) { final ColorScheme colorScheme = Theme.of(context).colorScheme; final MaterialLocalizations localizations = MaterialLocalizations.of(context); final TextTheme textTheme = Theme.of(context).textTheme; + final TextStyle headerStyle = textTheme.caption?.apply( + color: colorScheme.onSurface.withOpacity(0.60), + ); final TextStyle dayStyle = textTheme.caption; final Color enabledDayColor = colorScheme.onSurface.withOpacity(0.87); final Color disabledDayColor = colorScheme.onSurface.withOpacity(0.38); @@ -680,13 +934,13 @@ class _DayPicker extends StatelessWidget { final Color selectedDayBackground = colorScheme.primary; final Color todayColor = colorScheme.primary; - final int year = displayedMonth.year; - final int month = displayedMonth.month; + final int year = widget.displayedMonth.year; + final int month = widget.displayedMonth.month; final int daysInMonth = utils.getDaysInMonth(year, month); final int dayOffset = utils.firstDayOffset(year, month, localizations); - final List dayItems = []; + final List dayItems = _dayHeaders(headerStyle, localizations); // 1-based day of month, e.g. 1-31 for January, and 1-29 for February on // a leap year. int day = -dayOffset; @@ -696,13 +950,14 @@ class _DayPicker extends StatelessWidget { dayItems.add(Container()); } else { final DateTime dayToBuild = DateTime(year, month, day); - final bool isDisabled = dayToBuild.isAfter(lastDate) || - dayToBuild.isBefore(firstDate) || - (selectableDayPredicate != null && !selectableDayPredicate(dayToBuild)); + final bool isDisabled = dayToBuild.isAfter(widget.lastDate) || + dayToBuild.isBefore(widget.firstDate) || + (widget.selectableDayPredicate != null && !widget.selectableDayPredicate(dayToBuild)); + final bool isSelectedDay = utils.isSameDay(widget.selectedDate, dayToBuild); + final bool isToday = utils.isSameDay(widget.currentDate, dayToBuild); BoxDecoration decoration; Color dayColor = enabledDayColor; - final bool isSelectedDay = utils.isSameDay(selectedDate, dayToBuild); if (isSelectedDay) { // The selected day gets a circle background highlight, and a // contrasting text color. @@ -713,7 +968,7 @@ class _DayPicker extends StatelessWidget { ); } else if (isDisabled) { dayColor = disabledDayColor; - } else if (utils.isSameDay(currentDate, dayToBuild)) { + } else if (isToday) { // The current day gets a different text color and a circle stroke // border. dayColor = todayColor; @@ -735,9 +990,11 @@ class _DayPicker extends StatelessWidget { child: dayWidget, ); } else { - dayWidget = GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onChanged(dayToBuild), + dayWidget = InkResponse( + focusNode: _dayFocusNodes[day - 1], + onTap: () => widget.onChanged(dayToBuild), + radius: _dayPickerRowHeight / 2 + 4, + splashColor: selectedDayBackground.withOpacity(0.38), child: Semantics( // We want the day of month to be spoken first irrespective of the // locale-specific preferences or TextDirection. This is because @@ -781,7 +1038,7 @@ class _DayPickerGridDelegate extends SliverGridDelegate { const int columnCount = DateTime.daysPerWeek; final double tileWidth = constraints.crossAxisExtent / columnCount; final double tileHeight = math.min(_dayPickerRowHeight, - constraints.viewportMainAxisExtent / _maxDayPickerRowCount); + constraints.viewportMainAxisExtent / (_maxDayPickerRowCount + 1)); return SliverGridRegularTileLayout( childCrossAxisExtent: tileWidth, childMainAxisExtent: tileHeight, @@ -798,64 +1055,6 @@ class _DayPickerGridDelegate extends SliverGridDelegate { const _DayPickerGridDelegate _dayPickerGridDelegate = _DayPickerGridDelegate(); -class _DayHeaders extends StatelessWidget { - /// Builds widgets showing abbreviated days of week. The first widget in the - /// returned list corresponds to the first day of week for the current locale. - /// - /// Examples: - /// - /// ``` - /// ┌ Sunday is the first day of week in the US (en_US) - /// | - /// S M T W T F S <-- the returned list contains these widgets - /// _ _ _ _ _ 1 2 - /// 3 4 5 6 7 8 9 - /// - /// ┌ But it's Monday in the UK (en_GB) - /// | - /// M T W T F S S <-- the returned list contains these widgets - /// _ _ _ _ 1 2 3 - /// 4 5 6 7 8 9 10 - /// ``` - List _getDayHeaders(TextStyle headerStyle, MaterialLocalizations localizations) { - final List result = []; - for (int i = localizations.firstDayOfWeekIndex; true; i = (i + 1) % 7) { - final String weekday = localizations.narrowWeekdays[i]; - result.add(ExcludeSemantics( - child: Center(child: Text(weekday, style: headerStyle)), - )); - if (i == (localizations.firstDayOfWeekIndex - 1) % 7) - break; - } - return result; - } - - @override - Widget build(BuildContext context) { - final ThemeData theme = Theme.of(context); - final ColorScheme colorScheme = theme.colorScheme; - final TextStyle dayHeaderStyle = theme.textTheme.caption?.apply( - color: colorScheme.onSurface.withOpacity(0.60), - ); - final MaterialLocalizations localizations = MaterialLocalizations.of(context); - final List labels = _getDayHeaders(dayHeaderStyle, localizations); - - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: _monthPickerHorizontalPadding, - ), - child: GridView.custom( - shrinkWrap: true, - gridDelegate: _dayPickerGridDelegate, - childrenDelegate: SliverChildListDelegate( - labels, - addRepaintBoundaries: false, - ), - ), - ); - } -} - /// A scrollable list of years to allow picking a year. class _YearPicker extends StatefulWidget { /// Creates a year picker. diff --git a/packages/flutter/lib/src/material/pickers/date_picker_dialog.dart b/packages/flutter/lib/src/material/pickers/date_picker_dialog.dart index 0cd2e8826e0..7c508118876 100644 --- a/packages/flutter/lib/src/material/pickers/date_picker_dialog.dart +++ b/packages/flutter/lib/src/material/pickers/date_picker_dialog.dart @@ -6,7 +6,7 @@ import 'dart:math' as math; -import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import '../button_bar.dart'; @@ -357,6 +357,11 @@ class _DatePickerDialogState extends State<_DatePickerDialog> { return null; } + static final Map _formShortcutMap = { + // Pressing enter on the field will move focus to the next field or control. + LogicalKeySet(LogicalKeyboardKey.enter): const NextFocusIntent(), + }; + @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); @@ -419,24 +424,27 @@ class _DatePickerDialogState extends State<_DatePickerDialog> { child: Container( padding: const EdgeInsets.symmetric(horizontal: 24), height: orientation == Orientation.portrait ? _inputFormPortraitHeight : _inputFormLandscapeHeight, - child: Column( - children: [ - const Spacer(), - InputDatePickerFormField( - initialDate: _selectedDate, - firstDate: widget.firstDate, - lastDate: widget.lastDate, - onDateSubmitted: _handleDateChanged, - onDateSaved: _handleDateChanged, - selectableDayPredicate: widget.selectableDayPredicate, - errorFormatText: widget.errorFormatText, - errorInvalidText: widget.errorInvalidText, - fieldHintText: widget.fieldHintText, - fieldLabelText: widget.fieldLabelText, - autofocus: true, - ), - const Spacer(), - ], + child: Shortcuts( + shortcuts: _formShortcutMap, + child: Column( + children: [ + const Spacer(), + InputDatePickerFormField( + initialDate: _selectedDate, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + onDateSubmitted: _handleDateChanged, + onDateSaved: _handleDateChanged, + selectableDayPredicate: widget.selectableDayPredicate, + errorFormatText: widget.errorFormatText, + errorInvalidText: widget.errorInvalidText, + fieldHintText: widget.fieldHintText, + fieldLabelText: widget.fieldLabelText, + autofocus: true, + ), + const Spacer(), + ], + ), ), ), ); diff --git a/packages/flutter/lib/src/material/pickers/date_utils.dart b/packages/flutter/lib/src/material/pickers/date_utils.dart index ef90c0431d9..44acaedd804 100644 --- a/packages/flutter/lib/src/material/pickers/date_utils.dart +++ b/packages/flutter/lib/src/material/pickers/date_utils.dart @@ -26,12 +26,20 @@ DateTimeRange datesOnly(DateTimeRange range) { } /// Returns true if the two [DateTime] objects have the same day, month, and -/// year. +/// year, or are both null. bool isSameDay(DateTime dateA, DateTime dateB) { return - dateA.year == dateB.year && - dateA.month == dateB.month && - dateA.day == dateB.day; + dateA?.year == dateB?.year && + dateA?.month == dateB?.month && + dateA?.day == dateB?.day; +} + +/// Returns true if the two [DateTime] objects have the same month, and +/// year, or are both null. +bool isSameMonth(DateTime dateA, DateTime dateB) { + return + dateA?.year == dateB?.year && + dateA?.month == dateB?.month; } /// Determines the number of months between two [DateTime] objects. diff --git a/packages/flutter/test/material/date_picker_test.dart b/packages/flutter/test/material/date_picker_test.dart index 680aac98c0e..3cd811216d2 100644 --- a/packages/flutter/test/material/date_picker_test.dart +++ b/packages/flutter/test/material/date_picker_test.dart @@ -83,7 +83,11 @@ void main() { SystemChannels.platform.setMockMethodCallHandler(null); }); - Future prepareDatePicker(WidgetTester tester, Future callback(Future date)) async { + Future prepareDatePicker( + WidgetTester tester, + Future callback(Future date), + { TextDirection textDirection = TextDirection.ltr } + ) async { BuildContext buttonContext; await tester.pumpWidget(MaterialApp( home: Material( @@ -119,6 +123,12 @@ void main() { fieldHintText: fieldHintText, fieldLabelText: fieldLabelText, helpText: helpText, + builder: (BuildContext context, Widget child) { + return Directionality( + textDirection: textDirection, + child: child, + ); + }, ); await tester.pumpAndSettle(const Duration(seconds: 1)); @@ -845,123 +855,153 @@ void main() { expect(tester.getSemantics(find.text('1')), matchesSemantics( label: '1, Friday, January 1, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('2')), matchesSemantics( label: '2, Saturday, January 2, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('3')), matchesSemantics( label: '3, Sunday, January 3, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('4')), matchesSemantics( label: '4, Monday, January 4, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('5')), matchesSemantics( label: '5, Tuesday, January 5, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('6')), matchesSemantics( label: '6, Wednesday, January 6, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('7')), matchesSemantics( label: '7, Thursday, January 7, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('8')), matchesSemantics( label: '8, Friday, January 8, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('9')), matchesSemantics( label: '9, Saturday, January 9, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('10')), matchesSemantics( label: '10, Sunday, January 10, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('11')), matchesSemantics( label: '11, Monday, January 11, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('12')), matchesSemantics( label: '12, Tuesday, January 12, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('13')), matchesSemantics( label: '13, Wednesday, January 13, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('14')), matchesSemantics( label: '14, Thursday, January 14, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('15')), matchesSemantics( label: '15, Friday, January 15, 2016', hasTapAction: true, isSelected: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('16')), matchesSemantics( label: '16, Saturday, January 16, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('17')), matchesSemantics( label: '17, Sunday, January 17, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('18')), matchesSemantics( label: '18, Monday, January 18, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('19')), matchesSemantics( label: '19, Tuesday, January 19, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('20')), matchesSemantics( label: '20, Wednesday, January 20, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('21')), matchesSemantics( label: '21, Thursday, January 21, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('22')), matchesSemantics( label: '22, Friday, January 22, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('23')), matchesSemantics( label: '23, Saturday, January 23, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('24')), matchesSemantics( label: '24, Sunday, January 24, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('25')), matchesSemantics( label: '25, Monday, January 25, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('26')), matchesSemantics( label: '26, Tuesday, January 26, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('27')), matchesSemantics( label: '27, Wednesday, January 27, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('28')), matchesSemantics( label: '28, Thursday, January 28, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('29')), matchesSemantics( label: '29, Friday, January 29, 2016', hasTapAction: true, + isFocusable: true, )); expect(tester.getSemantics(find.text('30')), matchesSemantics( label: '30, Saturday, January 30, 2016', hasTapAction: true, + isFocusable: true, )); // Ok/Cancel buttons @@ -1113,6 +1153,7 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.sendKeyEvent(LogicalKeyboardKey.space); await tester.pumpAndSettle(); // Should be in the input mode @@ -1159,6 +1200,128 @@ void main() { expect(find.text('March 2016'), findsOneWidget); }); }); + + testWidgets('Can navigate date grid with arrow keys', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future date) async { + // Navigate to the grid + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + + // Navigate from Jan 15 to Jan 18 with arrow keys + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + // Activate it + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Navigate out of the grid and to the OK button + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + + // Activate OK + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Should have selected Jan 18 + expect(await date, DateTime(2016, DateTime.january, 18)); + }); + }); + + testWidgets('Navigating with arrow keys scrolls months', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future date) async { + // Navigate to the grid + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Navigate from Jan 15 to Dec 31 with arrow keys + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + // Should have scrolled to Dec 2015 + expect(find.text('December 2015'), findsOneWidget); + + // Navigate from Dec 31 to Nov 26 with arrow keys + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + + // Should have scrolled to Nov 2015 + expect(find.text('November 2015'), findsOneWidget); + + // Activate it + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Navigate out of the grid and to the OK button + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Activate OK + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Should have selected Jan 18 + expect(await date, DateTime(2015, DateTime.november, 26)); + }); + }); + + testWidgets('RTL text direction reverses the horizontal arrow key navigation', (WidgetTester tester) async { + await prepareDatePicker(tester, (Future date) async { + // Navigate to the grid + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Navigate from Jan 15 to 19 with arrow keys + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + + // Activate it + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Navigate out of the grid and to the OK button + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + // Activate OK + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + + // Should have selected Jan 18 + expect(await date, DateTime(2016, DateTime.january, 19)); + }, + textDirection: TextDirection.rtl); + }); }); group('Screen configurations', () {