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