diff --git a/example/lib/home.dart b/example/lib/home.dart index a0b93d43..3a55cec8 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -19,6 +19,7 @@ import 'package:zeta_example/pages/components/navigation_bar_example.dart'; import 'package:zeta_example/pages/components/navigation_rail_example.dart'; import 'package:zeta_example/pages/components/phone_input_example.dart'; import 'package:zeta_example/pages/components/radio_example.dart'; +import 'package:zeta_example/pages/components/select_input_example.dart'; import 'package:zeta_example/pages/components/search_bar_example.dart'; import 'package:zeta_example/pages/components/segmented_control_example.dart'; import 'package:zeta_example/pages/components/stepper_example.dart'; @@ -74,6 +75,7 @@ final List components = [ Component(SearchBarExample.name, (context) => const SearchBarExample()), Component(TooltipExample.name, (context) => const TooltipExample()), Component(NavigationRailExample.name, (context) => const NavigationRailExample()), + Component(SelectInputExample.name, (context) => const SelectInputExample()), ]; final List theme = [ diff --git a/example/lib/pages/components/select_input_example.dart b/example/lib/pages/components/select_input_example.dart new file mode 100644 index 00000000..d8f85c81 --- /dev/null +++ b/example/lib/pages/components/select_input_example.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:zeta_example/widgets.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +class SelectInputExample extends StatefulWidget { + static const String name = 'SelectInput'; + const SelectInputExample({super.key}); + + @override + State createState() => _SelectInputExampleState(); +} + +class _SelectInputExampleState extends State { + String? _errorText; + ZetaSelectInputItem? selectedItem = ZetaSelectInputItem( + value: 'Item 1', + ); + + @override + Widget build(BuildContext context) { + final zeta = Zeta.of(context); + + return ExampleScaffold( + name: 'Select Input', + child: Center( + child: SingleChildScrollView( + child: SizedBox( + width: 320, + child: Column( + children: [ + ZetaSelectInput( + label: Row( + children: [ + Text('Label'), + Padding( + padding: const EdgeInsets.only(left: 6), + child: Text( + '*', + style: TextStyle(color: zeta.colors.red.shade60), + ), + ), + ], + ), + hint: 'Default hint text', + leadingIcon: Icon(ZetaIcons.star_round), + hasError: _errorText != null, + errorText: _errorText, + onChanged: (item) { + setState(() { + selectedItem = item; + if (item != null) { + _errorText = null; + } + }); + }, + onTextChanged: (value) { + setState(() { + if (value.isEmpty) { + _errorText = 'Required'; + } else { + _errorText = null; + } + }); + }, + selectedItem: selectedItem, + items: [ + ZetaSelectInputItem( + value: 'Item 1', + ), + ZetaSelectInputItem( + value: 'Item 2', + ), + ZetaSelectInputItem( + value: 'Item 3', + ), + ZetaSelectInputItem( + value: 'Item 4', + ), + ZetaSelectInputItem( + value: 'Item 5', + ), + ZetaSelectInputItem( + value: 'Item 6', + ), + ZetaSelectInputItem( + value: 'Item 7', + ), + ZetaSelectInputItem( + value: 'Item 8', + ), + ZetaSelectInputItem( + value: 'Item 9', + ), + ZetaSelectInputItem( + value: 'Item 10', + ), + ZetaSelectInputItem( + value: 'Item 11', + ), + ZetaSelectInputItem( + value: 'Item 12', + ), + ], + ), + const SizedBox(height: 120), + ], + ), + ), + ), + ), + ); + } +} diff --git a/example/widgetbook/main.dart b/example/widgetbook/main.dart index 0a7ffcce..c8882d05 100644 --- a/example/widgetbook/main.dart +++ b/example/widgetbook/main.dart @@ -28,6 +28,7 @@ import 'pages/components/progress_widgetbook.dart'; import 'pages/components/radio_widgetbook.dart'; import 'pages/components/search_bar_widgetbook.dart'; import 'pages/components/segmented_control_widgetbook.dart'; +import 'pages/components/select_input_widgetbook.dart'; import 'pages/components/stepper_widgetbook.dart'; import 'pages/components/switch_widgetbook.dart'; import 'pages/components/snack_bar_widgetbook.dart'; @@ -137,6 +138,7 @@ class HotReload extends StatelessWidget { WidgetbookUseCase(name: 'Search Bar', builder: (context) => searchBarUseCase(context)), WidgetbookUseCase(name: 'Navigation Rail', builder: (context) => navigationRailUseCase(context)), WidgetbookUseCase(name: 'Tooltip', builder: (context) => tooltipUseCase(context)), + WidgetbookUseCase(name: 'Select Input', builder: (context) => selectInputUseCase(context)), ]..sort((a, b) => a.name.compareTo(b.name)), ), WidgetbookCategory( diff --git a/example/widgetbook/pages/components/select_input_widgetbook.dart b/example/widgetbook/pages/components/select_input_widgetbook.dart new file mode 100644 index 00000000..da2ff862 --- /dev/null +++ b/example/widgetbook/pages/components/select_input_widgetbook.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:zeta_flutter/zeta_flutter.dart'; + +import '../../test/test_components.dart'; +import '../../utils/utils.dart'; + +Widget selectInputUseCase(BuildContext context) { + final zeta = Zeta.of(context); + final items = [ + ZetaSelectInputItem(value: 'Item 1'), + ZetaSelectInputItem(value: 'Item 2'), + ZetaSelectInputItem(value: 'Item 3'), + ZetaSelectInputItem(value: 'Item 4'), + ZetaSelectInputItem(value: 'Item 5'), + ZetaSelectInputItem(value: 'Item 6'), + ZetaSelectInputItem(value: 'Item 7'), + ZetaSelectInputItem(value: 'Item 8'), + ZetaSelectInputItem(value: 'Item 9'), + ZetaSelectInputItem(value: 'Item 10'), + ZetaSelectInputItem(value: 'Item 11'), + ZetaSelectInputItem(value: 'Item 12'), + ]; + late ZetaSelectInputItem? selectedItem = items.first; + String? _errorText; + final label = context.knobs.string( + label: 'Label', + initialValue: 'Label', + ); + final hint = context.knobs.string( + label: 'Hint', + initialValue: 'Default hint text', + ); + final rounded = context.knobs.boolean(label: 'Rounded', initialValue: true); + final enabled = context.knobs.boolean(label: 'Enabled', initialValue: true); + final required = context.knobs.boolean(label: 'Required', initialValue: true); + final size = context.knobs.list( + label: 'Size', + options: ZetaWidgetSize.values, + labelBuilder: (size) => size.name, + ); + final iconData = iconKnob( + context, + name: "Icon", + rounded: rounded, + initial: rounded ? ZetaIcons.star_round : ZetaIcons.star_sharp, + ); + + return WidgetbookTestWidget( + widget: StatefulBuilder( + builder: (context, setState) { + return Padding( + padding: const EdgeInsets.all(ZetaSpacing.m), + child: ZetaSelectInput( + rounded: rounded, + enabled: enabled, + size: size, + label: Row( + children: [ + Text(label), + if (required) + Padding( + padding: const EdgeInsets.only(left: 6), + child: Text( + '*', + style: TextStyle(color: zeta.colors.red.shade60), + ), + ), + ], + ), + hint: hint, + leadingIcon: Icon(iconData), + hasError: _errorText != null, + errorText: _errorText, + onChanged: (item) { + setState(() { + selectedItem = item; + if (item != null) { + _errorText = null; + } + }); + }, + onTextChanged: (value) { + setState(() { + if (required && value.isEmpty) { + _errorText = 'Required'; + } else { + _errorText = null; + } + }); + }, + selectedItem: selectedItem, + items: items, + ), + ); + }, + ), + ); +} diff --git a/lib/src/components/select_input/select_input.dart b/lib/src/components/select_input/select_input.dart new file mode 100644 index 00000000..1b409290 --- /dev/null +++ b/lib/src/components/select_input/select_input.dart @@ -0,0 +1,649 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../../zeta_flutter.dart'; + +enum _MenuPosition { top, bottom } + +/// Class for [ZetaSelectInput] +class ZetaSelectInput extends StatefulWidget { + ///Constructor of [ZetaSelectInput] + const ZetaSelectInput({ + super.key, + required this.items, + this.onChanged, + this.onTextChanged, + this.selectedItem, + this.size, + this.leadingIcon, + this.label, + this.hint, + this.enabled = true, + this.rounded = true, + this.hasError = false, + this.errorText, + }); + + /// Input items as list of [ZetaSelectInputItem] + final List items; + + /// Currently selected item + final ZetaSelectInputItem? selectedItem; + + /// Handles changes of select menu + final ValueSetter? onChanged; + + /// Handles changes of input text + final ValueSetter? onTextChanged; + + /// Determines the size of the input field. + /// Default is `ZetaDateInputSize.large` + final ZetaWidgetSize? size; + + /// The input's leading icon. + final Widget? leadingIcon; + + /// If provided, displays a label above the input field. + final Widget? label; + + /// If provided, displays a hint below the input field. + final String? hint; + + /// Determines if the input field should be enabled (default) or disabled. + final bool enabled; + + /// Determines if the input field should be displayed in error style. + /// Default is `false`. + /// If `enabled` is `false`, this has no effect. + final bool hasError; + + /// In combination with `hasError: true`, provides the error message + /// to be displayed below the input field. + final String? errorText; + + /// {@macro zeta-component-rounded} + final bool rounded; + + @override + State createState() => _ZetaSelectInputState(); + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('rounded', rounded)) + ..add( + ObjectFlagProperty?>.has( + 'onChanged', + onChanged, + ), + ) + ..add(EnumProperty('size', size)) + ..add(StringProperty('hint', hint)) + ..add(DiagnosticsProperty('enabled', enabled)) + ..add(DiagnosticsProperty('hasError', hasError)) + ..add(StringProperty('errorText', errorText)) + ..add(ObjectFlagProperty?>.has('onTextChanged', onTextChanged)); + } +} + +class _ZetaSelectInputState extends State { + final OverlayPortalController _overlayController = OverlayPortalController(); + final _link = LayerLink(); + late String? _selectedValue; + late List _menuItems; + Size _menuSize = Size.zero; + _MenuPosition? _menuPosition = _MenuPosition.bottom; + + @override + void initState() { + super.initState(); + _selectedValue = widget.selectedItem?.value; + _menuItems = List.from(widget.items); + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: _link, + child: OverlayPortal( + controller: _overlayController, + overlayChildBuilder: (BuildContext context) { + return CompositedTransformFollower( + link: _link, + targetAnchor: _menuPosition == _MenuPosition.top ? Alignment.topLeft : Alignment.bottomLeft, + followerAnchor: _menuPosition == _MenuPosition.top ? Alignment.bottomLeft : Alignment.topLeft, + child: Align( + alignment: _menuPosition == _MenuPosition.top ? Alignment.bottomLeft : Alignment.topLeft, + child: _ZetaSelectInputMenu( + size: _menuSize, + itemSize: widget.size, + items: _menuItems, + selectedValue: _selectedValue, + onSelected: (item) { + if (item != null) { + _selectedValue = item.value; + widget.onChanged?.call(item); + } + _overlayController.hide(); + }, + rounded: widget.rounded, + ), + ), + ); + }, + child: _InputComponent( + size: widget.size, + label: widget.label, + hint: widget.hint, + leadingIcon: widget.leadingIcon, + enabled: widget.enabled, + rounded: widget.rounded, + hasError: widget.hasError, + errorText: widget.errorText, + initialValue: _selectedValue, + onToggleMenu: widget.items.isEmpty + ? null + : () { + if (_overlayController.isShowing) { + _overlayController.hide(); + return setState(() {}); + } + final box = context.findRenderObject() as RenderBox?; + final offset = box?.size.topLeft( + box.localToGlobal(Offset.zero), + ); + final upperHeight = offset?.dy ?? 0; + final lowerHeight = MediaQuery.of(context).size.height - upperHeight - (box?.size.height ?? 0); + setState(() { + _menuPosition = upperHeight > lowerHeight ? _MenuPosition.top : _MenuPosition.bottom; + _menuSize = Size( + box?.size.width ?? (MediaQuery.of(context).size.width - ZetaSpacing.x10), + (upperHeight > lowerHeight ? upperHeight : lowerHeight) - ZetaSpacing.m, + ); + _menuItems = List.from(widget.items); + }); + _overlayController.show(); + }, + menuIsShowing: _overlayController.isShowing, + onChanged: (value) { + widget.onTextChanged?.call(value); + _selectedValue = value; + _menuItems = widget.items + .where( + (item) => item.value.toLowerCase().contains(value.toLowerCase()), + ) + .toList(); + final item = widget.items.firstWhereOrNull( + (item) => item.value.toLowerCase() == value.toLowerCase(), + ); + widget.onChanged?.call(item); + setState(() {}); + }, + ), + ), + ); + } +} + +class _InputComponent extends StatefulWidget { + const _InputComponent({ + this.size, + this.label, + this.hint, + this.leadingIcon, + this.enabled = true, + this.rounded = true, + this.hasError = false, + this.errorText, + this.initialValue, + this.onChanged, + this.onToggleMenu, + this.menuIsShowing = false, + }); + + final ZetaWidgetSize? size; + final Widget? label; + final String? hint; + final Widget? leadingIcon; + final bool enabled; + final bool rounded; + final bool hasError; + final String? errorText; + final String? initialValue; + final void Function(String)? onChanged; + final VoidCallback? onToggleMenu; + final bool menuIsShowing; + + @override + State<_InputComponent> createState() => _InputComponentState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(EnumProperty('size', size)) + ..add(StringProperty('hint', hint)) + ..add(DiagnosticsProperty('enabled', enabled)) + ..add(DiagnosticsProperty('rounded', rounded)) + ..add(DiagnosticsProperty('hasError', hasError)) + ..add(StringProperty('errorText', errorText)) + ..add(ObjectFlagProperty.has('onChanged', onChanged)) + ..add(ObjectFlagProperty.has('onToggleMenu', onToggleMenu)) + ..add(DiagnosticsProperty('menuIsShowing', menuIsShowing)) + ..add(StringProperty('initialValue', initialValue)); + } +} + +class _InputComponentState extends State<_InputComponent> { + final _controller = TextEditingController(); + late ZetaWidgetSize _size; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _setParams(); + } + + @override + void didUpdateWidget(_InputComponent oldWidget) { + super.didUpdateWidget(oldWidget); + _setParams(); + } + + void _setParams() { + _controller.text = widget.initialValue ?? ''; + _size = widget.size ?? ZetaWidgetSize.large; + _hasError = widget.hasError; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final zeta = Zeta.of(context); + final showError = _hasError && widget.errorText != null; + final hintErrorColor = widget.enabled + ? showError + ? zeta.colors.red + : zeta.colors.cool.shade70 + : zeta.colors.cool.shade50; + final iconSize = _iconSize(_size); + final inputVerticalPadding = _inputVerticalPadding(_size); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.label != null) + Padding( + padding: const EdgeInsets.only(bottom: 5), + child: DefaultTextStyle( + style: ZetaTextStyles.bodyMedium.copyWith( + color: widget.enabled ? zeta.colors.textDefault : zeta.colors.cool.shade50, + ), + child: widget.label!, + ), + ), + TextFormField( + enabled: widget.enabled, + controller: _controller, + onChanged: widget.onChanged, + style: _size == ZetaWidgetSize.small ? ZetaTextStyles.bodyXSmall : ZetaTextStyles.bodyMedium, + decoration: InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 10, + vertical: inputVerticalPadding, + ), + prefixIcon: widget.leadingIcon == null + ? null + : Padding( + padding: const EdgeInsets.only(left: ZetaSpacing.x2_5, right: ZetaSpacing.xs), + child: IconTheme( + data: IconThemeData( + color: widget.enabled ? zeta.colors.cool.shade70 : zeta.colors.cool.shade50, + size: iconSize, + ), + child: widget.leadingIcon!, + ), + ), + prefixIconConstraints: const BoxConstraints( + minHeight: ZetaSpacing.m, + minWidth: ZetaSpacing.m, + ), + suffixIcon: widget.onToggleMenu == null + ? null + : Padding( + padding: const EdgeInsets.only(right: ZetaSpacing.xxs), + child: IconButton( + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + onPressed: widget.onToggleMenu, + icon: Icon( + widget.menuIsShowing + ? (widget.rounded ? ZetaIcons.expand_less_round : ZetaIcons.expand_less_sharp) + : (widget.rounded ? ZetaIcons.expand_more_round : ZetaIcons.expand_more_sharp), + color: widget.enabled ? zeta.colors.textDefault : zeta.colors.cool.shade50, + size: iconSize, + ), + ), + ), + suffixIconConstraints: const BoxConstraints( + minHeight: ZetaSpacing.m, + minWidth: ZetaSpacing.m, + ), + hintStyle: _size == ZetaWidgetSize.small + ? ZetaTextStyles.bodyXSmall.copyWith( + color: widget.enabled ? zeta.colors.textDefault : zeta.colors.cool.shade50, + ) + : ZetaTextStyles.bodyMedium.copyWith( + color: widget.enabled ? zeta.colors.textDefault : zeta.colors.cool.shade50, + ), + filled: !widget.enabled || _hasError ? true : null, + fillColor: widget.enabled + ? _hasError + ? zeta.colors.red.shade10 + : null + : zeta.colors.cool.shade30, + enabledBorder: _hasError + ? _errorInputBorder(zeta, rounded: widget.rounded) + : _defaultInputBorder(zeta, rounded: widget.rounded), + focusedBorder: _hasError + ? _errorInputBorder(zeta, rounded: widget.rounded) + : _focusedInputBorder(zeta, rounded: widget.rounded), + disabledBorder: _defaultInputBorder(zeta, rounded: widget.rounded), + errorBorder: _errorInputBorder(zeta, rounded: widget.rounded), + focusedErrorBorder: _errorInputBorder(zeta, rounded: widget.rounded), + ), + ), + if (widget.hint != null || showError) + Padding( + padding: const EdgeInsets.only(top: 5), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: Icon( + showError && widget.enabled + ? (widget.rounded ? ZetaIcons.error_round : ZetaIcons.error_sharp) + : (widget.rounded ? ZetaIcons.info_round : ZetaIcons.info_sharp), + size: ZetaSpacing.b, + color: hintErrorColor, + ), + ), + Expanded( + child: Text( + showError && widget.enabled ? widget.errorText! : widget.hint!, + style: ZetaTextStyles.bodyXSmall.copyWith( + color: hintErrorColor, + ), + ), + ), + ], + ), + ), + ], + ); + } + + double _inputVerticalPadding(ZetaWidgetSize size) => switch (size) { + ZetaWidgetSize.large => ZetaSpacing.x3, + ZetaWidgetSize.medium => ZetaSpacing.x2, + ZetaWidgetSize.small => ZetaSpacing.x2, + }; + + double _iconSize(ZetaWidgetSize size) => switch (size) { + ZetaWidgetSize.large => ZetaSpacing.x5, + ZetaWidgetSize.medium => ZetaSpacing.x5, + ZetaWidgetSize.small => ZetaSpacing.x4, + }; + + OutlineInputBorder _defaultInputBorder( + Zeta zeta, { + required bool rounded, + }) => + OutlineInputBorder( + borderRadius: rounded ? ZetaRadius.minimal : ZetaRadius.none, + borderSide: BorderSide(color: zeta.colors.cool.shade40), + ); + + OutlineInputBorder _focusedInputBorder( + Zeta zeta, { + required bool rounded, + }) => + OutlineInputBorder( + borderRadius: rounded ? ZetaRadius.minimal : ZetaRadius.none, + borderSide: BorderSide(color: zeta.colors.blue.shade50), + ); + + OutlineInputBorder _errorInputBorder( + Zeta zeta, { + required bool rounded, + }) => + OutlineInputBorder( + borderRadius: rounded ? ZetaRadius.minimal : ZetaRadius.none, + borderSide: BorderSide(color: zeta.colors.red.shade50), + ); +} + +/// Class for [ZetaSelectInputItem] +class ZetaSelectInputItem extends StatelessWidget { + ///Public constructor for [ZetaSelectInputItem] + const ZetaSelectInputItem({ + super.key, + required this.value, + this.size = ZetaWidgetSize.large, + }) : rounded = true, + selected = false, + onPressed = null; + + const ZetaSelectInputItem._({ + super.key, + required this.rounded, + required this.selected, + required this.value, + this.onPressed, + this.size = ZetaWidgetSize.large, + }); + + /// {@macro zeta-component-rounded} + final bool rounded; + + /// If [ZetaSelectInputItem] is selected + final bool selected; + + /// Value of [ZetaSelectInputItem] + final String value; + + /// Handles clicking for [ZetaSelectInputItem] + final VoidCallback? onPressed; + + /// The size of [ZetaSelectInputItem] + final ZetaWidgetSize size; + + /// Returns copy of [ZetaSelectInputItem] with those private variables included + ZetaSelectInputItem copyWith({ + bool? rounded, + bool? selected, + VoidCallback? onPressed, + ZetaWidgetSize? size, + }) { + return ZetaSelectInputItem._( + rounded: rounded ?? this.rounded, + selected: selected ?? this.selected, + onPressed: onPressed ?? this.onPressed, + size: size ?? this.size, + value: value, + key: key, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('rounded', rounded)) + ..add(DiagnosticsProperty('selected', selected)) + ..add(StringProperty('value', value)) + ..add(ObjectFlagProperty.has('onPressed', onPressed)) + ..add(EnumProperty('size', size)); + } + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + + return DefaultTextStyle( + style: ZetaTextStyles.bodyMedium, + child: OutlinedButton( + onPressed: onPressed, + style: _getStyle(colors, size), + child: Text(value), + ), + ); + } + + ButtonStyle _getStyle(ZetaColors colors, ZetaWidgetSize size) { + final visualDensity = switch (size) { + ZetaWidgetSize.large => 0.0, + ZetaWidgetSize.medium => -2.0, + ZetaWidgetSize.small => -4.0, + }; + return ButtonStyle( + backgroundColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.hovered)) { + return colors.surfaceHovered; + } + + if (states.contains(MaterialState.pressed)) { + return colors.surfaceSelected; + } + + if (states.contains(MaterialState.disabled) || onPressed == null) { + return colors.surfaceDisabled; + } + return colors.surfacePrimary; + }), + foregroundColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.disabled)) { + return colors.textDisabled; + } + return colors.textDefault; + }), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: rounded ? ZetaRadius.minimal : ZetaRadius.none, + ), + ), + side: MaterialStatePropertyAll( + selected ? BorderSide(color: colors.primary.shade60) : BorderSide.none, + ), + padding: const MaterialStatePropertyAll( + EdgeInsets.symmetric(horizontal: ZetaSpacing.b), + ), + elevation: const MaterialStatePropertyAll(0), + overlayColor: const MaterialStatePropertyAll(Colors.transparent), + textStyle: MaterialStatePropertyAll( + size == ZetaWidgetSize.small ? ZetaTextStyles.bodyXSmall : ZetaTextStyles.bodyMedium, + ), + minimumSize: const MaterialStatePropertyAll(Size.fromHeight(48)), + alignment: Alignment.centerLeft, + visualDensity: VisualDensity( + horizontal: visualDensity, + vertical: visualDensity, + ), + ); + } +} + +class _ZetaSelectInputMenu extends StatelessWidget { + const _ZetaSelectInputMenu({ + required this.items, + required this.onSelected, + required this.size, + this.selectedValue, + this.rounded = true, + this.itemSize, + }); + + /// Input items for the menu + final List items; + + /// Handles selecting an item from the menu + final ValueSetter onSelected; + + /// The value of the currently selected item + final String? selectedValue; + + /// The size of the menu. + final Size size; + + /// {@macro zeta-component-rounded} + final bool rounded; + + /// The size of [ZetaSelectInputItem] + final ZetaWidgetSize? itemSize; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add( + ObjectFlagProperty>.has( + 'onSelected', + onSelected, + ), + ) + ..add(DiagnosticsProperty('rounded', rounded)) + ..add(StringProperty('selectedValue', selectedValue)) + ..add(DiagnosticsProperty('size', size)) + ..add(EnumProperty('itemSize', itemSize)); + } + + @override + Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: size.width, + maxHeight: size.height, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: colors.surfacePrimary, + borderRadius: rounded ? ZetaRadius.minimal : ZetaRadius.none, + boxShadow: const [ + BoxShadow(blurRadius: 2, color: Color.fromRGBO(40, 51, 61, 0.04)), + BoxShadow( + blurRadius: 8, + color: Color.fromRGBO(96, 104, 112, 0.16), + blurStyle: BlurStyle.outer, + offset: Offset(0, 4), + ), + ], + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: items.map((item) { + return item.copyWith( + rounded: rounded, + selected: selectedValue?.toLowerCase() == item.value.toLowerCase(), + onPressed: () => onSelected(item), + size: itemSize, + ); + }).toList(), + ), + ), + ), + ); + } +} diff --git a/lib/zeta_flutter.dart b/lib/zeta_flutter.dart index f56ab17e..dcb83bb9 100644 --- a/lib/zeta_flutter.dart +++ b/lib/zeta_flutter.dart @@ -38,6 +38,7 @@ export 'src/components/progress/progress_circle.dart'; export 'src/components/radio/radio.dart'; export 'src/components/search_bar/search_bar.dart'; export 'src/components/segmented_control/segmented_control.dart'; +export 'src/components/select_input/select_input.dart'; export 'src/components/snack_bar/snack_bar.dart'; export 'src/components/stepper/stepper.dart'; export 'src/components/switch/zeta_switch.dart';