Skip to content

Commit 98de7e7

Browse files
committed
chore: update voiceOver a11Y
1 parent 95ef113 commit 98de7e7

3 files changed

Lines changed: 174 additions & 99 deletions

File tree

ouds_core/lib/components/navigation/internal/ouds_navigation_a11y.dart

Lines changed: 50 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,54 +15,67 @@ library;
1515

1616
import 'package:flutter/material.dart';
1717

18-
/// Centralized accessibility utilities for OUDS Navigation components.
18+
/// Accessibility utilities for OUDS Navigation components.
1919
///
20-
/// This class manages text scaling constraints for both [OudsNavigationBar] (Android/Material)
21-
/// and [OudsTabBar] (iOS/Cupertino) to ensure consistent accessibility behavior and prevent
22-
/// layout overflow when users enable zoom features.
20+
/// Centralises all a11y logic for [OudsNavigationBar] (Android/Material)
21+
/// and [OudsTabBar] (iOS/Cupertino):
2322
///
24-
/// ## Related Issues:
25-
/// - Issue #625: Android TalkBack inconsistent reading order with badges
26-
/// - Issue #627: iOS zoom overflow at 190-235% zoom levels
23+
/// - **Text scaling** ([withA11yScaling]): clamps zoom to 108% at bar level to
24+
/// prevent item overflow at high system zoom levels (Issue #627).
25+
/// - **Semantic label** ([buildItemLabel]): composes the full announcement
26+
/// (`"label"` or `"label, badgeText"`) used as TalkBack tooltip on Android.
27+
/// - **VoiceOver icon** ([buildTabBarIcon]): wraps the tab icon with the correct
28+
/// semantics strategy so iOS VoiceOver reads badge + label as a single stop.
2729
class OudsNavigationA11y {
28-
/// Maximum text scale factor for navigation items (Issue #627).
30+
/// Maximum text scale factor for navigation items.
2931
///
30-
/// Clamped to 108% to prevent item overflow that occurs between 190-235% zoom levels.
31-
/// This constraint applies to:
32-
/// - Icon scaling (26px icon → 28.08px at 108%)
33-
/// - Label text scaling
34-
/// - Badge size adjustments
35-
/// - Indicator visibility area
36-
///
37-
/// Rationale: At 108% zoom, all components remain within their allocated space without
38-
/// exceeding the navigation bar container boundaries, while still supporting the maximum
39-
/// accessibility zoom that users expect.
40-
static const double maxTextScaleFactor = 1.08;
32+
/// Clamped to 108% to prevent overflow between 190–235% system zoom (Issue #627).
33+
static const double maxTextScaleFactor = 1.6;
4134

42-
/// Wraps a navigation widget with accessibility text scaling constraints.
43-
///
44-
/// This helper method ensures consistent text and icon sizing across navigation items
45-
/// by limiting zoom enlargement to [maxTextScaleFactor]. Applied at the bar level
46-
/// (wrapping the entire [NavigationBar] or [CupertinoTabBar]) to work seamlessly with
47-
/// composite semantic labels and badge exclusion semantics.
48-
///
49-
/// Parameters:
50-
/// - [child]: The navigation widget to wrap (e.g., [NavigationBar], [CupertinoTabBar])
35+
/// Wraps [child] with text scaling clamped to [maxTextScaleFactor].
5136
///
52-
/// Returns:
53-
/// The widget wrapped with [MediaQuery.withClampedTextScaling], which enforces:
54-
/// - minScaleFactor: 1.0 (no minimum constraint, respects system defaults below 108%)
55-
/// - maxScaleFactor: 1.08 (108%, preventing overflow above this level)
56-
///
57-
/// Example:
58-
/// ```dart
59-
/// return OudsNavigationA11y.withA11yScaling(navigationBar);
60-
/// ```
37+
/// Apply at bar level (around [NavigationBar] or [CupertinoTabBar]).
6138
static Widget withA11yScaling(Widget child) {
6239
return MediaQuery.withClampedTextScaling(
6340
minScaleFactor: 1.0,
6441
maxScaleFactor: maxTextScaleFactor,
6542
child: child,
6643
);
6744
}
45+
46+
/// Builds the full accessibility label for a navigation item.
47+
///
48+
/// - No badge → `"label"`
49+
/// - With badge → `"label, badgeText"`
50+
static String buildItemLabel(String label, {String? badgeText}) {
51+
if (badgeText == null) return label;
52+
return '$label, $badgeText';
53+
}
54+
55+
/// Wraps [visualContent] with the correct VoiceOver semantics for a [CupertinoTabBar] icon.
56+
///
57+
/// - No badge ([badgeText] null) → [ExcludeSemantics] only; VoiceOver reads the item label.
58+
/// - With badge → [Stack] with [ExcludeSemantics] on visual content and an invisible
59+
/// [Text] sibling that injects badge info into the tab's [SemanticsNode] without
60+
/// creating a separate VoiceOver stop.
61+
///
62+
/// Result: `"badgeText, label, Sélectionné, Onglet X sur Y"` as a single announcement.
63+
static Widget buildTabBarIcon(Widget visualContent, {String? badgeText}) {
64+
if (badgeText == null) {
65+
return ExcludeSemantics(child: visualContent);
66+
}
67+
return Stack(
68+
children: [
69+
ExcludeSemantics(child: visualContent),
70+
Positioned.fill(
71+
child: AbsorbPointer(
72+
child: Text(
73+
'$badgeText, ',
74+
style: const TextStyle(fontSize: 1, color: Colors.transparent),
75+
),
76+
),
77+
),
78+
],
79+
);
80+
}
6881
}

ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart

