Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## 4.15.0

- fix: `MenuFlyout` no longer throws `TypeError` on sub-items ([#1337](https://github.com/bdlukaa/fluent_ui/issues/1337))
- fix: `MenuFlyout` sub-item tree now correctly expands to the left and shows a `chevron_left` icon when right-to-left directionality is enabled ([#1342](https://github.com/bdlukaa/fluent_ui/issues/1342))
- feat: Controls now respond to `VisualDensity` from `FluentThemeData` for compact sizing. Use `FluentThemeData(visualDensity: VisualDensity.compact)` to enable compact mode ([#1175](https://github.com/bdlukaa/fluent_ui/issues/1175))
- fix: `NavigationView` no longer throws `BoxConstraints has a negative minimum height` when header and menu button are both absent ([#1334](https://github.com/bdlukaa/fluent_ui/issues/1334))
- fix: `ProgressBar` chooses the correct direction when directionality is right-to-left ([#1291](https://github.com/bdlukaa/fluent_ui/issues/1291))
Expand Down
91 changes: 68 additions & 23 deletions lib/src/controls/flyouts/menu_flyout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -504,14 +504,31 @@ typedef MenuItemsBuilder =
/// change between two states, checked or unchecked
/// * [RadioMenuFlyoutItem], which represents a menu item that is mutually
/// exclusive with other radio menu items in its group

/// The default trailing widget for [MenuFlyoutSubItem].
///
/// It shows a [WindowsIcons.chevron_right] icon in left-to-right mode and a
/// [WindowsIcons.chevron_left] icon in right-to-left mode.
class _MenuFlyoutSubItemChevron extends StatelessWidget {
const _MenuFlyoutSubItemChevron();

@override
Widget build(BuildContext context) {
final isRtl = Directionality.of(context) == TextDirection.rtl;
return WindowsIcon(
isRtl ? WindowsIcons.chevron_left : WindowsIcons.chevron_right,
);
}
}

class MenuFlyoutSubItem extends MenuFlyoutItem {
/// Creates a menu flyout sub item
MenuFlyoutSubItem({
required super.text,
required this.items,
super.key,
super.leading,
super.trailing = const WindowsIcon(WindowsIcons.chevron_right),
super.trailing = const _MenuFlyoutSubItemChevron(),
this.showBehavior = SubItemShowAction.hover,
this.showHoverDelay = const Duration(milliseconds: 450),
}) : super(onPressed: null);
Expand Down Expand Up @@ -657,6 +674,7 @@ class _MenuFlyoutSubItemState extends State<_MenuFlyoutSubItem>
parentRect: itemRect,
parentSize: itemBox.size,
margin: parent.margin,
textDirection: Directionality.of(context),
),
child: Flyout(
rootFlyout: parent.rootFlyout,
Expand Down Expand Up @@ -720,11 +738,13 @@ class _SubItemPositionDelegate extends SingleChildLayoutDelegate {
final Rect parentRect;
final Size parentSize;
final double margin;
final TextDirection textDirection;

const _SubItemPositionDelegate({
required this.parentRect,
required this.parentSize,
required this.margin,
required this.textDirection,
});

@override
Expand All @@ -736,27 +756,51 @@ class _SubItemPositionDelegate extends SingleChildLayoutDelegate {

@override
Offset getPositionForChild(Size rootSize, Size flyoutSize) {
var x = parentRect.left + parentRect.size.width;

// if the flyout will overflow the screen on the right
final willOverflowX = x + flyoutSize.width + margin > rootSize.width;

// if overflow x on the right, we check for some cases
//
// if the space available on the right is greater than the space available on
// the left, use the right.
//
// otherwise, we position the flyout at the end of the screen
if (willOverflowX) {
final rightX = parentRect.left - flyoutSize.width;
if (rightX > margin) {
x = rightX;
} else {
x = clampDouble(
rootSize.width - flyoutSize.width - margin,
0,
rootSize.width,
);
final isRtl = textDirection == TextDirection.rtl;
double x;

if (isRtl) {
// In RTL, the sub-menu should open to the left of the parent item by
// default.
x = parentRect.left - flyoutSize.width;

// if the flyout will overflow the screen on the left
final willOverflowX = x < margin;

if (willOverflowX) {
// try to the right of the parent item
final rightX = parentRect.left + parentRect.size.width;
if (rightX + flyoutSize.width + margin <= rootSize.width) {
x = rightX;
} else {
x = clampDouble(margin, 0, rootSize.width);
}
}
} else {
// In LTR, the sub-menu should open to the right of the parent item by
// default.
x = parentRect.left + parentRect.size.width;

// if the flyout will overflow the screen on the right
final willOverflowX = x + flyoutSize.width + margin > rootSize.width;

// if overflow x on the right, we check for some cases
//
// if the space available on the right is greater than the space available
// on the left, use the right.
//
// otherwise, we position the flyout at the end of the screen
if (willOverflowX) {
final leftX = parentRect.left - flyoutSize.width;
if (leftX > margin) {
x = leftX;
} else {
x = clampDouble(
rootSize.width - flyoutSize.width - margin,
0,
rootSize.width,
);
}
}
}

Expand All @@ -775,6 +819,7 @@ class _SubItemPositionDelegate extends SingleChildLayoutDelegate {
bool shouldRelayout(covariant _SubItemPositionDelegate oldDelegate) {
return oldDelegate.parentRect != parentRect ||
oldDelegate.parentSize != parentSize ||
oldDelegate.margin != margin;
oldDelegate.margin != margin ||
oldDelegate.textDirection != textDirection;
}
}
156 changes: 156 additions & 0 deletions test/flyout_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -287,4 +287,160 @@ void main() {
await gesture.removePointer();
},
);

testWidgets(
'MenuFlyoutSubItem shows chevron_right trailing icon in LTR context',
(tester) async {
final controller = FlyoutController();

await tester.pumpWidget(
wrapApp(
child: Center(
child: FlyoutTarget(
controller: controller,
child: const Text('Target'),
),
),
),
);

controller.showFlyout<void>(
builder: (context) => MenuFlyout(
items: [
MenuFlyoutSubItem(
text: const Text('Sub Menu'),
items: (context) => [],
),
],
),
);
await tester.pumpAndSettle();

// Find all WindowsIcon widgets in the tree and verify the sub-item
// trailing uses chevron_right in LTR mode.
final icons = tester.widgetList<WindowsIcon>(find.byType(WindowsIcon));
expect(
icons.any((icon) => icon.icon == WindowsIcons.chevron_right),
isTrue,
reason:
'MenuFlyoutSubItem should show chevron_right trailing icon in LTR',
);
expect(
icons.any((icon) => icon.icon == WindowsIcons.chevron_left),
isFalse,
reason:
'MenuFlyoutSubItem should not show chevron_left trailing icon in LTR',
);
},
);

testWidgets(
'MenuFlyoutSubItem shows chevron_left trailing icon in RTL context',
(tester) async {
final controller = FlyoutController();

await tester.pumpWidget(
FluentApp(
home: Directionality(
textDirection: TextDirection.rtl,
child: Center(
child: FlyoutTarget(
controller: controller,
child: const Text('Target'),
),
),
),
),
);

controller.showFlyout<void>(
builder: (context) => MenuFlyout(
items: [
MenuFlyoutSubItem(
text: const Text('Sub Menu'),
items: (context) => [],
),
],
),
);
await tester.pumpAndSettle();

// Find all WindowsIcon widgets in the tree and verify the sub-item
// trailing uses chevron_left in RTL mode.
final icons = tester.widgetList<WindowsIcon>(find.byType(WindowsIcon));
expect(
icons.any((icon) => icon.icon == WindowsIcons.chevron_left),
isTrue,
reason:
'MenuFlyoutSubItem should show chevron_left trailing icon in RTL',
);
expect(
icons.any((icon) => icon.icon == WindowsIcons.chevron_right),
isFalse,
reason:
'MenuFlyoutSubItem should not show chevron_right trailing icon in RTL',
);
},
);

testWidgets(
'MenuFlyoutSubItem sub-menu opens to the left in RTL context',
(tester) async {
final controller = FlyoutController();

await tester.pumpWidget(
FluentApp(
home: Directionality(
textDirection: TextDirection.rtl,
child: Center(
child: FlyoutTarget(
controller: controller,
child: const Text('Target'),
),
),
),
),
);

controller.showFlyout<void>(
builder: (context) => MenuFlyout(
items: [
MenuFlyoutSubItem(
text: const Text('Sub Menu'),
items: (context) => [
MenuFlyoutItem(
text: const Text('Sub Item 1'),
onPressed: () {},
),
],
),
],
),
);
await tester.pumpAndSettle();

expect(find.text('Sub Menu'), findsOneWidget);
final subMenuParentRect = tester.getRect(find.text('Sub Menu'));

// Hover over the sub-menu item to trigger its display
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
await gesture.moveTo(tester.getCenter(find.text('Sub Menu')));
await tester.pumpAndSettle(const Duration(milliseconds: 500));

// The sub-menu should now be showing
expect(find.text('Sub Item 1'), findsOneWidget);

// In RTL mode, the sub-menu should appear to the LEFT of the parent item.
final subItemRect = tester.getRect(find.text('Sub Item 1'));
expect(
subItemRect.left,
lessThan(subMenuParentRect.left),
reason:
'In RTL, sub-menu should open to the left of the parent item',
);

await gesture.removePointer();
},
);
}