Fliin
On this page

Components / forms

Select

Offers one or several choices from a closed list, from a trigger that shows the current choice. A list as wide as the trigger, or a bottom sheet or a dialog.

16 of 16 variants pass
Select, placeholder state
Platform
Brightness
Text scale
Direction

Import

In a Flutter app where fliin init has run:

fliin add selectAlso installs: core, icons, popover, sheet, dialog, close-button, field, text-field

Examples

Placeholder

Before a choice, the select shows its placeholder; touching it opens the list of options, and choosing one closes it.

Select, placeholder state
import 'package:your_app/ui/fliin/components/select/fl_select.dart';
import 'package:flutter/widgets.dart';

class SelectPlaceholderExample extends StatefulWidget {
  const SelectPlaceholderExample({super.key});

  @override
  State<SelectPlaceholderExample> createState() => _SelectPlaceholderExampleState();
}

class _SelectPlaceholderExampleState extends State<SelectPlaceholderExample> {
  String? _state;

  @override
  Widget build(BuildContext context) {
    return FlSelect<String>(
      label: 'State',
      placeholder: 'Select one',
      listLabel: 'Choose a state',
      options: const [
        FlSelectOption(value: 'ca', label: 'California'),
        FlSelectOption(value: 'ny', label: 'New York'),
        FlSelectOption(value: 'tx', label: 'Texas'),
        FlSelectOption(value: 'fl', label: 'Florida'),
      ],
      selection: FlSingleSelection(value: _state, onChanged: (state) => setState(() => _state = state)),
    );
  }
}

Open

The list open, the current choice marked. A FlSelectController opens or closes it from code, for instance to ask again after a failed save.

Select, open state
import 'package:your_app/ui/fliin/components/select/fl_select.dart';
import 'package:flutter/widgets.dart';

class SelectOpenExample extends StatefulWidget {
  const SelectOpenExample({super.key, this.controller});

  final FlSelectController? controller;

  @override
  State<SelectOpenExample> createState() => _SelectOpenExampleState();
}

class _SelectOpenExampleState extends State<SelectOpenExample> {
  String? _state = 'ca';

  @override
  Widget build(BuildContext context) {
    return FlSelect<String>(
      controller: widget.controller,
      label: 'State',
      placeholder: 'Select one',
      listLabel: 'Choose a state',
      options: const [
        FlSelectOption(value: 'ca', label: 'California'),
        FlSelectOption(value: 'ny', label: 'New York'),
        FlSelectOption(value: 'tx', label: 'Texas'),
        FlSelectOption(value: 'fl', label: 'Florida'),
      ],
      selection: FlSingleSelection(value: _state, onChanged: (state) => setState(() => _state = state)),
    );
  }
}

Disabled

onChanged: null disables the select: it keeps showing its value but no longer opens.

Select, disabled state
import 'package:your_app/ui/fliin/components/select/fl_select.dart';
import 'package:flutter/widgets.dart';

class SelectDisabledExample extends StatelessWidget {
  const SelectDisabledExample({super.key});

  @override
  Widget build(BuildContext context) {
    return const FlSelect<String>(
      label: 'State',
      placeholder: 'Select one',
      listLabel: 'Choose a state',
      options: [
        FlSelectOption(value: 'ca', label: 'California'),
        FlSelectOption(value: 'ny', label: 'New York'),
        FlSelectOption(value: 'tx', label: 'Texas'),
        FlSelectOption(value: 'fl', label: 'Florida'),
      ],
      selection: FlSingleSelection(value: 'ny', onChanged: null),
    );
  }
}

Description

showLabel writes the label above the trigger, required adds its asterisk, and helper explains the choice below it, as on a text field.

Select, description state
import 'package:your_app/ui/fliin/components/select/fl_select.dart';
import 'package:flutter/widgets.dart';

class SelectDescriptionExample extends StatefulWidget {
  const SelectDescriptionExample({super.key});

  @override
  State<SelectDescriptionExample> createState() => _SelectDescriptionExampleState();
}

class _SelectDescriptionExampleState extends State<SelectDescriptionExample> {
  String? _country;

  @override
  Widget build(BuildContext context) {
    return FlSelect<String>(
      label: 'Country',
      showLabel: true,
      required: true,
      placeholder: 'Select a country',
      helper: 'Sets your currency and the taxes on your invoices.',
      options: const [
        FlSelectOption(value: 'fr', label: 'France'),
        FlSelectOption(value: 'de', label: 'Germany'),
        FlSelectOption(value: 'es', label: 'Spain'),
        FlSelectOption(value: 'it', label: 'Italy'),
      ],
      selection: FlSingleSelection(value: _country, onChanged: (country) => setState(() => _country = country)),
    );
  }
}

Leading

