Skip to content

LayrzButtonGroup

Kenny Mochizuki Escalona edited this page Aug 18, 2026 · 6 revisions

LayrzButtonGroup

A responsive button group that renders actions as either a row of buttons or a single trigger opening a dropdown menu, depending on viewport width.

Metadata
Mirrors: ThemedActionsButtons (layrz_theme)
Phase: M2 (Core primitives)
Domain: Buttons
Primitive: Wrap + LayrzDropdownMenu (responsive collapse)
Status: Confirmed scope.


Overview

LayrzButtonGroup manages a collection of actions rendered as a row of LayrzButton instances. On narrow viewports (below the md breakpoint, 960px), it automatically collapses into a single trigger button that opens a dropdown menu listing the actions.

Key principle: Mode selection is automatic by default (null), collapsing to dropdown at the md breakpoint. The mode can be explicitly forced to always-row or always-dropdown via the useDropdown parameter. Actions preserve their labels, icons, semantic types, and disabled states across both render modes.

Design Principles

  • Responsive collapse: Automatically switches between row and dropdown modes at viewport breakpoints.
  • Nullable mode switch: A single boolean parameter (useDropdown) gates the behavior; nullability is the switch rather than an enum, preventing invalid combinations.
  • Button-to-entry conversion: LayrzButton instances are directly rendered in row mode and converted to LayrzDropdownEntry instances in dropdown mode, preserving semantic meaning.
  • Semantic color mapping: Buttons with semantic types (success, danger, info, etc.) map to their token colors in the dropdown; custom buttons without an explicit color render with no dot.
  • Auto-close on selection: Dropdown entries close the menu automatically after tapping.
  • Stable trigger naming: The trigger requires an explicit accessible name (the triggerHintText parameter), not derived from its contents. Platform overflow menus identify the control, not enumerate its actions.

API Structure

Default Constructor

class LayrzButtonGroup extends StatelessWidget {
  /// The actions rendered by this group, in order.
  ///
  /// Must be a list of [LayrzButton] instances. The buttons are rendered directly
  /// in row mode and converted to dropdown entries in dropdown mode.
  /// An empty list renders nothing in both modes.
  final List<LayrzButton> actions;

  /// Forces the render mode. When null, the mode follows the responsive breakpoint,
  /// collapsing to the dropdown below `md`.
  ///
  /// - `true`: always render dropdown mode
  /// - `false`: always render row mode
  /// - `null` (default): switch automatically at the md breakpoint
  final bool? useDropdown;

  /// Gap between buttons in row mode. Defaults to `tokens.spacing.base`.
  ///
  /// Only applies in row mode. Unused in dropdown mode.
  final double? spacing;

  /// Icon shown on the collapsed trigger. Defaults to the overflow-dots icon.
  ///
  /// Only applies in dropdown mode.
  final IconData? triggerIcon;

  /// Accessible name and tooltip for the collapsed trigger button.
  ///
  /// Shown as the trigger's tooltip on hover and announced by screen readers.
  /// Platform overflow menus render a stable control name rather than enumerating
  /// their contents, so this is required unconditionally — even though [useDropdown]
  /// defaults to null and the group may collapse at any viewport width, the caller
  /// always knows the semantic name to assign.
  final String triggerHintText;

  /// Horizontal alignment of the dropdown panel against the trigger.
  ///
  /// Defaults to [LayrzDropdownMenuAlignment.start].
  /// Only applies in dropdown mode.
  final LayrzDropdownMenuAlignment alignment;

  /// Creates a new [LayrzButtonGroup].
  ///
  /// The [actions] and [triggerHintText] parameters are required. All others
  /// are optional with sensible defaults.
  ///
  /// The default constructor renders a fixed `LayrzButtonStyle.elevatedFab` trigger
  /// in dropdown mode. To customize the trigger's style and behavior, use
  /// [LayrzButtonGroup.builder] instead.
  const LayrzButtonGroup({
    required this.actions,
    required this.triggerHintText,
    this.useDropdown,
    this.spacing,
    this.triggerIcon,
    this.alignment = LayrzDropdownMenuAlignment.start,
    super.key,
  });

