ashlarui

Dropdown Menu

A menu of actions from a trigger: checkbox and radio items, groups and submenus.

Dropdown Menu, as it looks in an app Dropdown Menu, 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/dropdown_menu.dart';

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

  @override
  Widget build(BuildContext context) => DropdownMenu(
    width: 224,
    builder: (context, menu) => Button(
      variant: ButtonVariant.outline,
      trailing: const AshlarIcon(AshlarIconsRoundedStroke.chevronDown),
      onPressed: () => menu.isOpen ? menu.close() : menu.open(),
      child: const Text('My account'),
    ),
    items: [
      const MenuLabel(child: Text('My account')),
      MenuItem(
        leading: const AshlarIcon(AshlarIconsRoundedStroke.user),
        shortcut: const Text('⇧⌘P'),
        onPressed: () {},
        child: const Text('Profile'),
      ),
      MenuItem(
        leading: const AshlarIcon(AshlarIconsRoundedStroke.creditCard),
        onPressed: () {},
        child: const Text('Billing'),
      ),
      MenuItem(
        leading: const AshlarIcon(AshlarIconsRoundedStroke.settings),
        shortcut: const Text('⌘,'),
        onPressed: () {},
        child: const Text('Settings'),
      ),
      const MenuSeparator(),
      MenuItem(
        leading: const AshlarIcon(AshlarIconsRoundedStroke.logOut),
        destructive: true,
        onPressed: () {},
        child: const Text('Sign out'),
      ),
    ],
  );
}

Add it

In your Flutter project:

$ ashlar add dropdown-menu

It writes theme.dart, palette.dart, group.dart, pressable.dart, popover.dart and dropdown_menu.dart into lib/ui, keeping any you already have, and adds ashlar_icons to pubspec.yaml. It needs pressable, popover and ashlar_icons, which come with it.

Examples

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

Checkbox items

Things to show or hide, the menu staying open.

Checkbox items, as it looks in an app Checkbox items, as it looks in an app, dark
// Checkbox items: Things to show or hide, the menu staying open.
import 'package:flutter/widgets.dart';

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

class DropdownMenuCheckboxesExample extends StatefulWidget {
  const DropdownMenuCheckboxesExample({super.key});

  @override
  State<DropdownMenuCheckboxesExample> createState() => _DropdownMenuCheckboxesExampleState();
}

class _DropdownMenuCheckboxesExampleState extends State<DropdownMenuCheckboxesExample> {
  final shown = {'Status bar'};

  @override
  Widget build(BuildContext context) => DropdownMenu(
    builder: (context, menu) => Button(variant: ButtonVariant.outline, onPressed: menu.open, child: const Text('View')),
    items: [
      const MenuLabel(child: Text('Show')),
      for (final name in ['Status bar', 'Activity bar', 'Panel'])
        MenuCheckboxItem(
          checked: shown.contains(name),
          closeOnPress: false,
          onChanged: (v) => setState(() => v ? shown.add(name) : shown.remove(name)),
          child: Text(name),
        ),
    ],
  );
}

Radio items

One choice of several.

Radio items, as it looks in an app Radio items, as it looks in an app, dark
// Radio items: One choice of several.
import 'package:flutter/widgets.dart';

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

class DropdownMenuRadioExample extends StatefulWidget {
  const DropdownMenuRadioExample({super.key});

  @override
  State<DropdownMenuRadioExample> createState() => _DropdownMenuRadioExampleState();
}

class _DropdownMenuRadioExampleState extends State<DropdownMenuRadioExample> {
  var side = 'Bottom';

  @override
  Widget build(BuildContext context) => DropdownMenu(
    builder: (context, menu) =>
        Button(variant: ButtonVariant.outline, onPressed: menu.open, child: Text('Panel: $side')),
    items: [
      MenuRadioGroup<String>(
        value: side,
        onChanged: (v) => setState(() => side = v),
        children: const [
          MenuRadioItem(value: 'Top', child: Text('Top')),
          MenuRadioItem(value: 'Bottom', child: Text('Bottom')),
          MenuRadioItem(value: 'Right', child: Text('Right')),
        ],
      ),
    ],
  );
}

