-
Notifications
You must be signed in to change notification settings - Fork 0
LayrzDropdownMenu
A Material-free floating dropdown menu with a sealed item hierarchy, builder-based trigger wiring, and full keyboard accessibility.
Metadata
Mirrors: None (new component for layrz_ui)
Phase: M2 (Core primitives)
Domain: Menus and Navigation
Primitive: RawMenuAnchor + FadeTransition + CustomSingleChildLayout + WidgetStatesController
Status: Confirmed scope.
LayrzDropdownMenu displays a floating menu panel on demand, anchored to a caller-supplied trigger widget. The trigger is built with access to the menu controller, allowing it to wire its own tap handlers directly to controller.open(), controller.close(), or toggle logic based on controller.isOpen. Menu entries close the menu automatically after tapping.
Key principle: The trigger is a builder, not a child parameter. This design prevents the menu from accidentally wrapping its trigger and losing gestures to the gesture arena. The trigger wires itself.
-
Builder-based trigger: The trigger widget is built with access to the
MenuController, allowing direct wiring of open/close logic without gesture interception. -
Sealed item hierarchy: Only two concrete types are allowed:
LayrzDropdownEntry(interactive) andLayrzDropdownLabel(section heading). Custom widget types are impossible by construction. -
Auto-close on selection: Menu entries invoke their
onTapcallback and close automatically — no manual close is required. - Full keyboard support: Escape dismisses, arrow keys traverse focusable entries (labels and disabled entries are skipped), and outside taps close the menu.
-
Minimal animation: Enter animation is fade + 4px translate. Exit is synchronous (no animation) —
RawMenuAnchorremoves the overlay immediately. - Flexible alignment: Panel alignment relative to the trigger (start, center, end) with automatic repositioning if space is insufficient.
class LayrzDropdownMenu extends StatefulWidget {
/// Builds the trigger widget that opens/closes the menu.
///
/// The builder receives the menu [controller], which should be wired to the
/// trigger's own event handlers. For example:
/// ```dart
/// builder: (context, controller) => LayrzButton(
/// labelText: 'Actions',
/// onTap: controller.isOpen ? controller.close : controller.open,
/// )
/// ```
/// The controller also exposes [isOpen] for toggle behavior. This pattern ensures
/// the trigger is never wrapped by the menu, preventing gesture arena conflicts.
final LayrzDropdownMenuBuilder builder;
/// The items to display in the dropdown menu.
///
/// A list of [LayrzDropdownItem] subclasses: [LayrzDropdownEntry] and
/// [LayrzDropdownLabel]. Only these two types are allowed (sealed class guarantee).
final List<LayrzDropdownItem> items;
/// Optional controller for programmatic control of the menu's open/close state.
///
/// When null, the menu is owned by the [LayrzDropdownMenu] and has no
/// external control. When non-null, callers can open or close the menu by
/// calling [controller.open()] and [controller.close()].
///
/// **Important:** The controller instance must never be swapped via [didUpdateWidget].
/// An assertion will fail if a different controller instance is passed on a rebuild.
/// [MenuController] holds no disposable resources and is safe to share across
/// multiple menu instances.
final MenuController? controller;
/// Called when the menu is opened.
///
/// Guaranteed to fire before the overlay is shown and the fade-in animation starts.
final VoidCallback? onOpen;
/// Called when the menu is closed.
///
/// Fires after the menu is removed from the overlay.
final VoidCallback? onClose;
/// Optional focus node passed to the trigger widget for keyboard interaction.
///
/// When the menu is closed, focus returns to this node. The caller must ensure
/// this node outlives the menu widget.
final FocusNode? childFocusNode;
/// The horizontal alignment of the menu panel relative to the trigger.
///
/// Defaults to [LayrzDropdownMenuAlignment.start]. The panel is positioned according
/// to this alignment and then clamped into the overlay bounds.
final LayrzDropdownMenuAlignment alignment;
/// Creates a new [LayrzDropdownMenu].
///
/// [builder] and [items] are required. All other parameters are optional.
const LayrzDropdownMenu({
required this.builder,
required this.items,
this.controller,
this.onOpen,
this.onClose,
this.childFocusNode,
this.alignment = LayrzDropdownMenuAlignment.start,
super.key,
});
}typedef LayrzDropdownMenuBuilder = Widget Function(
BuildContext context,
MenuController controller,
);The builder receives the menu controller and should wire its trigger widget to open/close logic:
// Simple toggle
builder: (context, controller) => LayrzButton(
labelText: 'Actions',
onTap: controller.isOpen ? controller.close : controller.open,
)
// Always open on tap
builder: (context, controller) => LayrzButton(
labelText: 'Actions',
onTap: controller.open,
)Controls the horizontal positioning of the menu panel relative to the trigger:
enum LayrzDropdownMenuAlignment {
/// Align the menu's left edge with the trigger's left edge.
start,
/// Center the menu horizontally under the trigger.
center,
/// Align the menu's right edge with the trigger's right edge.
end,
}Default: start
The panel is positioned according to this alignment and then clamped into the overlay bounds. If the panel would overflow off-screen, it is repositioned to fit.
Base class for menu items. Only two concrete subtypes are allowed: LayrzDropdownEntry (interactive) and LayrzDropdownLabel (non-interactive section heading).
sealed class LayrzDropdownItem extends StatelessWidget {
/// Whether this item can receive focus and be activated via keyboard or mouse.
///
/// - [LayrzDropdownEntry]: true when enabled
/// - [LayrzDropdownLabel]: false (non-focusable)
bool get isFocusable;
}An interactive entry in a dropdown menu.
final class LayrzDropdownEntry extends LayrzDropdownItem {
/// The text displayed on the entry.
final String labelText;
/// Called when the entry is tapped.
///
/// Must be non-null. The dropdown menu closes automatically after this callback
/// is invoked, so there is no need to manage menu state in the callback.
final VoidCallback onTap;
/// Optional icon displayed before the label (from layrz_icons).
final IconData? icon;
/// Whether this entry is interactive and accepts input.
///
/// When false, the entry is visually greyed and does not respond to taps,
/// focus, or keyboard input. Defaults to true.
final bool enabled;
/// Optional colour that paints the leading dot of this entry.
///
/// When null, no dot is rendered. When non-null, a small circular dot is displayed
/// at the left edge of the entry using this exact color. The dot is independent
/// from the icon and appears alongside or in place of it.
///
/// This is a paint-only property; it does not affect backgrounds, text, or other
/// entry styling.
///
/// This is useful for destructive entries that pass tokens.colors.danger, or
/// for actions that echo the color of a UI element (e.g., `LayrzButtonGroup`
/// overflow menu entries mirror the button's own semantic color).
///
/// **Breaking change as of 0.0.8**: Previously typed as `LayrzColorSwatch?` with
/// dot color derived from `shade500`. Now a plain `Color?`. This is source-compatible
/// for callers passing token swatches (a `Color` can hold a swatch's base value),
/// and visually identical since token swatches are constructed with `shade500` as
/// their primary value.
final Color? color;
/// Optional keyboard shortcut keys displayed right-aligned in the entry.
///
/// A set of [LogicalKeyboardKey] values (typically modifiers like
/// [LogicalKeyboardKey.control] and a key like [LogicalKeyboardKey.keyS]).
/// The set is formatted using [formatLayrzShortcut] for display with
/// platform-native glyphs (⌘ on macOS, Ctrl elsewhere; ⌃ on macOS, Ctrl elsewhere).
///
/// This is display-only and never binds any keys. The application owns all
/// keyboard binding. When [LayrzPlatform.isMobile] is true, the shortcut is
/// hidden entirely (no reserved space).
///
/// Example:
/// ```dart
/// shortcut: {LogicalKeyboardKey.control, LogicalKeyboardKey.keyS}
/// // Renders as "Ctrl+S" or "⌘+S" depending on platform
/// ```
final Set<LogicalKeyboardKey>? shortcut;
/// Creates a new [LayrzDropdownEntry].
const LayrzDropdownEntry({
required this.labelText,
required this.onTap,
this.icon,
this.enabled = true,
this.color,
this.shortcut,
super.key,
});
@override
bool get isFocusable => enabled;
}Interaction states (hovered, pressed, focused) change color and background only — geometry is fixed per decision D15:
| State | Background | Label Color | Icon Color |
|---|---|---|---|
| Resting | surface |
fg1 |
fg1 |
| Hovered | surface2 |
fg1 |
fg1 |
| Focused | surface2 |
fg1 |
fg1 |
| Pressed | surface3 |
fg1 |
fg1 |
| Disabled | surface |
fg3 |
fg3 |
Six convenience factory constructors preset the icon and semantic colour to match common action semantics. Each factory takes the same parameters as the main constructor (with icon and color optional for override), plus enabled, shortcut, and key.
Preset: icon contentSaveOutline, color tokens.colors.success
LayrzDropdownEntry.save(
labelText: 'Save',
onTap: () { /* ... */ },
)Preset: icon closeCircleOutline, color tokens.colors.danger
LayrzDropdownEntry.cancel(
labelText: 'Cancel',
onTap: () { /* ... */ },
)Preset: icon informationBoxOutline, color tokens.colors.info
LayrzDropdownEntry.info(
labelText: 'Information',
onTap: () { /* ... */ },
)Preset: icon eyeOutline, color tokens.colors.info
LayrzDropdownEntry.show(
labelText: 'Show Details',
onTap: () { /* ... */ },
)Preset: icon pencilOutline, color tokens.colors.warning
LayrzDropdownEntry.edit(
labelText: 'Edit',
onTap: () { /* ... */ },
)Preset: icon trashCanOutline, color tokens.colors.danger
LayrzDropdownEntry.delete(
labelText: 'Delete',
onTap: () { /* ... */ },
)All factories allow icon and color to be overridden if needed:
LayrzDropdownEntry.save(
labelText: 'Export',
icon: MdiIcons.download, // override
onTap: () { /* ... */ },
)A non-interactive section heading in a dropdown menu.
final class LayrzDropdownLabel extends LayrzDropdownItem {
/// The text displayed as the label.
///
/// Casing is determined by the caller — the widget does not uppercase or
/// transform text.
final String labelText;
/// Optional colour used to tint the label's band.
///
/// When null, the band keeps the neutral [LayrzColorTokens.surface3] fill, so
/// menus written before this parameter existed are unchanged. When set, the band
/// is filled with this colour at [LayrzColorTokens.tonalOpacity], flattened over
/// the panel surface — the same treatment as [LayrzChipStyle.filledTonal].
final Color? color;
/// Creates a new [LayrzDropdownLabel].
const LayrzDropdownLabel({
required this.labelText,
this.color,
super.key,
});
@override
bool get isFocusable => false;
}Labels render as a full-width section band with a surface3 background (or tinted with an optional accent color). Text uses tokens.typography.body style in the subdued foreground color (fg3). Labels are non-focusable and are skipped during keyboard traversal.
The MenuController class is part of the Flutter SDK (package:flutter/widgets.dart):
class MenuController {
/// Opens the menu.
void open();
/// Closes the menu.
void close();
/// Whether the menu is currently open.
bool get isOpen;
}Access the controller in the widget tree using MenuController.maybeOf(context). The menu automatically wires this up so that entry taps can close the menu without explicit controller access.
- Escape key: Dismisses the menu and returns focus to the trigger
- Arrow keys (Up / Down): Traverse focusable entries. Labels and disabled entries are skipped.
- Outside taps: Close the menu
- Entries expose button semantics with enabled/disabled state, making them accessible to screen readers
- Enter animation: Fade + 4px translate from the anchor's horizontal edge
-
Exit animation: None — overlay removal is synchronous and owned by
RawMenuAnchor
- Menu width: Clamped to [160, 320] logical pixels
- Entry height: Fixed at 40 logical pixels
- Panel position: Below the trigger by default; flips above if insufficient space below
- Overlay padding: 8 logical pixels on all sides (clamped within screen bounds)
LayrzDropdownMenu(
builder: (context, controller) => LayrzButton(
labelText: 'Actions',
onTap: controller.isOpen ? controller.close : controller.open,
),
items: [
LayrzDropdownEntry(
labelText: 'Edit',
icon: MdiIcons.pencilOutline,
onTap: () => _editItem(),
),
LayrzDropdownEntry(
labelText: 'Delete',
icon: MdiIcons.trashCanOutline,
color: context.tokens.colors.danger,
onTap: () => _deleteItem(),
),
],
)LayrzDropdownMenu(
builder: (context, controller) => LayrzButton.show(
labelText: 'View Options',
onTap: controller.open,
),
items: [
LayrzDropdownLabel(labelText: 'Display'),
LayrzDropdownEntry(
labelText: 'Compact View',
onTap: () => _setViewMode(ViewMode.compact),
icon: MdiIcons.formatColumns,
),
LayrzDropdownEntry(
labelText: 'Detailed View',
onTap: () => _setViewMode(ViewMode.detailed),
icon: MdiIcons.formatColumns,
),
LayrzDropdownLabel(labelText: 'Sort'),
LayrzDropdownEntry(
labelText: 'By Name',
onTap: () => _sortBy(SortKey.name),
),
LayrzDropdownEntry(
labelText: 'By Date',
onTap: () => _sortBy(SortKey.date),
),
],
)LayrzDropdownMenu(
builder: (context, controller) => LayrzButton(
labelText: 'More',
onTap: controller.isOpen ? controller.close : controller.open,
),
items: [
LayrzDropdownEntry(
labelText: 'Save',
icon: MdiIcons.floppy,
onTap: () => _save(),
shortcut: {LogicalKeyboardKey.control, LogicalKeyboardKey.keyS},
),
LayrzDropdownEntry(
labelText: 'Duplicate',
icon: MdiIcons.contentCopy,
onTap: () => _duplicate(),
shortcut: {LogicalKeyboardKey.control, LogicalKeyboardKey.keyD},
),
],
)final controller = MenuController();
LayrzDropdownMenu(
controller: controller,
builder: (context, controller) => LayrzButton(
labelText: 'Open/Close',
onTap: controller.isOpen ? controller.close : controller.open,
),
items: [
LayrzDropdownEntry(
labelText: 'Option 1',
onTap: () => _handleOption1(),
),
],
)
// Later, open the menu programmatically
controller.open();-
Trigger design: The trigger is a builder that wires itself. This prevents the menu from wrapping the trigger and losing gesture recognition. The pattern
onTap: controller.isOpen ? controller.close : controller.openprovides toggle behavior. -
Color dots and accents: The optional
colorparameter onLayrzDropdownEntrypaints a small dot at the entry's left edge using the exact color provided. This is useful for echoing action colors in overflow menus (e.g.,LayrzButtonGroupoverflow). The dot is independent from theicon— an entry may have a dot, an icon, both, or neither. -
Label tinting: The optional
colorparameter onLayrzDropdownLabeltints the label band's background. When null, the band uses the neutralsurface3fill, preserving backward compatibility with menus created before this parameter existed. - Shortcut display: Shortcuts are never bound by layrz_ui; the application owns all keyboard binding. Shortcut rendering uses platform-native glyphs and is hidden entirely on mobile platforms (iOS/Android), saving space.
-
No exit animation:
RawMenuAnchortears down the overlay synchronously when the menu closes. An enter-only animation (fade + translate) is lighter and avoids flicker. -
Controller lifecycle:
MenuControllerholds no disposable resources. A single controller instance can be shared across multiple menu instances, enabling synchronized open/close behavior.
- LayrzButton — Button component suitable for menu triggers
- Input Contract — for input-focused selection components
Made with ❤️ by Golden M, Inc.
- LayrzTextInput
- LayrzSelectableAction
- LayrzSelectionToolbar
- LayrzTextSelectionControls
- LayrzSelectionMagnifier
- LayrzSelectionHandlePainter
- LayrzTextAreaInput
- LayrzComboBoxInput
- LayrzNumberInput
- LayrzPasswordInput
- LayrzCheckboxInput
- LayrzRadioInput
- LayrzSelectInput
- LayrzMultiSelectInput
- LayrzSearchInput
- LayrzDualListInput
- LayrzDurationInput