Skip to content

Commit 15aa544

Browse files
committed
chore(A11Y): update accessibility Bottom Bar
1 parent 51c9edc commit 15aa544

4 files changed

Lines changed: 278 additions & 90 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* // Software Name: OUDS Flutter
3+
* // SPDX-FileCopyrightText: Copyright (c) Orange SA
4+
* // SPDX-License-Identifier: MIT
5+
* //
6+
* // This software is distributed under the MIT license,
7+
* // the text of which is available at https://opensource.org/license/MIT/
8+
* // or see the "LICENSE" file for more details.
9+
* //
10+
* // Software description: Flutter library of reusable graphical components
11+
* //
12+
*/
13+
/// @nodoc
14+
library;
15+
16+
import 'package:flutter/material.dart';
17+
18+
/// Centralized accessibility utilities for OUDS Navigation components.
19+
///
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
27+
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
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;
41+
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)
56+
///
57+
/// Example:
58+
/// ```dart
59+
/// return OudsNavigationA11y.withA11yScaling(navigationBar);
60+
/// ```
61+
static Widget withA11yScaling(Widget child) {
62+
return MediaQuery.withClampedTextScaling(
63+
minScaleFactor: 1.0,
64+
maxScaleFactor: maxTextScaleFactor,
65+
child: child,
66+
);
67+
}
68+
}

ouds_core/lib/components/navigation/ouds_navigation_bar.dart

Lines changed: 78 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ library;
1515