Each option's leading, an icon here, shows before its label in the list and, once chosen, in the trigger; description adds a line under the label.

Select, leading state
import 'package:your_app/ui/fliin/components/select/fl_select.dart';
import 'package:your_app/ui/fliin/core/fliin.dart';
import 'package:your_app/ui/fliin/icons/fl_icons.dart';
import 'package:flutter/widgets.dart';

class SelectLeadingExample extends StatefulWidget {
  const SelectLeadingExample({super.key, this.controller});

  final FlSelectController? controller;

  @override
  State<SelectLeadingExample> createState() => _SelectLeadingExampleState();
}

class _SelectLeadingExampleState extends State<SelectLeadingExample> {
  String? _delivery = 'home';

  @override
  Widget build(BuildContext context) {
    return FlSelect<String>(
      controller: widget.controller,
      label: 'Delivery',
      showLabel: true,
      placeholder: 'Choose a delivery',
      options: [
        FlSelectOption(
          value: 'home',
          label: 'Home delivery',
          description: 'Tomorrow, before 1 pm',
          leading: FlIcon(FlIcons.house),
        ),
        FlSelectOption(
          value: 'pickup',
          label: 'Pickup point',
          description: 'In 2 days, free',
          leading: FlIcon(FlIcons.mapPin),
        ),
        FlSelectOption(
          value: 'store',
          label: 'In store',
          description: 'Today, from 4 pm',
          leading: FlIcon(FlIcons.shoppingCart),
        ),
      ],
      selection: FlSingleSelection(value: _delivery, onChanged: (delivery) => setState(() => _delivery = delivery)),
    );
  }
}

Multiple

FlMultipleSelection keeps the list open while each option checks and unchecks; the trigger lists the chosen labels.

Select, multiple state
import 'package:your_app/ui/fliin/components/select/fl_select.dart';
import 'package:flutter/widgets.dart';

class SelectMultipleExample extends StatefulWidget {
  const SelectMultipleExample({super.key});

  @override
  State<SelectMultipleExample> createState() => _SelectMultipleExampleState();
}

class _SelectMultipleExampleState extends State<SelectMultipleExample> {
  var _days = const {'mon', 'wed'};

  @override
  Widget build(BuildContext context) {
    return FlSelect<String>(
      label: 'Delivery days',
      showLabel: true,
      placeholder: 'Select days',
      options: const [
        FlSelectOption(value: 'mon', label: 'Monday'),
        FlSelectOption(value: 'tue', label: 'Tuesday'),
        FlSelectOption(value: 'wed', label: 'Wednesday'),
        FlSelectOption(value: 'thu', label: 'Thursday'),
        FlSelectOption(value: 'fri', label: 'Friday'),
      ],
      selection: FlMultipleSelection(values: _days, onChanged: (days) => setState(() => _days = days)),
    );
  }
}

Invalid

error rings the trigger in danger and replaces the helper, until the choice is made.

Select, invalid state
import 'package:your_app/ui/fliin/components/select/fl_select.dart';
import 'package:flutter/widgets.dart';

class SelectInvalidExample extends StatefulWidget {
  const SelectInvalidExample({super.key});

  @override
  State<SelectInvalidExample> createState() => _SelectInvalidExampleState();
}

class _SelectInvalidExampleState extends State<SelectInvalidExample> {
  String? _size;

  @override
  Widget build(BuildContext context) {
    return FlSelect<String>(
      label: 'Size',
      showLabel: true,
      required: true,
      placeholder: 'Select a size',
      helper: 'See the size guide for your measurements.',
      error: _size == null ? 'Choose a size to add this item to your bag.' : null,
      options: const [
        FlSelectOption(value: 's', label: 'Small'),
        FlSelectOption(value: 'm', label: 'Medium'),
        FlSelectOption(value: 'l', label: 'Large'),
      ],
      selection: FlSingleSelection(value: _size, onChanged: (size) => setState(() => _size = size)),
    );
  }
}

Usage

The code the reference images render:

final example = Overlay(
  initialEntries: [
    OverlayEntry(
      builder: (context) => ColoredBox(
        color: FlScope.of(context).colors.surface,
        child: Padding(
          padding: EdgeInsetsDirectional.all(FlScope.of(context).space.lg),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            spacing: FlScope.of(context).space.md,
            children: [
              FlSelect<int>(
                label: 'Size',
                placeholder: 'Choose a size',
                options: const [],
                selection: FlSingleSelection(value: null, onChanged: null),
              ),
              Builder(
                builder: (context) {
                  final controller = FlSelectController();
                  WidgetsBinding.instance.addPostFrameCallback(
                    (_) => controller.open(),
                  );
                  return FlSelect<int>(
                    controller: controller,
                    label: 'Fruit',
                    placeholder: 'Choose a fruit',
                    listLabel: 'Fruits',
                    options: [
                      for (final (i, name)
                          in (variant.dir == TextDirection.rtl
                                  ? const ['تفاح', 'برتقال', 'موز']
                                  : const ['Apple', 'Orange', 'Banana'])
                              .indexed)
                        FlSelectOption(value: i, label: name),
                    ],
                    selection: FlSingleSelection(value: 0, onChanged: (_) {}),
                  );
                },
              ),
            ],
          ),
        ),
      ),
    ),
  ],
);

