From adee0d19fd23696a2b45a57ffac67de05b355143 Mon Sep 17 00:00:00 2001 From: Harry Sild <46851868+Kypsis@users.noreply.github.com> Date: Fri, 22 Sep 2023 13:54:05 +0300 Subject: [PATCH] feat: [MDS-592] Create Dropdown component (#256) --- .../lib/src/storybook/stories/dropdown.dart | 221 +++++++ .../lib/src/storybook/stories/popover.dart | 20 +- example/lib/src/storybook/storybook.dart | 2 + lib/moon_design.dart | 1 + lib/src/theme/dropdown/dropdown_colors.dart | 56 ++ .../theme/dropdown/dropdown_properties.dart | 88 +++ lib/src/theme/dropdown/dropdown_shadows.dart | 36 ++ lib/src/theme/dropdown/dropdown_theme.dart | 83 +++ lib/src/theme/theme.dart | 10 + lib/src/widgets/dropdown/dropdown.dart | 555 ++++++++++++++++++ lib/src/widgets/popover/popover.dart | 64 +- test/dropdown_test.dart | 106 ++++ test/tooltip_test.dart | 7 +- 13 files changed, 1209 insertions(+), 40 deletions(-) create mode 100644 example/lib/src/storybook/stories/dropdown.dart create mode 100644 lib/src/theme/dropdown/dropdown_colors.dart create mode 100644 lib/src/theme/dropdown/dropdown_properties.dart create mode 100644 lib/src/theme/dropdown/dropdown_shadows.dart create mode 100644 lib/src/theme/dropdown/dropdown_theme.dart create mode 100644 lib/src/widgets/dropdown/dropdown.dart create mode 100644 test/dropdown_test.dart diff --git a/example/lib/src/storybook/stories/dropdown.dart b/example/lib/src/storybook/stories/dropdown.dart new file mode 100644 index 00000000..46b35acd --- /dev/null +++ b/example/lib/src/storybook/stories/dropdown.dart @@ -0,0 +1,221 @@ +import 'package:example/src/storybook/common/color_options.dart'; +import 'package:flutter/material.dart'; +import 'package:moon_design/moon_design.dart'; + +import 'package:storybook_flutter/storybook_flutter.dart'; + +const String _groupId = "dropdown"; + +bool _show = false; +bool _showInner = false; +Color? _buttonColor; +String _buttonName = "Piccolo"; + +class DropdownStory extends Story { + DropdownStory() + : super( + name: "Dropdown", + builder: (BuildContext context) { + final dropdownAnchorPositionKnob = context.knobs.nullable.options( + label: "dropdownAnchorPosition", + description: "Anchor position variants for MoonDropdown on the child (target).", + enabled: false, + initial: MoonDropdownAnchorPosition.bottom, + options: const [ + Option(label: "top", value: MoonDropdownAnchorPosition.top), + Option(label: "bottom", value: MoonDropdownAnchorPosition.bottom), + Option(label: "left", value: MoonDropdownAnchorPosition.left), + Option(label: "right", value: MoonDropdownAnchorPosition.right), + Option(label: "topLeft", value: MoonDropdownAnchorPosition.topLeft), + Option(label: "topRight", value: MoonDropdownAnchorPosition.topRight), + Option(label: "bottomLeft", value: MoonDropdownAnchorPosition.bottomLeft), + Option(label: "bottomRight", value: MoonDropdownAnchorPosition.bottomRight), + Option(label: "vertical", value: MoonDropdownAnchorPosition.vertical), + Option(label: "horizontal", value: MoonDropdownAnchorPosition.horizontal), + ], + ); + + final backgroundColorKnob = context.knobs.nullable.options( + label: "backgroundColor", + description: "MoonColors variants for MoonDropdown background.", + enabled: false, + initial: 0, + // piccolo + options: colorOptions, + ); + + final backgroundColor = colorTable(context)[backgroundColorKnob ?? 40]; + + final borderColorKnob = context.knobs.nullable.options( + label: "borderColor", + description: "MoonColors variants for MoonDropdown border.", + enabled: false, + initial: 0, + // piccolo + options: colorOptions, + ); + + final borderColor = colorTable(context)[borderColorKnob ?? 40]; + + final borderRadiusKnob = context.knobs.nullable.sliderInt( + label: "borderRadius", + description: "Border radius for MoonDropdown.", + enabled: false, + initial: 8, + max: 32, + ); + + final distanceToTargetKnob = context.knobs.nullable.slider( + label: "distanceToTarget", + description: "Set the distance to target child widget.", + enabled: false, + initial: 8, + max: 100, + ); + + final showShadowKnob = context.knobs.boolean( + label: "Show shadow", + description: "Show shadows under MoonDropdown.", + initial: true, + ); + + final constrainWidthToChildKnob = context.knobs.boolean( + label: "constrainWidthToChild", + description: "Constrain the width of the MoonDropdown to be same as the child (target).", + initial: true, + ); + + // Used to avoid the stale closure within callbacks in Story + final colorPiccolo = context.moonColors!.piccolo; + final colorKrillin = context.moonColors!.krillin100; + final colorRoshi100 = context.moonColors!.roshi100; + final colorRoshi60 = context.moonColors!.roshi60; + final colorRoshi10 = context.moonColors!.roshi10; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 64), + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MoonDropdown( + show: _show, + groupId: _groupId, + maxWidth: 250, + borderColor: borderColor ?? Colors.transparent, + backgroundColor: backgroundColor, + borderRadius: + borderRadiusKnob != null ? BorderRadius.circular(borderRadiusKnob.toDouble()) : null, + constrainWidthToChild: constrainWidthToChildKnob, + distanceToTarget: distanceToTargetKnob, + dropdownAnchorPosition: dropdownAnchorPositionKnob ?? MoonDropdownAnchorPosition.bottom, + dropdownShadows: showShadowKnob == true ? null : [], + onTapOutside: () => setState(() { + _show = false; + _showInner = false; + }), + content: Column( + children: [ + MoonMenuItem( + title: const Text("Piccolo"), + borderRadius: const MoonSquircleBorderRadius.all(MoonSquircleRadius(cornerRadius: 12)), + onTap: () => setState(() { + _show = false; + _buttonName = "Piccolo"; + _buttonColor = colorPiccolo; + }), + ), + const SizedBox(height: 4), + MoonMenuItem( + title: const Text("Krillin"), + borderRadius: const MoonSquircleBorderRadius.all(MoonSquircleRadius(cornerRadius: 12)), + onTap: () => setState(() { + _show = false; + _buttonName = "Krillin"; + _buttonColor = colorKrillin; + }), + ), + const SizedBox(height: 4), + MoonDropdown( + show: _showInner, + groupId: _groupId, + maxWidth: 100, + constrainWidthToChild: constrainWidthToChildKnob, + distanceToTarget: distanceToTargetKnob, + dropdownAnchorPosition: dropdownAnchorPositionKnob ?? MoonDropdownAnchorPosition.bottom, + followerAnchor: dropdownAnchorPositionKnob == null ? Alignment.topLeft : null, + targetAnchor: dropdownAnchorPositionKnob == null ? Alignment.topRight : null, + offset: dropdownAnchorPositionKnob == null ? const Offset(8, 0) : null, + content: Column( + children: [ + MoonMenuItem( + title: const Text("Roshi100"), + borderRadius: + const MoonSquircleBorderRadius.all(MoonSquircleRadius(cornerRadius: 12)), + onTap: () => setState(() { + _show = false; + _showInner = false; + _buttonName = "Roshi100"; + _buttonColor = colorRoshi100; + }), + ), + const SizedBox(height: 4), + MoonMenuItem( + title: const Text("Roshi60"), + borderRadius: + const MoonSquircleBorderRadius.all(MoonSquircleRadius(cornerRadius: 12)), + onTap: () => setState(() { + _show = false; + _showInner = false; + _buttonName = "Roshi60"; + _buttonColor = colorRoshi60; + }), + ), + const SizedBox(height: 4), + MoonMenuItem( + title: const Text("Roshi10"), + borderRadius: + const MoonSquircleBorderRadius.all(MoonSquircleRadius(cornerRadius: 12)), + onTap: () => setState(() { + _show = false; + _showInner = false; + _buttonName = "Roshi10"; + _buttonColor = colorRoshi10; + }), + ), + ], + ), + child: MoonMenuItem( + backgroundColor: _showInner ? context.moonColors!.heles : null, + title: const Text("Roshi"), + borderRadius: + const MoonSquircleBorderRadius.all(MoonSquircleRadius(cornerRadius: 12)), + onTap: () => setState(() { + _showInner = !_showInner; + }), + trailing: const MoonIcon( + MoonIcons.chevron_right_16, + size: 16, + ), + ), + ), + ], + ), + child: MoonFilledButton( + width: 128, + label: Text(_buttonName), + backgroundColor: _buttonColor, + onTap: () => setState(() => _show = !_show), + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); +} diff --git a/example/lib/src/storybook/stories/popover.dart b/example/lib/src/storybook/stories/popover.dart index 89be9913..f7359129 100644 --- a/example/lib/src/storybook/stories/popover.dart +++ b/example/lib/src/storybook/stories/popover.dart @@ -34,17 +34,6 @@ class PopoverStory extends Story { ], ); - final textColorKnob = context.knobs.nullable.options( - label: "Text color", - description: "MoonColors variants for MoonPopover text.", - enabled: false, - initial: 0, - // piccolo - options: colorOptions, - ); - - final textColor = colorTable(context)[textColorKnob ?? 40]; - final backgroundColorKnob = context.knobs.nullable.options( label: "backgroundColor", description: "MoonColors variants for MoonPopover background.", @@ -106,13 +95,13 @@ class PopoverStory extends Story { distanceToTarget: distanceToTargetKnob, popoverPosition: popoverPositionKnob ?? MoonPopoverPosition.top, popoverShadows: showShadowKnob == true ? null : [], + onTapOutside: () => setState(() => show = false), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 190), child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( - textDirection: Directionality.of(context), mainAxisSize: MainAxisSize.min, children: [ MoonAvatar( @@ -121,10 +110,7 @@ class PopoverStory extends Story { ), const SizedBox(width: 12), Expanded( - child: Text( - customLabelTextKnob, - style: TextStyle(color: textColor), - ), + child: Text(customLabelTextKnob), ), ], ), @@ -139,7 +125,7 @@ class PopoverStory extends Story { ), ), child: MoonFilledButton( - onTap: () => setState(() => show = true), + onTap: () => setState(() => show = !show), label: const Text("Tap me"), ), ); diff --git a/example/lib/src/storybook/storybook.dart b/example/lib/src/storybook/storybook.dart index 1823ec4b..25a2b344 100644 --- a/example/lib/src/storybook/storybook.dart +++ b/example/lib/src/storybook/storybook.dart @@ -12,6 +12,7 @@ import 'package:example/src/storybook/stories/circular_loader.dart'; import 'package:example/src/storybook/stories/circular_progress.dart'; import 'package:example/src/storybook/stories/dot_indicator.dart'; import 'package:example/src/storybook/stories/drawer.dart'; +import 'package:example/src/storybook/stories/dropdown.dart'; import 'package:example/src/storybook/stories/icons.dart'; import 'package:example/src/storybook/stories/linear_loader.dart'; import 'package:example/src/storybook/stories/linear_progress.dart'; @@ -123,6 +124,7 @@ class StorybookPage extends StatelessWidget { CircularProgressStory(), DotIndicatorStory(), DrawerStory(), + DropdownStory(), IconsStory(), LinearLoaderStory(), LinearProgressStory(), diff --git a/lib/moon_design.dart b/lib/moon_design.dart index 987d117e..1568f228 100644 --- a/lib/moon_design.dart +++ b/lib/moon_design.dart @@ -74,6 +74,7 @@ export 'package:moon_design/src/widgets/common/progress_indicators/circular_prog export 'package:moon_design/src/widgets/common/progress_indicators/linear_progress_indicator.dart'; export 'package:moon_design/src/widgets/dot_indicator/dot_indicator.dart'; export 'package:moon_design/src/widgets/drawer/drawer.dart'; +export 'package:moon_design/src/widgets/dropdown/dropdown.dart'; export 'package:moon_design/src/widgets/loaders/circular_loader.dart'; export 'package:moon_design/src/widgets/loaders/linear_loader.dart'; export 'package:moon_design/src/widgets/menu_item/menu_item.dart'; diff --git a/lib/src/theme/dropdown/dropdown_colors.dart b/lib/src/theme/dropdown/dropdown_colors.dart new file mode 100644 index 00000000..e4689b49 --- /dev/null +++ b/lib/src/theme/dropdown/dropdown_colors.dart @@ -0,0 +1,56 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/utils/color_premul_lerp.dart'; + +@immutable +class MoonDropdownColors extends ThemeExtension with DiagnosticableTreeMixin { + /// Dropdown text color. + final Color textColor; + + /// Dropdown icon color. + final Color iconColor; + + /// Dropdown background color. + final Color backgroundColor; + + const MoonDropdownColors({ + required this.textColor, + required this.iconColor, + required this.backgroundColor, + }); + + @override + MoonDropdownColors copyWith({ + Color? textColor, + Color? iconColor, + Color? backgroundColor, + }) { + return MoonDropdownColors( + textColor: textColor ?? this.textColor, + iconColor: iconColor ?? this.iconColor, + backgroundColor: backgroundColor ?? this.backgroundColor, + ); + } + + @override + MoonDropdownColors lerp(ThemeExtension? other, double t) { + if (other is! MoonDropdownColors) return this; + + return MoonDropdownColors( + textColor: colorPremulLerp(textColor, other.textColor, t)!, + iconColor: colorPremulLerp(iconColor, other.iconColor, t)!, + backgroundColor: colorPremulLerp(backgroundColor, other.backgroundColor, t)!, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonDropdownColors")) + ..add(ColorProperty("textColor", textColor)) + ..add(ColorProperty("iconColor", iconColor)) + ..add(ColorProperty("backgroundColor", backgroundColor)); + } +} diff --git a/lib/src/theme/dropdown/dropdown_properties.dart b/lib/src/theme/dropdown/dropdown_properties.dart new file mode 100644 index 00000000..c0b6fb85 --- /dev/null +++ b/lib/src/theme/dropdown/dropdown_properties.dart @@ -0,0 +1,88 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +@immutable +class MoonDropdownProperties extends ThemeExtension with DiagnosticableTreeMixin { + /// Dropdown border radius. + final BorderRadiusGeometry borderRadius; + + /// Dropdown distance to target child widget. + final double distanceToTarget; + + /// Dropdown transition duration (fade in or out animation). + final Duration transitionDuration; + + /// Dropdown transition curve (fade in or out animation). + final Curve transitionCurve; + + /// Padding around dropdown content. + final EdgeInsetsGeometry contentPadding; + + /// Margin of the dropdown. + final EdgeInsetsGeometry dropdownMargin; + + /// Dropdown text style. + final TextStyle textStyle; + + const MoonDropdownProperties({ + required this.borderRadius, + required this.distanceToTarget, + required this.transitionDuration, + required this.transitionCurve, + required this.contentPadding, + required this.dropdownMargin, + required this.textStyle, + }); + + @override + MoonDropdownProperties copyWith({ + BorderRadiusGeometry? borderRadius, + double? distanceToTarget, + Duration? transitionDuration, + Curve? transitionCurve, + EdgeInsetsGeometry? contentPadding, + EdgeInsetsGeometry? dropdownMargin, + TextStyle? textStyle, + }) { + return MoonDropdownProperties( + borderRadius: borderRadius ?? this.borderRadius, + distanceToTarget: distanceToTarget ?? this.distanceToTarget, + transitionDuration: transitionDuration ?? this.transitionDuration, + transitionCurve: transitionCurve ?? this.transitionCurve, + contentPadding: contentPadding ?? this.contentPadding, + dropdownMargin: dropdownMargin ?? this.dropdownMargin, + textStyle: textStyle ?? this.textStyle, + ); + } + + @override + MoonDropdownProperties lerp(ThemeExtension? other, double t) { + if (other is! MoonDropdownProperties) return this; + + return MoonDropdownProperties( + borderRadius: BorderRadiusGeometry.lerp(borderRadius, other.borderRadius, t)!, + distanceToTarget: lerpDouble(distanceToTarget, other.distanceToTarget, t)!, + transitionDuration: lerpDuration(transitionDuration, other.transitionDuration, t), + transitionCurve: other.transitionCurve, + contentPadding: EdgeInsetsGeometry.lerp(contentPadding, other.contentPadding, t)!, + dropdownMargin: EdgeInsetsGeometry.lerp(dropdownMargin, other.dropdownMargin, t)!, + textStyle: TextStyle.lerp(textStyle, other.textStyle, t)!, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonDropdownProperties")) + ..add(DiagnosticsProperty("borderRadius", borderRadius)) + ..add(DoubleProperty("distanceToTarget", distanceToTarget)) + ..add(DiagnosticsProperty("transitionDuration", transitionDuration)) + ..add(DiagnosticsProperty("transitionCurve", transitionCurve)) + ..add(DiagnosticsProperty("contentPadding", contentPadding)) + ..add(DiagnosticsProperty("dropdownMargin", dropdownMargin)) + ..add(DiagnosticsProperty("textStyle", textStyle)); + } +} diff --git a/lib/src/theme/dropdown/dropdown_shadows.dart b/lib/src/theme/dropdown/dropdown_shadows.dart new file mode 100644 index 00000000..7e085f0e --- /dev/null +++ b/lib/src/theme/dropdown/dropdown_shadows.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +@immutable +class MoonDropdownShadows extends ThemeExtension with DiagnosticableTreeMixin { + /// Dropdown shadows. + final List dropdownShadows; + + const MoonDropdownShadows({ + required this.dropdownShadows, + }); + + @override + MoonDropdownShadows copyWith({List? dropdownShadows}) { + return MoonDropdownShadows( + dropdownShadows: dropdownShadows ?? this.dropdownShadows, + ); + } + + @override + MoonDropdownShadows lerp(ThemeExtension? other, double t) { + if (other is! MoonDropdownShadows) return this; + + return MoonDropdownShadows( + dropdownShadows: BoxShadow.lerpList(dropdownShadows, other.dropdownShadows, t)!, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonDropdownShadows")) + ..add(DiagnosticsProperty>("dropdownShadows", dropdownShadows)); + } +} diff --git a/lib/src/theme/dropdown/dropdown_theme.dart b/lib/src/theme/dropdown/dropdown_theme.dart new file mode 100644 index 00000000..8dfff392 --- /dev/null +++ b/lib/src/theme/dropdown/dropdown_theme.dart @@ -0,0 +1,83 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/dropdown/dropdown_colors.dart'; +import 'package:moon_design/src/theme/dropdown/dropdown_properties.dart'; +import 'package:moon_design/src/theme/dropdown/dropdown_shadows.dart'; +import 'package:moon_design/src/theme/tokens/tokens.dart'; + +@immutable +class MoonDropdownTheme extends ThemeExtension with DiagnosticableTreeMixin { + /// MDS tokens. + final MoonTokens tokens; + + /// Dropdown colors. + final MoonDropdownColors colors; + + /// Dropdown properties. + final MoonDropdownProperties properties; + + /// Dropdown shadows. + final MoonDropdownShadows shadows; + + MoonDropdownTheme({ + required this.tokens, + MoonDropdownColors? colors, + MoonDropdownProperties? properties, + MoonDropdownShadows? shadows, + }) : colors = colors ?? + MoonDropdownColors( + textColor: tokens.colors.textPrimary, + iconColor: tokens.colors.iconPrimary, + backgroundColor: tokens.colors.gohan, + ), + properties = properties ?? + MoonDropdownProperties( + borderRadius: tokens.borders.interactiveMd, + distanceToTarget: tokens.sizes.x4s, + transitionDuration: Duration.zero, + transitionCurve: tokens.transitions.defaultTransitionCurve, + contentPadding: EdgeInsets.all(tokens.sizes.x5s), + dropdownMargin: EdgeInsets.all(tokens.sizes.x4s), + textStyle: tokens.typography.body.textDefault, + ), + shadows = shadows ?? MoonDropdownShadows(dropdownShadows: tokens.shadows.sm); + + @override + MoonDropdownTheme copyWith({ + MoonTokens? tokens, + MoonDropdownColors? colors, + MoonDropdownProperties? properties, + MoonDropdownShadows? shadows, + }) { + return MoonDropdownTheme( + tokens: tokens ?? this.tokens, + colors: colors ?? this.colors, + properties: properties ?? this.properties, + shadows: shadows ?? this.shadows, + ); + } + + @override + MoonDropdownTheme lerp(ThemeExtension? other, double t) { + if (other is! MoonDropdownTheme) return this; + + return MoonDropdownTheme( + tokens: tokens.lerp(other.tokens, t), + colors: colors.lerp(other.colors, t), + properties: properties.lerp(other.properties, t), + shadows: shadows.lerp(other.shadows, t), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder diagnosticProperties) { + super.debugFillProperties(diagnosticProperties); + diagnosticProperties + ..add(DiagnosticsProperty("type", "MoonDropdownTheme")) + ..add(DiagnosticsProperty("tokens", tokens)) + ..add(DiagnosticsProperty("colors", colors)) + ..add(DiagnosticsProperty("properties", properties)) + ..add(DiagnosticsProperty("shadows", shadows)); + } +} diff --git a/lib/src/theme/theme.dart b/lib/src/theme/theme.dart index b1c16b92..53dd9bb5 100644 --- a/lib/src/theme/theme.dart +++ b/lib/src/theme/theme.dart @@ -12,6 +12,7 @@ import 'package:moon_design/src/theme/checkbox/checkbox_theme.dart'; import 'package:moon_design/src/theme/chip/chip_theme.dart'; import 'package:moon_design/src/theme/dot_indicator/dot_indicator_theme.dart'; import 'package:moon_design/src/theme/drawer/drawer_theme.dart'; +import 'package:moon_design/src/theme/dropdown/dropdown_theme.dart'; import 'package:moon_design/src/theme/effects/effects_theme.dart'; import 'package:moon_design/src/theme/loaders/circular_loader/circular_loader_theme.dart'; import 'package:moon_design/src/theme/loaders/linear_loader/linear_loader_theme.dart'; @@ -84,6 +85,9 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { /// Moon Design System MoonDrawer widget theming. final MoonDrawerTheme drawerTheme; + /// Moon Design System MoonDropdown widget theming. + final MoonDropdownTheme dropdownTheme; + /// Moon Design System effects. final MoonEffectsTheme effects; @@ -150,6 +154,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { MoonCircularProgressTheme? circularProgressTheme, MoonDotIndicatorTheme? dotIndicatorTheme, MoonDrawerTheme? drawerTheme, + MoonDropdownTheme? dropdownTheme, MoonEffectsTheme? effects, MoonLinearLoaderTheme? linearLoaderTheme, MoonLinearProgressTheme? linearProgressTheme, @@ -180,6 +185,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { circularProgressTheme = circularProgressTheme ?? MoonCircularProgressTheme(tokens: tokens), dotIndicatorTheme = dotIndicatorTheme ?? MoonDotIndicatorTheme(tokens: tokens), drawerTheme = drawerTheme ?? MoonDrawerTheme(tokens: tokens), + dropdownTheme = dropdownTheme ?? MoonDropdownTheme(tokens: tokens), effects = effects ?? MoonEffectsTheme(tokens: tokens), linearLoaderTheme = linearLoaderTheme ?? MoonLinearLoaderTheme(tokens: tokens), linearProgressTheme = linearProgressTheme ?? MoonLinearProgressTheme(tokens: tokens), @@ -214,6 +220,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { MoonCircularProgressTheme? circularProgressTheme, MoonDotIndicatorTheme? dotIndicatorTheme, MoonDrawerTheme? drawerTheme, + MoonDropdownTheme? dropdownTheme, MoonEffectsTheme? effects, MoonLinearLoaderTheme? linearLoaderTheme, MoonLinearProgressTheme? linearProgressTheme, @@ -247,6 +254,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { circularProgressTheme: circularProgressTheme ?? this.circularProgressTheme, dotIndicatorTheme: dotIndicatorTheme ?? this.dotIndicatorTheme, drawerTheme: drawerTheme ?? this.drawerTheme, + dropdownTheme: dropdownTheme ?? this.dropdownTheme, effects: effects ?? this.effects, linearLoaderTheme: linearLoaderTheme ?? this.linearLoaderTheme, linearProgressTheme: linearProgressTheme ?? this.linearProgressTheme, @@ -286,6 +294,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { circularProgressTheme: circularProgressTheme.lerp(other.circularProgressTheme, t), dotIndicatorTheme: dotIndicatorTheme.lerp(other.dotIndicatorTheme, t), drawerTheme: drawerTheme.lerp(other.drawerTheme, t), + dropdownTheme: dropdownTheme.lerp(other.dropdownTheme, t), effects: effects.lerp(other.effects, t), linearLoaderTheme: linearLoaderTheme.lerp(other.linearLoaderTheme, t), linearProgressTheme: linearProgressTheme.lerp(other.linearProgressTheme, t), @@ -324,6 +333,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { ..add(DiagnosticsProperty("MoonCircularProgressTheme", circularProgressTheme)) ..add(DiagnosticsProperty("MoonDotIndicatorTheme", dotIndicatorTheme)) ..add(DiagnosticsProperty("MoonDrawerTheme", drawerTheme)) + ..add(DiagnosticsProperty("MoonDropdownTheme", dropdownTheme)) ..add(DiagnosticsProperty("MoonEffectsTheme", effects)) ..add(DiagnosticsProperty("MoonLinearLoaderTheme", linearLoaderTheme)) ..add(DiagnosticsProperty("MoonLinearProgressTheme", linearProgressTheme)) diff --git a/lib/src/widgets/dropdown/dropdown.dart b/lib/src/widgets/dropdown/dropdown.dart new file mode 100644 index 00000000..d8291b39 --- /dev/null +++ b/lib/src/widgets/dropdown/dropdown.dart @@ -0,0 +1,555 @@ +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/theme.dart'; +import 'package:moon_design/src/theme/tokens/colors.dart'; +import 'package:moon_design/src/theme/tokens/shadows.dart'; +import 'package:moon_design/src/theme/tokens/sizes.dart'; +import 'package:moon_design/src/theme/tokens/transitions.dart'; +import 'package:moon_design/src/theme/tokens/typography/typography.dart'; +import 'package:moon_design/src/utils/extensions.dart'; +import 'package:moon_design/src/utils/shape_decoration_premul.dart'; +import 'package:moon_design/src/utils/squircle/squircle_border.dart'; + +enum MoonDropdownAnchorPosition { + top, + topLeft, + topRight, + bottom, + bottomLeft, + bottomRight, + left, + right, + vertical, + horizontal, +} + +class MoonDropdown extends StatefulWidget { + /// Sets the dropdown anchor position on dropdown content (follower). + /// + /// Note: this will override [MoonDropdownAnchorPosition] property. + final Alignment? followerAnchor; + + /// Sets the dropdown anchor position on the child (target). + /// + /// Note: this will override [MoonDropdownAnchorPosition] property. + final Alignment? targetAnchor; + + /// If true, the dropdown will be constrained to the width of its child (target). + final bool constrainWidthToChild; + + /// Controls the dropdown visibility. + final bool show; + + /// The border radius of the dropdown. + final BorderRadiusGeometry? borderRadius; + + /// The color of the dropdown background. + final Color? backgroundColor; + + /// The color of the dropdown border. + final Color borderColor; + + /// Custom decoration for the dropdown. + final Decoration? decoration; + + /// The width of the dropdown border. + final double borderWidth; + + /// The distance from the tip of the dropdown arrow (tail) to the target widget. + /// + /// Note: this will be overriden by the [offset] property. + final double? distanceToTarget; + + /// Optional size constraint. If a constraint is not set the size will adjust to the content. + final double? minHeight; + + /// Optional size constraint. If a constraint is not set the size will adjust to the content. + final double? minWidth; + + /// Optional size constraint. If a constraint is not set the size will adjust to the content. + final double? maxHeight; + + /// Optional size constraint. If a constraint is not set the size will adjust to the content. + final double? maxWidth; + + /// Dropdown transition duration (fade in or out animation). + final Duration? transitionDuration; + + /// Dropdown transition curve (fade in or out animation). + final Curve? transitionCurve; + + /// Padding around the dropdown content. + final EdgeInsetsGeometry? contentPadding; + + /// The margin around dropdown. Used to prevent the dropdown from touching the edges of the viewport. + final EdgeInsetsGeometry? dropdownMargin; + + /// List of dropdown shadows. + final List? dropdownShadows; + + /// Sets the dropdown anchor position on the child (target). Defaults to [MoonDropdownAnchorPosition.bottom] + /// + /// Note: this is a convenience property whose parameters either will be overriden or ignored when using + /// [followerAnchor], [targetAnchor], [offset] or [maxWidth] properties. + final MoonDropdownAnchorPosition dropdownAnchorPosition; + + /// The offset of the dropdown. + /// + /// Note: this will override [distanceToTarget] property. + final Offset? offset; + + /// `RouteObserver` used to listen for route changes that will hide the dropdown when the widget's route is not active. + final RouteObserver>? routeObserver; + + /// The group id of the dropdown. Used to prevent the dropdown from closing when tapping inside nested dropdowns with + /// the same group id. + final String? groupId; + + /// The semantic label for the dropdown. + final String? semanticLabel; + + /// Callback that is called when the user taps outside the dropdown. + final VoidCallback? onTapOutside; + + /// The child (target) of the dropdown. + final Widget child; + + /// The widget that its placed inside the dropdown and functions as its content. + final Widget content; + + /// MDS dropdown widget. + const MoonDropdown({ + super.key, + required this.show, + this.followerAnchor, + this.targetAnchor, + this.constrainWidthToChild = false, + this.borderRadius, + this.backgroundColor, + this.borderColor = Colors.transparent, + this.decoration, + this.borderWidth = 0, + this.distanceToTarget, + this.minHeight, + this.minWidth, + this.maxHeight, + this.maxWidth, + this.transitionDuration, + this.transitionCurve, + this.contentPadding, + this.dropdownMargin, + this.dropdownShadows, + this.dropdownAnchorPosition = MoonDropdownAnchorPosition.bottom, + this.offset, + this.routeObserver, + this.groupId, + this.semanticLabel, + this.onTapOutside, + required this.child, + required this.content, + }); + + @override + _MoonDropdownState createState() => _MoonDropdownState(); +} + +class _MoonDropdownState extends State with RouteAware, SingleTickerProviderStateMixin { + late final Key _regionKey = widget.groupId != null ? ValueKey(widget.groupId) : ObjectKey(widget); + final LayerLink _layerLink = LayerLink(); + + AnimationController? _animationController; + CurvedAnimation? _curvedAnimation; + + OverlayEntry? _overlayEntry; + + bool _routeIsShowing = true; + + bool get shouldShowdropdown => widget.show && _routeIsShowing; + + void _showDropdown() { + _overlayEntry = OverlayEntry(builder: (BuildContext context) => _createOverlayContent()); + Overlay.of(context).insert(_overlayEntry!); + + _animationController!.value = 0; + _animationController!.forward(); + } + + void _updateDropdown() { + _overlayEntry?.markNeedsBuild(); + } + + void _removeDropdown({bool immediately = false}) { + if (immediately) { + _clearOverlayEntry(); + } else { + _animationController!.value = 1; + _animationController!.reverse().then((value) => _clearOverlayEntry()); + } + } + + void _handleTapOutside() { + widget.onTapOutside?.call(); + } + + void _clearOverlayEntry() { + if (_overlayEntry != null) { + _overlayEntry!.remove(); + _overlayEntry = null; + } + } + + _DropdownPositionProperties _resolveDropdownPositionParameters({ + required MoonDropdownAnchorPosition dropdownAnchorPosition, + required double distanceToTarget, + required double overlayWidth, + required double dropdownTargetGlobalLeft, + required double dropdownTargetGlobalCenter, + required double dropdownTargetGlobalRight, + required EdgeInsets dropdownMargin, + }) { + switch (dropdownAnchorPosition) { + case MoonDropdownAnchorPosition.top: + return _DropdownPositionProperties( + offset: Offset(0, -distanceToTarget), + targetAnchor: Alignment.topCenter, + followerAnchor: Alignment.bottomCenter, + dropdownMaxWidth: + overlayWidth - ((overlayWidth / 2 - dropdownTargetGlobalCenter) * 2).abs() - dropdownMargin.horizontal, + ); + + case MoonDropdownAnchorPosition.bottom: + return _DropdownPositionProperties( + offset: Offset(0, distanceToTarget), + targetAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.topCenter, + dropdownMaxWidth: + overlayWidth - ((overlayWidth / 2 - dropdownTargetGlobalCenter) * 2).abs() - dropdownMargin.horizontal, + ); + + case MoonDropdownAnchorPosition.left: + return _DropdownPositionProperties( + offset: Offset(-distanceToTarget, 0), + targetAnchor: Alignment.centerLeft, + followerAnchor: Alignment.centerRight, + dropdownMaxWidth: dropdownTargetGlobalLeft - distanceToTarget - dropdownMargin.left, + ); + + case MoonDropdownAnchorPosition.right: + return _DropdownPositionProperties( + offset: Offset(distanceToTarget, 0), + targetAnchor: Alignment.centerRight, + followerAnchor: Alignment.centerLeft, + dropdownMaxWidth: overlayWidth - dropdownTargetGlobalRight - distanceToTarget - dropdownMargin.right, + ); + + case MoonDropdownAnchorPosition.topLeft: + return _DropdownPositionProperties( + offset: Offset(0, -distanceToTarget), + targetAnchor: Alignment.topLeft, + followerAnchor: Alignment.bottomLeft, + dropdownMaxWidth: overlayWidth - dropdownTargetGlobalLeft - dropdownMargin.left, + ); + + case MoonDropdownAnchorPosition.topRight: + return _DropdownPositionProperties( + offset: Offset(0, -distanceToTarget), + targetAnchor: Alignment.topRight, + followerAnchor: Alignment.bottomRight, + dropdownMaxWidth: dropdownTargetGlobalRight - dropdownMargin.right, + ); + + case MoonDropdownAnchorPosition.bottomLeft: + return _DropdownPositionProperties( + offset: Offset(0, distanceToTarget), + targetAnchor: Alignment.bottomLeft, + followerAnchor: Alignment.topLeft, + dropdownMaxWidth: overlayWidth - dropdownTargetGlobalLeft - dropdownMargin.left, + ); + + case MoonDropdownAnchorPosition.bottomRight: + return _DropdownPositionProperties( + offset: Offset(0, distanceToTarget), + targetAnchor: Alignment.bottomRight, + followerAnchor: Alignment.topRight, + dropdownMaxWidth: dropdownTargetGlobalRight - dropdownMargin.right, + ); + + default: + throw AssertionError("No match: $dropdownAnchorPosition"); + } + } + + @override + void didPush() { + _routeIsShowing = true; + // Route was pushed onto navigator and is now topmost route. + if (shouldShowdropdown) { + _removeDropdown(); + + WidgetsBinding.instance.addPostFrameCallback((Duration _) { + if (!mounted) return; + + _showDropdown(); + }); + } + } + + @override + void didPushNext() { + _routeIsShowing = false; + + _removeDropdown(); + } + + @override + Future didPopNext() async { + _routeIsShowing = true; + + if (shouldShowdropdown) { + // Covering route was popped off the navigator. + _removeDropdown(); + + await Future.delayed(const Duration(milliseconds: 100), () { + if (mounted) _showDropdown(); + }); + } + } + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((Duration _) { + widget.routeObserver?.subscribe(this, ModalRoute.of(context)! as PageRoute); + }); + } + + @override + void didUpdateWidget(MoonDropdown oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.routeObserver != widget.routeObserver) { + oldWidget.routeObserver?.unsubscribe(this); + widget.routeObserver?.subscribe(this, ModalRoute.of(context)! as PageRoute); + } + + WidgetsBinding.instance.addPostFrameCallback((Duration _) { + if (!_routeIsShowing) return; + + if (oldWidget.dropdownAnchorPosition != widget.dropdownAnchorPosition) { + _removeDropdown(immediately: true); + _showDropdown(); + } else if (shouldShowdropdown && _overlayEntry == null) { + _showDropdown(); + } else if (!shouldShowdropdown && _overlayEntry != null) { + _removeDropdown(); + } + + _updateDropdown(); + }); + } + + @override + void deactivate() { + if (_overlayEntry != null) _removeDropdown(immediately: true); + + super.deactivate(); + } + + @override + void dispose() { + if (_overlayEntry != null) _removeDropdown(immediately: true); + + widget.routeObserver?.unsubscribe(this); + + super.dispose(); + } + + Widget _createOverlayContent() { + final BorderRadiusGeometry effectiveBorderRadius = + widget.borderRadius ?? context.moonTheme?.dropdownTheme.properties.borderRadius ?? BorderRadius.circular(12); + + final Color effectiveBackgroundColor = + widget.backgroundColor ?? context.moonTheme?.dropdownTheme.colors.backgroundColor ?? MoonColors.light.gohan; + + final Color effectiveTextColor = context.moonTheme?.dropdownTheme.colors.textColor ?? MoonColors.light.textPrimary; + + final Color effectiveIconColor = context.moonTheme?.dropdownTheme.colors.iconColor ?? MoonColors.light.iconPrimary; + + final TextStyle effectiveTextStyle = + context.moonTheme?.dropdownTheme.properties.textStyle ?? MoonTypography.typography.body.textDefault; + + final double effectiveDistanceToTarget = + widget.distanceToTarget ?? context.moonTheme?.dropdownTheme.properties.distanceToTarget ?? MoonSizes.sizes.x4s; + + final EdgeInsetsGeometry effectiveContentPadding = + widget.contentPadding ?? context.moonTheme?.dropdownTheme.properties.contentPadding ?? const EdgeInsets.all(4); + + final EdgeInsets resolvedContentPadding = effectiveContentPadding.resolve(Directionality.of(context)); + + final EdgeInsetsGeometry effectiveDropdownMargin = + widget.dropdownMargin ?? context.moonTheme?.dropdownTheme.properties.dropdownMargin ?? const EdgeInsets.all(8); + + final EdgeInsets resolvedDropdownMargin = effectiveDropdownMargin.resolve(Directionality.of(context)); + + final List effectivedropdownShadows = + widget.dropdownShadows ?? context.moonTheme?.dropdownTheme.shadows.dropdownShadows ?? MoonShadows.light.sm; + + MoonDropdownAnchorPosition dropdownAnchorPosition = widget.dropdownAnchorPosition; + + final RenderBox overlayRenderBox = Overlay.of(context).context.findRenderObject()! as RenderBox; + + final RenderBox targetRenderBox = context.findRenderObject()! as RenderBox; + + final Offset dropdownTargetGlobalCenter = + targetRenderBox.localToGlobal(targetRenderBox.size.center(Offset.zero), ancestor: overlayRenderBox); + + final Offset dropdownTargetGlobalLeft = + targetRenderBox.localToGlobal(targetRenderBox.size.centerLeft(Offset.zero), ancestor: overlayRenderBox); + + final Offset dropdownTargetGlobalRight = + targetRenderBox.localToGlobal(targetRenderBox.size.centerRight(Offset.zero), ancestor: overlayRenderBox); + + if (Directionality.of(context) == TextDirection.rtl || + dropdownAnchorPosition == MoonDropdownAnchorPosition.horizontal || + dropdownAnchorPosition == MoonDropdownAnchorPosition.vertical) { + switch (dropdownAnchorPosition) { + case MoonDropdownAnchorPosition.left: + dropdownAnchorPosition = MoonDropdownAnchorPosition.right; + case MoonDropdownAnchorPosition.right: + dropdownAnchorPosition = MoonDropdownAnchorPosition.left; + case MoonDropdownAnchorPosition.topLeft: + dropdownAnchorPosition = MoonDropdownAnchorPosition.topRight; + case MoonDropdownAnchorPosition.topRight: + dropdownAnchorPosition = MoonDropdownAnchorPosition.topLeft; + case MoonDropdownAnchorPosition.bottomLeft: + dropdownAnchorPosition = MoonDropdownAnchorPosition.bottomRight; + case MoonDropdownAnchorPosition.bottomRight: + dropdownAnchorPosition = MoonDropdownAnchorPosition.bottomLeft; + case MoonDropdownAnchorPosition.vertical: + dropdownAnchorPosition = dropdownTargetGlobalCenter.dy < overlayRenderBox.size.center(Offset.zero).dy + ? MoonDropdownAnchorPosition.bottom + : MoonDropdownAnchorPosition.top; + case MoonDropdownAnchorPosition.horizontal: + dropdownAnchorPosition = dropdownTargetGlobalCenter.dx < overlayRenderBox.size.center(Offset.zero).dx + ? MoonDropdownAnchorPosition.right + : MoonDropdownAnchorPosition.left; + default: + break; + } + } + + final _DropdownPositionProperties dropdownAnchorPositionParameters = _resolveDropdownPositionParameters( + dropdownAnchorPosition: dropdownAnchorPosition, + distanceToTarget: effectiveDistanceToTarget, + overlayWidth: overlayRenderBox.size.width, + dropdownTargetGlobalLeft: dropdownTargetGlobalLeft.dx, + dropdownTargetGlobalCenter: dropdownTargetGlobalCenter.dx, + dropdownTargetGlobalRight: dropdownTargetGlobalRight.dx, + dropdownMargin: resolvedDropdownMargin, + ); + + final double targetWidth = targetRenderBox.size.width; + + final double effectiveDropdownWidth = widget.constrainWidthToChild + ? targetWidth + : widget.maxWidth != null + ? widget.maxWidth! + : dropdownAnchorPositionParameters.dropdownMaxWidth; + + return Semantics( + label: widget.semanticLabel, + child: UnconstrainedBox( + child: CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: widget.offset ?? dropdownAnchorPositionParameters.offset, + followerAnchor: widget.followerAnchor ?? dropdownAnchorPositionParameters.followerAnchor, + targetAnchor: widget.targetAnchor ?? dropdownAnchorPositionParameters.targetAnchor, + child: TapRegion( + groupId: _regionKey, + behavior: HitTestBehavior.translucent, + onTapOutside: (PointerDownEvent _) => _handleTapOutside(), + child: RepaintBoundary( + child: FadeTransition( + opacity: _curvedAnimation!, + child: IconTheme( + data: IconThemeData(color: effectiveIconColor), + child: DefaultTextStyle( + style: effectiveTextStyle.copyWith(color: effectiveTextColor), + child: Container( + constraints: BoxConstraints( + minHeight: widget.minHeight ?? 0, + maxHeight: widget.maxHeight ?? double.infinity, + minWidth: widget.minWidth ?? 0, + maxWidth: effectiveDropdownWidth, + ), + padding: resolvedContentPadding, + decoration: widget.decoration ?? + ShapeDecorationWithPremultipliedAlpha( + color: effectiveBackgroundColor, + shadows: effectivedropdownShadows, + shape: MoonSquircleBorder( + borderRadius: effectiveBorderRadius.squircleBorderRadius(context), + side: BorderSide(color: widget.borderColor), + ), + ), + child: Directionality( + textDirection: Directionality.of(context), + child: widget.content, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final Duration effectiveTransitionDuration = widget.transitionDuration ?? + context.moonTheme?.dropdownTheme.properties.transitionDuration ?? + MoonTransitions.transitions.defaultTransitionDuration; + + final Curve effectiveTransitionCurve = widget.transitionCurve ?? + context.moonTheme?.dropdownTheme.properties.transitionCurve ?? + MoonTransitions.transitions.defaultTransitionCurve; + + _animationController ??= AnimationController( + duration: effectiveTransitionDuration, + vsync: this, + ); + + _curvedAnimation ??= CurvedAnimation( + parent: _animationController!, + curve: effectiveTransitionCurve, + ); + + return TapRegion( + groupId: _regionKey, + behavior: HitTestBehavior.translucent, + child: CompositedTransformTarget( + link: _layerLink, + child: widget.child, + ), + ); + } +} + +class _DropdownPositionProperties { + final Alignment followerAnchor; + final Alignment targetAnchor; + final double dropdownMaxWidth; + final Offset offset; + + _DropdownPositionProperties({ + required this.followerAnchor, + required this.targetAnchor, + required this.dropdownMaxWidth, + required this.offset, + }); +} diff --git a/lib/src/widgets/popover/popover.dart b/lib/src/widgets/popover/popover.dart index b1d12be3..6b97d94b 100644 --- a/lib/src/widgets/popover/popover.dart +++ b/lib/src/widgets/popover/popover.dart @@ -38,6 +38,9 @@ class MoonPopover extends StatefulWidget { /// The color of the popover border. final Color borderColor; + /// Custom decoration for the popover. + final Decoration? decoration; + /// The width of the popover border. final double borderWidth; @@ -77,13 +80,13 @@ class MoonPopover extends StatefulWidget { /// `RouteObserver` used to listen for route changes that will hide the popover when the widget's route is not active. final RouteObserver>? routeObserver; - /// Custom decoration for the popover. - final Decoration? decoration; - /// The semantic label for the popover. final String? semanticLabel; - /// The [child] widget which the popover will target. + /// Callback that is called when the user taps outside the popover. + final VoidCallback? onTapOutside; + + /// The child (target) of the popover. final Widget child; /// The widget that its placed inside the popover and functions as its content. @@ -96,6 +99,7 @@ class MoonPopover extends StatefulWidget { this.borderRadius, this.backgroundColor, this.borderColor = Colors.transparent, + this.decoration, this.borderWidth = 0, this.distanceToTarget, this.minHeight, @@ -109,8 +113,8 @@ class MoonPopover extends StatefulWidget { this.popoverShadows, this.popoverPosition = MoonPopoverPosition.top, this.routeObserver, - this.decoration, this.semanticLabel, + this.onTapOutside, required this.child, required this.content, }); @@ -134,7 +138,7 @@ class MoonPopover extends StatefulWidget { } class MoonPopoverState extends State with RouteAware, SingleTickerProviderStateMixin { - final GlobalKey _popoverKey = GlobalKey(); + late final ObjectKey _regionKey = ObjectKey(widget); final LayerLink _layerLink = LayerLink(); AnimationController? _animationController; @@ -170,6 +174,11 @@ class MoonPopoverState extends State with RouteAware, SingleTickerP } } + void _handleTapOutside() { + widget.onTapOutside?.call(); + _removePopover(); + } + void _clearOverlayEntry() { if (_overlayEntry != null) { MoonPopover._openedPopovers.remove(this); @@ -254,7 +263,7 @@ class MoonPopoverState extends State with RouteAware, SingleTickerP ); default: - throw AssertionError(popoverPosition); + throw AssertionError("No match: $popoverPosition"); } } @@ -371,17 +380,17 @@ class MoonPopoverState extends State with RouteAware, SingleTickerP final List effectivePopoverShadows = widget.popoverShadows ?? context.moonTheme?.popoverTheme.shadows.popoverShadows ?? MoonShadows.light.sm; - final overlayRenderBox = Overlay.of(context).context.findRenderObject()! as RenderBox; + final RenderBox overlayRenderBox = Overlay.of(context).context.findRenderObject()! as RenderBox; - final targetRenderBox = context.findRenderObject()! as RenderBox; + final RenderBox targetRenderBox = context.findRenderObject()! as RenderBox; - final popoverTargetGlobalCenter = + final Offset popoverTargetGlobalCenter = targetRenderBox.localToGlobal(targetRenderBox.size.center(Offset.zero), ancestor: overlayRenderBox); - final popoverTargetGlobalLeft = + final Offset popoverTargetGlobalLeft = targetRenderBox.localToGlobal(targetRenderBox.size.centerLeft(Offset.zero), ancestor: overlayRenderBox); - final popoverTargetGlobalRight = + final Offset popoverTargetGlobalRight = targetRenderBox.localToGlobal(targetRenderBox.size.centerRight(Offset.zero), ancestor: overlayRenderBox); if (Directionality.of(context) == TextDirection.rtl || @@ -413,7 +422,7 @@ class MoonPopoverState extends State with RouteAware, SingleTickerP } } - final popoverPositionParameters = _resolvePopoverPositionParameters( + final _PopoverPositionProperties popoverPositionParameters = _resolvePopoverPositionParameters( popoverPosition: popoverPosition, distanceToTarget: effectiveDistanceToTarget, overlayWidth: overlayRenderBox.size.width, @@ -422,6 +431,9 @@ class MoonPopoverState extends State with RouteAware, SingleTickerP popoverTargetGlobalRight: popoverTargetGlobalRight.dx, ); + final double effectiveDropdownWidth = + widget.maxWidth != null ? widget.maxWidth! : popoverPositionParameters.popoverMaxWidth; + return Semantics( label: widget.semanticLabel, child: UnconstrainedBox( @@ -432,8 +444,9 @@ class MoonPopoverState extends State with RouteAware, SingleTickerP followerAnchor: popoverPositionParameters.followerAnchor, targetAnchor: popoverPositionParameters.targetAnchor, child: TapRegion( + groupId: _regionKey, behavior: HitTestBehavior.translucent, - onTapOutside: (PointerDownEvent _) => _removePopover(), + onTapOutside: (PointerDownEvent _) => _handleTapOutside(), child: RepaintBoundary( child: FadeTransition( opacity: _curvedAnimation!, @@ -442,8 +455,12 @@ class MoonPopoverState extends State with RouteAware, SingleTickerP child: DefaultTextStyle( style: effectiveTextStyle.copyWith(color: effectiveTextColor), child: Container( - key: _popoverKey, - constraints: BoxConstraints(maxWidth: popoverPositionParameters.popoverMaxWidth), + constraints: BoxConstraints( + minHeight: widget.minHeight ?? 0, + maxHeight: widget.maxHeight ?? double.infinity, + minWidth: widget.minWidth ?? 0, + maxWidth: effectiveDropdownWidth, + ), padding: resolvedContentPadding, decoration: widget.decoration ?? ShapeDecorationWithPremultipliedAlpha( @@ -454,7 +471,10 @@ class MoonPopoverState extends State with RouteAware, SingleTickerP side: BorderSide(color: widget.borderColor), ), ), - child: widget.content, + child: Directionality( + textDirection: Directionality.of(context), + child: widget.content, + ), ), ), ), @@ -486,9 +506,13 @@ class MoonPopoverState extends State with RouteAware, SingleTickerP curve: effectiveTransitionCurve, ); - return CompositedTransformTarget( - link: _layerLink, - child: widget.child, + return TapRegion( + groupId: _regionKey, + behavior: HitTestBehavior.translucent, + child: CompositedTransformTarget( + link: _layerLink, + child: widget.child, + ), ); } } diff --git a/test/dropdown_test.dart b/test/dropdown_test.dart new file mode 100644 index 00000000..3a2a071d --- /dev/null +++ b/test/dropdown_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:moon_design/src/widgets/buttons/filled_button.dart'; +import 'package:moon_design/src/widgets/dropdown/dropdown.dart'; + +void main() { + const key = Key("dropdown_test"); + + testWidgets("Provided key is used", (tester) async { + await tester.pumpWidget(MoonDropdown(key: key, show: true, content: Container(), child: Container())); + expect(find.byKey(key), findsOneWidget); + }); + + testWidgets("Dropdown is shown after clicking on button", (tester) async { + await tester.pumpWidget(const TestWidget(key: key, content: content)); + final button = find.byType(MoonFilledButton); + + expect(button, findsOneWidget); + + await tester.tap(button); + await tester.pumpAndSettle(); + + expect(find.byWidget(content), findsOneWidget); + }); + + testWidgets("Dropdown is hidden after clicking outside content", (tester) async { + await tester.pumpWidget(const TestWidget(key: key, content: content)); + final button = find.byType(MoonFilledButton); + + expect(button, findsOneWidget); + + await tester.tap(button); + await tester.pumpAndSettle(); + + expect(find.byWidget(content), findsOneWidget); + + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + + expect(find.byWidget(content), findsNothing); + }); + + testWidgets("Provided color is used", (tester) async { + await tester.pumpWidget( + const TestWidget( + key: key, + content: content, + color: Colors.red, + ), + ); + final button = find.byType(MoonFilledButton); + + expect(button, findsOneWidget); + + await tester.tap(button); + await tester.pumpAndSettle(); + + expect(find.byWidget(content), findsOneWidget); + expect( + find.byWidgetPredicate((widget) => widget is MoonDropdown && widget.backgroundColor == Colors.red), + findsOneWidget, + ); + }); +} + +const String dropdownText = "Dropdown content"; +const Widget content = Text(dropdownText); + +bool show = false; + +class TestWidget extends StatelessWidget { + final Widget content; + final Color color; + + const TestWidget({ + required this.content, + this.color = Colors.white, + super.key, + }); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (context, setState) { + return MoonDropdown( + show: show, + backgroundColor: color, + content: content, + onTapOutside: () => setState(() => show = false), + child: MoonFilledButton( + onTap: () { + setState(() => show = true); + }, + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/test/tooltip_test.dart b/test/tooltip_test.dart index 6ce0fdf5..dc2e52a0 100644 --- a/test/tooltip_test.dart +++ b/test/tooltip_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; + import 'package:moon_design/src/widgets/buttons/filled_button.dart'; import 'package:moon_design/src/widgets/tooltip/tooltip.dart'; @@ -31,11 +32,11 @@ void main() { await tester.tap(button); await tester.pumpAndSettle(); - final tip = _findTooltipContent(tooltipText); - await tester.tap(tip); + final tooltipContent = _findTooltipContent(tooltipText); + await tester.tap(tooltipContent); await tester.pumpAndSettle(); - expect(tip, findsNothing); + expect(tooltipContent, findsNothing); }); testWidgets("Provided color is used", (tester) async {