// hand rolled replacement for shadcn's Select (dropdown + popover). // pixel-identical port of shadcn_flutter 0.0.52 form/select.dart — the closed // outline trigger, the popover surface (card bg, muted border, xl radius) and // the ghost item buttons with their check trailing. // // we only build the slice the app actually uses: single-select, static item // lists + the searchable builder popup. multi-select / controlled / theming // variants aren't wired up here (nothing in the app touches them). // // like the other GarageUI files we still read shadcn's Theme/ColorScheme + the // scaleAlpha extension + the bundled LucideIcons during the migration. that // import gets repointed later. everything else is plain flutter. import "dart:async"; import "dart:math" as math; import "dart:ui" as ui; import "package:flutter/services.dart"; import "package:flutter/widgets.dart"; import "package:garage_ui/field_error.dart"; import "package:garage_ui/popover_placement.dart"; import "package:garage_ui/surface.dart" show Gap; import "package:garage_ui/semantics_scope.dart"; import "package:garage_ui/theme/garage_theme.dart"; import "package:garage_ui/theme/support.dart"; import "package:garage_ui/button.dart"; import "package:flutter_lucide/flutter_lucide.dart"; /// builds the widget for a selected value (shown in the closed trigger). typedef SelectValueBuilder = Widget Function(BuildContext context, T value); /// builds the popup content. a [SelectPopup] instance is itself callable as /// one of these (it just returns itself), which is how the call sites pass /// `popup: SelectPopup(...)` directly. typedef SelectPopupBuilder = Widget Function(BuildContext context); /// builds the item delegate for a given search query (nullable when empty). typedef SelectItemsBuilder = FutureOr Function( BuildContext context, String? searchQuery, ); // ───────────────────────────────────────────────────────────────────────── // internal wiring between the trigger and the popover // ───────────────────────────────────────────────────────────────────────── // carries the selection state + the select/close callbacks down into the // popover subtree (which lives in the Overlay, outside the trigger's tree). // this is our stand-in for shadcn's SelectData + SelectPopupHandle. class _SelectHandle { final bool Function(Object? value) isSelected; final void Function(Object? value, bool selected) selectItem; final bool hasSelection; final bool enabled; final ControlDensity? density; const _SelectHandle({ required this.isSelected, required this.selectItem, required this.hasSelection, required this.enabled, required this.density, }); } class _SelectScope extends InheritedWidget { final _SelectHandle handle; const _SelectScope({required this.handle, required super.child}); static _SelectHandle? maybeOf(BuildContext context) { return context.dependOnInheritedWidgetOfExactType<_SelectScope>()?.handle; } @override bool updateShouldNotify(_SelectScope oldWidget) { // handle is rebuilt every open, so just always notify. cheap enough. return true; } } // ───────────────────────────────────────────────────────────────────────── // the select widget // ───────────────────────────────────────────────────────────────────────── /// a dropdown that opens a popover of options. drive it with [value] + /// [onChanged] — it doesn't hold its own selection, the parent does. /// Which control family a [Select] trigger draws as. Mirrors the button and /// text field variants of the same names. enum SelectVariant { /// Input-style: controlFill fill, controlBorder stroke. The default, /// matching TextField and Button.outline. outline, /// Solid secondary fill, same as Button.secondary. secondary, /// No fill and no stroke until you point at it, same as Button.ghost. /// /// For a select thats reporting a VALUE rather than offering a control - a /// settings row where the whole right hand column is values, and a box round /// every one of them is a box too many. ghost, } class Select extends StatefulWidget { /// called when a new value is picked. if null the select is disabled. final ValueChanged? onChanged; /// shown (muted) in the trigger when nothing is selected. final Widget? placeholder; /// the current value. null means "show the placeholder". final T? value; /// builds the trigger content for the current value. final SelectValueBuilder itemBuilder; /// Builds a widget for the START of the trigger, from the same value /// [itemBuilder] gets. /// /// For the mark that identifies a value rather than names it - a line's /// colour, a scheme's swatch. Inline in [itemBuilder] it rides along with /// the label, and since the trigger centres its value that puts a centred /// "swatch + label" block on screen with the swatch wandering left as the /// label gets longer. Pinned to the edge it sits still, and the label /// centres on its own. final SelectValueBuilder? leadingBuilder; /// builds the popover. usually a [SelectPopup]. final SelectPopupBuilder popup; /// hard override for enabled state. defaults to `onChanged != null`. final bool? enabled; /// Compact matches the app's standard controls; normal keeps the same /// vertical rhythm but adds the roomier horizontal inset. final ControlDensity? density; /// Which control family the trigger draws as. Only the trigger changes - /// the popup surface is the same either way. final SelectVariant variant; /// screen reader label naming what the select controls ("Line style"). The /// trigger's own content is the current *value*, which is not the same /// thing — without this you hear "Dashed, button" and no clue what of. final String? semanticLabel; /// How wide the open list is. /// /// Null follows the trigger exactly - the list is the width of the thing it /// came out of, lines up under it, and never sprawls across the window /// because thats where the room happened to be. /// /// Name it to override that. A square [iconOnly] trigger has no useful /// width to follow, so it has to: a 44px list of country names is a column /// of shredded text. /// /// Either way its clamped to the room the overlay actually has, so it cant /// open off the side of a phone. final double? popupWidth; /// Measure THIS for the list's width instead of the trigger. /// /// For a select welded into a bigger control - a flag beside a phone number /// - where the list should line up with the whole thing rather than with the /// square that opens it. Put the key on the outer control. /// /// Read when the list opens, not during build, so it cant be a LayoutBuilder /// round the control: that runs its builder DURING layout, and tearing the /// subtree down while navigating trips /// `_debugRelayoutBoundaryAlreadyMarkedNeedsLayout`. By open time the layout /// has settled and the render box just answers. final GlobalKey? popupAnchorKey; /// Drop the chevron and square the padding, for a trigger whose value IS /// the whole control - a flag, a colour swatch, an icon. /// /// Same relationship [IconButton] has to [Button]: the chevron and the wide /// horizontal padding both exist to sit beside a LABEL, and with no label /// they leave a control thats wider than it is tall for no reason. final bool iconOnly; const Select({ super.key, this.iconOnly = false, this.popupWidth, this.popupAnchorKey, this.onChanged, this.placeholder, this.value, this.enabled, this.density, this.variant = SelectVariant.outline, this.semanticLabel, required this.itemBuilder, this.leadingBuilder, required this.popup, }); @override State> createState() => _SelectState(); } class _SelectState extends State> { final LayerLink _link = LayerLink(); final GlobalKey _triggerKey = GlobalKey(); OverlayEntry? _entry; bool get _isOpen => _entry != null; bool _isSelected(Object? value) => widget.value == value; void _selectItem(Object? value, bool selected) { // default single-select behaviour: picking selects, and since we never // allow unselect (canUnselect is off everywhere in the app) a re-tap on // the current value just closes without a change. if (selected) { widget.onChanged?.call(value as T?); } _close(); } void _toggle() { if (_isOpen) { _close(); } else { _open(); } } void _open() { if (_isOpen) return; final theme = GarageTheme.of(context); final densityGap = theme.density.containerGap; // How far the popup hangs off the trigger. // // containerGap is the panel-to-panel number and it reads as a gulf under // a control you just clicked - the popup stops looking like it came OUT // of the select. gapSm is a step down the same ladder, still off the // control rather than glued to it. final popupGap = theme.density.gapSm; final maxHeight = theme.density.popupMaxHeight; final render = _triggerKey.currentContext?.findRenderObject() as RenderBox?; final anchorWidth = render?.size.width ?? 0.0; final overlayWidth = (Overlay.of(context).context.findRenderObject() as RenderBox?) ?.size .width; // a minimum against the trigger, then clamped to the room actually going. // Resolved BEFORE placement so the side it opens to is chosen against the // width it will really be, not the trigger's. final anchored = (widget.popupAnchorKey?.currentContext?.findRenderObject() as RenderBox?) ?.size .width; // THE width, not a floor. A select is as wide as its trigger unless it // was told otherwise. // // It used to be a minimum, free to grow to its widest row, on the // reasoning that the trigger shows one value and the list has to show // them all. What that actually produced was a list the width of whatever // room the chosen side had - and a trigger in a panel against the right // edge of the window has the entire window to its left, so picking a line // opened a popup 1900px wide with one row in it. var popupWidth = math.max( anchorWidth, math.max(anchored ?? 0.0, widget.popupWidth ?? 0.0), ); if (overlayWidth != null) { popupWidth = math.min(popupWidth, overlayWidth - densityGap * 2); } // flip above the trigger when there isnt room below, and clamp the list to // whatever room the chosen side actually has - a fixed maxHeight would // still overflow a short viewport even after flipping final overlayBox = Overlay.of(context).context.findRenderObject() as RenderBox?; // Which side to hang off. Now that the popup is exactly as wide as its // trigger the two sides put it in the same place, so this only really // decides which edge it clamps against in the corner case where the room // is narrower than the trigger - and the roomier side is the right answer // to that either way. final targetDx = (render != null && overlayBox != null) ? render.localToGlobal(Offset.zero, ancestor: overlayBox).dx : 0.0; final roomRight = overlayBox == null ? 0.0 : overlayBox.size.width - targetDx; final roomLeft = render == null ? 0.0 : targetDx + render.size.width; final placement = (render != null && overlayBox != null) ? resolvePopoverPlacement( target: render, overlay: overlayBox, preferredSize: Size(popupWidth, maxHeight), preferRight: roomRight >= roomLeft, gap: popupGap, ) : null; final resolvedMaxHeight = placement == null ? maxHeight : math.min(maxHeight, placement.maxHeight); // Same number as the floor, so the popup is exactly the width it resolved // to. The room the chosen side has is a clamp on that, never a licence to // grow into it - the list is still allowed to be narrower than the room // beside it, which is the normal case. final popupMaxWidth = placement != null ? math.min(popupWidth, placement.maxWidth) : popupWidth; final enabled = widget.enabled ?? widget.onChanged != null; final density = widget.density ?? ControlDensity.of(theme); final handle = _SelectHandle( isSelected: _isSelected, selectItem: _selectItem, hasSelection: widget.value != null, enabled: enabled, density: density, ); final overlay = Overlay.of(context); _entry = OverlayEntry( builder: (ctx) { return Stack( children: [ // modal barrier — tap anywhere outside to dismiss. Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, excludeFromSemantics: true, onTap: _close, child: const SizedBox.expand(), ), ), CompositedTransformFollower( link: _link, showWhenUnlinked: false, targetAnchor: placement?.targetAnchor ?? Alignment.bottomCenter, followerAnchor: placement?.followerAnchor ?? Alignment.topCenter, offset: placement?.offset ?? Offset(0, popupGap), child: ConstrainedBox( constraints: BoxConstraints( minWidth: popupMaxWidth, maxWidth: popupMaxWidth, maxHeight: resolvedMaxHeight, ), child: Focus( autofocus: true, onKeyEvent: (node, event) { if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) { _close(); return KeyEventResult.handled; } return KeyEventResult.ignored; }, child: _SelectScope( handle: handle, child: Builder(builder: (c) => widget.popup(c)), ), ), ), ), ], ); }, ); overlay.insert(_entry!); } void _close() { _entry?.remove(); _entry = null; } @override void didUpdateWidget(covariant Select oldWidget) { super.didUpdateWidget(oldWidget); // if we got disabled while open, bail out of the popover. final enabled = widget.enabled ?? widget.onChanged != null; if (!enabled && _isOpen) _close(); } @override void dispose() { _close(); super.dispose(); } @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final cs = theme.colorScheme; final enabled = widget.enabled ?? widget.onChanged != null; final density = widget.density ?? ControlDensity.of(theme); // secondary draws its content in secondaryForeground, same as // Button.secondary - the fill is solid enough that plain `foreground` // isnt the right pairing. // same story as TextField - a row's control slot is always secondary. final variant = PropertySlotScope.of(context) ? SelectVariant.secondary : widget.variant; final invalid = FieldErrorScope.of(context); final enabledContentColor = variant == SelectVariant.secondary ? cs.secondaryForeground : cs.foreground; final textStyle = theme.typography.small.copyWith( color: enabled ? enabledContentColor : cs.mutedForeground, ); final showValue = widget.value != null; Widget valueChild; if (showValue) { valueChild = widget.itemBuilder(context, widget.value as T); } else if (widget.placeholder != null) { // placeholder renders muted, same as shadcn's `.muted`. valueChild = DefaultTextStyle.merge( style: TextStyle(color: cs.mutedForeground), child: widget.placeholder!, ); } else { valueChild = const SizedBox(); } final chevronWidth = theme.iconTheme.small.size ?? 0; final gap = theme.density.controlGap; final leadingChild = showValue && widget.leadingBuilder != null ? widget.leadingBuilder!(context, widget.value as T) : null; final content = Row( mainAxisSize: MainAxisSize.min, children: [ // each end carries the same width as the other - the mark and a // chevron's worth of nothing on the left, the mark's worth of nothing // and the chevron on the right. Thats what puts the value in the // middle of the TRIGGER rather than in the middle of whats left over. if (!widget.iconOnly) ...[ if (leadingChild != null) ...[leadingChild, SizedBox(width: gap)], SizedBox(width: chevronWidth), SizedBox(width: gap), ], Expanded( child: Align(alignment: Alignment.center, child: valueChild), ), if (!widget.iconOnly) ...[ SizedBox(width: gap), if (leadingChild != null) ...[ _MirrorOf(child: leadingChild), SizedBox(width: gap), ], Icon( LucideIcons.chevrons_up_down, // Match the compact icon token so the chevron does not drive the // trigger taller than an equivalent button/field. size: theme.iconTheme.small.size, color: enabledContentColor.scaleAlpha(0.5), ), ], ], ); return MergeSemantics( child: Semantics( container: true, button: true, enabled: enabled, expanded: _isOpen, label: resolveSemanticLabel(context, widget.semanticLabel), onTap: enabled ? _toggle : null, child: _buildTrigger( theme, density, enabled, textStyle, content, variant, invalid, ), ), ); } Widget _buildTrigger( ThemeData theme, ControlDensity density, bool enabled, TextStyle textStyle, Widget content, SelectVariant variant, bool invalid, ) { return IntrinsicWidth( child: ConstrainedBox( constraints: const BoxConstraints(), child: FieldErrorOutline( invalid: invalid, // the same radius the three trigger decorations round themselves // to, so the outline sits ON the border rather than beside it. borderRadius: theme.borderRadiusMd, child: CompositedTransformTarget( link: _link, child: _Btn( key: _triggerKey, enabled: enabled, onPressed: enabled ? _toggle : null, // the trigger IS a control - it goes through the exact same padding // resolution as a bordered button, so a Select sitting next to a // TextField or a Button.outline comes out the same height. it used // to carry its own hand-tuned vertical insets and landed 19px // compact / 29px normal against everything else's 23 / 27. padding: resolveControlPadding( theme, density: density, includesBorder: true, squarePadding: widget.iconOnly, ), alignment: AlignmentDirectional.centerStart, cursor: SystemMouseCursors.click, decoration: (hovered, disabled) => switch (variant) { SelectVariant.outline => _outlineDecoration( theme, hovered: hovered, disabled: disabled, ), SelectVariant.secondary => _secondaryDecoration( theme, hovered: hovered, disabled: disabled, ), SelectVariant.ghost => _ghostDecoration( theme, hovered: hovered && !disabled, ), }, textStyle: textStyle, child: content, ), ), ), ), ); } } // Popup rows have their own height target - they sit tighter than the trigger // on purpose. The vertical inset falls straight out of popupRowHeight, so the // row lands on exactly that number and nobody has to re-tune a half pixel. EdgeInsets _selectMenuPadding(ControlDensity density, ThemeData theme) { final tokens = density.tokens(theme); final vertical = tokens.popupRowPaddingY; return density.resolve(theme).copyWith(top: vertical, bottom: vertical); } // ───────────────────────────────────────────────────────────────────────── // the popover // ───────────────────────────────────────────────────────────────────────── /// the popover surface for a [Select]. use the default constructor with a /// static [SelectItemList], or [SelectPopup.builder] for a searchable list /// built on demand from the query. class SelectPopup extends StatefulWidget { /// static item delegate (default constructor). final SelectItemDelegate? items; /// on-demand item builder (`.builder` constructor). gets the search query. final SelectItemsBuilder? builder; /// placeholder shown inside the search field. final Widget? searchPlaceholder; /// whether the search field is shown (only in builder mode). final bool enableSearch; /// whether the rows are a plain Column (static lists) or a lazy ListView /// (builder lists, which can be long). Both only take the height they /// need - see [_listView]. final bool shrinkWrap; const SelectPopup({ super.key, this.items, this.searchPlaceholder, this.shrinkWrap = true, }) : builder = null, enableSearch = false; const SelectPopup.builder({ super.key, required this.builder, this.searchPlaceholder, }) : items = null, enableSearch = true, shrinkWrap = false; /// lets a SelectPopup be handed straight to `popup:` (a SelectPopupBuilder). Widget call(BuildContext context) => this; @override State> createState() => _SelectPopupState(); } class _SelectPopupState extends State> { final TextEditingController _searchController = TextEditingController(); final ScrollController _scrollController = ScrollController(); @override void dispose() { _searchController.dispose(); _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final column = Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ if (widget.enableSearch) ...[ _SearchField( controller: _searchController, placeholder: widget.searchPlaceholder, onChanged: (_) => setState(() {}), ), Container(height: 1, color: theme.colorScheme.divider), ], Flexible( child: ListenableBuilder( listenable: _searchController, builder: (context, _) { final query = _searchController.text.isEmpty ? null : _searchController.text; return _buildList(context, query); }, ), ), ], ); // No IntrinsicWidth. The width comes from the trigger now (see // Select.popupWidth), so the popup is handed a tight cross axis and has // nothing to measure - asking a column how wide it wants to be and then // ignoring the answer is just a layout pass nobody reads. // // [shrinkWrap] picks which list: a static one is a scrolling Column, the // builder path is a lazy ListView. Either way the popup ends up as tall // as its rows and no taller. return _popupSurface(theme, column); } Widget _buildList(BuildContext context, String? query) { if (widget.builder != null) { final result = widget.builder!(context, query); if (result is Future) { return FutureBuilder( future: result, builder: (context, snap) { if (snap.connectionState == ConnectionState.waiting) { return const SizedBox(); } if (snap.hasError) { // never swallow — surface it for debugging then show nothing. debugPrint("SelectPopup builder failed: ${snap.error}"); debugPrintStack(stackTrace: snap.stackTrace); return const SizedBox(); } return _listView(context, snap.data); }, ); } return _listView(context, result); } return _listView(context, widget.items); } Widget _listView(BuildContext context, SelectItemDelegate? delegate) { final theme = GarageTheme.of(context); final count = delegate?.estimatedChildCount ?? 0; if (delegate == null || count == 0) { return const SizedBox(); } final inset = _popupInset(theme); // A static list is laid out as a COLUMN, not a ListView. // // A ListView is a scrollable: it takes every pixel of cross axis its // handed and cannot report an intrinsic width, so a popup built on one // either fills whatever ceiling it is given or has none and runs off the // edge. Neither is "as wide as the widest option". // // A Column can say how wide it wants to be, and SingleChildScrollView // passes that question straight through to it (its render object // delegates computeMaxIntrinsicWidth to its child), so wrapping the pair // in an IntrinsicWidth gives a popup that sizes to its rows AND still // scrolls when there are too many to show. // // Only for the static path. The builder constructor sets shrinkWrap false // and can be handed hundreds of rows - there, laying them all out to // measure the widest is exactly what a ListView exists to avoid. if (widget.shrinkWrap) { return SingleChildScrollView( controller: _scrollController, padding: EdgeInsets.all(inset), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ for (var i = 0; i < count; i++) ...[ if (i > 0) Gap(_popupRowGap(theme)), ?delegate.build(context, i), ], ], ), ); } // shrinkWrap regardless of [widget.shrinkWrap] - that flag is about which // list we build, not how tall it ends up. A ListView left to expand eats // every pixel of popupMaxHeight its handed, so a one-row "Search lines" // popup opened a 312px wall of empty surface under its single row. // // This stays lazy: the incoming maxHeight is bounded (the overlay's // ConstrainedBox, then Flexible), so the shrink wrapping viewport still // only lays out as far as that extent and stops. Its the *unbounded* case // that forces a full layout pass, and we never hand it one. return ListView.separated( controller: _scrollController, padding: EdgeInsets.all(inset), shrinkWrap: true, itemCount: count, separatorBuilder: (context, _) => Gap(_popupRowGap(theme)), itemBuilder: (context, index) => delegate.build(context, index) ?? const SizedBox(), ); } } /// the padding between the surface's edge and the rows inside it. half the /// container gap, same inset a dropdown menu gives its own list - these are /// the same kind of surface so they shouldnt disagree. double _popupInset(dynamic theme) => (theme.density.containerGap as double) * 0.5; /// air between one row and the next. /// /// They were flush, which reads as one block with lines drawn on it rather /// than as a list of things you can point at - and the selected row, which is /// a secondary button with a border, had its border sitting directly on its /// neighbours. double _popupRowGap(dynamic theme) => (theme.density.gapXxs as double); // the popover surface — a port of ModalContainer -> Card -> OutlinedContainer. // popover background, muted border, hard-edge clip, plus the theme's surface // opacity/blur if it sets any. The fill matches MenuPopup so a select doesnt // read as a different kind of surface. Widget _popupSurface(dynamic theme, Widget child) { final cs = theme.colorScheme; final double borderWidth = theme.density.controlBorderWidth as double; // Concentric with the rows, not the same as them. // // The surface used to take radiusMd, which is exactly what a row takes - // so with the inset between them the outer corner turned inside a corner of // the same curve, and the gap at 45 degrees was visibly tighter than the // gap along the edges. An outer radius is its inner radius plus whatever // sits between the two. // less 2: concentric is the rule, but the full sum reads too soft on a // surface this size - the corner starts curving before the first row does. final BorderRadius radius = _growRadius( theme.borderRadiusMd as BorderRadius, _popupInset(theme) + borderWidth - 2, ); var bg = cs.popover as Color; final double? surfaceOpacity = theme.surfaceOpacity; if (surfaceOpacity != null) { bg = bg.scaleAlpha(surfaceOpacity); } final innerRadius = _subtractRadius(radius, borderWidth); Widget surface = Container( decoration: BoxDecoration( color: bg, border: Border.all( color: cs.popoverBorder, width: borderWidth, strokeAlign: BorderSide.strokeAlignCenter, ), borderRadius: radius, ), child: ClipRRect( borderRadius: innerRadius, clipBehavior: Clip.hardEdge, child: DefaultTextStyle.merge( style: TextStyle(color: cs.foreground), child: child, ), ), ); final double? surfaceBlur = theme.surfaceBlur; if (surfaceBlur != null && surfaceBlur > 0) { surface = ClipRRect( borderRadius: radius, child: BackdropFilter( filter: ui.ImageFilter.blur(sigmaX: surfaceBlur, sigmaY: surfaceBlur), child: surface, ), ); } return surface; } // ───────────────────────────────────────────────────────────────────────── // items // ───────────────────────────────────────────────────────────────────────── /// a tappable option inside a [SelectPopup]. shows a check on the selected /// one, and reserves the check column on the others so text stays aligned. class SelectItemButton extends StatelessWidget { final T value; final Widget child; final bool? enabled; /// The row's mark - a swatch, a type icon - pinned to the start of the row. /// Pass it here rather than building it into [child]: see [_Btn.leading]. final Widget? leading; const SelectItemButton({ super.key, required this.value, required this.child, this.enabled, this.leading, }); @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final cs = theme.colorScheme; // controlGap - this is the row's label to check-icon gap, same as a button's. final densityGap = theme.density.controlGap; final handle = _SelectScope.maybeOf(context); final selected = handle?.isSelected(value) ?? false; final hasSelection = handle?.hasSelection ?? false; final en = enabled ?? true; // the selected row reads as a secondary button, so its label takes the // secondary foreground rather than the popover's. final textStyle = theme.typography.small.copyWith( color: selected ? cs.secondaryForeground : (en ? cs.foreground : cs.mutedForeground), ); Widget? trailing; if (selected) { // same optical nudge every control puts on its content, so the check // tracks the label beside it. it used to be a hardcoded 2, which sat the // check a clear 2px under the row centre. final nudge = theme.density.controlTextOffset; final check = Icon( LucideIcons.check, size: theme.iconTheme.small.size, color: textStyle.color, ); trailing = nudge == 0 ? check : Transform.translate(offset: Offset(0, nudge), child: check); } else if (hasSelection) { trailing = SizedBox(width: theme.iconTheme.small.size); } return MergeSemantics( child: Semantics( container: true, inMutuallyExclusiveGroup: true, selected: selected, enabled: en, onTap: en ? () => handle?.selectItem(value, !selected) : null, child: _row( context, theme, en, handle, selected, trailing, densityGap, textStyle, ), ), ); } Widget _row( BuildContext context, ThemeData theme, bool en, _SelectHandle? handle, bool selected, Widget? trailing, double densityGap, TextStyle textStyle, ) { return _Btn( enabled: en, onPressed: en ? () => handle?.selectItem(value, !selected) : null, padding: _selectMenuPadding( handle?.density ?? ControlDensity.compact, theme, ), // centred, like every other select in the app - see the trigger. The // check column is mirrored on the left by leadingWidth so the label // sits in the middle of the ROW rather than in the middle of whats // left of it. alignment: Alignment.center, cursor: SystemMouseCursors.basic, disableTransition: true, trailing: trailing, trailingGap: densityGap, leadingWidth: trailing == null ? 0 : theme.iconTheme.small.size ?? 0, leading: leading, // The selected row IS a secondary button - the same helper the // secondary trigger uses, border and all, rather than a fill that // merely looks like one. // // A check in the margin says which row is selected; a filled row says it // without being read, which in a list of ten is the difference between // finding the current value and scanning for a tick. decoration: (hovered, disabled) => selected ? _secondaryDecoration(theme, hovered: hovered, disabled: disabled) : _ghostDecoration(theme, hovered: hovered && !disabled), textStyle: textStyle, child: child, ); } } /// Holds exactly as much width as [child] would take, and draws nothing. /// /// The trigger balances whatever sits at its end with one of these at its /// start, so a centred label is centred in the control. Measuring the real /// widget rather than guessing a number means a swatch, an icon and a two /// character badge all balance correctly without anyone maintaining a table /// of widths. class _MirrorOf extends StatelessWidget { const _MirrorOf({required this.child}); final Widget child; @override Widget build(BuildContext context) { return Visibility( visible: false, maintainSize: true, maintainAnimation: true, maintainState: true, child: IgnorePointer(child: ExcludeSemantics(child: child)), ); } } /// a value-carrying wrapper that reports its selected state to descendants. /// kept for API parity — the app builds items with [SelectItemButton] instead, /// but this mirrors shadcn's SelectItem. class SelectItem extends StatelessWidget { final WidgetBuilder builder; final Object? value; const SelectItem({super.key, required this.value, required this.builder}); @override Widget build(BuildContext context) { return Builder(builder: builder); } } // ───────────────────────────────────────────────────────────────────────── // item delegates // ───────────────────────────────────────────────────────────────────────── /// how a popup gets its items — either a fixed list ([SelectItemList]) or, /// via [SelectPopup.builder], a freshly built one per search query. abstract class SelectItemDelegate { const SelectItemDelegate(); Widget? build(BuildContext context, int index); int? get estimatedChildCount => null; } /// the common case: a static list of [SelectItemButton]s. class SelectItemList extends SelectItemDelegate { final List children; const SelectItemList({required this.children}); @override Widget build(BuildContext context, int index) => children[index]; @override int get estimatedChildCount => children.length; } // ───────────────────────────────────────────────────────────────────────── // the little stateful button both the trigger and items are built on // ───────────────────────────────────────────────────────────────────────── class _Btn extends StatefulWidget { final Widget child; final Widget? trailing; final double trailingGap; /// Reserves the same width on the LEFT as [trailing] takes on the right, so /// a centre-aligned child is centred in the control rather than in whats /// left of it after the check/chevron. final double leadingWidth; /// Pinned to the start of the row, with its width mirrored at the end. /// /// The mark that identifies a value - a swatch, a type icon. Rows put it /// here rather than inline with the label for the same reason the trigger /// does: a centred "swatch + label" pair walks its swatch sideways with /// every change of label length, and a list of ten of those has no left /// edge at all. final Widget? leading; final VoidCallback? onPressed; final bool enabled; final EdgeInsetsGeometry padding; final AlignmentGeometry alignment; final bool disableTransition; final MouseCursor cursor; final BoxDecoration Function(bool hovered, bool disabled) decoration; final TextStyle textStyle; const _Btn({ super.key, required this.child, this.trailing, this.trailingGap = 0, this.leadingWidth = 0, this.leading, required this.onPressed, required this.enabled, required this.padding, required this.alignment, this.disableTransition = false, required this.cursor, required this.decoration, required this.textStyle, }); @override State<_Btn> createState() => _BtnState(); } class _BtnState extends State<_Btn> { bool _hovered = false; bool _focused = false; @override Widget build(BuildContext context) { final disabled = !widget.enabled; var deco = widget.decoration( (_hovered || _focused) && widget.enabled, disabled, ); // same corner-squaring + border-merge Button already does (button.dart) - // without this a Select sitting in a ButtonGroup (see object_field.dart) // keeps its own full radius and a doubled-up border against its // neighbour instead of reading as one connected control final group = ButtonGroupScope.maybeOf(context); if (group != null) { deco = deco.copyWith( borderRadius: group.corners.applyTo( deco.borderRadius ?? BorderRadius.zero, Directionality.of(context), ), ); if (deco.border case final Border border) { deco = deco.copyWith( border: group.mergedBorder(border, Directionality.of(context)), ); } } final leading = widget.leading; Widget inner; if (widget.trailing != null || leading != null) { inner = IntrinsicWidth( child: IntrinsicHeight( child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // both ends carry the same width, so the child is centred in the // ROW rather than in what the mark and the check left of it. if (leading != null) ...[ leading, SizedBox(width: widget.trailingGap), ], if (widget.leadingWidth > 0) ...[ SizedBox(width: widget.leadingWidth), SizedBox(width: widget.trailingGap), ], Expanded( child: Align(alignment: widget.alignment, child: widget.child), ), if (widget.trailing != null) ...[ SizedBox(width: widget.trailingGap), if (leading != null) ...[ _MirrorOf(child: leading), SizedBox(width: widget.trailingGap), ], widget.trailing!, ] else if (leading != null) ...[ SizedBox(width: widget.trailingGap), _MirrorOf(child: leading), ], ], ), ), ); } else { inner = widget.child; } // plain Container - no colour easing on hover, matching Button and // TextField. see the note in button.dart. final content = Container( padding: widget.padding, decoration: deco, child: DefaultTextStyle.merge( style: widget.textStyle, child: IconTheme.merge( data: IconThemeData(color: widget.textStyle.color), child: inner, ), ), ); return MouseRegion( // forbidden when its off, not the plain arrow. A disabled select looks // much like an enabled one holding a value, so the pointer is the first // thing that tells you it wont open - before you click and nothing // happens. cursor: widget.enabled ? widget.cursor : SystemMouseCursors.forbidden, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), // this used to be pointer-only, so a Select trigger and every row in // its popup were unreachable by keyboard - you could see them, tab // straight past them, and never open one. Button has had this since // forever; the select family just never got it. child: FocusableActionDetector( enabled: widget.enabled, shortcuts: const { SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(), SingleActivator(LogicalKeyboardKey.space): ActivateIntent(), }, actions: { ActivateIntent: CallbackAction( onInvoke: (_) { widget.onPressed?.call(); return null; }, ), }, onShowFocusHighlight: (v) { if (mounted) setState(() => _focused = v); }, child: GestureDetector( behavior: HitTestBehavior.opaque, excludeFromSemantics: true, onTap: widget.enabled ? widget.onPressed : null, child: content, ), ), ); } } // ───────────────────────────────────────────────────────────────────────── // decorations (ports of shadcn's outline + ghost button variances) // ───────────────────────────────────────────────────────────────────────── // no strokeAlign override on any of these - it defaults to inside, same as the // TextField and Button.outline borders. Center-aligned (the old value here) // only feeds Container half the stroke per side, so the trigger came out a // pixel shorter than a field with identical padding, and painted the other half // of the stroke outside its own bounds. button.dart hit this too. // Outline trigger. Same tokens TextField's own decoration and Button.outline // use (controlFill/Hovered + controlBorder + radiusMd), so a Select sitting // in a row of fields reads as the same control family. It used to run // input.scaleAlpha(0.3) over cs.border - shadcn-migration leftovers that gave // it a fill and a stroke nothing else in the app drew. BoxDecoration _outlineDecoration( dynamic theme, { required bool hovered, required bool disabled, }) { final cs = theme.colorScheme; final radius = theme.borderRadiusMd as BorderRadius; final width = theme.density.controlBorderWidth as double; if (disabled) { return BoxDecoration( color: cs.controlFill, border: Border.all(color: cs.controlBorder, width: width), borderRadius: radius, ); } return BoxDecoration( color: hovered ? cs.controlFillHovered : cs.controlFill, border: Border.all(color: cs.controlBorder, width: width), borderRadius: radius, ); } // Secondary trigger - the Select equivalent of Button.secondary. Same // secondary fill + controlBorder stroke that button draws, so the two are // interchangeable in a toolbar. Hover comes off the scheme's secondaryHovered, // the same token button reads. BoxDecoration _secondaryDecoration( dynamic theme, { required bool hovered, required bool disabled, }) { final cs = theme.colorScheme; final radius = theme.borderRadiusMd as BorderRadius; final width = theme.density.controlBorderWidth as double; return BoxDecoration( color: (hovered && !disabled) ? cs.secondaryHovered as Color : cs.secondary as Color, border: Border.all(color: cs.controlBorder, width: width), borderRadius: radius, ); } BoxDecoration _ghostDecoration(dynamic theme, {required bool hovered}) { final cs = theme.colorScheme; final radius = theme.borderRadiusMd as BorderRadius; if (hovered) { return BoxDecoration(color: cs.popoverItemHovered, borderRadius: radius); } return BoxDecoration( color: (cs.muted as Color).withValues(alpha: 0), borderRadius: radius, ); } // ───────────────────────────────────────────────────────────────────────── // search field (builder popups) // ───────────────────────────────────────────────────────────────────────── class _SearchField extends StatefulWidget { final TextEditingController controller; final Widget? placeholder; final ValueChanged onChanged; const _SearchField({ required this.controller, required this.placeholder, required this.onChanged, }); @override State<_SearchField> createState() => _SearchFieldState(); } class _SearchFieldState extends State<_SearchField> { final FocusNode _focusNode = FocusNode(); @override void dispose() { _focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = GarageTheme.of(context); final cs = theme.colorScheme; final style = theme.typography.small.copyWith(color: cs.foreground); final showPlaceholder = widget.controller.text.isEmpty && widget.placeholder != null; // the search row is a control, so it takes the control tokens rather than // the 12/16/8 literals it was ported with - those didnt move with density // at all, so the row stayed the same height while every control around it // changed. final density = theme.density; return Padding( padding: EdgeInsets.symmetric( vertical: density.controlPaddingY, horizontal: density.buttonPaddingX, ), child: Row( children: [ Icon( LucideIcons.search, size: theme.iconTheme.small.size, color: cs.mutedForeground, ), SizedBox(width: density.controlGap), Expanded( child: Stack( alignment: AlignmentDirectional.centerStart, children: [ if (showPlaceholder) DefaultTextStyle.merge( style: TextStyle(color: cs.mutedForeground), child: widget.placeholder!, ), EditableText( controller: widget.controller, focusNode: _focusNode, autofocus: true, style: style, cursorColor: cs.primary, backgroundCursorColor: cs.muted, selectionColor: cs.primary.scaleAlpha(0.3), onChanged: (v) { setState(() {}); widget.onChanged(v); }, ), ], ), ), ], ), ); } } // clamps each corner radius down by the border width, like shadcn's // subtractByBorder so the inner clip sits flush inside the stroke. /// the other direction: a corner that wraps [w] of padding around [r]. BorderRadius _growRadius(BorderRadius r, double w) { Radius add(Radius a) => Radius.elliptical(a.x + w, a.y + w); return BorderRadius.only( topLeft: add(r.topLeft), topRight: add(r.topRight), bottomLeft: add(r.bottomLeft), bottomRight: add(r.bottomRight), ); } BorderRadius _subtractRadius(BorderRadius r, double w) { Radius sub(Radius a) => Radius.elliptical( (a.x - w).clamp(0.0, double.infinity), (a.y - w).clamp(0.0, double.infinity), ); return BorderRadius.only( topLeft: sub(r.topLeft), topRight: sub(r.topRight), bottomLeft: sub(r.bottomLeft), bottomRight: sub(r.bottomRight), ); }