diff --git a/CHANGELOG.md b/CHANGELOG.md index 6230b410..b3b3910b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.9.0 + +- New `ShadContextMenu` component. +- Add `groupId` to `ShadPopover`, to determine if the tap is inside the popover or not. +- Add `onFocusChange` to `ShadFocusable` and `ShadButton`. +- Add `onSecondaryTap` to `ShadButton`. + ## 0.8.1 - Fix `ShadTabs` not updating the controller when the value changes. @@ -6,6 +13,7 @@ - **BREAKING CHANGE**: Refactor `ShadResizablePanelGroup` in order to react to window resize correctly. The sizes have been normalized. You don't need to provide anymore a pixel size, but a value between 0 and 1 which indicates the percentage of the available space. - Add `onChanged` to `ShadTabs`. +- Add `onSecondaryTap` to `ShadGestureDetector` and `ShadButton`. - Fix `maxWidth` missing in `ShadSelectForlField`. ## 0.7.3 diff --git a/README.md b/README.md index e74bc102..b96bb7e6 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,11 @@ See the [documentation](https://mariuti.com/shadcn-ui/) to interact with the com - [ ] Collapsible - [x] [Combobox](https://mariuti.com/shadcn-ui/components/select/#with-search) - [ ] Command -- [ ] Context Menu +- [x] [Context Menu](https://mariuti.com/shadcn-ui/components/context-menu/) - [ ] Data Table - [ ] Date Picker - [ ] Drawer -- [ ] Dropdown Menu +- [x] Dropdown Menu Use Context Menu instead - [x] [Form](https://mariuti.com/shadcn-ui/components/form/) - [x] Hover Card Use Popover instead - [x] [Image](https://mariuti.com/shadcn-ui/components/image/) diff --git a/example/lib/common/base_scaffold.dart b/example/lib/common/base_scaffold.dart index e1016b70..2caa4298 100644 --- a/example/lib/common/base_scaffold.dart +++ b/example/lib/common/base_scaffold.dart @@ -13,6 +13,7 @@ class BaseScaffold extends StatelessWidget { this.wrapChildrenInScrollable = true, this.wrapSingleChildInColumn = true, this.alignment, + this.gap = 8, }); final List children; @@ -22,6 +23,7 @@ class BaseScaffold extends StatelessWidget { final bool wrapChildrenInScrollable; final bool wrapSingleChildInColumn; final Alignment? alignment; + final double gap; @override Widget build(BuildContext context) { @@ -34,7 +36,7 @@ class BaseScaffold extends StatelessWidget { ? children[0] : Column( crossAxisAlignment: crossAxisAlignment, - children: children.separatedBy(const SizedBox(height: 8)), + children: children.separatedBy(SizedBox(height: gap)), ), ); @@ -52,7 +54,7 @@ class BaseScaffold extends StatelessWidget { child: Center( child: Column( crossAxisAlignment: CrossAxisAlignment.center, - children: editable!.separatedBy(const SizedBox(height: 8)), + children: editable!.separatedBy(SizedBox(height: gap)), ), ), ); diff --git a/example/lib/main.dart b/example/lib/main.dart index 73084417..e7f25e50 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -7,6 +7,7 @@ import 'package:example/pages/button.dart'; import 'package:example/pages/card.dart'; import 'package:example/pages/checkbox.dart'; import 'package:example/pages/checkbox_form_field.dart'; +import 'package:example/pages/context_menu.dart'; import 'package:example/pages/dialog.dart'; import 'package:example/pages/image.dart'; import 'package:example/pages/input.dart'; @@ -47,6 +48,7 @@ final routes = { '/card': (_) => const CardPage(), '/checkbox': (_) => const CheckboxPage(), '/checkbox-form-field': (_) => const CheckboxFormFieldPage(), + '/context-menu': (_) => const ContextMenuPage(), '/dialog': (_) => const DialogPage(), '/image': (_) => const ImagePage(), '/input': (_) => const InputPage(), @@ -62,8 +64,8 @@ final routes = { '/slider': (_) => const SliderPage(), '/switch': (_) => const SwitchPage(), '/switch-form-field': (_) => const SwitchFormFieldPage(), - '/tabs': (_) => const TabsPage(), '/table': (_) => const TablePage(), + '/tabs': (_) => const TabsPage(), '/toast': (_) => const ToastPage(), '/tooltip': (_) => const TooltipPage(), '/typography': (_) => const TypographyPage(), diff --git a/example/lib/pages/context_menu.dart b/example/lib/pages/context_menu.dart new file mode 100644 index 00000000..35ec76ca --- /dev/null +++ b/example/lib/pages/context_menu.dart @@ -0,0 +1,93 @@ +import 'package:example/common/base_scaffold.dart'; +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class ContextMenuPage extends StatelessWidget { + const ContextMenuPage({super.key}); + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return BaseScaffold( + appBarTitle: 'ContextMenu', + children: [ + ShadContextMenuRegion( + constraints: const BoxConstraints(minWidth: 300), + child: Container( + width: 300, + height: 200, + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.border), + borderRadius: BorderRadius.circular(8), + ), + child: const Text('Right click here'), + ), + children: [ + const ShadContextMenuItem.inset( + child: Text('Back'), + ), + const ShadContextMenuItem.inset( + enabled: false, + child: Text('Forward'), + ), + const ShadContextMenuItem.inset( + child: Text('Reload'), + ), + const ShadContextMenuItem.inset( + child: Text('More Tools'), + trailing: ShadImage.square( + LucideIcons.chevronRight, + size: 16, + ), + children: [ + ShadContextMenuItem( + child: Text('Save Page As...'), + ), + ShadContextMenuItem( + child: Text('Create Shortcut...'), + ), + ShadContextMenuItem( + child: Text('Name Window...'), + ), + Divider(height: 8), + ShadContextMenuItem( + child: Text('Developer Tools'), + ), + ], + ), + const Divider(height: 8), + const ShadContextMenuItem( + leading: ShadImage.square(LucideIcons.check, size: 16), + child: Text('Show Bookmarks Bar'), + ), + const ShadContextMenuItem.inset(child: Text('Show Full URLs')), + const Divider(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(36, 8, 8, 8), + child: Text('People', style: theme.textTheme.small), + ), + const Divider(height: 8), + ShadContextMenuItem( + leading: SizedBox.square( + dimension: 16, + child: Center( + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: theme.colorScheme.foreground, + shape: BoxShape.circle, + ), + ), + ), + ), + child: const Text('Pedro Duarte'), + ), + const ShadContextMenuItem.inset(child: Text('Colm Tuite')), + ], + ), + ], + ); + } +} diff --git a/example/lib/pages/select.dart b/example/lib/pages/select.dart index 6116a9c4..b0c8851d 100644 --- a/example/lib/pages/select.dart +++ b/example/lib/pages/select.dart @@ -190,6 +190,7 @@ class _SelectPageState extends State { enabled: enabled, focusNode: focusNodes[2], minWidth: 180, + maxWidth: 300, placeholder: const Text('Select framework...'), onSearchChanged: (value) => setState(() => searchValue = value), searchPlaceholder: const Text('Search framework'), diff --git a/lib/shadcn_ui.dart b/lib/shadcn_ui.dart index bd890dc2..a4c69fef 100644 --- a/lib/shadcn_ui.dart +++ b/lib/shadcn_ui.dart @@ -11,6 +11,7 @@ export 'src/components/badge.dart'; export 'src/components/button.dart'; export 'src/components/card.dart'; export 'src/components/checkbox.dart'; +export 'src/components/context_menu.dart'; export 'src/components/dialog.dart'; export 'src/components/form/field.dart'; export 'src/components/form/fields/checkbox.dart'; @@ -29,8 +30,8 @@ export 'src/components/select.dart'; export 'src/components/sheet.dart'; export 'src/components/slider.dart'; export 'src/components/switch.dart'; -export 'src/components/tabs.dart'; export 'src/components/table.dart'; +export 'src/components/tabs.dart'; export 'src/components/toast.dart'; export 'src/components/tooltip.dart'; @@ -65,35 +66,37 @@ export 'src/theme/components/badge.dart'; export 'src/theme/components/button.dart'; export 'src/theme/components/card.dart'; export 'src/theme/components/checkbox.dart'; +export 'src/theme/components/context_menu.dart'; export 'src/theme/components/decorator.dart'; export 'src/theme/components/dialog.dart'; export 'src/theme/components/input.dart'; +export 'src/theme/components/input_decorator.dart'; export 'src/theme/components/option.dart'; export 'src/theme/components/popover.dart'; export 'src/theme/components/progress.dart'; export 'src/theme/components/radio.dart'; +export 'src/theme/components/resizable.dart'; export 'src/theme/components/select.dart'; export 'src/theme/components/sheet.dart'; export 'src/theme/components/slider.dart'; export 'src/theme/components/switch.dart'; export 'src/theme/components/table.dart'; +export 'src/theme/components/tabs.dart'; export 'src/theme/components/toast.dart'; export 'src/theme/components/tooltip.dart'; export 'src/theme/text_theme/text_styles_default.dart'; export 'src/theme/text_theme/theme.dart'; -export 'src/theme/components/resizable.dart'; -export 'src/theme/components/tabs.dart'; -export 'src/theme/components/input_decorator.dart'; // Utils -export 'src/utils/position.dart'; -export 'src/utils/responsive.dart'; -export 'src/utils/states_controller.dart'; export 'src/utils/animation_builder.dart'; +export 'src/utils/effects.dart'; export 'src/utils/extensions.dart'; -export 'src/utils/provider.dart' hide ProviderReadExt, ProviderWatchExt; export 'src/utils/gesture_detector.dart'; -export 'src/utils/effects.dart'; +export 'src/utils/position.dart'; +export 'src/utils/provider.dart' hide ProviderReadExt, ProviderWatchExt; +export 'src/utils/responsive.dart'; +export 'src/utils/states_controller.dart'; +export 'src/utils/mouse_area.dart'; // External libraries export 'package:flutter_animate/flutter_animate.dart' hide Effect; diff --git a/lib/src/app.dart b/lib/src/app.dart index e0832c7e..b4185d2f 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -11,6 +11,7 @@ import 'package:shadcn_ui/src/components/toast.dart'; import 'package:shadcn_ui/src/theme/color_scheme/slate.dart'; import 'package:shadcn_ui/src/theme/data.dart'; import 'package:shadcn_ui/src/theme/theme.dart'; +import 'package:shadcn_ui/src/utils/mouse_area.dart'; import 'package:shadcn_ui/src/utils/mouse_cursor_provider.dart'; enum ShadAppType { @@ -568,8 +569,10 @@ class _ShadAppState extends State { child: ShadAnimatedTheme( data: theme(context), curve: widget.themeCurve, - child: ShadMouseCursorProvider( - child: _buildApp(context), + child: ShadMouseAreaSurface( + child: ShadMouseCursorProvider( + child: _buildApp(context), + ), ), ), ), diff --git a/lib/src/components/button.dart b/lib/src/components/button.dart index b0114c68..8e041c83 100644 --- a/lib/src/components/button.dart +++ b/lib/src/components/button.dart @@ -61,6 +61,9 @@ class ShadButton extends StatefulWidget { this.onTapDown, this.onTapUp, this.onTapCancel, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, this.onLongPressStart, this.onLongPressCancel, this.onLongPressUp, @@ -72,6 +75,7 @@ class ShadButton extends StatefulWidget { this.longPressDuration, this.textDirection, this.gap, + this.onFocusChange, }) : variant = ShadButtonVariant.primary; const ShadButton.raw({ @@ -109,6 +113,9 @@ class ShadButton extends StatefulWidget { this.onTapDown, this.onTapUp, this.onTapCancel, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, this.onLongPressStart, this.onLongPressCancel, this.onLongPressUp, @@ -120,6 +127,7 @@ class ShadButton extends StatefulWidget { this.longPressDuration, this.textDirection, this.gap, + this.onFocusChange, }); const ShadButton.destructive({ @@ -156,6 +164,9 @@ class ShadButton extends StatefulWidget { this.onTapDown, this.onTapUp, this.onTapCancel, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, this.onLongPressStart, this.onLongPressCancel, this.onLongPressUp, @@ -167,6 +178,7 @@ class ShadButton extends StatefulWidget { this.longPressDuration, this.textDirection, this.gap, + this.onFocusChange, }) : variant = ShadButtonVariant.destructive; const ShadButton.outline({ @@ -203,6 +215,9 @@ class ShadButton extends StatefulWidget { this.onTapDown, this.onTapUp, this.onTapCancel, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, this.onLongPressStart, this.onLongPressCancel, this.onLongPressUp, @@ -214,6 +229,7 @@ class ShadButton extends StatefulWidget { this.longPressDuration, this.textDirection, this.gap, + this.onFocusChange, }) : variant = ShadButtonVariant.outline; const ShadButton.secondary({ @@ -250,6 +266,9 @@ class ShadButton extends StatefulWidget { this.onTapDown, this.onTapUp, this.onTapCancel, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, this.onLongPressStart, this.onLongPressCancel, this.onLongPressUp, @@ -261,6 +280,7 @@ class ShadButton extends StatefulWidget { this.longPressDuration, this.textDirection, this.gap, + this.onFocusChange, }) : variant = ShadButtonVariant.secondary; const ShadButton.ghost({ @@ -297,6 +317,9 @@ class ShadButton extends StatefulWidget { this.onTapDown, this.onTapUp, this.onTapCancel, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, this.onLongPressStart, this.onLongPressCancel, this.onLongPressUp, @@ -308,6 +331,7 @@ class ShadButton extends StatefulWidget { this.longPressDuration, this.textDirection, this.gap, + this.onFocusChange, }) : variant = ShadButtonVariant.ghost; const ShadButton.link({ @@ -343,6 +367,9 @@ class ShadButton extends StatefulWidget { this.onTapDown, this.onTapUp, this.onTapCancel, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, this.onLongPressStart, this.onLongPressCancel, this.onLongPressUp, @@ -354,6 +381,7 @@ class ShadButton extends StatefulWidget { this.longPressDuration, this.textDirection, this.gap, + this.onFocusChange, }) : variant = ShadButtonVariant.link, icon = null; @@ -367,7 +395,7 @@ class ShadButton extends StatefulWidget { final MouseCursor? cursor; final double? width; final double? height; - final EdgeInsets? padding; + final EdgeInsetsGeometry? padding; final Color? backgroundColor; final Color? hoverBackgroundColor; final Color? foregroundColor; @@ -410,6 +438,9 @@ class ShadButton extends StatefulWidget { final ValueChanged? onTapDown; final ValueChanged? onTapUp; final VoidCallback? onTapCancel; + final ValueChanged? onSecondaryTapDown; + final ValueChanged? onSecondaryTapUp; + final VoidCallback? onSecondaryTapCancel; final ValueChanged? onLongPressStart; final VoidCallback? onLongPressCancel; final VoidCallback? onLongPressUp; @@ -420,6 +451,7 @@ class ShadButton extends StatefulWidget { final VoidCallback? onDoubleTapCancel; final Duration? longPressDuration; final TextDirection? textDirection; + final ValueChanged? onFocusChange; @override State createState() => _ShadButtonState(); @@ -538,14 +570,14 @@ class _ShadButtonState extends State { return defaultWidthForSize(theme, buttonTheme(theme).size); } - EdgeInsets defaultPaddingForSize( + EdgeInsetsGeometry defaultPaddingForSize( ShadThemeData theme, ShadButtonSize size, ) { return sizeTheme(theme, size).padding; } - EdgeInsets padding(ShadThemeData theme) { + EdgeInsetsGeometry padding(ShadThemeData theme) { if (widget.padding != null) return widget.padding!; if (widget.size != null) { return defaultPaddingForSize(theme, widget.size!); @@ -716,6 +748,7 @@ class _ShadButtonState extends State { canRequestFocus: enabled, autofocus: widget.autofocus, focusNode: focusNode, + onFocusChange: widget.onFocusChange, builder: (context, focused, child) => ShadDecorator( decoration: updatedDecoration, focused: focused, @@ -725,6 +758,7 @@ class _ShadButtonState extends State { behavior: HitTestBehavior.opaque, onHoverChange: (value) { statesController.update(ShadState.hovered, value); + widget.onHoverChange?.call(value); }, hoverStrategies: effectiveHoverStrategies, cursor: cursor(theme), @@ -742,6 +776,15 @@ class _ShadButtonState extends State { statesController.update(ShadState.pressed, false); widget.onTapCancel?.call(); }, + onSecondaryTapDown: (details) { + widget.onSecondaryTapDown?.call(details); + }, + onSecondaryTapUp: (details) { + widget.onSecondaryTapUp?.call(details); + }, + onSecondaryTapCancel: () { + widget.onSecondaryTapCancel?.call(); + }, onDoubleTap: widget.onDoubleTap, onDoubleTapDown: widget.onDoubleTapDown, onDoubleTapCancel: widget.onDoubleTapCancel, diff --git a/lib/src/components/context_menu.dart b/lib/src/components/context_menu.dart new file mode 100644 index 00000000..d025b2bb --- /dev/null +++ b/lib/src/components/context_menu.dart @@ -0,0 +1,723 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:shadcn_ui/src/utils/provider.dart'; + +const kContextMenuGroupId = ValueKey('context-menu'); + +/// {@template ShadContextMenuRegion} +/// A widget that shows the context menu when the user right clicks the [child]. +/// {@endtemplate} +class ShadContextMenuRegion extends StatefulWidget { + /// {@macro ShadContextMenuRegion} + const ShadContextMenuRegion({ + super.key, + required this.child, + required this.children, + this.visible = false, + this.constraints, + this.onHoverArea, + this.padding, + this.groupId, + this.effects, + this.shadows, + this.decoration, + this.filter, + }); + + /// {@template ShadContextMenuRegion.child} + /// The child that triggers the visibility of the context menu. + /// {@endtemplate} + final Widget child; + + /// {@macro ShadContextMenu.children} + final List children; + + /// {@macro ShadContextMenu.visible} + final bool visible; + + /// {@macro ShadContextMenu.constraints} + final BoxConstraints? constraints; + + /// {@macro ShadContextMenu.onHoverArea} + final ValueChanged? onHoverArea; + + /// {@macro ShadContextMenu.padding} + final EdgeInsetsGeometry? padding; + + /// {@macro ShadMouseArea.groupId} + final Object? groupId; + + /// {@macro ShadPopover.effects} + final List>? effects; + + /// {@macro ShadPopover.shadows} + final List? shadows; + + /// {@macro ShadPopover.decoration} + final ShadDecoration? decoration; + + /// {@macro ShadPopover.filter} + final ImageFilter? filter; + + @override + State createState() => _ShadContextMenuRegionState(); +} + +class _ShadContextMenuRegionState extends State { + late bool visible = widget.visible; + ShadAnchorBase? anchor; + + @override + void didUpdateWidget(covariant ShadContextMenuRegion oldWidget) { + super.didUpdateWidget(oldWidget); + visible = widget.visible; + } + + void showAtOffset(Offset offset) { + setState(() { + anchor = ShadGlobalAnchor(offset); + visible = true; + }); + } + + void hide() { + if (!visible) return; + setState(() => visible = false); + } + + /// A special method for web, to disable the default browser context menu. + Future showOnWeb(TapDownDetails details) async { + await BrowserContextMenu.disableContextMenu(); + showAtOffset(details.globalPosition); + await Future.delayed(Duration.zero); + await BrowserContextMenu.enableContextMenu(); + } + + void show(TapDownDetails details) { + showAtOffset(details.globalPosition); + } + + @override + Widget build(BuildContext context) { + return ShadContextMenu( + anchor: anchor, + visible: visible, + child: ShadGestureDetector( + onTapDown: (_) => hide(), + onSecondaryTapDown: kIsWeb ? showOnWeb : show, + child: widget.child, + ), + children: widget.children, + constraints: widget.constraints, + onHoverArea: widget.onHoverArea, + padding: widget.padding, + groupId: widget.groupId, + effects: widget.effects, + shadows: widget.shadows, + decoration: widget.decoration, + filter: widget.filter, + ); + } +} + +class ShadContextMenu extends StatefulWidget { + const ShadContextMenu({ + super.key, + required this.child, + required this.children, + this.anchor, + this.visible = false, + this.constraints, + this.onHoverArea, + this.padding, + this.groupId, + this.effects, + this.shadows, + this.decoration, + this.filter, + }); + + /// {@template ShadContextMenu.child} + /// The child of the context menu. + /// {@endtemplate} + final Widget child; + + /// {@template ShadContextMenu.children} + /// The children of the context menu. + /// {@endtemplate} + final List children; + + /// {@template ShadContextMenu.anchor} + /// The anchor of the context menu. + /// {@endtemplate} + final ShadAnchorBase? anchor; + + /// {@template ShadContextMenu.visible} + /// Whether the context menu is visible, defaults to false. + /// {@endtemplate} + final bool visible; + + /// {@template ShadContextMenu.constraints} + /// The constraints of the context menu, defaults to + /// `BoxConstraints(minWidth: 128)`. + /// {@endtemplate} + final BoxConstraints? constraints; + + /// {@template ShadContextMenu.onHoverArea} + /// The callback called when the hover area changes. + /// {@endtemplate} + final ValueChanged? onHoverArea; + + /// {@template ShadContextMenu.padding} + /// The padding of the context menu, defaults to + /// `EdgeInsets.symmetric(vertical: 4)`. + /// {@endtemplate} + final EdgeInsetsGeometry? padding; + + /// {@macro ShadMouseArea.groupId} + final Object? groupId; + + /// {@macro ShadPopover.effects} + final List>? effects; + + /// {@macro ShadPopover.shadows} + final List? shadows; + + /// {@macro ShadPopover.decoration} + final ShadDecoration? decoration; + + /// {@macro ShadPopover.filter} + final ImageFilter? filter; + + @override + State createState() => ShadContextMenuState(); +} + +class ShadContextMenuState extends State { + late bool visible = widget.visible; + + @override + void didUpdateWidget(covariant ShadContextMenu oldWidget) { + super.didUpdateWidget(oldWidget); + visible = widget.visible; + } + + void setVisible(bool visible) { + if (visible == this.visible) return; + setState(() => this.visible = visible); + } + + @override + Widget build(BuildContext context) { + // if the context menu has no children, just return the child + if (widget.children.isEmpty) return widget.child; + + final theme = ShadTheme.of(context); + + final effectiveConstraints = widget.constraints ?? + theme.contextMenuTheme.constraints ?? + const BoxConstraints(minWidth: 128); + + final effectivePadding = widget.padding ?? + theme.contextMenuTheme.padding ?? + const EdgeInsets.symmetric(vertical: 4); + + final effectiveDecoration = + widget.decoration ?? theme.contextMenuTheme.decoration; + + final effectiveFilter = widget.filter ?? theme.contextMenuTheme.filter; + + final effectiveEffects = widget.effects ?? theme.contextMenuTheme.effects; + + final effectiveShadows = widget.shadows ?? theme.contextMenuTheme.shadows; + + Widget child = ShadPopover( + visible: visible, + padding: effectivePadding, + areaGroupId: widget.groupId, + groupId: kContextMenuGroupId, + anchor: widget.anchor, + decoration: effectiveDecoration, + effects: effectiveEffects, + shadows: effectiveShadows, + filter: effectiveFilter, + popover: (context) { + return ShadMouseArea( + groupId: widget.groupId, + key: ValueKey(widget.groupId), + child: ConstrainedBox( + constraints: effectiveConstraints, + child: IntrinsicWidth( + child: TapRegion( + groupId: kContextMenuGroupId, + child: FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: widget.children, + ), + ), + ), + ), + ), + ); + }, + child: ShadMouseArea( + groupId: widget.groupId, + onEnter: (_) => widget.onHoverArea?.call(true), + onExit: (_) => widget.onHoverArea?.call(false), + child: widget.child, + ), + ); + + // just put one context menu inherited widget. + final contextMenu = context.maybeRead(); + if (contextMenu == null) { + child = ShadProvider(data: this, child: child); + } + + return child; + } +} + +class ShadContextMenuItemController extends ChangeNotifier { + ShadContextMenuItemController({ + required this.itemKey, + bool hovered = false, + bool focused = false, + }) : _hovered = hovered, + _focused = focused; + + bool _hovered = false; + bool get hovered => _hovered; + void setHovered(bool hovered) { + if (hovered == _hovered) return; + _hovered = hovered; + notifyListeners(); + } + + bool _focused = false; + bool get focused => _focused; + void setFocused(bool focused) { + if (focused == _focused) return; + _focused = focused; + notifyListeners(); + } + + final Key itemKey; + + /// Maps the children key to the item controller + final Map children = {}; + + bool get selected => + _hovered || _focused || children.values.any((e) => e.selected); + + void registerSubItem(ShadContextMenuItemController controller) { + children[controller.itemKey] = controller; + } + + void unregisterSubItem(ShadContextMenuItemController controller) { + children.remove(controller.itemKey); + } +} + +/// The variant of the context menu item. +enum ShadContextMenuItemVariant { + primary, + inset, +} + +class ShadContextMenuItem extends StatefulWidget { + const ShadContextMenuItem({ + super.key, + required this.child, + this.children = const [], + this.enabled = true, + this.leading, + this.trailing, + this.leadingPadding, + this.trailingPadding, + this.padding, + this.onPressed, + this.anchor, + this.showDelay, + this.height, + this.buttonVariant, + this.decoration, + this.textStyle, + this.trailingTextStyle, + this.constraints, + this.subMenuPadding, + this.backgroundColor, + this.selectedBackgroundColor, + this.closeOnTap, + }) : variant = ShadContextMenuItemVariant.primary, + insetPadding = null; + + const ShadContextMenuItem.raw({ + super.key, + required this.variant, + required this.child, + this.children = const [], + this.enabled = true, + this.leading, + this.trailing, + this.leadingPadding, + this.trailingPadding, + this.padding, + this.insetPadding, + this.onPressed, + this.anchor, + this.showDelay, + this.height, + this.buttonVariant, + this.decoration, + this.textStyle, + this.trailingTextStyle, + this.constraints, + this.subMenuPadding, + this.backgroundColor, + this.selectedBackgroundColor, + this.closeOnTap, + }); + + const ShadContextMenuItem.inset({ + super.key, + required this.child, + this.children = const [], + this.enabled = true, + this.leading, + this.trailing, + this.leadingPadding, + this.trailingPadding, + this.padding, + this.insetPadding, + this.onPressed, + this.anchor, + this.showDelay, + this.height, + this.buttonVariant, + this.decoration, + this.textStyle, + this.trailingTextStyle, + this.constraints, + this.subMenuPadding, + this.backgroundColor, + this.selectedBackgroundColor, + this.closeOnTap, + }) : variant = ShadContextMenuItemVariant.inset; + + /// {@template ShadContextMenuItem.variant} + /// The variant of the context menu item, defaults to + /// [ShadContextMenuItemVariant.primary]. + /// {@endtemplate} + final ShadContextMenuItemVariant variant; + + /// {@template ShadContextMenuItem.child} + /// The child of the context menu item. + /// {@endtemplate} + final Widget child; + + /// {@template ShadContextMenuItem.enabled} + /// Whether the context menu item is enabled, defaults to true. + /// {@endtemplate} + final bool enabled; + + /// {@template ShadContextMenuItem.leading} + /// The leading widget of the context menu item. + /// {@endtemplate} + final Widget? leading; + + /// {@template ShadContextMenuItem.trailing} + /// The trailing widget of the context menu item. + /// {@endtemplate} + final Widget? trailing; + + /// {@template ShadContextMenuItem.leadingPadding} + /// The padding of the leading widget, defaults to + /// `EdgeInsets.only(right: 8)`. + /// {@endtemplate} + final EdgeInsetsGeometry? leadingPadding; + + /// {@template ShadContextMenuItem.trailingPadding} + /// The padding of the trailing widget, defaults to + /// `EdgeInsets.only(left: 8)`. + /// {@endtemplate} + final EdgeInsetsGeometry? trailingPadding; + + /// {@template ShadContextMenuItem.padding} + /// The padding of the context menu item, defaults to + /// `EdgeInsets.symmetric(horizontal: 4)`. + /// {@endtemplate} + final EdgeInsetsGeometry? padding; + + /// {@template ShadContextMenuItem.insetPadding} + /// The padding of the context menu item when it is inset, defaults to + /// `EdgeInsets.only(left: 32, right: 8)` when the variant is inset, otherwise + /// `EdgeInsets.symmetric(horizontal: 8)` + /// {@endtemplate} + final EdgeInsetsGeometry? insetPadding; + + /// {@template ShadContextMenuItem.onPressed} + /// The callback called when the context menu item is pressed. + /// {@endtemplate} + final VoidCallback? onPressed; + + /// {@template ShadContextMenuItem.anchor} + /// The anchor of the context menu item, defaults to + /// `ShadAnchor(overlayAlignment: + /// Alignment.topRight, offset: Offset(-8, -3))`. + /// {@endtemplate} + final ShadAnchorBase? anchor; + + /// {@template ShadContextMenuItem.showDelay} + /// The delay before the context menu is shown, defaults to 100ms. + /// + /// This is useful when the mouse is moved outside the item and towards the + /// submenu, to avoid losing the focus on the item. + /// {@endtemplate} + final Duration? showDelay; + + /// {@template ShadContextMenuItem.height} + /// The height of the context menu item, defaults to 32. + /// {@endtemplate} + final double? height; + + /// {@template ShadContextMenuItem.buttonVariant} + /// The variant of the button of the context menu item, defaults to + /// [ShadButtonVariant.ghost]. + /// {@endtemplate} + final ShadButtonVariant? buttonVariant; + + /// {@template ShadContextMenuItem.decoration} + /// The decoration of the context menu item, defaults to + /// `ShadDecoration(secondaryBorder: ShadBorder.none)`. + /// {@endtemplate} + final ShadDecoration? decoration; + + /// {@template ShadContextMenuItem.textStyle} + /// The text style of the context menu item, defaults to + /// `small.copyWith(fontWeight: FontWeight.normal)`. + /// {@endtemplate} + final TextStyle? textStyle; + + /// {@template ShadContextMenuItem.trailingTextStyle} + /// The text style of the trailing widget, defaults to + /// `muted.copyWith(fontSize: 12, height: 1)`. + /// {@endtemplate} + final TextStyle? trailingTextStyle; + + /// {@macro ShadContextMenu.constraints} + final BoxConstraints? constraints; + + /// {@macro ShadContextMenu.padding} + final EdgeInsetsGeometry? subMenuPadding; + + /// {@template ShadContextMenuItem.backgroundColor} + /// The background color of the context menu item, defaults to + /// `null`. + /// {@endtemplate} + final Color? backgroundColor; + + /// {@template ShadContextMenuItem.selectedBackgroundColor} + /// The background color of the context menu item when it is selected, + /// defaults to `theme.colorScheme.accent`. + /// {@endtemplate} + final Color? selectedBackgroundColor; + + /// {@template ShadContextMenuItem.closeOnTap} + /// Whether the context menu should be closed when the item is tapped, + /// defaults to `true` when [children] is empty, otherwise `false`. + /// {@endtemplate} + final bool? closeOnTap; + + /// {@template ShadContextMenuItem.children} + /// The children of the context menu item. + /// {@endtemplate} + final List children; + + @override + State createState() => _ShadContextMenuItemState(); +} + +class _ShadContextMenuItemState extends State { + final itemKey = UniqueKey(); + late final controller = ShadContextMenuItemController(itemKey: itemKey); + // get the parent item controller, if any, meaning this item is a submenu + late final parentItemController = + context.maybeRead(); + + bool get hasTrailingIcon => widget.children.isNotEmpty; + + @override + void initState() { + super.initState(); + // register the subitem controller if this item is a submenu + parentItemController?.registerSubItem(controller); + } + + @override + void dispose() { + parentItemController?.unregisterSubItem(controller); + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext contex) { + final theme = ShadTheme.of(context); + + final contextMenu = context.read(); + + final effectivePadding = widget.padding ?? + theme.contextMenuTheme.itemPadding ?? + const EdgeInsets.symmetric(horizontal: 4); + + final defaultInsetPadding = switch (widget.variant) { + ShadContextMenuItemVariant.primary => + const EdgeInsets.symmetric(horizontal: 8), + ShadContextMenuItemVariant.inset => + const EdgeInsets.only(left: 32, right: 8), + }; + + final effectiveInsetPadding = widget.insetPadding ?? + theme.contextMenuTheme.insetPadding ?? + defaultInsetPadding; + + final effectiveLeadingPadding = widget.leadingPadding ?? + theme.contextMenuTheme.leadingPadding ?? + const EdgeInsets.only(right: 8); + + final effectiveTrailingPadding = widget.trailingPadding ?? + theme.contextMenuTheme.trailingPadding ?? + const EdgeInsets.only(left: 8); + + final effectiveAnchor = widget.anchor ?? + theme.contextMenuTheme.anchor ?? + ShadAnchor( + overlayAlignment: Alignment.topRight, + offset: Offset(-8, parentItemController != null ? -5 : -3), + ); + + final effectiveHeight = + widget.height ?? theme.contextMenuTheme.height ?? 32; + + final effectiveButtonVariant = widget.buttonVariant ?? + theme.contextMenuTheme.buttonVariant ?? + ShadButtonVariant.ghost; + + final effectiveDecoration = widget.decoration ?? + theme.contextMenuTheme.itemDecoration ?? + const ShadDecoration( + secondaryBorder: ShadBorder.none, + secondaryFocusedBorder: ShadBorder.none, + ); + + final effectiveTextStyle = widget.textStyle ?? + theme.contextMenuTheme.textStyle ?? + theme.textTheme.small.copyWith(fontWeight: FontWeight.normal); + + final effectiveTrailingTextStyle = widget.trailingTextStyle ?? + theme.contextMenuTheme.trailingTextStyle ?? + theme.textTheme.muted.copyWith( + fontSize: 12, + height: 1, + ); + + final effectiveBackgroundColor = + widget.backgroundColor ?? theme.contextMenuTheme.backgroundColor; + + final effectiveSelectedBackgroundColor = widget.selectedBackgroundColor ?? + theme.contextMenuTheme.selectedBackgroundColor ?? + theme.colorScheme.accent; + + final effectiveCloseOnTap = widget.closeOnTap ?? + theme.contextMenuTheme.closeOnTap ?? + widget.children.isEmpty; + + /// if the item has children, use the current item key, + /// otherwise use the parent item controller's item key + /// or the current item key if there is no parent item + final effectiveGroupId = widget.children.isNotEmpty + ? itemKey + : parentItemController?.itemKey ?? itemKey; + + Widget child = ListenableBuilder( + listenable: controller, + builder: (context, child) { + return ShadContextMenu( + visible: controller.selected, + anchor: effectiveAnchor, + constraints: widget.constraints, + padding: widget.subMenuPadding, + groupId: effectiveGroupId, + onHoverArea: controller.setHovered, + children: widget.children, + child: Padding( + padding: effectivePadding, + child: ShadButton.raw( + height: effectiveHeight, + enabled: widget.enabled, + variant: effectiveButtonVariant, + decoration: effectiveDecoration, + width: double.infinity, + padding: effectiveInsetPadding, + backgroundColor: controller.selected + ? effectiveSelectedBackgroundColor + : effectiveBackgroundColor, + onFocusChange: controller.setFocused, + onPressed: () { + widget.onPressed?.call(); + if (effectiveCloseOnTap) contextMenu.setVisible(false); + }, + child: child, + ), + ), + ); + }, + child: Expanded( + child: Row( + children: [ + if (widget.leading != null) + Padding( + padding: effectiveLeadingPadding, + child: widget.leading, + ), + Expanded( + child: DefaultTextStyle( + style: effectiveTextStyle, + child: widget.child, + ), + ), + if (widget.trailing != null) + Padding( + padding: effectiveTrailingPadding, + child: DefaultTextStyle( + style: effectiveTrailingTextStyle, + child: widget.trailing!, + ), + ), + ], + ), + ), + ); + + // if the item has a submenu, wrap it in a provider to provide the + // controller to the children + if (widget.children.isNotEmpty) { + child = ShadProvider( + data: controller, + child: child, + ); + } + + return child; + } +} diff --git a/lib/src/components/form/form.dart b/lib/src/components/form/form.dart index b261687e..735afefe 100644 --- a/lib/src/components/form/form.dart +++ b/lib/src/components/form/form.dart @@ -29,7 +29,8 @@ class ShadForm extends StatefulWidget { required this.child, this.onChanged, this.canPop, - this.onPopInvoked, + @Deprecated('Use onPopInvokedWithResult instead') this.onPopInvoked, + this.onPopInvokedWithResult, this.autovalidateMode = ShadAutovalidateMode.alwaysAfterFirstValidation, this.initialValue = const {}, this.enabled = true, @@ -38,7 +39,9 @@ class ShadForm extends StatefulWidget { final VoidCallback? onChanged; final bool? canPop; + @Deprecated('Use onPopInvokedWithResult instead') final void Function(bool)? onPopInvoked; + final PopInvokedWithResultCallback? onPopInvokedWithResult; final ShadAutovalidateMode autovalidateMode; final Widget child; final Map initialValue; @@ -185,7 +188,9 @@ class ShadFormState extends State { return Form( key: _formKey, autovalidateMode: mode, + // ignore: deprecated_member_use_from_same_package, deprecated_member_use onPopInvoked: widget.onPopInvoked, + onPopInvokedWithResult: widget.onPopInvokedWithResult, canPop: widget.canPop, child: child!, ); diff --git a/lib/src/components/popover.dart b/lib/src/components/popover.dart index 0984f25e..b15f8917 100644 --- a/lib/src/components/popover.dart +++ b/lib/src/components/popover.dart @@ -7,6 +7,7 @@ import 'package:shadcn_ui/src/raw_components/portal.dart'; import 'package:shadcn_ui/src/theme/components/decorator.dart'; import 'package:shadcn_ui/src/theme/theme.dart'; import 'package:shadcn_ui/src/theme/themes/shadows.dart'; +import 'package:shadcn_ui/src/utils/mouse_area.dart'; /// Controls the visibility of a [ShadPopover]. class ShadPopoverController extends ChangeNotifier { @@ -46,6 +47,8 @@ class ShadPopover extends StatefulWidget { this.padding, this.decoration, this.filter, + this.groupId, + this.areaGroupId, }) : assert( (controller != null) ^ (visible != null), 'Either controller or visible must be provided', @@ -57,7 +60,7 @@ class ShadPopover extends StatefulWidget { /// The child widget. final Widget child; - /// {@template popover.controller} + /// {@template ShadPopover.controller} /// The controller that controls the visibility of the [popover]. /// {@endtemplate} final ShadPopoverController? controller; @@ -72,7 +75,7 @@ class ShadPopover extends StatefulWidget { /// focused. final FocusNode? focusNode; - ///{@template popover.anchor} + ///{@template ShadPopover.anchor} /// The position of the [popover] in the global coordinate system. /// /// Defaults to @@ -80,35 +83,45 @@ class ShadPopover extends StatefulWidget { /// {@endtemplate} final ShadAnchorBase? anchor; - /// {@template popover.effects} + /// {@template ShadPopover.effects} /// The animation effects applied to the [popover]. Defaults to /// [FadeEffect(), ScaleEffect(begin: Offset(.95, .95), end: Offset(1, 1)), /// MoveEffect(begin: Offset(0, 2), end: Offset(0, 0))]. /// {@endtemplate} final List>? effects; - /// {@template popover.shadows} + /// {@template ShadPopover.shadows} /// The shadows applied to the [popover], defaults to /// [ShadShadows.md]. /// {@endtemplate} final List? shadows; - /// {@template popover.padding} + /// {@template ShadPopover.padding} /// The padding of the [popover], defaults to /// `EdgeInsets.symmetric(horizontal: 12, vertical: 6)`. /// {@endtemplate} - final EdgeInsets? padding; + final EdgeInsetsGeometry? padding; - /// {@template popover.decoration} + /// {@template ShadPopover.decoration} /// The decoration of the [popover]. /// {@endtemplate} final ShadDecoration? decoration; - /// {@template popover.filter} + /// {@template ShadPopover.filter} /// The filter of the [popover], defaults to `null`. /// {@endtemplate} final ImageFilter? filter; + /// {@template ShadPopover.groupId} + /// The group id of the [popover], defaults to `UniqueKey()`. + /// + /// Used to determine it the tap is inside the [popover] or not. + /// {@endtemplate} + final Object? groupId; + + /// {@macro ShadMouseArea.groupId} + final Object? areaGroupId; + @override State createState() => _ShadPopoverState(); } @@ -117,7 +130,10 @@ class _ShadPopoverState extends State { ShadPopoverController? _controller; ShadPopoverController get controller => widget.controller ?? _controller!; bool animating = false; - final popoverKey = UniqueKey(); + + late final _popoverKey = UniqueKey(); + + Object get groupId => widget.groupId ?? _popoverKey; @override void initState() { @@ -171,17 +187,20 @@ class _ShadPopoverState extends State { final effectiveFilter = widget.filter ?? theme.popoverTheme.filter; - Widget popover = ShadDecorator( - decoration: effectiveDecoration, - child: Padding( - padding: effectivePadding, - child: DefaultTextStyle( - style: TextStyle( - color: theme.colorScheme.popoverForeground, - ), - textAlign: TextAlign.center, - child: Builder( - builder: widget.popover, + Widget popover = ShadMouseArea( + groupId: widget.areaGroupId, + child: ShadDecorator( + decoration: effectiveDecoration, + child: Padding( + padding: effectivePadding, + child: DefaultTextStyle( + style: TextStyle( + color: theme.colorScheme.popoverForeground, + ), + textAlign: TextAlign.center, + child: Builder( + builder: widget.popover, + ), ), ), ), @@ -203,7 +222,7 @@ class _ShadPopoverState extends State { if (widget.closeOnTapOutside) { popover = TapRegion( - groupId: popoverKey, + groupId: groupId, behavior: HitTestBehavior.opaque, onTapOutside: (_) => controller.hide(), child: popover, @@ -211,7 +230,7 @@ class _ShadPopoverState extends State { } return TapRegion( - groupId: popoverKey, + groupId: groupId, child: ListenableBuilder( listenable: controller, builder: (context, _) { diff --git a/lib/src/raw_components/focusable.dart b/lib/src/raw_components/focusable.dart index 72bebf51..fe111830 100644 --- a/lib/src/raw_components/focusable.dart +++ b/lib/src/raw_components/focusable.dart @@ -14,6 +14,7 @@ class ShadFocusable extends StatefulWidget { this.canRequestFocus = true, this.autofocus = false, this.child, + this.onFocusChange, }); final bool canRequestFocus; @@ -21,6 +22,7 @@ class ShadFocusable extends StatefulWidget { final FocusNode? focusNode; final FocusWidgetBuilder builder; final Widget? child; + final ValueChanged? onFocusChange; @override State createState() => _ShadFocusableState(); @@ -37,16 +39,23 @@ class _ShadFocusableState extends State { void initState() { super.initState(); if (widget.focusNode == null) _internal = FocusNode(); - isFocused.value = focusNode.hasFocus; + isFocused + ..value = focusNode.hasFocus + ..addListener(onFocusChange); } @override void dispose() { + isFocused.removeListener(onFocusChange); _internal?.dispose(); isFocused.dispose(); super.dispose(); } + void onFocusChange() { + widget.onFocusChange?.call(isFocused.value); + } + @override Widget build(BuildContext context) { return Focus( diff --git a/lib/src/raw_components/portal.dart b/lib/src/raw_components/portal.dart index 49d73cb4..933888a9 100644 --- a/lib/src/raw_components/portal.dart +++ b/lib/src/raw_components/portal.dart @@ -58,6 +58,13 @@ class ShadAnchor extends ShadAnchorBase { } } +class ShadGlobalAnchor extends ShadAnchorBase { + const ShadGlobalAnchor(this.offset); + + /// The global offset where the overlay is positioned. + final Offset offset; +} + class ShadPortal extends StatefulWidget { const ShadPortal({ super.key, @@ -154,6 +161,20 @@ class _ShadPortalState extends State { ); } + Widget buildGlobalPosition( + BuildContext context, + ShadGlobalAnchor anchor, + ) { + return CustomSingleChildLayout( + delegate: ShadPositionDelegate( + target: anchor.offset, + verticalOffset: 0, + preferBelow: true, + ), + child: widget.portalBuilder(context), + ); + } + @override Widget build(BuildContext context) { return CompositedTransformTarget( @@ -170,6 +191,8 @@ class _ShadPortalState extends State { final ShadAnchorAuto anchor => buildAutoPosition(context, anchor), final ShadAnchor anchor => buildManualPosition(context, anchor), + final ShadGlobalAnchor anchor => + buildGlobalPosition(context, anchor), }, ), ); diff --git a/lib/src/theme/components/context_menu.dart b/lib/src/theme/components/context_menu.dart new file mode 100644 index 00000000..be52fc73 --- /dev/null +++ b/lib/src/theme/components/context_menu.dart @@ -0,0 +1,284 @@ +import 'dart:ui'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:shadcn_ui/src/components/button.dart'; +import 'package:shadcn_ui/src/raw_components/portal.dart'; + +import 'package:shadcn_ui/src/theme/components/decorator.dart'; + +@immutable +class ShadContextMenuTheme { + const ShadContextMenuTheme({ + this.merge = true, + this.constraints, + this.padding, + this.leadingPadding, + this.trailingPadding, + this.itemPadding, + this.insetPadding, + this.anchor, + this.showDelay, + this.height, + this.buttonVariant, + this.decoration, + this.textStyle, + this.trailingTextStyle, + this.itemConstraints, + this.subMenuPadding, + this.backgroundColor, + this.selectedBackgroundColor, + this.closeOnTap, + this.effects, + this.shadows, + this.itemDecoration, + this.filter, + }); + + final bool merge; + + /// {@macro ShadContextMenu.constraints} + final BoxConstraints? constraints; + + /// {@macro ShadContextMenu.padding} + final EdgeInsetsGeometry? padding; + + /// {@macro ShadContextMenuItem.leadingPadding} + final EdgeInsetsGeometry? leadingPadding; + + /// {@macro ShadContextMenuItem.trailingPadding} + final EdgeInsetsGeometry? trailingPadding; + + /// {@macro ShadContextMenuItem.padding} + final EdgeInsetsGeometry? itemPadding; + + /// {@macro ShadContextMenuItem.insetPadding} + final EdgeInsetsGeometry? insetPadding; + + /// {@macro ShadContextMenuItem.anchor} + final ShadAnchorBase? anchor; + + /// {@macro ShadContextMenuItem.showDelay} + final Duration? showDelay; + + /// {@macro ShadContextMenuItem.height} + final double? height; + + /// {@macro ShadContextMenuItem.buttonVariant} + final ShadButtonVariant? buttonVariant; + + /// {@macro ShadContextMenuItem.decoration} + final ShadDecoration? itemDecoration; + + /// {@macro ShadContextMenuItem.textStyle} + final TextStyle? textStyle; + + /// {@macro ShadContextMenuItem.trailingTextStyle} + final TextStyle? trailingTextStyle; + + /// {@macro ShadContextMenu.constraints} + final BoxConstraints? itemConstraints; + + /// {@macro ShadContextMenu.padding} + final EdgeInsetsGeometry? subMenuPadding; + + /// {@macro ShadContextMenuItem.backgroundColor} + final Color? backgroundColor; + + /// {@macro ShadContextMenuItem.selectedBackgroundColor} + final Color? selectedBackgroundColor; + + /// {@macro ShadContextMenuItem.closeOnTap} + final bool? closeOnTap; + + /// {@macro ShadPopover.effects} + final List>? effects; + + /// {@macro ShadPopover.shadows} + final List? shadows; + + /// {@macro ShadPopover.decoration} + final ShadDecoration? decoration; + + /// {@macro ShadPopover.filter} + final ImageFilter? filter; + + static ShadContextMenuTheme lerp( + ShadContextMenuTheme a, + ShadContextMenuTheme b, + double t, + ) { + if (identical(a, b)) return a; + return ShadContextMenuTheme( + merge: t < .5 ? a.merge : b.merge, + constraints: BoxConstraints.lerp(a.constraints, b.constraints, t), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + leadingPadding: + EdgeInsetsGeometry.lerp(a.leadingPadding, b.leadingPadding, t), + trailingPadding: + EdgeInsetsGeometry.lerp(a.trailingPadding, b.trailingPadding, t), + itemPadding: EdgeInsetsGeometry.lerp(a.itemPadding, b.itemPadding, t), + insetPadding: EdgeInsetsGeometry.lerp(a.insetPadding, b.insetPadding, t), + anchor: t < .5 ? a.anchor : b.anchor, + showDelay: t < .5 ? a.showDelay : b.showDelay, + height: lerpDouble(a.height, b.height, t), + buttonVariant: t < .5 ? a.buttonVariant : b.buttonVariant, + decoration: ShadDecoration.lerp(a.decoration, b.decoration, t), + textStyle: TextStyle.lerp(a.textStyle, b.textStyle, t), + trailingTextStyle: + TextStyle.lerp(a.trailingTextStyle, b.trailingTextStyle, t), + itemConstraints: + BoxConstraints.lerp(a.itemConstraints, b.itemConstraints, t), + subMenuPadding: + EdgeInsetsGeometry.lerp(a.subMenuPadding, b.subMenuPadding, t), + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + selectedBackgroundColor: + Color.lerp(a.selectedBackgroundColor, b.selectedBackgroundColor, t), + closeOnTap: t < .5 ? a.closeOnTap : b.closeOnTap, + itemDecoration: + ShadDecoration.lerp(a.itemDecoration, b.itemDecoration, t), + effects: t < .5 ? a.effects : b.effects, + shadows: t < .5 ? a.shadows : b.shadows, + filter: t < .5 ? a.filter : b.filter, + ); + } + + ShadContextMenuTheme copyWith({ + bool? merge, + BoxConstraints? constraints, + EdgeInsetsGeometry? padding, + EdgeInsetsGeometry? leadingPadding, + EdgeInsetsGeometry? trailingPadding, + EdgeInsetsGeometry? itemPadding, + EdgeInsetsGeometry? insetPadding, + ShadAnchorBase? anchor, + Duration? showDelay, + double? height, + ShadButtonVariant? buttonVariant, + ShadDecoration? decoration, + TextStyle? textStyle, + TextStyle? trailingTextStyle, + BoxConstraints? itemConstraints, + EdgeInsetsGeometry? subMenuPadding, + Color? backgroundColor, + Color? selectedBackgroundColor, + bool? closeOnTap, + List>? effects, + List? shadows, + ShadDecoration? itemDecoration, + ImageFilter? filter, + }) { + return ShadContextMenuTheme( + merge: merge ?? this.merge, + constraints: constraints ?? this.constraints, + padding: padding ?? this.padding, + leadingPadding: leadingPadding ?? this.leadingPadding, + trailingPadding: trailingPadding ?? this.trailingPadding, + itemPadding: itemPadding ?? this.itemPadding, + insetPadding: insetPadding ?? this.insetPadding, + anchor: anchor ?? this.anchor, + showDelay: showDelay ?? this.showDelay, + height: height ?? this.height, + buttonVariant: buttonVariant ?? this.buttonVariant, + decoration: decoration ?? this.decoration, + textStyle: textStyle ?? this.textStyle, + trailingTextStyle: trailingTextStyle ?? this.trailingTextStyle, + itemConstraints: itemConstraints ?? this.itemConstraints, + subMenuPadding: subMenuPadding ?? this.subMenuPadding, + backgroundColor: backgroundColor ?? this.backgroundColor, + selectedBackgroundColor: + selectedBackgroundColor ?? this.selectedBackgroundColor, + closeOnTap: closeOnTap ?? this.closeOnTap, + effects: effects ?? this.effects, + shadows: shadows ?? this.shadows, + itemDecoration: itemDecoration ?? this.itemDecoration, + filter: filter ?? this.filter, + ); + } + + ShadContextMenuTheme mergeWith(ShadContextMenuTheme? other) { + if (other == null) return this; + if (!other.merge) return other; + return copyWith( + constraints: other.constraints, + padding: other.padding, + leadingPadding: other.leadingPadding, + trailingPadding: other.trailingPadding, + itemPadding: other.itemPadding, + insetPadding: other.insetPadding, + anchor: other.anchor, + showDelay: other.showDelay, + height: other.height, + buttonVariant: other.buttonVariant, + decoration: decoration?.mergeWith(other.decoration) ?? other.decoration, + textStyle: other.textStyle, + trailingTextStyle: other.trailingTextStyle, + itemConstraints: other.itemConstraints, + subMenuPadding: other.subMenuPadding, + backgroundColor: other.backgroundColor, + selectedBackgroundColor: other.selectedBackgroundColor, + closeOnTap: other.closeOnTap, + effects: other.effects, + shadows: other.shadows, + itemDecoration: other.itemDecoration, + filter: other.filter, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is ShadContextMenuTheme && + other.merge == merge && + other.constraints == constraints && + other.padding == padding && + other.leadingPadding == leadingPadding && + other.trailingPadding == trailingPadding && + other.itemPadding == itemPadding && + other.insetPadding == insetPadding && + other.anchor == anchor && + other.showDelay == showDelay && + other.height == height && + other.buttonVariant == buttonVariant && + other.decoration == decoration && + other.textStyle == textStyle && + other.trailingTextStyle == trailingTextStyle && + other.itemConstraints == itemConstraints && + other.subMenuPadding == subMenuPadding && + other.backgroundColor == backgroundColor && + other.selectedBackgroundColor == selectedBackgroundColor && + other.closeOnTap == closeOnTap && + other.effects == effects && + other.shadows == shadows && + other.itemDecoration == itemDecoration && + other.filter == filter; + } + + @override + int get hashCode { + return merge.hashCode ^ + constraints.hashCode ^ + padding.hashCode ^ + leadingPadding.hashCode ^ + trailingPadding.hashCode ^ + itemPadding.hashCode ^ + insetPadding.hashCode ^ + anchor.hashCode ^ + showDelay.hashCode ^ + height.hashCode ^ + buttonVariant.hashCode ^ + decoration.hashCode ^ + textStyle.hashCode ^ + trailingTextStyle.hashCode ^ + itemConstraints.hashCode ^ + subMenuPadding.hashCode ^ + backgroundColor.hashCode ^ + selectedBackgroundColor.hashCode ^ + closeOnTap.hashCode ^ + effects.hashCode ^ + shadows.hashCode ^ + itemDecoration.hashCode ^ + filter.hashCode; + } +} diff --git a/lib/src/theme/components/decorator.dart b/lib/src/theme/components/decorator.dart index c7d4edd9..b2789901 100644 --- a/lib/src/theme/components/decorator.dart +++ b/lib/src/theme/components/decorator.dart @@ -199,7 +199,15 @@ class ShadDecoration { this.fallbackToLabelStyle, }); - static const ShadDecoration none = ShadDecoration(merge: false); + static const ShadDecoration none = ShadDecoration( + merge: false, + border: ShadBorder.none, + focusedBorder: ShadBorder.none, + errorBorder: ShadBorder.none, + secondaryBorder: ShadBorder.none, + secondaryFocusedBorder: ShadBorder.none, + secondaryErrorBorder: ShadBorder.none, + ); final bool merge; final TextStyle? labelStyle; diff --git a/lib/src/theme/data.dart b/lib/src/theme/data.dart index a6c306d6..04ceeee6 100644 --- a/lib/src/theme/data.dart +++ b/lib/src/theme/data.dart @@ -9,6 +9,7 @@ import 'package:shadcn_ui/src/theme/components/badge.dart'; import 'package:shadcn_ui/src/theme/components/button.dart'; import 'package:shadcn_ui/src/theme/components/card.dart'; import 'package:shadcn_ui/src/theme/components/checkbox.dart'; +import 'package:shadcn_ui/src/theme/components/context_menu.dart'; import 'package:shadcn_ui/src/theme/components/decorator.dart'; import 'package:shadcn_ui/src/theme/components/dialog.dart'; import 'package:shadcn_ui/src/theme/components/input.dart'; @@ -80,6 +81,7 @@ class ShadThemeData extends ShadBaseTheme { bool? disableSecondaryBorder, ShadTabsTheme? tabsTheme, ShadThemeVariant? variant, + ShadContextMenuTheme? contextMenuTheme, }) { final effectiveRadius = radius ?? const BorderRadius.all(Radius.circular(6)); @@ -214,6 +216,8 @@ class ShadThemeData extends ShadBaseTheme { hoverStrategies: hoverStrategies ?? effectiveVariant.hoverStrategies(), disableSecondaryBorder: effectiveDisableSecondaryBorder, tabsTheme: effectiveVariant.tabsTheme().mergeWith(tabsTheme), + contextMenuTheme: + effectiveVariant.contextMenuTheme().mergeWith(contextMenuTheme), ); } @@ -262,6 +266,7 @@ class ShadThemeData extends ShadBaseTheme { required super.hoverStrategies, required super.disableSecondaryBorder, required super.tabsTheme, + required super.contextMenuTheme, }); static ShadThemeData lerp(ShadThemeData a, ShadThemeData b, double t) { @@ -354,6 +359,8 @@ class ShadThemeData extends ShadBaseTheme { disableSecondaryBorder: t < .5 ? a.disableSecondaryBorder : b.disableSecondaryBorder, tabsTheme: ShadTabsTheme.lerp(a.tabsTheme, b.tabsTheme, t), + contextMenuTheme: + ShadContextMenuTheme.lerp(a.contextMenuTheme, b.contextMenuTheme, t), ); } @@ -405,7 +412,8 @@ class ShadThemeData extends ShadBaseTheme { other.resizableTheme == resizableTheme && other.hoverStrategies == hoverStrategies && other.disableSecondaryBorder == disableSecondaryBorder && - other.tabsTheme == tabsTheme; + other.tabsTheme == tabsTheme && + other.contextMenuTheme == contextMenuTheme; } @override @@ -453,7 +461,8 @@ class ShadThemeData extends ShadBaseTheme { resizableTheme.hashCode ^ hoverStrategies.hashCode ^ disableSecondaryBorder.hashCode ^ - tabsTheme.hashCode; + tabsTheme.hashCode ^ + contextMenuTheme.hashCode; } ShadThemeData copyWith({ @@ -501,6 +510,7 @@ class ShadThemeData extends ShadBaseTheme { ShadHoverStrategies? hoverStrategies, bool? disableSecondaryBorder, ShadTabsTheme? tabsTheme, + ShadContextMenuTheme? contextMenuTheme, }) { return ShadThemeData( colorScheme: colorScheme ?? this.colorScheme, @@ -552,6 +562,7 @@ class ShadThemeData extends ShadBaseTheme { disableSecondaryBorder: disableSecondaryBorder ?? this.disableSecondaryBorder, tabsTheme: tabsTheme ?? this.tabsTheme, + contextMenuTheme: contextMenuTheme ?? this.contextMenuTheme, ); } } diff --git a/lib/src/theme/themes/base.dart b/lib/src/theme/themes/base.dart index 0679a8fd..11c6d2ea 100644 --- a/lib/src/theme/themes/base.dart +++ b/lib/src/theme/themes/base.dart @@ -7,6 +7,7 @@ import 'package:shadcn_ui/src/theme/components/badge.dart'; import 'package:shadcn_ui/src/theme/components/button.dart'; import 'package:shadcn_ui/src/theme/components/card.dart'; import 'package:shadcn_ui/src/theme/components/checkbox.dart'; +import 'package:shadcn_ui/src/theme/components/context_menu.dart'; import 'package:shadcn_ui/src/theme/components/decorator.dart'; import 'package:shadcn_ui/src/theme/components/dialog.dart'; import 'package:shadcn_ui/src/theme/components/input.dart'; @@ -74,6 +75,7 @@ abstract class ShadBaseTheme { required this.hoverStrategies, required this.disableSecondaryBorder, required this.tabsTheme, + required this.contextMenuTheme, }); final ShadColorScheme colorScheme; @@ -120,6 +122,7 @@ abstract class ShadBaseTheme { final ShadHoverStrategies hoverStrategies; final bool disableSecondaryBorder; final ShadTabsTheme tabsTheme; + final ShadContextMenuTheme contextMenuTheme; } @immutable @@ -161,4 +164,5 @@ abstract class ShadThemeVariant { ShadResizableTheme resizableTheme(); ShadHoverStrategies hoverStrategies(); ShadTabsTheme tabsTheme(); + ShadContextMenuTheme contextMenuTheme(); } diff --git a/lib/src/theme/themes/default_theme_no_secondary_border_variant.dart b/lib/src/theme/themes/default_theme_no_secondary_border_variant.dart index f7c708d7..99fc544f 100644 --- a/lib/src/theme/themes/default_theme_no_secondary_border_variant.dart +++ b/lib/src/theme/themes/default_theme_no_secondary_border_variant.dart @@ -4,6 +4,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:shadcn_ui/src/components/button.dart'; import 'package:shadcn_ui/src/raw_components/portal.dart'; import 'package:shadcn_ui/src/theme/color_scheme/base.dart'; import 'package:shadcn_ui/src/theme/components/accordion.dart'; @@ -13,6 +14,7 @@ import 'package:shadcn_ui/src/theme/components/badge.dart'; import 'package:shadcn_ui/src/theme/components/button.dart'; import 'package:shadcn_ui/src/theme/components/card.dart'; import 'package:shadcn_ui/src/theme/components/checkbox.dart'; +import 'package:shadcn_ui/src/theme/components/context_menu.dart'; import 'package:shadcn_ui/src/theme/components/decorator.dart'; import 'package:shadcn_ui/src/theme/components/dialog.dart'; import 'package:shadcn_ui/src/theme/components/input.dart'; @@ -751,4 +753,24 @@ class ShadDefaultThemeNoSecondaryBorderVariant extends ShadThemeVariant { ShadTextTheme textTheme() { return effectiveTextTheme; } + + @override + ShadContextMenuTheme contextMenuTheme() => ShadContextMenuTheme( + constraints: const BoxConstraints(minWidth: 128), + padding: const EdgeInsets.symmetric(vertical: 4), + itemPadding: const EdgeInsets.symmetric(horizontal: 4), + leadingPadding: const EdgeInsets.only(right: 8), + trailingPadding: const EdgeInsets.only(left: 8), + showDelay: const Duration(milliseconds: 100), + height: 32, + buttonVariant: ShadButtonVariant.ghost, + itemDecoration: ShadDecoration( + focusedBorder: decorationTheme().border, + ), + textStyle: + effectiveTextTheme.small.copyWith(fontWeight: FontWeight.normal), + trailingTextStyle: + effectiveTextTheme.muted.copyWith(fontSize: 12, height: 1), + selectedBackgroundColor: colorScheme.accent, + ); } diff --git a/lib/src/theme/themes/default_theme_variant.dart b/lib/src/theme/themes/default_theme_variant.dart index 17c90dd1..3c1a498b 100644 --- a/lib/src/theme/themes/default_theme_variant.dart +++ b/lib/src/theme/themes/default_theme_variant.dart @@ -4,6 +4,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:shadcn_ui/src/components/button.dart'; import 'package:shadcn_ui/src/raw_components/portal.dart'; import 'package:shadcn_ui/src/theme/color_scheme/base.dart'; import 'package:shadcn_ui/src/theme/components/accordion.dart'; @@ -13,6 +14,7 @@ import 'package:shadcn_ui/src/theme/components/badge.dart'; import 'package:shadcn_ui/src/theme/components/button.dart'; import 'package:shadcn_ui/src/theme/components/card.dart'; import 'package:shadcn_ui/src/theme/components/checkbox.dart'; +import 'package:shadcn_ui/src/theme/components/context_menu.dart'; import 'package:shadcn_ui/src/theme/components/decorator.dart'; import 'package:shadcn_ui/src/theme/components/dialog.dart'; import 'package:shadcn_ui/src/theme/components/input.dart'; @@ -723,4 +725,25 @@ class ShadDefaultThemeVariant extends ShadThemeVariant { ShadTextTheme textTheme() { return effectiveTextTheme; } + + @override + ShadContextMenuTheme contextMenuTheme() => ShadContextMenuTheme( + constraints: const BoxConstraints(minWidth: 128), + padding: const EdgeInsets.symmetric(vertical: 4), + itemPadding: const EdgeInsets.symmetric(horizontal: 4), + leadingPadding: const EdgeInsets.only(right: 8), + trailingPadding: const EdgeInsets.only(left: 8), + showDelay: const Duration(milliseconds: 100), + height: 32, + buttonVariant: ShadButtonVariant.ghost, + itemDecoration: const ShadDecoration( + secondaryBorder: ShadBorder.none, + secondaryFocusedBorder: ShadBorder.none, + ), + textStyle: + effectiveTextTheme.small.copyWith(fontWeight: FontWeight.normal), + trailingTextStyle: + effectiveTextTheme.muted.copyWith(fontSize: 12, height: 1), + selectedBackgroundColor: colorScheme.accent, + ); } diff --git a/lib/src/utils/gesture_detector.dart b/lib/src/utils/gesture_detector.dart index 60915c39..653dbcc4 100644 --- a/lib/src/utils/gesture_detector.dart +++ b/lib/src/utils/gesture_detector.dart @@ -69,6 +69,10 @@ class ShadGestureDetector extends StatelessWidget { this.onTapDown, this.onTapUp, this.onTapCancel, + this.onSecondaryTap, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, this.onLongPress, this.onLongPressStart, this.onLongPressCancel, @@ -98,6 +102,10 @@ class ShadGestureDetector extends StatelessWidget { final ValueChanged? onTapDown; final ValueChanged? onTapUp; final VoidCallback? onTapCancel; + final VoidCallback? onSecondaryTap; + final ValueChanged? onSecondaryTapDown; + final ValueChanged? onSecondaryTapUp; + final VoidCallback? onSecondaryTapCancel; final VoidCallback? onLongPress; final ValueChanged? onLongPressStart; final VoidCallback? onLongPressCancel; @@ -193,6 +201,22 @@ class ShadGestureDetector extends StatelessWidget { onTapDown?.call(d); } + void effectiveOnSecondaryTapDown(TapDownDetails d) { + onSecondaryTapDown?.call(d); + } + + void effectiveOnSecondaryTapUp(TapUpDetails d) { + onSecondaryTapUp?.call(d); + } + + void effectiveOnSecondaryTapCancel() { + onSecondaryTapCancel?.call(); + } + + void effectiveOnSecondaryTap() { + onSecondaryTap?.call(); + } + void effectiveOnTapUp(TapUpDetails d) { setHover(ShadHoverStrategy.onTapUp); onTapUp?.call(d); @@ -250,6 +274,10 @@ class ShadGestureDetector extends StatelessWidget { ..onTapUp = effectiveOnTapUp ..onTap = onTap ..onTapCancel = effectiveOnTapCancel + ..onSecondaryTapDown = effectiveOnSecondaryTapDown + ..onSecondaryTapUp = effectiveOnSecondaryTapUp + ..onSecondaryTap = effectiveOnSecondaryTap + ..onSecondaryTapCancel = effectiveOnSecondaryTapCancel ..gestureSettings = gestureSettings ..supportedDevices = supportedDevices; }, diff --git a/lib/src/utils/mouse_area.dart b/lib/src/utils/mouse_area.dart new file mode 100644 index 00000000..a8e38bf0 --- /dev/null +++ b/lib/src/utils/mouse_area.dart @@ -0,0 +1,515 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +abstract class MouseAreaRegistry { + /// Register the given [ShadMouseAreaRenderBox] with the registry. + void registerMouseArea(ShadMouseAreaRenderBox region); + + /// Unregister the given [ShadMouseAreaRenderBox] with the registry. + void unregisterMouseArea(ShadMouseAreaRenderBox region); + + /// Allows finding of the nearest [MouseAreaRegistry], such as a + /// [MouseAreaSurfaceRenderBox]. + static MouseAreaRegistry? maybeOf(BuildContext context) { + return context.findAncestorRenderObjectOfType(); + } + + /// Allows finding of the nearest [MouseAreaRegistry], such as a + /// [MouseAreaSurfaceRenderBox]. + /// + /// Will throw if a [MouseAreaRegistry] isn't found. + static MouseAreaRegistry of(BuildContext context) { + final registry = maybeOf(context); + assert(() { + if (registry == null) { + throw FlutterError( + ''' +MouseRegionRegistry.of() was called with a context that does not contain a MouseRegionSurface widget.\n + No MouseRegionSurface widget ancestor could be found starting from the context that was passed to + MouseRegionRegistry.of().\n + The context used was:\n + $context +''', + ); + } + return true; + }()); + return registry!; + } +} + +class MouseAreaSurfaceRenderBox extends RenderProxyBoxWithHitTestBehavior + implements MouseAreaRegistry { + final Expando _cachedResults = Expando(); + final Set _registeredRegions = + {}; + final Map> _groupIdToRegions = + >{}; + + @override + void handleEvent(PointerEvent event, HitTestEntry entry) { + assert(debugHandleEvent(event, entry)); + assert( + () { + for (final region in _registeredRegions) { + if (!region.enabled) { + return false; + } + } + return true; + }(), + 'A MouseAreaRegion was registered when it was disabled.', + ); + + if (_registeredRegions.isEmpty) { + return; + } + + final result = _cachedResults[entry]; + + if (result == null) { + return; + } + + // A child was hit, so we need to call onExit for those regions or + // groups of regions that were not hit. + final hitRegions = _getRegionsHit(_registeredRegions, result.path) + .cast() + .toSet(); + + final insideRegions = { + for (final ShadMouseAreaRenderBox region in hitRegions) + if (region.groupId == null) + region + // Adding all grouped regions, so they act as a single region. + else + ..._groupIdToRegions[region.groupId]!, + }; + // If they're not inside, then they're outside. + final outsideRegions = _registeredRegions.difference(insideRegions); + for (final region in outsideRegions) { + region.onExit?.call( + PointerExitEvent( + viewId: event.viewId, + timeStamp: event.timeStamp, + kind: event.kind, + pointer: event.pointer, + device: event.device, + position: event.position, + delta: event.delta, + buttons: event.buttons, + obscured: event.obscured, + pressureMin: event.pressureMin, + pressureMax: event.pressureMax, + distance: event.distance, + distanceMax: event.distanceMax, + size: event.size, + radiusMajor: event.radiusMajor, + radiusMinor: event.radiusMinor, + radiusMin: event.radiusMin, + radiusMax: event.radiusMax, + orientation: event.orientation, + tilt: event.tilt, + down: event.down, + synthesized: event.synthesized, + embedderId: event.embedderId, + ), + ); + } + for (final region in insideRegions) { + region.onEnter?.call( + PointerEnterEvent( + viewId: event.viewId, + timeStamp: event.timeStamp, + kind: event.kind, + pointer: event.pointer, + device: event.device, + position: event.position, + delta: event.delta, + buttons: event.buttons, + obscured: event.obscured, + pressureMin: event.pressureMin, + pressureMax: event.pressureMax, + distance: event.distance, + distanceMax: event.distanceMax, + size: event.size, + radiusMajor: event.radiusMajor, + radiusMinor: event.radiusMinor, + radiusMin: event.radiusMin, + radiusMax: event.radiusMax, + orientation: event.orientation, + tilt: event.tilt, + down: event.down, + synthesized: event.synthesized, + embedderId: event.embedderId, + ), + ); + } + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (!size.contains(position)) { + return false; + } + + final hitTarget = + hitTestChildren(result, position: position) || hitTestSelf(position); + + if (hitTarget) { + final entry = BoxHitTestEntry(this, position); + _cachedResults[entry] = result; + result.add(entry); + } + + return hitTarget; + } + + @override + void registerMouseArea(ShadMouseAreaRenderBox region) { + assert(!_registeredRegions.contains(region)); + _registeredRegions.add(region); + if (region.groupId != null) { + _groupIdToRegions[region.groupId] ??= {}; + _groupIdToRegions[region.groupId]!.add(region); + } + } + + @override + void unregisterMouseArea(ShadMouseAreaRenderBox region) { + assert(_registeredRegions.contains(region)); + _registeredRegions.remove(region); + if (region.groupId != null) { + assert(_groupIdToRegions.containsKey(region.groupId)); + _groupIdToRegions[region.groupId]!.remove(region); + if (_groupIdToRegions[region.groupId]!.isEmpty) { + _groupIdToRegions.remove(region.groupId); + } + } + } + + // Returns the registered regions that are in the hit path. + Set _getRegionsHit( + Set detectors, + Iterable hitTestPath, + ) { + return { + for (final HitTestEntry entry in hitTestPath) + if (entry.target case final HitTestTarget target) + if (_registeredRegions.contains(target)) target, + }; + } +} + +class ShadMouseArea extends SingleChildRenderObjectWidget { + /// Creates a const [ShadMouseArea]. + /// + /// The [child] argument is required. + const ShadMouseArea({ + super.key, + super.child, + this.enabled = true, + this.behavior = HitTestBehavior.deferToChild, + this.groupId, + this.onEnter, + this.onExit, + this.cursor = MouseCursor.defer, + String? debugLabel, + }) : debugLabel = kReleaseMode ? null : debugLabel; + + /// Whether or not this [ShadMouseArea] is enabled as part of the composite + /// region. + final bool enabled; + + /// How to behave during hit testing when deciding how the hit test propagates + /// to children and whether to consider targets behind this [ShadMouseArea]. + /// + /// Defaults to [HitTestBehavior.deferToChild]. + /// + /// See [HitTestBehavior] for the allowed values and their meanings. + final HitTestBehavior behavior; + + /// {@template ShadMouseArea.groupId} + /// An optional group ID that groups [ShadMouseArea]s together so that they + /// operate as one region. If any member of a group is hit by a particular + /// hover, then all members will have their [onEnter] or [onExit] called. + /// + /// If the group id is null, then only this region is hit tested. + /// {@endtemplate} + final Object? groupId; + + /// Triggered when a pointer enters the region. + final PointerEnterEventListener? onEnter; + + /// Triggered when a pointer exits the region. + final PointerExitEventListener? onExit; + + /// The mouse cursor for mouse pointers that are hovering over the region. + /// + /// When a mouse enters the region, its cursor will be changed to the [cursor] + /// When the mouse leaves the region, the cursor will be decided by the region + /// found at the new location. + /// + /// The [cursor] defaults to [MouseCursor.defer], deferring the choice of + /// cursor to the next region behind it in hit-test order. + final MouseCursor cursor; + + /// An optional debug label to help with debugging in debug mode. + /// + /// Will be null in release mode. + final String? debugLabel; + + @override + RenderObject createRenderObject(BuildContext context) { + return ShadMouseAreaRenderBox( + registry: MouseAreaRegistry.maybeOf(context), + enabled: enabled, + behavior: behavior, + groupId: groupId, + debugLabel: debugLabel, + onEnter: onEnter, + onExit: onExit, + cursor: cursor, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add( + FlagProperty( + 'enabled', + value: enabled, + ifFalse: 'DISABLED', + defaultValue: true, + ), + ) + ..add( + DiagnosticsProperty( + 'behavior', + behavior, + defaultValue: HitTestBehavior.deferToChild, + ), + ) + ..add( + DiagnosticsProperty( + 'debugLabel', + debugLabel, + defaultValue: null, + ), + ) + ..add( + DiagnosticsProperty('groupId', groupId, defaultValue: null), + ); + } + + @override + void updateRenderObject( + BuildContext context, + covariant ShadMouseAreaRenderBox renderObject, + ) { + renderObject + ..registry = MouseAreaRegistry.maybeOf(context) + ..enabled = enabled + ..behavior = behavior + ..groupId = groupId + ..onEnter = onEnter + ..onExit = onExit; + if (!kReleaseMode) { + renderObject.debugLabel = debugLabel; + } + } +} + +class ShadMouseAreaRenderBox extends RenderProxyBoxWithHitTestBehavior { + /// Creates a [ShadMouseAreaRenderBox]. + ShadMouseAreaRenderBox({ + this.onEnter, + this.onExit, + MouseAreaRegistry? registry, + bool enabled = true, + super.behavior = HitTestBehavior.deferToChild, + bool validForMouseTracker = true, + Object? groupId, + String? debugLabel, + MouseCursor cursor = MouseCursor.defer, + }) : _registry = registry, + _cursor = cursor, + _validForMouseTracker = validForMouseTracker, + _enabled = enabled, + _groupId = groupId, + debugLabel = kReleaseMode ? null : debugLabel; + + bool _isRegistered = false; + + /// A label used in debug builds. Will be null in release builds. + String? debugLabel; + + bool _enabled; + + Object? _groupId; + MouseAreaRegistry? _registry; + bool _validForMouseTracker; + + MouseCursor _cursor; + PointerEnterEventListener? onEnter; + PointerExitEventListener? onExit; + + MouseCursor get cursor => _cursor; + set cursor(MouseCursor value) { + if (_cursor != value) { + _cursor = value; + // A repaint is needed in order to trigger a device update of + // [MouseTracker] so that this new value can be found. + markNeedsPaint(); + } + } + + /// Whether or not this region should participate in the composite region. + bool get enabled => _enabled; + + set enabled(bool value) { + if (_enabled != value) { + _enabled = value; + markNeedsLayout(); + } + } + + /// An optional group ID that groups [ShadMouseAreaRenderBox]s together so + /// that they operate as one region. If any member of a group is hit by a + /// particular hover, then all members will have their + /// [onEnter] or [onExit] called. + /// + /// If the group id is null, then only this region is hit tested. + Object? get groupId => _groupId; + + set groupId(Object? value) { + if (_groupId != value) { + // If the group changes, we need to unregister and re-register under the + // new group. The re-registration happens automatically in layout(). + if (_isRegistered) { + _registry!.unregisterMouseArea(this); + _isRegistered = false; + } + _groupId = value; + markNeedsLayout(); + } + } + + /// The registry that this [ShadMouseAreaRenderBox] should register with. + /// + /// If the [registry] is null, then this region will not be registered + /// anywhere, and will not do any tap detection. + /// + /// A [MouseAreaSurfaceRenderBox] is a [MouseAreaRegistry]. + MouseAreaRegistry? get registry => _registry; + + set registry(MouseAreaRegistry? value) { + if (_registry != value) { + if (_isRegistered) { + _registry!.unregisterMouseArea(this); + _isRegistered = false; + } + _registry = value; + markNeedsLayout(); + } + } + + bool get validForMouseTracker => _validForMouseTracker; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _validForMouseTracker = true; + } + + @override + Size computeSizeForNoChild(BoxConstraints constraints) { + return constraints.biggest; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add( + DiagnosticsProperty( + 'debugLabel', + debugLabel, + defaultValue: null, + ), + ) + ..add( + DiagnosticsProperty('groupId', groupId, defaultValue: null), + ) + ..add( + FlagProperty( + 'enabled', + value: enabled, + ifFalse: 'DISABLED', + defaultValue: true, + ), + ); + } + + @override + void detach() { + // It's possible that the renderObject be detached during mouse events + // dispatching, set the [MouseTrackerAnnotation.validForMouseTracker] false + // to prevent the callbacks from being called. + _validForMouseTracker = false; + super.detach(); + } + + @override + void dispose() { + if (_isRegistered) { + _registry!.unregisterMouseArea(this); + } + super.dispose(); + } + + @override + void layout(Constraints constraints, {bool parentUsesSize = false}) { + super.layout(constraints, parentUsesSize: parentUsesSize); + if (_registry == null) { + return; + } + if (_isRegistered) { + _registry!.unregisterMouseArea(this); + } + final shouldBeRegistered = _enabled && _registry != null; + if (shouldBeRegistered) { + _registry!.registerMouseArea(this); + } + _isRegistered = shouldBeRegistered; + } +} + +/// A widget that provides notification of a hover inside or outside of a set of +/// registered regions, grouped by [ShadMouseArea.groupId], without +/// participating in the [gesture disambiguation](https://flutter.dev/to/gesture-disambiguation) system. +class ShadMouseAreaSurface extends SingleChildRenderObjectWidget { + /// Creates a const [RenderTapRegionSurface]. + /// + /// The [child] attribute is required. + const ShadMouseAreaSurface({ + super.key, + required Widget super.child, + }); + + @override + RenderObject createRenderObject(BuildContext context) { + return MouseAreaSurfaceRenderBox(); + } + + @override + void updateRenderObject( + BuildContext context, + RenderProxyBoxWithHitTestBehavior renderObject, + ) {} +} diff --git a/lib/src/utils/provider.dart b/lib/src/utils/provider.dart index 2cd96f58..3228695c 100644 --- a/lib/src/utils/provider.dart +++ b/lib/src/utils/provider.dart @@ -2,10 +2,12 @@ import 'package:flutter/cupertino.dart'; extension ProviderReadExt on BuildContext { T read() => ShadProvider.of(this, listen: false); + T? maybeRead() => ShadProvider.maybeOf(this, listen: false); } extension ProviderWatchExt on BuildContext { T watch() => ShadProvider.of(this); + T? maybeWatch() => ShadProvider.maybeOf(this); } class ShadProvider extends InheritedWidget { @@ -16,7 +18,10 @@ class ShadProvider extends InheritedWidget { this.notifyUpdate, }); + /// The data to be provided final T data; + + /// Whether to notify the update of the provider, defaults to false final bool Function(ShadProvider oldWidget)? notifyUpdate; static T of(BuildContext context, {bool listen = true}) { diff --git a/playground/lib/main.dart b/playground/lib/main.dart index 505ac240..f641050b 100644 --- a/playground/lib/main.dart +++ b/playground/lib/main.dart @@ -7,6 +7,7 @@ import 'package:playground/pages/badge.dart'; import 'package:playground/pages/button.dart'; import 'package:playground/pages/card.dart'; import 'package:playground/pages/checkbox.dart'; +import 'package:playground/pages/context_menu.dart'; import 'package:playground/pages/dialog.dart'; import 'package:playground/pages/form.dart'; import 'package:playground/pages/image.dart'; @@ -235,5 +236,9 @@ final _router = GoRouter( path: '/tabs', builder: (context, state) => const TabsPage(), ), + GoRoute( + path: '/context-menu', + builder: (context, state) => const ContextMenuPage(), + ), ], ); diff --git a/playground/lib/pages/accordion.dart b/playground/lib/pages/accordion.dart index c104b99e..bf6ddb92 100644 --- a/playground/lib/pages/accordion.dart +++ b/playground/lib/pages/accordion.dart @@ -36,30 +36,28 @@ class AccordionPage extends StatelessWidget { maxWidth: 600, ), padding: const EdgeInsets.symmetric(horizontal: 40), - child: () { - return switch (style) { - ShadAccordionType.single => - ShadAccordion<({String content, String title})>( - children: details.map( - (detail) => ShadAccordionItem( - value: detail, - title: Text(detail.title), - child: Text(detail.content), - ), + child: switch (style) { + ShadAccordionType.single => + ShadAccordion<({String content, String title})>( + children: details.map( + (detail) => ShadAccordionItem( + value: detail, + title: Text(detail.title), + child: Text(detail.content), ), ), - ShadAccordionType.multiple => - ShadAccordion<({String content, String title})>.multiple( - children: details.map( - (detail) => ShadAccordionItem( - value: detail, - title: Text(detail.title), - child: Text(detail.content), - ), + ), + ShadAccordionType.multiple => + ShadAccordion<({String content, String title})>.multiple( + children: details.map( + (detail) => ShadAccordionItem( + value: detail, + title: Text(detail.title), + child: Text(detail.content), ), ), - }; - }(), + ), + }, ), ), ); diff --git a/playground/lib/pages/alert.dart b/playground/lib/pages/alert.dart index acb97bc3..029f74cf 100644 --- a/playground/lib/pages/alert.dart +++ b/playground/lib/pages/alert.dart @@ -16,22 +16,20 @@ class AlertPage extends StatelessWidget { body: Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 40), - child: () { - return switch (style) { - ShadAlertVariant.primary => const ShadAlert( - iconSrc: LucideIcons.terminal, - title: Text('Heads up!'), - description: - Text('You can add components to your app using the cli.'), - ), - ShadAlertVariant.destructive => const ShadAlert.destructive( - iconSrc: LucideIcons.circleAlert, - title: Text('Error'), - description: - Text('Your session has expired. Please log in again.'), - ), - }; - }(), + child: switch (style) { + ShadAlertVariant.primary => const ShadAlert( + iconSrc: LucideIcons.terminal, + title: Text('Heads up!'), + description: + Text('You can add components to your app using the cli.'), + ), + ShadAlertVariant.destructive => const ShadAlert.destructive( + iconSrc: LucideIcons.circleAlert, + title: Text('Error'), + description: + Text('Your session has expired. Please log in again.'), + ), + }, ), ), ); diff --git a/playground/lib/pages/badge.dart b/playground/lib/pages/badge.dart index af1f8694..a51c82f0 100644 --- a/playground/lib/pages/badge.dart +++ b/playground/lib/pages/badge.dart @@ -12,22 +12,20 @@ class BadgePage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( body: Center( - child: () { - return switch (variant) { - ShadBadgeVariant.primary => const ShadBadge( - child: Text('Primary'), - ), - ShadBadgeVariant.secondary => const ShadBadge.secondary( - child: Text('Secondary'), - ), - ShadBadgeVariant.outline => const ShadBadge.outline( - child: Text('Outline'), - ), - ShadBadgeVariant.destructive => const ShadBadge.destructive( - child: Text('Destructive'), - ), - }; - }(), + child: switch (variant) { + ShadBadgeVariant.primary => const ShadBadge( + child: Text('Primary'), + ), + ShadBadgeVariant.secondary => const ShadBadge.secondary( + child: Text('Secondary'), + ), + ShadBadgeVariant.outline => const ShadBadge.outline( + child: Text('Outline'), + ), + ShadBadgeVariant.destructive => const ShadBadge.destructive( + child: Text('Destructive'), + ), + }, ), ); } diff --git a/playground/lib/pages/button.dart b/playground/lib/pages/button.dart index 9f79ad54..12fc931a 100644 --- a/playground/lib/pages/button.dart +++ b/playground/lib/pages/button.dart @@ -23,75 +23,73 @@ class ButtonPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( body: Center( - child: () { - return switch (style) { - PlagroundButtonStyle.primary => ShadButton( - child: const Text('Primary'), - onPressed: () {}, + child: switch (style) { + PlagroundButtonStyle.primary => ShadButton( + child: const Text('Primary'), + onPressed: () {}, + ), + PlagroundButtonStyle.secondary => ShadButton.secondary( + child: const Text('Secondary'), + onPressed: () {}, + ), + PlagroundButtonStyle.destructive => ShadButton.destructive( + child: const Text('Destructive'), + onPressed: () {}, + ), + PlagroundButtonStyle.outline => ShadButton.outline( + child: const Text('Outline'), + onPressed: () {}, + ), + PlagroundButtonStyle.ghost => ShadButton.ghost( + child: const Text('Ghost'), + onPressed: () {}, + ), + PlagroundButtonStyle.link => ShadButton.link( + child: const Text('Link'), + onPressed: () {}, + ), + PlagroundButtonStyle.icon => ShadButton.outline( + icon: const Icon( + Icons.chevron_right, + size: 16, ), - PlagroundButtonStyle.secondary => ShadButton.secondary( - child: const Text('Secondary'), - onPressed: () {}, + onPressed: () {}, + ), + PlagroundButtonStyle.textIcon => ShadButton( + onPressed: () {}, + icon: const Icon( + Icons.mail_outlined, + size: 16, ), - PlagroundButtonStyle.destructive => ShadButton.destructive( - child: const Text('Destructive'), - onPressed: () {}, - ), - PlagroundButtonStyle.outline => ShadButton.outline( - child: const Text('Outline'), - onPressed: () {}, - ), - PlagroundButtonStyle.ghost => ShadButton.ghost( - child: const Text('Ghost'), - onPressed: () {}, - ), - PlagroundButtonStyle.link => ShadButton.link( - child: const Text('Link'), - onPressed: () {}, - ), - PlagroundButtonStyle.icon => ShadButton.outline( - icon: const Icon( - Icons.chevron_right, - size: 16, + child: const Text('Login with Email'), + ), + PlagroundButtonStyle.loading => ShadButton( + onPressed: () {}, + icon: const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator( + strokeWidth: 2, ), - onPressed: () {}, ), - PlagroundButtonStyle.textIcon => ShadButton( - onPressed: () {}, - icon: const Icon( - Icons.mail_outlined, - size: 16, + child: const Text('Please wait'), + ), + PlagroundButtonStyle.gradientShadow => ShadButton( + onPressed: () {}, + gradient: const LinearGradient(colors: [ + Colors.cyan, + Colors.indigo, + ]), + shadows: [ + BoxShadow( + color: Colors.blue.withOpacity(.4), + spreadRadius: 4, + blurRadius: 10, + offset: const Offset(0, 2), ), - child: const Text('Login with Email'), - ), - PlagroundButtonStyle.loading => ShadButton( - onPressed: () {}, - icon: const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - child: const Text('Please wait'), - ), - PlagroundButtonStyle.gradientShadow => ShadButton( - onPressed: () {}, - gradient: const LinearGradient(colors: [ - Colors.cyan, - Colors.indigo, - ]), - shadows: [ - BoxShadow( - color: Colors.blue.withOpacity(.4), - spreadRadius: 4, - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - child: const Text('Gradient with Shadow'), - ), - }; - }(), + ], + child: const Text('Gradient with Shadow'), + ), + }, ), ); } diff --git a/playground/lib/pages/card.dart b/playground/lib/pages/card.dart index ad6346cd..88d5e343 100644 --- a/playground/lib/pages/card.dart +++ b/playground/lib/pages/card.dart @@ -19,12 +19,10 @@ class CardPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( body: Center( - child: () { - return switch (style) { - CardStyle.project => const CardProject(), - CardStyle.notifications => const CardNotifications(), - }; - }(), + child: switch (style) { + CardStyle.project => const CardProject(), + CardStyle.notifications => const CardNotifications(), + }, ), ); } diff --git a/playground/lib/pages/context_menu.dart b/playground/lib/pages/context_menu.dart new file mode 100644 index 00000000..5ec072a1 --- /dev/null +++ b/playground/lib/pages/context_menu.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class ContextMenuPage extends StatelessWidget { + const ContextMenuPage({super.key}); + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: ShadContextMenuRegion( + constraints: const BoxConstraints(minWidth: 300), + child: Container( + width: 300, + height: 200, + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.border), + borderRadius: BorderRadius.circular(8), + ), + child: const Text('Right click here'), + ), + children: [ + const ShadContextMenuItem.inset( + child: Text('Back'), + ), + const ShadContextMenuItem.inset( + enabled: false, + child: Text('Forward'), + ), + const ShadContextMenuItem.inset( + child: Text('Reload'), + ), + const ShadContextMenuItem.inset( + child: Text('More Tools'), + trailing: ShadImage.square( + LucideIcons.chevronRight, + size: 16, + ), + children: [ + ShadContextMenuItem( + child: Text('Save Page As...'), + ), + ShadContextMenuItem( + child: Text('Create Shortcut...'), + ), + ShadContextMenuItem( + child: Text('Name Window...'), + ), + Divider(height: 8), + ShadContextMenuItem( + child: Text('Developer Tools'), + ), + ], + ), + const Divider(height: 8), + const ShadContextMenuItem( + leading: ShadImage.square(LucideIcons.check, size: 16), + child: Text('Show Bookmarks Bar'), + ), + const ShadContextMenuItem.inset(child: Text('Show Full URLs')), + const Divider(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(36, 8, 8, 8), + child: Text('People', style: theme.textTheme.small), + ), + const Divider(height: 8), + ShadContextMenuItem( + leading: SizedBox.square( + dimension: 16, + child: Center( + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: theme.colorScheme.foreground, + shape: BoxShape.circle, + ), + ), + ), + ), + child: const Text('Pedro Duarte'), + ), + const ShadContextMenuItem.inset(child: Text('Colm Tuite')), + ], + ), + ), + ); + } +} diff --git a/playground/lib/pages/dialog.dart b/playground/lib/pages/dialog.dart index 1af575f6..67c9d6aa 100644 --- a/playground/lib/pages/dialog.dart +++ b/playground/lib/pages/dialog.dart @@ -20,79 +20,77 @@ class DialogPage extends StatelessWidget { final theme = ShadTheme.of(context); return Scaffold( body: Center( - child: () { - return switch (style) { - ShadDialogVariant.primary => ShadButton.outline( - child: const Text('Edit Profile'), - onPressed: () { - showShadDialog( - context: context, - builder: (context) => ShadDialog( - title: const Text('Edit Profile'), - description: const Text( - "Make changes to your profile here. Click save when you're done"), - actions: const [ShadButton(child: Text('Save changes'))], - child: Container( - width: 375, - padding: const EdgeInsets.symmetric(vertical: 20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: profile - .map( - (p) => Row( - children: [ - Expanded( - child: Text( - p.title, - textAlign: TextAlign.end, - style: theme.textTheme.small, - ), + child: switch (style) { + ShadDialogVariant.primary => ShadButton.outline( + child: const Text('Edit Profile'), + onPressed: () { + showShadDialog( + context: context, + builder: (context) => ShadDialog( + title: const Text('Edit Profile'), + description: const Text( + "Make changes to your profile here. Click save when you're done"), + actions: const [ShadButton(child: Text('Save changes'))], + child: Container( + width: 375, + padding: const EdgeInsets.symmetric(vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: profile + .map( + (p) => Row( + children: [ + Expanded( + child: Text( + p.title, + textAlign: TextAlign.end, + style: theme.textTheme.small, ), - const SizedBox(width: 16), - Expanded( - flex: 3, - child: ShadInput(initialValue: p.value), - ), - ], - ), - ) - .toList(), - ), + ), + const SizedBox(width: 16), + Expanded( + flex: 3, + child: ShadInput(initialValue: p.value), + ), + ], + ), + ) + .toList(), ), ), - ); - }, - ), - ShadDialogVariant.alert => ShadButton.outline( - child: const Text('Show Dialog'), - onPressed: () { - showShadDialog( - context: context, - builder: (context) => ShadDialog.alert( - title: const Text('Are you absolutely sure?'), - description: const Padding( - padding: EdgeInsets.only(bottom: 8), - child: Text( - 'This action cannot be undone. This will permanently delete your account and remove your data from our servers.', - ), + ), + ); + }, + ), + ShadDialogVariant.alert => ShadButton.outline( + child: const Text('Show Dialog'), + onPressed: () { + showShadDialog( + context: context, + builder: (context) => ShadDialog.alert( + title: const Text('Are you absolutely sure?'), + description: const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'This action cannot be undone. This will permanently delete your account and remove your data from our servers.', ), - actions: [ - ShadButton.outline( - child: const Text('Cancel'), - onPressed: () => Navigator.of(context).pop(false), - ), - ShadButton( - child: const Text('Continue'), - onPressed: () => Navigator.of(context).pop(true), - ), - ], ), - ); - }, - ), - }; - }(), + actions: [ + ShadButton.outline( + child: const Text('Cancel'), + onPressed: () => Navigator.of(context).pop(false), + ), + ShadButton( + child: const Text('Continue'), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ), + ); + }, + ), + }, ), ); } diff --git a/playground/lib/pages/form.dart b/playground/lib/pages/form.dart index 2980d338..211aa15f 100644 --- a/playground/lib/pages/form.dart +++ b/playground/lib/pages/form.dart @@ -58,91 +58,87 @@ class _FormPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - () { - return switch (widget.style) { - FormStyle.inputField => ShadInputFormField( - id: 'username', - label: const Text('Username'), - placeholder: const Text('Enter your username'), - description: - const Text('This is your public display name.'), - validator: (v) { - if (v.length < 2) { - return 'Username must be at least 2 characters.'; - } - return null; - }, - ), - FormStyle.checkboxField => ShadCheckboxFormField( - id: 'terms', - initialValue: false, - inputLabel: - const Text('I accept the terms and conditions'), - onChanged: (v) {}, - inputSublabel: - const Text('You agree to our Terms and Conditions'), - validator: (v) { - if (!v) { - return 'You must accept the terms and conditions'; - } - return null; - }, - ), - FormStyle.switchField => ShadSwitchFormField( - id: 'terms', - initialValue: false, - inputLabel: - const Text('I accept the terms and conditions'), - onChanged: (v) {}, - inputSublabel: - const Text('You agree to our Terms and Conditions'), - validator: (v) { - if (!v) { - return 'You must accept the terms and conditions'; - } - return null; - }, - ), - FormStyle.selectField => ShadSelectFormField( - id: 'email', - minWidth: 350, - initialValue: null, - onChanged: (v) {}, - options: verifiedEmails - .map((email) => - ShadOption(value: email, child: Text(email))) - .toList(), - selectedOptionBuilder: (context, value) => value == - 'none' - ? const Text('Select a verified email to display') - : Text(value), - placeholder: - const Text('Select a verified email to display'), - validator: (v) { - if (v == null) { - return 'Please select an email to display'; - } - return null; - }, - ), - FormStyle.radioField => - ShadRadioGroupFormField( - label: const Text('Notify me about'), - items: NotifyAbout.values.map( - (e) => ShadRadio( - value: e, - label: Text(e.message), - ), + switch (widget.style) { + FormStyle.inputField => ShadInputFormField( + id: 'username', + label: const Text('Username'), + placeholder: const Text('Enter your username'), + description: + const Text('This is your public display name.'), + validator: (v) { + if (v.length < 2) { + return 'Username must be at least 2 characters.'; + } + return null; + }, + ), + FormStyle.checkboxField => ShadCheckboxFormField( + id: 'terms', + initialValue: false, + inputLabel: + const Text('I accept the terms and conditions'), + onChanged: (v) {}, + inputSublabel: + const Text('You agree to our Terms and Conditions'), + validator: (v) { + if (!v) { + return 'You must accept the terms and conditions'; + } + return null; + }, + ), + FormStyle.switchField => ShadSwitchFormField( + id: 'terms', + initialValue: false, + inputLabel: + const Text('I accept the terms and conditions'), + onChanged: (v) {}, + inputSublabel: + const Text('You agree to our Terms and Conditions'), + validator: (v) { + if (!v) { + return 'You must accept the terms and conditions'; + } + return null; + }, + ), + FormStyle.selectField => ShadSelectFormField( + id: 'email', + minWidth: 350, + initialValue: null, + onChanged: (v) {}, + options: verifiedEmails + .map((email) => + ShadOption(value: email, child: Text(email))) + .toList(), + selectedOptionBuilder: (context, value) => value == 'none' + ? const Text('Select a verified email to display') + : Text(value), + placeholder: + const Text('Select a verified email to display'), + validator: (v) { + if (v == null) { + return 'Please select an email to display'; + } + return null; + }, + ), + FormStyle.radioField => ShadRadioGroupFormField( + label: const Text('Notify me about'), + items: NotifyAbout.values.map( + (e) => ShadRadio( + value: e, + label: Text(e.message), ), - validator: (v) { - if (v == null) { - return 'You need to select a notification type.'; - } - return null; - }, ), - }; - }(), + validator: (v) { + if (v == null) { + return 'You need to select a notification type.'; + } + return null; + }, + ), + }, const SizedBox(height: 16), ShadButton( child: const Text('Submit'), diff --git a/playground/lib/pages/resizable.dart b/playground/lib/pages/resizable.dart index 322a422c..9a8affdd 100644 --- a/playground/lib/pages/resizable.dart +++ b/playground/lib/pages/resizable.dart @@ -23,13 +23,11 @@ class ResizablePage extends StatelessWidget { child: Container( constraints: const BoxConstraints(maxWidth: 600), padding: const EdgeInsets.symmetric(horizontal: 40), - child: () { - return switch (style) { - ShadResizableStyle.basic => const BasicResizable(), - ShadResizableStyle.vertical => const VerticalResizable(), - ShadResizableStyle.handle => const HandleResizable(), - }; - }(), + child: switch (style) { + ShadResizableStyle.basic => const BasicResizable(), + ShadResizableStyle.vertical => const VerticalResizable(), + ShadResizableStyle.handle => const HandleResizable(), + }, ), ), ); diff --git a/playground/lib/pages/select.dart b/playground/lib/pages/select.dart index e89ff986..2b23ba2d 100644 --- a/playground/lib/pages/select.dart +++ b/playground/lib/pages/select.dart @@ -71,69 +71,67 @@ class SelectPage extends StatelessWidget { padding: const EdgeInsets.only(top: 24), child: Align( alignment: Alignment.topCenter, - child: () { - return switch (variant) { - SelectVariant.fruits => ConstrainedBox( - constraints: const BoxConstraints(minWidth: 180), - child: ShadSelect( - placeholder: const Text('Select a fruit'), - options: [ - Padding( - padding: const EdgeInsets.fromLTRB(32, 6, 6, 6), - child: Text( - 'Fruits', - style: theme.textTheme.muted.copyWith( - fontWeight: FontWeight.w600, - color: theme.colorScheme.popoverForeground, - ), - textAlign: TextAlign.start, + child: switch (variant) { + SelectVariant.fruits => ConstrainedBox( + constraints: const BoxConstraints(minWidth: 180), + child: ShadSelect( + placeholder: const Text('Select a fruit'), + options: [ + Padding( + padding: const EdgeInsets.fromLTRB(32, 6, 6, 6), + child: Text( + 'Fruits', + style: theme.textTheme.muted.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.popoverForeground, ), + textAlign: TextAlign.start, ), - ...fruits.entries.map((e) => - ShadOption(value: e.key, child: Text(e.value))), - ], - selectedOptionBuilder: (context, value) => - Text(fruits[value]!), - onChanged: print, - ), + ), + ...fruits.entries.map( + (e) => ShadOption(value: e.key, child: Text(e.value))), + ], + selectedOptionBuilder: (context, value) => + Text(fruits[value]!), + onChanged: print, ), - SelectVariant.timezone => ConstrainedBox( - constraints: const BoxConstraints(minWidth: 280), - child: ShadSelect( - placeholder: const Text('Select a timezone'), - options: timezones.entries.map( - (zone) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(32, 6, 6, 6), - child: Text( - zone.key, - style: theme.textTheme.muted.copyWith( - fontWeight: FontWeight.w600, - color: theme.colorScheme.popoverForeground, - ), - textAlign: TextAlign.start, + ), + SelectVariant.timezone => ConstrainedBox( + constraints: const BoxConstraints(minWidth: 280), + child: ShadSelect( + placeholder: const Text('Select a timezone'), + options: timezones.entries.map( + (zone) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(32, 6, 6, 6), + child: Text( + zone.key, + style: theme.textTheme.muted.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.popoverForeground, ), + textAlign: TextAlign.start, ), - ...zone.value.entries.map((e) => - ShadOption(value: e.key, child: Text(e.value))) - ], - ), + ), + ...zone.value.entries.map((e) => + ShadOption(value: e.key, child: Text(e.value))) + ], ), - onChanged: print, - selectedOptionBuilder: (context, value) { - final timezone = timezones.entries - .firstWhere( - (element) => element.value.containsKey(value)) - .value[value]; - return Text(timezone!); - }, ), + onChanged: print, + selectedOptionBuilder: (context, value) { + final timezone = timezones.entries + .firstWhere( + (element) => element.value.containsKey(value)) + .value[value]; + return Text(timezone!); + }, ), - SelectVariant.frameworks => const SelectWithSearch(), - }; - }(), + ), + SelectVariant.frameworks => const SelectWithSearch(), + }, ), ), ); diff --git a/playground/lib/pages/sheet.dart b/playground/lib/pages/sheet.dart index 150baf75..c19c3376 100644 --- a/playground/lib/pages/sheet.dart +++ b/playground/lib/pages/sheet.dart @@ -21,75 +21,73 @@ class SheetPage extends StatelessWidget { body: Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 40), - child: () { - return switch (style) { - SheetStyle.primary => ShadButton.outline( - child: const Text('Open'), - onPressed: () => showShadSheet( + child: switch (style) { + SheetStyle.primary => ShadButton.outline( + child: const Text('Open'), + onPressed: () => showShadSheet( + side: ShadSheetSide.right, + context: context, + builder: (context) => const EditProfileSheet( side: ShadSheetSide.right, - context: context, - builder: (context) => const EditProfileSheet( - side: ShadSheetSide.right, - ), ), ), - SheetStyle.side => Row( - mainAxisSize: MainAxisSize.min, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - children: [ - ShadButton.outline( - width: 100, - child: const Text('Top'), - onPressed: () => showShadSheet( - side: ShadSheetSide.top, - context: context, - builder: (context) => - const EditProfileSheet(side: ShadSheetSide.top), - ), + ), + SheetStyle.side => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + ShadButton.outline( + width: 100, + child: const Text('Top'), + onPressed: () => showShadSheet( + side: ShadSheetSide.top, + context: context, + builder: (context) => + const EditProfileSheet(side: ShadSheetSide.top), ), - ShadButton.outline( - width: 100, - child: const Text('Bottom'), - onPressed: () => showShadSheet( - side: ShadSheetSide.bottom, - context: context, - builder: (context) => const EditProfileSheet( - side: ShadSheetSide.bottom), - ), + ), + ShadButton.outline( + width: 100, + child: const Text('Bottom'), + onPressed: () => showShadSheet( + side: ShadSheetSide.bottom, + context: context, + builder: (context) => const EditProfileSheet( + side: ShadSheetSide.bottom), ), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - ShadButton.outline( - width: 100, - child: const Text('Right'), - onPressed: () => showShadSheet( - side: ShadSheetSide.right, - context: context, - builder: (context) => const EditProfileSheet( - side: ShadSheetSide.right), - ), + ), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + ShadButton.outline( + width: 100, + child: const Text('Right'), + onPressed: () => showShadSheet( + side: ShadSheetSide.right, + context: context, + builder: (context) => + const EditProfileSheet(side: ShadSheetSide.right), ), - ShadButton.outline( - width: 100, - child: const Text('Left'), - onPressed: () => showShadSheet( - side: ShadSheetSide.left, - context: context, - builder: (context) => const EditProfileSheet( - side: ShadSheetSide.left), - ), + ), + ShadButton.outline( + width: 100, + child: const Text('Left'), + onPressed: () => showShadSheet( + side: ShadSheetSide.left, + context: context, + builder: (context) => + const EditProfileSheet(side: ShadSheetSide.left), ), - ], - ), - ], - ), - }; - }(), + ), + ], + ), + ], + ), + }, ), ), ); diff --git a/playground/lib/pages/toast.dart b/playground/lib/pages/toast.dart index 749a19a2..b6ddc064 100644 --- a/playground/lib/pages/toast.dart +++ b/playground/lib/pages/toast.dart @@ -22,85 +22,82 @@ class ToastPage extends StatelessWidget { final theme = ShadTheme.of(context); return Scaffold( body: Center( - child: () { - return switch (style) { - ToastStyle.schedule => ShadButton.outline( - child: const Text('Add to calendar'), - onPressed: () { - ShadToaster.of(context).show( - ShadToast( - title: const Text('Scheduled: Catch up'), - description: - const Text('Friday, February 10, 2023 at 5:57 PM'), - action: ShadButton.outline( - child: const Text('Undo'), - onPressed: () => ShadToaster.of(context).hide(), - ), + child: switch (style) { + ToastStyle.schedule => ShadButton.outline( + child: const Text('Add to calendar'), + onPressed: () { + ShadToaster.of(context).show( + ShadToast( + title: const Text('Scheduled: Catch up'), + description: + const Text('Friday, February 10, 2023 at 5:57 PM'), + action: ShadButton.outline( + child: const Text('Undo'), + onPressed: () => ShadToaster.of(context).hide(), ), - ); - }, - ), - ToastStyle.destructive => ShadButton.outline( - child: const Text('Show Toast'), - onPressed: () { - ShadToaster.of(context).show( - ShadToast.destructive( - title: const Text('Uh oh! Something went wrong'), - description: - const Text('There was a problem with your request'), - action: ShadButton.destructive( - decoration: ShadDecoration( - border: ShadBorder.all( - color: theme.colorScheme.destructiveForeground, - ), + ), + ); + }, + ), + ToastStyle.destructive => ShadButton.outline( + child: const Text('Show Toast'), + onPressed: () { + ShadToaster.of(context).show( + ShadToast.destructive( + title: const Text('Uh oh! Something went wrong'), + description: + const Text('There was a problem with your request'), + action: ShadButton.destructive( + decoration: ShadDecoration( + border: ShadBorder.all( + color: theme.colorScheme.destructiveForeground, ), - onPressed: () => ShadToaster.of(context).hide(), - child: const Text('Try again'), ), + onPressed: () => ShadToaster.of(context).hide(), + child: const Text('Try again'), ), - ); - }, - ), - ToastStyle.simple => ShadButton.outline( - child: const Text('Show Toast'), - onPressed: () { - ShadToaster.of(context).show( - const ShadToast( - description: Text('Your message has been sent.'), - ), - ); - }, - ), - ToastStyle.withTitle => ShadButton.outline( - child: const Text('Show Toast'), - onPressed: () { - ShadToaster.of(context).show( - const ShadToast( - title: Text('Uh oh! Something went wrong'), - description: - Text('There was a problem with your request'), - ), - ); - }, - ), - ToastStyle.withAction => ShadButton.outline( - child: const Text('Show Toast'), - onPressed: () { - ShadToaster.of(context).show( - ShadToast( - title: const Text('Uh oh! Something went wrong'), - description: - const Text('There was a problem with your request'), - action: ShadButton.outline( - child: const Text('Try again'), - onPressed: () => ShadToaster.of(context).hide(), - ), + ), + ); + }, + ), + ToastStyle.simple => ShadButton.outline( + child: const Text('Show Toast'), + onPressed: () { + ShadToaster.of(context).show( + const ShadToast( + description: Text('Your message has been sent.'), + ), + ); + }, + ), + ToastStyle.withTitle => ShadButton.outline( + child: const Text('Show Toast'), + onPressed: () { + ShadToaster.of(context).show( + const ShadToast( + title: Text('Uh oh! Something went wrong'), + description: Text('There was a problem with your request'), + ), + ); + }, + ), + ToastStyle.withAction => ShadButton.outline( + child: const Text('Show Toast'), + onPressed: () { + ShadToaster.of(context).show( + ShadToast( + title: const Text('Uh oh! Something went wrong'), + description: + const Text('There was a problem with your request'), + action: ShadButton.outline( + child: const Text('Try again'), + onPressed: () => ShadToaster.of(context).hide(), ), - ); - }, - ), - }; - }(), + ), + ); + }, + ), + }, ), ); } diff --git a/playground/lib/pages/typography.dart b/playground/lib/pages/typography.dart index 41a12a70..250bd903 100644 --- a/playground/lib/pages/typography.dart +++ b/playground/lib/pages/typography.dart @@ -31,62 +31,60 @@ class TypographyPage extends StatelessWidget { body: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Center( - child: () { - return switch (style) { - TypographyStyle.h1Large => Text( - 'Taxing Laughter: The Joke Tax Chronicles', - style: ShadTheme.of(context).textTheme.h1Large, - ), - TypographyStyle.h1 => Text( - 'Taxing Laughter: The Joke Tax Chronicles', - style: ShadTheme.of(context).textTheme.h1, - ), - TypographyStyle.h2 => Text( - 'The People of the Kingdom', - style: ShadTheme.of(context).textTheme.h2, - ), - TypographyStyle.h3 => Text( - 'The Joke Tax', - style: ShadTheme.of(context).textTheme.h3, - ), - TypographyStyle.h4 => Text( - 'People stopped telling jokes', - style: ShadTheme.of(context).textTheme.h4, - ), - TypographyStyle.p => Text( - 'The king, seeing how much happier his subjects were, realized the error of his ways and repealed the joke tax.', - style: ShadTheme.of(context).textTheme.p, - ), - TypographyStyle.blockquote => Text( - '"After all," he said, "everyone enjoys a good joke, so it\'s only fair that they should pay for the privilege."', - style: ShadTheme.of(context).textTheme.blockquote, - ), - TypographyStyle.table => Text( - "King's Treasury", - style: ShadTheme.of(context).textTheme.table, - ), - TypographyStyle.list => Text( - '1st level of puns: 5 gold coins', - style: ShadTheme.of(context).textTheme.list, - ), - TypographyStyle.lead => Text( - 'A modal dialog that interrupts the user with important content and expects a response.', - style: ShadTheme.of(context).textTheme.lead, - ), - TypographyStyle.large => Text( - 'Are you absolutely sure?', - style: ShadTheme.of(context).textTheme.large, - ), - TypographyStyle.small => Text( - 'Email address', - style: ShadTheme.of(context).textTheme.small, - ), - TypographyStyle.muted => Text( - 'Enter your email address.', - style: ShadTheme.of(context).textTheme.muted, - ), - }; - }(), + child: switch (style) { + TypographyStyle.h1Large => Text( + 'Taxing Laughter: The Joke Tax Chronicles', + style: ShadTheme.of(context).textTheme.h1Large, + ), + TypographyStyle.h1 => Text( + 'Taxing Laughter: The Joke Tax Chronicles', + style: ShadTheme.of(context).textTheme.h1, + ), + TypographyStyle.h2 => Text( + 'The People of the Kingdom', + style: ShadTheme.of(context).textTheme.h2, + ), + TypographyStyle.h3 => Text( + 'The Joke Tax', + style: ShadTheme.of(context).textTheme.h3, + ), + TypographyStyle.h4 => Text( + 'People stopped telling jokes', + style: ShadTheme.of(context).textTheme.h4, + ), + TypographyStyle.p => Text( + 'The king, seeing how much happier his subjects were, realized the error of his ways and repealed the joke tax.', + style: ShadTheme.of(context).textTheme.p, + ), + TypographyStyle.blockquote => Text( + '"After all," he said, "everyone enjoys a good joke, so it\'s only fair that they should pay for the privilege."', + style: ShadTheme.of(context).textTheme.blockquote, + ), + TypographyStyle.table => Text( + "King's Treasury", + style: ShadTheme.of(context).textTheme.table, + ), + TypographyStyle.list => Text( + '1st level of puns: 5 gold coins', + style: ShadTheme.of(context).textTheme.list, + ), + TypographyStyle.lead => Text( + 'A modal dialog that interrupts the user with important content and expects a response.', + style: ShadTheme.of(context).textTheme.lead, + ), + TypographyStyle.large => Text( + 'Are you absolutely sure?', + style: ShadTheme.of(context).textTheme.large, + ), + TypographyStyle.small => Text( + 'Email address', + style: ShadTheme.of(context).textTheme.small, + ), + TypographyStyle.muted => Text( + 'Enter your email address.', + style: ShadTheme.of(context).textTheme.muted, + ), + }, ), ), ); diff --git a/pubspec.yaml b/pubspec.yaml index cd342fcc..719b614f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: shadcn_ui description: shadcn-ui ported in Flutter. Awesome UI components for Flutter, fully customizable. -version: 0.8.1 +version: 0.9.0 homepage: https://mariuti.com/shadcn-ui repository: https://github.com/nank1ro/flutter-shadcn-ui documentation: https://mariuti.com/shadcn-ui