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),
              ],
            ],
          ),
        );
      },
    );
  }
}

State Snapshots

Trigger – NakedSelectState<T>

  • isOpen → overlay visibility
  • value → currently selected value (nullable)
  • hasValue helper plus raw widgetStates from the trigger builder

Options – NakedSelectOptionState<T>

  • value → option value
  • isSelected → convenience getter supplied via WidgetState.selected
  • widgetStates → hover/focus/pressed/disabled information

Access from Context

  • NakedSelectState.of<T>(context) / maybeOf<T>(context) → read the trigger state
  • NakedSelectOptionState.of<T>(context) / maybeOf<T>(context) → read the option state inside overlays
  • NakedSelectState.controllerOf(context) / maybeControllerOf(context) → obtain the shared WidgetStatesController

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 a ValueWidgetBuilder<NakedSelectState<T>>
  • overlayBuilder(BuildContext, RawMenuAnchorOverlayInfo) – build any list/panel/search UI
  • value / onChanged → manage the selected value externally (falls back to internal state when value is null)
  • positioning → tune OverlayPositionConfig alignment, fallback, and offset for the dropdown shell
  • closeOnSelect / closeOnClickOutside → opt into dismissal after selection and pointer taps outside
  • consumeOutsideTaps / useRootOverlay → control how outside gestures are captured and which overlay stack is used
  • enabled → disables trigger & option activation when false
  • triggerFocusNode → reuse an external focus node; focus is restored here automatically on close
  • semanticLabel → override trigger semantics; the current value?.toString() is announced when provided
  • Lifecycle hooks: onOpen, onClose, onCanceled fire around overlay visibility; onOpenRequested, onCloseRequested intercept 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 semanticLabel and selected value; set both for clarity
  • Respect closeOnSelect for quick dismissal or keep it false for multi-step flows
  • Keep options at least 44×44 logical pixels for touch friendliness