  /// Creates a [LayrzButtonGroup] with a caller-supplied trigger widget.
  ///
  /// The [builder] receives the menu [MenuController] and must wire it to the
  /// trigger's own tap handler for toggle behavior. This allows full control over
  /// the trigger's styling and behavior — use a different button style, icon, text,
  /// or any custom widget entirely.
  ///
  /// **Critical: gesture-arena warning.** The trigger widget wins the gesture arena.
  /// Do NOT wrap it in a `GestureDetector`, because `LayrzButton` keeps a non-null
  /// `onTapCancel` even when disabled, which wins the arena and silently prevents
  /// the menu from opening. Wire the controller's methods directly to the trigger's
  /// own tap handler:
  ///
  /// ```dart
  /// LayrzButtonGroup.builder(
  ///   actions: [...],
  ///   builder: (context, controller) => MyCustomButton(
  ///     onTap: controller.isOpen ? controller.close : controller.open,
  ///   ),
  /// )
  /// ```
  ///
  /// **Wrong** (menu will never open):
  /// ```dart
  /// LayrzButtonGroup.builder(
  ///   actions: [...],
  ///   builder: (context, controller) => GestureDetector(
  ///     onTap: controller.isOpen ? controller.close : controller.open,
  ///     child: MyCustomButton(),  // Wrong: gesture lost to GestureDetector parent
  ///   ),
  /// )
  /// ```
  ///
  /// In row mode, the builder is never called — the group renders its actions as
  /// a `Wrap` directly. The builder is only consulted when the group is in dropdown
  /// mode (determined by the md breakpoint or the `useDropdown` parameter).
  ///
  /// This constructor is mutually exclusive with the default constructor: do not
  /// pass `triggerIcon` or `triggerHintText` here.
  const LayrzButtonGroup.builder({
    required this.actions,
    required this.builder,
    this.useDropdown,
    this.spacing,
    this.alignment = LayrzDropdownMenuAlignment.start,
    super.key,
  })  : triggerHintText = null,
        triggerIcon = null;
}

Constructor Selection

Choose the default constructor when:

  • You want a simple, built-in trigger using LayrzButtonStyle.elevatedFab
  • The default icon (overflow dots) or a custom icon from triggerIcon is sufficient
  • You do not need to customize the trigger's styling

Choose .builder when:

  • You need a different button style (outlined, ghost, etc.)
  • You want a custom label, icon, or entirely different widget as the trigger
  • You want full control over the trigger's appearance and behavior

Responsive Behavior

The component uses the md breakpoint (viewport width ≥ 960px) to decide between modes:

  • Row mode (at or above md): renders all actions as a horizontal Wrap with optional spacing.
  • Dropdown mode (below md): renders a single FAB trigger opening a menu with the actions converted to entries.

The useDropdown parameter allows explicit override:

  • null (default): automatic based on breakpoint
  • true: always dropdown
  • false: always row

Button-to-Entry Conversion

When rendering in dropdown mode, each LayrzButton is converted to a LayrzDropdownEntry by:

  1. Label: Button's labelText becomes the entry's labelText.
  2. Icon: Button's icon is preserved.
  3. Color dot: Resolved via LayrzButtonType.semanticColor(LayrzTokens):
    • Semantic types (success, danger, info, etc.) map to their token color
    • Custom buttons with an explicit color use that color
    • Custom buttons without a color render with no dot — the dot stays a signal rather than decoration
  4. Enabled state: Button's enabled state is preserved; entries mirror this.
  5. Tap handler: Button's onTap callback is invoked; the menu closes automatically after.

Trigger and Tooltip

In dropdown mode, the trigger is a FAB button (circular, icon-only) opening the menu. The triggerHintText is required and serves as:

  • Tooltip on hover: Shown to users who hover over the trigger.
  • Accessible label: Announced by screen readers to users with assistive technology.
  • Control name: Identifies the trigger as an overflow menu, not as a list of its contents.

Platform overflow menus do not enumerate their actions in their affordance labels; they name the control instead (e.g., "More options", "Actions", "Table menu"). The required triggerHintText parameter ensures this semantic clarity.

  • Default trigger icon: LayrzIcons.solarOutlineMenuDots (overflow dots), overridable via triggerIcon.

Implementation Note

The trigger wires its own gesture via LayrzDropdownMenu's builder: (context, controller) pattern. A menu that wrapped its trigger would silently never open, because LayrzButton keeps a non-null onTapCancel even when disabled, and wins the gesture arena. The builder pattern is essential.


Interaction States

Buttons in row mode exhibit their standard interaction states (hover, press, focus). Entries in dropdown mode exhibit menu entry states (hover, press, focus, disabled).


Examples

Row of Action Buttons

LayrzButtonGroup(
  triggerHintText: 'Table actions',
  actions: [
    LayrzButton.save(
      labelText: 'Save',
      onTap: () => _save(),
    ),
    LayrzButton.cancel(
      labelText: 'Cancel',
      onTap: () => _cancel(),
    ),
    LayrzButton.delete(
      labelText: 'Delete',
      onTap: () => _delete(),
    ),
  ],
)

Automatic Responsive Collapse

LayrzButtonGroup(
  triggerHintText: 'Row actions',
  actions: [
    LayrzButton(
      labelText: 'Edit',
      icon: LayrzIcons.solarPenNewSquare,
      type: LayrzButtonType.warning,
      style: LayrzButtonStyle.elevatedFab,
      onTap: () => _edit(),
    ),
    LayrzButton(
      labelText: 'Delete',
      icon: LayrzIcons.solarTrash,
      type: LayrzButtonType.danger,
      style: LayrzButtonStyle.elevatedFab,
      onTap: () => _delete(),
    ),
  ],
  // useDropdown: null (default) — switches at md breakpoint
)

