Skip to content

LayrzTooltip

Kenny Mochizuki Escalona edited this page Aug 25, 2026 · 8 revisions

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.


Overview

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.

Design Principles

  • 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 contentText or rich text via contentRichText. Widget content is deliberately not supported, mirroring the design principle applied to LayrzButton.
  • Layout-neutral wrapping — wrapping a widget in LayrzTooltip does 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 Overlay ancestor exists in the widget tree, the tooltip silently degrades to returning the child unchanged instead of crashing.

API Structure

Core Constructor

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();
}

Surface Styling

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.


LayrzPreferredSide

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.


Content Parameters: contentText vs. contentRichText

Exactly one of these must be provided; both or neither trigger an assertion error.

contentText

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(),
)

contentRichText

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(),
)

LayrzTooltipTrigger

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,
}

Trigger Modes

.pointer Mode (Default)

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 PointerRouter on PointerDownEvent)
  • 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.mouseIsConnected at 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

.tap 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
  • Hover has no effect in this mode, even on desktop
  • Works identically regardless of mouse presence

Dismissal Mechanism

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 with PointerUpEvent when the finger lifts
  • If dismissal observed PointerUpEvent, the tooltip would close immediately on release, contradicting the desired behavior
  • By observing PointerDownEvent only, the tooltip stays open until the next touch, tap, or gesture begins

Pass-Through Interaction: Surface vs. Anchor

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: true ensures the floating hint is always click-through
  • Anchor area: Is transparent (HitTestBehavior.translucent on wrapper layers), so pointers pass through to the child and to layers beneath in a Stack

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.

Known Constraint: Interaction with Child's onLongPress

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.


Overlay Requirement and Graceful Degradation

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(),
)

Material-Free Construction

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:

  1. Semantics — announces the tooltip content for accessibility
  2. MouseRegion (opaque: false, hitTestBehavior: HitTestBehavior.translucent) — hover detection on desktop (pointer mode only)
  3. GestureDetector (behavior: HitTestBehavior.translucent, onLongPress or onTap) — trigger detection based on mode
  4. KeyedSubtree — measures the anchor bounds via GlobalKey for positioning
  5. 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
  6. TextPainter — predicts surface size upfront for position computation
  7. 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.


Usage Examples

Basic Plain-Text Tooltip

LayrzTooltip(
  contentText: 'Save your work',
  child: LayrzButton.save(
    labelText: 'Save',
    onTap: () => _save(),
  ),
)

Rich-Text Tooltip with Mixed Styles

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'),
  ),
)

Positioned Above the Anchor

LayrzTooltip(
  position: LayrzPreferredSide.top,
  contentText: 'More information',
  child: Icon(MdiIcons.informationBoxOutline),
)

Positioned to the Right

LayrzTooltip(
  position: LayrzPreferredSide.right,
  contentText: 'Tooltip on the right',
  child: Container(
    width: 80,
    height: 80,
    color: Colors.green,
  ),
)

Dependencies

  • M1 Theme System (LayrzTheme, LayrzThemeData, LayrzTokens) — colors, typography, spacing, radius, and motion tokens
  • Flutter 3.47+RawTooltip and stable @Preview API
  • M1 Extensions (BuildContext.tokens) — for accessing theme tokens

Accessibility

  • The tooltip content is announced via the tooltip parameter of Semantics, 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 fg1 background and background text.

Relationship to Other Components

  • LayrzButton hintText: LayrzButton consumes LayrzTooltip internally to render tooltips for the hintText parameter. Buttons with hintText use this component's surface styling and positioning.
  • Standalone tooltips: Use LayrzTooltip directly 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

Clone this wiki locally