ashlarui

Tooltip

A short label over a control on hover, focus or long press.

Tooltip, as it looks in an app Tooltip, as it looks in an app, dark
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';

import 'ui/button.dart';
import 'ui/tooltip.dart';

class TooltipDemo extends StatelessWidget {
  const TooltipDemo({super.key});

  @override
  Widget build(BuildContext context) => Tooltip(
    message: 'Add to library',
    child: Button(
      size: ButtonSize.icon,
      variant: ButtonVariant.outline,
      semanticLabel: 'Add',
      onPressed: () {},
      child: const AshlarIcon(AshlarIconsRoundedStroke.plus),
    ),
  );
}

Try its options

Each option its constructor takes, to change and see; the code below it is what you chose, to copy.

Tooltip with controls, as it looks in an app Tooltip with controls, as it looks in an app, dark

Add it

In your Flutter project:

$ ashlar add tooltip

It writes theme.dart, palette.dart and tooltip.dart into lib/ui, keeping any you already have.

Examples

Other ways to use it, each running live, with its code.

Sides

Top, bottom, left or right.

Sides, as it looks in an app Sides, as it looks in an app, dark
// Sides: Top, bottom, left or right.
import 'package:flutter/widgets.dart';

import 'ui/button.dart';
import 'ui/tooltip.dart';

class TooltipSidesExample extends StatelessWidget {
  const TooltipSidesExample({super.key});

  @override
  Widget build(BuildContext context) => Wrap(
    spacing: 8,
    children: [
      for (final side in TooltipSide.values)
        Tooltip(
          message: 'On the ${side.name}',
          side: side,
          child: Button(variant: ButtonVariant.outline, onPressed: () {}, child: Text(side.name)),
        ),
    ],
  );
}

With a shortcut

Words and keys together.

With a shortcut, as it looks in an app With a shortcut, as it looks in an app, dark
// With a shortcut: Words and keys together.
import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';

import 'ui/button.dart';
import 'ui/kbd.dart';
import 'ui/tooltip.dart';

class TooltipShortcutExample extends StatelessWidget {
  const TooltipShortcutExample({super.key});

  @override
  Widget build(BuildContext context) => Tooltip(
    message: 'Save, Command S',
    content: const Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text('Save'),
        SizedBox(width: 8),
        Kbd(child: Text('⌘S')),
      ],
    ),
    child: Button(
      variant: ButtonVariant.outline,
      size: ButtonSize.icon,
      semanticLabel: 'Save',
      onPressed: () {},
      child: const AshlarIcon(AshlarIconsRoundedStroke.save),
    ),
  );
}

API

Read from the source above: each parameter with its type, its default, and what it is for.

TooltipSideenum

A short label that appears over child when it is hovered, focused from the keyboard or long pressed. Use it for what an icon button does, not for anything a person needs.

ValueWhat it is for
top
bottom
left
right

Tooltipclass

Parameter Type Default What it is for
messagerequired String What it says, and what a screen reader hears.
childrequired Widget What it describes: hovered, focused or long pressed to show it.
side TooltipSide TooltipSide.top Which side of the child it shows on. It flips when there is no room.
content Widget? Shown in place of message when there is more to show: a shortcut in Kbd keys.

Source

What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add will not write over it.

lib/ui/tooltip.dart 136 lines
// Ashlar UI: Tooltip. Copied into your app by `ashlar add tooltip`; it is yours to change.
//
// Built on RawTooltip from Flutter's widgets layer, which shows it on hover and on a long
// press. This file also shows it on keyboard focus and gives screen readers the message.

import 'package:flutter/widgets.dart';

import 'theme.dart';

/// A short label that appears over [child] when it is hovered, focused from the keyboard
/// or long pressed. Use it for what an icon button does, not for anything a person needs.
///
/// ```dart
/// Tooltip(message: 'Add to library', child: Button(size: ButtonSize.icon, semanticLabel: 'Add', onPressed: add, child: AshlarIcon(AshlarIconsRoundedStroke.plus)))
/// ```
/// The side of its child a [Tooltip] shows on.
enum TooltipSide { top, bottom, left, right }

