Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions packages/wonky_app/lib/src/board/pages/board_search.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ class BoardSearchButton extends StatelessWidget {
return ShadPopover(
controller: controller,
padding: EdgeInsets.zero,
anchor: const ShadAnchor(
childAlignment: AlignmentDirectional.bottomEnd,
overlayAlignment: AlignmentDirectional.topEnd,
offset: Offset(0, 6),
anchor: WonkyMenuPlacement.onScreen(
const ShadAnchor(
childAlignment: AlignmentDirectional.bottomEnd,
overlayAlignment: AlignmentDirectional.topEnd,
offset: Offset(0, 6),
),
),
popover: (context) => BoardSearchPanel(
services: services,
Expand Down
11 changes: 7 additions & 4 deletions packages/wonky_app/lib/src/drive/top_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:shad/shad.dart';
import 'package:wonky_client/wonky_client.dart';
import 'package:wonky_ui/wonky_ui.dart';

import '../bootstrap/app_urls.dart';
import '../branding/product_icon.dart';
Expand Down Expand Up @@ -219,10 +220,12 @@ class _DriveSearchFieldState extends State<DriveSearchField> {
return ShadPopover(
controller: _popover,
padding: const EdgeInsets.all(4),
anchor: const ShadAnchor(
childAlignment: AlignmentDirectional.bottomStart,
overlayAlignment: AlignmentDirectional.topStart,
offset: Offset(0, 4),
anchor: WonkyMenuPlacement.onScreen(
const ShadAnchor(
childAlignment: AlignmentDirectional.bottomStart,
overlayAlignment: AlignmentDirectional.topStart,
offset: Offset(0, 4),
),
),
popover: (context) {
final q = _text.text.trim();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,12 @@ class _NotificationsButtonState extends State<NotificationsButton> {
final l = WonkyAppLocalizations.of(context);
return ShadPopover(
controller: _popover,
anchor: const ShadAnchor(
childAlignment: AlignmentDirectional.bottomEnd,
overlayAlignment: AlignmentDirectional.topEnd,
offset: Offset(0, 6),
anchor: WonkyMenuPlacement.onScreen(
const ShadAnchor(
childAlignment: AlignmentDirectional.bottomEnd,
overlayAlignment: AlignmentDirectional.topEnd,
offset: Offset(0, 6),
),
),
popover: (context) => NotificationsPanel(
controller: _ctl,
Expand Down
58 changes: 50 additions & 8 deletions packages/wonky_ui/lib/src/widgets/menu/dropdown_menu.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:shad/shad.dart';

import 'menu_entry.dart';
import 'menu_items.dart';
import 'menu_placement.dart';
import 'open_menu.dart';

/// Builds the trigger of a [WonkyDropdownMenu].
Expand Down Expand Up @@ -30,7 +31,11 @@ class WonkyDropdownMenu extends StatefulWidget {
final WonkyDropdownTriggerBuilder builder;
final ShadContextMenuController? controller;

/// Defaults to below the trigger, start-aligned.
/// Defaults to below the trigger, start-aligned. A `ShadAnchor` is kept on
/// screen: the menu opens on the other side of the trigger when it does
/// not fit and shifts sideways at the window edges
/// ([WonkyMenuPlacement.onScreen]); menus taller than the room beside the
/// trigger scroll.
final ShadAnchorBase? anchor;
final BoxConstraints? constraints;
final ValueChanged<bool>? onOpenChanged;
Expand Down Expand Up @@ -69,6 +74,25 @@ class _WonkyDropdownMenuState extends State<WonkyDropdownMenu> {
super.dispose();
}

static const _defaultAnchor = ShadAnchor(
childAlignment: AlignmentDirectional.bottomStart,
overlayAlignment: AlignmentDirectional.topStart,
offset: Offset(0, 4),
);

/// The room beside the trigger: the menu opens below or above it, so it
/// may be as tall as the larger side.
double? _maxHeight(ShadAnchorBase anchor) {
final rect = WonkyMenuPlacement.rectOf(context);
if (rect == null) return null;
final gap = switch (anchor) {
ShadAnchor(:final offset) ||
ShadAnchorAuto(:final offset) => offset.dy.abs(),
ShadGlobalAnchor() => 0.0,
};
return WonkyMenuPlacement.itemsMaxHeightBeside(context, rect, gap: gap);
}

void _onChanged() {
final open = _controller.isOpen;
if (open != _wasOpen) {
Expand All @@ -85,23 +109,22 @@ class _WonkyDropdownMenuState extends State<WonkyDropdownMenu> {

@override
Widget build(BuildContext context) {
final anchor = widget.anchor ?? _defaultAnchor;
final items = _controller.isOpen
? buildWonkyMenuItems(
context,
widget.entries(),
onClose: _controller.hide,
requestFocus: true,
maxHeight: () => _maxHeight(anchor),
)
: const <Widget>[SizedBox.shrink()];
return ShadContextMenu(
controller: _controller,
anchor:
widget.anchor ??
const ShadAnchor(
childAlignment: AlignmentDirectional.bottomStart,
overlayAlignment: AlignmentDirectional.topStart,
offset: Offset(0, 4),
),
// A manual anchor never moves the menu back onto the screen.
anchor: anchor is ShadAnchor
? WonkyMenuPlacement.onScreen(anchor)
: anchor,
constraints: widget.constraints ?? const BoxConstraints(minWidth: 200),
items: items,
onTapOutside: (_) => _controller.hide(),
Expand Down Expand Up @@ -159,6 +182,11 @@ class _LazyContextMenuRegion extends StatefulWidget {
class _LazyContextMenuRegionState extends State<_LazyContextMenuRegion> {
final _controller = ShadContextMenuController();

/// Where the last pointer went down: the menu opens there (shad flips it
/// above the pointer when it does not fit below and keeps it on screen
/// horizontally), so it may be as tall as the larger side.
Offset? _pointer;

@override
void initState() {
super.initState();
Expand All @@ -181,6 +209,16 @@ class _LazyContextMenuRegionState extends State<_LazyContextMenuRegion> {
super.dispose();
}

double? _maxHeight() {
final at = _pointer;
if (at == null || !mounted) return null;
return WonkyMenuPlacement.itemsMaxHeight(
MediaQuery.sizeOf(context).height,
below: at.dy,
above: at.dy,
);
}

@override
Widget build(BuildContext context) {
return ShadContextMenuRegion(
Expand All @@ -192,12 +230,16 @@ class _LazyContextMenuRegionState extends State<_LazyContextMenuRegion> {
context,
widget.entries(),
onClose: _controller.hide,
maxHeight: _maxHeight,
)
else
const SizedBox.shrink(),
],
child: Listener(
// Also over empty space, where shad's detector opens the menu too.
behavior: HitTestBehavior.translucent,
onPointerDown: (e) {
_pointer = e.position;
if (e.buttons == 2) widget.onOpen?.call();
},
child: widget.child,
Expand Down
85 changes: 68 additions & 17 deletions packages/wonky_ui/lib/src/widgets/menu/menu_items.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart';
import 'package:shad/shad.dart';

import 'menu_entry.dart';
import 'menu_placement.dart';

/// Renders [entries] as shad context-menu items (for `ShadContextMenu`,
/// `ShadContextMenuRegion` and `ShadMenubarItem`).
Expand All @@ -13,11 +14,30 @@ import 'menu_entry.dart';
///
/// With [requestFocus], the menu takes keyboard focus when it opens (↑/↓,
/// Home/End move between items; Enter activates).
///
/// [maxHeight], evaluated when the menu builds, limits the items' height and
/// taller menus scroll (`WonkyMenuPlacement.itemsMaxHeight`). Submenus are
/// limited to the room beside their item and open where they fit.
List<Widget> buildWonkyMenuItems(
BuildContext context,
List<WonkyMenuEntry> entries, {
VoidCallback? onClose,
bool requestFocus = false,
double? Function()? maxHeight,
}) => _menuItems(
entries,
onClose: onClose,
requestFocus: requestFocus,
maxHeight: maxHeight,
nested: false,
);

List<Widget> _menuItems(
List<WonkyMenuEntry> entries, {
required VoidCallback? onClose,
required bool requestFocus,
required double? Function()? maxHeight,
required bool nested,
}) {
final tidy = entries.tidy();
if (tidy.isEmpty) return const [];
Expand All @@ -26,6 +46,8 @@ List<Widget> buildWonkyMenuItems(
entries: tidy,
onClose: onClose,
requestFocus: requestFocus,
maxHeight: maxHeight,
nested: nested,
),
];
}
Expand All @@ -35,11 +57,17 @@ class _TypeAheadMenuBody extends StatefulWidget {
required this.entries,
this.onClose,
this.requestFocus = false,
this.maxHeight,
this.nested = false,
});

final List<WonkyMenuEntry> entries;
final VoidCallback? onClose;
final bool requestFocus;
final double? Function()? maxHeight;

/// Whether this body is a submenu (its own submenus open nested).
final bool nested;

@override
State<_TypeAheadMenuBody> createState() => _TypeAheadMenuBodyState();
Expand Down Expand Up @@ -178,15 +206,19 @@ class _TypeAheadMenuBodyState extends State<_TypeAheadMenuBody> {
),
);
}
return Focus(
focusNode: _root,
onKeyEvent: _onKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
Widget body = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
);
final maxHeight = widget.maxHeight?.call();
if (maxHeight != null) {
body = ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: SingleChildScrollView(child: body),
);
}
return Focus(focusNode: _root, onKeyEvent: _onKey, child: body);
}

Widget _buildEntry(BuildContext context, WonkyMenuEntry e, bool leading) {
Expand Down Expand Up @@ -220,17 +252,24 @@ class _TypeAheadMenuBodyState extends State<_TypeAheadMenuBody> {
return _stableEnabled(
e.key,
e.enabled,
(key) => ShadContextMenuItem(
// The builder's context finds the item's box: the submenu is as
// tall as the room beside the item allows.
(key) => Builder(
key: key,
enabled: e.enabled,
leading: lead(e.icon),
trailing: const Icon(LucideIcons.chevronRight, size: 16),
items: buildWonkyMenuItems(
context,
e.children,
onClose: widget.onClose,
builder: (itemContext) => ShadContextMenuItem(
enabled: e.enabled,
anchor: WonkyMenuPlacement.submenu(nested: widget.nested),
leading: lead(e.icon),
trailing: const Icon(LucideIcons.chevronRight, size: 16),
items: _menuItems(
e.children,
onClose: widget.onClose,
requestFocus: false,
maxHeight: () => _submenuMaxHeight(itemContext),
nested: true,
),
child: Text(e.label),
),
child: Text(e.label),
),
);
case WonkyMenuItem():
Expand Down Expand Up @@ -305,6 +344,18 @@ class _TypeAheadMenuBodyState extends State<_TypeAheadMenuBody> {
}
}

/// The tallest the items of the submenu of the item at [itemContext] may be:
/// it opens level with the item's top, or ends level with its bottom.
double? _submenuMaxHeight(BuildContext itemContext) {
final rect = WonkyMenuPlacement.rectOf(itemContext);
if (rect == null) return null;
return WonkyMenuPlacement.itemsMaxHeight(
MediaQuery.sizeOf(itemContext).height,
below: rect.top,
above: rect.bottom,
);
}

/// Builds a shad menu item whose element is recreated when [enabled]
/// changes.
///
Expand Down
Loading
Loading