Skip to content

LayrzDropdownMenu

Kenny Mochizuki Escalona edited this page Aug 20, 2026 · 4 revisions

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.


Overview

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.

Design Principles

  • 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) and LayrzDropdownLabel (section heading). Custom widget types are impossible by construction.
  • Auto-close on selection: Menu entries invoke their onTap callback 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) — RawMenuAnchor removes the overlay immediately.
  • Flexible alignment: Panel alignment relative to the trigger (start, center, end) with automatic repositioning if space is insufficient.

API Structure

Core Constructor

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

Builder Signature

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

LayrzDropdownMenuAlignment

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.


LayrzDropdownItem (Sealed)

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

LayrzDropdownEntry

An interactive entry in a dropdown menu.

Constructor

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

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

Semantic Factories

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.

.save()

Preset: icon contentSaveOutline, color tokens.colors.success

LayrzDropdownEntry.save(
  labelText: 'Save',
  onTap: () { /* ... */ },
)

.cancel()

Preset: icon closeCircleOutline, color tokens.colors.danger

LayrzDropdownEntry.cancel(
  labelText: 'Cancel',
  onTap: () { /* ... */ },
)

.info()

Preset: icon informationBoxOutline, color tokens.colors.info

LayrzDropdownEntry.info(
  labelText: 'Information',
  onTap: () { /* ... */ },
)

.show()

Preset: icon eyeOutline, color tokens.colors.info

LayrzDropdownEntry.show(
  labelText: 'Show Details',
  onTap: () { /* ... */ },
)

.edit()

Preset: icon pencilOutline, color tokens.colors.warning

LayrzDropdownEntry.edit(
  labelText: 'Edit',
  onTap: () { /* ... */ },
)

.delete()

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: () { /* ... */ },
)

LayrzDropdownLabel

A non-interactive section heading in a dropdown menu.

Constructor

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.


Menu Controller

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.


Keyboard and Accessibility

  • 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

Animation

  • Enter animation: Fade + 4px translate from the anchor's horizontal edge
  • Exit animation: None — overlay removal is synchronous and owned by RawMenuAnchor

Sizing and Layout

  • 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)

Examples

Basic Menu with Actions

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

Menu with Labels and Grouped Items

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

Menu with Keyboard Shortcuts

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

Programmatic Control

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

Notes

  • 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.open provides toggle behavior.
  • Color dots and accents: The optional color parameter on LayrzDropdownEntry paints 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., LayrzButtonGroup overflow). The dot is independent from the icon — an entry may have a dot, an icon, both, or neither.
  • Label tinting: The optional color parameter on LayrzDropdownLabel tints the label band's background. When null, the band uses the neutral surface3 fill, 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: RawMenuAnchor tears down the overlay synchronously when the menu closes. An enter-only animation (fade + translate) is lighter and avoids flicker.
  • Controller lifecycle: MenuController holds no disposable resources. A single controller instance can be shared across multiple menu instances, enabling synchronized open/close behavior.

See Also

Clone this wiki locally