From e5be1a3904dba81fac5084692a4e5b3c5631da71 Mon Sep 17 00:00:00 2001 From: Kishan Rathore <34465683+rkishan516@users.noreply.github.com> Date: Wed, 24 Dec 2025 13:23:19 +0530 Subject: [PATCH] fix: exclude semantics for disabled dates (#178981) ## Changes * Exclude semantics for disabled dates directly in CupertinoDatePicker fixes: #178713 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. --- .../lib/src/cupertino/date_picker.dart | 36 ++++++---- .../flutter/lib/src/cupertino/picker.dart | 18 ++++- .../test/cupertino/date_picker_test.dart | 62 +++++++++++++++++ .../flutter/test/cupertino/picker_test.dart | 69 +++++++++++++++++++ 4 files changed, 170 insertions(+), 15 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/date_picker.dart b/packages/flutter/lib/src/cupertino/date_picker.dart index 261b31ecd4c..64a97529904 100644 --- a/packages/flutter/lib/src/cupertino/date_picker.dart +++ b/packages/flutter/lib/src/cupertino/date_picker.dart @@ -899,10 +899,12 @@ class _CupertinoDatePickerDateTimeState extends State { ? localizations.todayLabel : localizations.datePickerMediumDate(rangeStart); - return itemPositioningBuilder( + final bool isDisabled = !_isSelectableDate(rangeStart); + final Widget child = itemPositioningBuilder( context, - Text(dateText, style: _themeTextStyle(context, isValid: _isSelectableDate(rangeStart))), + Text(dateText, style: _themeTextStyle(context, isValid: !isDisabled)), ); + return isDisabled ? ExcludeSemantics(child: child) : child; }, selectionOverlay: selectionOverlay, ), @@ -983,15 +985,17 @@ class _CupertinoDatePickerDateTimeState extends State { children: List.generate(24, (int index) { final int hour = isHourRegionFlipped ? (index + 12) % 24 : index; final int displayHour = widget.use24hFormat ? hour : (hour + 11) % 12 + 1; + final bool isDisabled = !_isValidHour(selectedAmPm, index); - return itemPositioningBuilder( + final Widget child = itemPositioningBuilder( context, Text( localizations.datePickerHour(displayHour), semanticsLabel: localizations.datePickerHourSemanticsLabel(displayHour), - style: _themeTextStyle(context, isValid: _isValidHour(selectedAmPm, index)), + style: _themeTextStyle(context, isValid: !isDisabled), ), ); + return isDisabled ? ExcludeSemantics(child: child) : child; }), ), ); @@ -1040,7 +1044,7 @@ class _CupertinoDatePickerDateTimeState extends State { (widget.minimumDate?.isAfter(date) ?? false) || (widget.maximumDate?.isBefore(date) ?? false); - return itemPositioningBuilder( + final Widget child = itemPositioningBuilder( context, Text( localizations.datePickerMinute(minute), @@ -1048,6 +1052,7 @@ class _CupertinoDatePickerDateTimeState extends State { style: _themeTextStyle(context, isValid: !isInvalidMinute), ), ); + return isInvalidMinute ? ExcludeSemantics(child: child) : child; }), ), ); @@ -1085,15 +1090,17 @@ class _CupertinoDatePickerDateTimeState extends State { }, selectionOverlay: selectionOverlay, children: List.generate(2, (int index) { - return itemPositioningBuilder( + final bool isDisabled = !_isValidHour(index, _selectedHourIndex); + final Widget child = itemPositioningBuilder( context, Text( index == 0 ? localizations.anteMeridiemAbbreviation : localizations.postMeridiemAbbreviation, - style: _themeTextStyle(context, isValid: _isValidHour(index, _selectedHourIndex)), + style: _themeTextStyle(context, isValid: !isDisabled), ), ); + return isDisabled ? ExcludeSemantics(child: child) : child; }), ), ); @@ -1472,13 +1479,14 @@ class _CupertinoDatePickerDateState extends State { (widget.maximumDate?.year == selectedYear && widget.maximumDate!.month == selectedMonth && widget.maximumDate!.day < day); - return itemPositioningBuilder( + final Widget child = itemPositioningBuilder( context, Text( localizations.datePickerDayOfMonth(day, dayOfWeek), style: _themeTextStyle(context, isValid: !isInvalidDay), ), ); + return isInvalidDay ? ExcludeSemantics(child: child) : child; }), ), ); @@ -1526,10 +1534,11 @@ class _CupertinoDatePickerDateState extends State { ? localizations.datePickerStandaloneMonth(month) : localizations.datePickerMonth(month); - return itemPositioningBuilder( + final Widget child = itemPositioningBuilder( context, Text(monthName, style: _themeTextStyle(context, isValid: !isInvalidMonth)), ); + return isInvalidMonth ? ExcludeSemantics(child: child) : child; }), ), ); @@ -1579,13 +1588,14 @@ class _CupertinoDatePickerDateState extends State { (widget.minimumDate == null || widget.minimumDate!.year <= year) && (widget.maximumDate == null || widget.maximumDate!.year >= year); - return itemPositioningBuilder( + final Widget child = itemPositioningBuilder( context, Text( localizations.datePickerYear(year), style: _themeTextStyle(context, isValid: isValidYear), ), ); + return isValidYear ? child : ExcludeSemantics(child: child); }, selectionOverlay: selectionOverlay, ), @@ -1892,10 +1902,11 @@ class _CupertinoDatePickerMonthYearState extends State { ? localizations.datePickerStandaloneMonth(month) : localizations.datePickerMonth(month); - return itemPositioningBuilder( + final Widget child = itemPositioningBuilder( context, Text(monthName, style: _themeTextStyle(context, isValid: !isInvalidMonth)), ); + return isInvalidMonth ? ExcludeSemantics(child: child) : child; }), ), ); @@ -1944,13 +1955,14 @@ class _CupertinoDatePickerMonthYearState extends State { (widget.minimumDate == null || widget.minimumDate!.year <= year) && (widget.maximumDate == null || widget.maximumDate!.year >= year); - return itemPositioningBuilder( + final Widget child = itemPositioningBuilder( context, Text( localizations.datePickerYear(year), style: _themeTextStyle(context, isValid: isValidYear), ), ); + return isValidYear ? child : ExcludeSemantics(child: child); }, selectionOverlay: selectionOverlay, ), diff --git a/packages/flutter/lib/src/cupertino/picker.dart b/packages/flutter/lib/src/cupertino/picker.dart index f32f30d085f..399fc01f690 100644 --- a/packages/flutter/lib/src/cupertino/picker.dart +++ b/packages/flutter/lib/src/cupertino/picker.dart @@ -568,14 +568,26 @@ class _RenderCupertinoPickerSemantics extends RenderProxyBox { if (indexedChildren[_currentIndex] == null) { return node.updateWith(config: config); } - config.value = indexedChildren[_currentIndex]!.label; + final String currentLabel = indexedChildren[_currentIndex]!.label; + // If the current item has an empty label (e.g., wrapped with ExcludeSemantics), + // don't set any semantics configuration to avoid assertion errors. + // The semantics system requires that if "value" is empty, "increasedValue" + // and "decreasedValue" must also be empty, and no increase/decrease actions + // should be set. + if (currentLabel.isEmpty) { + return node.updateWith(config: config); + } + config.value = currentLabel; final SemanticsNode? previousChild = indexedChildren[_currentIndex - 1]; final SemanticsNode? nextChild = indexedChildren[_currentIndex + 1]; - if (nextChild != null) { + // Only set increase/decrease actions if the adjacent item has a non-empty label. + // Items wrapped with ExcludeSemantics will have empty labels and should not + // be navigable via accessibility actions. + if (nextChild != null && nextChild.label.isNotEmpty) { config.increasedValue = nextChild.label; config.onIncrease = _handleIncrease; } - if (previousChild != null) { + if (previousChild != null && previousChild.label.isNotEmpty) { config.decreasedValue = previousChild.label; config.onDecrease = _handleDecrease; } diff --git a/packages/flutter/test/cupertino/date_picker_test.dart b/packages/flutter/test/cupertino/date_picker_test.dart index 8c1c1ea05ea..9f1cd246d07 100644 --- a/packages/flutter/test/cupertino/date_picker_test.dart +++ b/packages/flutter/test/cupertino/date_picker_test.dart @@ -2078,6 +2078,68 @@ void main() { handle.dispose(); }); + testWidgets('CupertinoDatePicker semantics excludes disabled dates', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + debugResetSemanticsIdCounter(); + final minimumDate = DateTime(2018, 6, 10); + final maximumDate = DateTime(2018, 6, 20); + + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox( + height: 400.0, + width: 400.0, + child: CupertinoDatePicker( + minimumDate: minimumDate, + maximumDate: maximumDate, + initialDateTime: minimumDate, // Start at minimum date + onDateTimeChanged: (DateTime newDateTime) {}, + mode: CupertinoDatePickerMode.date, + ), + ), + ), + ), + ); + + // Find the day picker column semantics node + // The day picker should have increase action (to go to day 11) but NO decrease action + // (because day 9 is disabled and wrapped with ExcludeSemantics) + final SemanticsNode rootNode = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!; + + // Find semantics node with value '10' (the current day) + SemanticsNode? findNodeWithValue(SemanticsNode node, String value) { + if (node.value == value) { + return node; + } + SemanticsNode? result; + node.visitChildren((SemanticsNode child) { + result ??= findNodeWithValue(child, value); + return result == null; + }); + return result; + } + + final SemanticsNode? dayPickerNode = findNodeWithValue(rootNode, '10'); + expect(dayPickerNode, isNotNull, reason: 'Should find day picker at day 10'); + + // At the minimum date (day 10), the day picker should NOT have a decrease action + // because day 9 is disabled (wrapped with ExcludeSemantics) + final SemanticsData data = dayPickerNode!.getSemanticsData(); + expect( + data.hasAction(SemanticsAction.decrease), + isFalse, + reason: 'Day picker at minimum date should not have decrease action (day 9 is disabled)', + ); + expect( + data.hasAction(SemanticsAction.increase), + isTrue, + reason: 'Day picker at minimum date should have increase action (day 11 is valid)', + ); + + handle.dispose(); + }); + testWidgets('DatePicker adapts to MaterialApp dark mode', (WidgetTester tester) async { Widget buildDatePicker(Brightness brightness) { return MaterialApp( diff --git a/packages/flutter/test/cupertino/picker_test.dart b/packages/flutter/test/cupertino/picker_test.dart index 19c2c371fec..aeb690f6363 100644 --- a/packages/flutter/test/cupertino/picker_test.dart +++ b/packages/flutter/test/cupertino/picker_test.dart @@ -100,6 +100,75 @@ void main() { semantics.dispose(); }); + testWidgets('Picker semantics excludes current item with empty label', ( + WidgetTester tester, + ) async { + // When the current item has an empty label (e.g., wrapped with ExcludeSemantics), + // the picker should not set any value, increasedValue, decreasedValue, or actions. + final semantics = SemanticsTester(tester); + final controller = FixedExtentScrollController(initialItem: 1); + addTearDown(controller.dispose); + + await tester.pumpWidget( + CupertinoApp( + home: SizedBox( + height: 300.0, + width: 300.0, + child: CupertinoPicker( + scrollController: controller, + itemExtent: 50.0, + onSelectedItemChanged: (_) {}, + children: const [ + Text('0'), + // Item at index 1 is excluded from semantics (simulating a disabled item). + ExcludeSemantics(child: Text('1')), + Text('2'), + ], + ), + ), + ), + ); + + // When the current item (index 1) has an empty label due to ExcludeSemantics, + // the picker should not have any value or actions set. + expect(semantics, isNot(includesNodeWith(value: '1'))); + // Also verify that no increase/decrease actions are set for this item. + expect( + semantics, + isNot(includesNodeWith(actions: [SemanticsAction.increase])), + ); + expect( + semantics, + isNot(includesNodeWith(actions: [SemanticsAction.decrease])), + ); + + // Scroll to item 0 which has a valid label. + controller.jumpToItem(0); + await tester.pumpAndSettle(); + + // Now the picker should have value '0' but no increase action + // because the next item (1) has an empty label. + expect(semantics, includesNodeWith(value: '0')); + expect( + semantics, + isNot(includesNodeWith(value: '0', actions: [SemanticsAction.increase])), + ); + + // Scroll to item 2 which has a valid label. + controller.jumpToItem(2); + await tester.pumpAndSettle(); + + // Now the picker should have value '2' but no decrease action + // because the previous item (1) has an empty label. + expect(semantics, includesNodeWith(value: '2')); + expect( + semantics, + isNot(includesNodeWith(value: '2', actions: [SemanticsAction.decrease])), + ); + + semantics.dispose(); + }); + group('layout', () { // Regression test for https://github.com/flutter/flutter/issues/22999 testWidgets('CupertinoPicker.builder test', (WidgetTester tester) async {