API

FlSelectOption: final class<T>
FlSelectOption.new: const new({required T value, required String label, String? description, Widget? leading, bool enabled})
FlSelectOption.value: get T
FlSelectOption.label: get String
FlSelectOption.description: get String?
FlSelectOption.leading: get Widget?
FlSelectOption.enabled: get bool
FlSelection: sealed class<T> permits {FlMultipleSelection, FlSingleSelection}
FlSelection.new: const new()
FlSingleSelection: final class<T> extends FlSelection<T>
FlSingleSelection.new: const new({required T? value, required ValueChanged<T>? onChanged})
FlSingleSelection.value: get T?
FlSingleSelection.onChanged: get ValueChanged<T>?
FlMultipleSelection: final class<T> extends FlSelection<T>
FlMultipleSelection.new: const new({required Set<T> values, required ValueChanged<Set<T>>? onChanged})
FlMultipleSelection.values: get Set<T>
FlMultipleSelection.onChanged: get ValueChanged<Set<T>>?
FlSelectController: class
FlSelectController.new: new()
FlSelectController.isOpen: get bool
FlSelectController.open: void()
FlSelectController.close: void()
FlSelectPresentation: enum {popover, sheet, dialog}
FlSelectPresentation.popover: static final FlSelectPresentation
FlSelectPresentation.sheet: static final FlSelectPresentation
FlSelectPresentation.dialog: static final FlSelectPresentation
FlSelect: class<T> extends StatefulWidget
FlSelect.new: const new({super.key, required List<FlSelectOption<T>> options, required FlSelection<T> selection, required String label, required String placeholder, String? listLabel, FlSelectPresentation presentation, String? barrierLabel, String? closeLabel, FlSelectController? controller, bool showLabel, String? helper, String? error, bool required, FlInputVariant variant})
FlSelect.options: get List<FlSelectOption<T>>
FlSelect.selection: get FlSelection<T>
FlSelect.label: get String
FlSelect.placeholder: get String
FlSelect.listLabel: get String?
FlSelect.presentation: get FlSelectPresentation
FlSelect.barrierLabel: get String?
FlSelect.closeLabel: get String?
FlSelect.controller: get FlSelectController?
FlSelect.showLabel: get bool
FlSelect.helper: get String?
FlSelect.error: get String?
FlSelect.required: get bool
FlSelect.variant: get FlInputVariant
FlSelect.createState: State<FlSelect<T>>()
FlSelectOptionRow: class<T> extends StatelessWidget
FlSelectOptionRow.new: const new({super.key, required FlSelectOption<T> option, required bool selected, required VoidCallback? onPressed, bool active})
FlSelectOptionRow.option: get FlSelectOption<T>
FlSelectOptionRow.selected: get bool
FlSelectOptionRow.onPressed: get VoidCallback?
FlSelectOptionRow.active: get bool
FlSelectOptionRow.build: Widget(BuildContext context)

Reference renders

The 16 images published with version 0.2.5, rendered by the shared harness on macOS with Flutter 3.47.7. fliin verify select compares your copy to them.

iOS · Light
Select, iOS, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Select, iOS, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Select, iOS, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Select, iOS, Light, text 2.0×, Right to left
Text 2.0× · Right to left
iOS · Dark
Select, iOS, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Select, iOS, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Select, iOS, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Select, iOS, Dark, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Light
Select, Android, Light, text 1.0×, Left to right
Text 1.0× · Left to right
Select, Android, Light, text 1.0×, Right to left
Text 1.0× · Right to left
Select, Android, Light, text 2.0×, Left to right
Text 2.0× · Left to right
Select, Android, Light, text 2.0×, Right to left
Text 2.0× · Right to left
Android · Dark
Select, Android, Dark, text 1.0×, Left to right
Text 1.0× · Left to right
Select, Android, Dark, text 1.0×, Right to left
Text 1.0× · Right to left
Select, Android, Dark, text 2.0×, Left to right
Text 2.0× · Left to right
Select, Android, Dark, text 2.0×, Right to left
Text 2.0× · Right to left

Use it to

  • pick a size or a quantity from a dropdown
  • add a dropdown field to a form
  • offer a fixed set of choices instead of free text
  • let people pick several tags in one field
  • choose a delivery method from a closed list
Not for
  • triggering an action: use a menu (menu)
  • two or three choices always visible: use radio buttons (radio)
  • entering free text: use a text field (text-field)
  • a city or an address to find by typing: use a combo box (combo-box)
States
  • Open
  • Placeholder
  • Disabled

Source

The files fliin add select copies into your app, before your prefix and imports are applied.

select/fl_select.dart

// Modified from the Select of HeroUI Native 1.0.10, Copyright 2025 NextUI Inc.,
// licensed under Apache 2.0: https://www.apache.org/licenses/LICENSE-2.0
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:fliin_ui/components/close_button/fl_close_button.dart';
import 'package:fliin_ui/components/dialog/fl_dialog.dart';
import 'package:fliin_ui/components/field/fl_field.dart';
import 'package:fliin_ui/components/popover/fl_popover.dart';
import 'package:fliin_ui/components/sheet/fl_sheet.dart';
import 'package:fliin_ui/components/text_field/fl_input.dart';
import 'package:fliin_ui/core/fliin.dart';
import 'package:fliin_ui/icons/fl_icons.dart';

/// An option of an [FlSelect].
final class FlSelectOption<T> {
  /// An option with value [value].
  const FlSelectOption({required this.value, required this.label, this.description, this.leading, this.enabled = true});

  /// The value returned on choice.
  final T value;

  /// The option text (`Select.ItemLabel`), also shown in the trigger when
  /// it is selected.
  final String label;

  /// The text under the label (`Select.ItemDescription`); null, no text.
  final String? description;

  /// What precedes the label, in the row and, once chosen, in the trigger
  /// of a single selection: an `FlAvatar` of `sm` size for a person, a flag,
  /// an icon (muted, 16 by default). Decorative: the label names the
  /// option. When null, nothing.
  final Widget? leading;

  /// False: the option can't be picked.
  final bool enabled;
}

/// What the select picks: one value, or several.
sealed class FlSelection<T> {
  const FlSelection();

  bool get _enabled;
  bool _contains(T value);

  /// True when picking closes the select.
  bool get _closesOnChoice;
  void _choose(T value);
}

/// At most one value; picking it closes the select.
final class FlSingleSelection<T> extends FlSelection<T> {
  /// [value] selected, null without a choice; a null [onChanged] disables
  /// the select.
  const FlSingleSelection({required this.value, required this.onChanged});

  /// The selected value, null without a choice.
  final T? value;

  /// Called with the selected value.
  final ValueChanged<T>? onChanged;

  @override
  bool get _enabled => onChanged != null;

  @override
  bool _contains(T value) => this.value == value;

  @override
  bool get _closesOnChoice => true;

  @override
  void _choose(T value) => onChanged?.call(value);
}

/// Several values; each is checked and unchecked, and the select stays
/// open.
final class FlMultipleSelection<T> extends FlSelection<T> {
  /// [values] selected; a null [onChanged] disables the select.
  const FlMultipleSelection({required this.values, required this.onChanged});

  /// The selected values.
  final Set<T> values;

  /// Called with the selected values.
  final ValueChanged<Set<T>>? onChanged;

  @override
  bool get _enabled => onChanged != null;

  @override
  bool _contains(T value) => values.contains(value);

  @override
  bool get _closesOnChoice => false;

  @override
  void _choose(T value) => onChanged?.call(values.contains(value) ? ({...values}..remove(value)) : {...values, value});
}

/// Opens and closes the [FlSelect] it is given to, once that select is
/// mounted.
class FlSelectController {
  _FlSelectState<Object?>? _state;

  /// True when the list is open.
  bool get isOpen => _state?._open ?? false;

  /// Opens the list. No effect if it already is, or if the select is
  /// disabled or not mounted.
  void open() {
    final state = _state;
    if (state != null && !state._open && state.widget.selection._enabled) {
      state._show();
    }
  }

  /// Closes the list. No effect if it already is closed.
  void close() {
    if (isOpen) _state?._close();
  }
}

/// How the select presents its options.
enum FlSelectPresentation {
  /// A floating list under the trigger, at its width.
  popover,

  /// A bottom sheet ([showFlSheet]).
  sheet,

  /// A centered dialog ([showFlDialog]).
  dialog,
}

