diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f3c6e3..88c99e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# 0.21.0 + - feat: adding `NesDropdownMenu` + # 0.20.0 - feat: adding different frames to `NesDialog`s - feat: allow `NesFileExplorer` to have custom icons diff --git a/example/lib/gallery/gallery_page.dart b/example/lib/gallery/gallery_page.dart index 07b1871..4491e9b 100644 --- a/example/lib/gallery/gallery_page.dart +++ b/example/lib/gallery/gallery_page.dart @@ -81,6 +81,8 @@ class GalleryPage extends StatelessWidget { const SizedBox(height: 32), const TextFieldsSection(), const SizedBox(height: 32), + const DropDownMenusSection(), + const SizedBox(height: 32), const ContainersSection(), const SizedBox(height: 32), const ButtonsSection(), diff --git a/example/lib/gallery/sections/dropdown_menus.dart b/example/lib/gallery/sections/dropdown_menus.dart new file mode 100644 index 0000000..ae9dd1c --- /dev/null +++ b/example/lib/gallery/sections/dropdown_menus.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:nes_ui/nes_ui.dart'; + +class DropDownMenusSection extends StatelessWidget { + const DropDownMenusSection({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Drop Down Menus', + style: theme.textTheme.displayMedium, + ), + const SizedBox(height: 16), + const NesDropdownMenu( + initialValue: 'Option 1', + entries: [ + NesDropdownMenuEntry(value: 'Option 1', label: 'Option 1'), + NesDropdownMenuEntry(value: 'Option 2', label: 'Option 2'), + NesDropdownMenuEntry(value: 'Option 3', label: 'Option 3'), + ], + ), + ], + ); + } +} diff --git a/example/lib/gallery/sections/sections.dart b/example/lib/gallery/sections/sections.dart index e3e49dc..9946be5 100644 --- a/example/lib/gallery/sections/sections.dart +++ b/example/lib/gallery/sections/sections.dart @@ -5,6 +5,7 @@ export 'containers.dart'; export 'custom_extensions.dart'; export 'dialogs.dart'; export 'drop_shadows.dart'; +export 'dropdown_menus.dart'; export 'effects.dart'; export 'file_explorer.dart'; export 'icon_badges.dart'; diff --git a/example/pubspec.lock b/example/pubspec.lock index 47f1bf4..ddff926 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -119,10 +119,10 @@ packages: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.12.0" mini_sprite: dependency: "direct main" description: diff --git a/lib/src/widgets/nes_dropdown_menu.dart b/lib/src/widgets/nes_dropdown_menu.dart new file mode 100644 index 0000000..b68d3bd --- /dev/null +++ b/lib/src/widgets/nes_dropdown_menu.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:nes_ui/nes_ui.dart'; + +/// {@template nes_dropdown_menu_entry} +/// A single entry in a NES dropdown menu. +/// {@endtemplate} +class NesDropdownMenuEntry { + /// {@macro nes_dropdown_menu_entry} + const NesDropdownMenuEntry({ + required this.value, + required this.label, + }); + + /// The value of the entry. + final T value; + + /// The label to display. + final String label; +} + +/// {@template nes_dropdown_menu} +/// A NES styled dropdown menu. +/// {@endtemplate} +// ignore: must_be_immutable +class NesDropdownMenu extends StatefulWidget { + /// {@macro nes_dropdown_menu} + const NesDropdownMenu({ + required this.entries, + this.initialValue, + this.onChanged, + this.width, + super.key, + }); + + /// The entries to display in the dropdown menu. + final List> entries; + + /// The initial value of the dropdown menu. + final T? initialValue; + + /// Called when the value of the dropdown menu changes. + final void Function(T value)? onChanged; + + /// The width of the dropdown menu. + final double? width; + + @override + State> createState() => _NesDropdownMenuState(); +} + +class _NesDropdownMenuState extends State> { + late final _controller = OverlayPortalController(); + late T? _selectedValue = widget.initialValue; + + final _link = LayerLink(); + + void _toggleMenu() { + setState(() { + if (_controller.isShowing) { + _controller.hide(); + } else { + _controller.show(); + } + }); + } + + @override + Widget build(BuildContext context) { + final result = widget.entries.where( + (entry) => entry.value == _selectedValue, + ); + + final selectedEntry = result.isNotEmpty ? result.first : null; + + const padding = EdgeInsets.symmetric( + vertical: 8, + horizontal: 8, + ); + + final width = widget.width ?? 200; + + return CompositedTransformTarget( + link: _link, + child: OverlayPortal( + controller: _controller, + overlayChildBuilder: (context) { + return CompositedTransformFollower( + link: _link, + targetAnchor: Alignment.bottomLeft, + child: Align( + alignment: AlignmentDirectional.topStart, + child: NesDropshadow( + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: NesContainer( + padding: padding, + width: width, + child: Column( + mainAxisSize: MainAxisSize.min, + children: widget.entries.map( + (entry) { + final isSelected = entry.value == _selectedValue; + + return SizedBox( + height: 24, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Opacity( + opacity: isSelected ? 0.5 : 1, + child: NesPressable( + onPress: !isSelected + ? () { + widget.onChanged?.call(entry.value); + setState(() { + _selectedValue = entry.value; + _toggleMenu(); + }); + } + : null, + child: Text(entry.label), + ), + ), + ), + ), + ); + }, + ).toList(), + ), + ), + ), + ), + ), + ); + }, + child: NesContainer( + width: width, + height: 48, + padding: padding, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + Expanded( + child: selectedEntry != null + ? Text( + selectedEntry.label, + ) + : const SizedBox(), + ), + const SizedBox(width: 8), + NesIconButton( + size: const Size.square(24), + icon: _controller.isShowing + ? NesIcons.topArrowIndicator + : NesIcons.bottomArrowIndicator, + onPress: _toggleMenu, + ), + const SizedBox(width: 8), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/widgets/widgets.dart b/lib/src/widgets/widgets.dart index c17431a..d679b06 100644 --- a/lib/src/widgets/widgets.dart +++ b/lib/src/widgets/widgets.dart @@ -6,6 +6,7 @@ export 'nes_button.dart'; export 'nes_check_box.dart'; export 'nes_checkered_decoration.dart'; export 'nes_controller_focus.dart'; +export 'nes_dropdown_menu.dart'; export 'nes_dropshadow.dart'; export 'nes_file_explorer.dart'; export 'nes_fixed_viewport.dart'; diff --git a/test/src/widgets/nes_dropdown_menu_test.dart b/test/src/widgets/nes_dropdown_menu_test.dart new file mode 100644 index 0000000..490a372 --- /dev/null +++ b/test/src/widgets/nes_dropdown_menu_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nes_ui/nes_ui.dart'; + +void main() { + group('NesDropdownMenu', () { + testWidgets('renders correctly', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: flutterNesTheme(), + home: const Scaffold( + body: NesDropdownMenu( + initialValue: 'Option 1', + entries: [ + NesDropdownMenuEntry(value: 'Option 1', label: 'Option 1'), + NesDropdownMenuEntry(value: 'Option 2', label: 'Option 2'), + NesDropdownMenuEntry(value: 'Option 3', label: 'Option 3'), + ], + ), + ), + ), + ); + + await tester.tap(find.byType(NesDropdownMenu)); + }); + + testWidgets('shows the selected initial value', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: flutterNesTheme(), + home: const Scaffold( + body: NesDropdownMenu( + initialValue: 'Option 2', + entries: [ + NesDropdownMenuEntry(value: 'Option 1', label: 'Option 1'), + NesDropdownMenuEntry(value: 'Option 2', label: 'Option 2'), + NesDropdownMenuEntry(value: 'Option 3', label: 'Option 3'), + ], + ), + ), + ), + ); + + expect(find.text('Option 2'), findsOneWidget); + }); + + testWidgets('calls onChanged when an entry is selected', (tester) async { + String? selectedValue; + + await tester.pumpWidget( + MaterialApp( + theme: flutterNesTheme(), + home: Scaffold( + body: NesDropdownMenu( + initialValue: 'Option 1', + entries: const [ + NesDropdownMenuEntry(value: 'Option 1', label: 'Option 1'), + NesDropdownMenuEntry(value: 'Option 2', label: 'Option 2'), + NesDropdownMenuEntry(value: 'Option 3', label: 'Option 3'), + ], + onChanged: (value) { + selectedValue = value; + }, + ), + ), + ), + ); + + await tester.tap(find.byType(NesIcon)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Option 2')); + await tester.pumpAndSettle(); + + expect(selectedValue, 'Option 2'); + }); + }); +} diff --git a/widgetbook/lib/widgetbook/use_cases/dropdown_menus.dart b/widgetbook/lib/widgetbook/use_cases/dropdown_menus.dart new file mode 100644 index 0000000..e46980f --- /dev/null +++ b/widgetbook/lib/widgetbook/use_cases/dropdown_menus.dart @@ -0,0 +1,21 @@ +// ignore_for_file: public_member_api_docs + +import 'package:flutter/widgets.dart'; +import 'package:nes_ui/nes_ui.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +@widgetbook.UseCase( + name: 'default', + type: NesDropdownMenu, +) +Widget normal(BuildContext context) { + return const Center( + child: NesDropdownMenu( + entries: [ + NesDropdownMenuEntry(value: '1', label: 'Option 1'), + NesDropdownMenuEntry(value: '2', label: 'Option 2'), + NesDropdownMenuEntry(value: '3', label: 'Option 3'), + ], + ), + ); +} diff --git a/widgetbook/lib/widgetbook/widgetbook.directories.g.dart b/widgetbook/lib/widgetbook/widgetbook.directories.g.dart index fe4dbd9..ee1aba7 100644 --- a/widgetbook/lib/widgetbook/widgetbook.directories.g.dart +++ b/widgetbook/lib/widgetbook/widgetbook.directories.g.dart @@ -12,8 +12,9 @@ import 'package:widgetbook/widgetbook.dart' as _i1; import 'package:widgetbook_app/widgetbook/use_cases/buttons.dart' as _i2; import 'package:widgetbook_app/widgetbook/use_cases/checkboxes.dart' as _i3; -import 'package:widgetbook_app/widgetbook/use_cases/containers.dart' as _i5; -import 'package:widgetbook_app/widgetbook/use_cases/running_texts.dart' as _i4; +import 'package:widgetbook_app/widgetbook/use_cases/containers.dart' as _i6; +import 'package:widgetbook_app/widgetbook/use_cases/dropdown_menus.dart' as _i4; +import 'package:widgetbook_app/widgetbook/use_cases/running_texts.dart' as _i5; final directories = <_i1.WidgetbookNode>[ _i1.WidgetbookFolder( @@ -52,17 +53,24 @@ final directories = <_i1.WidgetbookNode>[ ), ), _i1.WidgetbookLeafComponent( - name: 'NesRunningText', + name: 'NesDropdownMenu', useCase: _i1.WidgetbookUseCase( name: 'default', builder: _i4.normal, ), ), + _i1.WidgetbookLeafComponent( + name: 'NesRunningText', + useCase: _i1.WidgetbookUseCase( + name: 'default', + builder: _i5.normal, + ), + ), _i1.WidgetbookLeafComponent( name: 'NesRunningTextLines', useCase: _i1.WidgetbookUseCase( name: 'default', - builder: _i4.lines, + builder: _i5.lines, ), ), _i1.WidgetbookFolder( @@ -73,19 +81,19 @@ final directories = <_i1.WidgetbookNode>[ useCases: [ _i1.WidgetbookUseCase( name: 'default', - builder: _i5.normal, + builder: _i6.normal, ), _i1.WidgetbookUseCase( name: 'with corner inner square painter', - builder: _i5.cornerInnerSquare, + builder: _i6.cornerInnerSquare, ), _i1.WidgetbookUseCase( name: 'with label', - builder: _i5.label, + builder: _i6.label, ), _i1.WidgetbookUseCase( name: 'with square corner painter', - builder: _i5.squareConer, + builder: _i6.squareConer, ), ], )