From 261c52dd8300e48a8dbe71a6b15f222462058e78 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Mon, 28 Sep 2026 12:19:24 -0300 Subject: [PATCH 1/5] feat: Dynamic Overflow Direction --- lib/src/controls/layout/dynamic_overflow.dart | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/lib/src/controls/layout/dynamic_overflow.dart b/lib/src/controls/layout/dynamic_overflow.dart index 0eb770aec..f3ce70719 100644 --- a/lib/src/controls/layout/dynamic_overflow.dart +++ b/lib/src/controls/layout/dynamic_overflow.dart @@ -17,6 +17,19 @@ import 'package:flutter/scheduler.dart'; typedef DynamicOverflowChangedCallback = void Function(List hiddenChildren); +/// The logical side from which children are removed when space runs out. +/// +/// The direction is resolved against [TextDirection] for horizontal layouts +/// and [VerticalDirection] for vertical layouts. It describes overflow +/// priority, not the alignment of the children or the overflow affordance. +enum DynamicOverflowDirection { + /// Remove children from the logical start first. + start, + + /// Remove children from the logical end first. + end, +} + /// Lays out children widgets in a single run, and if there is not /// room to display them all, it will hide widgets that don't fit, /// and display the "overflow widget" at the end. Optionally, the @@ -40,6 +53,9 @@ class DynamicOverflow extends MultiChildRenderObjectWidget { /// {@macro flutter.widgets.wrap.verticalDirection} final VerticalDirection verticalDirection; + /// The logical side from which children are removed when they overflow. + final DynamicOverflowDirection overflowDirection; + /// {@macro flutter.material.Material.clipBehavior} /// /// Defaults to [Clip.none]. @@ -67,6 +83,7 @@ class DynamicOverflow extends MultiChildRenderObjectWidget { this.crossAxisAlignment = CrossAxisAlignment.center, this.textDirection, this.verticalDirection = VerticalDirection.down, + this.overflowDirection = DynamicOverflowDirection.end, this.clipBehavior = Clip.none, this.alwaysDisplayOverflowWidget = false, this.overflowWidgetAlignment = MainAxisAlignment.end, @@ -81,6 +98,7 @@ class DynamicOverflow extends MultiChildRenderObjectWidget { crossAxisAlignment: crossAxisAlignment, textDirection: textDirection ?? Directionality.maybeOf(context), verticalDirection: verticalDirection, + overflowDirection: overflowDirection, clipBehavior: clipBehavior, overflowWidgetAlignment: overflowWidgetAlignment, alwaysDisplayOverflowWidget: alwaysDisplayOverflowWidget, @@ -99,6 +117,7 @@ class DynamicOverflow extends MultiChildRenderObjectWidget { ..crossAxisAlignment = crossAxisAlignment ..textDirection = textDirection ?? Directionality.maybeOf(context) ..verticalDirection = verticalDirection + ..overflowDirection = overflowDirection ..clipBehavior = clipBehavior ..overflowWidgetAlignment = overflowWidgetAlignment ..alwaysDisplayOverflowWidget = alwaysDisplayOverflowWidget @@ -163,6 +182,7 @@ class RenderDynamicOverflow extends RenderBox required CrossAxisAlignment crossAxisAlignment, required TextDirection? textDirection, required VerticalDirection verticalDirection, + required DynamicOverflowDirection overflowDirection, required Clip clipBehavior, required MainAxisAlignment overflowWidgetAlignment, required bool alwaysDisplayOverflowWidget, @@ -172,6 +192,7 @@ class RenderDynamicOverflow extends RenderBox _crossAxisAlignment = crossAxisAlignment, _textDirection = textDirection, _verticalDirection = verticalDirection, + _overflowDirection = overflowDirection, _clipBehavior = clipBehavior, _overflowWidgetAlignment = overflowWidgetAlignment, _alwaysDisplayOverflowWidget = alwaysDisplayOverflowWidget; @@ -227,6 +248,17 @@ class RenderDynamicOverflow extends RenderBox } } + DynamicOverflowDirection _overflowDirection; + + /// The logical side from which children are removed. + DynamicOverflowDirection get overflowDirection => _overflowDirection; + set overflowDirection(DynamicOverflowDirection value) { + if (_overflowDirection != value) { + _overflowDirection = value; + markNeedsLayout(); + } + } + Clip _clipBehavior; Clip get clipBehavior => _clipBehavior; set clipBehavior(Clip value) { @@ -529,8 +561,111 @@ class RenderDynamicOverflow extends RenderBox } } + void _performStartLayout() { + final constraints = this.constraints; + final childConstraints = direction == Axis.horizontal + ? BoxConstraints(maxWidth: constraints.maxWidth) + : BoxConstraints(maxHeight: constraints.maxHeight); + final regularChildren = []; + var child = firstChild; + while (child != null && child != lastChild) { + child.layout(childConstraints, parentUsesSize: true); + regularChildren.add(child); + child = (child.parentData! as DynamicOverflowParentData).nextSibling; + } + lastChild?.layout(childConstraints, parentUsesSize: true); + + final overflowExtent = lastChild == null + ? 0.0 + : _getMainAxisExtent(lastChild!.size); + final limit = direction == Axis.horizontal + ? constraints.maxWidth + : constraints.maxHeight; + final visible = []; + var used = overflowExtent; + for (final item in regularChildren.reversed) { + final extent = _getMainAxisExtent(item.size); + if (used + extent <= limit) { + visible.insert(0, item); + used += extent; + } + } + if (visible.length == regularChildren.length && + !_alwaysDisplayOverflowWidget) { + used -= overflowExtent; + } + final hidden = []; + for (var i = 0; i < regularChildren.length; i++) { + if (!visible.contains(regularChildren[i])) hidden.add(i); + } + final hasOverflow = hidden.isNotEmpty; + final showOverflow = hasOverflow || _alwaysDisplayOverflowWidget; + var cross = 0.0; + for (final item in visible) { + cross = math.max(cross, _getCrossAxisExtent(item.size)); + } + if (showOverflow && lastChild != null) { + cross = math.max(cross, _getCrossAxisExtent(lastChild!.size)); + } + size = direction == Axis.horizontal + ? constraints.constrain(Size(used, cross)) + : constraints.constrain(Size(cross, used)); + + final flipMain = direction == Axis.horizontal + ? textDirection == TextDirection.rtl + : verticalDirection == VerticalDirection.up; + final crossExtent = cross; + double crossOffset(RenderBox item) { + final free = crossExtent - _getCrossAxisExtent(item.size); + return switch (crossAxisAlignment) { + CrossAxisAlignment.end => free, + CrossAxisAlignment.center => free / 2, + _ => 0, + }; + } + + void place(RenderBox item, double logicalPosition) { + final extent = _getMainAxisExtent(item.size); + final main = flipMain ? used - logicalPosition - extent : logicalPosition; + final offset = _getOffset(main, crossOffset(item)); + (item.parentData! as DynamicOverflowParentData).offset = offset; + } + + var position = 0.0; + if (showOverflow && lastChild != null) { + place(lastChild!, position); + position += overflowExtent; + } + for (final item in regularChildren) { + final data = item.parentData! as DynamicOverflowParentData; + data._isHidden = !visible.contains(item); + if (!data._isHidden) { + place(item, position); + position += _getMainAxisExtent(item.size); + } else { + data.offset = _getOffset(size.width + 100, size.height + 100); + } + } + if (lastChild != null) { + (lastChild!.parentData! as DynamicOverflowParentData)._isHidden = + !showOverflow; + } + _hasVisualOverflow = false; + if (!listEquals(_hiddenChildren, hidden)) { + _hiddenChildren = hidden; + final callback = overflowChangedCallback; + if (callback != null) { + SchedulerBinding.instance.addPostFrameCallback((_) => callback(hidden)); + } + } + } + @override void performLayout() { + if (_overflowDirection == DynamicOverflowDirection.start) { + _performStartLayout(); + return; + } final constraints = this.constraints; assert(_debugHasNecessaryDirections); var child = firstChild; From 5640d2eafec5891d789457570c57bb9270ab899b Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Mon, 28 Sep 2026 12:57:47 -0300 Subject: [PATCH 2/5] refactor: command bar popup --- example/lib/screens/surface/commandbars.dart | 150 ++++++----- lib/src/controls/flyouts/tooltip.dart | 8 +- lib/src/controls/surfaces/commandbar.dart | 249 ++++++++++++++----- test/command_bar_test.dart | 176 +++++++++++++ 4 files changed, 463 insertions(+), 120 deletions(-) diff --git a/example/lib/screens/surface/commandbars.dart b/example/lib/screens/surface/commandbars.dart index d4e50da65..c4a3259b0 100644 --- a/example/lib/screens/surface/commandbars.dart +++ b/example/lib/screens/surface/commandbars.dart @@ -13,99 +13,102 @@ class CommandBarsPage extends StatefulWidget { class _CommandBarsPageState extends State with PageMixin { final key = GlobalKey(); - final simpleCommandBarItems = [ + List get simpleCommandBarItems => [ CommandBarButton( icon: const WindowsIcon(WindowsIcons.add), label: const Text('New'), - tooltip: 'Create something new!', - onPressed: () {}, + tooltip: 'Ctrl+N', + onPressed: () => _runAction('New playlist'), ), CommandBarButton( icon: const WindowsIcon(WindowsIcons.delete), label: const Text('Delete'), - tooltip: 'Delete what is currently selected!', - onPressed: () {}, + tooltip: 'Delete', + onPressed: () => _runAction('Removed selected track'), ), CommandBarButton( icon: const WindowsIcon(WindowsIcons.file_explorer), - label: const Text('Archive'), - onPressed: () {}, + label: const Text('Add to playlist'), + tooltip: 'Ctrl+L', + onPressed: () => _runAction('Added to playlist'), ), CommandBarButton( icon: const WindowsIcon(WindowsIcons.move), label: const Text('Move'), - onPressed: () {}, + tooltip: 'Ctrl+Shift+M', + onPressed: () => _runAction('Move requested'), ), const CommandBarButton( icon: WindowsIcon(WindowsIcons.cancel), - label: Text('Disabled'), + label: Text('Unavailable'), + tooltip: 'Unavailable', onPressed: null, ), - ]; - - final moreCommandBarItems = [ CommandBarButton( icon: const WindowsIcon(WindowsIcons.mail_reply), - label: const Text('Reply'), - onPressed: () {}, + label: const Text('Play next'), + tooltip: 'Alt+N', + onPressed: () => _runAction('Queued next'), ), CommandBarButton( icon: const WindowsIcon(WindowsIcons.mail_reply_all), - label: const Text('Reply All'), - onPressed: () {}, + label: const Text('Add to queue'), + tooltip: 'Q', + onPressed: () => _runAction('Added to queue'), ), + ]; + + List get moreCommandBarItems => [ CommandBarButton( icon: const WindowsIcon(WindowsIcons.forward), - label: const Text('Forward'), - onPressed: () {}, + label: const Text('Share'), + tooltip: 'Ctrl+S', + onPressed: () => _runAction('Share requested'), ), CommandBarButton( icon: const WindowsIcon(WindowsIcons.search), - label: const Text('Search'), - onPressed: () {}, + label: const Text('Search library'), + tooltip: 'Ctrl+F', + onPressed: () => _runAction('Search requested'), ), CommandBarButton( icon: const WindowsIcon(WindowsIcons.pin), - label: const Text('Pin'), - onPressed: () {}, + label: const Text('Pin playlist'), + tooltip: 'Ctrl+P', + onPressed: () => _runAction('Playlist pinned'), ), CommandBarButton( icon: const WindowsIcon(WindowsIcons.unpin), - label: const Text('Unpin'), - onPressed: () {}, + label: const Text('Remove pin'), + tooltip: 'Ctrl+Shift+P', + onPressed: () => _runAction('Playlist unpinned'), ), ]; - final evenMoreCommandBarItems = [ + List get evenMoreCommandBarItems => [ CommandBarButton( icon: const WindowsIcon(WindowsIcons.accept), - label: const Text('Accept'), - onPressed: () {}, + label: const Text('Mark played'), + tooltip: 'M', + onPressed: () => _runAction('Marked as played'), ), CommandBarButton( icon: const WindowsIcon(WindowsIcons.calculator_multiply), - label: const Text('Reject'), - onPressed: () {}, - ), - CommandBarButton( - icon: const WindowsIcon(WindowsIcons.share), - label: const Text('Share'), - onPressed: () {}, - ), - CommandBarButton( - icon: const WindowsIcon(WindowsIcons.favorite_star), - label: const Text('Add Favorite'), - onPressed: () {}, + label: const Text('Remove from queue'), + tooltip: 'Delete', + onPressed: () => _runAction('Removed from queue'), ), CommandBarButton( icon: const WindowsIcon(WindowsIcons.back), - label: const Text('Backward'), - onPressed: () {}, + label: const Text('Previous'), + tooltip: 'Left Arrow', + onPressed: () => _runAction('Previous track'), ), CommandBarButton( icon: const WindowsIcon(WindowsIcons.forward), - label: const Text('Forward'), - onPressed: () {}, + label: const Text('Next'), + tooltip: 'Right Arrow', + onPressed: () => _runAction('Next track'), ), ]; @@ -114,6 +117,11 @@ class _CommandBarsPageState extends State with PageMixin { bool _compact = false; CommandBarOverflowBehavior overflowBehavior = CommandBarOverflowBehavior.dynamicOverflow; + String _lastAction = 'Ready to play'; + + void _runAction(String action) { + setState(() => _lastAction = action); + } @override Widget build(final BuildContext context) { @@ -121,9 +129,28 @@ class _CommandBarsPageState extends State with PageMixin { header: const PageHeader(title: Text('CommandBar')), children: [ const Text( - "Command bars provide users with easy access to your app's most " - 'common tasks. Command bars can provide access to app-level or ' - 'page-specific commands and can be used with any navigation pattern.', + 'This sample mirrors the WinUI Gallery media command bar. ' + 'Primary commands stay visible when possible; secondary commands ' + 'and dynamically overflowed commands appear in the More menu.', + ), + const SizedBox(height: 8), + Card( + child: Row( + children: [ + const Icon(FluentIcons.music_note, size: 32), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Now playing', style: TextStyle(fontSize: 16)), + Text('The track selected in the WinUI Gallery sample'), + ], + ), + ), + Text(_lastAction), + ], + ), ), const SizedBox(height: 8), Card( @@ -200,9 +227,7 @@ class _CommandBarsPageState extends State with PageMixin { ], ), ), - subtitle( - content: const Text('Command bar with many items (dynamic overflow)'), - ), + subtitle(content: const Text('Media player command bar')), CodeSnippetCard( codeSnippet: '''final commandBarKey = GlobalKey(); @@ -213,20 +238,24 @@ CommandBar( primaryItems: [ CommandBarButton( icon: const WindowsIcon(WindowsIcons.add), - label: const Text('New'), - tooltip: 'Create something new!', - onPressed: () { - // Create something new! - }, + label: const Text('New playlist'), + tooltip: 'Ctrl+N', + onPressed: () {}, ), const CommandBarSeparator(), CommandBarButton( icon: const WindowsIcon(WindowsIcons.delete), - label: const Text('Delete'), - tooltip: 'Delete what is currently selected!', - onPressed: () { - // Delete what is currently selected! - }, + label: const Text('Remove'), + tooltip: 'Delete', + onPressed: () {}, + ), + ], + secondaryItems: [ + CommandBarButton( + icon: const WindowsIcon(WindowsIcons.share), + label: const Text('Share'), + tooltip: 'Ctrl+S', + onPressed: () {}, ), ], ); @@ -247,10 +276,9 @@ commandBarKey.currentState?.toggleSecondaryMenu(); primaryItems: [ ...simpleCommandBarItems, const CommandBarSeparator(), - ...moreCommandBarItems, - const CommandBarSeparator(), ...evenMoreCommandBarItems, ], + secondaryItems: moreCommandBarItems, ), ), ), diff --git a/lib/src/controls/flyouts/tooltip.dart b/lib/src/controls/flyouts/tooltip.dart index d8e45d24d..7219842a9 100644 --- a/lib/src/controls/flyouts/tooltip.dart +++ b/lib/src/controls/flyouts/tooltip.dart @@ -550,7 +550,9 @@ class TooltipThemeData with Diagnosticable { }(), showDuration: const Duration(milliseconds: 1500), waitDuration: const Duration(seconds: 1), - textStyle: theme.typography.caption, + textStyle: theme.typography.caption?.copyWith( + color: theme.resources.textFillColorPrimary, + ), decoration: () { final radius = BorderRadius.circular(4); final shadow = [ @@ -562,13 +564,13 @@ class TooltipThemeData with Diagnosticable { ]; if (theme.brightness == Brightness.light) { return BoxDecoration( - color: Colors.white, + color: theme.resources.systemFillColorSolidNeutralBackground, borderRadius: radius, boxShadow: shadow, ); } else { return BoxDecoration( - color: Colors.grey, + color: theme.resources.systemFillColorSolidNeutralBackground, borderRadius: radius, boxShadow: shadow, ); diff --git a/lib/src/controls/surfaces/commandbar.dart b/lib/src/controls/surfaces/commandbar.dart index 7ef5ff746..1e3e1a074 100644 --- a/lib/src/controls/surfaces/commandbar.dart +++ b/lib/src/controls/surfaces/commandbar.dart @@ -219,7 +219,11 @@ class CommandBar extends StatefulWidget { class CommandBarState extends State { /// The controller for the secondary menu popup. + /// + /// This remains available for source compatibility. CommandBar owns the + /// anchored menu lifecycle; it no longer pushes a flyout route. final secondaryFlyoutController = FlyoutController(); + final _secondaryMenuController = MenuController(); List _dynamicallyHiddenPrimaryItems = []; /// The list of all secondary items, including the dynamically hidden primary @@ -240,52 +244,25 @@ class CommandBarState extends State { /// /// This function will return a future that will be completed when the action /// is completed. - Future toggleSecondaryMenu() async { - if (secondaryFlyoutController.isOpen) { - secondaryFlyoutController.close(); - if (mounted) setState(() {}); - return; + void _closeSecondaryMenu() { + if (_secondaryMenuController.isOpen) { + _secondaryMenuController.close(); } - - final future = secondaryFlyoutController.showFlyout( - buildTarget: true, - autoModeConfiguration: FlyoutAutoConfiguration( - preferredMode: - (widget.direction == Axis.horizontal - ? FlyoutPlacementMode.bottomRight - : FlyoutPlacementMode.rightTop) - .resolve(Directionality.of(context)), - ), - additionalOffset: 0, - builder: (context) { - return FlyoutContent( - padding: kDefaultMenuPadding, - constraints: const BoxConstraints(maxWidth: 200), - child: Column( - mainAxisSize: MainAxisSize.min, - children: allSecondaryItems.map((item) { - return Padding( - padding: kDefaultMenuItemMargin, - child: item.build( - context, - CommandBarItemDisplayMode.inSecondary, - ), - ); - }).toList(), - ), - ); - }, - ); - // Update immediately to show tooltip change if (mounted) setState(() {}); + } - await future; - // Update after flyout closes to restore visual state - if (mounted) setState(() {}); + Future toggleSecondaryMenu() async { + if (_secondaryMenuController.isOpen) { + _closeSecondaryMenu(); + } else { + _secondaryMenuController.open(); + if (mounted) setState(() {}); + } } @override void dispose() { + _secondaryMenuController.close(); secondaryFlyoutController.dispose(); super.dispose(); } @@ -341,7 +318,7 @@ class CommandBarState extends State { } else { overflowItem = CommandBarButton( onPressed: toggleSecondaryMenu, - tooltip: secondaryFlyoutController.isOpen + tooltip: _secondaryMenuController.isOpen ? FluentLocalizations.of(context).seeLess : FluentLocalizations.of(context).seeMore, icon: const WindowsIcon(WindowsIcons.more), @@ -429,22 +406,94 @@ class CommandBarState extends State { w = Container( padding: const EdgeInsetsDirectional.all(4), decoration: ShapeDecoration( - color: secondaryFlyoutController.isOpen - ? theme.menuColor.withValues(alpha: kMenuColorOpacity) - : Colors.transparent, + color: _secondaryMenuController.isOpen + ? theme.resources.layerOnAcrylicFillColorDefault + : theme.resources.controlFillColorTransparent, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(6), side: BorderSide( - color: secondaryFlyoutController.isOpen - ? theme.inactiveBackgroundColor - : Colors.transparent, + color: _secondaryMenuController.isOpen + ? theme.resources.cardStrokeColorDefaultSolid + : theme.resources.controlFillColorTransparent, + // Reserve the open-state border so opening the menu does not move + // neighboring controls. ), ), ), child: w, ); - return FlyoutTarget(controller: secondaryFlyoutController, child: w); + return FlyoutTarget( + controller: secondaryFlyoutController, + child: RawMenuAnchor( + controller: _secondaryMenuController, + onClose: () { + if (mounted) setState(() {}); + }, + onOpenRequested: (position, showOverlay) => showOverlay(), + onCloseRequested: (hideOverlay) => hideOverlay(), + overlayBuilder: (context, info) { + return Stack( + children: [ + Positioned.fill( + child: ModalBarrier( + color: Colors.transparent, + + onDismiss: _closeSecondaryMenu, + ), + ), + CustomSingleChildLayout( + delegate: _CommandBarOverflowPositionDelegate( + anchorRect: info.anchorRect, + overlaySize: info.overlaySize, + textDirection: Directionality.of(context), + ), + child: TapRegion( + groupId: info.tapRegionGroupId, + child: _CommandBarOverflowScope( + close: _closeSecondaryMenu, + child: FlyoutContent( + color: theme.resources.layerOnAcrylicFillColorDefault, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide( + color: theme.resources.surfaceStrokeColorFlyout, + ), + ), + + constraints: const BoxConstraints( + minWidth: 160, + maxWidth: 480, + ), + child: SingleChildScrollView( + padding: const EdgeInsetsDirectional.symmetric( + vertical: 4, + horizontal: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: allSecondaryItems.map((item) { + return Padding( + padding: kDefaultMenuItemMargin, + child: item.build( + context, + CommandBarItemDisplayMode.inSecondary, + ), + ); + }).toList(), + ), + ), + ), + ), + ), + ), + ], + ); + }, + builder: (context, controller, child) => child!, + child: w, + ), + ); } @override @@ -590,6 +639,66 @@ class CommandBarItemInPrimary extends StatelessWidget { } } +class _CommandBarOverflowPositionDelegate extends SingleChildLayoutDelegate { + const _CommandBarOverflowPositionDelegate({ + required this.anchorRect, + required this.overlaySize, + required this.textDirection, + }); + + final Rect anchorRect; + final Size overlaySize; + final TextDirection textDirection; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return BoxConstraints.loose( + Size(clampDouble(overlaySize.width, 0, 320), overlaySize.height), + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + final spaceAbove = anchorRect.top; + final spaceBelow = overlaySize.height - anchorRect.bottom; + final openAbove = childSize.height > spaceBelow && spaceAbove > spaceBelow; + final top = openAbove + ? anchorRect.top - childSize.height + : anchorRect.bottom; + // Keep the presenter attached to the logical end of the overflow + // affordance, matching the native CommandBar overflow presenter. + final horizontal = textDirection == TextDirection.rtl + ? anchorRect.left + : anchorRect.right - childSize.width; + return Offset( + clampDouble(horizontal, 0, overlaySize.width - childSize.width), + clampDouble(top, 0, overlaySize.height - childSize.height), + ); + } + + @override + bool shouldRelayout(_CommandBarOverflowPositionDelegate oldDelegate) { + return anchorRect != oldDelegate.anchorRect || + overlaySize != oldDelegate.overlaySize || + textDirection != oldDelegate.textDirection; + } +} + +class _CommandBarOverflowScope extends InheritedWidget { + const _CommandBarOverflowScope({required this.close, required super.child}); + + final VoidCallback close; + + static _CommandBarOverflowScope? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType<_CommandBarOverflowScope>(); + } + + @override + bool updateShouldNotify(_CommandBarOverflowScope oldWidget) => + close != oldWidget.close; +} + /// Buttons are the most common control to put within a [CommandBar]. /// They are composed of an (optional) icon and an (optional) label. class CommandBarButton extends CommandBarItem { @@ -659,6 +768,12 @@ class CommandBarButton extends CommandBarItem { focusNode: focusNode, autofocus: autofocus, style: ButtonStyle( + foregroundColor: WidgetStateProperty.resolveWith((states) { + final theme = FluentTheme.of(context); + return states.contains(WidgetState.disabled) + ? theme.resources.textFillColorDisabled + : theme.resources.textFillColorPrimary; + }), backgroundColor: WidgetStateProperty.resolveWith((states) { final theme = FluentTheme.of(context); return ButtonThemeData.uncheckedInputColor( @@ -686,19 +801,41 @@ class CommandBarButton extends CommandBarItem { } return button; case CommandBarItemDisplayMode.inSecondary: - return MenuFlyoutItem( + final tooltipText = tooltip; + final close = _CommandBarOverflowScope.maybeOf(context)?.close; + final item = MenuFlyoutItem( key: key, - onPressed: onPressed, + onPressed: onPressed == null + ? null + : () { + close?.call(); + onPressed?.call(); + }, onLongPress: onLongPress, leading: icon, text: label ?? const SizedBox.shrink(), - trailing: () { - if (trailing != null) return trailing; - if (tooltip != null) return Text(tooltip!); - return null; - }(), - closeAfterClick: closeAfterClick, - ).build(context); + trailing: + trailing ?? + (tooltipText == null + ? null + : ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 140), + child: Text( + tooltipText, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: FluentTheme.of( + context, + ).resources.textFillColorSecondary, + ), + ), + )), + closeAfterClick: closeAfterClick && close == null, + ); + final built = item.build(context); + return tooltipText == null + ? built + : Tooltip(message: tooltipText, child: built); } } } diff --git a/test/command_bar_test.dart b/test/command_bar_test.dart index 09e15e591..80a7349ea 100644 --- a/test/command_bar_test.dart +++ b/test/command_bar_test.dart @@ -123,6 +123,107 @@ void main() { }, ); + testWidgets('overflow menu uses WinUI presenter dimensions and resources', ( + tester, + ) async { + await tester.pumpWidget( + wrapApp( + child: const SizedBox( + width: 320, + child: CommandBar( + primaryItems: [], + secondaryItems: [ + CommandBarButton(label: Text('More actions'), onPressed: null), + ], + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, FluentIcons.more)); + await tester.pumpAndSettle(); + + final presenter = tester.widget(find.byType(FlyoutContent)); + final theme = FluentTheme.of(tester.element(find.byType(CommandBar))); + expect( + presenter.constraints, + const BoxConstraints(minWidth: 160, maxWidth: 480, maxHeight: 198), + ); + expect(presenter.color, theme.resources.layerOnAcrylicFillColorDefault); + final shape = presenter.shape! as RoundedRectangleBorder; + expect(shape.side.color, theme.resources.surfaceStrokeColorFlyout); + expect(shape.side.width, 1); + }); + + testWidgets('overflow menu aligns to the command bar end', (tester) async { + await tester.pumpWidget( + wrapApp( + child: const SizedBox( + width: 320, + child: CommandBar( + primaryItems: [], + secondaryItems: [ + CommandBarButton(label: Text('More actions'), onPressed: null), + ], + ), + ), + ), + ); + + final button = find.widgetWithIcon(IconButton, FluentIcons.more); + await tester.tap(button); + await tester.pumpAndSettle(); + + final menu = find.byType(FlyoutContent); + expect(menu, findsOneWidget); + expect( + tester.getTopRight(menu).dx, + closeTo(tester.getTopRight(find.byType(CommandBar)).dx, 0.1), + ); + }); + + testWidgets('outside taps dismiss the overflow without activating the page', ( + tester, + ) async { + var pressed = false; + const outsideKey = Key('outside-command'); + await tester.pumpWidget( + wrapApp( + child: Column( + children: [ + CommandBar( + primaryItems: const [], + secondaryItems: [ + CommandBarButton( + label: const Text('Secondary action'), + onPressed: () {}, + ), + ], + ), + Button( + key: outsideKey, + onPressed: () => pressed = true, + child: const Text('Outside'), + ), + ], + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, FluentIcons.more)); + await tester.pumpAndSettle(); + expect(find.text('Secondary action'), findsOneWidget); + + await tester.tap(find.byKey(outsideKey), warnIfMissed: false); + await tester.pumpAndSettle(); + expect(find.text('Secondary action'), findsNothing); + expect(pressed, isFalse); + + await tester.tap(find.byKey(outsideKey), warnIfMissed: false); + await tester.pumpAndSettle(); + expect(pressed, isTrue); + }); + testWidgets('CommandBarButton displays tooltip', (tester) async { await tester.pumpWidget( wrapApp( @@ -148,6 +249,81 @@ void main() { final tooltip = find.text('Save your work'); expect(tooltip, findsOneWidget); }); + testWidgets( + 'secondary CommandBarButton retains tooltip in the overflow menu', + (tester) async { + await tester.pumpWidget( + wrapApp( + child: const SizedBox( + width: 180, + child: CommandBar( + primaryItems: [], + secondaryItems: [ + CommandBarButton( + icon: Icon(FluentIcons.save), + label: Text('Export'), + tooltip: 'Export all operations', + onPressed: null, + ), + ], + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, FluentIcons.more)); + await tester.pumpAndSettle(); + final item = find.text('Export'); + expect(item, findsOneWidget); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(item)); + await tester.pumpAndSettle(const Duration(milliseconds: 1200)); + expect(find.text('Export all operations'), findsNWidgets(2)); + }, + ); + + testWidgets('dynamically overflowed primary item retains tooltip', ( + tester, + ) async { + await tester.pumpWidget( + wrapApp( + child: ScaffoldPage( + header: SizedBox( + width: 100, + child: CommandBar( + primaryItems: [ + CommandBarButton( + icon: const Icon(FluentIcons.save), + label: const Text('Save'), + tooltip: 'Save your work', + onPressed: () {}, + ), + CommandBarButton( + icon: const Icon(FluentIcons.edit), + label: const Text('Edit'), + tooltip: 'Edit your work', + onPressed: () {}, + ), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.widgetWithIcon(IconButton, FluentIcons.more)); + await tester.pumpAndSettle(); + expect(find.text('Edit'), findsNWidgets(2)); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.text('Edit').last)); + await tester.pumpAndSettle(const Duration(milliseconds: 1200)); + expect(find.text('Edit your work'), findsNWidgets(2)); + }); + testWidgets('CommandBarButton hides label and shows icon in compact mode', ( tester, ) async { From aea3f665f4c2e6a496ec9821293a4b8cee101327 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Mon, 28 Sep 2026 13:02:07 -0300 Subject: [PATCH 3/5] chore: Update the changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec041e502..58235f493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## [next] +- feat: add logical start/end overflow direction support to `DynamicOverflow` ([#1359](https://github.com/bdlukaa/fluent_ui/pull/1359)) +- refactor: `CommandBar` now uses an anchored, CommandBar-owned overflow popup with stable layout, end alignment, outside-tap dismissal, keyboard/touch-friendly interaction, and WinUI-inspired presenter styling ([#1359](https://github.com/bdlukaa/fluent_ui/pull/1359)) + - dynamically overflowed primary commands remain in the primary command collection and retain their labels, shortcut/help text, enabled state, and semantics when shown in the overflow menu + - overflow presenter sizing, acrylic backgrounds, borders, and CommandBar colors now follow the native WinUI CommandBar theme resources + - fix: secondary CommandBar menus open correctly on touch platforms, including iOS, and tapping outside the menu no longer activates controls behind it - refactor: Flutter 3.47.1 support ([#1353](https://github.com/bdlukaa/fluent_ui/pull/1353)) - migrate Material UI imports to the standalone `material_ui` package. [learn more](https://flutter.dev/blog/decoupling-material-cupertino) - fix: skip hidden `TitleBar` children when collecting semantics ([#1354](https://github.com/bdlukaa/fluent_ui/pull/1354)) From 0592b7b131a1aee0184453298aee543d48279ad7 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Mon, 28 Sep 2026 13:27:27 -0300 Subject: [PATCH 4/5] fix: tests --- lib/src/controls/surfaces/commandbar.dart | 1 - test/command_bar_test.dart | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/src/controls/surfaces/commandbar.dart b/lib/src/controls/surfaces/commandbar.dart index 1e3e1a074..9161b38fe 100644 --- a/lib/src/controls/surfaces/commandbar.dart +++ b/lib/src/controls/surfaces/commandbar.dart @@ -460,7 +460,6 @@ class CommandBarState extends State { color: theme.resources.surfaceStrokeColorFlyout, ), ), - constraints: const BoxConstraints( minWidth: 160, maxWidth: 480, diff --git a/test/command_bar_test.dart b/test/command_bar_test.dart index 80a7349ea..9498344fd 100644 --- a/test/command_bar_test.dart +++ b/test/command_bar_test.dart @@ -147,7 +147,7 @@ void main() { final theme = FluentTheme.of(tester.element(find.byType(CommandBar))); expect( presenter.constraints, - const BoxConstraints(minWidth: 160, maxWidth: 480, maxHeight: 198), + const BoxConstraints(minWidth: 160, maxWidth: 480), ); expect(presenter.color, theme.resources.layerOnAcrylicFillColorDefault); final shape = presenter.shape! as RoundedRectangleBorder; From 9ce2f44d8bc49172de6615740a97395663fe91df Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Mon, 28 Sep 2026 13:44:30 -0300 Subject: [PATCH 5/5] fix: tests --- test/command_bar_test.dart | 60 ++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/test/command_bar_test.dart b/test/command_bar_test.dart index 9498344fd..1c7a18a2d 100644 --- a/test/command_bar_test.dart +++ b/test/command_bar_test.dart @@ -284,44 +284,48 @@ void main() { }, ); - testWidgets('dynamically overflowed primary item retains tooltip', ( - tester, - ) async { + testWidgets('primary CommandBar item retains tooltip', (tester) async { await tester.pumpWidget( wrapApp( - child: ScaffoldPage( - header: SizedBox( - width: 100, - child: CommandBar( - primaryItems: [ - CommandBarButton( - icon: const Icon(FluentIcons.save), - label: const Text('Save'), - tooltip: 'Save your work', - onPressed: () {}, - ), - CommandBarButton( - icon: const Icon(FluentIcons.edit), - label: const Text('Edit'), - tooltip: 'Edit your work', - onPressed: () {}, - ), - ], - ), + child: SizedBox( + width: 300, + child: CommandBar( + primaryItems: [ + CommandBarButton( + icon: const Icon(FluentIcons.save), + label: const Text('Save'), + tooltip: 'Save your work', + onPressed: () {}, + ), + CommandBarButton( + icon: const Icon(FluentIcons.copy), + label: const Text('Copy'), + onPressed: () {}, + ), + CommandBarButton( + icon: const Icon(FluentIcons.delete), + label: const Text('Delete'), + onPressed: () {}, + ), + CommandBarButton( + icon: const Icon(FluentIcons.edit), + label: const Text('Edit'), + tooltip: 'Edit your work', + onPressed: () {}, + ), + ], ), ), ), ); - await tester.tap(find.widgetWithIcon(IconButton, FluentIcons.more)); - await tester.pumpAndSettle(); - expect(find.text('Edit'), findsNWidgets(2)); - + final item = find.text('Edit'); + expect(item, findsOneWidget); final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); await gesture.addPointer(); - await gesture.moveTo(tester.getCenter(find.text('Edit').last)); + await gesture.moveTo(tester.getCenter(item)); await tester.pumpAndSettle(const Duration(milliseconds: 1200)); - expect(find.text('Edit your work'), findsNWidgets(2)); + expect(find.text('Edit your work'), findsOneWidget); }); testWidgets('CommandBarButton hides label and shows icon in compact mode', (