fl_select 0.15.0+1 copy "fl_select: ^0.15.0+1" to clipboard
fl_select: ^0.15.0+1 copied to clipboard

A Flutter package for building selection UIs (e.g. filter bar) on a composable architecture of entry points, delegates and layouts, A2UI-ready, with search, theming, and i18n.

A Flutter package for building selection UIs (e.g. filter bar) on a composable architecture of entry points, delegates, and layouts. A2UI-ready, with single & multiple selection, sync/async loading, search filtering, theming, and i18n built in.

Playground

Highlights

Agent Skills #

This package ships an agent skill for the Dart Skills CLI at skills/fl_select-code-generation, so AI coding agents (Claude Code, Cursor, Codex, Windsurf, GitHub Copilot, etc.) use fl_select correctly — accurate APIs, no hallucinated parameters.

It is discovered automatically in any project that depends on fl_select:

dart run skills@ get

For rendering agent-authored JSON through the GenUI (A2UI) SDK, see the companion package fl_select_genui, which ships its own skill the same way.

Features #

A composable architecture of entry points, delegates, and layouts — any delegate plugs into any entry point, and any layout into any category.

  • 5 entry points: inline, button, bar, dialog, bottom sheet — SelectView, PopupSelectButton, PopupSelectBar, showSelect, showModalBottomSelect.
  • 7 delegates: ListSelectDelegate, GridSelectDelegate, WrapSelectDelegate, CascadingSelectDelegate, TabNavSelectDelegate, SideNavSelectDelegate, ExpandableSelectDelegate.
  • 5 category layouts: list, grid, wrap, range slider, counter.
  • Sync & async data loading.
  • Single & multiple selection.
  • Text, Range, Category, "Any", and custom entries.
  • Custom item view, skeleton, error state, and action bar.
  • Result serialization to URL query parameters.
  • Search filtering.
  • Light & dark theming with rich styles.
  • Built-in i18n in 10 languages.
  • Accessibility support for screen readers.

Getting started #

Install

flutter pub add fl_select

Import

import 'package:fl_select/fl_select.dart';

Usage #

Delegates

A delegate controls both data loading and how the body is rendered, and works with every entry point above. Flat delegates (ListSelectDelegate, GridSelectDelegate, WrapSelectDelegate) render flat leaves created with SelectTextEntry(...) / SelectRangeEntry(...); CascadingSelectDelegate renders a multi-level cascade of unlimited depth (category -> child -> grandchild -> ...) and ignores category.layout; the three two-level category delegates (TabNavSelectDelegate, SideNavSelectDelegate, ExpandableSelectDelegate) render a tree of SelectCategoryEntry roots whose children follow category.layout (list / grid / wrap / range slider / counter):

ListSelectDelegate GridSelectDelegate WrapSelectDelegate CascadingSelectDelegate TabNavSelectDelegate SideNavSelectDelegate ExpandableSelectDelegate
ListSelectDelegate GridSelectDelegate WrapSelectDelegate CascadingSelectDelegate TabNavSelectDelegate SideNavSelectDelegate ExpandableSelectDelegate

SelectEntry

Entries form a tree. SelectCategoryEntry is the root (a category) and SelectChildEntry is any non-root node. Nesting is the only wiring: an entry's parent is the entry that holds it, so you nest entries with children (plus header / footer and layout on a category).

Entry Purpose
SelectCategoryEntry Root node. Takes children, selectionMode, header/footer and layout.
SelectTextEntry A plain text leaf, flat or nested (pass children). Use .any(...) for the "Any" (clear) entry.
SelectRangeEntry<N, E> A numeric range leaf (min/max, snapped by divisions). Use .any(...) for "Any" and .custom(...) for a user-input range. SelectIntEntry<E> is a handy alias for SelectRangeEntry<int, E>.

Sibling ids must be distinct — within the top level, the children of one node, and the children of a header / footer. Binding reports a duplicate id as an ArgumentError, so keep them apart; the same id may still be reused under different parents (the built-in "Any" and "custom" entries rely on that).

Selection is controlled by SelectionMode (single by default, or multiple), set on a SelectCategoryEntry (per category) or on the delegate (fallback). In multiple-selection mode, an entry with immediate: true applies on tap and skips the action bar.

Entries load asynchronously via entriesLoader, which returns a Future<SelectEntries> where SelectEntries is Set<SelectEntry>.

