Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/remix/lib/src/components/progress/progress.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
library remix_progress;

import 'dart:ui' show SemanticsRole;

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:mix/mix.dart';
Expand Down
12 changes: 12 additions & 0 deletions packages/remix/lib/src/components/progress/progress.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 23 additions & 1 deletion packages/remix/lib/src/components/progress/progress_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ class RemixProgress extends StatelessWidget {
const RemixProgress({
super.key,
required this.value,
this.semanticsLabel,
this.semanticsValue,
this.style = const RemixProgressStyler.create(),
this.styleSpec,
}) : assert(
Expand All @@ -30,6 +32,15 @@ class RemixProgress extends StatelessWidget {
/// A value of 0 means empty, while 1 means completely filled.
final double value;

/// The accessible name exposed when this progress bar is not decorative.
final String? semanticsLabel;

/// Optional progress value expressed as a number from 0 to 100 or a
/// percentage.
///
/// Defaults to the rounded 0–100 value when [semanticsLabel] is provided.
final String? semanticsValue;

/// The style configuration for the progress bar.
final RemixProgressStyler style;

Expand All @@ -38,7 +49,7 @@ class RemixProgress extends StatelessWidget {

@override
Widget build(BuildContext context) {
return RemixStyleSpecBuilder<RemixProgressSpec>(
final progress = RemixStyleSpecBuilder<RemixProgressSpec>(
style: style,
styleSpec: styleSpec,
builder: (context, spec) {
Expand Down Expand Up @@ -66,5 +77,16 @@ class RemixProgress extends StatelessWidget {
);
},
);

if (semanticsLabel == null && semanticsValue == null) return progress;

return Semantics(
role: SemanticsRole.progressBar,
label: semanticsLabel,
value: semanticsValue ?? '${(value * 100).round()}',
minValue: '0',
maxValue: '100',
child: progress,
);
}
}
1 change: 1 addition & 0 deletions packages/remix/lib/src/components/spinner/spinner.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
library remix_spinner;

import 'dart:math';
import 'dart:ui' show SemanticsRole;

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
Expand Down
13 changes: 12 additions & 1 deletion packages/remix/lib/src/components/spinner/spinner.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 18 additions & 1 deletion packages/remix/lib/src/components/spinner/spinner_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,20 @@ part of 'spinner.dart';
class RemixSpinner extends StatelessWidget {
const RemixSpinner({
super.key,
this.semanticsLabel,
this.semanticsValue,
this.style = const RemixSpinnerStyler.create(),
this.styleSpec,
});

static final styleFrom = RemixSpinnerStyler.new;

/// The accessible name exposed when this spinner is not decorative.
final String? semanticsLabel;

/// Optional status text exposed with [semanticsLabel].
final String? semanticsValue;

/// The style configuration for the spinner.
final RemixSpinnerStyler style;

Expand All @@ -35,11 +43,20 @@ class RemixSpinner extends StatelessWidget {

@override
Widget build(BuildContext context) {
return RemixStyleSpecBuilder<RemixSpinnerSpec>(
final spinner = RemixStyleSpecBuilder<RemixSpinnerSpec>(
style: style,
styleSpec: styleSpec,
builder: (context, spec) => _SpinnerSpecWidget(spec: spec),
);

if (semanticsLabel == null && semanticsValue == null) return spinner;

return Semantics(
role: SemanticsRole.loadingSpinner,
label: semanticsLabel,
value: semanticsValue,
child: spinner,
);
}
}

Expand Down
133 changes: 133 additions & 0 deletions packages/remix/test/components/progress/progress_widget_test.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:remix/remix.dart';

Expand Down Expand Up @@ -381,4 +382,136 @@ void main() {
expect(find.byKey(key), findsOneWidget);
});
});

group('RemixProgress Accessibility', () {
testWidgets('is decorative when semantic inputs are omitted', (
tester,
) async {
final semantics = tester.ensureSemantics();
try {
await tester.pumpRemixApp(const RemixProgress(value: 0.5));
await tester.pump();

final nodes = tester.semantics.simulatedAccessibilityTraversal().where(
(node) => node.getSemanticsData().role == SemanticsRole.progressBar,
);
expect(nodes, isEmpty);
} finally {
semantics.dispose();
}
});

testWidgets('Fortal forwards one named normalized progress node', (
tester,
) async {
final semantics = tester.ensureSemantics();
try {
await tester.pumpRemixApp(
const FortalProgress(
value: 0.42,
semanticsLabel: 'Uploading workspace',
),
);
await tester.pump();

final nodes = tester.semantics
.simulatedAccessibilityTraversal()
.where(
(node) =>
node.getSemanticsData().role == SemanticsRole.progressBar,
)
.toList();
expect(nodes, hasLength(1));
expect(
nodes.single,
isSemantics(
label: 'Uploading workspace',
value: '42',
minValue: '0',
maxValue: '100',
hasTapAction: false,
hasLongPressAction: false,
hasIncreaseAction: false,
hasDecreaseAction: false,
),
);

final progress = tester.widget<RemixProgress>(
find.byType(RemixProgress),
);
expect(progress.semanticsLabel, 'Uploading workspace');
expect(progress.semanticsValue, isNull);
} finally {
semantics.dispose();
}
});

testWidgets('uses a caller-provided progress value', (tester) async {
final semantics = tester.ensureSemantics();
try {
await tester.pumpRemixApp(
const RemixProgress(
value: 0.42,
semanticsLabel: 'Uploading workspace',
semanticsValue: '42%',
),
);
await tester.pump();

expect(
tester.getSemantics(find.byType(RemixProgress)),
isSemantics(
label: 'Uploading workspace',
value: '42%',
minValue: '0',
maxValue: '100',
),
);
} finally {
semantics.dispose();
}
});

testWidgets('updates the existing progress node', (tester) async {
final semantics = tester.ensureSemantics();
try {
var value = 0.25;
late StateSetter update;

await tester.pumpRemixApp(
StatefulBuilder(
builder: (context, setState) {
update = setState;
return FortalProgress(
value: value,
semanticsLabel: 'Uploading workspace',
);
},
),
);
await tester.pump();

List<SemanticsNode> progressNodes() => tester.semantics
.simulatedAccessibilityTraversal()
.where(
(node) =>
node.getSemanticsData().role == SemanticsRole.progressBar,
)
.toList();

final initialNode = progressNodes().single;
expect(initialNode.getSemanticsData().value, '25');

update(() => value = 0.75);
await tester.pump();

final updatedNodes = progressNodes();
expect(updatedNodes, hasLength(1));
expect(updatedNodes.single.id, initialNode.id);
expect(updatedNodes.single.getSemanticsData().value, '75');
} finally {
semantics.dispose();
}
});
});
}
Loading
Loading