Force Dropdown Mode

LayrzButtonGroup(
  triggerHintText: 'Export formats',
  useDropdown: true,
  actions: [
    LayrzButton(
      labelText: 'Export as PDF',
      icon: LayrzIcons.solarDocumentDownload,
      type: LayrzButtonType.info,
      onTap: () => _exportPDF(),
    ),
    LayrzButton(
      labelText: 'Export as CSV',
      icon: LayrzIcons.solarDocumentDownload,
      type: LayrzButtonType.info,
      onTap: () => _exportCSV(),
    ),
  ],
)

Force Row Mode

LayrzButtonGroup(
  triggerHintText: 'Row actions',
  useDropdown: false,
  spacing: 16,
  actions: [
    LayrzButton.info(labelText: 'Learn', onTap: () => _learn()),
    LayrzButton.show(labelText: 'Preview', onTap: () => _preview()),
  ],
)

Custom Trigger with Icon

LayrzButtonGroup(
  triggerHintText: 'Data management',
  actions: [
    LayrzButton(labelText: 'New', icon: LayrzIcons.solarPlusBold, onTap: () => _new()),
    LayrzButton(labelText: 'Import', icon: LayrzIcons.solarImportBold, onTap: () => _import()),
    LayrzButton(labelText: 'Upload', icon: LayrzIcons.solarUploadBold, onTap: () => _upload()),
  ],
  triggerIcon: LayrzIcons.solarDatabaseAddBold,
)

Empty Actions List

LayrzButtonGroup(
  triggerHintText: 'Actions',
  actions: [],
  // Renders nothing
)

Custom Trigger with .builder (Outlined Style)

LayrzButtonGroup.builder(
  actions: [
    LayrzButton(labelText: 'Export PDF', icon: LayrzIcons.solarDocumentDownload, onTap: () => _exportPDF()),
    LayrzButton(labelText: 'Export CSV', icon: LayrzIcons.solarDocumentDownload, onTap: () => _exportCSV()),
    LayrzButton(labelText: 'Print', icon: LayrzIcons.solarPrinter, onTap: () => _print()),
  ],
  useDropdown: true,
  builder: (context, controller) => LayrzButton(
    labelText: 'Export',
    icon: LayrzIcons.solarOutlineSettings,
    style: LayrzButtonStyle.outlinedFab,
    onTap: controller.isOpen ? controller.close : controller.open,
  ),
)

Custom Trigger with .builder (Fully Custom Widget)

LayrzButtonGroup.builder(
  actions: [...],
  builder: (context, controller) => Container(
    decoration: BoxDecoration(
      color: context.tokens.colors.primary,
      borderRadius: BorderRadius.circular(8),
    ),
    child: InkWell(
      onTap: controller.isOpen ? controller.close : controller.open,
      child: Padding(
        padding: EdgeInsets.all(context.tokens.spacing.sp8),
        child: Icon(LayrzIcons.solarSettings),
      ),
    ),
  ),
)

Keyboard and Accessibility

  • Row mode: Each button is independently accessible via tab and keyboard activation.
  • Dropdown mode: The trigger is a FAB button; the menu provides full keyboard support (Escape to close, arrow keys to traverse entries).

Notes

  • Mode nullability: The nullable useDropdown parameter is a deliberate design choice. Using a boolean switch rather than an enum (always, responsive, never) prevents invalid combinations and makes the common case (automatic, null) require no argument.
  • No free spacing in dropdown mode: Spacing only applies in row mode. Dropdown layout is fixed by the menu component.
  • Trigger styling in default constructor: The default constructor uses a fixed LayrzButtonStyle.elevatedFab trigger. To customize the trigger's style, use .builder.
  • Builder and row mode: The builder is only called when the group is in dropdown mode. In row mode, the builder is ignored and the actions render as a Wrap directly.
  • Gesture arena in builder: The trigger widget supplied to .builder must own the gesture. Do NOT wrap it in a GestureDetector, because LayrzButton keeps a non-null onTapCancel even when disabled, which wins the gesture arena and prevents the menu from opening. Wire the controller's methods directly to the trigger.
  • Empty actions handling: An empty action list renders SizedBox.shrink() in both modes, taking up no space.
  • Semantic colors in dropdown: Button semantic types are resolved to token colors via LayrzButtonType.semanticColor(LayrzTokens). This ensures dropdown entries visually echo their button counterparts.
  • Constructor exclusivity: The default constructor and .builder are mutually exclusive — no parameter can express an invalid combination. Pass triggerIcon or triggerHintText only to the default constructor; pass builder only to .builder.

See Also

Clone this wiki locally