mirror of
https://github.com/cogwheel0/conduit.git
synced 2026-10-19 17:53:43 +00:00
Preserve control accessibility names and validate segment choices
This commit is contained in:
parent
f9df8a510c
commit
076ceae571
4 changed files with 268 additions and 63 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue