Skip to content

Dropdowns & Menus

CDX provides four overlay-based selection widgets that share the same trigger system, overlay positioning, and dismissal model:

WidgetSelectsNotes
Dropdown<T>One valueSync, async-once, or query-driven options
MultiSelectDropdown<T>Set of valuesCheckbox panel with search + select-shown / clear-all
ActionMenuFires callbacksThree-dot menus, user menus — uses ActionMenuItem sealed
DropdownFormField<T>One value (form-integrated)FormField adapter for Dropdown
dart
Dropdown<String>(
  trigger: const DropdownTrigger.field(label: 'Country'),
  options: const [
    DropdownOption(value: 'us', title: 'United States'),
    DropdownOption(value: 'uk', title: 'United Kingdom'),
    DropdownOption(value: 'de', title: 'Germany'),
  ],
  value: selectedCountry,
  onChanged: (v) => setState(() => selectedCountry = v),
  searchable: true,
  clearable: true,
)

Three option modes (mutually exclusive)

ModeParamBehavior
Staticoptions:Renders as-is; client-side filter when searchable: true
One-shot asyncasyncOptions:Fetched on first open, cached. Use controller.refresh() to refetch
Query-drivenonQueryChanged: (query, pageSize) async => ...Server-side search; debounced (queryDebounceMs, default 300 ms)

Three trigger variants

dart
DropdownTrigger.field(label: 'Country', placeholder: 'Pick one')   // Form-style outlined input
DropdownTrigger.button(label: 'Site', icon: FluentIcons.location_24_regular)  // Toolbar button
DropdownTrigger.icon(icon: FluentIcons.filter_24_regular, tooltip: 'Filter')  // Icon-only

For full control use triggerBuilder:

dart
Dropdown<Site>(
  triggerBuilder: (context, state) => MyCustomTrigger(
    label: state.selectedLabel ?? 'Choose site',
    isOpen: state.isOpen,
  ),
  options: siteOptions,
  ...
)

For async dropdowns with a pre-selected value, pass resolveSelectedLabel so the trigger can show a friendly name before that option appears in the loaded page:

dart
Dropdown<String>(
  trigger: const DropdownTrigger.field(label: 'Area'),
  value: selectedAreaId,
  onQueryChanged: (query, pageSize) => api.searchAreas(query, limit: pageSize),
  resolveSelectedLabel: (id) => api.areaName(id),
  onChanged: (id) => setState(() => selectedAreaId = id),
)

Passed to triggerBuilder and exposed via DropdownController.stateListenable:

dart
class DropdownState<T> {
  final T? selectedValue;
  final String? selectedLabel;
  final bool isOpen;
  final bool isHovered;
  final bool isLoading;
  final bool hasError;
}

Imperative API:

dart
final controller = DropdownController<String>();
controller.open();
controller.close();
controller.toggle();
controller.select('us', label: 'United States');
controller.clear();
controller.refresh();             // re-fetch async options
controller.setLoading(true);

Section divider rendered inline with the option list (non-tappable):

dart
const DropdownOption.header(value: 'cat-eu', title: 'Europe'),
const DropdownOption(value: 'de', title: 'Germany'),
const DropdownOption(value: 'fr', title: 'France'),
const DropdownOption.header(value: 'cat-na', title: 'North America'),
const DropdownOption(value: 'us', title: 'United States'),

MultiSelectDropdown

Multi-value variant with the same trigger system, plus a checkbox-tile panel and built-in "Select Shown" / "Clear All" actions.

dart
MultiSelectDropdown<String>(
  trigger: const DropdownTrigger.field(label: 'Tags'),
  options: const [
    MultiSelectOption(value: 'urgent', title: 'Urgent'),
    MultiSelectOption(value: 'review', title: 'Needs review'),
    MultiSelectOption(value: 'archived', title: 'Archived'),
  ],
  value: selectedTags,
  onChanged: (next) => setState(() => selectedTags = next),
)

// Async / server-side search
MultiSelectDropdown<User>(
  trigger: const DropdownTrigger.field(label: 'Assignees'),
  onQueryChanged: (query, pageSize) => api.searchUsers(query, limit: pageSize),
  pageSize: 25,
  value: assignees,
  onChanged: (next) => store.setAssignees(next),
  searchHint: 'Search users…',
)

The trigger label automatically shows '$count selected'.

ActionMenu

The "three-dot menu" / user menu primitive. Items are a sealed ActionMenuItem hierarchy:

dart
ActionMenu(
  trigger: DropdownTrigger.icon(icon: FluentIcons.more_horizontal_24_regular),
  items: [
    ActionMenuItem.header(title: 'John Doe', subtitle: 'john@example.com'),
    const ActionMenuItem.divider(),
    ActionMenuItem.action(
      title: 'Settings',
      icon: FluentIcons.settings_24_regular,
      onTap: _openSettings,
    ),
    ActionMenuItem.action(
      title: 'Profile',
      icon: FluentIcons.person_24_regular,
      trailing: const Icon(FluentIcons.checkmark_24_regular, size: 18),
      onTap: _openProfile,
    ),
    const ActionMenuItem.divider(),
    ActionMenuItem.action(
      title: 'Logout',
      icon: FluentIcons.sign_out_24_regular,
      isDestructive: true,
      onTap: _logout,
    ),
  ],
)

ActionMenuItem variants

VariantRenders
.actionTappable row with icon (or leading widget), title, optional trailing for selection markers, isDestructive flag
.dividerHorizontal divider with breathing room
.headerNon-tappable title + optional subtitle (typically a user header)
.customFree-form widget body with no built-in row decoration

When ANY action carries a trailing widget, the menu reserves the trailing column on every row so selection changes don't reflow the layout.

FormField adapter so Dropdown participates in Form validation and Form.save():

dart
DropdownFormField<String>(
  trigger: const DropdownTrigger.field(label: 'Role'),
  options: roleOptions,
  initialValue: 'operator',
  validator: (v) => v == null ? 'Required' : null,
  onSaved: (v) => formData.role = v,
)
  • OverlaysbuildOverlayFollower, TapRegion groups, root-overlay targeting
  • ButtonsSplitActionButton reuses ActionMenuItem
  • ThemingselectedTint for the active option row