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.
This commit is contained in:
Kishan Rathore 2025-12-24 13:23:19 +05:30 committed by GitHub
parent 750331336d
commit e5be1a3904
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 170 additions and 15 deletions

View File

@ -899,10 +899,12 @@ class _CupertinoDatePickerDateTimeState extends State<CupertinoDatePicker> {
? 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<CupertinoDatePicker> {
children: List<Widget>.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<CupertinoDatePicker> {
(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<CupertinoDatePicker> {
style: _themeTextStyle(context, isValid: !isInvalidMinute),
),
);
return isInvalidMinute ? ExcludeSemantics(child: child) : child;
}),
),
);
@ -1085,15 +1090,17 @@ class _CupertinoDatePickerDateTimeState extends State<CupertinoDatePicker> {
},
selectionOverlay: selectionOverlay,
children: List<Widget>.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<CupertinoDatePicker> {
(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<CupertinoDatePicker> {
? 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<CupertinoDatePicker> {
(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<CupertinoDatePicker> {
? 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<CupertinoDatePicker> {
(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,
),

View File

@ -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;
}

View File

@ -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(

View File

@ -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 <Widget>[
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>[SemanticsAction.increase])),
);
expect(
semantics,
isNot(includesNodeWith(actions: <SemanticsAction>[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>[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>[SemanticsAction.decrease])),
);
semantics.dispose();
});
group('layout', () {
// Regression test for https://github.com/flutter/flutter/issues/22999
testWidgets('CupertinoPicker.builder test', (WidgetTester tester) async {