Skip to content

LayrzSelectInput

Kenny Mochizuki Escalona edited this page Aug 25, 2026 · 10 revisions

LayrzSelectInput

Single-value selection field with searchable dropdown list surface.

Specification Status: LayrzSelectInput has shipped and is implemented at lib/src/inputs/src/select/select_input.dart (+ select_input_surface.dart). The sections below marked Shipped behavior describe the actual, current implementation and were corrected as part of DESIGN-40 (dropdown height rule), DESIGN-144 (focus node wiring), and the BREAKING field-as-searcher redesign below.

Everything below that, starting at "Deltas from the Input Contract", is the original pre-implementation design sketch carried over from the layrz_theme migration plan. It predates the actual implementation and does not match the shipped API in several places (for example: the shipped surface is LayrzAnchoredPanel on desktop / LayrzBottomSheet on mobile, not RawDialog; there is no dialogConstraints, itemExtent, overrideHeightDialog, autoclose, autoSelectFirst, or returnNullOnClose; LayrzSelectItem<T> has labelText, value, child, and searchableAttributes — no icon, leading, onTap, or isRemoved). Reconciling the rest of this legacy sketch with the shipped widget is out of scope for DESIGN-40/144 and is left for a dedicated documentation pass.

Shipped behavior: selection surface height (DESIGN-40)

The desktop selection surface follows one rule: height = min(content, 300.0), scroll past 300. This is enforced exactly once, via maxHeight: 300.0 passed to the LayrzAnchoredPanel that anchors the surface below (or above) the field:

  • If the filtered item list plus the search field fit in fewer than 300 logical pixels, the panel shrinks to content — it is never padded out to a fixed height.
  • If content exceeds 300 logical pixels, the panel clamps to exactly 300 and scrolls.

