Skip to content

Commit c5f74bd

Browse files
committed
Keep tappables inside a tappable reachable to screen readers
A tappable node wrapped itself in MergeSemantics, which folded every tappable inside it (pins on a floor plan, buttons on a card) into one button, so they couldn't be reached or activated by assistive tech, and a semantics click on any of them ran the outer node's action. A node with tappable descendants now leaves its own tap out of the semantics tree and lets the inner ones keep their nodes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012anUJ6o6K6kjUXur8Ak8XX
1 parent 50ce058 commit c5f74bd

2 files changed

Lines changed: 83 additions & 0 deletions

File tree

‎lib/src/transformers/widget_node_transformer_manager.dart‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,12 @@ abstract class WidgetNodeTransformerManager
192192

193193
collect(node);
194194

195+
// A tappable inside a tappable (a pin on a plan, a button on a card)
196+
// would be merged into the outer button and become unreachable. The
197+
// outer one then stays out of the semantics tree (see
198+
// [hasTappableDescendant]) so the inner ones keep their own nodes.
199+
if (descendants.any(_isTappable)) return widget;
200+
195201
final bool hasText = descendants.any((child) => child is TextNode);
196202
final String label = hasText ? '' : semanticLabelFor(node, descendants);
197203
return MergeSemantics(
@@ -203,6 +209,29 @@ abstract class WidgetNodeTransformerManager
203209
);
204210
}
205211

212+
static bool _isTappable(BaseNode node) =>
213+
node is ReactionMixin &&
214+
(node as ReactionMixin).reactions.any(
215+
(reaction) =>
216+
reaction.trigger.type == TriggerType.click &&
217+
reaction.action.enabled,
218+
);
219+
220+
/// Whether a node inside [node] reacts to clicks itself. [node]'s own
221+
/// tap then leaves the semantics tree to its descendants.
222+
bool hasTappableDescendant(BaseNode node) {
223+
bool visit(BaseNode parent) {
224+
for (final String id in parent.childrenOrEmpty) {
225+
final BaseNode? child = _tryGetNode(id);
226+
if (child == null) continue;
227+
if (_isTappable(child) || visit(child)) return true;
228+
}
229+
return false;
230+
}
231+
232+
return visit(node);
233+
}
234+
206235
/// The name a screen reader announces for [node] when it has no text.
207236
static String semanticLabelFor(BaseNode node, List<BaseNode> descendants) {
208237
final String name = node.name.trim();
@@ -287,6 +316,7 @@ abstract class WidgetNodeTransformerManager
287316
type: MaterialType.transparency,
288317
child: Builder(
289318
builder: (tapContext) => InkWell(
319+
excludeFromSemantics: hasTappableDescendant(node),
290320
onTapUp: (details) => recordTap(tapContext, details),
291321
onTap: click,
292322
onLongPress: onLongPressReactions.isEmpty
@@ -321,6 +351,7 @@ abstract class WidgetNodeTransformerManager
321351
child: Builder(
322352
builder: (tapContext) => GestureDetector(
323353
behavior: HitTestBehavior.opaque,
354+
excludeFromSemantics: hasTappableDescendant(node),
324355
onTapUp: onClickReactions.isEmpty
325356
? null
326357
: (details) => recordTap(tapContext, details),

‎test/semantics_test.dart‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,56 @@ void main() {
7575
'Add to bag',
7676
);
7777
});
78+
79+
testWidgets('tappables inside a tappable keep their own buttons', (
80+
tester,
81+
) async {
82+
final SemanticsHandle handle = tester.ensureSemantics();
83+
final FrameNode plan = FrameNode.fromJson({
84+
...FrameNode(
85+
id: 'plan',
86+
name: 'Plan',
87+
basicBoxLocal: NodeBox(0, 0, 200, 100),
88+
children: ['p1', 'p2'],
89+
).toJson(),
90+
'reactions': [
91+
Reaction(
92+
action: LinkAction(url: ''),
93+
trigger: TriggerModel(type: TriggerType.click),
94+
).toJson(),
95+
],
96+
})..parentID = kRootNode;
97+
final RectangleNode p1 = tappable('p1', 'Pin one')..parentID = 'plan';
98+
final RectangleNode p2 = tappable('p2', 'Pin two')..parentID = 'plan';
99+
final registry = NodeRegistry()
100+
..setNodes({for (final n in [plan, p1, p2]) n.id: n});
101+
final manager = PassiveNodeTransformerManager(
102+
registry.getNodeByID,
103+
(context, bounds, pageID, layoutID, canvasID) => const SizedBox(),
104+
);
105+
await tester.pumpWidget(
106+
ChangeNotifierProvider<CodelesslyContext>.value(
107+
value: CodelesslyContext.empty(),
108+
child: MaterialApp(
109+
home: Align(
110+
alignment: Alignment.topLeft,
111+
child: Builder(
112+
builder: (context) => manager.buildWidgetFromNode(
113+
plan,
114+
context,
115+
settings: const WidgetBuildSettings(debugLabel: 'test'),
116+
),
117+
),
118+
),
119+
),
120+
),
121+
);
122+
for (final String name in ['Pin one', 'Pin two']) {
123+
expect(
124+
tester.getSemantics(find.bySemanticsLabel(name)),
125+
matchesSemantics(label: name, isButton: true, hasTapAction: true),
126+
);
127+
}
128+
handle.dispose();
129+
});
78130
}

0 commit comments

Comments
 (0)