/// A choice among options: a trigger like a field
/// (an `FlInputFrame`: `field` fill and shadow, no outline at rest, a
/// hairline under the pointer, continuous corners of `shape.radiusControl`) that shows the value or
/// [placeholder] and a chevron that turns around on opening, then the list
/// of options on an [FlPopover] as wide as the trigger, 8 points from it,
/// in a sheet or in a dialog. The chosen option carries a `selected` check:
/// a chosen state, never the accent.
///
/// The trigger is announced by [label] and the value, expanded or
/// collapsed; the list takes focus on opening, Escape and a tap outside
/// close it, and focus returns to the trigger. When disabled (null
/// `onChanged`), it fades to `disabledOpacity`.
class FlSelect<T> extends StatefulWidget {
  /// A closed select.
  const FlSelect({
    super.key,
    required this.options,
    required this.selection,
    required this.label,
    required this.placeholder,
    this.listLabel,
    this.presentation = FlSelectPresentation.popover,
    this.barrierLabel,
    this.closeLabel,
    this.controller,
    this.showLabel = false,
    this.helper,
    this.error,
    this.required = false,
    this.variant = FlInputVariant.primary,
  }) : assert(
         presentation == FlSelectPresentation.popover || barrierLabel != null,
         'a sheet or a dialog closes through its barrier, which must be named',
       );

  /// The options, in display order.
  final List<FlSelectOption<T>> options;

  /// The selected value, or values.
  final FlSelection<T> selection;

  /// The select's name, read with the value by the screen reader.
  final String label;

  /// The trigger text without a choice, in `fieldPlaceholder`.
  final String placeholder;

  /// The list title (`Select.ListLabel`); null, no title.
  final String? listLabel;

  /// Anchored list by default, sheet or dialog.
  final FlSelectPresentation presentation;

  /// The name of the sheet's or dialog's barrier, which closes it.
  final String? barrierLabel;

  /// The name of the dialog's close button (`Select.Close`); null, no
  /// button.
  final String? closeLabel;

  /// To open and close the list without touching the trigger.
  final FlSelectController? controller;

  /// True: [label] shows above the trigger as an `FlLabel`, `flFieldGap`
  /// apart, and fades with it when disabled, like an `FlTextField`'s. False
  /// by default: the screen supplies its own label, or none.
  final bool showLabel;

  /// The help under the trigger (`Description`), in muted `textSm`,
  /// replaced by [error] when there is one; read with the trigger. When
  /// null, none.
  final String? helper;

  /// The error under the trigger (`FieldError`): when set, the trigger takes
  /// a `danger` ring and the message replaces [helper], announced when it
  /// appears. When null, the select is valid.
  final String? error;

  /// True: a `danger` asterisk follows the visible label ([showLabel]), and
  /// the trigger is announced as required.
  final bool required;

  /// The trigger's background: `primary` by default (white and shadowed),
  /// `secondary` on a surface, like an `FlTextField`'s.
  final FlInputVariant variant;

  @override
  State<FlSelect<T>> createState() => _FlSelectState<T>();
}

class _FlSelectState<T> extends State<FlSelect<T>> with SingleTickerProviderStateMixin {
  final FlPopoverController _popover = FlPopoverController();
  late final AnimationController _turn = AnimationController.unbounded(vsync: this);
  bool _open = false;

  /// The open sheet or dialog, to close it.
  BuildContext? _route;

  @override
  void initState() {
    super.initState();
    widget.controller?._state = this;
  }

