ashlarui

Combobox

A select you can search: one pick or several as chips, groups, clearing.

Combobox, as it looks in an app Combobox, as it looks in an app, dark
import 'package:flutter/widgets.dart';

import 'ui/combobox.dart';

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

  @override
  State<ComboboxDemo> createState() => _ComboboxDemoState();
}

class _ComboboxDemoState extends State<ComboboxDemo> {
  String? county;

  static const counties = [
    'Baringo', 'Bomet', 'Bungoma', 'Busia', 'Embu', 'Garissa', 'Kajiado', 'Kakamega', 'Kericho', 'Kiambu', //
    'Kilifi', 'Kisii', 'Kisumu', 'Kitui', 'Lamu', 'Machakos', 'Meru', 'Mombasa', 'Nairobi', 'Nakuru', 'Nyeri',
  ];

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 240,
    child: Combobox<String>(
      value: county,
      placeholder: 'Choose a county',
      searchPlaceholder: 'Search counties',
      semanticLabel: 'County',
      options: [for (final c in counties) ComboboxOption(c, c)],
      onChanged: (v) => setState(() => county = v),
    ),
  );
}

Try its options

Each option its constructor takes, to change and see; the code below it is what you chose, to copy.

Combobox with controls, as it looks in an app Combobox with controls, as it looks in an app, dark

Add it

In your Flutter project:

$ ashlar add combobox

It writes theme.dart, palette.dart, group.dart, pressable.dart, popover.dart, selection.dart, input.dart, button.dart, dialog.dart, command.dart and combobox.dart into lib/ui, keeping any you already have, and adds ashlar_icons to pubspec.yaml. It needs pressable, popover, command and ashlar_icons, which come with it.

Examples

Other ways to use it, each running live, with its code.

Several

Picks as chips, each with a remove button.

Several, as it looks in an app Several, as it looks in an app, dark
// Several: Picks as chips, each with a remove button.
import 'package:flutter/widgets.dart';

import 'ui/combobox.dart';

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

  @override
  State<ComboboxMultipleExample> createState() => _ComboboxMultipleExampleState();
}

class _ComboboxMultipleExampleState extends State<ComboboxMultipleExample> {
  var picked = {'nairobi', 'kisumu'};

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 360,
    child: Combobox<String>.multiple(
      values: picked,
      placeholder: 'Add counties',
      onValuesChanged: (v) => setState(() => picked = v),
      options: const [
        ComboboxOption('nairobi', 'Nairobi'),
        ComboboxOption('mombasa', 'Mombasa'),
        ComboboxOption('kisumu', 'Kisumu'),
        ComboboxOption('nakuru', 'Nakuru'),
      ],
    ),
  );
}

Clearable, in groups

A clear button, and options under headings.

Clearable, in groups, as it looks in an app Clearable, in groups, as it looks in an app, dark
// Clearable, in groups: A clear button, and options under headings.
import 'package:flutter/widgets.dart';

import 'ui/combobox.dart';

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

  @override
  State<ComboboxClearExample> createState() => _ComboboxClearExampleState();
}

class _ComboboxClearExampleState extends State<ComboboxClearExample> {
  String? lang = 'dart';

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 300,
    child: Combobox<String>(
      value: lang,
      onChanged: (v) => setState(() => lang = v),
      onCleared: () => setState(() => lang = null),
      options: const [
        ComboboxOption('dart', 'Dart', group: 'Apps'),
        ComboboxOption('kotlin', 'Kotlin', group: 'Apps'),
        ComboboxOption('go', 'Go', group: 'Servers'),
        ComboboxOption('rust', 'Rust', group: 'Servers', keywords: ['cargo']),
      ],
    ),
  );
}

API

Read from the source above: each parameter with its type, its default, and what it is for.

ComboboxOptionclass

One choice in a Combobox. Options with the same group are listed together under it.

Parameter Type Default What it is for
valuepositional T The value it stands for.
labelpositional String What the option says.
keywords List<String> const [] Other words that find it when typed.
enabled bool true False fades it and stops it being picked.
groupString?The heading it sits under.
iconWidget?Before the label.

Comboboxclass

A Select you can search: it opens a list with a search box, for more options than fit a plain list. Combobox.multiple picks several, shown as chips that a tap removes.

Combobox(…)

Parameter Type Default What it is for
valuerequired T? The value chosen, or null.
optionsrequired List<ComboboxOption<T>> What can be chosen, in order.
onChangedrequired ValueChanged<T>? Null disables it.
onCleared VoidCallback? Shows a button that clears the choice, and is called by it.
placeholder String 'Choose…' Faint text shown while it is empty.
searchPlaceholder String 'Search…' Faint text in the search field.
emptyText String 'Nothing found.' Shown when nothing matches what was typed.
error bool false Draws it as invalid.
semanticLabel String? What a screen reader calls it when its visible text does not say, as for an icon alone.

Combobox.multiple(…)

Parameter Type Default What it is for
valuesrequired Set<T> The values chosen, for a combobox that takes several.
optionsrequired List<ComboboxOption<T>> What can be chosen, in order.
onValuesChangedrequired ValueChanged<Set<T>>? Told the new values whenever one changes.
placeholder String 'Choose…' Faint text shown while it is empty.
searchPlaceholder String 'Search…' Faint text in the search field.
emptyText String 'Nothing found.' Shown when nothing matches what was typed.
error bool false Draws it as invalid.
semanticLabel String? What a screen reader calls it when its visible text does not say, as for an icon alone.

Source

What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add will not write over it.

lib/ui/combobox.dart 389 lines
// Ashlar UI: Combobox. Copied into your app by `ashlar add combobox`; it is yours to change.

import 'package:ashlar_icons/ashlar_icons.dart';
import 'package:flutter/widgets.dart';

import 'command.dart';
import 'popover.dart';
import 'pressable.dart';
import 'theme.dart';

/// One choice in a [Combobox]. Options with the same [group] are listed together under it.
@immutable
class ComboboxOption<T> {
  const ComboboxOption(
    this.value,
    this.label, {
    this.keywords = const [],
    this.enabled = true,
    this.group,
    this.icon,
  });

  /// The value it stands for.
  final T value;

  /// What the option says.
  final String label;

  /// Other words that find it when typed.
  final List<String> keywords;

  /// False fades it and stops it being picked.
  final bool enabled;

  /// The heading it sits under.
  final String? group;

  /// Before the label.
  final Widget? icon;
}

/// A Select you can search: it opens a list with a search box, for more options than fit a
/// plain list. [Combobox.multiple] picks several, shown as chips that a tap removes.
///
/// ```dart
/// Combobox<String>(
///   value: county,
///   placeholder: 'Choose a county',
///   options: [for (final c in counties) ComboboxOption(c.code, c.name)],
///   onChanged: (v) => setState(() => county = v),
///   onCleared: () => setState(() => county = null),
/// )
/// Combobox<String>.multiple(values: tags, options: allTags, onValuesChanged: (v) => setState(() => tags = v))
/// ```
class Combobox<T> extends StatefulWidget {
  const Combobox({
    super.key,
    required this.value,
    required this.options,
    required this.onChanged,
    this.onCleared,
    this.placeholder = 'Choose…',
    this.searchPlaceholder = 'Search…',
    this.emptyText = 'Nothing found.',
    this.error = false,
    this.semanticLabel,
  }) : values = null,
       onValuesChanged = null;

  const Combobox.multiple({
    super.key,
    required Set<T> this.values,
    required this.options,
    required this.onValuesChanged,
    this.placeholder = 'Choose…',
    this.searchPlaceholder = 'Search…',
    this.emptyText = 'Nothing found.',
    this.error = false,
    this.semanticLabel,
  }) : value = null,
       onChanged = null,
       onCleared = null;

