Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/widget/tooltip.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const NakedTooltip({
this.positioning = const OverlayPositionConfig(),
this.semanticLabel,
this.excludeSemantics = false,
this.excludeOverlaySemantics,
})
```

Expand All @@ -95,7 +96,8 @@ const NakedTooltip({
- `animationStyle` → customize animation duration, curve, and reverse behavior
- `onTriggered` → called when triggered by tap or long press (not hover)
- `semanticLabel` → text announced by assistive technologies
- `excludeSemantics` → hide tooltip semantics from accessibility services
- `excludeSemantics` → hide the trigger, tooltip description, and visual overlay subtree from accessibility services
- `excludeOverlaySemantics` → control the visual overlay subtree independently; by default it is hidden when a non-empty `semanticLabel` describes the tooltip on the trigger, and retained otherwise

Comment thread
leoafarias marked this conversation as resolved.
## Behaviour Notes

Expand All @@ -105,6 +107,7 @@ const NakedTooltip({
- Use `AnimationStyle.noAnimation` to disable the built-in transition
- The tooltip dismisses on outside tap (controllable via `enableTapToDismiss`)
- Only one tooltip is shown at a time when using nested tooltips
- Visual overlay content is semantics-excluded automatically when a non-empty `semanticLabel` describes it on the trigger; set `excludeOverlaySemantics` explicitly to override this behavior

## Positioning Tips

Expand Down
19 changes: 18 additions & 1 deletion packages/naked_ui/lib/src/naked_tooltip.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class NakedTooltip extends StatefulWidget {
this.useRootOverlay = false,
this.semanticLabel,
this.excludeSemantics = false,
this.excludeOverlaySemantics,
});

/// The widget that triggers the tooltip.
Expand Down Expand Up @@ -98,9 +99,18 @@ class NakedTooltip extends StatefulWidget {
/// Whether the tooltip is inserted into the root overlay.
final bool useRootOverlay;

/// Whether to hide the trigger subtree from the semantics tree.
/// Whether to hide the trigger and overlay subtrees from the semantics tree.
final bool excludeSemantics;

/// Whether to hide the visual overlay subtree from the semantics tree.
///
/// When null, the overlay is excluded only when a non-empty [semanticLabel]
/// is exposed on the trigger. Set this to true to always exclude the overlay
/// or false to always include independently meaningful custom content.
///
/// [excludeSemantics] takes precedence and hides the entire tooltip.
final bool? excludeOverlaySemantics;

@override
State<NakedTooltip> createState() {
assert(!hoverDelay.isNegative, 'hoverDelay must not be negative');
Expand Down Expand Up @@ -371,6 +381,13 @@ class _NakedTooltipState extends State<NakedTooltip>

Widget _buildOverlay(BuildContext context, RawMenuOverlayInfo info) {
Widget result = widget.overlayBuilder(context, _animation);
final excludeOverlaySemantics =
widget.excludeSemantics ||
(widget.excludeOverlaySemantics ??
(widget.semanticLabel?.trim().isNotEmpty ?? false));
if (excludeOverlaySemantics) {
result = ExcludeSemantics(child: result);
}
result = MouseRegion(
opaque: false,
onEnter: _handleContentEnter,
Expand Down
234 changes: 206 additions & 28 deletions packages/naked_ui/test/semantics/naked_tooltip_semantics_test.dart
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
import 'dart:ui' show SemanticsAction, Tristate;

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:naked_ui/naked_ui.dart';

import 'semantics_test_utils.dart';

List<SemanticsNode> _nodesWithLabel(WidgetTester tester, String label) {
return tester.semantics
.simulatedAccessibilityTraversal()
.where((node) => node.getSemanticsData().label == label)
.toList();
}

void main() {
Widget _buildTestApp(Widget child) {
return MaterialApp(
home: Scaffold(body: Center(child: child)),
);
}

Widget _buildMaterialTooltip({
required String message,
required String child,
}) {
return Tooltip(message: message, child: Text(child));
}

Widget _buildNakedTooltip({required String message, required String child}) {
return NakedTooltip(
semanticLabel: message,
Expand Down Expand Up @@ -94,38 +97,213 @@ void main() {
handle.dispose();
});

testWidgets('tooltip hover behavior semantics', (tester) async {
testWidgets('keeps one unchanged trigger node through hover lifecycle', (
tester,
) async {
final handle = tester.ensureSemantics();
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer();
await tester.pump();
const label = 'Filter chats';

await tester.pumpWidget(
_buildTestApp(
_buildMaterialTooltip(message: 'Hover tooltip', child: 'Hover me'),
),
);
try {
await tester.pumpWidget(
_buildTestApp(
NakedTooltip(
semanticLabel: label,
hoverDelay: Duration.zero,
dismissDelay: Duration.zero,
animationStyle: AnimationStyle.noAnimation,
overlayBuilder: (context, animation) => const Text(label),
child: NakedButton(
key: const Key('naked-trigger'),
semanticLabel: label,
onPressed: () {},
child: const SizedBox.square(dimension: 40),
),
),
),
);
final closedTrigger = summarizeMergedFromRoot(
tester,
control: ControlType.button,
);
final labelCounts = <int>[_nodesWithLabel(tester, label).length];
final triggerStates = <SemanticsSummary>[closedTrigger];

await mouse.moveTo(tester.getCenter(find.text('Hover me')));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await mouse.moveTo(
tester.getCenter(find.byKey(const Key('naked-trigger'))),
);
await tester.pumpAndSettle();

expect(find.text('Hover tooltip'), findsOneWidget);
final openNodes = _nodesWithLabel(tester, label);
labelCounts.add(openNodes.length);
final data = openNodes.single.getSemanticsData();
expect(data.tooltip, label);
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.flagsCollection.isFocused, isNot(Tristate.none));
expect(data.hasAction(SemanticsAction.tap), isTrue);
triggerStates.add(
summarizeMergedFromRoot(tester, control: ControlType.button),
);

await tester.pumpWidget(
_buildTestApp(
_buildNakedTooltip(message: 'Hover tooltip', child: 'Hover me'),
),
await mouse.moveTo(const Offset(-1000, -1000));
await tester.pumpAndSettle();
labelCounts.add(_nodesWithLabel(tester, label).length);
triggerStates.add(
summarizeMergedFromRoot(tester, control: ControlType.button),
);

expect(labelCounts, [1, 1, 1]);
expect(triggerStates, everyElement(closedTrigger));
} finally {
await mouse.removePointer();
handle.dispose();
}
});

testWidgets('can include meaningful custom overlay semantics', (
tester,
) async {
final handle = tester.ensureSemantics();

try {
await tester.pumpWidget(
_buildTestApp(
NakedTooltip(
open: true,
semanticLabel: 'Show connection help',
excludeOverlaySemantics: false,
animationStyle: AnimationStyle.noAnimation,
overlayBuilder: (context, animation) => Semantics(
label: 'Connection status',
child: const ExcludeSemantics(child: Text('Connected')),
),
child: NakedButton(
key: const Key('custom-overlay-trigger'),
semanticLabel: 'Show status',
onPressed: () {},
child: const SizedBox.square(dimension: 40),
),
),
),
);
await tester.pumpAndSettle();

final trigger = _nodesWithLabel(tester, 'Show status').single;
final overlay = _nodesWithLabel(tester, 'Connection status').single;

expect(trigger.getSemanticsData().tooltip, 'Show connection help');
expect(
trigger.getSemanticsData().hasAction(SemanticsAction.tap),
isTrue,
);
expect(overlay.getSemanticsData().label, 'Connection status');
expect(find.text('Connected'), findsOneWidget);
} finally {
handle.dispose();
}
});

for (final (description, semanticLabel) in <(String, String?)>[
('omitted', null),
('blank', ' '),
]) {
testWidgets(
'keeps overlay semantics when the trigger tooltip label is $description',
(tester) async {
final handle = tester.ensureSemantics();

try {
await tester.pumpWidget(
_buildTestApp(
NakedTooltip(
open: true,
semanticLabel: semanticLabel,
animationStyle: AnimationStyle.noAnimation,
overlayBuilder: (context, animation) =>
const Text('Connection status'),
child: NakedButton(
semanticLabel: 'Show status',
onPressed: () {},
child: const SizedBox.square(dimension: 40),
),
),
),
);
await tester.pumpAndSettle();

final trigger = _nodesWithLabel(tester, 'Show status').single;
expect(trigger.getSemanticsData().tooltip.trim(), isEmpty);
expect(_nodesWithLabel(tester, 'Connection status'), hasLength(1));
} finally {
handle.dispose();
}
},
);
}

testWidgets('can explicitly exclude an unlabeled overlay', (tester) async {
final handle = tester.ensureSemantics();

try {
await tester.pumpWidget(
_buildTestApp(
NakedTooltip(
open: true,
excludeOverlaySemantics: true,
animationStyle: AnimationStyle.noAnimation,
overlayBuilder: (context, animation) =>
const Text('Connection status'),
child: NakedButton(
semanticLabel: 'Show status',
onPressed: () {},
child: const SizedBox.square(dimension: 40),
),
),
),
);
await tester.pumpAndSettle();

expect(_nodesWithLabel(tester, 'Show status'), hasLength(1));
expect(_nodesWithLabel(tester, 'Connection status'), isEmpty);
} finally {
handle.dispose();
}
});

await mouse.moveTo(tester.getCenter(find.text('Hover me')));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
testWidgets('excludeSemantics overrides explicit overlay inclusion', (
tester,
) async {
final handle = tester.ensureSemantics();

expect(find.text('Hover me'), findsOneWidget);
try {
await tester.pumpWidget(
_buildTestApp(
NakedTooltip(
open: true,
semanticLabel: 'Show connection help',
excludeSemantics: true,
excludeOverlaySemantics: false,
useRootOverlay: true,
animationStyle: AnimationStyle.noAnimation,
overlayBuilder: (context, animation) =>
const Text('Connection status'),
child: NakedButton(
semanticLabel: 'Show status',
onPressed: () {},
child: const SizedBox.square(dimension: 40),
),
),
),
);
await tester.pumpAndSettle();

await mouse.removePointer();
handle.dispose();
expect(_nodesWithLabel(tester, 'Show status'), isEmpty);
expect(_nodesWithLabel(tester, 'Connection status'), isEmpty);
} finally {
handle.dispose();
}
});

testWidgets('semantics label accessibility', (tester) async {
Expand Down
Loading