fix(a11y): label interactive controls

This commit is contained in:
zarzet
2026-08-04 16:40:48 +07:00
parent d3f9388ba8
commit 6742fe886f
35 changed files with 319 additions and 58 deletions
+12
View File
@@ -7434,6 +7434,18 @@ abstract class AppLocalizations {
/// **'Up next'**
String get nowPlayingUpNext;
/// Tooltip for the previous-track playback control
///
/// In en, this message translates to:
/// **'Previous track'**
String get nowPlayingPreviousTrack;
/// Tooltip for the next-track playback control
///
/// In en, this message translates to:
/// **'Next track'**
String get nowPlayingNextTrack;
/// Menu item and section title for track metadata details
///
/// In en, this message translates to:
+6
View File
@@ -4580,6 +4580,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingPreviousTrack => 'Vorheriger Titel';
@override
String get nowPlayingNextTrack => 'Nächster Titel';
@override
String get nowPlayingDetails => 'Details';
+6
View File
@@ -4535,6 +4535,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingPreviousTrack => 'Previous track';
@override
String get nowPlayingNextTrack => 'Next track';
@override
String get nowPlayingDetails => 'Details';
+12
View File
@@ -4529,6 +4529,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingPreviousTrack => 'Pista anterior';
@override
String get nowPlayingNextTrack => 'Pista siguiente';
@override
String get nowPlayingDetails => 'Details';
@@ -9255,6 +9261,12 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingPreviousTrack => 'Pista anterior';
@override
String get nowPlayingNextTrack => 'Pista siguiente';
@override
String get nowPlayingDetails => 'Details';
+6
View File
@@ -4649,6 +4649,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get nowPlayingUpNext => 'À suivre';
@override
String get nowPlayingPreviousTrack => 'Piste précédente';
@override
String get nowPlayingNextTrack => 'Piste suivante';
@override
String get nowPlayingDetails => 'Détails';
+6
View File
@@ -4533,6 +4533,12 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingPreviousTrack => 'Lagu sebelumnya';
@override
String get nowPlayingNextTrack => 'Lagu berikutnya';
@override
String get nowPlayingDetails => 'Details';
+6
View File
@@ -4524,6 +4524,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingPreviousTrack => '前の曲';
@override
String get nowPlayingNextTrack => '次の曲';
@override
String get nowPlayingDetails => 'Details';
+6
View File
@@ -4409,6 +4409,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get nowPlayingUpNext => '다음 곡';
@override
String get nowPlayingPreviousTrack => '이전 곡';
@override
String get nowPlayingNextTrack => '다음 곡';
@override
String get nowPlayingDetails => '트랙 세부 정보';
+12
View File
@@ -4529,6 +4529,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingPreviousTrack => 'Faixa anterior';
@override
String get nowPlayingNextTrack => 'Próxima faixa';
@override
String get nowPlayingDetails => 'Details';
@@ -9220,6 +9226,12 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingPreviousTrack => 'Faixa anterior';
@override
String get nowPlayingNextTrack => 'Próxima faixa';
@override
String get nowPlayingDetails => 'Details';
+6
View File
@@ -4566,6 +4566,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingPreviousTrack => 'Предыдущий трек';
@override
String get nowPlayingNextTrack => 'Следующий трек';
@override
String get nowPlayingDetails => 'Details';
+6
View File
@@ -4565,6 +4565,12 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingPreviousTrack => 'Önceki parça';
@override
String get nowPlayingNextTrack => 'Sonraki parça';
@override
String get nowPlayingDetails => 'Details';
+6
View File
@@ -4583,6 +4583,12 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get nowPlayingUpNext => 'Up next';
@override
String get nowPlayingPreviousTrack => 'Попередній трек';
@override
String get nowPlayingNextTrack => 'Наступний трек';
@override
String get nowPlayingDetails => 'Details';
+2
View File
@@ -4395,6 +4395,8 @@
"@nowPlayingUpNext": {
"description": "Title for the playback queue sheet"
},
"nowPlayingPreviousTrack": "Vorheriger Titel",
"nowPlayingNextTrack": "Nächster Titel",
"logAutoScrollOff": "Auto-Scrollen AUS",
"trackConvertFailed": "Konvertierung fehlgeschlagen",
"@trackConvertFailed": {
+8
View File
@@ -5861,6 +5861,14 @@
"@nowPlayingUpNext": {
"description": "Title for the playback queue sheet"
},
"nowPlayingPreviousTrack": "Previous track",
"@nowPlayingPreviousTrack": {
"description": "Tooltip for the previous-track playback control"
},
"nowPlayingNextTrack": "Next track",
"@nowPlayingNextTrack": {
"description": "Tooltip for the next-track playback control"
},
"nowPlayingDetails": "Details",
"@nowPlayingDetails": {
"description": "Menu item and section title for track metadata details"
+3 -1
View File
@@ -4286,5 +4286,7 @@
"settingsGroupContent": "Contenido y metadatos",
"settingsGroupDownloads": "Descargas y archivos",
"settingsGroupSystem": "Sistema",
"settingsGroupHelp": "Información y soporte"
"settingsGroupHelp": "Información y soporte",
"nowPlayingPreviousTrack": "Pista anterior",
"nowPlayingNextTrack": "Pista siguiente"
}
+2
View File
@@ -5502,6 +5502,8 @@
"@nowPlayingUpNext": {
"description": "Title for the playback queue sheet"
},
"nowPlayingPreviousTrack": "Pista anterior",
"nowPlayingNextTrack": "Pista siguiente",
"nowPlayingDetails": "Details",
"@nowPlayingDetails": {
"description": "Menu item and section title for track metadata details"
+2
View File
@@ -4395,6 +4395,8 @@
"@nowPlayingUpNext": {
"description": "Title for the playback queue sheet"
},
"nowPlayingPreviousTrack": "Piste précédente",
"nowPlayingNextTrack": "Piste suivante",
"logAutoScrollOff": "Défilement automatique désactivé",
"trackConvertFailed": "Échec de la conversion",
"@trackConvertFailed": {
+8
View File
@@ -4440,6 +4440,14 @@
"@nowPlayingUpNext": {
"description": "Title for the playback queue sheet"
},
"nowPlayingPreviousTrack": "Lagu sebelumnya",
"@nowPlayingPreviousTrack": {
"description": "Tooltip for the previous-track playback control"
},
"nowPlayingNextTrack": "Lagu berikutnya",
"@nowPlayingNextTrack": {
"description": "Tooltip for the next-track playback control"
},
"logAutoScrollOff": "Auto-scroll OFF",
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
+2
View File
@@ -4395,6 +4395,8 @@
"@nowPlayingUpNext": {
"description": "Title for the playback queue sheet"
},
"nowPlayingPreviousTrack": "前の曲",
"nowPlayingNextTrack": "次の曲",
"logAutoScrollOff": "Auto-scroll OFF",
"trackConvertFailed": "変換に失敗しました",
"@trackConvertFailed": {
+2
View File
@@ -5641,6 +5641,8 @@
"@nowPlayingUpNext": {
"description": "Title for the playback queue sheet"
},
"nowPlayingPreviousTrack": "이전 곡",
"nowPlayingNextTrack": "다음 곡",
"nowPlayingDetails": "트랙 세부 정보",
"@nowPlayingDetails": {
"description": "Menu item and section title for track metadata details"
+3 -1
View File
@@ -4286,5 +4286,7 @@
"settingsGroupContent": "Conteúdo e metadados",
"settingsGroupDownloads": "Downloads e arquivos",
"settingsGroupSystem": "Sistema",
"settingsGroupHelp": "Sobre e suporte"
"settingsGroupHelp": "Sobre e suporte",
"nowPlayingPreviousTrack": "Faixa anterior",
"nowPlayingNextTrack": "Próxima faixa"
}
+2
View File
@@ -4395,6 +4395,8 @@
"@nowPlayingUpNext": {
"description": "Title for the playback queue sheet"
},
"nowPlayingPreviousTrack": "Faixa anterior",
"nowPlayingNextTrack": "Próxima faixa",
"logAutoScrollOff": "Auto-scroll OFF",
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {
+2
View File
@@ -4395,6 +4395,8 @@
"@nowPlayingUpNext": {
"description": "Title for the playback queue sheet"
},
"nowPlayingPreviousTrack": "Предыдущий трек",
"nowPlayingNextTrack": "Следующий трек",
"logAutoScrollOff": "Auto-scroll OFF",
"trackConvertFailed": "Ошибка конвертации",
"@trackConvertFailed": {
+2
View File
@@ -4395,6 +4395,8 @@
"@nowPlayingUpNext": {
"description": "Title for the playback queue sheet"
},
"nowPlayingPreviousTrack": "Önceki parça",
"nowPlayingNextTrack": "Sonraki parça",
"logAutoScrollOff": "Auto-scroll OFF",
"trackConvertFailed": "Dönüştürme başarısız oldu",
"@trackConvertFailed": {
+2
View File
@@ -4395,6 +4395,8 @@
"@nowPlayingUpNext": {
"description": "Title for the playback queue sheet"
},
"nowPlayingPreviousTrack": "Попередній трек",
"nowPlayingNextTrack": "Наступний трек",
"logAutoScrollOff": "Auto-scroll OFF",
"trackConvertFailed": "Конвертація не вдалася",
"@trackConvertFailed": {
@@ -639,6 +639,7 @@ class _LibraryTracksFolderScreenState
},
),
IconButton(
tooltip: context.l10n.collectionPlaylistChangeCover,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
+6
View File
@@ -337,6 +337,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
return Scaffold(
appBar: AppBar(
leading: IconButton(
tooltip: context.l10n.nowPlayingMinimize,
icon: const Icon(Icons.keyboard_arrow_down),
onPressed: () => Navigator.of(context).maybePop(),
),
@@ -1217,6 +1218,7 @@ class _PlaybackControls extends ConsumerWidget {
const SizedBox(width: 8),
IconButton(
iconSize: 44,
tooltip: context.l10n.nowPlayingPreviousTrack,
icon: const Icon(Icons.skip_previous),
onPressed: controller.previous,
),
@@ -1230,6 +1232,9 @@ class _PlaybackControls extends ConsumerWidget {
iconSize: 44,
padding: const EdgeInsets.all(12),
color: colorScheme.onPrimary,
tooltip: isPlaying
? context.l10n.actionPause
: context.l10n.tooltipPlay,
icon: isLoading
? const SizedBox.square(
dimension: 32,
@@ -1244,6 +1249,7 @@ class _PlaybackControls extends ConsumerWidget {
const SizedBox(width: 20),
IconButton(
iconSize: 44,
tooltip: context.l10n.nowPlayingNextTrack,
icon: const Icon(Icons.skip_next),
onPressed: controller.next,
),
+20 -10
View File
@@ -627,16 +627,26 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState {
child: Row(
children: [
if (_isPlaylistSelectionMode)
GestureDetector(
onTap: () => _togglePlaylistSelection(playlist.id),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
Semantics(
button: true,
checked: isSelected,
label: playlist.name,
child: GestureDetector(
onTap: () => _togglePlaylistSelection(playlist.id),
behavior: HitTestBehavior.opaque,
child: ConstrainedBox(
constraints: BoxConstraints.tightFor(
width: context.tokens.minTouchTarget,
height: context.tokens.minTouchTarget,
),
child: Center(
child: AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
),
),
),
),
),
+8 -12
View File
@@ -1063,21 +1063,17 @@ extension _QueueTabItemWidgets on _QueueTabState {
valueListenable: fileExistsListenable,
builder: (context, fileExists, child) {
if (fileExists) {
return Semantics(
button: true,
label: context.l10n.a11yPlayTrackByArtist(
return IconButton.filled(
tooltip: context.l10n.a11yPlayTrackByArtist(
item.trackName,
item.artistName,
),
child: IconButton.filled(
onPressed: () => _playLibraryItem(item, libraryItems),
visualDensity: VisualDensity.compact,
iconSize: 18,
icon: const Icon(Icons.play_arrow),
style: IconButton.styleFrom(
minimumSize: const Size.square(36),
padding: EdgeInsets.zero,
),
onPressed: () => _playLibraryItem(item, libraryItems),
iconSize: 18,
icon: const Icon(Icons.play_arrow),
style: IconButton.styleFrom(
minimumSize: Size.square(context.tokens.minTouchTarget),
padding: EdgeInsets.zero,
),
);
}
+3 -3
View File
@@ -728,7 +728,7 @@ class _ExtensionItem extends StatelessWidget {
onPressed: onUpdate,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 40),
minimumSize: Size(0, context.tokens.minTouchTarget),
),
child: Text(context.l10n.storeUpdate),
)
@@ -737,7 +737,7 @@ class _ExtensionItem extends StatelessWidget {
onPressed: null,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 40),
minimumSize: Size(0, context.tokens.minTouchTarget),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -755,7 +755,7 @@ class _ExtensionItem extends StatelessWidget {
onPressed: onInstall,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 40),
minimumSize: Size(0, context.tokens.minTouchTarget),
),
child: Text(context.l10n.storeInstall),
),
+5
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/screens/now_playing_screen.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
@@ -113,6 +114,9 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
),
),
IconButton(
tooltip: isPlaying
? context.l10n.actionPause
: context.l10n.tooltipPlay,
icon: isLoading
? const SizedBox.square(
dimension: 20,
@@ -126,6 +130,7 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
: () => controller.togglePlayPause(isPlaying),
),
IconButton(
tooltip: context.l10n.nowPlayingNextTrack,
icon: const Icon(Icons.skip_next),
onPressed: controller.next,
),
+44 -30
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
/// Icon+label pill used in selection-mode bottom bars. Disabled state dims
/// both the fill and the content to 50% alpha.
@@ -19,42 +20,55 @@ class SelectionActionButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDisabled = onPressed == null;
return Material(
color: isDisabled
? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5)
: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onPressed,
return Semantics(
button: true,
enabled: !isDisabled,
label: label,
excludeSemantics: true,
child: Material(
color: isDisabled
? colorScheme.surfaceContainerHighest.withValues(alpha: 0.5)
: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 18,
color: isDisabled
? colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(14),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: context.tokens.minTouchTarget,
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 18,
color: isDisabled
? colorScheme.onSurfaceVariant.withValues(alpha: 0.5)
: colorScheme.onSecondaryContainer,
),
),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDisabled
? colorScheme.onSurfaceVariant.withValues(
alpha: 0.5,
)
: colorScheme.onSecondaryContainer,
),
),
),
],
),
],
),
),
),
),
+1 -1
View File
@@ -18,7 +18,7 @@ packages:
source: hosted
version: "0.3.14"
analyzer:
dependency: transitive
dependency: "direct dev"
description:
name: analyzer
sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
+1
View File
@@ -72,6 +72,7 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
analyzer: ^12.1.0
flutter_lints: ^6.0.0
build_runner: ^2.15.1
riverpod_lint: ^3.1.4
+100
View File
@@ -1,5 +1,10 @@
import 'dart:io';
import 'package:analyzer/dart/analysis/features.dart';
import 'package:analyzer/dart/analysis/utilities.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/source/line_info.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
@@ -10,6 +15,7 @@ import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/app_search_field.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
import 'package:spotiflac_android/widgets/collection_scaffold.dart';
import 'package:spotiflac_android/widgets/selection_action_button.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/track_card.dart';
@@ -27,6 +33,34 @@ List<File> _libSources() {
String _basename(File file) => file.uri.pathSegments.last;
class _IconButtonTooltipVisitor extends RecursiveAstVisitor<void> {
_IconButtonTooltipVisitor({
required this.file,
required this.lineInfo,
required this.offenders,
});
final File file;
final LineInfo lineInfo;
final List<String> offenders;
@override
void visitInstanceCreationExpression(InstanceCreationExpression node) {
if (node.constructorName.type.name.lexeme == 'IconButton') {
final hasTooltip = node.argumentList.arguments.any(
(argument) =>
argument is NamedExpression &&
argument.name.label.name == 'tooltip',
);
if (!hasTooltip) {
final line = lineInfo.getLocation(node.offset).lineNumber;
offenders.add('${file.path}:$line');
}
}
super.visitInstanceCreationExpression(node);
}
}
Widget _hostSliver(Widget sliver) {
return MaterialApp(
theme: AppTheme.light(),
@@ -84,6 +118,72 @@ void main() {
});
});
group('accessibility contracts', () {
test('every IconButton has a tooltip-backed accessible name', () {
final offenders = <String>[];
for (final file in _libSources()) {
final result = parseFile(
path: file.absolute.path,
featureSet: FeatureSet.latestLanguageVersion(),
);
result.unit.accept(
_IconButtonTooltipVisitor(
file: file,
lineInfo: result.lineInfo,
offenders: offenders,
),
);
}
expect(
offenders,
isEmpty,
reason:
'Icon-only controls must expose a tooltip so TalkBack, VoiceOver, '
'keyboard users, and pointer users receive the same action name.',
);
});
testWidgets('selection actions expose their label and disabled state', (
tester,
) async {
final semantics = tester.ensureSemantics();
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Center(
child: SelectionActionButton(
icon: Icons.delete,
label: 'Delete selected tracks',
onPressed: null,
colorScheme: AppTheme.light().colorScheme,
),
),
),
),
);
expect(
tester.getSemantics(find.byType(SelectionActionButton)),
matchesSemantics(
label: 'Delete selected tracks',
isButton: true,
hasEnabledState: true,
isEnabled: false,
),
);
expect(
tester.getSize(find.byType(SelectionActionButton)).height,
greaterThanOrEqualTo(AppTokens.standard.minTouchTarget),
);
} finally {
semantics.dispose();
}
});
});
group('AppSearchField', () {
testWidgets('uses the shared filled search style and clears input', (
tester,