NakedSelect
Headless single-select dropdown with typed trigger and option builders
Headless select dropdown component. Handles keyboard navigation, focus management, and accessibility. Use builder pattern for custom trigger and option styling.
When to use this
- 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
Working samples are in example/lib/api/naked_select.0.dart (plus .1/.2 for searchable and grouped variants).
Basic implementation
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),
],
],
),
);
},
);
}
}
Trigger – NakedSelectState<T>
isOpen→ overlay visibilityvalue→ currently selected value (nullable)hasValuehelper plus rawwidgetStatesfrom the trigger builder
Options – NakedSelectOptionState<T>
value→ option valueisSelected→ convenience getter supplied viaWidgetState.selectedwidgetStates→ hover/focus/pressed/disabled information
Access from Context
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
NakedSelect Constructor
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.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,
})
Key Parameters
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 falsetriggerFocusNode→ reuse an external focus node; focus is restored here automatically on closesemanticLabel→ override trigger semantics; the currentvalue?.toString()is announced when provided- Lifecycle hooks:
onOpen,onClose,onCanceledfire around overlay visibility;onOpenRequested,onCloseRequestedintercept RawMenuAnchor requests (e.g. for animations)
Options via NakedSelect.Option
NakedSelect.Option<T> is a factory for NakedSelectOption<T>:
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.
Accessibility Notes
- Each option is focusable and keyboard navigable; provide textual labels
- 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