1616
import 'package:flutter/material.dart';
1717
import 'package:ouds_core/components/control/internal/interaction/ouds_inherited_interaction_model.dart';
18+
import 'package:ouds_core/components/navigation/internal/ouds_navigation_a11y.dart';
1819
import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_background_modifier.dart';
1920
import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_border_modifier.dart';
2021
import 'package:ouds_core/components/navigation/internal/ouds_navigation_bar_state.dart';
@@ -126,24 +127,39 @@ class _OudsNavigationBarState extends State<OudsNavigationBar> {
126127
@override
127128
void initState() {
128129
super.initState();
129-
_selectedIndex = widget.selectedIndex.clamp(0, widget.destinations.length - 1);
130+
_selectedIndex = widget.selectedIndex.clamp(
131+
0,
132+
widget.destinations.length - 1,
133+
);
130134
}
131135

132136
/// Updates the selected index if [currentIndex] changes.
133137
@override
134138
void didUpdateWidget(covariant OudsNavigationBar oldWidget) {
135139
super.didUpdateWidget(oldWidget);
136140
if (widget.selectedIndex != oldWidget.selectedIndex) {
137-
_selectedIndex = widget.selectedIndex.clamp(0, widget.destinations.length - 1);
141+
_selectedIndex = widget.selectedIndex.clamp(
142+
0,
143+
widget.destinations.length - 1,
144+
);
138145
}
139146
}
140147

141148
/// Builds the navigation bar with dynamic label and icon colors and a custom indicator shape.
142149
@override
143150
Widget build(BuildContext context) {
144-
final interactionModelHover = OudsInheritedInteractionModel.of(context, InteractionAspect.hover);
145-
final interactionModelPressed = OudsInheritedInteractionModel.of(context, InteractionAspect.pressed);
146-
final interactionModelFocused = OudsInheritedInteractionModel.of(context, InteractionAspect.focused);
151+
final interactionModelHover = OudsInheritedInteractionModel.of(
152+
context,
153+
InteractionAspect.hover,
154+
);
155+
final interactionModelPressed = OudsInheritedInteractionModel.of(
156+
context,
157+
InteractionAspect.pressed,
158+
);
159+
final interactionModelFocused = OudsInheritedInteractionModel.of(
160+
context,
161+
InteractionAspect.focused,
162+
);
147163

148164
final isHovered = interactionModelHover?.state.isHovered ?? false;
149165
final isPressed = interactionModelPressed?.state.isPressed ?? false;
@@ -158,57 +174,72 @@ class _OudsNavigationBarState extends State<OudsNavigationBar> {
158174

159175
final barControlState = barStateDeterminer.determineControlState();
160176
final navigationBarModifier = OudsNavigationBarStatusModifier(context);
161-
final navigationBarBgModifier = OudsNavigationBarBackgroundColorModifier(context);
162-
final navigationBarBorderModifier = OudsNavigationBarBorderModifier(context);
177+
final navigationBarBgModifier = OudsNavigationBarBackgroundColorModifier(
178+
context,
179+
);
180+
final navigationBarBorderModifier = OudsNavigationBarBorderModifier(
181+
context,
182+
);
163183

164184
final safeIndex = _selectedIndex.clamp(0, widget.destinations.length - 1);
165185

166-
return ClipRect(
167-
child: BackdropFilter(
168-
filter: navigationBarBorderModifier.getBlurNavigationBar(),
169-
child: Container(
170-
decoration: BoxDecoration(
171-
border: navigationBarBorderModifier.getBorderNavigationBar(),
172-
),
173-
child: NavigationBar(
174-
height: _oudsNavigationBarHeight,
175-
selectedIndex: safeIndex,
176-
// `indicatorColor` paints the Material 3 active indicator behind the selected destination.
177-
indicatorColor: navigationBarModifier.getMaterialIndicatorBarColor(
178-
barControlState,
179-
widget.onDestinationSelected != null,
186+
// Wrap the entire navigation bar with accessibility text scaling constraints
187+
// to prevent item overflow when zoom is enabled. The maxScaleFactor of 1.08 (108%) ensures
188+
// that the 26px icon scales to 28.08px at maximum zoom. This constraint is applied at the bar level
189+
// to ensure consistent scaling across all items and work seamlessly with composite semantic labels.
190+
return OudsNavigationA11y.withA11yScaling(
191+
ClipRect(
192+
child: BackdropFilter(
193+
filter: navigationBarBorderModifier.getBlurNavigationBar(),
194+
child: Container(
195+
decoration: BoxDecoration(
196+
border: navigationBarBorderModifier.getBorderNavigationBar(),
180197
),
181-
// `overlayColor` is the transient ink overlay used for interaction feedback (pressed/hovered/focused),
182-
// resolved per destination via `WidgetState`.
183-
overlayColor: WidgetStateProperty.resolveWith<Color>(
184-
(states) {
198+
child: NavigationBar(
199+
height: _oudsNavigationBarHeight,
200+
selectedIndex: safeIndex,
201+
indicatorColor: navigationBarModifier
202+
.getMaterialIndicatorBarColor(
203+
barControlState,
204+
widget.onDestinationSelected != null,
205+
),
206+
overlayColor: WidgetStateProperty.resolveWith<Color>((states) {
185207
final isSelected = states.contains(WidgetState.selected);
186-
return navigationBarModifier.getMaterialIndicatorBarColor(barControlState, isSelected);
187-
},
188-
),
189-
backgroundColor: navigationBarBgModifier.getBackgroundColor(widget.translucent),
190-
// Label text style resolved per destination via `WidgetState` (at minimum selected/unselected).
191-
labelTextStyle: WidgetStateProperty.resolveWith<TextStyle>(
192-
(states) {
208+
return navigationBarModifier.getMaterialIndicatorBarColor(
209+
barControlState,
210+
isSelected,
211+
);
212+
}),
213+
backgroundColor: navigationBarBgModifier.getBackgroundColor(
214+
widget.translucent,
215+
),
216+
labelTextStyle: WidgetStateProperty.resolveWith<TextStyle>((
217+
states,
218+
) {
193219
final isSelected = states.contains(WidgetState.selected);
194-
return OudsTheme.of(context).typographyTokens.typeLabelDefaultMedium(context).copyWith(
195-
color: navigationBarModifier.getTextIconItemColor(barControlState, isSelected),
220+
return OudsTheme.of(context).typographyTokens
221+
.typeLabelDefaultMedium(context)
222+
.copyWith(
223+
color: navigationBarModifier.getTextIconItemColor(
224+
barControlState,
225+
isSelected,
226+
),
196227
);
197-
},
198-
),
199-
destinations: List.generate(
200-
widget.destinations.length,
201-
(index) => widget.destinations[index].toNavigationDestination(
202-
context,
203-
barControlState,
204-
isSelected: index == safeIndex,
228+
}),
229+
destinations: List.generate(
230+
widget.destinations.length,
231+
(index) => widget.destinations[index].toNavigationDestination(
232+
context,
233+
barControlState,
234+
isSelected: index == safeIndex,
235+
),
205236
),
237+
onDestinationSelected: (index) {
238+
if (index == safeIndex) return;
239+
setState(() => _selectedIndex = index);
240+
widget.onDestinationSelected?.call(index);
241+
},
206242
),
207-
onDestinationSelected: (index) {
208-
if (index == safeIndex) return;
209-
setState(() => _selectedIndex = index);
210-
widget.onDestinationSelected?.call(index);
211-
},
212243
),
213244
),
214245
),

0 commit comments

Comments
 (0)