-
Notifications
You must be signed in to change notification settings - Fork 0
LayrzTooltip
A Material-free tooltip widget that displays styled text on hover (desktop) or long-press (touch), with automatic positioning and viewport-aware flipping.
Metadata
Mirrors: ThemedTooltip (layrz_theme)
Phase: M2 (Core primitives)
Domain: Feedback
Primitive: OverlayPortal
Status: Confirmed scope.
LayrzTooltip wraps any child widget and shows a styled text surface on long-press (touch) or hover (desktop), positioned relative to the child. The tooltip surface has no customizable color or widget content parameters — the styling is standardized with fg1 background and background text to ensure visual consistency across the system.
- Fixed surface styling — background color, text color, padding, and radius are controlled by tokens and not exposed as parameters, ensuring a uniform tooltip appearance across the application.
-
Text content only — supports plain text via
contentTextor rich text viacontentRichText. Widget content is deliberately not supported, mirroring the design principle applied toLayrzButton. -
Layout-neutral wrapping — wrapping a widget in
LayrzTooltipdoes not change its size, position, or hit-testing. The child is rendered unchanged; the tooltip is a separate portal that does not affect layout. - Pass-through interaction — both the tooltip surface and the anchor wrapper use transparent hit-test behavior, so pointers pass through to the child and to layers beneath. This enables tooltips on interactive elements without breaking their functionality.
- Automatic positioning — positions on a preferred side of the anchor (top, bottom, left, right) and flips to the opposite side if it would overflow the viewport. The tooltip always renders outside the anchor's bounding box, preventing the anchor from losing hover state.
-
Graceful degradation — if no
Overlayancestor exists in the widget tree, the tooltip silently degrades to returning the child unchanged instead of crashing.
class LayrzTooltip extends StatefulWidget {
/// The widget to be wrapped with the tooltip.
final Widget child;
/// Plain-text content for the tooltip.
///
/// Mutually exclusive with [contentRichText]. Exactly one of the two must be non-null.
/// The text is rendered in [tokens.typography.label] with color [tokens.colors.sf1] (light text on dark background).
final String? contentText;
/// Rich-text content for the tooltip with optional per-span styling.
///
/// Mutually exclusive with [contentText]. Exactly one of the two must be non-null.
/// The base style is [tokens.typography.label] with color [tokens.colors.sf1] (light text on dark background).
/// Per-span overrides in this [TextSpan] tree take precedence.
final TextSpan? contentRichText;
/// The preferred position of the tooltip relative to its anchor.
///
/// Defaults to [LayrzPreferredSide.bottom]. If the tooltip would overflow the
/// overlay bounds on the preferred side, it automatically flips to the opposite side.
final LayrzPreferredSide position;
/// Optional title text rendered above the tooltip content.
///
/// When non-null, the title is rendered above the content in `tokens.typography.body`
/// (heavier than the content's `label` style) with the same color scheme.
/// When null, only the content is rendered.
final String? titleText;
/// The trigger mode for showing and dismissing the tooltip.
///
/// Defaults to [LayrzTooltipTrigger.pointer]. See [LayrzTooltipTrigger] for details
/// on each mode's behavior.
final LayrzTooltipTrigger trigger;
/// Creates a new [LayrzTooltip] with the given properties.
///
/// Exactly one of [contentText] or [contentRichText] must be non-null.
/// Providing both or neither triggers an assertion error.
const LayrzTooltip({
super.key,
required this.child,
this.titleText,
this.contentText,
this.contentRichText,
this.position = LayrzPreferredSide.bottom,
this.trigger = LayrzTooltipTrigger.pointer,
}) : assert(
(contentText == null) != (contentRichText == null),
'Provide exactly one of contentText or contentRichText.',
);
@override
State<LayrzTooltip> createState() => _LayrzTooltipState();
}The tooltip surface is standardized with the following fixed properties:
| Property | Value |
|---|---|
| Background color | tokens.colors.fg1 |
| Text color | tokens.colors.sf1 |
| Text style | tokens.typography.label |
| Horizontal padding |
tokens.spacing.sp3 (16 logical pixels) |
| Vertical padding |
tokens.spacing.sp2 (8 logical pixels) |
| Border radius |
tokens.radius.r2 (8 logical pixels) |
| Max width | 80% of viewport width |
Why surface styling is fixed: Allowing caller-configurable colors would fragment the visual language across the application. Centralizing the surface appearance ensures all tooltips read as one system. Callers needing different text styling (bold, colored spans, etc.) use contentRichText with per-span style overrides; the surface background and text base color remain constant.
Specifies the preferred position of the tooltip relative to its anchor. This type is shared —
LayrzAnchoredPanel uses the same enum for its own preferredSide parameter.
enum LayrzPreferredSide {
/// Position the tooltip above the anchor.
top,
/// Position the tooltip below the anchor (default).
bottom,
/// Position the tooltip to the left of the anchor.
left,
/// Position the tooltip to the right of the anchor.
right,
}Automatic flipping behavior: If the tooltip would overflow the overlay bounds on the preferred side, it automatically flips to the opposite side (top ↔ bottom, left ↔ right). The cross-axis position (horizontal for top/bottom, vertical for left/right) is clamped to keep the tooltip on screen.
Gap: Each position maintains a consistent gap (kLayrzTooltipOffset = 10.0 logical pixels) between the anchor edge and the tooltip surface.
Exactly one of these must be provided; both or neither trigger an assertion error.
Plain-text content. Renders in tokens.typography.label with tokens.colors.sf1 color (light text on dark background).
LayrzTooltip(
contentText: 'Click to copy',
child: MyButton(),
)Rich-text content with optional per-span styling. The base style is label with background text color; per-span overrides take precedence.
LayrzTooltip(
contentRichText: TextSpan(
text: 'Click ',
children: [
TextSpan(
text: 'to copy',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
child: MyButton(),
)Specifies the trigger mode for showing and dismissing the tooltip:
enum LayrzTooltipTrigger {
/// Tooltip is triggered by pointer hover (desktop) or long-press (touch).
pointer,
/// Tooltip is triggered and dismissed by single taps.
tap,
}Mouse-connected devices (desktop):
-
Show: hover over the anchor (via
MouseRegion) - Dismiss: pointer exits the anchor
- No barrier or full-screen dismiss behavior needed; hover is transient
Touch-only devices (no mouse):
- Show: long-press on the anchor
-
Dismiss: the next touch anywhere on screen (via global
PointerRouteronPointerDownEvent) - Releasing the long-press finger does NOT dismiss — the tooltip remains visible until the next touch elsewhere
- This behavior satisfies decision DESIGN-77 (stay open until tapped away)
Mouse connection detection:
- The widget observes
RendererBinding.instance.mouseTracker.mouseIsConnectedat initialization and listens for changes - If a mouse is connected mid-session, the widget switches to hover mode; if disconnected, it switches to long-press mode
- Show: single tap on the anchor toggles the tooltip open/closed
-
Dismiss:
- Another tap anywhere on screen (via global
PointerRouter) dismisses the tooltip - Taps on the anchor itself are excluded from the global route, allowing the anchor's own
GestureDetector(onTap:)to own the toggle without interference
- Another tap anywhere on screen (via global
- Hover has no effect in this mode, even on desktop
- Works identically regardless of mouse presence
Both modes use a global pointer route (GestureBinding.instance.pointerRouter.addGlobalRoute) to intercept PointerDownEvent only (not PointerUpEvent). This is critical:
- Long-press opens via
GestureDetector(onLongPress:), which ends withPointerUpEventwhen the finger lifts - If dismissal observed
PointerUpEvent, the tooltip would close immediately on release, contradicting the desired behavior - By observing
PointerDownEventonly, the tooltip stays open until the next touch, tap, or gesture begins
LayrzTooltip is composed on OverlayPortal and employs ignorePointer: true for the tooltip surface — meaning the visible hint does not consume pointer events. The wrapper layers (MouseRegion, GestureDetector) use HitTestBehavior.translucent, which allows pointer events to pass through to the child and beyond.
-
Tooltip surface: Does not intercept pointers;
ignorePointer: trueensures the floating hint is always click-through -
Anchor area: Is transparent (
HitTestBehavior.translucenton wrapper layers), so pointers pass through to the child and to layers beneath in aStack
Practical impact: Wrapping a widget in LayrzTooltip does not change its hit-test behaviour. A SizedBox wrapped in LayrzTooltip remains pass-through — taps on it or around it reach whatever is painted beneath. In overlapping Stack layouts, this guarantees that the tooltip wrapper does not become an opaque blocker.
// In a Stack with overlapping widgets:
Stack(
children: [
Container(color: Colors.blue), // backdrop
LayrzTooltip( // wraps SizedBox
contentText: 'Hint',
child: SizedBox(width: 50, height: 50),
),
],
)
// Tapping the SizedBox works. Tapping the empty space around it
// also taps through to the blue backdrop beneath. The tooltip wrapper
// is hit-test transparent.If the child has an onLongPress handler, the child's gesture takes precedence in the gesture arena and the tooltip will not show. This affects only the touch/long-press trigger path; hover-triggered tooltips on desktop are unaffected.
// On touch: tap is consumed by button's onTap, tooltip does not show.
// On desktop: hover triggers tooltip normally.
LayrzTooltip(
contentText: 'Tooltip hint',
child: LayrzButton(
labelText: 'Action',
onTap: () => _performAction(),
),
)This is a fundamental limitation of Flutter's gesture arena — the long-press from the tooltip competes with the child's long-press handler. For buttons, use the hintText parameter of LayrzButton instead of wrapping in a tooltip.
LayrzTooltip requires an Overlay ancestor in the widget tree to render the tooltip surface. In normal applications, LayrzApp provides an Overlay via its WidgetsApp base.
Graceful degradation: If no Overlay is found via Overlay.maybeOf(context), the widget simply returns its child unchanged — the tooltip silently does not appear instead of crashing. This allows tooltips to work in test harnesses and custom widget trees that do not provide a full ancestor hierarchy.
// In a full app tree with LayrzApp: tooltip shows normally.
// In a minimal test scaffold without Overlay: tooltip silently skips, child renders.
LayrzTooltip(
contentText: 'Tooltip',
child: MyWidget(),
)LayrzTooltip is composed entirely without Material or Cupertino imports, using only package:flutter/widgets.dart primitives. The widget wraps its child in a series of layers:
- Semantics — announces the tooltip content for accessibility
-
MouseRegion (
opaque: false,hitTestBehavior: HitTestBehavior.translucent) — hover detection on desktop (pointer mode only) -
GestureDetector (
behavior: HitTestBehavior.translucent,onLongPressoronTap) — trigger detection based on mode -
KeyedSubtree — measures the anchor bounds via
GlobalKeyfor positioning -
OverlayPortal — renders the tooltip surface in a portal above the child
- OverlayPortalController — manages showing/hiding the overlay independently for each trigger mode
-
IgnorePointer (
ignoring: true) — ensures the surface does not intercept pointers - FadeTransition — animates surface visibility with motion tokens
- Container — surface styling with padding and decoration
- Text / Text.rich — content rendering
- TextPainter — predicts surface size upfront for position computation
-
RenderBox (
localToGlobal,size) — measures anchor bounds
Why OverlayPortal instead of manual OverlayEntry:
OverlayPortal's overlayChildBuilder rebuilds whenever the host widget rebuilds. This ensures the tooltip position is recomputed fresh based on the anchor's current location. A manual OverlayEntry built once would freeze its position at the scroll offset where it opened, causing the tooltip to detach from its anchor during scrolling.
LayrzTooltip(
contentText: 'Save your work',
child: LayrzButton.save(
labelText: 'Save',
onTap: () => _save(),
),
)LayrzTooltip(
contentRichText: TextSpan(
text: 'Click to view ',
children: [
TextSpan(
text: 'details',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
child: Container(
width: 100,
height: 40,
color: Colors.blue,
child: Text('Info'),
),
)LayrzTooltip(
position: LayrzPreferredSide.top,
contentText: 'More information',
child: Icon(MdiIcons.informationBoxOutline),
)LayrzTooltip(
position: LayrzPreferredSide.right,
contentText: 'Tooltip on the right',
child: Container(
width: 80,
height: 80,
color: Colors.green,
),
)-
M1 Theme System (
LayrzTheme,LayrzThemeData,LayrzTokens) — colors, typography, spacing, radius, and motion tokens -
Flutter 3.47+ —
RawTooltipand stable@PreviewAPI -
M1 Extensions (
BuildContext.tokens) — for accessing theme tokens
- The tooltip content is announced via the
tooltipparameter ofSemantics, making the hint available to screen readers. - Keyboard navigation does not trigger tooltips; they appear only on hover or long-press.
- The fixed surface styling ensures sufficient color contrast between the
fg1background andbackgroundtext.
-
LayrzButton hintText:
LayrzButtonconsumesLayrzTooltipinternally to render tooltips for thehintTextparameter. Buttons withhintTextuse this component's surface styling and positioning. -
Standalone tooltips: Use
LayrzTooltipdirectly to add hint text to non-button interactive elements like icons, badges, or custom widgets.
Last updated: 2026-08-16
Related documents: Milestone 2, Design Tokens, Architecture, Decisions (D15), Roadmap
Made with ❤️ by Golden M, Inc.
- LayrzTextInput
- LayrzSelectableAction
- LayrzSelectionToolbar
- LayrzTextSelectionControls
- LayrzSelectionMagnifier
- LayrzSelectionHandlePainter
- LayrzTextAreaInput
- LayrzComboBoxInput
- LayrzNumberInput
- LayrzPasswordInput
- LayrzCheckboxInput
- LayrzRadioInput
- LayrzSelectInput
- LayrzMultiSelectInput
- LayrzSearchInput
- LayrzDualListInput
- LayrzDurationInput