Submenus

A row that opens a menu of its own.

Submenus, as it looks in an app Submenus, as it looks in an app, dark
// Submenus: A row that opens a menu of its own.
import 'package:flutter/widgets.dart';

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

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

  @override
  Widget build(BuildContext context) => DropdownMenu(
    builder: (context, menu) =>
        Button(variant: ButtonVariant.outline, onPressed: menu.open, child: const Text('Share')),
    items: [
      MenuItem(onPressed: () {}, shortcut: const Text('⌘C'), child: const Text('Copy link')),
      MenuSub(
        items: [
          MenuItem(onPressed: () {}, child: const Text('Email')),
          MenuItem(onPressed: () {}, child: const Text('Message')),
        ],
        child: const Text('Send to'),
      ),
      const MenuSeparator(),
      MenuItem(destructive: true, onPressed: () {}, child: const Text('Remove access')),
    ],
  );
}

API

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

DropdownMenuclass

A list of actions that opens from a trigger. builder draws the trigger and gets the controller that opens it. The arrow keys move through the items, Enter picks one and Escape closes the menu.

Parameter Type Default What it is for
builderrequired Widget Function(BuildContext context, MenuController controller) Builds what opens it, given a controller to open and close it with.
itemsrequired List<Widget> MenuItem, MenuLabel and MenuSeparator, in order.
controller MenuController? Opens and closes it from outside; one is made when not given.
side PopoverSide PopoverSide.bottom Which side it opens on. It moves to the other side when there is no room.
align PopoverAlign PopoverAlign.start How it lines up along that side: at the start, the centre or the end.
width double? Null sizes the menu to its widest item.

MenuListclass

The items of an open menu, in a scrolling column: focus starts on the list, and the arrow keys, Home and End move through the items. A Select uses it too.

Parameter Type Default What it is for
childrenrequired List<Widget> The menu's rows.
focusFirst bool false Puts focus on the first item as it opens, as a submenu does.

MenuItemclass

An action in a menu. Picking it closes the menu, then calls onPressed.

Parameter Type Default What it is for
childrequired Widget Its label.
onPressedrequired VoidCallback? Null disables the item.
leading Widget? A 16px icon before the label.
trailing Widget? An icon after the label, at the end of the row.
shortcut Widget? A key combination, shown faint at the end of the row: Text('⌘S').
destructive bool false Drawn in the destructive colour: delete, sign out.
selected bool? For a menu of choices: announced as chosen.
inset bool false Set in, to line up with checkbox and radio items beside it.
autofocus bool false Takes the keyboard's focus when it first appears.

MenuCheckboxItemclass

An item that is on or off: a setting shown in the menu. Picking it flips it and closes the menu.

Parameter Type Default What it is for
checkedrequired bool Whether it is ticked.
onChangedrequired ValueChanged<bool>? Null disables the item.
childrequired Widget Its label.
shortcut Widget? A key combination, shown faint at the end: Text('⌘K').
closeOnPress bool true False keeps the menu open, for setting several in a row.

MenuRadioGroupclass

The items of a menu of which one is chosen, by value. Put MenuRadioItems in it.

Parameter Type Default What it is for
valuerequired T? The value chosen, or null.
onChangedrequired ValueChanged<T>? Told each new value as it changes.
childrenrequired List<Widget> The radio items.

MenuRadioItemclass

One choice in a MenuRadioGroup, marked with a dot when it is the one chosen.

Parameter Type Default What it is for
valuerequired T The value it stands for.
childrequired Widget Its label.
enabled bool true False fades it and stops it taking presses and focus.

MenuGroupclass

Items that belong together, read out as a group.

Parameter Type Default What it is for
childrenrequired List<Widget> The group's rows.

MenuSubclass

A row that opens a further menu beside it: on hover, a click, Enter or the right arrow. The left arrow and Escape close it again.