Flat data for ListSelectDelegate / GridSelectDelegate / WrapSelectDelegate — SelectTextEntry(...) creates a flat leaf, and SelectRangeEntry.custom() adds a user-input range:

SelectEntries get listData => {
      SelectTextEntry(id: 'a', name: 'Kiwi'),
      // ...
    };

SelectEntries get gridData => {
      SelectIntEntry.custom(), // user-input min/max
      SelectIntEntry(id: 'a', name: '\$0-\$25', min: 0, max: 25),
      // ...
    };

SelectEntries get wrapData => {
      SelectTextEntry(id: 'a', name: 'Tiger'),
      // ...
    };

Two-level (category) data for TabNavSelectDelegate / SideNavSelectDelegate / ExpandableSelectDelegate — every category picks its own selectionMode, layout, and optional header / footer:

SelectEntries get multiCategoryData => {
      SelectCategoryEntry(
        id: 'cate1',
        name: 'Sport',
        children: {
          SelectTextEntry(id: 'a', name: 'Football'),
          // ...
        },
        selectionMode: SelectionMode.single,
        footer: SelectTextEntry(
          id: 'c1-f',
          name: 'Letter Grade',
          children: {
            SelectTextEntry(id: 'f-a', name: 'A'),
            // ...
          },
        ),
        footerSelectionMode: SelectionMode.single, // header: ... works the same
      ),
      SelectCategoryEntry(
        id: 'cate5',
        name: 'Price (Dollar)',
        children: {
          SelectRangeEntry(
            id: 'a',
            name: '\$0-\$2000000',
            min: 0,
            max: 2000000,
            divisions: 80,
          ),
          SelectRangeEntry.custom(),
        },
        selectionMode: SelectionMode.single,
        layout: const SelectRangeLayout(), // range slider
      ),
      // cate6 ('Counter') uses `layout: const SelectCounterLayout()` (stepper), etc.
    };

Multi-level (cascading) data for CascadingSelectDelegate (the shape of example/assets/cascading.json, a housing-transaction taxonomy) — nested children open one cascade column per level, at unlimited depth, and category.layout is ignored:

SelectEntries get cascadingData => {
      SelectCategoryEntry(
        id: 'residential',
        name: 'Residential',
        children: {
          SelectTextEntry(
            id: '11',
            name: 'Single-Family',
            children: {
              SelectTextEntry(
                id: '111',
                name: 'Rural',
                children: {
                  SelectTextEntry(id: '1111', name: 'RR'),
                  // ...
                },
              ),
              SelectTextEntry(
                id: '112',
                name: 'Urban',
                children: {
                  SelectTextEntry(id: '1121', name: 'SF-2'),
                  // ...
                },
              ),
            },
          ),
          // ...
        },
      ),
      // Commercial / Industrial / Special / Overlay ...
    };

An async loader (entriesLoader) — any Future<SelectEntries>; this one maps the decoded example/assets/cascading.json onto the tree shape above:

Future<SelectEntries> fetchCascadingData() async {
  // simulate a network delay
  await Future.delayed(const Duration(milliseconds: 350));
  final data = cascadingFromJson(await loadJsonData('cascading.json'));

  final SelectEntries entries = data
      .map(
        (category) => SelectCategoryEntry(
          id: category.id!,
          name: category.name!,
          selectionMode: SelectionMode.multiple,
          children: category.data
              ?.map(
                (child) => SelectTextEntry(
                  id: child.id!,
                  name: child.name!,
                  enabled: child.enabled ?? true,
                  children: child.data
                      ?.map(
                        (leaf) =>
                            SelectTextEntry(id: leaf.id!, name: leaf.name!),
                      )
                      .toSet(),
                ),
              )
              .toSet(),
        ),
      )
      .toSet();

  // One "Any" (clear) entry on top of every category
  for (final SelectEntry category in entries) {
    category.children?.insert(
      0,
      SelectTextEntry.any(name: 'Any', immediate: true),
    );
  }
  return entries;
}

loadJsonData reads the asset and cascadingFromJson decodes it into a small fromJson model — both from example/lib/entry_repository.dart. Every JSON level maps onto one children level (.map + children:).

For static data, skip the loader and pass the values directly — entries / selectedEntries / resetEntries are mutually exclusive with the loaders:

// Static data: no loader, no async — renders on the first frame
ListSelectDelegate(
  entries: listData,
  selectedEntries: {SelectTextEntry(id: 'a', name: 'Kiwi')},
  resetEntries: {SelectTextEntry(id: 'a', name: 'Kiwi')},
);

