From 0ab711c5887492ee6cc65206e1819622adb19997 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 28 Jul 2026 12:40:56 -0400 Subject: [PATCH] feat(remix): add optional indicator semantics Related to #90. --- .../lib/src/components/progress/progress.dart | 2 + .../src/components/progress/progress.g.dart | 12 ++ .../components/progress/progress_widget.dart | 24 +++- .../lib/src/components/spinner/spinner.dart | 1 + .../lib/src/components/spinner/spinner.g.dart | 13 +- .../components/spinner/spinner_widget.dart | 19 ++- .../progress/progress_widget_test.dart | 133 ++++++++++++++++++ .../spinner/spinner_widget_test.dart | 68 +++++++++ 8 files changed, 269 insertions(+), 3 deletions(-) diff --git a/packages/remix/lib/src/components/progress/progress.dart b/packages/remix/lib/src/components/progress/progress.dart index bb20a960..08dcb22c 100644 --- a/packages/remix/lib/src/components/progress/progress.dart +++ b/packages/remix/lib/src/components/progress/progress.dart @@ -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'; diff --git a/packages/remix/lib/src/components/progress/progress.g.dart b/packages/remix/lib/src/components/progress/progress.g.dart index 93a2185f..9952de15 100644 --- a/packages/remix/lib/src/components/progress/progress.g.dart +++ b/packages/remix/lib/src/components/progress/progress.g.dart @@ -106,18 +106,24 @@ class FortalProgress extends StatelessWidget { this.variant = .surface, this.size = .size2, required this.value, + this.semanticsLabel, + this.semanticsValue, }); const FortalProgress.surface({ super.key, this.size = .size2, required this.value, + this.semanticsLabel, + this.semanticsValue, }) : variant = FortalProgressVariant.surface; const FortalProgress.soft({ super.key, this.size = .size2, required this.value, + this.semanticsLabel, + this.semanticsValue, }) : variant = FortalProgressVariant.soft; final FortalProgressVariant variant; @@ -126,12 +132,18 @@ class FortalProgress extends StatelessWidget { final double value; + final String? semanticsLabel; + + final String? semanticsValue; + @override Widget build(BuildContext context) { return RemixProgress( key: this.key, style: fortalProgressStyler(variant: this.variant, size: this.size), value: this.value, + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, ); } } diff --git a/packages/remix/lib/src/components/progress/progress_widget.dart b/packages/remix/lib/src/components/progress/progress_widget.dart index c3a6f300..839e0c0d 100644 --- a/packages/remix/lib/src/components/progress/progress_widget.dart +++ b/packages/remix/lib/src/components/progress/progress_widget.dart @@ -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( @@ -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; @@ -38,7 +49,7 @@ class RemixProgress extends StatelessWidget { @override Widget build(BuildContext context) { - return RemixStyleSpecBuilder( + final progress = RemixStyleSpecBuilder( style: style, styleSpec: styleSpec, builder: (context, spec) { @@ -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, + ); } } diff --git a/packages/remix/lib/src/components/spinner/spinner.dart b/packages/remix/lib/src/components/spinner/spinner.dart index ba35b626..c5a071a5 100644 --- a/packages/remix/lib/src/components/spinner/spinner.dart +++ b/packages/remix/lib/src/components/spinner/spinner.dart @@ -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'; diff --git a/packages/remix/lib/src/components/spinner/spinner.g.dart b/packages/remix/lib/src/components/spinner/spinner.g.dart index 57a36d65..ae392482 100644 --- a/packages/remix/lib/src/components/spinner/spinner.g.dart +++ b/packages/remix/lib/src/components/spinner/spinner.g.dart @@ -122,15 +122,26 @@ typedef _$RemixSpinnerSpecMethods = _$RemixSpinnerSpec; // ignore: unused_elemen /// Fortal-themed preset for [RemixSpinner]. class FortalSpinner extends StatelessWidget { - const FortalSpinner({super.key, this.size = .size2}); + const FortalSpinner({ + super.key, + this.size = .size2, + this.semanticsLabel, + this.semanticsValue, + }); final FortalSpinnerSize size; + final String? semanticsLabel; + + final String? semanticsValue; + @override Widget build(BuildContext context) { return RemixSpinner( key: this.key, style: fortalSpinnerStyler(size: this.size), + semanticsLabel: this.semanticsLabel, + semanticsValue: this.semanticsValue, ); } } diff --git a/packages/remix/lib/src/components/spinner/spinner_widget.dart b/packages/remix/lib/src/components/spinner/spinner_widget.dart index e31074b4..ac13def3 100644 --- a/packages/remix/lib/src/components/spinner/spinner_widget.dart +++ b/packages/remix/lib/src/components/spinner/spinner_widget.dart @@ -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; @@ -35,11 +43,20 @@ class RemixSpinner extends StatelessWidget { @override Widget build(BuildContext context) { - return RemixStyleSpecBuilder( + final spinner = RemixStyleSpecBuilder( 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, + ); } } diff --git a/packages/remix/test/components/progress/progress_widget_test.dart b/packages/remix/test/components/progress/progress_widget_test.dart index 49c83b45..828417b9 100644 --- a/packages/remix/test/components/progress/progress_widget_test.dart +++ b/packages/remix/test/components/progress/progress_widget_test.dart @@ -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'; @@ -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( + 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 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(); + } + }); + }); } diff --git a/packages/remix/test/components/spinner/spinner_widget_test.dart b/packages/remix/test/components/spinner/spinner_widget_test.dart index 5dc551ed..1a08057e 100644 --- a/packages/remix/test/components/spinner/spinner_widget_test.dart +++ b/packages/remix/test/components/spinner/spinner_widget_test.dart @@ -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'; @@ -322,5 +323,72 @@ void main() { expect(find.byType(RemixSpinner), findsOneWidget); }); }); + + group('Accessibility', () { + testWidgets('is decorative when semantic inputs are omitted', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + await tester.pumpRemixApp(const RemixSpinner()); + await tester.pump(); + + final nodes = tester.semantics + .simulatedAccessibilityTraversal() + .where( + (node) => + node.getSemanticsData().role == + SemanticsRole.loadingSpinner, + ); + expect(nodes, isEmpty); + } finally { + semantics.dispose(); + } + }); + + testWidgets('Fortal forwards one labelled loading status node', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + await tester.pumpRemixApp( + const FortalSpinner( + semanticsLabel: 'Loading workspaces', + semanticsValue: 'Connecting', + ), + ); + await tester.pump(); + + final nodes = tester.semantics + .simulatedAccessibilityTraversal() + .where( + (node) => + node.getSemanticsData().role == + SemanticsRole.loadingSpinner, + ) + .toList(); + expect(nodes, hasLength(1)); + expect( + nodes.single, + isSemantics( + label: 'Loading workspaces', + value: 'Connecting', + hasTapAction: false, + hasLongPressAction: false, + hasIncreaseAction: false, + hasDecreaseAction: false, + ), + ); + + final spinner = tester.widget( + find.byType(RemixSpinner), + ); + expect(spinner.semanticsLabel, 'Loading workspaces'); + expect(spinner.semanticsValue, 'Connecting'); + } finally { + semantics.dispose(); + } + }); + }); }); }