Skip to content

LayrzButton

Kenny Mochizuki Escalona edited this page Aug 16, 2026 · 7 revisions

LayrzButton

A Material-free button component supporting twelve style variants, semantic color types, optional loading and cooldown states via controller, and six semantic factories.

Metadata
Mirrors: ThemedButton (layrz_theme)
Phase: M2 (Core primitives)
Domain: Buttons
Primitive: Hand-rolled (RawTooltip + FocusableActionDetector + MouseRegion + GestureDetector + AnimatedContainer)
Status: Confirmed scope.


Overview

LayrzButton is the sole button primitive in layrz_ui. It is not an input component and does not participate in the input contract defined in Input Contract. It renders an interactive, semantically meaningful button with text, icon, or both.

Design Principles

  • Simple label only: labelText (String) only. A label Widget parameter is explicitly not supported.
  • Icon as first-class parameter: Required for FAB variants; optional for regular button variants.
  • Twelve style variants in six pairs: each style has a standard and FAB variant.
  • Stateful affordances: loading indicator and cooldown timer, both managed by an optional LayrzButtonController.
  • Semantic factories: six pre-configured buttons for common actions (save, cancel, info, show, edit, delete).
  • Fixed sizing: height, width, icon size, spacing, and font size are standardised and not caller-configurable.

API Structure

Core Constructor

class LayrzButton extends StatefulWidget {
  /// The human-readable label displayed on the button.
  ///
  /// This is the only label representation; a Widget label parameter is not supported.
  final String labelText;

  /// Icon displayed on the button (from layrz_icons 2.0.0).
  ///
  /// Type is `IconData` (bare constant, not wrapped).
  /// For FAB variants, this icon is the only visible element.
  /// For regular variants, the icon appears to the left of the text.
  /// Optional for all styles.
  final IconData? icon;

  /// Callback invoked when the button is tapped.
  ///
  /// When null, the button is disabled (unresponsive to taps).
  /// Also respects the [isDisabled] flag — either signals disable.
  final VoidCallback? onTap;

  /// Whether the button is disabled.
  ///
  /// When true, the button does not respond to taps, regardless of [onTap].
  /// Either [onTap] being null OR [isDisabled] being true disables the button.
  final bool isDisabled;

  /// An optional controller that drives the busy state (loading or cooldown).
  ///
  /// When non-null, the controller manages all busy-state rendering and interaction:
  /// - Loading indicator from [LayrzButtonController.isLoading]
  /// - Cooldown countdown from [LayrzButtonController.cooldownTotal]
  ///
  /// Multiple buttons can share a single controller instance, enabling lockstep
  /// busy-state management across a form or action group. All buttons subscribed
  /// to the same controller move in perfect sync, preventing frame-by-frame drift.
  ///
  /// When null, the button has no loading or cooldown states and behaves normally.
  ///
  /// Disposal is caller-owned. If a button's controller is disposed before the
  /// button unmounts, the button remains functional and will not throw or `setState`
  /// after unmount.
  final LayrzButtonController? controller;

  /// The semantic type of the button — determines which token color to use.
  ///
  /// When [type] is [LayrzButtonType.custom], the [color] parameter is honoured.
  /// For any other type, [color] must be null (enforced by assertion).
  final LayrzButtonType type;

  /// The accent color for the button.
  ///
  /// Only applied when [type] is [LayrzButtonType.custom].
  /// Defaults to primary brand color if both [type] is custom and [color] is null.
  /// Must be null for any other [type].
  final Color? color;

  /// The visual style of the button.
  final LayrzButtonStyle style;

  /// Text for a tooltip hint.
  ///
  /// For Fab variants, this is appended to the [labelText] in the tooltip.
  /// For non-Fab variants, supplying this value enables a tooltip on hover/long-press.
  /// Fab buttons always show a tooltip composed of [labelText], or
  /// [labelText] + [hintText] if both are present.
  final String? hintText;

