Skip to content

Input Contract

Kenny Mochizuki Escalona edited this page Aug 27, 2026 · 13 revisions

Input Contract

This document defines the shared contract that every Layrz*Input component in layrz_ui conforms to. Rather than specifying this per-component, the contract is recorded here as the single source of truth for input field behavior, styling, and API, so the architectural decisions need to be made only once.


Central Architectural Fact: No Input Wraps Another Input

Every Layrz*Input composes its own (editing primitive + LayrzInputChrome) pair directly. No input obtains its chrome by wrapping a sibling input. This is decided in D63: No Input Wraps Another Input — Every Field Composes LayrzInputChrome Directly (2026-08-23), which reverses an earlier "LayrzTextInput as base of the family" pattern this document used to describe here.

D63 was decided because the wrapping pattern had already produced three shipped defects: LayrzNumberInput accepted errors and never forwarded them on its hideStepButtons=true path; LayrzSearchInput could not show an error at all; and LayrzNumberInput duplicated the chrome's border in an outer Container because wrapping left it no way to place its step buttons at the sides. All three followed from the same root cause — wrapping creates coupling that nothing in the codebase declares, so a change to the wrapped input's chrome has an undeclared blast radius, and properties get dropped silently rather than failing a build. LayrzSearchInput, LayrzComboBoxInput, and LayrzNumberInput's hideStepButtons=true path were the three inputs that wrapped, and all three have since migrated to compose LayrzInputChrome directly.

The picker-style inputs (date, time, color, emoji, etc.) render a read-only editing primitive through their own LayrzInputChrome and open their selection surface on tap — not a wrapped LayrzTextInput.

Exclusion — controls, not fields. LayrzCheckboxInput, LayrzSwitchInput, LayrzRadioInput, and LayrzSlider (in progress) are excluded from this rule entirely: they are controls with a label, not bordered fields, and own their visual state deliberately rather than going through LayrzInputChrome. D63 records this exclusion explicitly, "to be found by whoever later reads 'no input wraps another input' as universal." Do not read the rule above as applying to these four.

Consequences

  • Every input owns its own chrome composition. Each Layrz*Input pairs its editing primitive with LayrzInputChrome itself; none inherits chrome by wrapping another Layrz*Input.
  • Visual decisions still propagate through the shared chrome, not through inheritance. The chrome — label, prefix/suffix, help affordance, padding, error display, focus state, border decoration — is LayrzInputChrome (lib/src/inputs/src/shared/input_chrome.dart), a single shared component every chrome-owning input composes independently. Design decisions made for the chrome still propagate to every input that uses it, but no input is a de facto base class for another.
  • No separate input-container beyond the chrome itself. There is no wrapper component; LayrzInputChrome plays that role for every input that composes it directly.

Naming Convention: *Input Replaces *Picker

Every form field in layrz_ui is named Layrz*Input, regardless of how it collects its value. The *Picker suffix from layrz_theme is retired entirely.

Renamed Components

layrz_theme Name layrz_ui Name Collection Method
ThemedDatePicker LayrzDateInput Calendar dialog
ThemedTimePicker LayrzTimeInput Time picker dialog
ThemedDateTimePicker LayrzDateTimeInput Calendar + time in tabbed dialog
ThemedMonthPicker LayrzMonthInput Month + year grid
ThemedEmojiPicker LayrzEmojiInput Emoji selection dialog
ThemedColorPicker LayrzColorInput Color wheel/palette dialog
ThemedFilePicker LayrzFileInput System file browser
ThemedIconPicker LayrzIconInput Icon selection dialog
ThemedAvatarPicker LayrzAvatarInput System image picker

Rationale

The *Picker suffix suggests a distinct interaction pattern from form fields. In layrz_ui, all of these components are inputs that participate in forms like any text field or select field. They are not separate pickers; they are inputs with selection surfaces. The unified naming clarifies this conceptual unity.


The Shared Contract

Every Layrz*Input exposes the following API, drawn from the shared LayrzInputChrome each input composes directly (per D63, above). The sketches below use LayrzTextInput as the illustrative example — it is one consumer of the shared chrome, not the source every other input wraps.

