@@ -17,6 +17,7 @@ import 'package:flutter/material.dart';
1717import 'package:flutter_svg/flutter_svg.dart' ;
1818import 'package:ouds_core/components/badge/ouds_badge.dart' ;
1919import 'package:ouds_core/components/common/ouds_icon_status.dart' ;
20+ import 'package:ouds_core/components/navigation/internal/ouds_navigation_a11y.dart' ;
2021import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_indicator_animation.dart' ;
2122import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_state.dart' ;
2223import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_status_modifier.dart' ;
@@ -75,6 +76,15 @@ class OudsNavigationBarItem {
7576 this .badge,
7677 });
7778
79+ /// Full accessibility label combining [label] and the badge description (if any).
80+ ///
81+ /// Used as tooltip for [NavigationDestination] (Android/TalkBack) and as
82+ /// invisible sibling text for [BottomNavigationBarItem] (iOS/VoiceOver).
83+ String get _accessibilityLabel => OudsNavigationA11y .buildItemLabel (
84+ label,
85+ badgeText: badge? .accessibilityText,
86+ );
87+
7888 /// Builds the destination icon for a Material 3 [NavigationDestination] , optionally wrapped
7989 /// with an [OudsBadge] .
8090 ///
@@ -169,25 +179,36 @@ class OudsNavigationBarItem {
169179 required bool isSelected,
170180 }) {
171181 final modifier = OudsNavigationBarStatusModifier (context);
182+ final bar = OudsTheme .of (context).componentsTokens (context).bar;
172183
173- return NavigationDestination (
174- label: label,
175- icon: _buildBadgeIconNavigationDestination (
176- context,
177- icon,
178- modifier,
179- controlState,
180- badge,
181- isSelected: isSelected,
182- ),
183- selectedIcon: _buildBadgeIconNavigationDestination (
184- context,
185- icon,
186- modifier,
187- controlState,
188- badge,
189- isSelected: isSelected,
190- ),
184+ return Column (
185+ mainAxisSize: MainAxisSize .min,
186+ children: [
187+ // Top active indicator bar placed ABOVE the NavigationDestination
188+ _buildTopIndicatorBar (context, bar, isSelected, controlState),
189+ Flexible (
190+ child: NavigationDestination (
191+ label: label,
192+ tooltip: _accessibilityLabel,
193+ icon: _buildBadgeIconNavigationDestination (
194+ context,
195+ icon,
196+ modifier,
197+ controlState,
198+ badge,
199+ isSelected: isSelected,
200+ ),
201+ selectedIcon: _buildBadgeIconNavigationDestination (
202+ context,
203+ icon,
204+ modifier,
205+ controlState,
206+ badge,
207+ isSelected: isSelected,
208+ ),
209+ ),
210+ ),
211+ ],
191212 );
192213 }
193214
@@ -209,15 +230,15 @@ class OudsNavigationBarItem {
209230
210231 return BottomNavigationBarItem (
211232 label: label,
212- icon: _buildBadgeIconBottomNavigationBarItemScaled (
233+ icon: _buildBadgeIconBottomNavigationBarItem (
213234 context,
214235 icon,
215236 modifier,
216237 controlState,
217238 badge,
218239 isSelected: isSelected,
219240 ),
220- activeIcon: _buildBadgeIconBottomNavigationBarItemScaled (
241+ activeIcon: _buildBadgeIconBottomNavigationBarItem (
221242 context,
222243 icon,
223244 modifier,
@@ -228,28 +249,6 @@ class OudsNavigationBarItem {
228249 );
229250 }
230251
231- /// Builds the tab bar icon for [BottomNavigationBarItem] .
232- ///
233- /// This method is a wrapper for consistency with Android implementation.
234- /// Text scaling is applied by the parent [OudsTabBar] .
235- Widget _buildBadgeIconBottomNavigationBarItemScaled (
236- BuildContext context,
237- String assetName,
238- OudsNavigationBarStatusModifier modifier,
239- OudsNavigationBarControlState controlState,
240- final OudsNavigationBarItemBadge ? badge, {
241- required bool isSelected,
242- }) {
243- return _buildBadgeIconBottomNavigationBarItem (
244- context,
245- assetName,
246- modifier,
247- controlState,
248- badge,
249- isSelected: isSelected,
250- );
251- }
252-
253252 /// Builds the tab bar icon for a [BottomNavigationBarItem] (used by [CupertinoTabBar] ),
254253 /// including the optional top indicator and an optional [OudsBadge] .
255254 ///
@@ -292,12 +291,8 @@ class OudsNavigationBarItem {
292291 ),
293292 );
294293
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 > [
294+ final visualChildren = < Widget > [
299295 _buildTopIndicatorBar (context, bar, isSelected, controlState),
300- // Fixed 2px spacing between indicator and icon to ensure consistent layout
301296 const SizedBox (height: 2 ),
302297 badge != null
303298 ? OudsBadge .count (
@@ -312,29 +307,23 @@ class OudsNavigationBarItem {
312307 : widgetIcon,
313308 ];
314309
315- return Column (children: children);
310+ return OudsNavigationA11y .buildTabBarIcon (
311+ Column (children: visualChildren),
312+ badgeText: badge? .accessibilityText,
313+ );
316314 }
317315}
318316
319317/// Represents an optional badge attached to a navigation item.
320318///
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-
319+ /// - [contentDescription] : Full accessibility label announced by screen readers.
320+ /// Must include all relevant info (e.g. `"3 notifications unread"` ).
321+ /// - [count] : Optional count displayed visually inside the badge.
333322class OudsNavigationBarItemBadge {
334- /// Semantic description for accessibility .
323+ /// Full accessibility label for screen readers .
335324 final String contentDescription;
336325
337- /// Optional count displayed inside the badge.
326+ /// Optional count displayed visually inside the badge.
338327 final int ? count;
339328
340329 /// Creates a badge for a navigation bar item.
@@ -345,4 +334,10 @@ class OudsNavigationBarItemBadge {
345334
346335 /// Returns true if the badge has a numeric count.
347336 bool get hasCount => count != null ;
337+
338+ /// Accessibility text announced by VoiceOver / TalkBack.
339+ ///
340+ /// Returns [contentDescription] as-is — it should already contain
341+ /// the full description (e.g. `"3 notifications unread"` ).
342+ String get accessibilityText => contentDescription;
348343}
0 commit comments