  /// Creates a new [LayrzButton] with the given properties.
  ///
  /// The button accent color is determined by [type]:
  /// - For [LayrzButtonType.custom], uses the explicit [color] parameter (or primary if null)
  /// - For any other type, resolves to the corresponding semantic token color
  ///
  /// The [color] parameter is only used when [type] is [LayrzButtonType.custom];
  /// passing a color with any other type triggers an assertion error.
  ///
  /// Tooltip behavior is determined by [style] and [hintText]:
  /// - **Fab buttons** always show a tooltip (labelText, or labelText + hintText if hint is provided)
  /// - **Non-Fab buttons** show a tooltip only when [hintText] is non-null
  ///
  /// Button sizing is fixed and not caller-configurable:
  /// - Height: [kLayrzButtonHeight] (45 logical pixels)
  /// - Icon size: [kLayrzButtonIconSize] (22 logical pixels)
  /// - Icon separator: [kLayrzButtonIconSeparator] (8 logical pixels)
  /// - Font size: [kLayrzButtonFontSize] (14 logical pixels)
  ///
  /// Buttons can be constrained by their parent (e.g. `SizedBox(width: 120)`)
  /// and will clamp to that constraint, but the intrinsic sizing is standardised.
  ///
  /// Use the semantic factories (`.save()`, `.cancel()`, etc.) for convenience.
  const LayrzButton({
    super.key,
    required this.labelText,
    this.icon,
    this.onTap,
    this.isDisabled = false,
    this.controller,
    this.type = LayrzButtonType.custom,
    this.color,
    this.style = LayrzButtonStyle.elevated,
    this.hintText,
  });

LayrzButtonType

The semantic type enumeration that determines the button's accent color. Each type resolves to a specific token color:

enum LayrzButtonType {
  /// Success color — [LayrzTokens.colors.success] (green)
  success,

  /// Informational color — [LayrzTokens.colors.info] (blue)
  info,

  /// Contextual color — [LayrzTokens.colors.contextual]
  context,

  /// Danger color — [LayrzTokens.colors.danger] (red)
  danger,

  /// Warning color — [LayrzTokens.colors.warning] (orange)
  warning,

  /// Custom color — use an explicit [color] parameter
  ///
  /// The [color] parameter is only honoured when [type] is [LayrzButtonType.custom].
  /// Defaults to [LayrzTokens.colors.primary] if [color] is null.
  custom,
}

Twelve Style Variants

All styles render in both regular (rectangular) and Fab (square, icon-only) forms. The fill ladder model ensures consistent interaction behavior across all styles:

Pair Regular Style FAB Style Appearance
1 .filled .filledFab Solid background, no shadow
2 .elevated .elevatedFab Solid background with drop shadow (compact ramp)
3 .filledTonal .filledTonalFab Semi-transparent background (tonal opacity), no shadow
4 .outlined .outlinedFab Transparent background with accent border, no shadow
5 .outlinedTonal .outlinedTonalFab Semi-transparent background (tonal) with accent border, no shadow
6 .text .fab Fully transparent, content in accent color; hover adds subtle tonal fill

FAB Rendering

FAB variants (suffixed with Fab or the .fab style):

  • Render icon-only; labelText supplies the tooltip and accessible name
  • Always square, rendered at kLayrzButtonHeight × kLayrzButtonHeight (45 × 45 logical pixels)
  • Display a tooltip via RawTooltip showing labelText, or labelText\nhintText if both are present
  • Used for floating action buttons and icon-only toolbar buttons
  • Require an Overlay ancestor (provided by LayrzApp via WidgetsApp)

Semantic Factories

Six named constructors provide pre-configured buttons for common actions. Each factory supplies an appropriate icon from layrz_icons (2.0.0), applies a semantic color type, and uses the isElevated parameter to choose between elevated and flat styles.

Factory Signatures and Behavior

// Example: .save() factory
factory LayrzButton.save({
  required String labelText,
  required VoidCallback onTap,
  bool isFab = false,
  bool isElevated = true,
  bool isDisabled = false,
  LayrzButtonController? controller,
  String? hintText,
  Key? key,
}) → LayrzButton

All factories share the same parameter contract:

