Skip to content

Commit 012c232

Browse files
committed
fix(a11y): announce formatted category names in the filter bar
The category filter button renders formatted names ('International Beer') but its semanticLabel joined the raw category ids, so a screen reader announced 'international-beer'. Sighted and screen-reader users now get the same names, sorted so the announcement is deterministic (a Set has no defined order). Adds semantic tests pinning the label in all three states.
1 parent 95ddc4b commit 012c232

2 files changed

Lines changed: 157 additions & 7 deletions

File tree

lib/screens/drinks_screen.dart

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,18 @@ class _DrinksScreenState extends State<DrinksScreen> {
143143
: provider.selectedStyles.length == 1
144144
? provider.selectedStyles.first
145145
: '${provider.selectedStyles.length} styles';
146+
// Formatted and sorted so the screen reader announces the same names a
147+
// sighted user sees, in a deterministic order (a Set has none).
148+
final formattedCategories =
149+
provider.selectedCategories
150+
.map(BeverageTypeHelper.formatBeverageType)
151+
.toList()
152+
..sort();
146153
final categoryLabel = provider.selectedCategories.isEmpty
147154
? 'Category'
148-
: provider.selectedCategories.length == 1
149-
? BeverageTypeHelper.formatBeverageType(
150-
provider.selectedCategories.first,
151-
)
152-
: '${provider.selectedCategories.length} categories';
155+
: formattedCategories.length == 1
156+
? formattedCategories.first
157+
: '${formattedCategories.length} categories';
153158

154159
return Container(
155160
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
@@ -158,9 +163,9 @@ class _DrinksScreenState extends State<DrinksScreen> {
158163
Expanded(
159164
child: FilterButton(
160165
label: categoryLabel,
161-
semanticLabel: provider.selectedCategories.isEmpty
166+
semanticLabel: formattedCategories.isEmpty
162167
? 'Filter by category'
163-
: 'Filter by category: ${provider.selectedCategories.join(', ')}',
168+
: 'Filter by category: ${formattedCategories.join(', ')}',
164169
icon: Icons.filter_list,
165170
onPressed: () => showCategoryFilter(context),
166171
isActive: provider.selectedCategories.isNotEmpty,

test/drinks_screen_style_filter_test.dart

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:cambridge_beer_festival/screens/screens.dart';
55
import 'package:cambridge_beer_festival/models/models.dart';
66
import 'package:cambridge_beer_festival/providers/providers.dart';
77
import 'package:cambridge_beer_festival/services/services.dart';
8+
import 'package:cambridge_beer_festival/widgets/widgets.dart';
89
import 'package:go_router/go_router.dart';
910
import 'package:provider/provider.dart';
1011
import 'package:mockito/mockito.dart';
@@ -637,4 +638,148 @@ void main() {
637638
provider.dispose();
638639
});
639640
});
641+
642+
// The filter bar shows formatted category names ('International Beer'), so
643+
// the screen reader must announce those same names rather than the raw
644+
// category ids used as map keys ('international-beer').
645+
group('DrinksScreen category filter button label', () {
646+
late MockDrinkRepository mockDrinkRepository;
647+
late MockFestivalRepository mockFestivalRepository;
648+
late MockAnalyticsService mockAnalyticsService;
649+
late BeerProvider provider;
650+
651+
const testFestival = Festival(
652+
id: 'cbf2025',
653+
name: 'Cambridge Beer Festival 2025',
654+
dataBaseUrl: 'https://test.example.com/cbf2025',
655+
);
656+
657+
Drink drinkIn(String category, String id) => Drink(
658+
product: Product(
659+
id: id,
660+
name: 'Drink $id',
661+
abv: 5,
662+
category: category,
663+
dispense: 'cask',
664+
),
665+
producer: const Producer(
666+
id: 'brewery1',
667+
name: 'Test Brewery',
668+
location: 'Cambridge',
669+
products: [],
670+
),
671+
festivalId: 'cbf2025',
672+
);
673+
674+
setUp(() async {
675+
SharedPreferences.setMockInitialValues({});
676+
mockDrinkRepository = MockDrinkRepository();
677+
mockFestivalRepository = MockFestivalRepository();
678+
mockAnalyticsService = MockAnalyticsService();
679+
680+
when(mockFestivalRepository.getFestivals()).thenAnswer(
681+
(_) async => FestivalsResponse(
682+
festivals: [testFestival],
683+
defaultFestivalId: 'cbf2025',
684+
baseUrl: 'https://example.com',
685+
version: '1.0.0',
686+
),
687+
);
688+
when(
689+
mockFestivalRepository.getSelectedFestivalId(),
690+
).thenAnswer((_) async => null);
691+
when(mockDrinkRepository.getDrinks(any)).thenAnswer(
692+
(_) async => [
693+
drinkIn('international-beer', 'd1'),
694+
drinkIn('cider', 'd2'),
695+
],
696+
);
697+
698+
provider = BeerProvider(
699+
drinkRepository: mockDrinkRepository,
700+
festivalRepository: mockFestivalRepository,
701+
analyticsService: mockAnalyticsService,
702+
);
703+
await provider.initialize();
704+
await provider.loadDrinks();
705+
});
706+
707+
tearDown(() {
708+
provider.dispose();
709+
});
710+
711+
// Drink cards render their own category chip, so the category name can
712+
// appear more than once on screen. Scope assertions to the filter bar.
713+
Finder buttonText(String text) => find.descendant(
714+
of: find.byType(FilterButton),
715+
matching: find.text(text),
716+
);
717+
718+
Future<void> pumpScreen(WidgetTester tester) async {
719+
await tester.pumpWidget(
720+
ChangeNotifierProvider<BeerProvider>.value(
721+
value: provider,
722+
child: const MaterialApp(home: DrinksScreen(festivalId: 'cbf2025')),
723+
),
724+
);
725+
await tester.pumpAndSettle();
726+
}
727+
728+
testWidgets('reads "Category" with no selection', (tester) async {
729+
await pumpScreen(tester);
730+
731+
expect(buttonText('Category'), findsOneWidget);
732+
expect(find.bySemanticsLabel('Filter by category'), findsOneWidget);
733+
});
734+
735+
testWidgets('announces the formatted name for a single selection', (
736+
tester,
737+
) async {
738+
await pumpScreen(tester);
739+
provider.toggleCategory('international-beer');
740+
await tester.pumpAndSettle();
741+
742+
expect(buttonText('International Beer'), findsOneWidget);
743+
expect(
744+
find.bySemanticsLabel('Filter by category: International Beer'),
745+
findsOneWidget,
746+
);
747+
// The raw id must not reach the user, visibly or audibly.
748+
expect(buttonText('international-beer'), findsNothing);
749+
expect(
750+
find.bySemanticsLabel('Filter by category: international-beer'),
751+
findsNothing,
752+
);
753+
});
754+
755+
testWidgets('announces every formatted name, sorted, for a multi '
756+
'selection while the visible label counts them', (tester) async {
757+
await pumpScreen(tester);
758+
provider
759+
..toggleCategory('international-beer')
760+
..toggleCategory('cider');
761+
await tester.pumpAndSettle();
762+
763+
expect(buttonText('2 categories'), findsOneWidget);
764+
expect(
765+
find.bySemanticsLabel('Filter by category: Cider, International Beer'),
766+
findsOneWidget,
767+
);
768+
});
769+
770+
testWidgets('returns to "Category" once the selection is cleared', (
771+
tester,
772+
) async {
773+
await pumpScreen(tester);
774+
provider.toggleCategory('cider');
775+
await tester.pumpAndSettle();
776+
expect(buttonText('Cider'), findsOneWidget);
777+
778+
provider.clearCategories();
779+
await tester.pumpAndSettle();
780+
781+
expect(buttonText('Category'), findsOneWidget);
782+
expect(find.bySemanticsLabel('Filter by category'), findsOneWidget);
783+
});
784+
});
640785
}

0 commit comments

Comments
 (0)