Skip to content

Commit 09f5f2c

Browse files
committed
chore: update voiceOver a11Y
1 parent 95ef113 commit 09f5f2c

2 files changed

Lines changed: 58 additions & 65 deletions

File tree

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

Lines changed: 7 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,49 +15,19 @@ 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.
23-
///
24-
/// ## Related Issues:
25-
/// - Issue #625: Android TalkBack inconsistent reading order with badges
26-
/// - Issue #627: iOS zoom overflow at 190-235% zoom levels
20+
/// Manages text scaling constraints for [OudsNavigationBar] (Android/Material)
21+
/// and [OudsTabBar] (iOS/Cupertino) to prevent layout overflow at high zoom levels.
2722
class OudsNavigationA11y {
28-
/// Maximum text scale factor for navigation items (Issue #627).
29-
///
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
23+
/// Maximum text scale factor for navigation items.
3624
///
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.
25+
/// Clamped to 108% to prevent overflow between 190–235% system zoom (Issue #627).
4026
static const double maxTextScaleFactor = 1.08;
4127

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])
51-
///
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)
28+
/// Wraps [child] with text scaling clamped to [maxTextScaleFactor].
5629
///
57-
/// Example:
58-
/// ```dart
59-
/// return OudsNavigationA11y.withA11yScaling(navigationBar);
60-
/// ```
30+
/// Apply at bar level (around [NavigationBar] or [CupertinoTabBar]).
6131
static Widget withA11yScaling(Widget child) {
6232
return MediaQuery.withClampedTextScaling(
6333
minScaleFactor: 1.0,

ouds_core/lib/components/navigation/ouds_navigation_bar_item.dart

Lines changed: 51 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
333350
class 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

Comments
 (0)