  • labelText (String, required) — the button label
  • onTap (VoidCallback, required) — callback when tapped
  • isFab (bool, default false) — when true, renders the icon-only square FAB variant
  • isElevated (bool, defaults per factory) — when true, uses the elevated/outlined style; when false, uses the filled/outlined style
  • isDisabled (bool, default false) — disables the button
  • controller (LayrzButtonController?, optional) — optional busy-state controller
  • hintText (String?, optional) — tooltip hint text (Fab buttons only)
  • key (Key?, optional) — widget key

Factory Details

Factory Icon Type Default isElevated Style When isElevated: true Style When isElevated: false
.save() solarOutlineInboxIn success true elevated / elevatedFab filled / filledFab
.cancel() solarOutlineCloseSquare danger false elevated / elevatedFab filled / filledFab
.info() solarOutlineInfoSquare info true elevated / elevatedFab filled / filledFab
.show() solarOutlineEyeScan info true elevated / elevatedFab filled / filledFab
.edit() solarOutlinePenNewSquare warning true elevated / elevatedFab filled / filledFab
.delete() solarOutlineTrashBinMinimalisticN2 danger false elevated / elevatedFab filled / filledFab

isElevated Default Rationale

Four factories (.save, .info, .show, .edit) default to isElevated: true, reflecting typical placement on plain surfaces where shadow depth helps the button stand out.

Two factories (.cancel and .delete) default to isElevated: false, deliberately keeping destructive/cancellative actions visually quiet by default. Developers explicitly opt into shadow depth with isElevated: true when nesting these buttons inside elevated containers (cards, dialogs) where they would otherwise vanish against the background.


LayrzButtonController

A controller that drives the busy state (loading or cooldown) of one or more LayrzButton widgets. Multiple buttons can share a single controller instance, enabling lockstep busy-state management across a form or action group.

Key Concept: Shared Controller

When multiple buttons in a view share one controller, they all move in perfect sync:

// Single controller drives all buttons in a form
final controller = LayrzButtonController();

LayrzButton.save(
  labelText: 'Save',
  onTap: _save,
  controller: controller,
)

LayrzButton.cancel(
  labelText: 'Cancel',
  onTap: _cancel,
  controller: controller,
)

void _save() async {
  controller.startLoading();
  await _performSave();
  controller.stopLoading();
}

API

State Getters

  • isLoading (bool) — whether the button is in a loading state
  • cooldownTotal (Duration?) — the total cooldown duration if active, null otherwise
  • cooldownRemaining (Duration) — remaining time until cooldown expires (clamped ≥ 0)
  • cooldownProgress (double) — progress as a fraction [0.0, 1.0] where 1.0 is fully elapsed
  • isBusy (bool) — whether the button is in any busy state (loading, cooldown, or held by anti-flash floor)

Control Methods

/// Starts the loading indicator.
void startLoading();

/// Stops the loading indicator and initiates the anti-flash floor.
void stopLoading();

/// Starts a cooldown with the given [duration].
///
/// - Zero or negative duration is a no-op.
/// - If a cooldown is already running with the same duration, this is a no-op (idempotence).
/// - If the duration is different, the cooldown restarts.
void startCooldown(Duration duration);

/// Clears an active cooldown early, before it expires.
///
/// Unlike natural cooldown expiry, an explicit clear does NOT apply the anti-flash floor.
/// The button immediately becomes tappable.
void clearCooldown();

/// Resets all busy states to inactive.
void reset();

Lifecycle

  • Disposal is caller-owned. If a button's controller is disposed before the button unmounts, the button remains functional and will not throw or setState after unmount.
  • Buttons attach and detach listeners dynamically when the controller changes (didUpdateWidget).
  • All timers are cancelled on dispose(), preventing callbacks from firing after disposal.

Anti-Flash Floor

When a busy state (loading or cooldown) ends, the controller automatically holds the disabled/busy appearance for a minimum duration (kLayrzButtonMinBusyDuration, 100ms). This prevents rapid state changes from quick server responses from strobing the button and confusing the user.

  • A busy state visible < 100ms is held visible for the full 100ms floor
  • The button stays disabled for the entire held window, so a tap cannot land mid-fade
  • This is a deliberate visual/tactile tradeoff: the indicator briefly outlives the real busy state for calm, responsive feedback

Auto-Clear on Expiry

When a cooldown duration elapses, the controller automatically clears it and notifies listeners. The button does not clear the controller itself; only the application can clear via clearCooldown() or reset().


Four-State Interaction Model

The button implements four mutually exclusive interaction states with a shared fill ladder:

State Meaning Visual Treatment
Default Idle, no interaction Base rung of fill ladder
Hovered / Focused Pointer over or keyboard focus One step up the ladder
Pressed Pointer/finger held down Top of the ladder
Disabled Non-interactive (disabled, loading, cooldown, or anti-flash held) Greyed, all shadows cleared

Keyboard focus renders as the hover state (WCAG 2.4.7 Focus Visible, AA).

Fill Ladder Principle

Each style starts on a different rung and climbs the same ladder as interaction increases: transparent → tonal → solid. This unified approach ensures visual consistency across all styles and prevents geometry changes during state transitions (per decision D15):

Style Default Hovered / Focused Pressed
text / fab transparent tonal (light) tonal (stronger)
outlined / outlinedFab transparent + border tonal + border solid + border
outlinedTonal / outlinedTonalFab tonal + border tonal (stronger) + border solid + border
filledTonal / filledTonalFab tonal tonal (stronger) solid
filled / filledFab solid solid (lightened) solid (more lightened)
elevated / elevatedFab solid + shadow solid + larger shadow solid (no shadow)

Invariants

