diff --git a/example/lib/pages/components/dropdown_example.dart b/example/lib/pages/components/dropdown_example.dart index 0b37dcc2..2fb0ee93 100644 --- a/example/lib/pages/components/dropdown_example.dart +++ b/example/lib/pages/components/dropdown_example.dart @@ -11,45 +11,62 @@ class DropdownExample extends StatefulWidget { } class _DropdownExampleState extends State { - ZetaDropdownItem selectedItem = ZetaDropdownItem( - value: "Item 1", - leadingIcon: Icon(ZetaIcons.star_round), - ); + String selectedItem = "Item 1"; @override Widget build(BuildContext context) { + final items = [ + ZetaDropdownItem( + value: "Item 1", + icon: Icon(ZetaIcons.star_round), + ), + ZetaDropdownItem( + value: "Item 2", + icon: Icon(ZetaIcons.star_half_round), + ), + ZetaDropdownItem( + value: "Item 3", + ) + ]; + return ExampleScaffold( name: "Dropdown", - child: Center( - child: SingleChildScrollView( - child: SizedBox( - width: 320, - child: Column(children: [ + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ ZetaDropdown( - leadingType: LeadingStyle.checkbox, + disabled: true, + type: ZetaDropdownMenuType.standard, onChange: (value) { setState(() { selectedItem = value; }); }, - selectedItem: selectedItem, - items: [ - ZetaDropdownItem( - value: "Item 1", - leadingIcon: Icon(ZetaIcons.star_round), - ), - ZetaDropdownItem( - value: "Item 2", - leadingIcon: Icon(ZetaIcons.star_half_round), - ), - ZetaDropdownItem( - value: "Item 3", - ) - ], + value: selectedItem, + items: items, ), - Text('Selected item : ${selectedItem.value}') - ])), - ), + Text('Selected item : ${selectedItem}') + ], + ), + ), + ZetaDropdown( + items: items, + value: selectedItem, + type: ZetaDropdownMenuType.checkbox, + ), + ZetaDropdown( + items: items, + value: selectedItem, + size: ZetaDropdownSize.mini, + type: ZetaDropdownMenuType.radio, + ), + ], ), ); } diff --git a/example/widgetbook/pages/components/dropdown_widgetbook.dart b/example/widgetbook/pages/components/dropdown_widgetbook.dart index 8cdc9191..053c277a 100644 --- a/example/widgetbook/pages/components/dropdown_widgetbook.dart +++ b/example/widgetbook/pages/components/dropdown_widgetbook.dart @@ -3,6 +3,7 @@ import 'package:widgetbook/widgetbook.dart'; import 'package:zeta_flutter/zeta_flutter.dart'; import '../../test/test_components.dart'; +import '../../utils/utils.dart'; Widget dropdownUseCase(BuildContext context) => WidgetbookTestWidget( widget: Center( @@ -19,52 +20,37 @@ class DropdownExample extends StatefulWidget { } class _DropdownExampleState extends State { - List _children = [ + final items = [ ZetaDropdownItem( value: "Item 1", - leadingIcon: Icon(ZetaIcons.star_round), + icon: Icon(ZetaIcons.star_round), ), ZetaDropdownItem( value: "Item 2", - leadingIcon: Icon(ZetaIcons.star_half_round), + icon: Icon(ZetaIcons.star_half_round), ), ZetaDropdownItem( value: "Item 3", ) ]; - late ZetaDropdownItem selectedItem = ZetaDropdownItem( - value: "Item 1", - leadingIcon: Icon(ZetaIcons.star_round), - ); - @override Widget build(BuildContext _) { - return SingleChildScrollView( - child: SizedBox( - width: double.infinity, - child: Column(children: [ - ZetaDropdown( - leadingType: widget.c.knobs.list( - label: "Checkbox type", - options: [ - LeadingStyle.none, - LeadingStyle.checkbox, - LeadingStyle.radio, - ], - ), - onChange: (value) { - setState(() { - selectedItem = value; - }); - }, - selectedItem: selectedItem, - items: _children, - rounded: widget.c.knobs.boolean(label: "Rounded"), - isMinimized: widget.c.knobs.boolean(label: "Minimized"), - ), - Text('Selected item : ${selectedItem.value}') - ])), + return ZetaDropdown( + type: widget.c.knobs.list( + label: "Dropdown type", + options: ZetaDropdownMenuType.values, + labelBuilder: enumLabelBuilder, + ), + onChange: (value) {}, + items: items, + rounded: widget.c.knobs.boolean(label: "Rounded"), + disabled: widget.c.knobs.boolean(label: "Disabled"), + size: widget.c.knobs.list( + label: 'Size', + options: ZetaDropdownSize.values, + labelBuilder: enumLabelBuilder, + ), ); } } diff --git a/lib/src/components/dropdown/dropdown.dart b/lib/src/components/dropdown/dropdown.dart index 0e761d81..b88036c5 100644 --- a/lib/src/components/dropdown/dropdown.dart +++ b/lib/src/components/dropdown/dropdown.dart @@ -1,63 +1,153 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../zeta_flutter.dart'; +/// Sets the type of a [ZetaDropdown] +enum ZetaDropdownMenuType { + /// No leading elements before each item unless an icon is given to the [ZetaDropdownItem] + standard, + + /// Displays a [ZetaCheckbox] before each item. + checkbox, + + /// Displays a [ZetaRadio] before each item. + radio +} + +/// Used to set the size of a [ZetaDropdown] +enum ZetaDropdownSize { + /// Initial width of 320dp. + standard, + + /// Initial width of 120dp. + mini, +} + +/// An item used in a [ZetaDropdown]. +class ZetaDropdownItem { + /// Creates a new [ZetaDropdownItem] + ZetaDropdownItem({ + String? label, + required this.value, + this.icon, + }) { + this.label = label ?? this.value.toString(); + } + + /// The label for the item. + late final String label; + + /// The value of the item. + final T value; + + /// The icon shown next to the dropdown item. + /// + /// Will not be shown if the type of [ZetaDropdown] is set to anything other than [ZetaDropdownMenuType.standard] + final Widget? icon; +} + /// Class for [ZetaDropdown] -class ZetaDropdown extends StatefulWidget { - ///Constructor of [ZetaDropdown] +class ZetaDropdown extends StatefulWidget { + /// Creates a new [ZetaDropdown]. const ZetaDropdown({ - super.key, required this.items, - required this.onChange, - required this.selectedItem, + this.onChange, + this.value, this.rounded = true, - this.leadingType = LeadingStyle.none, - this.isMinimized = false, - }); + this.disabled = false, + this.type = ZetaDropdownMenuType.standard, + this.size = ZetaDropdownSize.standard, + super.key, + }) : assert(items.length > 0, 'Items must be greater than 0.'); - /// Input items as list of [ZetaDropdownItem] - final List items; + /// The items displayed in the dropdown. + final List> items; - /// Currently selected item - final ZetaDropdownItem selectedItem; + /// The value of the selected item. + /// + /// If no [ZetaDropdownItem] in [items] has a matching value, the first item in [items] will be set as the selected item. + final T? value; - /// Handles changes of dropdown menu - final ValueSetter onChange; + /// Called with the selected value whenever the dropdown is changed. + final ValueSetter? onChange; /// {@macro zeta-component-rounded} final bool rounded; - /// The style for the leading widget. Can be a checkbox or radio button - final LeadingStyle leadingType; + /// Disables the dropdown. + final bool disabled; - /// If menu is minimised. - final bool isMinimized; + /// The type of the dropdown menu. + /// + /// Defaults to [ZetaDropdownMenuType.standard] + final ZetaDropdownMenuType type; + + /// The size of the dropdown menu. + /// + /// Defaults to [ZetaDropdownSize.mini] + final ZetaDropdownSize size; @override - State createState() => _ZetaDropDownState(); + State> createState() => _ZetaDropDownState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties - ..add(EnumProperty('leadingType', leadingType)) + ..add(EnumProperty('leadingType', type)) ..add(DiagnosticsProperty('rounded', rounded)) - ..add( - ObjectFlagProperty>.has( - 'onChange', - onChange, - ), - ) - ..add(DiagnosticsProperty('isMinimized', isMinimized)); + ..add(IterableProperty>('items', items)) + ..add(DiagnosticsProperty('selectedItem', value)) + ..add(ObjectFlagProperty?>.has('onChange', onChange)) + ..add(EnumProperty('size', size)) + ..add(DiagnosticsProperty('disabled', disabled)); } } -class _ZetaDropDownState extends State { +class _ZetaDropDownState extends State> { final OverlayPortalController _tooltipController = OverlayPortalController(); final _link = LayerLink(); - final _menuKey = GlobalKey(); // declare a global key + final _menuKey = GlobalKey(); + final _headerKey = GlobalKey(); + + ZetaDropdownItem? _selectedItem; + + bool get _allocateLeadingSpace { + return widget.type != ZetaDropdownMenuType.standard || widget.items.map((item) => item.icon != null).contains(true); + } + + @override + void initState() { + super.initState(); + _setSelectedItem(); + } - /// Returns if click event position is within the header. + @override + void didUpdateWidget(ZetaDropdown oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value) { + setState(_setSelectedItem); + } + if (widget.disabled) { + unawaited( + Future.delayed(Duration.zero).then( + (value) => _tooltipController.hide(), + ), + ); + } + } + + void _setSelectedItem() { + try { + _selectedItem = widget.items.firstWhere((item) => item.value == widget.value); + } catch (e) { + _selectedItem = widget.items.first; + } + } + + // Returns if click event position is within the header. bool _isInHeader( Offset headerPosition, Size headerSize, @@ -85,25 +175,29 @@ class _ZetaDropDownState extends State { alignment: AlignmentDirectional.topStart, child: TapRegion( onTapOutside: (event) { - final headerBox = _menuKey.currentContext!.findRenderObject()! as RenderBox; + final headerBox = _headerKey.currentContext!.findRenderObject()! as RenderBox; final headerPosition = headerBox.localToGlobal(Offset.zero); - - if (!_isInHeader( + final inHeader = _isInHeader( headerPosition, headerBox.size, event.position, - )) _tooltipController.hide(); + ); + if (!inHeader) _tooltipController.hide(); }, - child: ZetaDropDownMenu( + child: _ZetaDropDownMenu( items: widget.items, - selected: widget.selectedItem.value, + selected: _selectedItem?.value, + rounded: widget.rounded, + allocateLeadingSpace: _allocateLeadingSpace, width: _size, - boxType: widget.leadingType, - onPress: (item) { - if (item != null) { - widget.onChange(item); - } + key: _menuKey, + menuType: widget.type, + onSelected: (item) { + setState(() { + _selectedItem = item; + }); + widget.onChange?.call(item.value); _tooltipController.hide(); }, ), @@ -111,18 +205,19 @@ class _ZetaDropDownState extends State { ), ); }, - child: widget.selectedItem.copyWith( - round: widget.rounded, - focus: _tooltipController.isShowing, - press: onTap, - inputKey: _menuKey, + child: _DropdownItem( + onPress: !widget.disabled ? onTap : null, + value: _selectedItem ?? widget.items.first, + allocateLeadingSpace: widget.type == ZetaDropdownMenuType.standard && _selectedItem?.icon != null, + rounded: widget.rounded, + key: _headerKey, ), ), ), ); } - double get _size => widget.isMinimized ? 120 : 320; + double get _size => widget.size == ZetaDropdownSize.mini ? 120 : 320; void onTap() { _tooltipController.toggle(); @@ -140,104 +235,62 @@ class _ZetaDropDownState extends State { } } -/// Checkbox enum for different checkbox types -enum LeadingStyle { - /// No Leading - none, - - /// Circular checkbox - checkbox, - - /// Square checkbox - radio -} - -/// Class for [ZetaDropdownItem] -class ZetaDropdownItem extends StatefulWidget { - ///Public constructor for [ZetaDropdownItem] - const ZetaDropdownItem({ +/// Class for [_DropdownItem] +class _DropdownItem extends StatefulWidget { + ///Public constructor for [_DropdownItem] + const _DropdownItem({ super.key, required this.value, - this.leadingIcon, - }) : rounded = true, - selected = false, - leadingType = LeadingStyle.none, - itemKey = null, - onPress = null; - - const ZetaDropdownItem._({ - super.key, + required this.allocateLeadingSpace, required this.rounded, - required this.selected, - required this.value, - this.leadingIcon, - this.onPress, - this.leadingType, + this.selected = false, + this.menuType = ZetaDropdownMenuType.standard, this.itemKey, + this.onPress, }); /// {@macro zeta-component-rounded} final bool rounded; - /// If [ZetaDropdownItem] is selected - final bool selected; + final bool allocateLeadingSpace; - /// Value of [ZetaDropdownItem] - final String value; + /// If [_DropdownItem] is selected + final bool selected; - /// Leading icon for [ZetaDropdownItem] - final Icon? leadingIcon; + /// Value of [_DropdownItem] + final ZetaDropdownItem value; - /// Handles clicking for [ZetaDropdownItem] + /// Handles clicking for [_DropdownItem] final VoidCallback? onPress; /// If checkbox is to be shown, the type of it. - final LeadingStyle? leadingType; + final ZetaDropdownMenuType menuType; /// Key for item final GlobalKey? itemKey; - /// Returns copy of [ZetaDropdownItem] with those private variables included - ZetaDropdownItem copyWith({ - bool? round, - bool? focus, - LeadingStyle? boxType, - VoidCallback? press, - GlobalKey? inputKey, - }) { - return ZetaDropdownItem._( - rounded: round ?? rounded, - selected: focus ?? selected, - onPress: press ?? onPress, - leadingType: boxType ?? leadingType, - itemKey: inputKey ?? itemKey, - value: value, - leadingIcon: leadingIcon, - key: key, - ); - } - @override - State createState() => _ZetaDropdownMenuItemState(); + State<_DropdownItem> createState() => _DropdownItemState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties ..add(DiagnosticsProperty('rounded', rounded)) ..add(DiagnosticsProperty('selected', selected)) - ..add(StringProperty('value', value)) ..add(ObjectFlagProperty.has('onPress', onPress)) - ..add(EnumProperty('leadingType', leadingType)) + ..add(EnumProperty('leadingType', menuType)) ..add( DiagnosticsProperty>?>( 'itemKey', itemKey, ), - ); + ) + ..add(DiagnosticsProperty('allocateLeadingSpace', allocateLeadingSpace)) + ..add(DiagnosticsProperty>('value', value)); } } -class _ZetaDropdownMenuItemState extends State { +class _DropdownItemState extends State<_DropdownItem> { final controller = MaterialStatesController(); @override @@ -254,45 +307,68 @@ class _ZetaDropdownMenuItemState extends State { Widget build(BuildContext context) { final colors = Zeta.of(context).colors; - return DefaultTextStyle( - style: ZetaTextStyles.bodyMedium, - child: OutlinedButton( - key: widget.itemKey, - onPressed: widget.onPress, - style: _getStyle(colors), - child: Row( - children: [ - const SizedBox(width: ZetaSpacing.x3), - _getLeadingWidget(), - const SizedBox(width: ZetaSpacing.x3), - Text( - widget.value, - ), - ], - ).paddingVertical(ZetaSpacing.x2_5), + Widget? leading = _getLeadingWidget(); + + if (leading != null) { + leading = Padding( + padding: const EdgeInsets.only(right: ZetaSpacing.x3), + child: leading, + ); + } + + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: ZetaSpacing.x10), + child: DefaultTextStyle( + style: ZetaTextStyles.bodyMedium, + child: OutlinedButton( + key: widget.itemKey, + onPressed: widget.onPress, + style: _getStyle(colors), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: ZetaSpacing.x3), + if (leading != null) leading, + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: ZetaSpacing.x2), + child: Align( + alignment: Alignment.centerLeft, + child: FittedBox( + child: Text( + widget.value.label, + ), + ), + ), + ), + ), + ], + ).paddingVertical(ZetaSpacing.x2_5), + ), ), ); } - Widget _getLeadingWidget() { - switch (widget.leadingType!) { - case LeadingStyle.checkbox: + Widget? _getLeadingWidget() { + if (!widget.allocateLeadingSpace) return null; + switch (widget.menuType) { + case ZetaDropdownMenuType.checkbox: return Checkbox( value: widget.selected, onChanged: (val) { widget.onPress!.call(); }, ); - case LeadingStyle.radio: + case ZetaDropdownMenuType.radio: return Radio( value: widget.selected, groupValue: true, onChanged: (val) { - widget.onPress!.call(); + widget.onPress?.call(); }, ); - case LeadingStyle.none: - return widget.leadingIcon ?? + case ZetaDropdownMenuType.standard: + return widget.value.icon ?? const SizedBox( width: 24, ); @@ -302,17 +378,19 @@ class _ZetaDropdownMenuItemState extends State { ButtonStyle _getStyle(ZetaColors colors) { return ButtonStyle( backgroundColor: MaterialStateProperty.resolveWith((states) { - if (states.contains(MaterialState.hovered)) { - return colors.surfaceHovered; + if (states.contains(MaterialState.disabled)) { + return colors.surfaceDisabled; + } + if (widget.selected) { + return colors.surfaceSelected; } - if (states.contains(MaterialState.pressed)) { return colors.surfaceSelected; } - - if (states.contains(MaterialState.disabled) || widget.onPress == null) { - return colors.surfaceDisabled; + if (states.contains(MaterialState.hovered)) { + return colors.surfaceHovered; } + return colors.surfacePrimary; }), foregroundColor: MaterialStateProperty.resolveWith((states) { @@ -326,9 +404,7 @@ class _ZetaDropdownMenuItemState extends State { borderRadius: widget.rounded ? ZetaRadius.minimal : ZetaRadius.none, ), ), - side: MaterialStatePropertyAll( - widget.selected ? BorderSide(color: colors.primary.shade60) : BorderSide.none, - ), + side: const MaterialStatePropertyAll(BorderSide.none), padding: const MaterialStatePropertyAll(EdgeInsets.zero), elevation: const MaterialStatePropertyAll(0), overlayColor: const MaterialStatePropertyAll(Colors.transparent), @@ -347,27 +423,28 @@ class _ZetaDropdownMenuItemState extends State { } } -///Class for [ZetaDropDownMenu] -class ZetaDropDownMenu extends StatefulWidget { - ///Constructor for [ZetaDropDownMenu] - const ZetaDropDownMenu({ - super.key, +class _ZetaDropDownMenu extends StatefulWidget { + ///Constructor for [_ZetaDropDownMenu] + const _ZetaDropDownMenu({ required this.items, - required this.onPress, + required this.onSelected, required this.selected, + required this.allocateLeadingSpace, this.rounded = false, this.width, - this.boxType, + this.menuType = ZetaDropdownMenuType.standard, + super.key, }); /// Input items for the menu - final List items; + final List> items; ///Handles clicking of item in menu - final ValueSetter onPress; + final ValueSetter> onSelected; + + final bool allocateLeadingSpace; - /// If item in menu is the currently selected item - final String selected; + final T? selected; /// {@macro zeta-component-rounded} final bool rounded; @@ -376,32 +453,30 @@ class ZetaDropDownMenu extends StatefulWidget { final double? width; /// If items have checkboxes, the type of that checkbox. - final LeadingStyle? boxType; + final ZetaDropdownMenuType menuType; @override - State createState() => _ZetaDropDownMenuState(); + State<_ZetaDropDownMenu> createState() => _ZetaDropDownMenuState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties - ..add( - ObjectFlagProperty>.has( - 'onPress', - onPress, - ), - ) ..add(DiagnosticsProperty('rounded', rounded)) ..add(DoubleProperty('width', width)) - ..add(EnumProperty('boxType', boxType)) - ..add(StringProperty('selected', selected)); + ..add(EnumProperty('boxType', menuType)) + ..add(IterableProperty>('items', items)) + ..add(ObjectFlagProperty>>.has('onSelected', onSelected)) + ..add(DiagnosticsProperty('allocateLeadingSpace', allocateLeadingSpace)) + ..add(DiagnosticsProperty('selected', selected)); } } -class _ZetaDropDownMenuState extends State { +class _ZetaDropDownMenuState extends State<_ZetaDropDownMenu> { @override Widget build(BuildContext context) { final colors = Zeta.of(context).colors; return Container( + padding: const EdgeInsets.all(ZetaSpacing.x3), decoration: BoxDecoration( color: colors.surfacePrimary, borderRadius: widget.rounded ? ZetaRadius.minimal : ZetaRadius.none, @@ -420,22 +495,19 @@ class _ZetaDropDownMenuState extends State { builder: (BuildContext bcontext) { return Column( mainAxisSize: MainAxisSize.min, - children: widget.items.map((item) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - item.copyWith( - round: widget.rounded, - focus: widget.selected == item.value, - boxType: widget.boxType, - press: () { - widget.onPress(item); - }, - ), - const SizedBox(height: ZetaSpacing.x1), - ], - ); - }).toList(), + children: widget.items + .map((item) { + return _DropdownItem( + value: item, + onPress: () => widget.onSelected(item), + selected: item.value == widget.selected, + allocateLeadingSpace: widget.allocateLeadingSpace, + menuType: widget.menuType, + rounded: widget.rounded, + ); + }) + .divide(const SizedBox(height: ZetaSpacing.x1)) + .toList(), ); }, ),