  /// The value chosen, or null.
  final T? value;

  /// The values chosen, for a combobox that takes several.
  final Set<T>? values;

  /// What can be chosen, in order.
  final List<ComboboxOption<T>> options;

  /// Null disables it.
  final ValueChanged<T>? onChanged;

  /// Told the new values whenever one changes.
  final ValueChanged<Set<T>>? onValuesChanged;

  /// Shows a button that clears the choice, and is called by it.
  final VoidCallback? onCleared;

  /// Faint text shown while it is empty.
  final String placeholder;

  /// Faint text in the search field.
  final String searchPlaceholder;

  /// Shown when nothing matches what was typed.
  final String emptyText;

  /// Draws it as invalid.
  final bool error;

  /// What a screen reader calls it when its visible text does not say, as for an icon
  /// alone.
  final String? semanticLabel;

  @override
  State<Combobox<T>> createState() => _ComboboxState<T>();
}

class _ComboboxState<T> extends State<Combobox<T>> {
  final _menu = MenuController();
  final _focus = FocusNode();

  bool get _many => widget.values != null;
  bool get _enabled =>
      _many ? widget.onValuesChanged != null : widget.onChanged != null;

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

  void _toggle(T v) {
    final next = {...widget.values!};
    next.contains(v) ? next.remove(v) : next.add(v);
    widget.onValuesChanged?.call(next);
  }