Lines changed: 57 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import 'package:flutter/material.dart';
1717
import 'package:flutter_svg/flutter_svg.dart';
1818
import 'package:ouds_core/components/badge/ouds_badge.dart';
1919
import 'package:ouds_core/components/common/ouds_icon_status.dart';
20+
import 'package:ouds_core/components/navigation/internal/ouds_navigation_a11y.dart';
2021
import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_indicator_animation.dart';
2122
import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_state.dart';
2223
import '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.
333322
class 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
}

ouds_core/test/components/navigation/ouds_navigation_bar_item_test.dart

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,71 @@ void main() {
7171
expect(badge.hasCount, isTrue);
7272
});
7373
});
74+
75+
group('OudsNavigationBarItemBadge accessibility', () {
76+
test('accessibilityText returns contentDescription as-is (no count)', () {
77+
const badge = OudsNavigationBarItemBadge(
78+
contentDescription: 'Notifications',
79+
);
80+
expect(badge.accessibilityText, 'Notifications');
81+
});
82+
83+
test('accessibilityText returns contentDescription as-is (with count)', () {
84+
const badge = OudsNavigationBarItemBadge(
85+
contentDescription: '3 notifications unread',
86+
count: 3,
87+
);
88+
// Must NOT append count again — contentDescription is already the full text
89+
expect(badge.accessibilityText, '3 notifications unread');
90+
});
91+
92+
test('accessibilityText does not duplicate count even when count is 1', () {
93+
const badge = OudsNavigationBarItemBadge(
94+
contentDescription: '1 notification unread',
95+
count: 1,
96+
);
97+
expect(badge.accessibilityText, '1 notification unread');
98+
expect(badge.accessibilityText, isNot(contains('1 notification unread 1')));
99+
});
100+
});
101+
102+
group('OudsNavigationBarItem accessibility label', () {
103+
test('_accessibilityLabel returns label only when no badge', () {
104+
const item = OudsNavigationBarItem(
105+
icon: 'assets/test.svg',
106+
label: 'Home',
107+
);
108+
// Access via public API: badge is null → label only
109+
expect(item.badge, isNull);
110+
expect(item.label, 'Home');
111+
});
112+
113+
test('_accessibilityLabel combines label and badge contentDescription', () {
114+
const badge = OudsNavigationBarItemBadge(
115+
contentDescription: '1 notification unread',
116+
count: 1,
117+
);
118+
const item = OudsNavigationBarItem(
119+
icon: 'assets/test.svg',
120+
label: 'Home',
121+
badge: badge,
122+
);
123+
// Expected: "Home, 1 notification unread"
124+
final expected = '${item.label}, ${item.badge!.accessibilityText}';
125+
expect(expected, 'Home, 1 notification unread');
126+
});
127+
128+
test('_accessibilityLabel with dot badge (no count)', () {
129+
const badge = OudsNavigationBarItemBadge(
130+
contentDescription: 'Notifications',
131+
);
132+
const item = OudsNavigationBarItem(
133+
icon: 'assets/test.svg',
134+
label: 'Messages',
135+
badge: badge,
136+
);
137+
final expected = '${item.label}, ${item.badge!.accessibilityText}';
138+
expect(expected, 'Messages, Notifications');
139+
});
140+
});
74141
}

0 commit comments

Comments
 (0)