  @override
  void didUpdateWidget(FlSelect<T> oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.controller != widget.controller) {
      oldWidget.controller?._state = null;
      widget.controller?._state = this;
    }
    if (widget.error case final error? when oldWidget.error == null && MediaQuery.supportsAnnounceOf(context)) {
      SemanticsService.sendAnnouncement(View.of(context), error, Directionality.of(context));
    }
  }

  @override
  void dispose() {
    if (widget.controller?._state == this) widget.controller?._state = null;
    _turn.dispose();
    super.dispose();
  }

  void _openChanged(bool open) {
    if (!mounted || open == _open) return;
    setState(() => _open = open);
    final motion = FlMotion.of(context);
    final target = open ? 1.0 : 0.0;
    if (motion.reduced) {
      _turn.value = target;
    } else {
      _turn.driveTo(target, FlSpringMotion(motion.tokens.indicator));
    }
  }

  Future<void> _show() async {
    switch (widget.presentation) {
      case FlSelectPresentation.popover:
        _popover.open();
      case FlSelectPresentation.sheet:
        _openChanged(true);
        await showFlSheet<void>(
          context,
          label: widget.label,
          barrierLabel: widget.barrierLabel!,
          title: widget.listLabel,
          builder: (context) {
            _route = context;
            return _List(select: widget, close: _close, listLabel: false);
          },
        );
        _route = null;
        _openChanged(false);
      case FlSelectPresentation.dialog:
        _openChanged(true);
        await showFlDialog<void>(
          context: context,
          barrierLabel: widget.barrierLabel,
          builder: (context) {
            _route = context;
            return _DialogPanel(select: widget, close: _close);
          },
        );
        _route = null;
        _openChanged(false);
    }
  }

  void _close() {
    switch (widget.presentation) {
      case FlSelectPresentation.popover:
        _popover.close();
      case FlSelectPresentation.sheet || FlSelectPresentation.dialog:
        if (_route case final route? when route.mounted) {
          Navigator.of(route).pop();
        }
    }
  }

  @override
  Widget build(BuildContext context) {
    final trigger = _Trigger(
      select: widget,
      open: _open,
      turn: _turn,
      onPressed: widget.selection._enabled ? (_open ? _close : _show) : null,
    );
    final theme = FlScope.of(context);
    if (widget.presentation != FlSelectPresentation.popover) {
      return _labelled(theme, trigger);
    }
    return _labelled(
      theme,
      FlPopover(
        controller: _popover,
        label: widget.label,
        offset: theme.size.selectOffset,
        matchAnchorWidth: true,
        fitBesideAnchor: true,
        // Izydesk's listbox: 6 around the options.
        padding: EdgeInsetsDirectional.all(theme.space.xs * 1.5),
        onOpenChanged: _openChanged,
        content: _List(select: widget, close: _close, listLabel: true),
        child: trigger,
      ),
    );
  }

  /// [field] under its visible label when [FlSelect.showLabel], the trigger
  /// reading the label itself.
  Widget _labelled(FlTheme theme, Widget field) {
    final helper = widget.helper;
    final error = widget.error;
    if (!widget.showLabel && helper == null && error == null) return field;
    final disabled = !widget.selection._enabled;
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      spacing: flFieldGap(theme),
      children: [
        if (widget.showLabel)
          Align(
            alignment: AlignmentDirectional.centerStart,
            child: ExcludeSemantics(
              child: FlLabel(widget.label, disabled: disabled, required: widget.required),
            ),
          ),
        field,
        // Tight around their text, like a text field's: at full width, the
        // contrast check took the trigger's shadow as their background.
        if (error != null)
          Align(
            alignment: AlignmentDirectional.centerStart,
            child: ExcludeSemantics(
              // Read in the trigger's hint and announced where the platform
              // can; a live region elsewhere (Android).
              excluding: MediaQuery.supportsAnnounceOf(context),
              child: FlFieldError(error, invalid: true),
            ),
          )
        else if (helper != null)
          Align(
            alignment: AlignmentDirectional.centerStart,
            child: ExcludeSemantics(child: FlDescription(helper, invalid: false, disabled: disabled)),
          ),
      ],
    );
  }
}

/// The trigger (`Select.Trigger` in its `default` variant).
class _Trigger<T> extends StatelessWidget {
  const _Trigger({required this.select, required this.open, required this.turn, required this.onPressed});

