a11y: decorative/meaningful image semantics, labeled icon buttons, audible addresses (#3465)

This commit is contained in:
Seth For Privacy
2026-08-20 16:40:07 +02:00
committed by GitHub
parent 3b100eae33
commit c9974673c1
12 changed files with 234 additions and 108 deletions
+44 -5
View File
@@ -17,6 +17,7 @@ class CakeImageWidget extends StatelessWidget {
this.alignment,
this.allowDrawingOutsideViewBox,
this.filterQuality,
this.semanticsLabel,
});
final String? imageUrl;
@@ -32,6 +33,16 @@ class CakeImageWidget extends StatelessWidget {
final double borderRadius;
final FilterQuality? filterQuality;
/// Accessible name for this image.
///
/// Leave `null` (the default) for decorative imagery — the image then
/// contributes nothing at all to the semantics tree, so screen readers do not
/// stop on an unnamed node. Pass a localized string only when the image is the
/// sole carrier of information for the user.
final String? semanticsLabel;
bool get _isDecorative => semanticsLabel == null;
@override
Widget build(BuildContext context) {
if (imageUrl == null || imageUrl!.isEmpty) {
@@ -52,6 +63,8 @@ class CakeImageWidget extends StatelessWidget {
alignment: alignment ?? Alignment.center,
allowDrawingOutsideViewBox: allowDrawingOutsideViewBox ?? false,
colorFilter: effectiveColorFilter,
semanticsLabel: semanticsLabel,
excludeFromSemantics: _isDecorative,
fit: fit ?? BoxFit.contain, errorBuilder: (context, e, trace) {
return SvgPicture.asset(
imageUrl!,
@@ -61,6 +74,8 @@ class CakeImageWidget extends StatelessWidget {
width: width,
errorBuilder: (_, __, ___) => SizedBox(height: height, width: width),
colorFilter: effectiveColorFilter,
semanticsLabel: semanticsLabel,
excludeFromSemantics: _isDecorative,
fit: fit ?? BoxFit.contain,
);
});
@@ -72,6 +87,8 @@ class CakeImageWidget extends StatelessWidget {
fit: fit,
color: color,
filterQuality: filterQuality ?? FilterQuality.medium,
semanticLabel: semanticsLabel,
excludeFromSemantics: _isDecorative,
errorBuilder: (_, __, ___) => _buildErrorWidget(context),
);
}
@@ -85,9 +102,9 @@ class CakeImageWidget extends StatelessWidget {
alignment: alignment ?? Alignment.center,
allowDrawingOutsideViewBox: allowDrawingOutsideViewBox ?? false,
fit: fit ?? BoxFit.contain,
placeholderBuilder: (_) {
return loadingWidget ?? const Center(child: CircularProgressIndicator());
},
semanticsLabel: semanticsLabel,
excludeFromSemantics: _isDecorative,
placeholderBuilder: (_) => _buildLoadingWidget(),
errorBuilder: (_, __, ___) => _buildErrorWidget(context),
)
: Image.network(
@@ -97,9 +114,11 @@ class CakeImageWidget extends StatelessWidget {
fit: fit ?? BoxFit.cover,
color: color,
filterQuality: filterQuality ?? FilterQuality.medium,
semanticLabel: semanticsLabel,
excludeFromSemantics: _isDecorative,
loadingBuilder: (_, Widget child, ImageChunkEvent? progress) {
if (progress == null) return child;
return loadingWidget ?? const Center(child: CircularProgressIndicator());
return _buildLoadingWidget();
},
errorBuilder: (_, __, ___) => _buildErrorWidget(context),
);
@@ -108,8 +127,13 @@ class CakeImageWidget extends StatelessWidget {
return imageWidget;
}
/// A caller-supplied [loadingWidget] owns its own semantics; the built-in
/// spinner is purely visual and must not become an unnamed focus stop.
Widget _buildLoadingWidget() =>
loadingWidget ?? const ExcludeSemantics(child: Center(child: CircularProgressIndicator()));
Widget _buildErrorWidget(BuildContext context) {
return Container(
final Widget placeholder = Container(
height: height,
width: width,
decoration: BoxDecoration(
@@ -125,5 +149,20 @@ class CakeImageWidget extends StatelessWidget {
),
),
);
// Several call sites render meaningful content (e.g. the asset's initials) as
// their [errorWidget], so leave its semantics to the caller.
if (errorWidget != null) {
return placeholder;
}
return _isDecorative
? ExcludeSemantics(child: placeholder)
: Semantics(
container: true,
image: true,
label: semanticsLabel,
child: ExcludeSemantics(child: placeholder),
);
}
}
+7 -1
View File
@@ -9,7 +9,9 @@ class RoundedCheckbox extends StatelessWidget {
@override
Widget build(BuildContext context) {
return value
// Unchecked renders nothing at all, so without an explicit `checked` state
// the selection is invisible to screen readers.
final Widget indicator = value
? Container(
height: 20.0,
width: 20.0,
@@ -23,5 +25,9 @@ class RoundedCheckbox extends StatelessWidget {
size: 14.0,
))
: Offstage();
// Deliberately not a semantics container: the state merges into the
// enclosing row/option node instead of adding a second focus stop.
return Semantics(checked: value, child: indicator);
}
}