Headless select dropdown component. Handles keyboard navigation, focus management, and accessibility. Use builder pattern for custom trigger and option styling.
- Form fields: Choose from a list of options (country, category, etc.)
- Filters: Select filter criteria from predefined options
- Settings: Choose preferences from dropdown lists
- Data selection: Pick items from large sets with search/grouping
Information
Working samples are in packages/example/lib/api/naked_select.0.dart (plus .1/.2 for searchable and grouped variants).
dart
import 'package:flutter/material.dart';
import 'package:naked_ui/naked_ui.dart';
class BasicSelectExample extends StatefulWidget {
const BasicSelectExample({super.key});
@override
State<BasicSelectExample> createState() => _BasicSelectExampleState();
}
class _BasicSelectExampleState extends State<BasicSelectExample> {
String? _value = 'apple';
@override
Widget build(BuildContext context) {
return NakedSelect<String>(
value: _value,
onChanged: (next) => setState(() => _value = next),
builder: (context, state, _) {
final bool isOpen = state.isOpen;
final bool hovered = state.isHovered;
final bool focused = state.isFocused;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: hovered ? Colors.grey.shade100 : Colors.white,
border: Border.all(
color: focused ? Colors.blue : Colors.grey.shade300,
width: focused ? 2 : 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(state.value ?? 'Select fruit'),
const SizedBox(width: 12),
Icon(isOpen ? Icons.expand_less : Icons.expand_more, size: 18),
],
),
);
},
overlayBuilder: (context, info) {
return Material(
elevation: 6,
borderRadius: BorderRadius.circular(12),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220, minWidth: 220),
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 6),
shrinkWrap: true,
children: const [
_SelectOption(value: 'apple', label: 'Apple'),
_SelectOption(value: 'banana', label: 'Banana'),
_SelectOption(value: 'mango', label: 'Mango'),
],
),
),
);
},
);
}
}
class _SelectOption extends StatelessWidget {
const _SelectOption({required this.value, required this.label});
final String value;
final String label;
@override
Widget build(BuildContext context) {
return NakedSelect.Option<String>(
value: value,
builder: (context, optionState, _) {
final bool isSelected = optionState.isSelected;
final bool isHovered = optionState.isHovered;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
color: isSelected
? Colors.blue.withOpacity(0.12)
: isHovered
? Colors.grey.shade100
: null,
child: Row(
children: [
Text(label),
if (isSelected) ...[
const Spacer(),
const Icon(Icons.check, size: 16, color: Colors.blue),
],
],
),
);
},
);
}
}isOpen→ overlay visibilityvalue→ currently selected value (nullable)hasValuehelper plus rawwidgetStatesfrom the trigger builder
value→ option valueisSelected→ convenience getter supplied viaWidgetState.selectedwidgetStates→ hover/focus/pressed/disabled information
NakedSelectState.of<T>(context)/maybeOf<T>(context)→ read the trigger stateNakedSelectOptionState.of<T>(context)/maybeOf<T>(context)→ read the option state inside overlaysNakedSelectState.controllerOf(context)/maybeControllerOf(context)→ obtain the sharedWidgetStatesController
dart
const NakedSelect({
Key? key,
this.child,
this.builder,
required this.overlayBuilder,
this.value,
this.onChanged,
this.closeOnSelect = true,
this.closeOnClickOutside = true,
this.enabled = true,
this.mouseCursor = SystemMouseCursors.click,
this.triggerFocusNode,
this.semanticLabel,
this.positioning = const OverlayPositionConfig(
targetAnchor: Alignment.bottomCenter,
followerAnchor: Alignment.topCenter,
),
this.onOpen,
this.onClose,
this.onCanceled,
this.onOpenRequested,
this.onCloseRequested,
this.consumeOutsideTaps = true,
this.useRootOverlay = false,
this.excludeSemantics = false,
})child/builder→ provide static trigger content or aValueWidgetBuilder<NakedSelectState<T>>overlayBuilder→(BuildContext, RawMenuAnchorOverlayInfo)– build any list/panel/search UIvalue/onChanged→ manage the selected value externally (falls back to internal state whenvalueis null)positioning→ tuneOverlayPositionConfigalignment, fallback, and offset for the dropdown shellcloseOnSelect/closeOnClickOutside→ opt into dismissal after selection and pointer taps outsideconsumeOutsideTaps/useRootOverlay→ control how outside gestures are captured and which overlay stack is usedenabled→ disables trigger & option activation when falsemouseCursor→ set the trigger cursor when the select is enabledtriggerFocusNode→ reuse an external focus node; focus is restored here automatically on closesemanticLabel→ override trigger semantics; the currentvalue?.toString()is announced when providedexcludeSemantics→ hide the select trigger subtree from the semantic tree; opened overlay content remains exposed- Lifecycle hooks:
onOpen,onClose,onCanceledfire around overlay visibility;onOpenRequested,onCloseRequestedintercept RawMenuAnchor requests (e.g. for animations)
NakedSelect.Option<T> is a factory for NakedSelectOption<T>:
dart
const NakedSelect.Option({
Key? key,
required T value,
Widget? child,
ValueWidgetBuilder<NakedSelectOptionState<T>>? builder,
bool enabled = true,
String? semanticLabel,
})Use the builder to react to optionState.isSelected or optionState.widgetStates for hover styling. Options respect the select's enabled and closeOnSelect settings automatically.
- Each option is focusable and keyboard navigable; provide textual labels
- PageUp/PageDown: Move focus by ~10 traversal steps for faster navigation (policy-driven)
- Trigger announces
semanticLabeland selected value; set both for clarity - Respect
closeOnSelectfor quick dismissal or keep itfalsefor multi-step flows - Keep options at least 44×44 logical pixels for touch friendliness