Parameter Type Default What it is for
childrequired Widget Its label.
itemsrequired List<Widget> The submenu's rows.
leading Widget? Before the label: usually an icon.
inset bool false Lines its label up with items that have a mark before them.
enabled bool true False fades it and stops it taking presses and focus.

MenuLabelclass

A heading over a group of items.

Parameter Type Default What it is for
childrequired Widget The label's words.
inset bool false Set in, to line up with checkbox and radio items under it.

MenuSeparatorclass

A line between groups of items.

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/dropdown_menu.dart 738 lines
// Ashlar UI: Dropdown Menu. Copied into your app by `ashlar add dropdown-menu`; it is
// yours to change.

import 'dart:async';
import 'dart:ui' show SemanticsRole;

import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';

import 'popover.dart';
import 'pressable.dart';
import 'theme.dart';

/// A list of actions that opens from a trigger. [builder] draws the trigger and gets the
/// controller that opens it. The arrow keys move through the items, Enter picks one and
/// Escape closes the menu.
///
/// ```dart
/// DropdownMenu(
///   builder: (context, menu) => Button(variant: ButtonVariant.outline, onPressed: menu.open, child: Text('Open')),
///   items: [
///     MenuLabel(child: Text('My account')),
///     MenuItem(leading: AshlarIcon(AshlarIconsRoundedStroke.user), onPressed: profile, child: Text('Profile')),
///     MenuSeparator(),
///     MenuItem(destructive: true, onPressed: signOut, child: Text('Sign out')),
///   ],
/// )
/// ```
class DropdownMenu extends StatefulWidget {
  const DropdownMenu({
    super.key,
    required this.builder,
    required this.items,
    this.controller,
    this.side = PopoverSide.bottom,
    this.align = PopoverAlign.start,
    this.width,
  });

  /// Builds what opens it, given a controller to open and close it with.
  final Widget Function(BuildContext context, MenuController controller)
  builder;

  /// [MenuItem], [MenuLabel] and [MenuSeparator], in order.
  final List<Widget> items;

  /// Opens and closes it from outside; one is made when not given.
  final MenuController? controller;

  /// Which side it opens on. It moves to the other side when there is no room.
  final PopoverSide side;

  /// How it lines up along that side: at the start, the centre or the end.
  final PopoverAlign align;

  /// Null sizes the menu to its widest item.
  final double? width;

  @override
  State<DropdownMenu> createState() => _DropdownMenuState();
}

class _DropdownMenuState extends State<DropdownMenu> {
  final _own = MenuController();

  @override
  Widget build(BuildContext context) => RawMenuAnchor(
    controller: widget.controller ?? _own,
    builder: (context, controller, _) => widget.builder(context, controller),
    overlayBuilder: (context, info) => PopoverPanel(
      info: info,
      side: widget.side,
      align: widget.align,
      width: widget.width,
      child: MenuList(children: widget.items),
    ),
  );
}

/// The items of an open menu, in a scrolling column: focus starts on the list, and the
/// arrow keys, Home and End move through the items. A Select uses it too.
class MenuList extends StatefulWidget {
  const MenuList({super.key, required this.children, this.focusFirst = false});

  /// The menu's rows.
  final List<Widget> children;

  /// Puts focus on the first item as it opens, as a submenu does.
  final bool focusFirst;

  @override
  State<MenuList> createState() => _MenuListState();
}

class _MenuListState extends State<MenuList> {
  final _scope = FocusScopeNode(debugLabel: 'Menu');

  @override
  void initState() {
    super.initState();
    if (widget.focusFirst) {
      WidgetsBinding.instance.addPostFrameCallback((_) {
        final first = _scope.traversalDescendants
            .where((n) => n.canRequestFocus && !n.skipTraversal)
            .firstOrNull;
        first?.requestFocus();
      });
    }
  }

  @override
  void dispose() {
    _scope.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final list = _list(context);
    // The first menu opened is the root; a submenu's list sits inside it.
    if (context.dependOnInheritedWidgetOfExactType<_MenuRoot>() != null) {
      return list;
    }
    return _MenuRoot(controller: MenuController.maybeOf(context), child: list);
  }

