-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
- 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:
LayrzButtoninstances are directly rendered in row mode and converted toLayrzDropdownEntryinstances 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
triggerHintTextparameter), not derived from its contents. Platform overflow menus identify the control, not enumerate its actions.
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;
}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
triggerIconis 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
The component uses the md breakpoint (viewport width ≥ 960px) to decide between modes:
-
Row mode (at or above
md): renders allactionsas a horizontalWrapwith 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
When rendering in dropdown mode, each LayrzButton is converted to a LayrzDropdownEntry by:
-
Label: Button's
labelTextbecomes the entry'slabelText. -
Icon: Button's
iconis preserved. -
Color dot: Resolved via
LayrzButtonType.semanticColor(LayrzTokens):- Semantic types (success, danger, info, etc.) map to their token color
- Custom buttons with an explicit
coloruse that color - Custom buttons without a
colorrender with no dot — the dot stays a signal rather than decoration
-
Enabled state: Button's
enabledstate is preserved; entries mirror this. -
Tap handler: Button's
onTapcallback is invoked; the menu closes automatically after.
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 viatriggerIcon.
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.
Buttons in row mode exhibit their standard interaction states (hover, press, focus). Entries in dropdown mode exhibit menu entry states (hover, press, focus, disabled).
LayrzButtonGroup(
triggerHintText: 'Table actions',
actions: [
LayrzButton.save(
labelText: 'Save',
onTap: () => _save(),
),
LayrzButton.cancel(
labelText: 'Cancel',
onTap: () => _cancel(),
),
LayrzButton.delete(
labelText: 'Delete',
onTap: () => _delete(),
),
],
)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
)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(),
),
],
)LayrzButtonGroup(
triggerHintText: 'Row actions',
useDropdown: false,
spacing: 16,
actions: [
LayrzButton.info(labelText: 'Learn', onTap: () => _learn()),
LayrzButton.show(labelText: 'Preview', onTap: () => _preview()),
],
)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,
)LayrzButtonGroup(
triggerHintText: 'Actions',
actions: [],
// Renders nothing
)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,
),
)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),
),
),
),
)- 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).
-
Mode nullability: The nullable
useDropdownparameter 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.elevatedFabtrigger. 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
Wrapdirectly. -
Gesture arena in builder: The trigger widget supplied to
.buildermust own the gesture. Do NOT wrap it in aGestureDetector, becauseLayrzButtonkeeps a non-nullonTapCanceleven 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
.builderare mutually exclusive — no parameter can express an invalid combination. PasstriggerIconortriggerHintTextonly to the default constructor; passbuilderonly to.builder.
- LayrzButton — Individual button component
- LayrzDropdownMenu — Dropdown menu component underlying collapse mode
- LayrzChipGroup — Analogous grouping component for chips
Made with ❤️ by Golden M, Inc.
- LayrzAnchoredPanel
- LayrzBottomSheet
- LayrzDialog
- LayrzDropdownMenu
- LayrzResponsiveModal
- LayrzPageTransition
- LayrzTextInput
- LayrzSelectableAction
- LayrzSelectionToolbar
- LayrzTextSelectionControls
- LayrzSelectionMagnifier
- LayrzSelectionHandlePainter
- LayrzTextAreaInput
- LayrzComboBoxInput
- LayrzNumberInput
- LayrzPasswordInput
- LayrzCheckboxInput
- LayrzRadioInput
- LayrzSelectInput
- LayrzMultiSelectInput
- LayrzSearchInput
- LayrzDualListInput
- LayrzDurationInput
- LayrzSlider
- LayrzStepper