-
Notifications
You must be signed in to change notification settings - Fork 0
LayrzSelectInput
Single-value selection field with searchable dropdown list surface.
Specification Status:
LayrzSelectInputhas shipped and is implemented atlib/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_thememigration plan. It predates the actual implementation and does not match the shipped API in several places (for example: the shipped surface isLayrzAnchoredPanelon desktop /LayrzBottomSheeton mobile, notRawDialog; there is nodialogConstraints,itemExtent,overrideHeightDialog,autoclose,autoSelectFirst, orreturnNullOnClose;LayrzSelectItem<T>now has onlyvalue,child, andsearchableStrings— see the item-reduction section below — nolabel,icon,leading,onTap, orisRemoved). 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.
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.
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 asLayrzAnchoredPanel.childFocusNodeso 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.
errors renders its footer independent of whether labelText is set. An earlier version rendered
the label and the error/counter footer together, conditionally, so a field with errors set but no
labelText showed no error text at all. The label row alone is now conditional on labelText; the
footer always renders when it has something to show.
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
enableSearchistrue(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
enableSearchvalues. A pick updates the field's own display immediately, whether or not the caller feeds an updatedvalueback on the next build. A caller-suppliedvaluechange is still honored — it reconciles the internal state — but is no longer required for the pick to be visible. -
enableSearch: falseis still a pure picker — not editable — but it now also self-displays from internal state rather than renderingvaluedirectly. 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
suffixSlotto an external sibling, followingLayrzNumberInput's step-button composition (chrome withshowBorder: false/borderRadius: BorderRadius.zero, plus an outer container drawing the unified border). Before this, a caller-suppliedsuffixIcon/suffix/suffixTextsilently 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.readOnlyis now alwaysfalse. It was already inert before this change — its only consequence anywhere was the lock icon, itself suppressed viasuppressReadOnlyLock: true— so this is a documentation correction as much as a behavior change. Non-editability (forenableSearch: false) now comes from the field's ownreadOnlyconfiguration, not the chrome's.
The field's display has four modes (idle, typing, blur-revert, external-reconcile) when
enableSearch is true:
-
Idle — renders the selected item's own
LayrzSelectItem.childwidget — icon, formatting, and all. Not a degraded plain-text label: whatever the item renders in the list, it renders identically in the field. -
Typing — shows the user's query as live
EditableText, and filters the opened list by it. -
Blur with nothing picked — reverts to rendering the selected item's
child. This is the mode people forget: a query the user abandons without picking anything does not linger in the field. -
A caller-supplied
valuearriving mid-query — reconciles the internal selection silently, without overwriting what the user is actively typing. The new value'schildrenders once the query resolves (a pick, or a blur-revert).
DESIGN-142. LayrzSelectItem<T> is reduced to exactly three fields:
class LayrzSelectItem<T> {
final T? value;
final Widget child;
final Set<String> searchableStrings;
}-
labelTextis gone entirely. There is no string anywhere on this type anymore. -
childis now required, not optional — it is the item's only presentation, in every surface that renders it (the dropdown list, a bottom sheet, and the field itself while idle — see mode 1 above). A caller migrating fromlabelTextsupplieschild: Text('...')instead. -
searchableAttributesis renamedsearchableStrings, same shape and semantics, and now the only thing search matches against — there is nolabelTextfallback to match on anymore. A caller that wants the visible text to also be searchable must put it insearchableStringsexplicitly (it is no longer implied by whatchildhappens to render).
This is the same principle already established for the surface's presentation, taken to its conclusion: the item defines its own look, in the list and in the field alike, and search is a fully separate concern driven only by explicit strings — which means an item can be found by text that never appears on screen at all (an internal code, an alternate spelling, a category tag).
The item's child renders under the theme's body text style, forced explicitly (via
DefaultTextStyle(style: tokens.typography.body, ...)) at every render site — both the surface's
list row and the field's idle display. This matters because child is now the only way an item
presents itself, so it must render correctly regardless of what ambient DefaultTextStyle happens
to be in scope at that exact point in the tree (a real risk in test harnesses, embedded previews,
or any future context that isn't a full LayrzApp).
A trap this reduction creates — read this before building a child: the forced
DefaultTextStyle only reaches widgets that actually inherit it. Text and Text.rich do;
the raw RichText widget does not — it paints its TextSpan's own style only, and a TextSpan
with no explicit color renders with no color, which the rendering engine then paints solid white
regardless of theme. Use Text.rich, never RichText directly, whenever a child needs
multiple styled runs (e.g. an icon glyph plus a name) — same TextSpan API, but it applies the
ambient style the way a plain Text does. The showroom's select demo (Custom Item Child section)
demonstrates the correct pattern.
Accessibility migration note: LayrzRadioInput's options previously announced
LayrzSelectItem.labelText as an explicit Semantics(label: ...), with the presentation Text
excluded from semantics to avoid double-announcing. With labelText gone, the explicit label and
the exclusion are both removed — child's own semantics (a plain Text, in the common case) now
merge upward into the option's semantics node instead, producing the identical single
announcement. A child with no inherent text semantics (icon-only, a color swatch, etc.) now
announces with no name at all, unless the caller wraps that child in its own
Semantics(label: '...'). This is a real reduction in default accessibility coverage for that
case — it does not affect any shipped demo or test today, but any new icon-only item needs an
explicit label from its own child to stay announced.
The whole chrome stays tappable, matching pre-redesign behavior. LayrzEditableField's own tap
handling only claims the text content's own hit region (the same limitation documented on
LayrzComboBoxInput, which shares the underlying editable-field primitive) — left alone, that would
have narrowed the field's clickable area to the text strip, so tapping the floating label or any
other chrome padding would silently do nothing. A LayrzTappable fallback wraps the chrome region
(fully transparent, so it adds no visual tint on top of the chrome's own state-driven styling) and
opens the surface for a tap anywhere else in the chrome, while a tap on the text itself still places
the cursor, a drag across it still selects a range, and a long-press still shows touch selection
handles.
| 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 |
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: Generic
T— any comparable type -
Selection surface: A list, filterable by the field's own query when
enableSearchis true -
Summary display: The field self-displays the selected item's
childwidget from its own internal state (see the field-as-searcher section above); unselected state shows the hint -
Item support type:
LayrzSelectItem<T>—value,child,searchableStrings(see the item-reduction section above; no relation to layrz_theme'sThemedSelectItem<T>shape anymore)
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 {},
});
}// 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.)
});
}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
enableSearchis 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
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 (forcanUnselectflow)
- 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.
-
Naming: Is
LayrzSelectItem<T>the correct name, or should it follow a different pattern (e.g.,SelectOption<T>,MenuItem<T>)? -
Value update semantics: When
valuechanges externally, does the field automatically find and display the matchingLayrzSelectItem, or is the caller responsible for keeping the value in sync with the items list? -
Dialog positioning: Should the dropdown be smart-positioned (above/below based on available space), or always below the field?
-
Custom item rendering: Does
LayrzSelectItem.contenttake precedence overicon,leading, andlabel, or are there specific rendering priorities? -
Search filtering: If
filteris not supplied, does it search all attributes (label + searchableAttributes), or label only? -
Unselect UI: When
canUnselectis true, how is the unselect action presented — a separate button, toggle behavior on the selected item, or both? -
Empty state text: Is there a default empty-list message, or must the caller provide one?
-
Keyboard navigation: Are arrow keys, Enter, and Escape supported in the dropdown list for desktop?
Last updated: 2026-08-25 (DESIGN-142, BREAKING — LayrzSelectItem<T> reduced to value,
child, searchableStrings; labelText is gone and child is now required as the item's only
presentation, rendered in the field itself while idle as well as in the surface's list; search now
matches only searchableStrings; also documents the RichText-vs-Text.rich trap and the radio
accessibility migration note. Field-as-searcher redesign (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) and 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). 2026-08-26
(v0.0.13): documented that the error footer is never gated on labelText being non-null.
Related documents: Input Contract, Component Catalog, Design Tokens
Made with ❤️ by Golden M, Inc.
- LayrzTextInput
- LayrzSelectableAction
- LayrzSelectionToolbar
- LayrzTextSelectionControls
- LayrzSelectionMagnifier
- LayrzSelectionHandlePainter
- LayrzTextAreaInput
- LayrzComboBoxInput
- LayrzNumberInput
- LayrzPasswordInput
- LayrzCheckboxInput
- LayrzRadioInput
- LayrzSelectInput
- LayrzMultiSelectInput
- LayrzSearchInput
- LayrzDualListInput
- LayrzDurationInput
- LayrzSlider