The surface itself (LayrzSelectInputSurface) applies no height cap of its own — a second, disagreeing cap inside the surface was the original defect (a fixed SizedBox(height: 300) around a list separately limited to LimitedBox(maxHeight: 300), which together overflowed by the search field's height once enough items were present). The rule is applied exactly once, by the caller.

On mobile (compact viewports), the surface has no height cap of its own either — it renders inside LayrzBottomSheet, whose own scrollable is the single scroll owner for that path.

Shipped behavior: focus node wiring (DESIGN-144)

LayrzSelectInput.focusNode (optional; an internal node is created and disposed when omitted) is attached to the focus tree on both platforms:

  • Desktop: the field's anchor content is wrapped in Focus(focusNode: ...), and the same node is passed as LayrzAnchoredPanel.childFocusNode so focus returns to the field when the panel closes.
  • Mobile (compact): the field's anchor content is likewise wrapped in Focus(focusNode: ...).

A caller-supplied FocusNode is therefore genuinely usable — calling .requestFocus() on it results in .hasFocus == true — on both platforms.

Shipped behavior: the field is the searcher (BREAKING spec change)

This is a deliberate, maintainer-directed change to the contract, not a bug fix. LayrzSelectInput was behaving correctly as specified before this change: it was a strictly controlled component whose field rendered value directly, and a caller that did not feed an updated value back after onChanged saw no visible update — the contract working as designed, not a defect. The maintainer chose to change that contract anyway. See the CHANGELOG for the full migration note.

What changed:

  • The field is now editable and is the searcher, when enableSearch is true (the default). There is no longer a separate search box inside the opened surface — typing directly into the field filters the list live. The panel's own search field and its controllers are gone.
  • The field self-displays from internal state, on both enableSearch values. A pick updates the field's own display immediately, whether or not the caller feeds an updated value back on the next build. A caller-supplied value change is still honored — it reconciles the internal state — but is no longer required for the pick to be visible.
  • enableSearch: false is still a pure picker — not editable — but it now also self-displays from internal state rather than rendering value directly. It needs no mode logic of its own (not editable ⇒ no query ⇒ no idle/typing distinction), only the same self-display as the searchable path.
  • The dropdown chevron moved out of suffixSlot to an external sibling, following LayrzNumberInput's step-button composition (chrome with showBorder: false / borderRadius: BorderRadius.zero, plus an outer container drawing the unified border). Before this, a caller-supplied suffixIcon/suffix/suffixText silently displaced the chevron (and vice versa) — both slots are now always free for the caller, and the chevron always renders alongside whatever the caller supplies.
  • LayrzInputChrome.readOnly is now always false. It was already inert before this change — its only consequence anywhere was the lock icon, itself suppressed via suppressReadOnlyLock: true — so this is a documentation correction as much as a behavior change. Non-editability (for enableSearch: false) now comes from the field's own readOnly configuration, not the chrome's.

The field's display has four modes (idle, typing, blur-revert, external-reconcile) when enableSearch is true:

  1. Idle — shows the selected item's label.
  2. Typing — shows the user's query, and filters the opened list by it.
  3. Blur with nothing picked — reverts to the selected item's label. This is the mode people forget: a query the user abandons without picking anything does not linger in the field.
  4. A caller-supplied value arriving mid-query — reconciles the internal selection silently, without overwriting what the user is actively typing. The new value's label appears once the query resolves (a pick, or a blur-revert).

Known interaction note: tapping the bare chrome region (e.g. its floating label, when one is present) does not open the surface — only a tap that lands on the field's own text content does. This follows the same, already-documented limitation on LayrzComboBoxInput (which shares the same underlying editable-field primitive), but is a behavior change for LayrzSelectInput specifically — its previous Text-based content had no gesture handling of its own, so a tap anywhere in the chrome opened the surface. Whether the full chrome should be tappable again is an open follow-up, not resolved by this change.

Metadata

Property Value
Mirror ThemedSelectInput<T>
Phase M3 Inputs
Domain Inputs
SDK Primitive Composes LayrzInputChrome + LayrzEditableField directly, with LayrzAnchoredPanel (desktop) / LayrzBottomSheet (mobile) for the selection surface

Conformance

LayrzSelectInput conforms to the Input Contract. It composes LayrzInputChrome and the shared LayrzEditableField primitive directly (the same primitive LayrzComboBoxInput and LayrzNumberInput use) — editable when enableSearch is true, read-only when false — and opens a selection surface on tap. The entire contract applies: labels, prefix/suffix, help affordance, focus management, padding, and validation error display.

Value Type and Selection Surface

  • Value type: Generic T — any comparable type
  • Selection surface: A list, filterable by the field's own query when enableSearch is true
  • Summary display: The field self-displays the label of the selected item from its own internal state (see the field-as-searcher section above); unselected state shows empty
  • Item support type: LayrzSelectItem<T> (derived from layrz_theme's ThemedSelectItem<T>)

Deltas from the Input Contract

Item Definition: LayrzSelectItem

LayrzSelectInput introduces a support type, LayrzSelectItem<T>, to represent each selectable item:

// Design sketch — illustrative only
class LayrzSelectItem<T> {
  /// Human-readable label displayed in the dropdown list.
  final String label;

  /// The value returned when this item is selected.
  final T? value;

  /// Optional icon displayed in the list and/or prefix.
  final IconData? icon;

  /// Optional custom widget displayed in the list (overrides icon and label).
  final Widget? content;

  /// Optional custom leading widget (e.g., avatar, thumbnail).
  final Widget? leading;

  /// Callback invoked when this item is tapped in the list.
  final VoidCallback? onTap;

  /// Attributes to include in search filtering (e.g., category, code).
  final Set<String> searchableAttributes;

  const LayrzSelectItem({
    required this.label,
    required this.value,
    this.icon,
    this.content,
    this.leading,
    this.onTap,
    this.searchableAttributes = const {},
  });
}

Selection Behavior

// Design sketch — illustrative only
class LayrzSelectInput<T> extends StatefulWidget {
  /// List of items available for selection.
  final List<LayrzSelectItem<T>> items;

  /// Callback invoked when user selects an item.
  /// Receives the selected item or null if unselected.
  final void Function(LayrzSelectItem<T>?)? onChanged;

  /// Currently selected value (matched against LayrzSelectItem.value).
  final T? value;

  /// Whether to enable search filtering in the dropdown.
  final bool enableSearch;

  /// Whether to automatically close the dropdown after selection.
  final bool autoclose;

  /// Whether the user can deselect the current item (uncheck it).
  final bool canUnselect;

  /// Custom filter function for search.
  /// If null, performs case-insensitive label search.
  final bool Function(String searchText, LayrzSelectItem<T>)? filter;

  /// Text displayed when the filtered list is empty.
  final String? emptyListText;

  /// Constraints for the dropdown dialog size.
  final BoxConstraints dialogConstraints;

  /// Height of each list item (affects scrollable area).
  final double itemExtent;

  const LayrzSelectInput({
    required this.items,
    this.onChanged,
    this.value,
    this.enableSearch = true,
    this.autoclose = true,
    this.canUnselect = false,
    this.filter,
    this.emptyListText,
    this.dialogConstraints = const BoxConstraints(maxWidth: 500, maxHeight: 500),
    this.itemExtent = 50,
    // ... shared contract parameters (label, placeholder, prefix, suffix, etc.)
  });
}

Inherits from Input Contract

All of the following are inherited from LayrzTextInput:

  • labelText and label (mutually exclusive)
  • placeholder
  • prefixIcon, prefixWidget, onPrefixTap (mutually exclusive icon and widget)
  • suffixIcon, suffixWidget, onSuffixTap (mutually exclusive icon and widget)
  • helpTitleText, helpContentText (two-part help tooltip)
  • readOnly — no longer accurate as "always true": see the field-as-searcher section above. The field is editable when enableSearch is true (the default) and read-only when false.
  • onTap (opens the dropdown; can be overridden by caller if needed)
  • focusNode, controller (focus and value management)
  • padding (defaults to spacing tokens)
  • disabled and error display

Reference: Current layrz_theme API

ThemedSelectInput<T> (source: lib/src/inputs/src/general/select_input.dart):

Parameter Type Notes
labelText String? Label text (or use label Widget instead)
label Widget? Label widget (mutually exclusive with labelText)
items List<ThemedSelectItem<T>> List of selectable items
value T? Currently selected value
onChanged void Function(ThemedSelectItem<T>?)? Callback when item selected
prefixIcon IconData? Icon in prefix slot
prefixText String? Text in prefix slot
onPrefixTap VoidCallback? Prefix tap handler
enableSearch bool Enable search in dropdown (default: true)
autoclose bool Close after selection (default: true)
canUnselect bool Allow deselection (default: false)
disabled bool Disable the field (default: false)
errors List<String> Error messages (default: [])
hideDetails bool Hide error/helper text (default: false)
hideTitle bool Hide title in dropdown dialog (default: false)
isRequired bool Mark field as required (default: false)
padding EdgeInsets? Field padding
filter bool Function(String, ThemedSelectItem<T>)? Custom search filter
dialogConstraints BoxConstraints Dropdown dialog size
itemExtent double Height of each list item (default: 50)
overrideHeightDialog double? Override computed dialog height
customChild Widget? Replace entire input with custom widget
returnNullOnClose bool Return null if dialog closed without selection (default: false)
autoSelectFirst bool Auto-select first item on first render (default: false)
translations Map<String, String> i18n strings (cancel, save, search, empty messages)
overridesLayrzTranslations bool Use custom translations over defaults (default: false)

ThemedSelectItem<T>:

  • label (String): Item display label
  • value (T?): Item value
  • icon (IconData?): Icon to display
  • leading (Widget?): Custom leading widget
  • content (Widget?): Custom content (overrides label/icon/leading)
  • onTap (VoidCallback?): Item tap callback
  • searchableAttributes (Set): Additional searchable text
  • isRemoved (bool): Marks item as removed (for canUnselect flow)

Dependencies and Blockers

  • LayrzTextInput — must ship first; LayrzSelectInput composes it.
  • LayrzTooltip — required for help affordances if using helpTitleText / helpContentText.
  • Material-free TextSelectionControls — if LayrzTextInput requires copy/paste in read-only mode, this blocker applies.

Open Questions

  1. Naming: Is LayrzSelectItem<T> the correct name, or should it follow a different pattern (e.g., SelectOption<T>, MenuItem<T>)?

  2. Value update semantics: When value changes externally, does the field automatically find and display the matching LayrzSelectItem, or is the caller responsible for keeping the value in sync with the items list?

  3. Dialog positioning: Should the dropdown be smart-positioned (above/below based on available space), or always below the field?

  4. Custom item rendering: Does LayrzSelectItem.content take precedence over icon, leading, and label, or are there specific rendering priorities?

  5. Search filtering: If filter is not supplied, does it search all attributes (label + searchableAttributes), or label only?

  6. Unselect UI: When canUnselect is true, how is the unselect action presented — a separate button, toggle behavior on the selected item, or both?

  7. Empty state text: Is there a default empty-list message, or must the caller provide one?

  8. Keyboard navigation: Are arrow keys, Enter, and Escape supported in the dropdown list for desktop?


Last updated: 2026-08-25 (field-as-searcher redesign, BREAKING — the field is now the searcher, self-displays from internal state on both enableSearch values, the dropdown chevron moved to an external sibling, and LayrzInputChrome.readOnly is now always false; DESIGN-40 / DESIGN-144's selection surface height rule and focus node wiring remain as previously corrected; the remaining pre-implementation sections are unchanged and still need a full documentation pass)
Related documents: Input Contract, Component Catalog, Design Tokens

Clone this wiki locally