  Widget _list(BuildContext context) => Semantics(
    role: SemanticsRole.menu,
    container: true,
    explicitChildNodes: true,
    child: Shortcuts(
      shortcuts: const {
        SingleActivator(LogicalKeyboardKey.arrowDown): NextFocusIntent(),
        SingleActivator(LogicalKeyboardKey.arrowUp): PreviousFocusIntent(),
        SingleActivator(LogicalKeyboardKey.home): _EdgeIntent(first: true),
        SingleActivator(LogicalKeyboardKey.end): _EdgeIntent(first: false),
      },
      child: FocusScope(
        node: _scope,
        autofocus: true,
        child: Builder(
          builder: (context) => Actions(
            actions: {
              _EdgeIntent: CallbackAction<_EdgeIntent>(
                onInvoke: (i) {
                  final scope = FocusScope.of(context);
                  final items = scope.traversalDescendants.toList();
                  if (items.isEmpty) return null;
                  (i.first ? items.first : items.last).requestFocus();
                  return null;
                },
              ),
            },
            child: IntrinsicWidth(
              child: SingleChildScrollView(
                padding: const EdgeInsets.all(4),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: widget.children,
                ),
              ),
            ),
          ),
        ),
      ),
    ),
  );
}

/// The outermost menu, so picking an item in a submenu closes all of them.
class _MenuRoot extends InheritedWidget {
  const _MenuRoot({required this.controller, required super.child});

  final MenuController? controller;

  static MenuController? of(BuildContext context) =>
      context.dependOnInheritedWidgetOfExactType<_MenuRoot>()?.controller;

  @override
  bool updateShouldNotify(_MenuRoot old) => old.controller != controller;
}

class _EdgeIntent extends Intent {
  const _EdgeIntent({required this.first});

  final bool first;
}

/// An action in a menu. Picking it closes the menu, then calls [onPressed].
class MenuItem extends StatelessWidget {
  const MenuItem({
    super.key,
    required this.child,
    required this.onPressed,
    this.leading,
    this.trailing,
    this.shortcut,
    this.destructive = false,
    this.selected,
    this.inset = false,
    this.autofocus = false,
  });

  /// Its label.
  final Widget child;

  /// Null disables the item.
  final VoidCallback? onPressed;

  /// A 16px icon before the label.
  final Widget? leading;

  /// An icon after the label, at the end of the row.
  final Widget? trailing;

  /// A key combination, shown faint at the end of the row: Text('⌘S').
  final Widget? shortcut;

  /// Drawn in the destructive colour: delete, sign out.
  final bool destructive;

  /// For a menu of choices: announced as chosen.
  final bool? selected;

  /// Set in, to line up with checkbox and radio items beside it.
  final bool inset;

  /// Takes the keyboard's focus when it first appears.
  final bool autofocus;

  @override
  Widget build(BuildContext context) => _MenuRow(
    onPressed: onPressed,
    leading: leading,
    trailing: trailing,
    shortcut: shortcut,
    destructive: destructive,
    selected: selected,
    inset: inset,
    autofocus: autofocus,
    role: SemanticsRole.menuItem,
    child: child,
  );
}

/// An item that is on or off: a setting shown in the menu. Picking it flips it and closes
/// the menu.
///
/// ```dart
/// MenuCheckboxItem(checked: showStatusBar, onChanged: (v) => setState(() => showStatusBar = v), child: Text('Status bar'))
/// ```
class MenuCheckboxItem extends StatelessWidget {
  const MenuCheckboxItem({
    super.key,
    required this.checked,
    required this.onChanged,
    required this.child,
    this.shortcut,
    this.closeOnPress = true,
  });

  /// Whether it is ticked.
  final bool checked;

  /// Null disables the item.
  final ValueChanged<bool>? onChanged;

  /// Its label.
  final Widget child;

  /// A key combination, shown faint at the end: Text('⌘K').
  final Widget? shortcut;

  /// False keeps the menu open, for setting several in a row.
  final bool closeOnPress;

  @override
  Widget build(BuildContext context) => _MenuRow(
    onPressed: onChanged == null ? null : () => onChanged!(!checked),
    checked: checked,
    closeOnPress: closeOnPress,
    shortcut: shortcut,
    role: SemanticsRole.menuItemCheckbox,
    indicator: checked
        ? const AshlarIcon(AshlarIconsRoundedStroke.check)
        : null,
    inset: true,
    child: child,
  );
}

/// The items of a menu of which one is chosen, by [value]. Put [MenuRadioItem]s in it.
///
/// ```dart
/// MenuRadioGroup<String>(value: position, onChanged: (v) => setState(() => position = v), children: [
///   MenuRadioItem(value: 'top', child: Text('Top')),
///   MenuRadioItem(value: 'bottom', child: Text('Bottom')),
/// ])
/// ```
class MenuRadioGroup<T> extends StatelessWidget {
  const MenuRadioGroup({
    super.key,
    required this.value,
    required this.onChanged,
    required this.children,
  });

  /// The value chosen, or null.
  final T? value;

  /// Told each new value as it changes.
  final ValueChanged<T>? onChanged;

  /// The radio items.
  final List<Widget> children;

  @override
  Widget build(BuildContext context) => _RadioScope<T>(
    value: value,
    onChanged: onChanged,
    child: MenuGroup(children: children),
  );
}

class _RadioScope<T> extends InheritedWidget {
  const _RadioScope({
    required this.value,
    required this.onChanged,
    required super.child,
  });

  final T? value;
  final ValueChanged<T>? onChanged;

  @override
  bool updateShouldNotify(_RadioScope<T> old) =>
      old.value != value || old.onChanged != onChanged;
}

/// One choice in a [MenuRadioGroup], marked with a dot when it is the one chosen.
class MenuRadioItem<T> extends StatelessWidget {
  const MenuRadioItem({
    super.key,
    required this.value,
    required this.child,
    this.enabled = true,
  });

  /// The value it stands for.
  final T value;

  /// Its label.
  final Widget child;

  /// False fades it and stops it taking presses and focus.
  final bool enabled;

  @override
  Widget build(BuildContext context) {
    final group = context.dependOnInheritedWidgetOfExactType<_RadioScope<T>>();
    final chosen = group?.value == value;
    return _MenuRow(
      onPressed: !enabled || group?.onChanged == null
          ? null
          : () => group!.onChanged!(value),
      checked: chosen,
      role: SemanticsRole.menuItemRadio,
      indicator: chosen
          ? Container(
              width: 8,
              height: 8,
              decoration: BoxDecoration(
                color: AshlarTheme.of(context).colors.popoverForeground,
                shape: BoxShape.circle,
              ),
            )
          : null,
      inset: true,
      child: child,
    );
  }
}

/// Items that belong together, read out as a group.
class MenuGroup extends StatelessWidget {
  const MenuGroup({super.key, required this.children});

  /// The group's rows.
  final List<Widget> children;

  @override
  Widget build(BuildContext context) => Semantics(
    container: true,
    explicitChildNodes: true,
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: children,
    ),
  );
}

/// A row that opens a further menu beside it: on hover, a click, Enter or the right arrow.
/// The left arrow and Escape close it again.
///
/// ```dart
/// MenuSub(child: Text('Share'), items: [MenuItem(onPressed: email, child: Text('Email')), ...])
/// ```
class MenuSub extends StatefulWidget {
  const MenuSub({
    super.key,
    required this.child,
    required this.items,
    this.leading,
    this.inset = false,
    this.enabled = true,
  });

  /// Its label.
  final Widget child;

  /// The submenu's rows.
  final List<Widget> items;

  /// Before the label: usually an icon.
  final Widget? leading;

  /// Lines its label up with items that have a mark before them.
  final bool inset;

  /// False fades it and stops it taking presses and focus.
  final bool enabled;

  @override
  State<MenuSub> createState() => _MenuSubState();
}