  List<CommandGroup> get _groups {
    final groups = <String?, List<ComboboxOption<T>>>{};
    for (final o in widget.options) {
      (groups[o.group] ??= []).add(o);
    }
    bool chosen(T v) => _many ? widget.values!.contains(v) : widget.value == v;
    return [
      for (final MapEntry(key: heading, value: options) in groups.entries)
        CommandGroup(heading: heading, [
          for (final o in options)
            CommandItem(
              o.label,
              keywords: o.keywords,
              enabled: o.enabled,
              leading: Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  SizedBox(
                    width: 16,
                    child: chosen(o.value)
                        ? const AshlarIcon(AshlarIconsRoundedStroke.check)
                        : null,
                  ),
                  if (o.icon != null) ...[const SizedBox(width: 8), o.icon!],
                ],
              ),
              onSelected: () {
                if (_many) {
                  // Several are picked in one go: the list stays open.
                  _toggle(o.value);
                } else {
                  _menu.close();
                  widget.onChanged?.call(o.value);
                }
              },
            ),
        ]),
    ];
  }

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final chosen = _many
        ? [
            for (final o in widget.options)
              if (widget.values!.contains(o.value)) o,
          ]
        : [
            for (final o in widget.options)
              if (o.value == widget.value) o,
          ];
    final shown = chosen.isEmpty
        ? widget.placeholder
        : chosen.map((o) => o.label).join(', ');
    return RawMenuAnchor(
      controller: _menu,
      childFocusNode: _focus,
      onClose: _focus.requestFocus,
      overlayBuilder: (context, info) => PopoverPanel(
        info: info,
        // The search field takes all the width it is given, so the width is set: the
        // trigger's, and never narrower than 200.
        width: info.anchorRect.width < 200 ? 200 : info.anchorRect.width,
        child: Command(
          placeholder: widget.searchPlaceholder,
          emptyText: widget.emptyText,
          groups: _groups,
        ),
      ),
      child: Pressable(
        focusNode: _focus,
        onPressed: _enabled
            ? () => _menu.isOpen ? _menu.close() : _menu.open()
            : null,
        semanticLabel: widget.semanticLabel,
        semanticValue: widget.semanticLabel == null ? null : shown,
        builder: (context, state) {
          final ring = widget.error ? c.destructive : c.ring;
          return Opacity(
            opacity: state.enabled ? 1 : .5,
            child: AnimatedContainer(
              duration: t.duration,
              constraints: BoxConstraints(minHeight: t.control(36)),
              padding: EdgeInsetsDirectional.only(
                start: _many && chosen.isNotEmpty ? 4 : 12,
                end: 10,
                top: 3,
                bottom: 3,
              ),
              decoration: BoxDecoration(
                color: state.hovered && !_many ? c.accent : c.background,
                borderRadius: BorderRadius.circular(t.radiusMd),
                border: Border.all(
                  color: widget.error
                      ? c.destructive
                      : (state.focused ? c.ring : c.input),
                ),
                boxShadow: [
                  if (state.focused)
                    BoxShadow(
                      color: ring.withValues(alpha: widget.error ? .2 : .5),
                      spreadRadius: 3,
                    ),
                  ...t.shadowXs,
                ],
              ),
              child: Row(
                children: [
                  Expanded(
                    child: _many && chosen.isNotEmpty
                        ? Wrap(
                            spacing: 4,
                            runSpacing: 4,
                            children: [
                              for (final o in chosen)
                                _Chip(
                                  label: o.label,
                                  onRemove: _enabled
                                      ? () => _toggle(o.value)
                                      : null,
                                ),
                            ],
                          )
                        : Text(
                            shown,
                            maxLines: 1,
                            overflow: TextOverflow.ellipsis,
                            style: t.text.sm.copyWith(
                              color: chosen.isEmpty
                                  ? c.mutedForeground
                                  : c.foreground,
                              fontWeight: chosen.isEmpty
                                  ? null
                                  : FontWeight.w500,
                            ),
                          ),
                  ),
                  const SizedBox(width: 8),
                  if (widget.onCleared != null &&
                      widget.value != null &&
                      _enabled)
                    _Clear(onPressed: widget.onCleared!)
                  else
                    AshlarIcon(
                      AshlarIconsRoundedStroke.chevronsUpDown,
                      size: 16,
                      color: c.mutedForeground.withValues(alpha: .5),
                    ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}

/// One picked option in a multiple combobox, with a button that removes it.
class _Chip extends StatelessWidget {
  const _Chip({required this.label, required this.onRemove});

  final String label;
  final VoidCallback? onRemove;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    return Container(
      // A height at least: larger text makes it taller rather than being cut off.
      constraints: const BoxConstraints(minHeight: 26),
      padding: const EdgeInsetsDirectional.only(start: 8, end: 2),
      decoration: BoxDecoration(
        color: c.secondary,
        borderRadius: BorderRadius.circular(t.radiusSm),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            label,
            style: t.text.xs.copyWith(
              color: c.secondaryForeground,
              fontWeight: FontWeight.w500,
            ),
          ),
          if (onRemove != null)
            Pressable(
              onPressed: onRemove,
              semanticLabel: 'Remove $label',
              builder: (context, state) => Container(
                width: 20,
                height: 20,
                margin: const EdgeInsetsDirectional.only(start: 2),
                alignment: Alignment.center,
                decoration: BoxDecoration(
                  color: state.hovered
                      ? c.background.withValues(alpha: .6)
                      : c.background.withValues(alpha: 0),
                  borderRadius: BorderRadius.circular(t.radiusSm),
                ),
                child: AshlarIcon(
                  AshlarIconsRoundedStroke.x,
                  size: 12,
                  color: c.secondaryForeground,
                ),
              ),
            )
          else
            const SizedBox(width: 6),
        ],
      ),
    );
  }
}

class _Clear extends StatelessWidget {
  const _Clear({required this.onPressed});

  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    return Pressable(
      onPressed: onPressed,
      semanticLabel: 'Clear',
      builder: (context, state) => Container(
        width: 20,
        height: 20,
        alignment: Alignment.center,
        decoration: BoxDecoration(
          color: state.hovered ? c.accent : c.accent.withValues(alpha: 0),
          borderRadius: BorderRadius.circular(t.radiusSm),
        ),
        child: AshlarIcon(
          AshlarIconsRoundedStroke.x,
          size: 14,
          color: c.mutedForeground,
        ),
      ),
    );
  }
}