Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/stream_core_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,15 @@ class StreamAvatar extends StatelessWidget {
Color? backgroundColor,
Color? foregroundColor,
bool showBorder = true,
String? semanticsLabel,
}) : props = .new(
size: size,
imageUrl: imageUrl,
placeholder: placeholder,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
showBorder: showBorder,
semanticsLabel: semanticsLabel,
);

/// The properties that configure this avatar.
Expand Down Expand Up @@ -123,6 +125,7 @@ class StreamAvatarProps {
this.backgroundColor,
this.foregroundColor,
this.showBorder = true,
this.semanticsLabel,
});

/// The URL of the avatar image.
Expand Down Expand Up @@ -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].
Expand Down Expand Up @@ -200,7 +211,7 @@ class DefaultStreamAvatar extends StatelessWidget {
size: _iconSizeForSize(effectiveSize),
);

return AnimatedContainer(
Widget avatar = AnimatedContainer(
alignment: .center,
clipBehavior: .antiAlias,
width: effectiveSize.value,
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ class StreamAvatarGroup extends StatelessWidget {
super.key,
StreamAvatarGroupSize? size,
required Iterable<Widget> 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;
Expand All @@ -118,6 +119,7 @@ class StreamAvatarGroupProps {
const StreamAvatarGroupProps({
this.size,
required this.children,
this.semanticsLabel,
});

/// The list of avatars to display in the group.
Expand All @@ -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].
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,15 @@ class StreamAvatarStack extends StatelessWidget {
required Iterable<Widget> 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;
Expand Down Expand Up @@ -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.
Expand All @@ -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].
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
}
Loading
Loading