Preserve control accessibility names and validate segment choices

This commit is contained in:
cogwheel 2026-10-08 01:19:29 +05:30
parent f9df8a510c
commit 076ceae571
4 changed files with 268 additions and 63 deletions

View file

@ -113,9 +113,12 @@ class AdaptiveChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final callback = enabled ? onSelected : null;
final chipLabel = semanticLabel == null
? label
: Semantics(label: semanticLabel, excludeSemantics: true, child: label);
final chip = switch (style) {
AdaptiveChipStyle.action => ActionChip(
label: label,
label: chipLabel,
avatar: avatar,
onPressed: enabled ? onPressed : null,
backgroundColor: backgroundColor,
@ -127,7 +130,7 @@ class AdaptiveChip extends StatelessWidget {
materialTapTargetSize: materialTapTargetSize,
),
AdaptiveChipStyle.choice => ChoiceChip(
label: label,
label: chipLabel,
selected: selected,
onSelected: callback,
avatar: avatar,
@ -142,7 +145,7 @@ class AdaptiveChip extends StatelessWidget {
materialTapTargetSize: materialTapTargetSize,
),
AdaptiveChipStyle.filter => FilterChip(
label: label,
label: chipLabel,
selected: selected,
onSelected: callback,
avatar: avatar,
@ -157,7 +160,7 @@ class AdaptiveChip extends StatelessWidget {
materialTapTargetSize: materialTapTargetSize,
),
AdaptiveChipStyle.input => InputChip(
label: label,
label: chipLabel,
selected: selected,
onSelected: callback,
avatar: avatar,
@ -175,8 +178,6 @@ class AdaptiveChip extends StatelessWidget {
materialTapTargetSize: materialTapTargetSize,
),
};
return semanticLabel == null
? chip
: Semantics(label: semanticLabel, child: chip);
return chip;
}
}

View file

@ -193,11 +193,15 @@ class AdaptiveButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final button = _buildButton(context);
return semanticLabel == null
return semanticLabel == null || !_canUseNative(context)
? button
: Semantics(label: semanticLabel, child: button);
}
Widget _semanticContent(Widget content) => semanticLabel == null
? content
: Semantics(label: semanticLabel, excludeSemantics: true, child: content);
Widget _buildButton(BuildContext context) {
if (_canUseNative(context)) {
final resolvedPadding = padding?.resolve(Directionality.of(context));
@ -307,7 +311,7 @@ class AdaptiveButton extends StatelessWidget {
borderRadius: resolvedRadius,
color: background,
minimumSize: minSize ?? Size(0, _defaultHeight),
child: _content(fallbackColor: foreground),
child: _semanticContent(_content(fallbackColor: foreground)),
);
final borderSide = style == AdaptiveButtonStyle.bordered
? BorderSide(color: primary)
@ -368,13 +372,17 @@ class AdaptiveButton extends StatelessWidget {
: borderRadius == null
? null
: RoundedRectangleBorder(borderRadius: effectiveRadius);
final primaryContent = _content(
fallbackColor: textColor ?? colorScheme.onPrimary,
wrapCustomChild: false,
final primaryContent = _semanticContent(
_content(
fallbackColor: textColor ?? colorScheme.onPrimary,
wrapCustomChild: false,
),
);
final accentContent = _content(
fallbackColor: textColor ?? color ?? colorScheme.primary,
wrapCustomChild: false,
final accentContent = _semanticContent(
_content(
fallbackColor: textColor ?? color ?? colorScheme.primary,
wrapCustomChild: false,
),
);
return switch (style) {
AdaptiveButtonStyle.filled => ElevatedButton(

View file

@ -9,17 +9,16 @@ class AdaptiveSegment<T extends Object> {
required this.label,
this.icon,
this.enabled = true,
this.sfSymbol,
this.semanticLabel,
});
final T value;
final Widget label;
final Widget? icon;
final String? sfSymbol;
final String? semanticLabel;
final bool enabled;
}
/// Requires at least two segments with distinct values on every platform.
class AdaptiveValueSegmentedControl<T extends Object> extends StatelessWidget {
const AdaptiveValueSegmentedControl({
super.key,
@ -43,8 +42,31 @@ class AdaptiveValueSegmentedControl<T extends Object> extends StatelessWidget {
onChanged!(next);
}
Widget _label(AdaptiveSegment<T> segment) => Semantics(
label: segment.semanticLabel,
enabled: segment.enabled && onChanged != null,
excludeSemantics: segment.semanticLabel != null,
child: segment.label,
);
Widget _cupertinoLabel(AdaptiveSegment<T> segment) {
final icon = segment.icon;
final label = _label(segment);
return icon == null
? label
: Row(
mainAxisSize: MainAxisSize.min,
children: [icon, const SizedBox(width: 6), label],
);
}
@override
Widget build(BuildContext context) {
if (segments.length < 2 ||
segments.map((segment) => segment.value).toSet().length !=
segments.length) {
throw ArgumentError('Segments require at least two distinct values.');
}
final platform = Theme.of(context).platform;
if (platform == TargetPlatform.iOS || platform == TargetPlatform.macOS) {
return CupertinoSlidingSegmentedControl<T>(
@ -57,7 +79,8 @@ class AdaptiveValueSegmentedControl<T extends Object> extends StatelessWidget {
if (next != null) _select(next);
},
children: {
for (final segment in segments) segment.value: segment.label,
for (final segment in segments)
segment.value: _cupertinoLabel(segment),
},
);
}
@ -69,7 +92,7 @@ class AdaptiveValueSegmentedControl<T extends Object> extends StatelessWidget {
for (final segment in segments)
ButtonSegment<T>(
value: segment.value,
label: segment.label,
label: _label(segment),
icon: segment.icon,
enabled: segment.enabled,
),

View file

@ -8,55 +8,228 @@ void main() {
testWidgets(
'Android chips keep selection, disabled state and separate deletion',
(tester) async {
PlatformUiCapabilities.debugPlatformOverride = TargetPlatform.android;
var selected = false;
var changes = 0;
var deletes = 0;
var enabled = true;
late StateSetter update;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) {
update = setState;
return AdaptiveChip.input(
label: const Text('Tag'),
selected: selected,
enabled: enabled,
onSelected: (value) => setState(() {
selected = value;
changes++;
}),
onDeleted: () => deletes++,
);
},
final semantics = tester.ensureSemantics();
try {
PlatformUiCapabilities.debugPlatformOverride = TargetPlatform.android;
var selected = false;
var changes = 0;
var deletes = 0;
var enabled = true;
late StateSetter update;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) {
update = setState;
return AdaptiveChip.input(
label: const Text('Tag'),
semanticLabel: 'Tag action',
selected: selected,
enabled: enabled,
onSelected: (value) => setState(() {
selected = value;
changes++;
}),
onDeleted: () => deletes++,
);
},
),
),
),
),
);
await tester.tap(find.text('Tag'));
await tester.pumpAndSettle();
expect(selected, isTrue);
expect(changes, 1);
await tester.tap(
find.byWidgetPredicate(
(widget) => widget is Tooltip && widget.message == 'Delete',
),
);
expect(deletes, 1);
expect(changes, 1);
update(() => enabled = false);
await tester.pumpAndSettle();
await tester.tap(find.text('Tag'));
expect(changes, 1);
expect(
tester.widget<InputChip>(find.byType(InputChip)).onDeleted,
isNull,
);
);
await tester.tap(find.text('Tag'));
await tester.pumpAndSettle();
expect(selected, isTrue);
expect(changes, 1);
expect(
tester.getSemantics(find.bySemanticsLabel('Tag action')),
isSemantics(
label: 'Tag action',
isButton: true,
isSelected: true,
hasTapAction: true,
),
);
await tester.tap(
find.byWidgetPredicate(
(widget) => widget is Tooltip && widget.message == 'Delete',
),
);
expect(deletes, 1);
expect(changes, 1);
update(() => enabled = false);
await tester.pumpAndSettle();
await tester.tap(find.text('Tag'));
expect(changes, 1);
expect(
tester.widget<InputChip>(find.byType(InputChip)).onDeleted,
isNull,
);
} finally {
semantics.dispose();
}
},
);
for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) {
testWidgets('button names retain activation on ${platform.name}', (
tester,
) async {
PlatformUiCapabilities.debugPlatformOverride = platform;
PlatformUiCapabilities.debugIOSMajorVersionOverride = 25;
final semantics = tester.ensureSemantics();
try {
var presses = 0;
var enabled = true;
late StateSetter update;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: platform),
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) {
update = setState;
return AdaptiveButton(
label: 'Visible',
semanticLabel: 'Submit',
enabled: enabled,
onPressed: () => presses++,
);
},
),
),
),
);
expect(
tester.getSemantics(find.bySemanticsLabel('Submit')),
isSemantics(label: 'Submit', isButton: true, hasTapAction: true),
);
await tester.tap(find.text('Visible'));
await tester.pumpAndSettle();
expect(presses, 1);
update(() => enabled = false);
await tester.pumpAndSettle();
expect(
tester.getSemantics(find.bySemanticsLabel('Submit')),
isSemantics(label: 'Submit', isButton: true),
);
await tester.tap(find.text('Visible'));
await tester.pumpAndSettle();
expect(presses, 1);
} finally {
semantics.dispose();
}
});
testWidgets(
'segments reject missing or duplicate choices on ${platform.name}',
(tester) async {
for (final segments in <List<AdaptiveSegment<int>>>[
[],
[const AdaptiveSegment(value: 1, label: Text('Only'))],
[
const AdaptiveSegment(value: 1, label: Text('One')),
const AdaptiveSegment(value: 1, label: Text('Duplicate')),
],
]) {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: platform),
home: Scaffold(
body: AdaptiveValueSegmentedControl<int>(
segments: segments,
value: 1,
onChanged: (_) {},
),
),
),
);
expect(tester.takeException(), isArgumentError);
}
},
);
testWidgets(
'typed segments preserve labels and selection rules on ${platform.name}',
(tester) async {
final semantics = tester.ensureSemantics();
try {
var selected = 1;
var enabled = true;
final changes = <int>[];
late StateSetter update;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: platform),
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) {
update = setState;
return AdaptiveValueSegmentedControl<int>(
value: selected,
segments: const [
AdaptiveSegment(
value: 1,
label: Text('One'),
icon: Icon(Icons.looks_one),
semanticLabel: 'First option',
),
AdaptiveSegment(
value: 2,
label: Text('Two'),
semanticLabel: 'Second option',
),
AdaptiveSegment(
value: 3,
label: Text('Disabled'),
enabled: false,
),
],
onChanged: enabled
? (next) => setState(() {
selected = next;
changes.add(next);
})
: null,
);
},
),
),
),
);
expect(find.bySemanticsLabel('First option'), findsOneWidget);
expect(find.byIcon(Icons.looks_one), findsOneWidget);
expect(find.bySemanticsLabel('Second option'), findsOneWidget);
expect(
tester.getSemantics(find.bySemanticsLabel('Disabled')),
isSemantics(isButton: true, isEnabled: false),
);
await tester.tap(find.text('Two'));
await tester.pumpAndSettle();
expect(selected, 2);
expect(changes, [2]);
expect(
tester.getSemantics(find.bySemanticsLabel('Second option')),
isSemantics(isSelected: true),
);
await tester.tap(find.text('Two'));
await tester.tap(find.text('Disabled'));
await tester.pumpAndSettle();
expect(changes, [2]);
update(() => enabled = false);
await tester.pumpAndSettle();
await tester.tap(find.text('One'));
await tester.pumpAndSettle();
expect(selected, 2);
expect(changes, [2]);
} finally {
semantics.dispose();
}
},
);
}
testWidgets('progress carries a known value and accessibility text', (
tester,
) async {