Field Identification and Labels

// Design sketch — illustrative only
class LayrzTextInput extends StatefulWidget {
  /// The label text displayed above the input field (optional if hintText is provided).
  /// 
  /// This is the only label representation; a `label` Widget parameter is not supported.
  /// At least one of [labelText] or [hintText] must be non-null.
  final String? labelText;

  /// Hint text displayed inside the empty field (optional if labelText is provided).
  /// 
  /// Shown inside the field, disappears when the field has text.
  /// At least one of [labelText] or [hintText] must be non-null.
  final String? hintText;

  // ...
}
  • At least one of labelText or hintText is mandatory — the widget asserts labelText != null || hintText != null in debug mode
  • Both can be non-null — a field can have both a label and a hint
  • No label Widget parameter; labels are text-only

Prefix and Suffix

Each input supports both a leading prefix and a trailing suffix. At most one of the three forms (icon, widget, or text) may be supplied per slot; providing multiple triggers a debug assertion.

// Design sketch — illustrative only
class LayrzTextInput extends StatefulWidget {
  /// Icon displayed in the prefix slot.
  /// 
  /// Mutually exclusive with [prefix] and [prefixText].
  final IconData? prefixIcon;

  /// Custom widget displayed in the prefix slot.
  /// 
  /// Mutually exclusive with [prefixIcon] and [prefixText].
  final Widget? prefix;

  /// Text displayed in the prefix slot.
  /// 
  /// Mutually exclusive with [prefixIcon] and [prefix].
  final String? prefixText;

  /// Callback invoked when the user taps the prefix.
  final VoidCallback? onPrefixTap;

  /// Icon displayed in the suffix slot.
  /// 
  /// Mutually exclusive with [suffix] and [suffixText].
  final IconData? suffixIcon;

  /// Custom widget displayed in the suffix slot.
  /// 
  /// Mutually exclusive with [suffixIcon] and [suffixText].
  final Widget? suffix;

  /// Text displayed in the suffix slot.
  /// 
  /// Mutually exclusive with [suffixIcon] and [suffix].
  final String? suffixText;

  /// Callback invoked when the user taps the suffix.
  final VoidCallback? onSuffixTap;

  // ...
}
  • Prefix slot: At most one of prefixIcon, prefix, or prefixText may be non-null; supplying multiple triggers a debug assertion.
  • Suffix slot: At most one of suffixIcon, suffix, or suffixText may be non-null; supplying multiple triggers a debug assertion.
  • Error icons coexist: The error icon is independent and always appears in the suffix when errors is non-empty
  • Read-only lock icon coexists: In read-only mode, a lock icon also appears in the suffix alongside caller-supplied suffix widgets

Semantics

A prefix or suffix slot always renders — the accessibility gap is in what it announces, and the rules differ by form:

  • A non-tappable icon slot with no name is decorative and is deliberately hidden from screen readers (ExcludeSemantics). There is nothing for it to announce, so it is removed from the tree rather than left as noise.

  • Text slots merge into the field's accessible name. prefixText: '$' on a field labelled 'Amount' announces as one name, not two. This is intended: '$', 'kg', '@' and similar unit/currency markers belong in the field's name, not in a separate node.

  • onPrefixTap:/onSuffixTap: currently create a pointer-only affordance. The icon or text is rendered and fully tappable, but it is not announced to a screen reader and it is never a keyboard focus stop. This is deliberate, not an oversight: the chrome constructs the icon or text itself, has no name for what tapping it does, and will not invent or infer one from the icon you chose — an anonymous action announced on the field's own node would be worse than no announcement at all, because it would misdirect the person using it.

  • What to do instead, as a contract rather than an omission: pass a real, focusable, labelled widget through prefix:/suffix: instead of the prefixIcon:/prefixText: + onPrefixTap: callback forms. A widget you supply is passed through untouched — the chrome does not exclude or reinterpret it — so any Semantics you attach to it survives exactly as you built it. The icon/text + callback forms have no such seam: there is nothing in them for you to attach a label to.

  • Any Semantics you attach inside that widget must set container: true. Semantics fragments from the chrome's descendants merge into the field's own single node by default, so a Semantics without container: true does not get a node of its own — its role, label, and tap action merge onto the text field's node instead. The field then announces as your control and fires your callback when activated, which is the exact defect container: true exists to prevent (see the chrome's own named-slot boundary in input_chrome.dart):

    // Wrong — merges onto the field's own semantics node
    prefix: Semantics(
      button: true,
      label: 'Copy',
      onTap: _copy,
      child: const Icon(LayrzIcons.solarOutlineCopy),
    ),
    
    // Right — container: true keeps it a node of its own
    prefix: Semantics(
      container: true,
      button: true,
      label: 'Copy',
      onTap: _copy,
      child: const Icon(LayrzIcons.solarOutlineCopy),
    ),
  • The gap is known and scheduled, not overlooked. Public prefixSemanticLabel:/suffixSemanticLabel: parameters are planned for the chrome-composing inputs, deferred so the M4 Pickers milestone can inform their final shape. Until they ship, a tappable icon/text slot built through the callback forms has no way to be named from outside this package. See decisions.md, D64 for the full mechanism and rationale.

