# 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 ```dart 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 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 ```dart typedef LayrzDropdownMenuBuilder = Widget Function( BuildContext context, MenuController controller, ); ``` The builder receives the menu controller and should wire its trigger widget to open/close logic: ```dart // 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: ```dart 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). ```dart 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 ```dart 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? 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` ```dart LayrzDropdownEntry.save( labelText: 'Save', onTap: () { /* ... */ }, ) ``` #### `.cancel()` Preset: icon `closeCircleOutline`, color `tokens.colors.danger` ```dart LayrzDropdownEntry.cancel( labelText: 'Cancel', onTap: () { /* ... */ }, ) ``` #### `.info()` Preset: icon `informationBoxOutline`, color `tokens.colors.info` ```dart LayrzDropdownEntry.info( labelText: 'Information', onTap: () { /* ... */ }, ) ``` #### `.show()` Preset: icon `eyeOutline`, color `tokens.colors.info` ```dart LayrzDropdownEntry.show( labelText: 'Show Details', onTap: () { /* ... */ }, ) ``` #### `.edit()` Preset: icon `pencilOutline`, color `tokens.colors.warning` ```dart LayrzDropdownEntry.edit( labelText: 'Edit', onTap: () { /* ... */ }, ) ``` #### `.delete()` Preset: icon `trashCanOutline`, color `tokens.colors.danger` ```dart LayrzDropdownEntry.delete( labelText: 'Delete', onTap: () { /* ... */ }, ) ``` All factories allow icon and color to be overridden if needed: ```dart LayrzDropdownEntry.save( labelText: 'Export', icon: MdiIcons.download, // override onTap: () { /* ... */ }, ) ``` --- ## LayrzDropdownLabel A non-interactive section heading in a dropdown menu. ### Constructor ```dart 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 tonal treatment used elsewhere in the design /// system (see [LayrzColorTokens.tonalOpacity]). 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`): ```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 ```dart 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 ```dart 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 ```dart 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 ```dart 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 - [LayrzButton](LayrzButton) — Button component suitable for menu triggers - [Input Contract](Input-Contract) — for input-focused selection components