  final FlSelect<T> select;
  final bool open;
  final Animation<double> turn;
  final VoidCallback? onPressed;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final colors = theme.colors;
    final radius = theme.shape.radiusControl;
    final chosen = [
      for (final option in select.options)
        if (select.selection._contains(option.value)) option.label,
    ];
    final display = chosen.isEmpty ? null : chosen.join(', ');
    // The chosen option's leading, in a single selection only: several
    // would not fit beside their labels.
    final leading = switch (select.selection) {
      FlSingleSelection(:final value?) => select.options.where((option) => option.value == value).firstOrNull?.leading,
      _ => null,
    };
    final announces = MediaQuery.supportsAnnounceOf(context);
    final described = select.error == null ? select.helper : (announces ? select.error : null);
    final hint = [?(display == null ? select.placeholder : null), ?described].join('\n');
    return FlPressable(
      onPressed: onPressed,
      borderRadius: BorderRadius.circular(radius),
      scaleOnPress: false,
      overlay: FlPressOverlay.none,
      expanded: open,
      builder: (context, states) => Semantics(
        label: select.label,
        value: display,
        hint: hint.isEmpty ? null : hint,
        isRequired: select.required ? true : null,
        excludeSemantics: true,
        child: Opacity(
          opacity: states.contains(WidgetState.disabled) ? theme.size.disabledOpacity : 1,
          // Open, the trigger carries the focus ring, like a field being typed in.
          child: FlInputFrame(
            variant: select.variant,
            outline: select.error == null ? null : colors.danger,
            focused: open,
            hovered: states.contains(WidgetState.hovered),
            // Izydesk's `.input.select-trigger`: the control height, `textSm`.
            child: ConstrainedBox(
              constraints: BoxConstraints(minHeight: theme.controlHeight),
              child: Padding(
                padding: EdgeInsetsDirectional.symmetric(horizontal: theme.space.md, vertical: theme.space.xs),
                child: Row(
                  spacing: theme.space.sm,
                  children: [
                    if (leading != null) _leading(context, leading),
                    Expanded(
                      child: flPopoverText(
                        context,
                        FlTextRole.textSm,
                        color: display == null ? colors.fieldPlaceholder : colors.onSurface,
                        child: Text(display ?? select.placeholder, maxLines: 1, overflow: TextOverflow.ellipsis),
                      ),
                    ),
                    RotationTransition(
                      turns: turn.drive(Tween(begin: 0, end: -0.5)),
                      child: FlIcon(
                        FlIcons.chevronDown,
                        size: _iconScale(context, theme.size.indicatorIcon),
                        color: colors.onSurfaceMuted,
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

/// The option list, with its title (`Select.ListLabel`) when [listLabel] is
/// true. It scrolls within the space its panel leaves it; the up and down
/// arrows move from one option to the next, Home and End go to the first
/// and the last.
class _List<T> extends StatefulWidget {
  const _List({required this.select, required this.close, required this.listLabel});

  final FlSelect<T> select;
  final VoidCallback close;
  final bool listLabel;

  @override
  State<_List<T>> createState() => _ListState<T>();
}

class _ListState<T> extends State<_List<T>> {
  final FocusNode _options = FocusNode(debugLabel: 'FlSelect', skipTraversal: true, canRequestFocus: false);

  @override
  void dispose() {
    _options.dispose();
    super.dispose();
  }

  /// [step] options past the focused one, clamped at the ends; the first or
  /// the last when [step] is infinite.
  void _move(int step) {
    final nodes = _options.traversalDescendants.where((node) => node.canRequestFocus).toList();
    if (nodes.isEmpty) return;
    final at = nodes.indexWhere((node) => node.hasPrimaryFocus);
    final next = switch (step) {
      < -1 => 0,
      > 1 => nodes.length - 1,
      _ => at < 0 ? 0 : (at + step).clamp(0, nodes.length - 1),
    };
    nodes[next].requestFocus();
  }

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final select = widget.select;
    final title = select.listLabel;
    return CallbackShortcuts(
      bindings: {
        const SingleActivator(LogicalKeyboardKey.arrowDown): () => _move(1),
        const SingleActivator(LogicalKeyboardKey.arrowUp): () => _move(-1),
        const SingleActivator(LogicalKeyboardKey.home): () => _move(-2),
        const SingleActivator(LogicalKeyboardKey.end): () => _move(2),
      },
      child: Focus(
        focusNode: _options,
        child: SingleChildScrollView(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              if (widget.listLabel && title != null)
                Padding(
                  // `px-2 py-1.5`.
                  padding: EdgeInsetsDirectional.symmetric(horizontal: theme.space.sm, vertical: theme.space.xs * 1.5),
                  child: Semantics(
                    header: true,
                    child: flPopoverText(
                      context,
                      FlTextRole.textSm,
                      color: theme.colors.onSurfaceMuted,
                      weight: theme.tokens.typography.weight.medium,
                      child: Text(title),
                    ),
                  ),
                ),
              for (final option in select.options)
                FlSelectOptionRow(
                  option: option,
                  selected: select.selection._contains(option.value),
                  onPressed: select.selection._enabled && option.enabled
                      ? () {
                          select.selection._choose(option.value);
                          if (select.selection._closesOnChoice) widget.close();
                        }
                      : null,
                ),
            ],
          ),
        ),
      ),
    );
  }
}

/// An option of a list (Izydesk's `.option`), the rows of [FlSelect] and of
/// `FlComboBox`: `space.md` around a `textSm` label, 44 high on one line, its
/// muted `textSm` description under it, a 16 `selected` check when it is
/// chosen, on `control` with `radiusControl` corners when pressed, hovered,
/// focused or [active]. The screen reader reads the label and the
/// description, and the chosen option as selected.
class FlSelectOptionRow<T> extends StatelessWidget {
  /// The row of [option].
  const FlSelectOptionRow({
    super.key,
    required this.option,
    required this.selected,
    required this.onPressed,
    this.active = false,
  });

  /// The option shown.
  final FlSelectOption<T> option;

  /// Chosen: the check, and `selected` for the screen reader.
  final bool selected;

  /// Called on tap; null with a disabled option or selection.
  final VoidCallback? onPressed;

  /// Designated from the keyboard while the focus stays elsewhere (the
  /// suggestion of a combo box): the hovered look.
  final bool active;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final colors = theme.colors;
    final description = option.description;
    return FlPressable(
      onPressed: onPressed,
      borderRadius: BorderRadius.circular(theme.shape.radiusControl),
      scaleOnPress: false,
      overlay: FlPressOverlay.none,
      focusRing: FlFocusRingPlacement.inside,
      builder: (context, states) => Semantics(
        label: description == null ? option.label : '${option.label}\n$description',
        selected: selected,
        excludeSemantics: true,
        child: Opacity(
          opacity: option.enabled ? 1 : theme.size.disabledOpacity,
          child: DecoratedBox(
            decoration: ShapeDecoration(
              color: active || states.any({WidgetState.pressed, WidgetState.hovered, WidgetState.focused}.contains)
                  ? colors.control
                  : null,
              shape: FlContinuousBorder(radius: theme.shape.radiusControl),
            ),
            // Izydesk's two-line `.option`: 6 above and below, the
            // description in `textXs`, still 44 high at least.
            child: ConstrainedBox(
              constraints: BoxConstraints(minHeight: theme.hitTarget),
              child: Padding(
                padding: EdgeInsetsDirectional.symmetric(
                  horizontal: theme.space.md,
                  vertical: description == null ? theme.space.md : theme.space.xs * 1.5,
                ),
                child: Row(
                  spacing: theme.space.sm,
                  children: [
                    if (option.leading case final leading?) _leading(context, leading),
                    Expanded(
                      child: Column(
                        mainAxisSize: MainAxisSize.min,
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          flPopoverText(context, FlTextRole.textSm, color: colors.onSurface, child: Text(option.label)),
                          if (description != null)
                            flPopoverText(
                              context,
                              FlTextRole.textXs,
                              color: colors.onSurfaceMuted,
                              child: Text(description),
                            ),
                        ],
                      ),
                    ),
                    if (selected)
                      // Centered in a 43.5-point line, Yoga snaps it to the
                      // pixel.
                      FlPixelSnap(
                        child: SizedBox.square(
                          dimension: _iconScale(context, theme.size.indicatorBox),
                          child: Center(
                            child: FlIcon(
                              FlIcons.check,
                              size: _iconScale(context, theme.size.indicatorIcon),
                              color: colors.selected,
                            ),
                          ),
                        ),
                      ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

/// The select's dialog (`select__dialog-content`): centered 20 from the
/// edges, `p-5`, `surfaceOverlay`, continuous `radiusCard` corners,
/// `overlay` shadow; the close button, the list title, then the options.
class _DialogPanel<T> extends StatelessWidget {
  const _DialogPanel({required this.select, required this.close});

  final FlSelect<T> select;
  final VoidCallback close;

  @override
  Widget build(BuildContext context) {
    final theme = FlScope.of(context);
    final radius = theme.shape.radiusCard;
    final padding = theme.space.xs * 5;
    final closeLabel = select.closeLabel;
    return LayoutBuilder(
      builder: (context, constraints) => Stack(
        children: [
          Positioned.fromRect(
            rect: flAnchorBounds(
              overlay: constraints.biggest,
              padding: MediaQuery.paddingOf(context),
              viewInsets: MediaQuery.viewInsetsOf(context),
              margin: padding,
            ),
            child: Center(
              child: ConstrainedBox(
                constraints: BoxConstraints(maxWidth: theme.size.dialogMaxWidth),
                child: Semantics(
                  container: true,
                  explicitChildNodes: true,
                  scopesRoute: true,
                  namesRoute: true,
                  label: select.label,
                  child: FlPixelSnap(
                    child: FlShadow(
                      layers: theme.shadows.overlay,
                      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radius)),
                      child: DecoratedBox(
                        decoration: ShapeDecoration(
                          color: theme.colors.surfaceOverlay,
                          shape: FlContinuousBorder(radius: radius),
                        ),
                        child: Padding(
                          padding: EdgeInsetsDirectional.all(padding),
                          child: Column(
                            mainAxisSize: MainAxisSize.min,
                            crossAxisAlignment: CrossAxisAlignment.stretch,
                            children: [
                              if (closeLabel != null)
                                Align(
                                  alignment: AlignmentDirectional.centerEnd,
                                  child: FlCloseButton(label: closeLabel, onPressed: close),
                                ),
                              Flexible(
                                child: _List(select: select, close: close, listLabel: true),
                              ),
                            ],
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

/// [extent] following the text scale, up to `size.iconMaxScale`.
double _iconScale(BuildContext context, double extent) =>
    MediaQuery.textScalerOf(context).clamp(maxScaleFactor: FlScope.of(context).size.iconMaxScale).scale(extent);

/// An option's [FlSelectOption.leading]: muted icons at the indicator size,
/// following the text scale; never read.
Widget _leading(BuildContext context, Widget leading) {
  final theme = FlScope.of(context);
  return ExcludeSemantics(
    child: IconTheme.merge(
      data: IconThemeData(color: theme.colors.onSurfaceMuted, size: _iconScale(context, theme.size.indicatorIcon)),
      child: leading,
    ),
  );
}

Arrows to move, Enter to open, Escape to close.