Help Affordance

An informational tooltip can be paired with the label, accessed via hover on desktop or long-press on mobile.

// Design sketch — illustrative only
class LayrzTextInput extends StatefulWidget {
  /// Title text displayed in the help tooltip.
  /// 
  /// If supplied, a help icon appears and opens a tooltip on hover/long-press.
  final String? helpTitleText;

  /// Body text displayed in the help tooltip.
  /// 
  /// Shown below [helpTitleText] in the tooltip popup.
  final String? helpContentText;

  // ...
}
  • helpTitleText and helpContentText form a two-part help message.
  • Rendered as a tooltip component (wrapping the SDK's RawTooltip).

Validation and Error Display

Errors are caller-owned. There is no built-in validator callback. Callers provide error messages directly as a list.

// Design sketch — illustrative only
class LayrzTextInput extends StatefulWidget {
  /// List of error messages to display below the field.
  /// 
  /// Caller-owned; no automatic validation.
  /// Multiple errors are joined with ", " (comma-space) into a single line.
  /// When non-empty, an error icon appears in the field and the joined message
  /// is rendered below the field in danger colour.
  final List<String> errors;

  /// Whether to hide the error message text below the field.
  /// 
  /// When true, error messages are not rendered (though the field's error visual state remains).
  final bool hideDetails;

  // ...
}
  • errors is a List<String>, not a validator callback. Callers manage validation and provide error messages.
  • Errors are joined with ", " into a single line (e.g., ['required', 'too short'] renders as 'required, too short')
  • Error visual state on field: When errors is non-empty, the field border becomes danger colour, the background becomes danger.shade50, and an error icon is displayed in the suffix.
  • hideDetails: When true, error messages below the field are hidden (but the field's error visual state remains).

Interactivity

// Design sketch — illustrative only
class LayrzTextInput extends StatefulWidget {
  /// Callback invoked when the input value changes.
  final ValueChanged<String>? onChanged;

  /// Callback invoked when the user submits the input (e.g., via Enter on desktop).
  final ValueChanged<String>? onSubmit;

  /// Callback invoked when the input gains or loses focus.
  final ValueChanged<bool>? onFocusChanged;

  /// Callback invoked when the user taps anywhere on the input field.
  /// 
  /// Distinct from [onPrefixTap] and [onSuffixTap]. Useful for picker-style inputs
  /// to open their selection surface. Fires even in read-only mode, but not in disabled mode.
  final VoidCallback? onTap;

  /// Whether the field is disabled (not editable, not tappable).
  /// 
  /// When true, keyboard input is blocked, the field does not respond to taps,
  /// and no callbacks fire. Text is darkened to fg4 and border becomes transparent.
  final bool disabled;

  /// Whether the field is read-only (not editable, but tappable).
  /// 
  /// When true, the field displays a value but is not directly editable.
  /// Callbacks like [onTap], [onPrefixTap], and [onSuffixTap] still fire.
  /// A lock icon is displayed in the suffix to signal read-only mode.
  /// Picker-style inputs use this to present a read-only display of the selected value.
  final bool readOnly;

  // ...
}
  • onChanged and onSubmit are independent callbacks.
  • onFocusChanged receives true when focus is gained, false when lost.
  • onTap fires in read-only mode (for pickers) but not in disabled mode.
  • disabled blocks all taps and input; text darkens to fg4; border becomes transparent.
  • readOnly allows onTap to fire (for pickers) and displays a lock icon in the suffix; rest fill and transparent border.

Focus and Lifecycle Management

// Design sketch — illustrative only
class LayrzTextInput extends StatefulWidget {
  /// Focus node for this input field.
  /// 
  /// If null, the widget constructs a node in [initState].
  /// The widget disposes only nodes it creates. Caller-supplied nodes are left untouched.
  final FocusNode? focusNode;

  /// Text controller for this input field.
  /// 
  /// If null, the widget constructs a controller in [initState].
  /// The widget disposes only controllers it creates. Caller-supplied controllers are left untouched.
  final TextEditingController? controller;

  // ...
}

Disposal contract: The widget disposes ONLY the instances it created; caller-supplied instances are left untouched. This is the standard pattern and prevents controller leaks, but bears explicit documentation because it is a common source of bugs.

Layout and Styling

// Design sketch — illustrative only
class LayrzTextInput extends StatefulWidget {
  /// Selects the comfortable padding ramp. Default false.
  ///
  /// `LayrzInputChrome` is the sole owner of the pixel values; there is no
  /// way to override them with a raw `EdgeInsets`.
  final bool dense;

  // ...
}
  • padding uses spacing tokens from the design-tokens document, resolved entirely inside LayrzInputChrome. isCompact no longer varies padding — the same value applies on every viewport, compact or wide:

    padding
    default pd2 — 10px
    dense pd1 — 6px

    Measured field heights follow directly: 43px default, 35px dense, flat across compact and wide (previously 55px/47px compact, 47px/39px wide). Every input now renders below the 44–48px range commonly recommended for touch targets, on mobile as well as desktop. This was accepted deliberately — see D66 for the full trade-off, including why gating dense off compact was considered and rejected.

  • Not customizable with a raw value. The 7 chrome-owning inputs (LayrzTextInput, LayrzTextAreaInput, LayrzSearchInput, LayrzNumberInput, LayrzSelectInput, LayrzDurationInput, LayrzComboBoxInput) expose only bool dense — there is no padding parameter, and a non-density override has no replacement. LayrzCheckboxInput, LayrzSwitchInput, and LayrzRadioInput have no chrome, so they have neither padding nor dense; their padding is fixed internally at pd2 (10px). See D66 for the full rationale.

Layout in Rows

When laying out inputs side-by-side in a Row, use CrossAxisAlignment.start on the Row. Inputs with error messages or labels are taller than those without, and the default CrossAxisAlignment.center alignment will misalign field boxes across the row and prevent error messages from hanging cleanly below. With CrossAxisAlignment.start, all field boxes sit on the same line regardless of label or error message height.

Value Display and Formatting

Concrete picker-style inputs compose their own editing primitive and LayrzInputChrome directly (per D63, above) and supply display text through the controller. Any visual affordance (color swatch, avatar thumbnail, icon preview) is rendered through the existing prefix widget parameter.

Each concrete input specifies:

  • The value type it holds (Color, DateTime, List<String>, etc.).
  • How to format that value for display in the controller's text.
  • The widget to render as a visual affordance in the prefix slot (if any).

What Each Concrete Input Adds

Every concrete Layrz*Input specifies only its deltas from the base contract:

  1. Value type — the data structure the input holds (e.g., Color, DateTime, Duration).
  2. Selection surface — the dialog or interaction pattern used to set the value (e.g., calendar picker, color wheel, emoji grid).
  3. Input-specific parameters — validation rules, formatting options, or constraints particular to the input type.

Illustrative Examples

Input Value Type Selection Surface Notes
LayrzNumberInput double Stepper buttons + keyboard input Min/max bounds, decimal formatting, step value
LayrzSelectInput String (or generic T) Searchable dropdown dialog Single selection, custom item rendering
LayrzMultiSelectInput List<String> (or generic List<T>) Searchable dropdown with checkboxes Multiple selection, checkboxes for visibility
LayrzCheckboxInput bool Inline checkbox, switch, or dropdown field Style variants: checkbox, switch, field (dropdown)
LayrzColorInput Color Color wheel and/or palette picker Hand-rolled to avoid Material dependency
LayrzFileInput {base64: String, bytes: Uint8List} System file browser Returns both base64 and raw bytes
LayrzIconInput IconData Icon selection dialog Picker over Solar icon set with search
LayrzAvatarInput String (base64) System image picker Stores image as base64, displays thumbnail

This table is illustrative of the pattern, not the complete catalogue. See Component Catalog for the full mapping of layrz_theme to layrz_ui inputs.


Shared Dependencies for the Entire Input Family

M1 Spacing Tokens

All default padding and inner spacing come from the spacing and radius token systems. The spacing tokens use five semantic levels: sp1 (4px), sp2 (8px), sp3 (16px), sp4 (24px), sp5 (32px). The radius tokens follow the same five-level model: r1 (4px), r2 (8px), r3 (16px), r4 (24px), r5 (32px). See the design-tokens document for detailed token specifications.

Impact: Spacing is consistent across all inputs and customizable from the theme.

Tooltip Component

The help affordance is built on the tooltip component, which wraps the SDK's RawTooltip. This must be available for all inputs to render help text.

Dependency: LayrzTooltip (M2) shipped in 0.0.7.

Material-Free Text Selection (DESIGN-74)

The SDK's EditableText ships with selection-handle positioning, drag-selection via keyboard, and keyboard selection (Ctrl+A, Ctrl+C). Touch drag handles, a selection toolbar, and the magnifier are deferred to DESIGN-74. LayrzTextInput is configured with selectionControls: null and contextMenuBuilder: null, making the selection overlay invisible while keyboard-driven selection remains functional.

Current state: Text selection and keyboard shortcuts work. Touch affordances (drag handles, selection toolbar) are DESIGN-74 scope and may land after the initial M2 ship.

Reference

See the flutter-347-audit document for the full analysis of SDK dependencies and Material coupling.


Interaction State Matrix

LayrzTextInput renders in six visual states. This matrix is the shared chrome for the entire input family:

State Fill Border (always 1.5px) Text
Rest surface2 transparent fg1, hint fg3
Hover surface3 transparent fg1
Focus surface2 colors.primary fg1
Error colors.danger.shade50 colors.danger fg1
Disabled surface2 transparent fg4
Read-only surface2 transparent fg1 + lock icon

State precedence: disabled > read-only > error > pressed > hover/focused > default.

Key characteristics:

  • Focus preserves fill — focus only changes the border to primary; fill stays surface2 (not elevated to surface)
  • Read-only is rest + lock icon — uses the rest state fill and transparent border, with a lock icon in the suffix
  • Disabled darkens text to fg4 and uses transparent border to signal permanent non-editability
  • All borders are solid — all six states use solid borders with constant width (1.5px) and radius (r2, 8 logical pixels)
  • Transparency creates invisible borders — states without a border color use transparent Color(0x00000000) to maintain constant geometry
  • Error state has danger colours on both fill and border
  • Light mode only — dark theme variants are out of scope (decision D7)
  • Fixed content height — padding is separate from content height; every chrome-owning input defaults to pd2/10px, dropping to pd1/6px with dense: true, identically on every viewport (D66)

Progress Tracking

Progress for input components and milestones is tracked in the Milestone Status tables in the repository documentation. See engineering/milestone-2.md for M2 work items and status.

Documentation files record specification and architecture; milestone tables record progress.


Last updated: 2026-08-27
Related documents: Component Catalog, design-tokens, architecture, roadmap, decisions, flutter-347-audit

Clone this wiki locally