SelectView

SelectView embeds a select directly in a page or dialog body. Pass any delegate — it controls both loading and rendering.

SelectView(
  margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
  delegate: ListSelectDelegate(entries: listData),
  onChanged: (SelectEntries selected) {
    print('toQueryParameters: ${selected.toQueryParameters()}');
  },
);

SelectView(
  delegate: CascadingSelectDelegate(
    entriesLoader: fetchCascadingData,
    selectionMode: SelectionMode.multiple,
    sideBarTheme: const SelectSideBarTheme(width: 120),
  ),
  onChanged: (SelectEntries selected) {
    print('toQueryMap: ${selected.toQueryMap()}');
  },
);

SelectView

PopupSelectButton

Opens a select overlay on tap, like PopupMenuButton. It takes one selectDelegate, a label/child, and a required onApplied callback. Four variants: text (default), .elevated(...), .filled(...), and .outlined(...); use direction to open above the trigger.

PopupSelectButton(
  label: 'List',
  selectDelegate: ListSelectDelegate(entries: listData),
  onApplied: (selected) => print('toQueryMap: ${selected.toQueryMap()}'),
);

// Variants: .elevated(...) / .filled(...) / .outlined(...)
PopupSelectButton.elevated(
  label: 'Cascading',
  selectDelegate: CascadingSelectDelegate(
    entriesLoader: fetchCascadingData,
    selectionMode: SelectionMode.multiple,
  ),
  onApplied: (selected) => print('onApplied: $selected'),
);

// Category delegates: defaultLayout controls the children layout,
// direction opens the overlay above the trigger
PopupSelectButton(
  direction: PopupSelectDirection.above,
  label: 'TabNav',
  selectDelegate: TabNavSelectDelegate(
    defaultLayout: SelectGridLayout(crossAxisCount: 3, childAspectRatio: 3),
    entries: multiCategoryData,
    selectionMode: SelectionMode.multiple,
  ),
  onApplied: (selected) => print('onApplied: $selected'),
);

PopupSelectButton

PopupSelectBar

A tab bar (PreferredSizeWidget) that opens an overlay select when a tab is tapped. Provide tabs for the bar and a matching selectDelegates list (one per tab), plus the required onApplied callback. Results also arrive via onChanged / onReset.

PopupSelectBar(
  isScrollable: true,
  tabs: const [
    PopupTab(label: 'List'),
    PopupTab(label: 'Grid'),
    PopupTab(child: Icon(Icons.wrap_text)), // icon tab
    // ... one PopupTab per delegate
  ],
  selectDelegates: [
    ListSelectDelegate(entries: listData),
    GridSelectDelegate(entries: gridData, crossAxisCount: 3),
    WrapSelectDelegate(entries: wrapData, selectionMode: SelectionMode.multiple),
    // ... one delegate per tab, in the same order
  ],
  onApplied: (tabData, selected) {
    // tabData is the PopupTabData; selected is the SelectEntries
    print('toQueryMap: ${selected.toQueryMap()}');
  },
);

PopupSelectBar

showSelect

Shows a select in a modal dialog. Returns the selected SelectEntries when applied, or null when dismissed. In single-selection mode, tapping an item applies immediately; in multi-selection mode, "Apply" in the action bar confirms.

final SelectEntries? result = await showSelect(
  context: context,
  delegate: ListSelectDelegate(entries: listData),
  leading: const Icon(Icons.list),
  title: const Text('ListSelect'),
);

if (result != null) {
  print('toQueryParameters: ${result.toQueryParameters()}');
}

Any delegate works, and the header is configurable:

await showSelect(
  context: context,
  delegate: TabNavSelectDelegate(
    defaultLayout: SelectGridLayout(crossAxisCount: 2),
    entries: multiCategoryData,
    selectionMode: SelectionMode.multiple,
  ),
  title: const Text('TabNavSelect'),
  trailing: const CloseButton(),
  centerTitle: false,
);

showSelect

showModalBottomSelect

Shows a select in a modal bottom sheet built on Flutter's showModalBottomSheet. Same interaction as showSelect. Standard sheet parameters (isScrollControlled, isDismissible, enableDrag, showDragHandle, constraints, etc.) are forwarded.

final SelectEntries? result = await showModalBottomSelect(
  context: context,
  delegate: ListSelectDelegate(entries: listData),
  leading: const Icon(Icons.list),
  title: const Text('ListSelect'),
);

if (result != null) {
  print('toQueryParameters: ${result.toQueryParameters()}');
}

title / leading / trailing / centerTitle behave like the showSelect dialog header, plus the forwarded sheet parameters:

await showModalBottomSelect(
  context: context,
  delegate: SideNavSelectDelegate(
    defaultLayout: SelectWrapLayout(spacing: 12),
    entries: multiCategoryData,
    selectionMode: SelectionMode.multiple,
  ),
  title: const Text('SideNavSelect'),
);

showModalBottomSelect

Set searchEnabled: true on any delegate to render a search bar above the body. Typing filters the displayed entries (debounced 300 ms by default) while preserving the layout and selection state — canceling the search restores the original entries.

CascadingSelectDelegate(
  entriesLoader: fetchCascadingData,
  searchEnabled: true, // works on any delegate
  searchHintText: 'Search',
  searchDebounceDuration: const Duration(milliseconds: 300),
  // searchPredicate: (entry, query) => entry.name?.contains(query) == true,
);

The default predicate (defaultSelectSearchPredicate) matches SelectEntry.name case-insensitively; provide a custom searchPredicate to match id, extra, or any other field. Style the bar via searchBarTheme (SelectSearchBarTheme) on the delegate, or globally on SelectThemeData.searchBarTheme (see Theming).

search

Custom item builder

Every delegate except CascadingSelectDelegate accepts an itemBuilder that replaces each regular item's widget — list tile, grid tile or chip — entirely. The builder receives the entry, the current selected state, an onTap that you must wire to your own gesture handler (e.g. InkWell.onTap) so taps keep flowing through the library's normal selection logic, and the categoryId of the owning category (null on the flat delegates, so one builder can serve both):

ListSelectDelegate(
  entries: listData,
  itemBuilder: (context, entry, {required selected, required onTap, categoryId}) {
    return InkWell(
      onTap: onTap,
      child: Container(
        padding: const EdgeInsets.all(12),
        color: selected ? Theme.of(context).colorScheme.primaryContainer : null,
        child: Row(
          children: [
            if (selected) const Icon(Icons.check),
            const SizedBox(width: 8),
            Text(entry.name ?? ''),
          ],
        ),
      ),
    );
  },
);

Returning null falls back to the default item widget, so you can customize only some entries or categories while keeping the built-in visuals elsewhere. Custom range entries (SelectRangeEntry.custom) are not passed to the builder — they keep rendering as the built-in min/max input field — and range-slider / counter category layouts keep their built-in controls. The builder does not cover a category's header/footer chips.

Serializing selections

Selections arrive as a SelectEntries tree. Two extensions turn that tree into URL query parameters — each category contributes key/value pairs keyed by its own id with the deepest selected leaf ids as values; an "Any" leaf resolves to its parent id; a custom SelectRangeEntry formats as min-max:

final selected = await showSelect(context: context, delegate: ...);

// Map<String, List<String>>, mirroring Uri.queryParametersAll
final map = selected?.toQueryMap(); // {price: [0-100], more: [near_subway]}

// Or a ready-made query string
selected?.toQueryParameters(); // price=0-100&more=near_subway

// Multi-value layouts via SelectArrayFormat
selected?.toQueryParameters(arrayFormat: SelectArrayFormat.brackets); // more[]=a&more[]=b
selected?.toQueryParameters(arrayFormat: SelectArrayFormat.comma);    // more=a,b
selected?.toQueryParameters(arrayFormat: SelectArrayFormat.indices);  // more[0]=a
selected?.toQueryParameters(
  arrayFormat: SelectArrayFormat.delimited,
  delimiter: '|',
); // more=a|b (covers OpenAPI pipeDelimited / spaceDelimited)

Values are percent-encoded by default; pass encode: false when the caller handles encoding.

Theming

Per instance — delegates carry the styling: set selectedColor / onSelectedColor (or any finer-grained *Theme field) directly on a delegate. PopupSelectBar also accepts a single selectTheme that overrides the styling of every tab's delegate:

GridSelectDelegate(
  entries: gridData,
  selectedColor: Theme.of(context).colorScheme.primary,
  onSelectedColor: Theme.of(context).colorScheme.onPrimary,
  gridTileTheme: const SelectGridTileTheme(variant: SelectGridTileVariant.outlined),
);

PopupSelectBar(
  tabs: ...,
  selectDelegates: ...,
  selectTheme: SelectThemeData(Theme.of(context)), // overrides every tab's delegate
  onApplied: (tabData, selected) {},
);

Globally — wrap SelectTheme above the Navigator so inline views, dialogs, sheets and popup overlays are all covered. Delegate-level theme fields merge field-wise on top of it; use SelectTheme.merge to layer a partial SelectThemeData over the ambient one:

MaterialApp(
  builder: (context, child) => SelectTheme.merge(
    data: SelectThemeData(Theme.of(context), selectedColor: Colors.teal),
    child: child!,
  ),
  home: const HomePage(),
);

PopupSelectBar / PopupSelectButton are themed through ThemeData extensions instead — register PopupSelectBarTheme and PopupSelectButtonTheme so every bar/button picks them up automatically:

MaterialApp(
  theme: ThemeData(
    colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
    extensions: [
      PopupSelectBarTheme(
        height: 48,
        labelColor: Colors.blue,
        selectTheme: SelectThemeData(ThemeData.light()),
      ),
      PopupSelectButtonTheme(
        backgroundColor: Colors.green,
        foregroundColor: Colors.white,
      ),
    ],
  ),
);

Or derive the extensions from the active theme in builder, so they follow light/dark mode and the app's seed color:

MaterialApp(
  theme: lightTheme,
  darkTheme: darkTheme,
  builder: (context, child) {
    final baseTheme = Theme.of(context);
    return Theme(
      data: baseTheme.copyWith(
        extensions: <ThemeExtension<dynamic>>[
          PopupSelectBarTheme(selectTheme: SelectThemeData(baseTheme)),
          PopupSelectButtonTheme(
            backgroundColor: baseTheme.colorScheme.primary,
            foregroundColor: baseTheme.colorScheme.onPrimary,
          ),
        ],
      ),
      child: child ?? const SizedBox.shrink(),
    );
  },
);

Internationalization

Add SelectLocalizationsDelegate() to your MaterialApp. It ships translations for de, en, es, fr, id, ja, ko, pt, vi, and zh (Hans/Hant), localizing the "Apply" / "Reset" / "Multiple" labels automatically.

const localizationsDelegates = <LocalizationsDelegate>[
  GlobalMaterialLocalizations.delegate,
  GlobalWidgetsLocalizations.delegate,
  SelectLocalizationsDelegate(),
];

const supportedLocales = SelectLocalizationsDelegate.supportedLocales;

MaterialApp(
  localizationsDelegates: localizationsDelegates,
  supportedLocales: supportedLocales,
  home: const HomePage(),
);

To override the labels for a single delegate, set applyText / resetText on it directly.

Accessibility #

fl_select provides comprehensive accessibility support for screen readers and assistive technologies. All components are designed to work out of the box with screen readers like TalkBack (Android) and VoiceOver (iOS).

What's Supported #

  • Screen reader announcements: Panel open/close, apply/reset actions, and selection changes are automatically announced
  • Semantic labels: All interactive elements have proper semantic information (buttons, selected states, values)
  • Keyboard navigation: Full keyboard support for all components
  • Localized labels: Accessibility announcements use the same i18n system as the UI (10 languages supported)

How to Use #

No additional setup is required — accessibility is enabled by default. Simply use the components as you normally would:

PopupSelectButton(
  label: 'Select options',
  selectDelegate: ListSelectDelegate(entries: listData),
  onApplied: (selected) => print('Selected: $selected'),
);

For custom item builders, wrap your widgets with Semantics to maintain accessibility:

itemBuilder: (context, entry, {required selected, required onTap, categoryId}) {
  return Semantics(
    button: true,
    selected: selected,
    label: entry.name,
    child: InkWell(
      onTap: onTap,
      child: YourCustomWidget(entry: entry),
    ),
  );
}
2
likes
150
points
705
downloads

Documentation

API reference

Publisher

verified publisherzeaon.dev

Weekly Downloads

A Flutter package for building selection UIs (e.g. filter bar) on a composable architecture of entry points, delegates and layouts, A2UI-ready, with search, theming, and i18n.

Repository (GitHub)
View/report issues

Topics

#select #multiselect #dropdown #cascade #widget

License

MIT (license)

Dependencies

collection, flutter

More

Packages that depend on fl_select