diff --git a/example/lib/pages/components/select_input_example.dart b/example/lib/pages/components/select_input_example.dart index ebfe784e..d6d3030e 100644 --- a/example/lib/pages/components/select_input_example.dart +++ b/example/lib/pages/components/select_input_example.dart @@ -11,6 +11,8 @@ class SelectInputExample extends StatefulWidget { } class _SelectInputExampleState extends State { + final formKey = GlobalKey(); + @override Widget build(BuildContext context) { return ExampleScaffold( @@ -19,27 +21,36 @@ class _SelectInputExampleState extends State { child: SingleChildScrollView( child: SizedBox( width: 320, - child: Column( - children: [ - ZetaSelectInput( - label: 'Label', - hintText: 'Default hint text', - leadingIcon: Icon(ZetaIcons.star_round), - items: [ - ZetaDropdownItem( - value: "Item 1", - icon: Icon(ZetaIcons.star_round), - ), - ZetaDropdownItem( - value: "Item 2", - icon: Icon(ZetaIcons.star_half_round), - ), - ZetaDropdownItem( - value: "Item 3", - ) - ], - ) - ], + child: Form( + key: formKey, + child: Column( + children: [ + ZetaSelectInput( + label: 'Label', + hintText: 'Default hint text', + initialValue: "Item 1", + items: [ + ZetaDropdownItem( + value: "Item 1", + icon: Icon(ZetaIcons.star_round), + ), + ZetaDropdownItem( + value: "Item 2", + icon: Icon(ZetaIcons.star_half_round), + ), + ZetaDropdownItem( + value: "Item 3", + ), + ], + ), + ZetaButton( + label: 'Validate', + onPressed: () { + formKey.currentState?.validate(); + }, + ) + ], + ), ), ), ), diff --git a/lib/src/components/dropdown/dropdown.dart b/lib/src/components/dropdown/dropdown.dart index 0bcb75a7..576c6421 100644 --- a/lib/src/components/dropdown/dropdown.dart +++ b/lib/src/components/dropdown/dropdown.dart @@ -26,13 +26,20 @@ enum ZetaDropdownSize { mini, } +/// A class for controlling a [ZetaDropdown] +/// +/// Can be accquired from the builder method of a [ZetaDropdown] abstract class ZetaDropdownController { + /// Returns true if the dropdown is open. bool get isOpen; + /// Opens the dropdown. void open(); + /// Closes the dropdown. void close(); + /// Toggles the dropdown open or closed depending on its current state. void toggle(); } @@ -42,7 +49,6 @@ class _DropdownControllerImpl implements ZetaDropdownController { final OverlayPortalController overlayPortalController; @override - // TODO: implement isOpen bool get isOpen => overlayPortalController.isShowing; @override @@ -90,8 +96,9 @@ class ZetaDropdown extends StatefulWidget { this.type = ZetaDropdownMenuType.standard, this.size = ZetaDropdownSize.standard, this.builder, + this.onDismissed, super.key, - }) : assert(items.length > 0, 'Items must be greater than 0.'); + }); /// The items displayed in the dropdown. final List> items; @@ -104,7 +111,10 @@ class ZetaDropdown extends StatefulWidget { /// Called with the selected value whenever the dropdown is changed. /// /// {@macro on-change-disable} - final ValueSetter? onChange; + final ValueSetter>? onChange; + + /// Called when the dropdown is dimissed. + final VoidCallback? onDismissed; /// {@macro zeta-component-rounded} final bool rounded; @@ -119,6 +129,9 @@ class ZetaDropdown extends StatefulWidget { /// Defaults to [ZetaDropdownSize.mini] final ZetaDropdownSize size; + /// A custom builder for the child of the dropdown. + /// + /// Provides a build context, the currently selected item in the dropdown and a controller which can be used to open/close the dropdown. final Widget Function( BuildContext context, ZetaDropdownItem? selectedItem, @@ -126,7 +139,7 @@ class ZetaDropdown extends StatefulWidget { )? builder; @override - State> createState() => _ZetaDropDownState(); + State> createState() => ZetaDropDownState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -135,8 +148,17 @@ class ZetaDropdown extends StatefulWidget { ..add(DiagnosticsProperty('rounded', rounded)) ..add(IterableProperty>('items', items)) ..add(DiagnosticsProperty('selectedItem', value)) - ..add(ObjectFlagProperty?>.has('onChange', onChange)) - ..add(EnumProperty('size', size)); + ..add(EnumProperty('size', size)) + ..add(ObjectFlagProperty>?>.has('onChange', onChange)) + ..add(ObjectFlagProperty.has('onDismissed', onDismissed)) + ..add( + ObjectFlagProperty< + Widget Function( + BuildContext context, + ZetaDropdownItem? selectedItem, + ZetaDropdownController controller, + )?>.has('builder', builder), + ); } } @@ -149,13 +171,13 @@ enum MenuPosition { down, } -class _ZetaDropDownState extends State> { +class ZetaDropDownState extends State> { final _DropdownControllerImpl _dropdownController = _DropdownControllerImpl( overlayPortalController: OverlayPortalController(), ); final _link = LayerLink(); final _menuKey = GlobalKey(); - final _headerKey = GlobalKey(); + final _childKey = GlobalKey(); MenuPosition _menuPosition = MenuPosition.down; ZetaDropdownItem? _selectedItem; @@ -170,13 +192,16 @@ class _ZetaDropDownState extends State> { _setSelectedItem(); } + /// Returns true if the dropdown is open. + bool get isOpen => _dropdownController.isOpen; + @override void didUpdateWidget(ZetaDropdown oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.value != widget.value) { setState(_setSelectedItem); } - if (widget.onChange != null) { + if (widget.onChange == null) { unawaited( Future.delayed(Duration.zero).then( (value) => _dropdownController.close(), @@ -187,15 +212,17 @@ class _ZetaDropDownState extends State> { /// Return position of header Offset get _headerPos { - final headerBox = _headerKey.currentContext!.findRenderObject()! as RenderBox; + final headerBox = _childKey.currentContext!.findRenderObject()! as RenderBox; return headerBox.localToGlobal(Offset.zero); } void _setSelectedItem() { - try { - _selectedItem = widget.items.firstWhere((item) => item.value == widget.value); - } catch (e) { - _selectedItem = widget.items.first; + if (widget.items.isNotEmpty) { + try { + _selectedItem = widget.items.firstWhere((item) => item.value == widget.value); + } catch (e) { + _selectedItem = null; + } } } @@ -216,14 +243,17 @@ class _ZetaDropDownState extends State> { late Widget child; if (widget.builder != null) { - child = widget.builder!(context, _selectedItem, _dropdownController); + child = Container( + key: _childKey, + child: widget.builder!(context, _selectedItem, _dropdownController), + ); } else { child = _DropdownItem( - onPress: widget.onChange != null ? onTap : null, + onPress: widget.onChange != null ? _onTap : null, value: _selectedItem ?? widget.items.first, allocateLeadingSpace: widget.type == ZetaDropdownMenuType.standard && _selectedItem?.icon != null, rounded: widget.rounded, - key: _headerKey, + key: _childKey, ); } @@ -240,19 +270,24 @@ class _ZetaDropDownState extends State> { ? Alignment.topLeft : Alignment.bottomLeft, // Align overlay dropdown in its correct position followerAnchor: _menuPosition == MenuPosition.up ? Alignment.bottomLeft : Alignment.topLeft, + offset: const Offset(0, ZetaSpacing.xL * -1), child: Align( alignment: _menuPosition == MenuPosition.up ? AlignmentDirectional.bottomStart : AlignmentDirectional.topStart, child: TapRegion( onTapOutside: (event) { - final headerBox = _headerKey.currentContext!.findRenderObject()! as RenderBox; + final headerBox = _childKey.currentContext!.findRenderObject()! as RenderBox; + final headerPosition = headerBox.localToGlobal(Offset.zero); final inHeader = _isInHeader( headerPosition, headerBox.size, event.position, ); - if (!inHeader) _dropdownController.close(); + if (!inHeader) { + _dropdownController.close(); + widget.onDismissed?.call(); + } }, child: _ZetaDropDownMenu( items: widget.items, @@ -266,7 +301,7 @@ class _ZetaDropDownState extends State> { setState(() { _selectedItem = item; }); - widget.onChange?.call(item.value); + widget.onChange?.call(item); _dropdownController.close(); }, ), @@ -282,10 +317,10 @@ class _ZetaDropDownState extends State> { double get _size => widget.size == ZetaDropdownSize.mini ? 120 : 320; - void onTap() { + void _onTap() { /// Version 1 : Calculate if overflow happens based on using calculations from sizes. final height = MediaQuery.of(context).size.height; - final headerRenderBox = _headerKey.currentContext!.findRenderObject()! as RenderBox; + final headerRenderBox = _childKey.currentContext!.findRenderObject()! as RenderBox; final dropdownItemHeight = headerRenderBox.size.height; /// Calculate if overflow can happen @@ -307,12 +342,14 @@ class _ZetaDropDownState extends State> { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add( - DiagnosticsProperty>>( - 'menuKey', - _menuKey, - ), - ); + properties + ..add( + DiagnosticsProperty>>( + 'menuKey', + _menuKey, + ), + ) + ..add(DiagnosticsProperty('isOpen', isOpen)); } } @@ -384,6 +421,12 @@ class _DropdownItemState extends State<_DropdownItem> { }); } + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final colors = Zeta.of(context).colors; @@ -404,6 +447,7 @@ class _DropdownItemState extends State<_DropdownItem> { child: OutlinedButton( key: widget.itemKey, onPressed: widget.onPress, + statesController: controller, style: _getStyle(colors), child: Row( mainAxisSize: MainAxisSize.min, @@ -484,7 +528,7 @@ class _DropdownItemState extends State<_DropdownItem> { ), side: WidgetStateBorderSide.resolveWith((states) { if (states.contains(WidgetState.focused)) { - return BorderSide(color: colors.borderPrimary); + return BorderSide(color: colors.borderPrimary, width: ZetaSpacing.xL); } return BorderSide.none; }), diff --git a/lib/src/components/select_input/select_input.dart b/lib/src/components/select_input/select_input.dart index 385c4300..84565da1 100644 --- a/lib/src/components/select_input/select_input.dart +++ b/lib/src/components/select_input/select_input.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -5,17 +6,20 @@ import '../../../zeta_flutter.dart'; import '../../interfaces/form_field.dart'; /// Class for [ZetaSelectInput] -class ZetaSelectInput extends ZetaFormField> { +class ZetaSelectInput extends ZetaFormField { ///Constructor of [ZetaSelectInput] const ZetaSelectInput({ super.key, required this.items, this.onTextChanged, this.size = ZetaWidgetSize.medium, - this.leadingIcon, this.label, this.hintText, this.rounded = true, + this.prefix, + this.placeholder, + this.validator, + this.errorText, super.disabled = false, super.initialValue, super.onChange, @@ -28,19 +32,30 @@ class ZetaSelectInput extends ZetaFormField> { /// Handles changes of input text final ValueSetter? onTextChanged; + /// The prefix widget for the input. + /// + /// Will be overriden if the selected item has an icon. + final Widget? prefix; + + /// The error text shown beneath the input when the validator fails. + final String? errorText; + + /// The validator for the input. + final String? Function(T? value)? validator; + /// Determines the size of the input field. /// Defaults to [ZetaWidgetSize.medium] final ZetaWidgetSize size; - /// The input's leading icon. - final Widget? leadingIcon; - /// If provided, displays a label above the input field. final String? label; /// If provided, displays a hint below the input field. final String? hintText; + /// The placeholder for the input. + final String? placeholder; + /// {@macro zeta-component-rounded} final bool rounded; @@ -53,49 +68,120 @@ class ZetaSelectInput extends ZetaFormField> { ..add(DiagnosticsProperty('rounded', rounded)) ..add(EnumProperty('size', size)) ..add(StringProperty('hint', hintText)) - ..add(ObjectFlagProperty?>.has('onTextChanged', onTextChanged)); + ..add(ObjectFlagProperty?>.has('onTextChanged', onTextChanged)) + ..add(IterableProperty>('items', items)) + ..add(StringProperty('errorText', errorText)) + ..add(ObjectFlagProperty.has('validator', validator)) + ..add(StringProperty('label', label)) + ..add(StringProperty('placeholder', placeholder)); } } class _ZetaSelectInputState extends State> { - late List> _filteredItems; + final GlobalKey> _dropdownKey = GlobalKey(); + final TextEditingController _inputController = TextEditingController(); + + ZetaDropdownItem? _selectedItem; + + bool get _dropdownOpen => _dropdownKey.currentState?.isOpen ?? false; + + IconData get _icon { + if (_dropdownOpen) { + return widget.rounded ? ZetaIcons.expand_less_round : ZetaIcons.expand_less_sharp; + } else { + return widget.rounded ? ZetaIcons.expand_more_round : ZetaIcons.expand_more_sharp; + } + } @override void initState() { - _filteredItems = widget.items; + _inputController.addListener( + () => setState(() {}), + ); + _setInitialItem(); super.initState(); } - void _onInputChanged(String? val) { + @override + void didUpdateWidget(covariant ZetaSelectInput oldWidget) { + if (oldWidget.initialValue != widget.initialValue) { + setState(_setInitialItem); + } + super.didUpdateWidget(oldWidget); + } + + void _setInitialItem() { + _selectedItem = widget.items.firstWhereOrNull((item) => item.value == widget.initialValue); + _inputController.text = _selectedItem?.label ?? ''; + } + + void _onInputChanged(ZetaDropdownController dropdownController) { + dropdownController.open(); + setState(() { + _selectedItem = null; + }); + widget.onChange?.call(null); + } + + void _onIconTapped(ZetaDropdownController dropdownController) { + dropdownController.toggle(); + setState(() {}); + } + + void _onDropdownChanged(ZetaDropdownItem item) { + _inputController.text = item.label; setState(() { - if (val != null && val.isNotEmpty) { - _filteredItems = widget.items - .where( - (item) => item.label.toLowerCase().contains( - val.toLowerCase(), - ), - ) - .toList(); - } else { - _filteredItems = widget.items; - } + _selectedItem = item; }); + widget.onChange?.call(item.value); } @override Widget build(BuildContext context) { + final colors = Zeta.of(context).colors; + + late List> filteredItems; + if (_inputController.text.isNotEmpty) { + filteredItems = widget.items.where( + (item) { + return item.label.toLowerCase().startsWith(_inputController.text.toLowerCase()); + }, + ).toList(); + } else { + filteredItems = widget.items; + } + return ZetaDropdown( - items: _filteredItems, - builder: (context, selectedItem, controller) { + items: filteredItems, + onChange: !widget.disabled ? _onDropdownChanged : null, + key: _dropdownKey, + value: _selectedItem?.value, + onDismissed: () => setState(() {}), + builder: (context, _, controller) { return ZetaTextInput( size: widget.size, - prefix: widget.leadingIcon, + requirementLevel: widget.requirementLevel, + disabled: widget.disabled, + validator: (_) { + final currentValue = _selectedItem?.value; + String? errorText; + final customValidation = widget.validator?.call(currentValue); + if ((currentValue == null && widget.requirementLevel != ZetaFormFieldRequirement.optional) || + customValidation != null) { + errorText = customValidation ?? widget.errorText ?? ''; + } + + return errorText; + }, + controller: _inputController, + prefix: _selectedItem?.icon ?? widget.prefix, label: widget.label, + placeholder: widget.placeholder, hintText: widget.hintText, - onChange: _onInputChanged, + onChange: (val) => _onInputChanged(controller), suffix: IconButton( - icon: const Icon(ZetaIcons.expand_more_round), - onPressed: widget.items.isEmpty ? null : controller.toggle, + icon: Icon(_icon, color: colors.iconSubtle), + onPressed: widget.disabled ? null : () => _onIconTapped(controller), ), ); }, diff --git a/lib/src/components/text_input/text_input.dart b/lib/src/components/text_input/text_input.dart index 7216c40f..bf804a79 100644 --- a/lib/src/components/text_input/text_input.dart +++ b/lib/src/components/text_input/text_input.dart @@ -35,6 +35,7 @@ class ZetaTextInput extends ZetaFormField { this.prefixTextStyle, this.suffixText, this.suffixTextStyle, + this.onSubmit, }) : assert(initialValue == null || controller == null, 'Only one of initial value and controller can be accepted.'), assert(prefix == null || prefixText == null, 'Only one of prefix or prefixText can be accepted.'), assert(suffix == null || suffixText == null, 'Only one of suffix or suffixText can be accepted.'); @@ -42,6 +43,9 @@ class ZetaTextInput extends ZetaFormField { /// The label displayed above the input. final String? label; + /// Called when the input is submitted. + final void Function(String? val)? onSubmit; + /// The hint text displayed below the input. final String? hintText; @@ -107,7 +111,8 @@ class ZetaTextInput extends ZetaFormField { ..add(DiagnosticsProperty('rounded', rounded)) ..add(DiagnosticsProperty('disabled', disabled)) ..add(IterableProperty('inputFormatters', inputFormatters)) - ..add(EnumProperty('requirementLevel', requirementLevel)); + ..add(EnumProperty('requirementLevel', requirementLevel)) + ..add(ObjectFlagProperty.has('onSubmit', onSubmit)); } } @@ -117,6 +122,7 @@ class ZetaTextInputState extends State implements ZetaFormFieldSt final GlobalKey> _key = GlobalKey(); ZetaColors get _colors => Zeta.of(context).colors; + // TODO(mikecoomber): refactor to use WidgetStateController bool _hovered = false; String? _errorText; @@ -235,6 +241,9 @@ class ZetaTextInputState extends State implements ZetaFormFieldSt _errorText = widget.errorText; }); } + if (oldWidget.initialValue != widget.initialValue && widget.initialValue != null) { + _controller.text = widget.initialValue!; + } super.didUpdateWidget(oldWidget); } @@ -283,6 +292,7 @@ class ZetaTextInputState extends State implements ZetaFormFieldSt }); return _errorText; }, + onFieldSubmitted: widget.onSubmit, textAlignVertical: TextAlignVertical.center, onChanged: widget.onChange, style: _baseTextStyle,