Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 = true,
})
```

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 and its tooltip description from accessibility services
- `excludeOverlaySemantics` → hide the visual overlay subtree from accessibility services (default: true); set false for independently meaningful custom content

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 by default; use `semanticLabel` to describe it on the trigger

## Positioning Tips

Expand Down
10 changes: 10 additions & 0 deletions 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 = true,
});

/// The widget that triggers the tooltip.
Expand Down Expand Up @@ -101,6 +102,12 @@ class NakedTooltip extends StatefulWidget {
/// Whether to hide the trigger subtree from the semantics tree.
final bool excludeSemantics;

/// Whether to hide the visual overlay subtree from the semantics tree.
///
/// The overlay is excluded by default so tooltip semantics stay on the
/// trigger. Set this to false for independently meaningful custom content.
final bool excludeOverlaySemantics;

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

Widget _buildOverlay(BuildContext context, RawMenuOverlayInfo info) {
Widget result = widget.overlayBuilder(context, _animation);
if (widget.excludeOverlaySemantics) {
result = ExcludeSemantics(child: result);
}
Comment thread
leoafarias marked this conversation as resolved.
result = MouseRegion(
opaque: false,
onEnter: _handleContentEnter,
Expand Down
135 changes: 106 additions & 29 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,112 @@ 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';

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 tester.pumpWidget(
_buildTestApp(
_buildMaterialTooltip(message: 'Hover tooltip', child: 'Hover me'),
),
);
await mouse.moveTo(
tester.getCenter(find.byKey(const Key('naked-trigger'))),
);
await tester.pumpAndSettle();

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 mouse.moveTo(tester.getCenter(find.text('Hover me')));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await mouse.moveTo(const Offset(-1000, -1000));
await tester.pumpAndSettle();
labelCounts.add(_nodesWithLabel(tester, label).length);
triggerStates.add(
summarizeMergedFromRoot(tester, control: ControlType.button),
);

expect(find.text('Hover tooltip'), findsOneWidget);
expect(labelCounts, [1, 1, 1]);
expect(triggerStates, everyElement(closedTrigger));
} finally {
await mouse.removePointer();
handle.dispose();
}
});

await tester.pumpWidget(
_buildTestApp(
_buildNakedTooltip(message: 'Hover tooltip', child: 'Hover me'),
),
);
testWidgets('can include meaningful custom overlay semantics', (
tester,
) async {
final handle = tester.ensureSemantics();

await mouse.moveTo(tester.getCenter(find.text('Hover me')));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
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();

expect(find.text('Hover me'), findsOneWidget);
final trigger = _nodesWithLabel(tester, 'Show status').single;
final overlay = _nodesWithLabel(tester, 'Connection status').single;

await mouse.removePointer();
handle.dispose();
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();
}
});

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