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 apps/naked_ui_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.withValues(alpha: 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),
],
],
),
);
},
);
}
}State Snapshots
Trigger – NakedSelectState<T>
isOpen→ overlay visibilityvalue→ currently selected value (nullable)hasValuehelper plus rawstatesfrom the trigger builder
Options – NakedSelectOptionState<T>
value→ option valueisSelected→ convenience getter supplied viaWidgetState.selectedstates→ 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({
super.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.semanticValue,
this.positioning = const OverlayPositionConfig(
alignment: OverlayAlignment.center,
),
this.constrainToAvailableHeight = false,
this.onOpen,
this.onClose,
this.open,
this.onOpenChanged,
this.onCanceled,
this.onOpenRequested,
this.onCloseRequested,
this.consumeOutsideTaps = true,
this.useRootOverlay = false,
this.excludeSemantics = false,
})Key Parameters
child/builder→ provide static trigger content or aValueWidgetBuilder<NakedSelectState<T>>overlayBuilder→(BuildContext, RawMenuOverlayInfo)– 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)
Options via NakedSelect.Option
NakedSelect.Option<T> is a factory for NakedSelectOption<T>:
const NakedSelectOption({
super.key,
required super.value,
super.enabled = true,
super.semanticLabel,
super.child,
super.builder,
})Use the builder to react to optionState.isSelected or optionState.states 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
- 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
semanticValue overrides the announced value. The overlay focuses the selected option, falling back to the first enabled option. open and onOpenChanged support externally controlled opening.