fix(catalog): keyboard reach of the result list, tab order, stable select focus
Tiles are tab stops again, tab order is search field, filters, list (also with the side bar), selects keep focus after a choice. Failure kind is logged with stable names. Refs #3 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
80b548819b
commit
2d18a49fc2
9 changed files with 240 additions and 107 deletions
|
|
@ -101,13 +101,16 @@ class SupabaseCatalogRepository implements CatalogRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps and logs an error. Logs the failure kind and the error type only.
|
/// Maps and logs an error. Logs the failure kind only.
|
||||||
CatalogFailure _fail(String operation, Object error) {
|
CatalogFailure _fail(String operation, Object error) {
|
||||||
final failure = mapCatalogError(error);
|
final failure = mapCatalogError(error);
|
||||||
_log.warning(
|
// Stable names (runtime type names are minified on the web).
|
||||||
'Catalog $operation failed: ${failure.runtimeType} '
|
final kind = switch (failure) {
|
||||||
'(${error.runtimeType})',
|
CatalogNetworkFailure() => 'network',
|
||||||
);
|
CatalogInvalidInputFailure() => 'invalidInput',
|
||||||
|
CatalogUnexpectedFailure() => 'unexpected',
|
||||||
|
};
|
||||||
|
_log.warning('Catalog $operation failed: $kind');
|
||||||
return failure;
|
return failure;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,9 @@ class _CatalogSearchViewState extends ConsumerState<CatalogSearchView> {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
FocusTraversalOrder(
|
||||||
|
order: const NumericFocusOrder(1),
|
||||||
|
child: Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
padding.left,
|
padding.left,
|
||||||
padding.top,
|
padding.top,
|
||||||
|
|
@ -272,18 +274,28 @@ class _CatalogSearchViewState extends ConsumerState<CatalogSearchView> {
|
||||||
),
|
),
|
||||||
child: _buildHeader(state, showFilterButton: !expanded),
|
child: _buildHeader(state, showFilterButton: !expanded),
|
||||||
),
|
),
|
||||||
Expanded(child: _buildResults(state, padding)),
|
),
|
||||||
|
Expanded(
|
||||||
|
child: FocusTraversalOrder(
|
||||||
|
order: const NumericFocusOrder(3),
|
||||||
|
child: _buildResults(state, padding),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (!expanded) {
|
// Tab order (Spec #3 § 6): search field, filters, list. With the side
|
||||||
return main;
|
// bar left of the field the visual order alone would be wrong.
|
||||||
}
|
return FocusTraversalGroup(
|
||||||
return Row(
|
policy: OrderedTraversalPolicy(),
|
||||||
|
child: expanded
|
||||||
|
? Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
FocusTraversalOrder(
|
||||||
|
order: const NumericFocusOrder(2),
|
||||||
|
child: SizedBox(
|
||||||
width: _sidebarWidth,
|
width: _sidebarWidth,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
key: const Key('catalog_filter_sidebar'),
|
key: const Key('catalog_filter_sidebar'),
|
||||||
|
|
@ -291,9 +303,12 @@ class _CatalogSearchViewState extends ConsumerState<CatalogSearchView> {
|
||||||
child: _buildSidebar(state),
|
child: _buildSidebar(state),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const VerticalDivider(width: 1),
|
const VerticalDivider(width: 1),
|
||||||
Expanded(child: main),
|
Expanded(child: main),
|
||||||
],
|
],
|
||||||
|
)
|
||||||
|
: main,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -118,15 +118,18 @@ class _SingleChoice extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// A controlled drop-down (no form field that keeps its own value), so a
|
||||||
|
// selection does not rebuild the field and keyboard focus stays on it.
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: fieldKey,
|
key: fieldKey,
|
||||||
child: DropdownButtonFormField<int?>(
|
child: InputDecorator(
|
||||||
key: ValueKey(value),
|
// "Any" is a value too, so the label always floats.
|
||||||
// The field keeps its own value; a new key follows outside changes
|
isEmpty: false,
|
||||||
// (reset, URL).
|
|
||||||
initialValue: value,
|
|
||||||
isExpanded: true,
|
|
||||||
decoration: InputDecoration(labelText: label),
|
decoration: InputDecoration(labelText: label),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<int?>(
|
||||||
|
value: value,
|
||||||
|
isExpanded: true,
|
||||||
items: [
|
items: [
|
||||||
DropdownMenuItem<int?>(child: Text(anyLabel)),
|
DropdownMenuItem<int?>(child: Text(anyLabel)),
|
||||||
for (final option in options)
|
for (final option in options)
|
||||||
|
|
@ -137,6 +140,8 @@ class _SingleChoice extends StatelessWidget {
|
||||||
],
|
],
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ import 'package:shuffle/features/catalog/domain/catalog_game_summary.dart';
|
||||||
///
|
///
|
||||||
/// Shows a [GameCover] placeholder (no image URL in #3), title, year,
|
/// Shows a [GameCover] placeholder (no image URL in #3), title, year,
|
||||||
/// publishers and short facts; missing facts are left out. [onTap] is `null`
|
/// publishers and short facts; missing facts are left out. [onTap] is `null`
|
||||||
/// in #3: the tile is then not a button (no dead link) but still focusable
|
/// in #3: the tile is then not a button (no dead link) but stays a keyboard
|
||||||
/// programmatically ([focusNode]) so keyboard focus can move to the first
|
/// stop with a visible focus ring, so keyboard users can walk the list (which
|
||||||
/// newly loaded tile.
|
/// scrolls it and loads more) and focus can move to the first newly loaded
|
||||||
|
/// tile ([focusNode]).
|
||||||
class CatalogGameTile extends StatefulWidget {
|
class CatalogGameTile extends StatefulWidget {
|
||||||
const CatalogGameTile({
|
const CatalogGameTile({
|
||||||
required this.game,
|
required this.game,
|
||||||
|
|
@ -147,8 +148,6 @@ class _CatalogGameTileState extends State<CatalogGameTile> {
|
||||||
final onTap = widget.onTap;
|
final onTap = widget.onTap;
|
||||||
return Focus(
|
return Focus(
|
||||||
focusNode: widget.focusNode,
|
focusNode: widget.focusNode,
|
||||||
// Not a tab stop while the tile does nothing, but focusable by code.
|
|
||||||
skipTraversal: onTap == null,
|
|
||||||
onFocusChange: (focused) => setState(() => _focused = focused),
|
onFocusChange: (focused) => setState(() => _focused = focused),
|
||||||
child: Semantics(
|
child: Semantics(
|
||||||
container: true,
|
container: true,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:shuffle/features/catalog/domain/catalog_search_query.dart';
|
import 'package:shuffle/features/catalog/domain/catalog_search_query.dart';
|
||||||
|
|
||||||
import '../../catalog_test_support.dart';
|
import '../../catalog_test_support.dart';
|
||||||
import 'catalog_search_view_test.dart';
|
import 'catalog_search_test_support.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('announcements', () {
|
group('announcements', () {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import 'package:shuffle/features/catalog/domain/catalog_failure.dart';
|
||||||
import 'package:shuffle/features/catalog/domain/catalog_search_query.dart';
|
import 'package:shuffle/features/catalog/domain/catalog_search_query.dart';
|
||||||
|
|
||||||
import '../../catalog_test_support.dart';
|
import '../../catalog_test_support.dart';
|
||||||
import 'catalog_search_view_test.dart';
|
import 'catalog_search_test_support.dart';
|
||||||
|
|
||||||
Future<void> openFilterSheet(WidgetTester tester) async {
|
Future<void> openFilterSheet(WidgetTester tester) async {
|
||||||
await tester.tap(find.byKey(const Key('catalog_filter_button')));
|
await tester.tap(find.byKey(const Key('catalog_filter_button')));
|
||||||
|
|
@ -184,24 +184,127 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
group('keyboard', () {
|
group('keyboard', () {
|
||||||
testWidgets('QUE-06 Kriterium 4: Tab leads from the field to the filters', (
|
Future<void> tab(WidgetTester tester) async {
|
||||||
tester,
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
) async {
|
await tester.pump();
|
||||||
final repository = FakeCatalogRepository(all: [game(1)]);
|
}
|
||||||
|
|
||||||
|
bool focusIsIn(Finder finder) {
|
||||||
|
final focused = FocusManager.instance.primaryFocus?.context;
|
||||||
|
return focused != null &&
|
||||||
|
find
|
||||||
|
.descendant(
|
||||||
|
of: finder,
|
||||||
|
matching: find.byElementPredicate((e) => e == focused),
|
||||||
|
)
|
||||||
|
.evaluate()
|
||||||
|
.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tile's `Focus` sits above its keyed card.
|
||||||
|
bool focusOnTile(String id) {
|
||||||
|
final focused = FocusManager.instance.primaryFocus?.context;
|
||||||
|
return focused != null &&
|
||||||
|
find
|
||||||
|
.ancestor(
|
||||||
|
of: find.byKey(Key('catalog_tile_$id')),
|
||||||
|
matching: find.byElementPredicate((e) => e == focused),
|
||||||
|
)
|
||||||
|
.evaluate()
|
||||||
|
.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
double scrollOffset(WidgetTester tester) => tester
|
||||||
|
.state<ScrollableState>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('catalog_result_list')),
|
||||||
|
matching: find.byType(Scrollable),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.position
|
||||||
|
.pixels;
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'QUE-06 Kriterium 4: Tab walks field, filters, list (compact)',
|
||||||
|
(tester) async {
|
||||||
|
final repository = FakeCatalogRepository(all: List.generate(75, game));
|
||||||
await pumpView(tester, repository);
|
await pumpView(tester, repository);
|
||||||
|
|
||||||
await tester.tap(searchInput);
|
await tester.tap(searchInput);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
await tab(tester);
|
||||||
await tester.pump();
|
|
||||||
final focused = FocusManager.instance.primaryFocus!.context;
|
|
||||||
expect(
|
expect(
|
||||||
find.descendant(
|
focusIsIn(find.byKey(const Key('catalog_filter_button'))),
|
||||||
of: find.byKey(const Key('catalog_filter_button')),
|
isTrue,
|
||||||
matching: find.byElementPredicate((e) => e == focused),
|
|
||||||
),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await tab(tester);
|
||||||
|
expect(focusOnTile('game-0'), isTrue);
|
||||||
|
|
||||||
|
// Walking on scrolls the list (and so loads more).
|
||||||
|
for (var i = 0; i < 12; i++) {
|
||||||
|
await tab(tester);
|
||||||
|
}
|
||||||
|
expect(scrollOffset(tester), greaterThan(0));
|
||||||
|
expect(focusOnTile('game-12'), isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'QUE-06 Kriterium 4: Tab order is field, filters, list (side bar)',
|
||||||
|
(tester) async {
|
||||||
|
final repository = FakeCatalogRepository(all: List.generate(75, game));
|
||||||
|
await pumpView(tester, repository, width: 1200);
|
||||||
|
|
||||||
|
await tester.tap(searchInput);
|
||||||
|
await tester.pump();
|
||||||
|
await tab(tester);
|
||||||
|
expect(
|
||||||
|
focusIsIn(find.byKey(const Key('catalog_filter_sidebar'))),
|
||||||
|
isTrue,
|
||||||
|
reason: 'the filters come right after the search field',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Through the filters (3 selects, 2 chip groups, reset) to the list.
|
||||||
|
var steps = 0;
|
||||||
|
while (!focusOnTile('game-0') && steps++ < 30) {
|
||||||
|
await tab(tester);
|
||||||
|
}
|
||||||
|
expect(focusOnTile('game-0'), isTrue);
|
||||||
|
expect(steps, lessThan(30));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('a selection keeps keyboard focus on the select', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final repository = FakeCatalogRepository(all: [game(1)]);
|
||||||
|
await pumpView(tester, repository);
|
||||||
|
await openFilterSheet(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('catalog_filter_players')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('4 Spieler').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(repository.calls.last.query.filters.players, 4);
|
||||||
|
expect(
|
||||||
|
focusIsIn(find.byKey(const Key('catalog_filter_players'))),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the sheet stays open while filters change the URL state', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final repository = FakeCatalogRepository(all: [game(1)]);
|
||||||
|
await pumpView(tester, repository);
|
||||||
|
await openFilterSheet(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('catalog_term_$genreFamily')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('catalog_filter_sheet')), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import 'package:shuffle/features/catalog/presentation/search/catalog_search_view
|
||||||
import '../../../../helpers/pump_app.dart';
|
import '../../../../helpers/pump_app.dart';
|
||||||
import '../../../../helpers/router_helpers.dart';
|
import '../../../../helpers/router_helpers.dart';
|
||||||
import '../../catalog_test_support.dart';
|
import '../../catalog_test_support.dart';
|
||||||
import 'catalog_search_view_test.dart';
|
import 'catalog_search_test_support.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('route /catalog', () {
|
group('route /catalog', () {
|
||||||
|
|
@ -187,6 +187,8 @@ void main() {
|
||||||
final params = currentUri(router).queryParameters;
|
final params = currentUri(router).queryParameters;
|
||||||
expect(params['q'], 'alt');
|
expect(params['q'], 'alt');
|
||||||
expect(params['genre'], genreFamily);
|
expect(params['genre'], genreFamily);
|
||||||
|
// Writing the URL does not close the open filter sheet.
|
||||||
|
expect(find.byKey(const Key('catalog_filter_sheet')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('outside the web nothing is written', (tester) async {
|
testWidgets('outside the web nothing is written', (tester) async {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shuffle/features/catalog/domain/catalog_search_query.dart';
|
||||||
|
import 'package:shuffle/features/catalog/presentation/search/catalog_search_view.dart';
|
||||||
|
|
||||||
|
import '../../../../helpers/pump_app.dart';
|
||||||
|
import '../../catalog_test_support.dart';
|
||||||
|
|
||||||
|
/// Sets a logical window size for the test (device pixel ratio 1).
|
||||||
|
void useWindow(WidgetTester tester, double width, [double height = 900]) {
|
||||||
|
tester.view.physicalSize = Size(width, height);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pumpView(
|
||||||
|
WidgetTester tester,
|
||||||
|
FakeCatalogRepository repository, {
|
||||||
|
CatalogSearchQuery initialQuery = const CatalogSearchQuery(),
|
||||||
|
ValueChanged<String>? onReportGame,
|
||||||
|
Locale locale = const Locale('de'),
|
||||||
|
double? textScale,
|
||||||
|
double width = 420,
|
||||||
|
bool settle = true,
|
||||||
|
}) async {
|
||||||
|
useWindow(tester, width);
|
||||||
|
await pumpApp(
|
||||||
|
tester,
|
||||||
|
locale: locale,
|
||||||
|
textScale: textScale,
|
||||||
|
overrides: catalogOverrides(repository),
|
||||||
|
settle: false,
|
||||||
|
home: Scaffold(
|
||||||
|
body: CatalogSearchView(
|
||||||
|
initialQuery: initialQuery,
|
||||||
|
onReportGame: onReportGame,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (settle) {
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Finder get searchInput => find.descendant(
|
||||||
|
of: find.byKey(const Key('catalog_search_field')),
|
||||||
|
matching: find.byType(TextField),
|
||||||
|
);
|
||||||
|
|
@ -4,51 +4,9 @@ import 'package:shuffle/core/widgets/game_cover.dart';
|
||||||
import 'package:shuffle/features/catalog/domain/catalog_failure.dart';
|
import 'package:shuffle/features/catalog/domain/catalog_failure.dart';
|
||||||
import 'package:shuffle/features/catalog/domain/catalog_search_query.dart';
|
import 'package:shuffle/features/catalog/domain/catalog_search_query.dart';
|
||||||
import 'package:shuffle/features/catalog/presentation/search/catalog_search_controller.dart';
|
import 'package:shuffle/features/catalog/presentation/search/catalog_search_controller.dart';
|
||||||
import 'package:shuffle/features/catalog/presentation/search/catalog_search_view.dart';
|
|
||||||
|
|
||||||
import '../../../../helpers/pump_app.dart';
|
|
||||||
import '../../catalog_test_support.dart';
|
import '../../catalog_test_support.dart';
|
||||||
|
import 'catalog_search_test_support.dart';
|
||||||
/// Sets a logical window size for the test (device pixel ratio 1).
|
|
||||||
void useWindow(WidgetTester tester, double width, [double height = 900]) {
|
|
||||||
tester.view.physicalSize = Size(width, height);
|
|
||||||
tester.view.devicePixelRatio = 1;
|
|
||||||
addTearDown(tester.view.reset);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> pumpView(
|
|
||||||
WidgetTester tester,
|
|
||||||
FakeCatalogRepository repository, {
|
|
||||||
CatalogSearchQuery initialQuery = const CatalogSearchQuery(),
|
|
||||||
ValueChanged<String>? onReportGame,
|
|
||||||
Locale locale = const Locale('de'),
|
|
||||||
double? textScale,
|
|
||||||
double width = 420,
|
|
||||||
bool settle = true,
|
|
||||||
}) async {
|
|
||||||
useWindow(tester, width);
|
|
||||||
await pumpApp(
|
|
||||||
tester,
|
|
||||||
locale: locale,
|
|
||||||
textScale: textScale,
|
|
||||||
overrides: catalogOverrides(repository),
|
|
||||||
settle: false,
|
|
||||||
home: Scaffold(
|
|
||||||
body: CatalogSearchView(
|
|
||||||
initialQuery: initialQuery,
|
|
||||||
onReportGame: onReportGame,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (settle) {
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Finder get searchInput => find.descendant(
|
|
||||||
of: find.byKey(const Key('catalog_search_field')),
|
|
||||||
matching: find.byType(TextField),
|
|
||||||
);
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('list and tiles', () {
|
group('list and tiles', () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue