Dropdowns & Menus
CDX provides four overlay-based selection widgets that share the same trigger system, overlay positioning, and dismissal model:
| Widget | Selects | Notes |
|---|---|---|
Dropdown<T> | One value | Sync, async-once, or query-driven options |
MultiSelectDropdown<T> | Set of values | Checkbox panel with search + select-shown / clear-all |
ActionMenu | Fires callbacks | Three-dot menus, user menus — uses ActionMenuItem sealed |
DropdownFormField<T> | One value (form-integrated) | FormField adapter for Dropdown |
Dropdown
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)
| Mode | Param | Behavior |
|---|---|---|
| Static | options: | Renders as-is; client-side filter when searchable: true |
| One-shot async | asyncOptions: | Fetched on first open, cached. Use controller.refresh() to refetch |
| Query-driven | onQueryChanged: (query, pageSize) async => ... | Server-side search; debounced (queryDebounceMs, default 300 ms) |
Three trigger variants
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-onlyFor full control use triggerBuilder:
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:
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),
)DropdownState<T>
Passed to triggerBuilder and exposed via DropdownController.stateListenable:
class DropdownState<T> {
final T? selectedValue;
final String? selectedLabel;
final bool isOpen;
final bool isHovered;
final bool isLoading;
final bool hasError;
}DropdownController<T>
Imperative API:
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);DropdownOption.header
Section divider rendered inline with the option list (non-tappable):
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.
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:
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
| Variant | Renders |
|---|---|
.action | Tappable row with icon (or leading widget), title, optional trailing for selection markers, isDestructive flag |
.divider | Horizontal divider with breathing room |
.header | Non-tappable title + optional subtitle (typically a user header) |
.custom | Free-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.
DropdownFormField
FormField adapter so Dropdown participates in Form validation and Form.save():
DropdownFormField<String>(
trigger: const DropdownTrigger.field(label: 'Role'),
options: roleOptions,
initialValue: 'operator',
validator: (v) => v == null ? 'Required' : null,
onSaved: (v) => formData.role = v,
)