class _MenuSubState extends State<MenuSub> {
  final _menu = MenuController();
  final _focus = FocusNode();
  Timer? _hover;
  bool _open = false;

  @override
  void dispose() {
    _hover?.cancel();
    _focus.dispose();
    super.dispose();
  }

  void _show() {
    _hover?.cancel();
    if (!_menu.isOpen) _menu.open();
  }

  void _back() {
    _menu.close();
    _focus.requestFocus();
  }

  @override
  Widget build(BuildContext context) => RawMenuAnchor(
    controller: _menu,
    childFocusNode: _focus,
    onOpen: () => setState(() => _open = true),
    onClose: () => setState(() => _open = false),
    overlayBuilder: (context, info) => PopoverPanel(
      info: info,
      side: isRtl(context) ? PopoverSide.left : PopoverSide.right,
      align: PopoverAlign.start,
      gap: -4,
      child: CallbackShortcuts(
        bindings: {
          SingleActivator(
            isRtl(context)
                ? LogicalKeyboardKey.arrowRight
                : LogicalKeyboardKey.arrowLeft,
          ): _back,
        },
        child: MenuList(focusFirst: true, children: widget.items),
      ),
    ),
    child: _MenuRow(
      focusNode: _focus,
      onPressed: widget.enabled ? _show : null,
      closeOnPress: false,
      lit: _open,
      expanded: _open,
      leading: widget.leading,
      inset: widget.inset,
      role: SemanticsRole.menuItem,
      onHover: () {
        // A moment's rest opens it, so passing over on the way down does not.
        _hover?.cancel();
        _hover = Timer(
          const Duration(milliseconds: 120),
          () => mounted ? _show() : null,
        );
      },
      keys: {
        SingleActivator(
          isRtl(context)
              ? LogicalKeyboardKey.arrowLeft
              : LogicalKeyboardKey.arrowRight,
        ): _show,
      },
      trailing: AshlarIcon(
        isRtl(context)
            ? AshlarIconsRoundedStroke.chevronLeft
            : AshlarIconsRoundedStroke.chevronRight,
      ),
      child: widget.child,
    ),
  );
}

/// The row every kind of menu item draws: an indicator slot for checks and dots, an icon,
/// the label, a shortcut and a trailing icon; lit under the pointer or the keyboard.
class _MenuRow extends StatefulWidget {
  const _MenuRow({
    required this.child,
    required this.onPressed,
    required this.role,
    this.leading,
    this.trailing,
    this.shortcut,
    this.destructive = false,
    this.selected,
    this.checked,
    this.expanded,
    this.indicator,
    this.inset = false,
    this.autofocus = false,
    this.closeOnPress = true,
    this.lit = false,
    this.focusNode,
    this.onHover,
    this.keys = const {},
  });

  final Widget child;
  final VoidCallback? onPressed;
  final SemanticsRole role;
  final Widget? leading, trailing, shortcut, indicator;
  final bool destructive, inset, autofocus, closeOnPress, lit;
  final bool? selected, checked, expanded;
  final FocusNode? focusNode;
  final VoidCallback? onHover;
  final Map<ShortcutActivator, VoidCallback> keys;

  @override
  State<_MenuRow> createState() => _MenuRowState();
}

class _MenuRowState extends State<_MenuRow> {
  FocusNode? _own;
  FocusNode get _focus => widget.focusNode ?? (_own ??= FocusNode());

