Files

1383 lines
50 KiB
Dart

// 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<T> = 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<T> =
FutureOr<SelectItemDelegate?> 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<T> extends StatefulWidget {
/// called when a new value is picked. if null the select is disabled.
final ValueChanged<T?>? 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<T> 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<T>? 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<Select<T>> createState() => _SelectState<T>();
}
class _SelectState<T> extends State<Select<T>> {
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<T> 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<T> extends StatefulWidget {
/// static item delegate (default constructor).
final SelectItemDelegate? items;
/// on-demand item builder (`.builder` constructor). gets the search query.
final SelectItemsBuilder<T>? 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<SelectPopup<T>> createState() => _SelectPopupState<T>();
}
class _SelectPopupState<T> extends State<SelectPopup<T>> {
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<SelectItemDelegate?>) {
return FutureBuilder<SelectItemDelegate?>(
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<T> 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<Widget> 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<ActivateIntent>(
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<String> 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),
);
}