class Tooltip extends StatefulWidget {
  const Tooltip({
    super.key,
    required this.message,
    required this.child,
    this.side = TooltipSide.top,
    this.content,
  });

  /// What it says, and what a screen reader hears.
  final String message;

  /// What it describes: hovered, focused or long pressed to show it.
  final Widget child;

  /// Which side of the child it shows on. It flips when there is no room.
  final TooltipSide side;

  /// Shown in place of [message] when there is more to show: a shortcut in Kbd keys.
  final Widget? content;

  @override
  State<Tooltip> createState() => _TooltipState();
}

class _TooltipState extends State<Tooltip> {
  final _tip = GlobalKey<RawTooltipState>();

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    return Focus(
      canRequestFocus: false,
      skipTraversal: true,
      onFocusChange: (focused) {
        if (!focused) {
          RawTooltip.dismissAllToolTips();
        } else if (FocusManager.instance.highlightMode ==
            FocusHighlightMode.traditional) {
          _tip.currentState?.ensureTooltipVisible();
        }
      },
      child: RawTooltip(
        key: _tip,
        // The message goes on the control itself, so a screen reader hears it with the
        // control's name rather than as a separate item.
        semanticsTooltip: null,
        hoverDelay: Duration.zero,
        positionDelegate: (p) {
          const gap = 4.0, margin = 8.0;
          final t = p.target,
              ts = p.targetSize,
              size = p.tooltipSize,
              room = p.overlaySize;
          double clampX(double x) => x.clamp(
            margin,
            (room.width - size.width - margin).clamp(margin, double.infinity),
          );
          double clampY(double y) => y.clamp(
            margin,
            (room.height - size.height - margin).clamp(margin, double.infinity),
          );
          final above = t.dy - ts.height / 2 - gap - size.height,
              under = t.dy + ts.height / 2 + gap;
          final before = t.dx - ts.width / 2 - gap - size.width,
              after = t.dx + ts.width / 2 + gap;
          final fitsAbove = above >= margin,
              fitsUnder = under + size.height <= room.height - margin;
          final fitsBefore = before >= margin,
              fitsAfter = after + size.width <= room.width - margin;
          switch (widget.side) {
            case TooltipSide.top || TooltipSide.bottom:
              final down = widget.side == TooltipSide.bottom
                  ? (fitsUnder || !fitsAbove)
                  : !(fitsAbove || !fitsUnder);
              return Offset(
                clampX(t.dx - size.width / 2),
                down ? under : above,
              );
            case TooltipSide.left || TooltipSide.right:
              final right = widget.side == TooltipSide.right
                  ? (fitsAfter || !fitsBefore)
                  : !(fitsBefore || !fitsAfter);
              return Offset(
                right ? after : before,
                clampY(t.dy - size.height / 2),
              );
          }
        },
        tooltipBuilder: (context, animation) => FadeTransition(
          opacity: animation,
          child: ScaleTransition(
            scale: Tween(begin: .95, end: 1.0).animate(animation),
            child: Container(
              constraints: const BoxConstraints(maxWidth: 280),
              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
              decoration: BoxDecoration(
                color: c.primary,
                borderRadius: BorderRadius.circular(t.radiusMd),
              ),
              child: DefaultTextStyle.merge(
                style: t.text.xs.copyWith(color: c.primaryForeground),
                child: IconTheme.merge(
                  data: IconThemeData(color: c.primaryForeground, size: 12),
                  child: widget.content ?? Text(widget.message),
                ),
              ),
            ),
          ),
        ),
        child: MergeSemantics(
          child: Semantics(tooltip: widget.message, child: widget.child),
        ),
      ),
    );
  }
}