  @override
  void dispose() {
    _own?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final menu = MenuController.maybeOf(context);
    return MouseRegion(
      // The pointer and the keyboard share one highlight, as in a native menu; moving to
      // another row closes a submenu left open beside this menu.
      onEnter: (_) {
        if (widget.onPressed == null) return;
        _focus.requestFocus();
        if (widget.onHover != null) {
          widget.onHover!();
        } else {
          menu?.closeChildren();
        }
      },
      child: Focus(
        canRequestFocus: false,
        skipTraversal: true,
        onFocusChange: (_) => setState(() {}),
        child: CallbackShortcuts(
          bindings: widget.keys,
          child: Pressable(
            focusNode: _focus,
            autofocus: widget.autofocus,
            role: widget.role,
            selected: widget.selected,
            checked: widget.checked,
            expanded: widget.expanded,
            onPressed: widget.onPressed == null
                ? null
                : () {
                    if (widget.closeOnPress) {
                      (_MenuRoot.of(context) ?? menu)?.close();
                    }
                    widget.onPressed!();
                  },
            builder: (context, state) {
              final lit =
                  state.enabled &&
                  (widget.lit || state.hovered || _focus.hasFocus);
              final fg = widget.destructive
                  ? c.destructive
                  : (lit ? c.accentForeground : c.popoverForeground);
              final bg = lit
                  ? (widget.destructive
                        ? c.destructive.withValues(alpha: .1)
                        : c.accent)
                  : (widget.destructive ? c.destructive : c.accent).withValues(
                      alpha: 0,
                    );
              return Opacity(
                opacity: state.enabled ? 1 : .5,
                child: Container(
                  padding: EdgeInsetsDirectional.only(
                    start: widget.inset ? 32 : 8,
                    end: 8,
                    top: 6,
                    bottom: 6,
                  ),
                  decoration: BoxDecoration(
                    color: bg,
                    borderRadius: BorderRadius.circular(t.radiusSm),
                  ),
                  child: DefaultTextStyle.merge(
                    style: t.text.sm.copyWith(color: fg),
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    child: IconTheme.merge(
                      data: IconThemeData(
                        color: widget.destructive
                            ? c.destructive
                            : c.mutedForeground,
                        size: 16,
                      ),
                      child: Stack(
                        clipBehavior: Clip.none,
                        alignment: AlignmentDirectional.centerStart,
                        children: [
                          // Checks and dots sit in the inset, in the item's own colour.
                          if (widget.indicator != null)
                            PositionedDirectional(
                              start: -24,
                              child: SizedBox(
                                width: 16,
                                child: Center(
                                  child: IconTheme.merge(
                                    data: IconThemeData(color: fg),
                                    child: widget.indicator!,
                                  ),
                                ),
                              ),
                            ),
                          Row(
                            children: [
                              if (widget.leading != null) ...[
                                widget.leading!,
                                const SizedBox(width: 8),
                              ],
                              Expanded(child: widget.child),
                              if (widget.shortcut != null) ...[
                                const SizedBox(width: 16),
                                DefaultTextStyle.merge(
                                  style: t.text.xs.copyWith(
                                    color: c.mutedForeground,
                                    letterSpacing: 1.2,
                                  ),
                                  child: widget.shortcut!,
                                ),
                              ],
                              if (widget.trailing != null) ...[
                                const SizedBox(width: 8),
                                widget.trailing!,
                              ],
                            ],
                          ),
                        ],
                      ),
                    ),
                  ),
                ),
              );
            },
          ),
        ),
      ),
    );
  }
}

/// A heading over a group of items.
class MenuLabel extends StatelessWidget {
  const MenuLabel({super.key, required this.child, this.inset = false});

  /// The label's words.
  final Widget child;

  /// Set in, to line up with checkbox and radio items under it.
  final bool inset;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    return Padding(
      padding: EdgeInsetsDirectional.only(
        start: inset ? 32 : 8,
        end: 8,
        top: 6,
        bottom: 6,
      ),
      child: DefaultTextStyle.merge(
        style: t.text.sm.copyWith(
          color: t.colors.popoverForeground,
          fontWeight: FontWeight.w500,
        ),
        child: Semantics(header: true, child: child),
      ),
    );
  }
}

/// A line between groups of items.
class MenuSeparator extends StatelessWidget {
  const MenuSeparator({super.key});

  @override
  Widget build(BuildContext context) => Container(
    height: 1,
    margin: const EdgeInsets.symmetric(vertical: 4),
    transform: Matrix4.translationValues(0, 0, 0),
    color: AshlarTheme.of(context).colors.border,
  );
}