Skip to content

Commit

Permalink
feat: adding dropdown menus
Browse files Browse the repository at this point in the history
  • Loading branch information
erickzanardo committed Jun 7, 2024
1 parent cce6975 commit f78148f
Show file tree
Hide file tree
Showing 10 changed files with 320 additions and 10 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions example/lib/gallery/gallery_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
29 changes: 29 additions & 0 deletions example/lib/gallery/sections/dropdown_menus.dart
Original file line number Diff line number Diff line change
@@ -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<String>(
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'),
],
),
],
);
}
}
1 change: 1 addition & 0 deletions example/lib/gallery/sections/sections.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
167 changes: 167 additions & 0 deletions lib/src/widgets/nes_dropdown_menu.dart
Original file line number Diff line number Diff line change
@@ -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<T> {
/// {@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<T> 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<NesDropdownMenuEntry<T>> 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<NesDropdownMenu<T>> createState() => _NesDropdownMenuState<T>();
}

class _NesDropdownMenuState<T> extends State<NesDropdownMenu<T>> {
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),
],
),
),
),
);
}
}
1 change: 1 addition & 0 deletions lib/src/widgets/widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
78 changes: 78 additions & 0 deletions test/src/widgets/nes_dropdown_menu_test.dart
Original file line number Diff line number Diff line change
@@ -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<String>(
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<String>));
});

testWidgets('shows the selected initial value', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: flutterNesTheme(),
home: const Scaffold(
body: NesDropdownMenu<String>(
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<String>(
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');
});
});
}
21 changes: 21 additions & 0 deletions widgetbook/lib/widgetbook/use_cases/dropdown_menus.dart
Original file line number Diff line number Diff line change
@@ -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<String>(
entries: [
NesDropdownMenuEntry(value: '1', label: 'Option 1'),
NesDropdownMenuEntry(value: '2', label: 'Option 2'),
NesDropdownMenuEntry(value: '3', label: 'Option 3'),
],
),
);
}
Loading

0 comments on commit f78148f

Please sign in to comment.