mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
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:
parent
750331336d
commit
e5be1a3904
@ -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,
|
||||
),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user