Skip to content

LayrzButton

Kenny Mochizuki Escalona edited this page Aug 20, 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 flutter_material_design_icons).
  ///
  /// 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.
  ///
  /// Defaults to [LayrzButtonStyle.elevated].
  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,
}

Six 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 .elevated .elevatedFab Solid background with drop shadow (compact ramp)
2 .outlined .outlinedFab Transparent background with accent border, no shadow
3 .outlinedTonal .outlinedTonalFab Semi-transparent background (tonal) with accent border, no shadow

FAB Rendering

FAB variants (suffixed with Fab):

  • 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)
  • Accessed via the asFab getter on styles: style.asFab maps a regular style to its Fab variant

Semantic Factories

Six named constructors provide pre-configured buttons for common actions. Each factory supplies an appropriate icon from flutter_material_design_icons, applies a semantic color type, and uses the style: parameter to choose the button's visual emphasis.

Factory Signatures and Behavior

// Example: .save() factory
factory LayrzButton.save({
  required String labelText,
  required VoidCallback onTap,
  bool isFab = false,
  LayrzButtonStyle style = LayrzButtonStyle.elevated,
  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; the factory automatically maps the given style to its Fab twin via the asFab getter
  • style (LayrzButtonStyle, default elevated) — the button's visual style. Only non-Fab values are accepted (asserted); the factory validates and maps to Fab if isFab: true. All six factories default to elevated.
  • 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 style Semantic Meaning
.save() contentSaveOutline success elevated Positive action confirming intent
.cancel() closeCircleOutline danger elevated Secondary action reverting state
.info() informationOutline info elevated Informational action
.show() eyeOutline info elevated Display or reveal action
.edit() pencilOutline warning elevated Modification action
.delete() trashCanOutline danger elevated Destructive action requiring intent

style Default Rationale

All six factories default to elevated, reflecting typical placement on plain surfaces where shadow depth helps buttons stand out. This uniform default simplifies the API — callers use the factories as-is for the most common case (plain surfaces).

For quieter button appearance, callers explicitly pass style: LayrzButtonStyle.outlined. This is particularly common for secondary actions like .cancel() or destructive actions like .delete(), where callers may want to de-emphasize the button in low-risk contexts. Pass style: LayrzButtonStyle.outlined when nesting buttons inside elevated containers (cards, dialogs) where shadow-depth emphasis would be redundant.


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


Text Scaling

Button labels now render at the system text scale factor. Previously, the button was measured at the user's text scale (adjusting its effective size) but painted unscaled (at 1.0x), causing labels to truncate early and leave visible dead space inside the button.

With proper text scaling, labels adapt to accessibility settings and browser zoom levels. The fixed kLayrzButtonHeight (45 logical pixels) accommodates the text up to a scale of approximately 3.04x before vertical clipping occurs — far beyond any reachable through OS settings (Android caps near 1.3x, iOS near 2.0x).


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
outlined / outlinedFab transparent + border tonal + border solid + border
outlinedTonal / outlinedTonalFab tonal + border tonal (stronger) + border solid + border
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
  • flutter_material_design_icons (^3.1.0) — icon constants (bare IconData, not wrapped)

Usage Examples

Basic Button

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

Floating Action Button (FAB)

LayrzButton(
  labelText: 'Add Item',
  icon: MdiIcons.plus,
  style: LayrzButtonStyle.elevatedFab,
  type: LayrzButtonType.success,
  onTap: () => _addItem(),
)

Semantic Factory

LayrzButton.save(
  labelText: 'Save',
  onTap: () => _save(),
  isFab: isCompactLayout,
  style: LayrzButtonStyle.elevated, // Default; omit for the same effect
)

Semantic Factory with Custom Style

LayrzButton.delete(
  labelText: 'Delete',
  onTap: () => _delete(),
  style: LayrzButtonStyle.elevated, // Elevate destructive button inside a card
)

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: MdiIcons.contentCopy,
  style: LayrzButtonStyle.outlined,
  hintText: 'Copy to clipboard',
  onTap: () => _copy(),
)

Constrained by Parent

SizedBox(
  width: 120,
  child: LayrzButton(
    labelText: 'Submit',
    style: LayrzButtonStyle.outlined,
    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