Skip to content

Commit 9daf035

Browse files
committed
accessibility: add semantics label to loading indicators and tests
- Add localized semanticsLabel for loading indicators - Add/adjust widget tests to assert the semantics label - Update tests in topic_list/home/message_list/action_sheet as needed
1 parent 9296fb3 commit 9daf035

File tree

7 files changed

+325
-202
lines changed

7 files changed

+325
-202
lines changed

lib/widgets/action_sheet.dart

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,13 @@ class BottomSheetEmptyContentPlaceholder extends StatelessWidget {
255255
final designVariables = DesignVariables.of(context);
256256

257257
final child = loading
258-
? CircularProgressIndicator()
258+
? Semantics(
259+
textDirection: Directionality.of(context),
260+
focusable: true,
261+
liveRegion: true,
262+
label: 'Loading…',
263+
child: CircularProgressIndicator(),
264+
)
259265
: Text(
260266
textAlign: TextAlign.center,
261267
style: TextStyle(
@@ -628,6 +634,8 @@ class ChannelFeedButton extends ActionSheetMenuItemButton {
628634
}
629635
}
630636

637+
638+
631639
class CopyChannelLinkButton extends ActionSheetMenuItemButton {
632640
const CopyChannelLinkButton({
633641
super.key,

lib/widgets/home.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,12 @@ class _LoadingPlaceholderPageState extends State<_LoadingPlaceholderPage> {
246246
child: Column(
247247
mainAxisSize: MainAxisSize.min,
248248
children: [
249-
const CircularProgressIndicator(),
249+
Semantics(
250+
label: 'Loading…',
251+
textDirection: Directionality.of(context),
252+
liveRegion: true,
253+
child:const CircularProgressIndicator(),
254+
),
250255
Visibility(
251256
visible: showTryAnotherAccount,
252257
maintainSize: true,

lib/widgets/message_list.dart

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,7 +1029,20 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
10291029
Widget build(BuildContext context) {
10301030
final zulipLocalizations = ZulipLocalizations.of(context);
10311031

1032-
if (!model.fetched) return const Center(child: CircularProgressIndicator());
1032+
if (!model.fetched) {
1033+
return Center(
1034+
child: Padding(
1035+
padding: const EdgeInsets.symmetric(vertical: 16.0),
1036+
child: Semantics(
1037+
label: 'Loading more messages',
1038+
textDirection: Directionality.of(context),
1039+
liveRegion: true,
1040+
child: const CircularProgressIndicator(),
1041+
),
1042+
),
1043+
);
1044+
}
1045+
10331046

10341047
if (model.items.isEmpty && model.haveNewest && model.haveOldest) {
10351048
final String header;
@@ -1283,10 +1296,18 @@ class _MessageListLoadingMore extends StatelessWidget {
12831296

12841297
@override
12851298
Widget build(BuildContext context) {
1286-
return const Center(
1299+
return Center(
12871300
child: Padding(
1288-
padding: EdgeInsets.symmetric(vertical: 16.0),
1289-
child: CircularProgressIndicator())); // TODO perhaps a different indicator
1301+
padding: const EdgeInsets.symmetric(vertical: 16.0),
1302+
child: Semantics(
1303+
textDirection: Directionality.of(context),
1304+
label: 'Loading more messages',
1305+
liveRegion: true,
1306+
child: const CircularProgressIndicator(),
1307+
),
1308+
),
1309+
);
1310+
// TODO perhaps a different indicator
12901311
}
12911312
}
12921313

lib/widgets/topic_list.dart

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,21 @@ class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMi
166166
@override
167167
Widget build(BuildContext context) {
168168
if (lastFetchedTopics == null) {
169-
return const Center(child: CircularProgressIndicator());
169+
return Center(
170+
child: Padding(
171+
padding: const EdgeInsets.symmetric(vertical: 16.0),
172+
child: Semantics(
173+
textDirection: Directionality.of(context),
174+
label: 'Loading…', // plain string (not localized)
175+
liveRegion: true,
176+
child: CircularProgressIndicator(),
177+
),
178+
),
179+
);
180+
170181
}
171182

183+
172184
// TODO(design) handle the rare case when `lastFetchedTopics` is empty
173185

174186
// This is adapted from parts of the build method on [_InboxPageState].

test/widgets/home_test.dart

Lines changed: 136 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -397,117 +397,165 @@ void main () {
397397
}
398398

399399
testWidgets('smoke', (tester) async {
400-
addTearDown(testBinding.reset);
401-
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration;
402-
await prepare(tester);
403-
await tester.pump(loadPerAccountDuration);
404-
checkOnHomePage(tester, expectedAccount: eg.selfAccount);
400+
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
401+
try {
402+
addTearDown(testBinding.reset);
403+
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration;
404+
await prepare(tester);
405+
406+
// While loading, the loading page should be shown and have the semantics label.
407+
checkOnLoadingPage();
408+
expect(find.bySemanticsLabel('Loading…'), findsOneWidget);
409+
410+
await tester.pump(loadPerAccountDuration);
411+
checkOnHomePage(tester, expectedAccount: eg.selfAccount);
412+
} finally {
413+
semanticsHandle.dispose();
414+
}
405415
});
406416

407-
testWidgets('"Try another account" button appears after timeout', (tester) async {
408-
addTearDown(testBinding.reset);
409-
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration;
410-
await prepare(tester);
411-
checkOnLoadingPage();
412-
check(find.text('Try another account').hitTestable()).findsNothing();
413-
414-
await tester.pump(kTryAnotherAccountWaitPeriod);
415-
checkOnLoadingPage();
416-
check(find.text('Try another account').hitTestable()).findsOne();
417417

418-
await tester.pump(loadPerAccountDuration);
419-
checkOnHomePage(tester, expectedAccount: eg.selfAccount);
418+
testWidgets('"Try another account" button appears after timeout', (tester) async {
419+
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
420+
try {
421+
addTearDown(testBinding.reset);
422+
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration;
423+
await prepare(tester);
424+
checkOnLoadingPage();
425+
// No try-another button immediately.
426+
check(find.text('Try another account').hitTestable()).findsNothing();
427+
428+
// The loading semantics should already be present.
429+
expect(find.bySemanticsLabel('Loading…'), findsOneWidget);
430+
431+
await tester.pump(kTryAnotherAccountWaitPeriod);
432+
checkOnLoadingPage();
433+
check(find.text('Try another account').hitTestable()).findsOne();
434+
435+
await tester.pump(loadPerAccountDuration);
436+
checkOnHomePage(tester, expectedAccount: eg.selfAccount);
437+
} finally {
438+
semanticsHandle.dispose();
439+
}
420440
});
421441

422-
testWidgets('while loading, go back from ChooseAccountPage', (tester) async {
423-
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration;
424-
await prepare(tester);
425-
await tester.pump(kTryAnotherAccountWaitPeriod);
426-
await tapTryAnotherAccount(tester);
427442

428-
lastPoppedRoute = null;
429-
await tester.tap(find.byType(BackButton));
430-
await tester.pump();
431-
check(lastPoppedRoute).isA<MaterialWidgetRoute>().page.isA<ChooseAccountPage>();
432-
await tester.pump(
433-
(lastPoppedRoute as TransitionRoute).reverseTransitionDuration
434-
// TODO not sure why a 1ms fudge is needed; investigate.
435-
+ Duration(milliseconds: 1));
436-
checkOnLoadingPage();
437-
438-
await tester.pump(loadPerAccountDuration);
439-
checkOnHomePage(tester, expectedAccount: eg.selfAccount);
443+
testWidgets('while loading, go back from ChooseAccountPage', (tester) async {
444+
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
445+
try {
446+
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration;
447+
await prepare(tester);
448+
await tester.pump(kTryAnotherAccountWaitPeriod);
449+
await tapTryAnotherAccount(tester);
450+
451+
lastPoppedRoute = null;
452+
await tester.tap(find.byType(BackButton));
453+
await tester.pump();
454+
check(lastPoppedRoute).isA<MaterialWidgetRoute>().page.isA<ChooseAccountPage>();
455+
await tester.pump(
456+
(lastPoppedRoute as TransitionRoute).reverseTransitionDuration
457+
// TODO not sure why a 1ms fudge is needed; investigate.
458+
+ Duration(milliseconds: 1));
459+
checkOnLoadingPage();
460+
461+
// Semantics check: loading label present
462+
expect(find.bySemanticsLabel('Loading…'), findsOneWidget);
463+
464+
await tester.pump(loadPerAccountDuration);
465+
checkOnHomePage(tester, expectedAccount: eg.selfAccount);
466+
} finally {
467+
semanticsHandle.dispose();
468+
}
440469
});
441470

442-
testWidgets('while loading, choose a different account', (tester) async {
443-
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration;
444-
await prepare(tester);
445-
await tester.pump(kTryAnotherAccountWaitPeriod);
446-
await tapTryAnotherAccount(tester);
447471

448-
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration * 2;
449-
await chooseAccountWithEmail(tester, eg.otherAccount.email);
450-
451-
await tester.pump(loadPerAccountDuration);
452-
// The second loadPerAccount is still pending.
453-
checkOnLoadingPage();
454-
455-
await tester.pump(loadPerAccountDuration);
456-
// The second loadPerAccount finished.
457-
checkOnHomePage(tester, expectedAccount: eg.otherAccount);
472+
testWidgets('while loading, choose a different account', (tester) async {
473+
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
474+
try {
475+
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration;
476+
await prepare(tester);
477+
await tester.pump(kTryAnotherAccountWaitPeriod);
478+
await tapTryAnotherAccount(tester);
479+
480+
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration * 2;
481+
await chooseAccountWithEmail(tester, eg.otherAccount.email);
482+
483+
// While the second account is still loading, we should be on a loading page.
484+
await tester.pump(loadPerAccountDuration);
485+
checkOnLoadingPage();
486+
expect(find.bySemanticsLabel('Loading…'), findsOneWidget);
487+
488+
await tester.pump(loadPerAccountDuration);
489+
// The second load finished.
490+
checkOnHomePage(tester, expectedAccount: eg.otherAccount);
491+
} finally {
492+
semanticsHandle.dispose();
493+
}
458494
});
459495

460-
testWidgets('while loading, choosing an account disallows going back', (tester) async {
461-
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration;
462-
await prepare(tester);
463-
await tester.pump(kTryAnotherAccountWaitPeriod);
464-
await tapTryAnotherAccount(tester);
465-
466-
// While still loading, choose a different account.
467-
await chooseAccountWithEmail(tester, eg.otherAccount.email);
468496

469-
// User cannot go back because the navigator stack
470-
// was cleared after choosing an account.
471-
check(getRouteOf(tester, find.byType(CircularProgressIndicator)))
472-
.isNotNull().isFirst.isTrue();
473-
474-
await tester.pump(loadPerAccountDuration); // wait for loadPerAccount
475-
checkOnHomePage(tester, expectedAccount: eg.otherAccount);
497+
testWidgets('while loading, choosing an account disallows going back', (tester) async {
498+
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
499+
try {
500+
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration;
501+
await prepare(tester);
502+
await tester.pump(kTryAnotherAccountWaitPeriod);
503+
await tapTryAnotherAccount(tester);
504+
505+
// While still loading, choose a different account.
506+
await chooseAccountWithEmail(tester, eg.otherAccount.email);
507+
508+
// User cannot go back because the navigator stack was cleared after choosing an account.
509+
check(getRouteOf(tester, find.byType(CircularProgressIndicator)))
510+
.isNotNull().isFirst.isTrue();
511+
512+
// Semantics check: ensure loading label present and first route is our account route.
513+
expect(find.bySemanticsLabel('Loading…'), findsOneWidget);
514+
515+
await tester.pump(loadPerAccountDuration); // wait for loadPerAccount
516+
checkOnHomePage(tester, expectedAccount: eg.otherAccount);
517+
} finally {
518+
semanticsHandle.dispose();
519+
}
476520
});
477521

478-
testWidgets('while loading, go to nested levels of ChooseAccountPage', (tester) async {
479-
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration;
480-
final thirdAccount = eg.account(user: eg.thirdUser);
481-
await testBinding.globalStore.add(thirdAccount, eg.initialSnapshot(
482-
realmUsers: [eg.thirdUser]));
483-
await prepare(tester);
484522

485-
await tester.pump(kTryAnotherAccountWaitPeriod);
486-
// While still loading the first account, choose a different account.
487-
await tapTryAnotherAccount(tester);
488-
await chooseAccountWithEmail(tester, eg.otherAccount.email);
489-
// User cannot go back because the navigator stack
490-
// was cleared after choosing an account.
491-
check(getRouteOf(tester, find.byType(CircularProgressIndicator)))
492-
.isA<MaterialAccountWidgetRoute>()
523+
testWidgets('while loading, go to nested levels of ChooseAccountPage', (tester) async {
524+
final SemanticsHandle semanticsHandle = tester.ensureSemantics();
525+
try {
526+
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration;
527+
final thirdAccount = eg.account(user: eg.thirdUser);
528+
await testBinding.globalStore.add(thirdAccount, eg.initialSnapshot(
529+
realmUsers: [eg.thirdUser]));
530+
await prepare(tester);
531+
532+
await tester.pump(kTryAnotherAccountWaitPeriod);
533+
await tapTryAnotherAccount(tester);
534+
await chooseAccountWithEmail(tester, eg.otherAccount.email);
535+
check(getRouteOf(tester, find.byType(CircularProgressIndicator)))
536+
.isA<MaterialAccountWidgetRoute>()
493537
..isFirst.isTrue()
494538
..accountId.equals(eg.otherAccount.id);
495539

496-
await tester.pump(kTryAnotherAccountWaitPeriod);
497-
// While still loading the second account, choose a different account.
498-
await tapTryAnotherAccount(tester);
499-
await chooseAccountWithEmail(tester, thirdAccount.email);
500-
// User cannot go back because the navigator stack
501-
// was cleared after choosing an account.
502-
check(getRouteOf(tester, find.byType(CircularProgressIndicator)))
503-
.isA<MaterialAccountWidgetRoute>()
540+
// Semantics check: loading label present
541+
expect(find.bySemanticsLabel('Loading…'), findsOneWidget);
542+
543+
await tester.pump(kTryAnotherAccountWaitPeriod);
544+
await tapTryAnotherAccount(tester);
545+
await chooseAccountWithEmail(tester, thirdAccount.email);
546+
check(getRouteOf(tester, find.byType(CircularProgressIndicator)))
547+
.isA<MaterialAccountWidgetRoute>()
504548
..isFirst.isTrue()
505549
..accountId.equals(thirdAccount.id);
506550

507-
await tester.pump(loadPerAccountDuration); // wait for loadPerAccount
508-
checkOnHomePage(tester, expectedAccount: thirdAccount);
551+
await tester.pump(loadPerAccountDuration); // wait for loadPerAccount
552+
checkOnHomePage(tester, expectedAccount: thirdAccount);
553+
} finally {
554+
semanticsHandle.dispose();
555+
}
509556
});
510557

558+
511559
testWidgets('after finishing loading, go back from ChooseAccountPage', (tester) async {
512560
testBinding.globalStore.loadPerAccountDuration = loadPerAccountDuration;
513561
await prepare(tester);

0 commit comments

Comments
 (0)