  • Border color is identical across all states for outlined styles
  • Border width is constant across all states
  • Geometry (height, width, padding) is byte-identical across all states — only color, shadow, and opacity change
  • Cursor is SystemMouseCursors.click (pointer) when enabled, SystemMouseCursors.basic (not-allowed) when disabled

Material-Free Construction

LayrzButton is built entirely without Material or Cupertino imports, using only package:flutter/widgets.dart primitives:

  1. Semantics — accessibility annotations
  2. RawTooltip (Flutter 3.47, package:flutter/widgets.dart) — for hintText tooltips
  3. FocusableActionDetector — focus handling and keyboard navigation
  4. MouseRegion — cursor and hover detection
  5. GestureDetector — tap and gesture handling
  6. AnimatedContainer — smooth state transitions (colour, shadow, opacity, border)

RawTooltip Caveat

The button uses RawTooltip to display tooltips. RawTooltip requires an Overlay ancestor, which is provided automatically by LayrzApp (via WidgetsApp). If LayrzButton is used outside LayrzApp's widget tree, tooltips will fail at runtime. This is temporary until LayrzTooltip lands.


Dependencies

  • M1 Theme System (LayrzTheme, LayrzThemeData, LayrzTokens) — colors, text styles, and motion tokens
  • M1 State resolution (WidgetState, WidgetStatesController) — for interaction state handling
  • Flutter 3.47+RawTooltip and @Preview API
  • layrz_icons (^2.0.0) — icon constants (bare IconData, not wrapped)

Usage Examples

Basic Button

LayrzButton(
  labelText: 'Save',
  style: LayrzButtonStyle.filledTonal,
  type: LayrzButtonType.custom,
  color: Colors.blue,
  onTap: () => _save(),
)

Floating Action Button (FAB)

LayrzButton(
  labelText: 'Add Item',
  icon: LayrzIcons.solarOutlinePlus,
  style: LayrzButtonStyle.filledFab,
  type: LayrzButtonType.success,
  onTap: () => _addItem(),
)

Semantic Factory

LayrzButton.save(
  labelText: 'Save',
  onTap: () => _save(),
  isFab: isCompactLayout,
)

Multiple Buttons with Shared Controller

final controller = LayrzButtonController();

Column(
  children: [
    LayrzButton.save(
      labelText: 'Save',
      onTap: () => _save(),
      controller: controller,
    ),
    LayrzButton.cancel(
      labelText: 'Cancel',
      onTap: () => _cancel(),
      controller: controller,
    ),
  ],
)

void _save() async {
  controller.startLoading();
  try {
    await _performSaveAsync();
  } finally {
    controller.stopLoading();
  }
}

With Cooldown

final controller = LayrzButtonController();

LayrzButton(
  labelText: 'Send',
  style: LayrzButtonStyle.elevated,
  type: LayrzButtonType.info,
  controller: controller,
  onTap: () => _send(),
)

void _send() async {
  controller.startCooldown(Duration(seconds: 10));
  // Cooldown auto-clears and notifies when duration elapses
}

With Tooltip

LayrzButton(
  labelText: 'Copy',
  icon: LayrzIcons.solarOutlineCopy,
  style: LayrzButtonStyle.outlined,
  hintText: 'Copy to clipboard',
  onTap: () => _copy(),
)

Constrained by Parent

SizedBox(
  width: 120,
  child: LayrzButton(
    labelText: 'Submit',
    style: LayrzButtonStyle.filled,
    onTap: () => _submit(),
  ),
)

Sizing Constants

Button sizing is fixed and defined by the following constants (from lib/constants/src/button.dart):

  • Height: kLayrzButtonHeight = 45 logical pixels
  • Icon size: kLayrzButtonIconSize = 22 logical pixels
  • Icon separator: kLayrzButtonIconSeparator = 8 logical pixels (gap between icon and label)
  • Font size: kLayrzButtonFontSize = 14 logical pixels
  • Horizontal padding: kLayrzButtonHorizontalPadding = 16 logical pixels (left + right of content)
  • Border width: tokens.border.base (inherited from token system)
  • Indicator height: kLayrzButtonIndicatorHeight = 3 logical pixels (loading/cooldown bar)
  • Minimum busy duration: kLayrzButtonMinBusyDuration = 100ms (anti-flash floor)

Last updated: 2026-08-15
Related documents: Milestone 2, Design Tokens, Architecture, Decisions (D15), Roadmap

Clone this wiki locally