base_menu 0.1.4
base_menu: ^0.1.4 copied to clipboard
Composable widgets for building menu systems.
Base Menu #
Composable widgets for building menu systems in Flutter.
Live Gallery · Floogle Docs Demo · API Reference

Features #
- Completely headless: no pixels are painted by this library
- Keyboard support that follows the WAI-ARIA Menu and Menubar Pattern
- Menu aim assist (safe triangles)
- A robust positioning algorithm with support for custom layout delegates
- Built on the MenuController API
Architecture #
Base Menu provides a small set of primitive widgets that control keyboard traversal, focus routing, and menu positioning. These widgets can be composed to create a wide variety of menu systems, from simple context menus to complex multi-level menu bars.
Core Components #
| Component | Description | Visual |
|---|---|---|
BaseMenu |
A single-layer menu overlay or context trigger. | ![]() |
BaseSubmenu |
A BaseMenu specialized for nested menus. Coordinates cross-axis keyboard navigation and hover traversal. Uses a BaseMenuItem as its anchor. |
![]() |
BaseMenuBar |
An inline grouping layer that coordinates keyboard and focus routing for a set of menu buttons. | ![]() |
BaseMenuItem |
A primary control that adds hover-to-focus and close-on-activate actions to a BaseControl. |
![]() |
BaseMenuPanel |
A layout container for a set of menu items. | ![]() |
Getting started #
Add the package to your pubspec.yaml and import it into your project:
import 'package:base_menu/base_menu.dart';
Customization #
Controls #
Add custom styling to menu items by using state selectors in a BaseControl or
BaseMenuItem.
// Implement styling for a menu item using the state selectors from BaseMenuItem.
class StyledLabel extends StatelessWidget {
const StyledLabel({super.key, required this.child});
final Widget child;
static const WidgetStateProperty<BoxDecoration> decoration = WidgetStateProperty.fromMap({
WidgetState.pressed: BoxDecoration(color: Color(0xFF2B4678)),
WidgetState.focused: BoxDecoration(color: Color(0xFF445E91)),
WidgetState.any: BoxDecoration(color: Color(0x00000000)),
});
static const WidgetStateProperty<TextStyle> textStyle = WidgetStateProperty.fromMap({
WidgetState.disabled: TextStyle(color: Color(0xFF74777F)),
WidgetState.pressed: TextStyle(color: Color(0xFFFFFFFF)),
WidgetState.focused: TextStyle(color: Color(0xFFFFFFFF)),
WidgetState.any: TextStyle(color: Color(0xFF1A1B20)),
});
@override
Widget build(BuildContext context) {
final states = BaseMenuItem.statesOf(context);
return DecoratedBox(
decoration: decoration.resolve(states),
child: DefaultTextStyle.merge(
style: textStyle.resolve(states),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Align(alignment: AlignmentDirectional.centerStart, child: child),
),
),
);
}
}
// Usage:
class CustomMenuItem extends StatelessWidget {
const CustomMenuItem({super.key, required this.child, this.onPressed});
final Widget child;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return BaseMenuItem(
onPressed: onPressed,
child: StyledLabel(child: child),
);
}
}
Panel #
Wrap a BaseMenuPanel in a DecoratedBox to apply a background color, border,
or shadow:
class StyledMenuPanel extends StatelessWidget {
const StyledMenuPanel({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
color: Color(0xffffffff),
borderRadius: BorderRadius.all(Radius.circular(4)),
boxShadow: [
BoxShadow(color: Color(0x18000000), blurRadius: 8, offset: Offset(0, 4)),
BoxShadow(color: Color(0x0D000000), blurRadius: 4, offset: Offset(0, 2)),
],
),
child: child,
);
}
}
// Usage:
BaseMenu(
menu: StyledMenuPanel(
child: BaseMenuPanel(
children: [
const MenuItem(label: 'Undo'),
const MenuItem(label: 'Redo'),
],
),
),
// ...
);
Positioning #
Use DefaultMenuPositioningDelegate to configure the menu's position relative
to its anchor:
BaseMenu(
positionDelegate: DefaultMenuPositioningDelegate(
// Attach the bottom-center of the anchor to the top-center of the menu
anchorAttachment: Alignment.bottomCenter,
menuAttachment: Alignment.topCenter,
// Add a 4-pixel vertical gap between the anchor and the menu
offset: Offset(0, 4),
// Match the vertical padding of the menu panel (6 pixels).
padding: EdgeInsets.symmetric(vertical: 6.0),
),
// ...
)

Aim assist #
| Disabled | Enabled |
|---|---|
![]() |
![]() |
To enable aim assist for a single menu, set the enableAimAssist property of
the menu's positioning delegate to true.
To control the behavior of aim assist for a subtree, wrap the subtree with
MenuAimScope and set the enable property to true. All descendant menus
will inherit the enable value unless a descendant menu's positioning delegate
overrides it.
// Enable aim assist for a subtree of menus.
MenuAimScope(
enable: true,
child: Column(
children: [
// Aim assist is enabled for this menu and its descendants.
BaseSubmenu(
controller: controllerOne,
menu: ExamplePanel(),
child: Text('File'),
),
BaseSubmenu(
controller: controllerTwo,
// Aim assist is disabled for this menu, but not its descendants.
positionDelegate: DefaultMenuPositioningDelegate(
enableAimAssist: false,
),
menu: ExamplePanel(),
child: Text('Edit'),
),
],
),
)
Contributing #
Contributions are welcome! Please open an issue or submit a pull request on GitHub. I'm especially interested in feedback on accessibility, API ergonomics, and edge cases in keyboard traversal and menu positioning logic.






