@@ -75,6 +75,13 @@ class OudsNavigationBarItem {
7575 this .badge,
7676 });
7777
78+ /// Full accessibility label combining [label] and the badge description (if any).
79+ ///
80+ /// Used as tooltip for [NavigationDestination] (Android/TalkBack) and as
81+ /// invisible sibling text for [BottomNavigationBarItem] (iOS/VoiceOver).
82+ String get _accessibilityLabel =>
83+ badge != null ? '$label , ${badge !.accessibilityText }' : label;
84+
7885 /// Builds the destination icon for a Material 3 [NavigationDestination] , optionally wrapped
7986 /// with an [OudsBadge] .
8087 ///
@@ -172,6 +179,9 @@ class OudsNavigationBarItem {
172179
173180 return NavigationDestination (
174181 label: label,
182+ // tooltip is used by NavigationDestination as the semantics hint for TalkBack.
183+ // Overridden to include badge info when present.
184+ tooltip: _accessibilityLabel,
175185 icon: _buildBadgeIconNavigationDestination (
176186 context,
177187 icon,
@@ -292,49 +302,56 @@ class OudsNavigationBarItem {
292302 ),
293303 );
294304
295- // Build consistent layout with fixed-height indicator space.
296- // The top indicator reserves space even when unselected to maintain stable positioning
297- // throughout the selection animation, preventing layout shifts.
298- final children = < Widget > [
305+ final visualChildren = < Widget > [
299306 _buildTopIndicatorBar (context, bar, isSelected, controlState),
300- // Fixed 2px spacing between indicator and icon to ensure consistent layout
301307 const SizedBox (height: 2 ),
302308 badge != null
303- ? OudsBadge .count (
304- semanticsLabel: badge.contentDescription,
305- label: badge.count.toString (),
306- status: Negative (),
307- size: badge.hasCount
308- ? OudsBadgeSize .medium
309- : OudsBadgeSize .xsmall,
310- child: widgetIcon,
309+ ? ExcludeSemantics (
310+ child: OudsBadge .count (
311+ semanticsLabel: badge.contentDescription,
312+ label: badge.count.toString (),
313+ status: Negative (),
314+ size: badge.hasCount
315+ ? OudsBadgeSize .medium
316+ : OudsBadgeSize .xsmall,
317+ child: widgetIcon,
318+ ),
311319 )
312320 : widgetIcon,
313321 ];
314322
315- return Column (children: children);
323+ if (badge == null ) {
324+ return ExcludeSemantics (child: Column (children: visualChildren));
325+ }
326+
327+ return Stack (
328+ children: [
329+ ExcludeSemantics (child: Column (children: visualChildren)),
330+ // Invisible text: merges badge info into CupertinoTabBar's SemanticsNode
331+ // without creating a separate VoiceOver stop.
332+ Positioned .fill (
333+ child: AbsorbPointer (
334+ child: Text (
335+ '${badge .accessibilityText }, ' ,
336+ style: const TextStyle (fontSize: 1 , color: Colors .transparent),
337+ ),
338+ ),
339+ ),
340+ ],
341+ );
316342 }
317343}
318344
319345/// Represents an optional badge attached to a navigation item.
320346///
321- /// Parameters:
322- /// - [contentDescription] : Semantic description for accessibility.
323- /// - [count] : Optional integer to display as badge count.
324- ///
325- /// Example usage:
326- /// ```dart
327- /// OudsNavigationBarItemBadge(
328- /// contentDescription: 'Unread messages',
329- /// count: 5,
330- /// );
331- /// ```
332-
347+ /// - [contentDescription] : Full accessibility label announced by screen readers.
348+ /// Must include all relevant info (e.g. `"3 notifications unread"` ).
349+ /// - [count] : Optional count displayed visually inside the badge.
333350class OudsNavigationBarItemBadge {
334- /// Semantic description for accessibility .
351+ /// Full accessibility label for screen readers .
335352 final String contentDescription;
336353
337- /// Optional count displayed inside the badge.
354+ /// Optional count displayed visually inside the badge.
338355 final int ? count;
339356
340357 /// Creates a badge for a navigation bar item.
@@ -345,4 +362,10 @@ class OudsNavigationBarItemBadge {
345362
346363 /// Returns true if the badge has a numeric count.
347364 bool get hasCount => count != null ;
365+
366+ /// Accessibility text announced by VoiceOver / TalkBack.
367+ ///
368+ /// Returns [contentDescription] as-is — it should already contain
369+ /// the full description (e.g. `"3 notifications unread"` ).
370+ String get accessibilityText => contentDescription;
348371}
0 commit comments