From c711bba905ac38bd81fdf0365ef1731323d61146 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 15 Jul 2026 16:12:36 +0200 Subject: [PATCH] feat(ui): add semanticsLabel to StreamAvatar, StreamAvatarGroup, StreamAvatarStack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit null (default) composes through — the placeholder or children speak per their own contract. Non-null exposes the widget as a single labeled image node and drops descendants (initials, icons, "+N" overflow badge) from the semantics tree. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/stream_core_flutter/CHANGELOG.md | 1 + .../src/components/avatar/stream_avatar.dart | 24 +++- .../avatar/stream_avatar_group.dart | 25 +++- .../avatar/stream_avatar_stack.dart | 31 ++++- .../avatar/stream_avatar_group_test.dart | 111 ++++++++++++++++++ .../avatar/stream_avatar_stack_test.dart | 107 +++++++++++++++++ .../components/avatar/stream_avatar_test.dart | 74 ++++++++++++ 7 files changed, 368 insertions(+), 5 deletions(-) create mode 100644 packages/stream_core_flutter/test/components/avatar/stream_avatar_group_test.dart create mode 100644 packages/stream_core_flutter/test/components/avatar/stream_avatar_stack_test.dart create mode 100644 packages/stream_core_flutter/test/components/avatar/stream_avatar_test.dart diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index 80f0c538..664e2d74 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -8,6 +8,7 @@ - Added `labelText` and `helperAffinity` to `StreamTextInput` for a floating label above the field and helper text positioned inside or outside the bordered chassis. - Added `focusNode` and `autofocus` pass-through on `StreamListTile`. - Added optional `semanticLabel` to `StreamBadgeNotification`, `StreamFileTypeIcon`, `StreamStepper`, `StreamMessageComposerAttachment`, and `StreamMessageComposerMediaAttachment`. +- Added optional `semanticsLabel` to `StreamAvatar`, `StreamAvatarGroup`, and `StreamAvatarStack`. On `StreamAvatar`, `null` (default) drops the placeholder's initials from the semantics tree via `ExcludeSemantics`; a non-null value exposes it as a labeled image node. On `StreamAvatarGroup` / `StreamAvatarStack`, `null` composes through — each child's own `semanticsLabel` applies — while a non-null value collapses the group into a single labeled image node and hides children and the "+N" overflow badge. - Added `excludeHeaderSemantics` to `StreamAppBar` and `StreamSheetHeader` for opting out of the default heading role and route naming on the title. - Added `onVisible` callback to `StreamSnackbar` — fires after the entrance animation completes (or synchronously when a screen reader is active). diff --git a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart index 7b218063..cf323b5c 100644 --- a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart +++ b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar.dart @@ -85,6 +85,7 @@ class StreamAvatar extends StatelessWidget { Color? backgroundColor, Color? foregroundColor, bool showBorder = true, + String? semanticsLabel, }) : props = .new( size: size, imageUrl: imageUrl, @@ -92,6 +93,7 @@ class StreamAvatar extends StatelessWidget { backgroundColor: backgroundColor, foregroundColor: foregroundColor, showBorder: showBorder, + semanticsLabel: semanticsLabel, ); /// The properties that configure this avatar. @@ -123,6 +125,7 @@ class StreamAvatarProps { this.backgroundColor, this.foregroundColor, this.showBorder = true, + this.semanticsLabel, }); /// The URL of the avatar image. @@ -164,6 +167,14 @@ class StreamAvatarProps { /// Defaults to true. The border style is determined by /// [StreamAvatarThemeData.border]. final bool showBorder; + + /// Screen-reader label for the avatar. + /// + /// When null (the default), the placeholder speaks for itself — wrap in + /// [ExcludeSemantics] when the surrounding row already labels the user. + /// When non-null, the avatar is exposed as a labeled image node and the + /// placeholder is dropped from the semantics tree. + final String? semanticsLabel; } /// The default implementation of [StreamAvatar]. @@ -200,7 +211,7 @@ class DefaultStreamAvatar extends StatelessWidget { size: _iconSizeForSize(effectiveSize), ); - return AnimatedContainer( + Widget avatar = AnimatedContainer( alignment: .center, clipBehavior: .antiAlias, width: effectiveSize.value, @@ -232,6 +243,17 @@ class DefaultStreamAvatar extends StatelessWidget { ), ), ); + + if (props.semanticsLabel case final label?) { + avatar = Semantics( + label: label, + image: true, + excludeSemantics: true, + child: avatar, + ); + } + + return avatar; } // Returns the appropriate text style for the given avatar size. diff --git a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_group.dart b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_group.dart index 4d74e80a..f46afef1 100644 --- a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_group.dart +++ b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_group.dart @@ -91,7 +91,8 @@ class StreamAvatarGroup extends StatelessWidget { super.key, StreamAvatarGroupSize? size, required Iterable children, - }) : props = .new(size: size, children: children); + String? semanticsLabel, + }) : props = .new(size: size, children: children, semanticsLabel: semanticsLabel); /// The properties that configure this avatar group. final StreamAvatarGroupProps props; @@ -118,6 +119,7 @@ class StreamAvatarGroupProps { const StreamAvatarGroupProps({ this.size, required this.children, + this.semanticsLabel, }); /// The list of avatars to display in the group. @@ -129,6 +131,14 @@ class StreamAvatarGroupProps { /// /// If null, defaults to [StreamAvatarGroupSize.lg]. final StreamAvatarGroupSize? size; + + /// Screen-reader label for the avatar group. + /// + /// When null (the default), the group composes through — each child's own + /// [StreamAvatar.semanticsLabel] applies. When non-null, the group is + /// exposed as a single labeled image node and its children (including the + /// "+N" overflow badge) are dropped from the semantics tree. + final String? semanticsLabel; } /// The default implementation of [StreamAvatarGroup]. @@ -159,7 +169,7 @@ class DefaultStreamAvatarGroup extends StatelessWidget { const avatarBorderWidth = 2.0; - return AnimatedContainer( + Widget group = AnimatedContainer( width: effectiveSize.value, height: effectiveSize.value, duration: kThemeChangeDuration, @@ -190,6 +200,17 @@ class DefaultStreamAvatarGroup extends StatelessWidget { ), ), ); + + if (props.semanticsLabel case final label?) { + group = Semantics( + label: label, + image: true, + excludeSemantics: true, + child: group, + ); + } + + return group; } // Build the widget for 1 avatar. diff --git a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart index e3705486..560b5d31 100644 --- a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart +++ b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart @@ -102,8 +102,15 @@ class StreamAvatarStack extends StatelessWidget { required Iterable children, double overlap = 0.33, int max = 5, + String? semanticsLabel, }) : assert(max >= 2, 'max must be at least 2'), - props = .new(size: size, children: children, overlap: overlap, max: max); + props = .new( + size: size, + children: children, + overlap: overlap, + max: max, + semanticsLabel: semanticsLabel, + ); /// The properties that configure this avatar stack. final StreamAvatarStackProps props; @@ -132,6 +139,7 @@ class StreamAvatarStackProps { required this.children, this.overlap = 0.33, this.max = 5, + this.semanticsLabel, }); /// The list of widgets to display in the stack. @@ -158,6 +166,14 @@ class StreamAvatarStackProps { /// /// Must be at least 2. Defaults to 5. final int max; + + /// Screen-reader label for the avatar stack. + /// + /// When null (the default), the stack composes through — each child's own + /// [StreamAvatar.semanticsLabel] applies. When non-null, the stack is + /// exposed as a single labeled image node and its children (including the + /// "+N" overflow badge) are dropped from the semantics tree. + final String? semanticsLabel; } /// The default implementation of [StreamAvatarStack]. @@ -193,7 +209,7 @@ class DefaultStreamAvatarStack extends StatelessWidget { const avatarBorderWidth = 2.0; - return MediaQuery.withNoTextScaling( + var stack = MediaQuery.withNoTextScaling( child: StreamAvatarTheme( data: StreamAvatarThemeData( size: avatarSize, @@ -216,6 +232,17 @@ class DefaultStreamAvatarStack extends StatelessWidget { ), ), ); + + if (props.semanticsLabel case final label?) { + stack = Semantics( + label: label, + image: true, + excludeSemantics: true, + child: stack, + ); + } + + return stack; } // Returns the appropriate avatar size for the given stack size. diff --git a/packages/stream_core_flutter/test/components/avatar/stream_avatar_group_test.dart b/packages/stream_core_flutter/test/components/avatar/stream_avatar_group_test.dart new file mode 100644 index 00000000..7b8166c5 --- /dev/null +++ b/packages/stream_core_flutter/test/components/avatar/stream_avatar_group_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +Widget _withStreamTheme(Widget child) { + return MaterialApp( + theme: ThemeData(extensions: [StreamTheme()]), + home: Scaffold(body: Center(child: child)), + ); +} + +void main() { + group('StreamAvatarGroup a11y', () { + testWidgets('semanticsLabel: null (default) — composes through, child placeholders speak', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _withStreamTheme( + StreamAvatarGroup( + children: [ + StreamAvatar(placeholder: (_) => const Text('AB')), + StreamAvatar(placeholder: (_) => const Text('CD')), + StreamAvatar(placeholder: (_) => const Text('EF')), + ], + ), + ), + ); + + // No wrapper on the group — each child's own semantics apply. Callers + // wanting the group decorative wrap in ExcludeSemantics themselves. + expect(find.bySemanticsLabel('AB'), findsOneWidget); + expect(find.bySemanticsLabel('CD'), findsOneWidget); + expect(find.bySemanticsLabel('EF'), findsOneWidget); + + handle.dispose(); + }); + + testWidgets('semanticsLabel non-null — labeled image node', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _withStreamTheme( + StreamAvatarGroup( + semanticsLabel: 'Alice, Bob and 1 other', + children: [ + StreamAvatar(placeholder: (_) => const Text('AB')), + StreamAvatar(placeholder: (_) => const Text('CD')), + StreamAvatar(placeholder: (_) => const Text('EF')), + ], + ), + ), + ); + + expect( + tester.getSemantics(find.byType(StreamAvatarGroup)), + isSemantics(label: 'Alice, Bob and 1 other', isImage: true), + ); + + // Child initials and any overflow "+N" badge merge into the outer + // labeled node — not surfaced as separate semantic children. + expect(find.bySemanticsLabel('AB'), findsNothing); + expect(find.bySemanticsLabel('CD'), findsNothing); + expect(find.bySemanticsLabel('EF'), findsNothing); + + handle.dispose(); + }); + + testWidgets('semanticsLabel: null — individually-labeled children still announce', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _withStreamTheme( + StreamAvatarGroup( + children: [ + StreamAvatar(semanticsLabel: 'Alice', placeholder: (_) => const Text('AB')), + StreamAvatar(semanticsLabel: 'Bob', placeholder: (_) => const Text('CD')), + ], + ), + ), + ); + + // Each labeled child speaks for itself — the group applies no override. + expect(find.bySemanticsLabel('Alice'), findsOneWidget); + expect(find.bySemanticsLabel('Bob'), findsOneWidget); + + handle.dispose(); + }); + + testWidgets('semanticsLabel non-null with 4+ children hides the "+N" overflow badge', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _withStreamTheme( + StreamAvatarGroup( + semanticsLabel: 'Alice, Bob and 3 others', + children: List.generate( + 5, + (i) => StreamAvatar(placeholder: (_) => Text('U$i')), + ), + ), + ), + ); + + // The overflow badge renders "+3" visually but must not surface as its + // own semantic node. + expect(find.bySemanticsLabel('+3'), findsNothing); + + handle.dispose(); + }); + }); +} diff --git a/packages/stream_core_flutter/test/components/avatar/stream_avatar_stack_test.dart b/packages/stream_core_flutter/test/components/avatar/stream_avatar_stack_test.dart new file mode 100644 index 00000000..6c54ed1a --- /dev/null +++ b/packages/stream_core_flutter/test/components/avatar/stream_avatar_stack_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +Widget _withStreamTheme(Widget child) { + return MaterialApp( + theme: ThemeData(extensions: [StreamTheme()]), + home: Scaffold(body: Center(child: child)), + ); +} + +void main() { + group('StreamAvatarStack a11y', () { + testWidgets('semanticsLabel: null (default) — composes through, child placeholders speak', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _withStreamTheme( + StreamAvatarStack( + children: [ + StreamAvatar(placeholder: (_) => const Text('AB')), + StreamAvatar(placeholder: (_) => const Text('CD')), + StreamAvatar(placeholder: (_) => const Text('EF')), + ], + ), + ), + ); + + // No wrapper on the stack — each child's own semantics apply. Callers + // wanting the stack decorative wrap in ExcludeSemantics themselves. + expect(find.bySemanticsLabel('AB'), findsOneWidget); + expect(find.bySemanticsLabel('CD'), findsOneWidget); + expect(find.bySemanticsLabel('EF'), findsOneWidget); + + handle.dispose(); + }); + + testWidgets('semanticsLabel non-null — labeled image node', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _withStreamTheme( + StreamAvatarStack( + semanticsLabel: 'Alice, Bob and 1 other', + children: [ + StreamAvatar(placeholder: (_) => const Text('AB')), + StreamAvatar(placeholder: (_) => const Text('CD')), + StreamAvatar(placeholder: (_) => const Text('EF')), + ], + ), + ), + ); + + expect( + tester.getSemantics(find.byType(StreamAvatarStack)), + isSemantics(label: 'Alice, Bob and 1 other', isImage: true), + ); + + expect(find.bySemanticsLabel('AB'), findsNothing); + expect(find.bySemanticsLabel('CD'), findsNothing); + expect(find.bySemanticsLabel('EF'), findsNothing); + + handle.dispose(); + }); + + testWidgets('semanticsLabel: null — individually-labeled children still announce', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _withStreamTheme( + StreamAvatarStack( + children: [ + StreamAvatar(semanticsLabel: 'Alice', placeholder: (_) => const Text('AB')), + StreamAvatar(semanticsLabel: 'Bob', placeholder: (_) => const Text('CD')), + ], + ), + ), + ); + + expect(find.bySemanticsLabel('Alice'), findsOneWidget); + expect(find.bySemanticsLabel('Bob'), findsOneWidget); + + handle.dispose(); + }); + + testWidgets('overflow "+N" badge hidden from semantics regardless of label', (tester) async { + final handle = tester.ensureSemantics(); + + // 6 children with default max = 5 → 5 visible + "+1" badge. + await tester.pumpWidget( + _withStreamTheme( + StreamAvatarStack( + semanticsLabel: 'Alice and 5 others', + children: List.generate( + 6, + (i) => StreamAvatar(placeholder: (_) => Text('U$i')), + ), + ), + ), + ); + + expect(find.bySemanticsLabel('+1'), findsNothing); + + handle.dispose(); + }); + }); +} diff --git a/packages/stream_core_flutter/test/components/avatar/stream_avatar_test.dart b/packages/stream_core_flutter/test/components/avatar/stream_avatar_test.dart new file mode 100644 index 00000000..ca76891d --- /dev/null +++ b/packages/stream_core_flutter/test/components/avatar/stream_avatar_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +Widget _withStreamTheme(Widget child) { + return MaterialApp( + theme: ThemeData(extensions: [StreamTheme()]), + home: Scaffold(body: Center(child: child)), + ); +} + +void main() { + group('StreamAvatar a11y', () { + testWidgets('semanticsLabel: null (default) — placeholder speaks for itself', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _withStreamTheme( + StreamAvatar(placeholder: (_) => const Text('AB')), + ), + ); + + // No semantic override on the avatar — the placeholder's own semantics + // apply. Callers that want the avatar decorative should wrap it in + // ExcludeSemantics themselves. + expect(find.bySemanticsLabel('AB'), findsOneWidget); + + handle.dispose(); + }); + + testWidgets('semanticsLabel: null wrapped in ExcludeSemantics — initials silent', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _withStreamTheme( + ExcludeSemantics( + child: StreamAvatar(placeholder: (_) => const Text('AB')), + ), + ), + ); + + // Canonical decorative-avatar pattern: caller wraps in ExcludeSemantics + // (or an ancestor's `excludeSemantics: true`) when the surrounding row + // already labels the avatar's context. + expect(find.bySemanticsLabel('AB'), findsNothing); + + handle.dispose(); + }); + + testWidgets('semanticsLabel non-null — labeled image node', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _withStreamTheme( + StreamAvatar( + semanticsLabel: 'Alice', + placeholder: (_) => const Text('AB'), + ), + ), + ); + + expect( + tester.getSemantics(find.byType(StreamAvatar)), + isSemantics(label: 'Alice', isImage: true), + ); + + // The placeholder's initials must not surface as a separate semantic + // node; they merge into the outer labeled node via excludeSemantics. + expect(find.bySemanticsLabel('AB'), findsNothing); + + handle.dispose(); + }); + }); +}