From dbc2ff8046fdd7b62ec9c331b250ddf7258af9c6 Mon Sep 17 00:00:00 2001 From: Shewart Date: Mon, 12 Oct 2026 18:12:11 +0200 Subject: [PATCH 1/2] feat: add command, command palette and navigation menu for MAUI and Avalonia The second batch of ShellUI Blazor components that had no native version, each for MAUI and Avalonia. - command (Command, CommandGroup, CommandItem, CommandSeparator): a search field over a list of grouped items. Typing filters by each item's text and keywords, and Enter picks the highlighted item. - CommandPalette, in the same file: the same list in a dialog near the top of the page. Picking an item, the backdrop or Escape closes it. - navigation-menu (NavigationMenu, NavigationMenuItem, NavigationMenuLink): a row of top-level links. An item with links opens a panel of them; an item without is a plain link. On Avalonia, Up / Down move the command highlight, a hotkey (Ctrl+K by default) toggles the palette anywhere in the window, and the navigation menu works with the arrow keys like the menubar. --- .../Avalonia.Demo/Components/UI/Command.cs | 560 +++++++++ .../Components/UI/NavigationMenu.cs | 353 ++++++ examples/Avalonia.Demo/MainWindow.axaml | 41 + examples/Avalonia.Demo/MainWindow.axaml.cs | 9 + examples/MAUI.Demo/Components/UI/Command.cs | 495 ++++++++ .../MAUI.Demo/Components/UI/NavigationMenu.cs | 260 ++++ examples/MAUI.Demo/MainPage.xaml | 44 + examples/MAUI.Demo/MainPage.xaml.cs | 4 + scripts/sync-templates.py | 4 + .../ComponentRegistry.cs | 4 +- .../Templates/CommandTemplate.cs | 1078 +++++++++++++++++ .../Templates/NavigationMenuTemplate.cs | 636 ++++++++++ .../TemplateContentTests.cs | 2 + 13 files changed, 3489 insertions(+), 1 deletion(-) create mode 100644 examples/Avalonia.Demo/Components/UI/Command.cs create mode 100644 examples/Avalonia.Demo/Components/UI/NavigationMenu.cs create mode 100644 examples/MAUI.Demo/Components/UI/Command.cs create mode 100644 examples/MAUI.Demo/Components/UI/NavigationMenu.cs create mode 100644 src/ShellUI.Native.Templates/Templates/CommandTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/NavigationMenuTemplate.cs diff --git a/examples/Avalonia.Demo/Components/UI/Command.cs b/examples/Avalonia.Demo/Components/UI/Command.cs new file mode 100644 index 0000000..9e4518d --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Command.cs @@ -0,0 +1,560 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Animation.Easings; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// Command menu — a search field over a list of grouped items (rounded-md border bg-popover). +// Typing filters the list; Up / Down move the highlight and Enter picks it. +// +// +// +// +// +// +// ... +// +// CommandPalette shows the same list in a dialog over the window. +public class Command : Border +{ + public static readonly StyledProperty PlaceholderProperty = + AvaloniaProperty.Register(nameof(Placeholder), "Type a command or search..."); + + // Shown when nothing matches the search. + public static readonly StyledProperty EmptyTextProperty = + AvaloniaProperty.Register(nameof(EmptyText), "No results found."); + + // The list scrolls past this height. + public static readonly StyledProperty MaxListHeightProperty = + AvaloniaProperty.Register(nameof(MaxListHeight), 300); + + private readonly TextBox _textBox; + private readonly TextBlock _empty; + private readonly StackPanel _list = new(); + private readonly ScrollViewer _scroll; + private CommandItem? _active; + + static Command() + { + PlaceholderProperty.Changed.AddClassHandler((c, e) => c._textBox.PlaceholderText = (string?)e.NewValue); + EmptyTextProperty.Changed.AddClassHandler((c, e) => c._empty.Text = (string?)e.NewValue); + MaxListHeightProperty.Changed.AddClassHandler((c, e) => c._scroll.MaxHeight = (double)e.NewValue!); + } + + public string? Placeholder + { + get => GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public string? EmptyText + { + get => GetValue(EmptyTextProperty); + set => SetValue(EmptyTextProperty, value); + } + + public double MaxListHeight + { + get => GetValue(MaxListHeightProperty); + set => SetValue(MaxListHeightProperty, value); + } + + // CommandGroup, CommandItem and CommandSeparator children. + [Content] + public Controls Groups => _list.Children; + + // The search text. + public string Query => _textBox.Text ?? string.Empty; + + public event EventHandler? ItemSelected; + + public Command() + { + _textBox = new TextBox + { + FontSize = 14, + PlaceholderText = Placeholder, + VerticalAlignment = VerticalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center + }; + ShellPlatform.StripNativeChrome(_textBox); + _textBox.TextChanged += (_, _) => Filter(); + // Tunnel: the text box would use Up / Down itself. + _textBox.AddHandler(KeyDownEvent, OnSearchKeyDown, RoutingStrategies.Tunnel); + + Grid.SetColumn(_textBox, 1); + var search = new Grid + { + Height = 44, + Margin = new Thickness(12, 0), + ColumnDefinitions = new ColumnDefinitions("Auto,*"), + ColumnSpacing = 8, + Children = { new Icon { Kind = IconName.Search, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }, _textBox } + }; + var line = new Border { Height = 1 }; + line.Token(BackgroundProperty, ShellToken.Border); + + _empty = new TextBlock { Text = EmptyText, FontSize = 14, TextAlignment = TextAlignment.Center, Margin = new Thickness(0, 24), IsVisible = false }; + _empty.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + _scroll = new ScrollViewer + { + Content = new StackPanel { Margin = new Thickness(4), Children = { _empty, _list } }, + MaxHeight = MaxListHeight, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled + }; + + Child = new StackPanel { Children = { search, line, _scroll } }; + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + ClipToBounds = true; + this.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border); + _list.Children.CollectionChanged += (_, _) => Filter(); + } + + // The Command an item or group sits in. + internal static Command? Of(StyledElement? element) + { + for (var parent = element?.Parent; parent != null; parent = parent.Parent) + if (parent is Command command) return command; + return null; + } + + // Used by CommandPalette, which draws the border itself. + internal void RemoveBorder() + { + BorderThickness = default; + CornerRadius = default; + this.ClearToken(BackgroundProperty); + Background = null; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Filter(); + } + + public new bool Focus(NavigationMethod method = NavigationMethod.Unspecified, KeyModifiers keyModifiers = KeyModifiers.None) + => _textBox.Focus(method, keyModifiers); + + // Clears the search. + public void Reset() => _textBox.Text = string.Empty; + + // Every item, in order, whether it sits in a group or directly in the list. + public IEnumerable Items() + { + foreach (var child in _list.Children) + { + if (child is CommandItem item) yield return item; + else if (child is CommandGroup group) + foreach (var grouped in group.Items.OfType()) yield return grouped; + } + } + + internal void Select(CommandItem item) + { + if (!item.IsEffectivelyEnabled) return; + ItemSelected?.Invoke(this, item); + item.RaiseClicked(); + } + + internal void SetActive(CommandItem? item) + { + if (ReferenceEquals(_active, item)) return; + _active?.Apply(false); + _active = item; + _active?.Apply(true); + } + + private void OnSearchKeyDown(object? sender, KeyEventArgs e) + { + switch (e.Key) + { + case Key.Down or Key.Up: + var items = Items().Where(i => i.IsVisible && i.IsEnabled).ToList(); + if (items.Count == 0) break; + var index = _active is null ? -1 : items.IndexOf(_active); + var next = e.Key == Key.Down ? (index + 1) % items.Count : (index <= 0 ? items.Count : index) - 1; + SetActive(items[next]); + items[next].BringIntoView(); + break; + case Key.Enter: + if (_active != null) Select(_active); + break; + default: + return; + } + e.Handled = true; + } + + private void Filter() + { + var words = Query.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var any = false; + foreach (var item in Items()) + { + item.IsVisible = words.All(item.Matches); + any |= item.IsVisible; + } + foreach (var child in _list.Children) + { + if (child is CommandGroup group) group.IsVisible = group.Items.OfType().Any(i => i.IsVisible); + // Dividers only make sense between groups, so they go while the list is filtered. + else if (child is CommandSeparator separator) separator.IsVisible = words.Length == 0; + } + _empty.IsVisible = !any; + SetActive(Items().FirstOrDefault(i => i.IsVisible && i.IsEnabled)); + } +} + +// A labelled section of a Command — the heading is px-2 py-1.5 text-xs font-medium muted. +public class CommandGroup : Border +{ + public static readonly StyledProperty HeadingProperty = + AvaloniaProperty.Register(nameof(Heading)); + + private readonly TextBlock _heading; + private readonly StackPanel _items = new(); + + static CommandGroup() + { + HeadingProperty.Changed.AddClassHandler((g, _) => g.Update()); + } + + public string? Heading + { + get => GetValue(HeadingProperty); + set => SetValue(HeadingProperty, value); + } + + [Content] + public Controls Items => _items.Children; + + public CommandGroup() + { + _heading = new TextBlock { FontSize = 12, FontWeight = FontWeight.Medium, Margin = new Thickness(8, 6), IsVisible = false }; + _heading.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + Child = new StackPanel { Children = { _heading, _items } }; + } + + private void Update() + { + _heading.Text = Heading ?? string.Empty; + _heading.IsVisible = !string.IsNullOrEmpty(Heading); + } +} + +// One row of a Command — rounded-sm px-2 h-9 text-sm; the highlighted row is bg-accent. +public class CommandItem : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + // Identifies the item to your code. + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + // Keyboard hint shown at the end of the row (display only). + public static readonly StyledProperty ShortcutProperty = + AvaloniaProperty.Register(nameof(Shortcut)); + + // Extra words the search matches besides Text. + public static readonly StyledProperty KeywordsProperty = + AvaloniaProperty.Register(nameof(Keywords)); + + private readonly Icon _icon; + private readonly TextBlock _label; + private readonly TextBlock _shortcut; + + static CommandItem() + { + TextProperty.Changed.AddClassHandler((i, _) => i.Update()); + IconProperty.Changed.AddClassHandler((i, _) => i.Update()); + ShortcutProperty.Changed.AddClassHandler((i, _) => i.Update()); + IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Opacity = i.IsEnabled ? 1.0 : 0.5); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public string? Shortcut + { + get => GetValue(ShortcutProperty); + set => SetValue(ShortcutProperty, value); + } + + public string? Keywords + { + get => GetValue(KeywordsProperty); + set => SetValue(KeywordsProperty, value); + } + + public event EventHandler? Clicked; + + public CommandItem() + { + _icon = new Icon { Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground, VerticalAlignment = VerticalAlignment.Center }; + _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + _label.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground); + _shortcut = new TextBlock { FontSize = 12, VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; + _shortcut.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), ColumnSpacing = 8 }; + Grid.SetColumn(_label, 1); + Grid.SetColumn(_shortcut, 2); + grid.Children.Add(_icon); + grid.Children.Add(_label); + grid.Children.Add(_shortcut); + + Child = grid; + Height = 36; + Padding = new Thickness(8, 0); + CornerRadius = new CornerRadius(ShellTheme.RadiusSm); + // Transparent, not null, so the whole row takes the pointer. + Background = Brushes.Transparent; + Cursor = new Cursor(StandardCursorType.Hand); + Update(); + } + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + if (IsEffectivelyEnabled) Command.Of(this)?.SetActive(this); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left) return; + Press(); + e.Handled = true; + } + + // Picks the item in code, as a click or Enter would. + public void Press() + { + if (!IsEffectivelyEnabled) return; + if (Command.Of(this) is { } command) command.Select(this); + else RaiseClicked(); + } + + internal bool Matches(string word) => + (Text ?? string.Empty).Contains(word, StringComparison.CurrentCultureIgnoreCase) || + (Keywords ?? string.Empty).Contains(word, StringComparison.CurrentCultureIgnoreCase); + + internal void Apply(bool active) + { + if (active) this.Token(BackgroundProperty, ShellToken.Accent); + else + { + this.ClearToken(BackgroundProperty); + Background = Brushes.Transparent; + } + } + + internal void RaiseClicked() => Clicked?.Invoke(this, EventArgs.Empty); + + private void Update() + { + _icon.Kind = Icon; + _icon.IsVisible = Icon != IconName.None; + _label.Text = Text ?? string.Empty; + _shortcut.Text = Shortcut ?? string.Empty; + _shortcut.IsVisible = !string.IsNullOrEmpty(Shortcut); + AutomationProperties.SetName(this, Text ?? string.Empty); + } +} + +// Divider between groups — -mx-1 my-1 h-px bg-border. +public class CommandSeparator : Border +{ + public CommandSeparator() + { + Height = 1; + Margin = new Thickness(-4, 4); + this.Token(BackgroundProperty, ShellToken.Border); + } +} + +// Command palette — a Command in a dialog near the top of the window (max-w-xl rounded-xl border +// bg-popover shadow), over a dimmed backdrop. Hotkey (Ctrl+K) toggles it; picking an item, +// clicking the backdrop or pressing Escape closes it. +// +// ... +// +// Open it from code with Palette.SetOpen(true). +public class CommandPalette : ShellOverlayHost +{ + public static readonly StyledProperty PlaceholderProperty = + AvaloniaProperty.Register(nameof(Placeholder), "Type a command or search..."); + + public static readonly StyledProperty EmptyTextProperty = + AvaloniaProperty.Register(nameof(EmptyText), "No results found."); + + // The key gesture that opens and closes the palette anywhere in the window; null for none. + public static readonly StyledProperty HotkeyProperty = + AvaloniaProperty.Register(nameof(Hotkey), new KeyGesture(Key.K, KeyModifiers.Control)); + + private readonly Command _command = new() { MaxListHeight = 360 }; + private TopLevel? _window; + + static CommandPalette() + { + PlaceholderProperty.Changed.AddClassHandler((p, e) => p._command.Placeholder = (string?)e.NewValue); + EmptyTextProperty.Changed.AddClassHandler((p, e) => p._command.EmptyText = (string?)e.NewValue); + } + + public string? Placeholder + { + get => GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public string? EmptyText + { + get => GetValue(EmptyTextProperty); + set => SetValue(EmptyTextProperty, value); + } + + public KeyGesture? Hotkey + { + get => GetValue(HotkeyProperty); + set => SetValue(HotkeyProperty, value); + } + + // The Command inside the dialog. + public Command Command => _command; + + public event EventHandler? ItemSelected; + + public CommandPalette() + { + _command.RemoveBorder(); + _command.ItemSelected += (_, item) => + { + SetOpen(false); + ItemSelected?.Invoke(this, item); + }; + Items.Add(new CommandPaletteContent(this, _command)); + // The groups declared inside the palette belong to its Command. + Items.CollectionChanged += (_, _) => + { + foreach (var child in Items.Where(c => c is not IShellOverlayContent && !_command.Groups.Contains(c)).ToList()) + _command.Groups.Add(child); + }; + OpenChanged += (_, open) => + { + if (open) _command.Reset(); + }; + } + + protected override bool ClaimsChild(Control child) => child is not IShellOverlayContent; + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _window = TopLevel.GetTopLevel(this); + _window?.AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _window?.RemoveHandler(KeyDownEvent, OnWindowKeyDown); + _window = null; + } + + private void OnWindowKeyDown(object? sender, KeyEventArgs e) + { + if (Hotkey is not { } hotkey || !hotkey.Matches(e) || !IsEffectivelyEnabled) return; + SetOpen(!Open); + e.Handled = true; + } +} + +// The backdrop and box of a CommandPalette. +internal sealed class CommandPaletteContent : Border, IShellOverlayContent +{ + private readonly Border _backdrop; + private readonly Border _box; + + public CommandPaletteContent(CommandPalette palette, Command command) + { + _backdrop = new Border(); + _backdrop.Token(BackgroundProperty, ShellToken.Overlay); + _backdrop.PointerPressed += (_, e) => + { + palette.SetOpen(false); + e.Handled = true; + }; + + _box = new Border + { + Child = command, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(12), + ClipToBounds = true, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Top, + BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 16, Blur = 32, Color = ShellTheme.Shadow(0.25) }) + }; + _box.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border); + // Clicks on the box must not reach the backdrop behind it. + _box.PointerPressed += (_, e) => e.Handled = true; + + Child = new Panel { Children = { _backdrop, _box } }; + SizeChanged += (_, e) => + { + _box.Width = Math.Max(0, Math.Min(576, e.NewSize.Width - 32)); + // top-[15vh] + _box.Margin = new Thickness(0, Math.Round(e.NewSize.Height * 0.15), 0, 0); + }; + } + + public async Task AnimateAsync(bool open) + { + if (open) + { + ShellMotion.Set(_backdrop, 0, "scale(1)"); + ShellMotion.Set(_box, 0, "scale(0.95)"); + await Task.WhenAll( + ShellMotion.To(_backdrop, 1, "scale(1)", 150, new CubicEaseOut()), + ShellMotion.To(_box, 1, "scale(1)", 150, new CubicEaseOut())); + } + else + { + await Task.WhenAll( + ShellMotion.To(_backdrop, 0, "scale(1)", 120, new CubicEaseIn()), + ShellMotion.To(_box, 0, "scale(0.95)", 120, new CubicEaseIn())); + } + } +} diff --git a/examples/Avalonia.Demo/Components/UI/NavigationMenu.cs b/examples/Avalonia.Demo/Components/UI/NavigationMenu.cs new file mode 100644 index 0000000..91bc8e7 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/NavigationMenu.cs @@ -0,0 +1,353 @@ +using System; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// Navigation menu — a row of top-level links for a site or app header. An item with links +// inside shows a chevron and opens a panel of them (title and description); an item without +// is a plain link. Items are tab stops: Enter, Space or Down opens one, Left / Right move +// between them, and Up / Down move through an open panel. +// +// +// +// +// +// +// +public class NavigationMenu : Border +{ + private readonly StackPanel _row = new() { Orientation = Orientation.Horizontal, Spacing = 4 }; + + // The NavigationMenuItem children. + [Content] + public Controls Items => _row.Children; + + public NavigationMenu() + { + Child = _row; + HorizontalAlignment = HorizontalAlignment.Left; + _row.Children.CollectionChanged += (_, _) => + { + foreach (var item in _row.Children.OfType()) item.Menu = this; + }; + } + + // Moves keyboard focus to the item before or after `from`, wrapping around. + internal void FocusNeighbour(NavigationMenuItem from, int direction) + { + var items = _row.Children.OfType().Where(i => i.IsEffectivelyEnabled).ToList(); + var index = items.IndexOf(from); + if (index < 0 || items.Count < 2) return; + items[(index + direction + items.Count) % items.Count].Focus(NavigationMethod.Directional); + } +} + +// A top-level entry — h-10 rounded-md px-4 text-sm font-medium, hover:bg-accent. With +// NavigationMenuLink children it opens a w-56 panel (rounded-md border bg-popover p-1 shadow-lg). +public class NavigationMenuItem : Border, IShellFocusable +{ + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title)); + + private readonly TextBlock _label; + private readonly Icon _chevron; + private readonly RotateTransform _turn = new(); + private readonly Border _trigger; + private readonly StackPanel _stack = new(); + private readonly ShellAnchoredPopup _popup; + + static NavigationMenuItem() + { + TitleProperty.Changed.AddClassHandler((i, _) => i.Update()); + IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Opacity = i.IsEnabled ? 1.0 : 0.5); + } + + public string? Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + // The NavigationMenuLink children. + [Content] + public Controls Links => _stack.Children; + + public bool IsOpen => _popup.IsOpen; + + // Raised when an item without links is pressed. + public event EventHandler? Clicked; + + internal NavigationMenu? Menu { get; set; } + + public NavigationMenuItem() + { + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center }; + _label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + _chevron = new Icon + { + Kind = IconName.ChevronDown, + Size = 12, + Token = ShellToken.Foreground, + VerticalAlignment = VerticalAlignment.Center, + RenderTransform = _turn, + RenderTransformOrigin = RelativePoint.Center, + IsVisible = false + }; + _trigger = new Border + { + Child = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4, VerticalAlignment = VerticalAlignment.Center, Children = { _label, _chevron } }, + Height = 40, + Padding = new Thickness(16, 0), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + // Transparent, not null, so the whole item takes the pointer. + Background = Brushes.Transparent, + Cursor = new Cursor(StandardCursorType.Hand) + }; + + var panel = new Border + { + Child = _stack, + Padding = new Thickness(4), + Width = 224, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + BoxShadow = ShellPopups.PanelShadow() + }; + panel.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border); + + _popup = new ShellAnchoredPopup(_trigger); + _popup.Popup.Child = panel; + _popup.Closed += (_, _) => Update(); + _stack.Children.CollectionChanged += (_, _) => Update(); + + Child = new Panel { Children = { _trigger, _popup.Popup } }; + ShellFocus.Ring(this, _trigger); + Update(); + } + + private bool HasLinks => _stack.Children.Count > 0; + + // Opens the panel, or raises Clicked when there are no links, as a click or Enter would. + public void Press() + { + if (!IsEffectivelyEnabled) return; + if (HasLinks) SetOpen(!IsOpen); + else Clicked?.Invoke(this, EventArgs.Empty); + } + + public void SetOpen(bool open) + { + if (open == _popup.IsOpen || (open && !HasLinks)) return; + if (!open) + { + _popup.Close(); + return; + } + foreach (var link in _stack.Children.OfType()) link.Owner = this; + _popup.Open(); + Update(); + } + + public void Close() => SetOpen(false); + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + Update(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + Update(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left) return; + Press(); + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (!ReferenceEquals(e.Source, this)) return; + switch (e.Key) + { + case Key.Enter or Key.Space: + Press(); + FocusFirstLink(); + break; + case Key.Down: + SetOpen(true); + FocusFirstLink(); + break; + case Key.Left: + Menu?.FocusNeighbour(this, -1); + break; + case Key.Right: + Menu?.FocusNeighbour(this, 1); + break; + default: + return; + } + e.Handled = true; + } + + // Into the panel, so the arrow keys and Enter work on the links. + private void FocusFirstLink() + { + if (IsOpen) _stack.Children.OfType().FirstOrDefault(l => l.IsEffectivelyEnabled)?.Focus(NavigationMethod.Directional); + } + + private void Update() + { + _label.Text = Title ?? string.Empty; + _chevron.IsVisible = HasLinks; + _turn.Angle = IsOpen ? 180 : 0; + AutomationProperties.SetName(this, Title ?? string.Empty); + if (IsOpen || (IsPointerOver && IsEffectivelyEnabled)) _trigger.Token(BackgroundProperty, ShellToken.Accent); + else + { + _trigger.ClearToken(BackgroundProperty); + _trigger.Background = Brushes.Transparent; + } + } +} + +// A link in an item's panel — rounded-sm p-2, hover:bg-accent: a text-sm font-medium title over +// an optional muted description, with an optional icon. A tab stop that activates with Enter or +// Space; Up and Down move between the links. +public class NavigationMenuLink : Border +{ + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title)); + + public static readonly StyledProperty DescriptionProperty = + AvaloniaProperty.Register(nameof(Description)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + private readonly Icon _icon; + private readonly TextBlock _title; + private readonly TextBlock _description; + + static NavigationMenuLink() + { + TitleProperty.Changed.AddClassHandler((l, _) => l.Update()); + DescriptionProperty.Changed.AddClassHandler((l, _) => l.Update()); + IconProperty.Changed.AddClassHandler((l, _) => l.Update()); + IsEnabledProperty.Changed.AddClassHandler((l, _) => l.Opacity = l.IsEnabled ? 1.0 : 0.5); + } + + public string? Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string? Description + { + get => GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public event EventHandler? Clicked; + + internal NavigationMenuItem? Owner { get; set; } + + public NavigationMenuLink() + { + _icon = new Icon { Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground, VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 2, 0, 0) }; + _title = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium }; + _title.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground); + _description = new TextBlock { FontSize = 13, IsVisible = false, TextWrapping = TextWrapping.Wrap }; + _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + + var text = new StackPanel { Spacing = 2, Children = { _title, _description } }; + Grid.SetColumn(text, 1); + Child = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*"), ColumnSpacing = 8, Children = { _icon, text } }; + Padding = new Thickness(8); + CornerRadius = new CornerRadius(ShellTheme.RadiusSm); + Background = Brushes.Transparent; + Cursor = new Cursor(StandardCursorType.Hand); + ShellFocus.Ring(this, this); + Update(); + } + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + if (IsEffectivelyEnabled) this.Token(BackgroundProperty, ShellToken.Accent); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + this.ClearToken(BackgroundProperty); + Background = Brushes.Transparent; + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left) return; + Press(); + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + switch (e.Key) + { + case Key.Enter or Key.Space: + Press(); + break; + case Key.Up or Key.Down: + if (Parent is not Panel rows) return; + var links = rows.Children.OfType().Where(l => l.IsEffectivelyEnabled).ToList(); + var index = links.IndexOf(this); + if (index < 0) return; + links[(index + (e.Key == Key.Down ? 1 : -1) + links.Count) % links.Count].Focus(NavigationMethod.Directional); + break; + default: + return; + } + e.Handled = true; + } + + // Picks the link in code, as a click or Enter/Space would. + public void Press() + { + if (!IsEffectivelyEnabled) return; + Owner?.Close(); + Clicked?.Invoke(this, EventArgs.Empty); + } + + private void Update() + { + _icon.Kind = Icon; + _icon.IsVisible = Icon != IconName.None; + _title.Text = Title ?? string.Empty; + _description.Text = Description ?? string.Empty; + _description.IsVisible = !string.IsNullOrEmpty(Description); + AutomationProperties.SetName(this, Title ?? string.Empty); + } +} diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml index 4521e2f..8b4b689 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml +++ b/examples/Avalonia.Demo/MainWindow.axaml @@ -631,6 +631,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs index 986d217..1686f8f 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml.cs +++ b/examples/Avalonia.Demo/MainWindow.axaml.cs @@ -75,6 +75,15 @@ public MainWindow() foreach (var menu in DemoMenubar.Menus.OfType()) foreach (var item in menu.Items.OfType()) item.Clicked += (_, _) => GroupChoice.Text = $"Menubar: {menu.Title} > {item.Text}."; + foreach (var item in DemoNavigationMenu.Items.OfType()) + { + item.Clicked += (_, _) => CommandChoice.Text = $"Navigation menu: {item.Title}."; + foreach (var link in item.Links.OfType()) + link.Clicked += (_, _) => CommandChoice.Text = $"Navigation menu: {item.Title} > {link.Title}."; + } + DemoCommand.ItemSelected += (_, item) => CommandChoice.Text = $"Command: {item.Value}."; + DemoPalette.ItemSelected += (_, item) => CommandChoice.Text = $"Palette: {item.Value}."; + OpenPalette.Clicked += (_, _) => DemoPalette.SetOpen(true); DemoNavbar.SelectedValueChanged += (_, value) => LayoutChoice.Text = $"Navbar: {value}."; DemoSidebar.SelectedValueChanged += (_, value) => { diff --git a/examples/MAUI.Demo/Components/UI/Command.cs b/examples/MAUI.Demo/Components/UI/Command.cs new file mode 100644 index 0000000..6e590c6 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Command.cs @@ -0,0 +1,495 @@ +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.Components.UI; + +// Command menu — a search field over a list of grouped items (rounded-md border bg-popover). +// Typing filters the list; Enter picks the highlighted item. +// +// +// +// +// +// +// ... +// +// CommandPalette shows the same list in a dialog over the page. +[ContentProperty(nameof(Groups))] +public partial class Command : ContentView +{ + public static readonly BindableProperty PlaceholderProperty = + BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(Command), "Type a command or search...", + propertyChanged: (b, o, n) => ((Command)b)._entry.Placeholder = n as string ?? string.Empty); + + // Shown when nothing matches the search. + public static readonly BindableProperty EmptyTextProperty = + BindableProperty.Create(nameof(EmptyText), typeof(string), typeof(Command), "No results found.", + propertyChanged: (b, o, n) => ((Command)b)._empty.Text = n as string ?? string.Empty); + + // The list scrolls past this height. + public static readonly BindableProperty MaxListHeightProperty = + BindableProperty.Create(nameof(MaxListHeight), typeof(double), typeof(Command), 300d, + propertyChanged: (b, o, n) => ((Command)b)._scroll.MaximumHeightRequest = (double)n); + + public string Placeholder + { + get => (string)GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public string EmptyText + { + get => (string)GetValue(EmptyTextProperty); + set => SetValue(EmptyTextProperty, value); + } + + public double MaxListHeight + { + get => (double)GetValue(MaxListHeightProperty); + set => SetValue(MaxListHeightProperty, value); + } + + // CommandGroup, CommandItem and CommandSeparator children. + public IList Groups => _list.Children; + + // The search text. + public string Query => _entry.Text ?? string.Empty; + + public event EventHandler? ItemSelected; + + private readonly Border _border; + private readonly Entry _entry; + private readonly Label _empty; + private readonly VerticalStackLayout _list; + private readonly ScrollView _scroll; + private CommandItem? _active; + + public Command() + { + _entry = new Entry + { + FontSize = 14, + Placeholder = Placeholder, + BackgroundColor = Colors.Transparent, + VerticalOptions = LayoutOptions.Center, + HorizontalOptions = LayoutOptions.Fill, + ClearButtonVisibility = ClearButtonVisibility.Never + }; + _entry.Token(Entry.TextColorProperty, ShellToken.Foreground); + _entry.Token(Entry.PlaceholderColorProperty, ShellToken.MutedForeground); + ShellPlatform.StripNativeChrome(_entry); + ShellFocus.Track(_entry); + _entry.TextChanged += (_, _) => Filter(); + _entry.Completed += (_, _) => { if (_active != null) Select(_active); }; + + var search = new Grid + { + HeightRequest = 44, + Padding = new Thickness(12, 0), + ColumnSpacing = 8, + ColumnDefinitions = { new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star) } + }; + search.Add(new Icon { Name = IconName.Search, Size = 16, Token = ShellToken.MutedForeground, VerticalOptions = LayoutOptions.Center }, 0, 0); + search.Add(_entry, 1, 0); + var line = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent }; + line.Token(BoxView.ColorProperty, ShellToken.Border); + + _empty = new Label { Text = EmptyText, FontSize = 14, HorizontalTextAlignment = TextAlignment.Center, Padding = new Thickness(0, 24), IsVisible = false }; + _empty.Token(Label.TextColorProperty, ShellToken.MutedForeground); + _list = new VerticalStackLayout { Spacing = 0 }; + _list.ChildAdded += (_, _) => Filter(); + _scroll = new ScrollView + { + Content = new VerticalStackLayout { Padding = new Thickness(4), Children = { _empty, _list } }, + MaximumHeightRequest = MaxListHeight, + VerticalScrollBarVisibility = ScrollBarVisibility.Default + }; + + _border = new Border + { + Content = new VerticalStackLayout { Spacing = 0, Children = { search, line, _scroll } }, + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd } + }; + _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover); + _border.Token(Border.StrokeProperty, ShellToken.Border); + Content = _border; + Loaded += (_, _) => Filter(); + } + + // The Command an item or group sits in. + internal static Command? Of(Element? element) + { + for (var parent = element?.Parent; parent != null; parent = parent.Parent) + if (parent is Command command) return command; + return null; + } + + // Used by CommandPalette, which draws the border itself. + internal void RemoveBorder() + { + _border.StrokeThickness = 0; + _border.ClearValue(VisualElement.BackgroundColorProperty); + _border.BackgroundColor = Colors.Transparent; + } + + public new void Focus() => _entry.Focus(); + + // Clears the search. + public void Reset() => _entry.Text = string.Empty; + + // Every item, in order, whether it sits in a group or directly in the list. + public IEnumerable Items() + { + foreach (var child in _list.Children) + { + if (child is CommandItem item) yield return item; + else if (child is CommandGroup group) + foreach (var grouped in group.Items.OfType()) yield return grouped; + } + } + + internal void Select(CommandItem item) + { + if (!item.IsEnabled) return; + ItemSelected?.Invoke(this, item); + item.RaiseClicked(); + } + + internal void SetActive(CommandItem? item) + { + if (ReferenceEquals(_active, item)) return; + _active?.Apply(false); + _active = item; + _active?.Apply(true); + } + + private void Filter() + { + var words = Query.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var any = false; + foreach (var item in Items()) + { + item.IsVisible = words.All(item.Matches); + any |= item.IsVisible; + } + foreach (var child in _list.Children) + { + if (child is CommandGroup group) group.IsVisible = group.Items.OfType().Any(i => i.IsVisible); + // Dividers only make sense between groups, so they go while the list is filtered. + else if (child is CommandSeparator separator) separator.IsVisible = words.Length == 0; + } + _empty.IsVisible = !any; + SetActive(Items().FirstOrDefault(i => i.IsVisible && i.IsEnabled)); + } +} + +// A labelled section of a Command — the heading is px-2 py-1.5 text-xs font-medium muted. +[ContentProperty(nameof(Items))] +public partial class CommandGroup : ContentView +{ + public static readonly BindableProperty HeadingProperty = + BindableProperty.Create(nameof(Heading), typeof(string), typeof(CommandGroup), string.Empty, + propertyChanged: (b, o, n) => ((CommandGroup)b).Update()); + + public string Heading + { + get => (string)GetValue(HeadingProperty); + set => SetValue(HeadingProperty, value); + } + + public IList Items => _items.Children; + + private readonly Label _heading; + private readonly VerticalStackLayout _items; + + public CommandGroup() + { + _heading = new Label { FontSize = 12, FontAttributes = FontAttributes.Bold, Padding = new Thickness(8, 6), IsVisible = false }; + _heading.Token(Label.TextColorProperty, ShellToken.MutedForeground); + _items = new VerticalStackLayout { Spacing = 0 }; + Content = new VerticalStackLayout { Spacing = 0, Children = { _heading, _items } }; + } + + private void Update() + { + _heading.Text = Heading ?? string.Empty; + _heading.IsVisible = !string.IsNullOrEmpty(Heading); + } +} + +// One row of a Command — rounded-sm px-2 h-9 text-sm; the highlighted row is bg-accent. +public partial class CommandItem : ContentView +{ + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(CommandItem), string.Empty, + propertyChanged: (b, o, n) => ((CommandItem)b).Update()); + + // Identifies the item to your code. + public static readonly BindableProperty ValueProperty = + BindableProperty.Create(nameof(Value), typeof(string), typeof(CommandItem), null); + + public static readonly BindableProperty IconProperty = + BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(CommandItem), IconName.None, + propertyChanged: (b, o, n) => ((CommandItem)b).Update()); + + // Keyboard hint shown at the end of the row (display only). + public static readonly BindableProperty ShortcutProperty = + BindableProperty.Create(nameof(Shortcut), typeof(string), typeof(CommandItem), string.Empty, + propertyChanged: (b, o, n) => ((CommandItem)b).Update()); + + // Extra words the search matches besides Text. + public static readonly BindableProperty KeywordsProperty = + BindableProperty.Create(nameof(Keywords), typeof(string), typeof(CommandItem), string.Empty); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string? Value + { + get => (string?)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public IconName Icon + { + get => (IconName)GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public string Shortcut + { + get => (string)GetValue(ShortcutProperty); + set => SetValue(ShortcutProperty, value); + } + + public string Keywords + { + get => (string)GetValue(KeywordsProperty); + set => SetValue(KeywordsProperty, value); + } + + public event EventHandler? Clicked; + + private readonly Border _row; + private readonly Icon _icon; + private readonly Label _label; + private readonly Label _shortcut; + + public CommandItem() + { + _icon = new Icon { Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground, VerticalOptions = LayoutOptions.Center }; + _label = new Label { FontSize = 14, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.TailTruncation }; + _label.Token(Label.TextColorProperty, ShellToken.PopoverForeground); + _shortcut = new Label { FontSize = 12, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, IsVisible = false }; + _shortcut.Token(Label.TextColorProperty, ShellToken.MutedForeground); + + var grid = new Grid + { + ColumnSpacing = 8, + ColumnDefinitions = + { + new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) + } + }; + grid.Add(_icon, 0, 0); + grid.Add(_label, 1, 0); + grid.Add(_shortcut, 2, 0); + + _row = new Border + { + Content = grid, + HeightRequest = 36, + Padding = new Thickness(8, 0), + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm }, + BackgroundColor = Colors.Transparent + }; + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => { if (IsEnabled) Command.Of(this)?.SetActive(this); }; + _row.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => Press(); + _row.GestureRecognizers.Add(tap); + + Content = _row; + Update(); + } + + protected override void OnPropertyChanged(string? propertyName = null) + { + base.OnPropertyChanged(propertyName); + if (propertyName == nameof(IsEnabled)) Opacity = IsEnabled ? 1 : 0.5; + } + + internal bool Matches(string word) => + (Text ?? string.Empty).Contains(word, StringComparison.CurrentCultureIgnoreCase) || + (Keywords ?? string.Empty).Contains(word, StringComparison.CurrentCultureIgnoreCase); + + internal void Apply(bool active) + { + if (active) _row.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); + else + { + _row.ClearValue(VisualElement.BackgroundColorProperty); + _row.BackgroundColor = Colors.Transparent; + } + } + + internal void RaiseClicked() => Clicked?.Invoke(this, EventArgs.Empty); + + private void Press() + { + if (!IsEnabled) return; + if (Command.Of(this) is { } command) command.Select(this); + else RaiseClicked(); + } + + private void Update() + { + _icon.Name = Icon; + _icon.IsVisible = Icon != IconName.None; + _label.Text = Text ?? string.Empty; + _shortcut.Text = Shortcut ?? string.Empty; + _shortcut.IsVisible = !string.IsNullOrEmpty(Shortcut); + SemanticProperties.SetDescription(this, Text ?? string.Empty); + } +} + +// Divider between groups — -mx-1 my-1 h-px bg-border. +public partial class CommandSeparator : ContentView +{ + public CommandSeparator() + { + var line = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent }; + line.Token(BoxView.ColorProperty, ShellToken.Border); + Margin = new Thickness(-4, 4); + Content = line; + } +} + +// Command palette — a Command in a dialog near the top of the page (max-w-xl rounded-xl border +// bg-popover shadow), over a dimmed backdrop. Picking an item, tapping the backdrop or pressing +// Escape closes it. Declare it where a Dialog would go and open it from code: +// +// ... +// +// Palette.SetOpen(true); +[ContentProperty(nameof(Groups))] +public partial class CommandPalette : ShellOverlayHost +{ + public static readonly BindableProperty PlaceholderProperty = + BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(CommandPalette), "Type a command or search...", + propertyChanged: (b, o, n) => ((CommandPalette)b)._command.Placeholder = n as string ?? string.Empty); + + public static readonly BindableProperty EmptyTextProperty = + BindableProperty.Create(nameof(EmptyText), typeof(string), typeof(CommandPalette), "No results found.", + propertyChanged: (b, o, n) => ((CommandPalette)b)._command.EmptyText = n as string ?? string.Empty); + + public string Placeholder + { + get => (string)GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public string EmptyText + { + get => (string)GetValue(EmptyTextProperty); + set => SetValue(EmptyTextProperty, value); + } + + public IList Groups => _command.Groups; + + // The Command inside the dialog. + public Command Command => _command; + + public event EventHandler? ItemSelected; + + private readonly Command _command; + + public CommandPalette() + { + _command = new Command { MaxListHeight = 360 }; + _command.RemoveBorder(); + _command.ItemSelected += (_, item) => + { + SetOpen(false); + ItemSelected?.Invoke(this, item); + }; + Children.Add(new CommandPaletteContent(this, _command)); + OpenChanged += async (_, open) => + { + if (!open) return; + _command.Reset(); + // The field can take focus once the dialog is on screen. + await Task.Delay(120); + if (Open) _command.Focus(); + }; + } + + protected override bool IsTrigger(Element child) => false; +} + +// The backdrop and box of a CommandPalette. +internal sealed partial class CommandPaletteContent : ContentView, IShellOverlayContent +{ + private readonly BoxView _backdrop; + private readonly Border _box; + + public CommandPaletteContent(CommandPalette palette, Command command) + { + _backdrop = new BoxView { BackgroundColor = Colors.Transparent }; + _backdrop.Token(BoxView.ColorProperty, ShellToken.Overlay); + var tapBackdrop = new TapGestureRecognizer(); + tapBackdrop.Tapped += (_, _) => palette.SetOpen(false); + _backdrop.GestureRecognizers.Add(tapBackdrop); + + _box = new Border + { + Content = command, + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 12 }, + VerticalOptions = LayoutOptions.Start, + HorizontalOptions = LayoutOptions.Center, + Shadow = new Shadow { Brush = new SolidColorBrush(Colors.Black), Offset = new Point(0, 16), Radius = 32, Opacity = 0.25f } + }; + _box.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover); + _box.Token(Border.StrokeProperty, ShellToken.Border); + // Swallow taps on the box so they don't fall through to the backdrop. + _box.GestureRecognizers.Add(new TapGestureRecognizer()); + + var root = new Grid { Children = { _backdrop, _box } }; + ShellPortal.EdgeToEdge(this, root); + root.SizeChanged += (_, _) => + { + _box.WidthRequest = Math.Max(0, Math.Min(576, root.Width - 32)); + // top-[15vh] + _box.Margin = new Thickness(0, Math.Round(root.Height * 0.15), 0, 0); + }; + Content = root; + } + + public async Task AnimateAsync(bool open) + { + _box.AbortAnimation("FadeTo"); + if (open) + { + _backdrop.Opacity = 0; + _box.Opacity = 0; + _box.Scale = 0.95; + await Task.WhenAll( + _backdrop.FadeToAsync(1, 150, Easing.CubicOut), + _box.FadeToAsync(1, 150, Easing.CubicOut), + _box.ScaleToAsync(1, 150, Easing.CubicOut)); + } + else + { + await Task.WhenAll( + _backdrop.FadeToAsync(0, 120, Easing.CubicIn), + _box.FadeToAsync(0, 120, Easing.CubicIn), + _box.ScaleToAsync(0.95, 120, Easing.CubicIn)); + } + } +} diff --git a/examples/MAUI.Demo/Components/UI/NavigationMenu.cs b/examples/MAUI.Demo/Components/UI/NavigationMenu.cs new file mode 100644 index 0000000..797a9e6 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/NavigationMenu.cs @@ -0,0 +1,260 @@ +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.Components.UI; + +// Navigation menu — a row of top-level links for a site or app header. An item with links +// inside shows a chevron and opens a panel of them (title and description); an item without +// is a plain link. +// +// +// +// +// +// +// +[ContentProperty(nameof(Items))] +public partial class NavigationMenu : ContentView +{ + public IList Items => _row.Children; + + private readonly HorizontalStackLayout _row; + + public NavigationMenu() + { + _row = new HorizontalStackLayout { Spacing = 4 }; + Content = _row; + HorizontalOptions = LayoutOptions.Start; + } +} + +// A top-level entry — h-10 rounded-md px-4 text-sm font-medium, hover:bg-accent. With +// NavigationMenuLink children it opens a w-56 panel (rounded-md border bg-popover p-1 shadow-lg). +[ContentProperty(nameof(Links))] +public partial class NavigationMenuItem : ContentView, IShellPopup +{ + public static readonly BindableProperty TitleProperty = + BindableProperty.Create(nameof(Title), typeof(string), typeof(NavigationMenuItem), string.Empty, + propertyChanged: (b, o, n) => ((NavigationMenuItem)b).Update()); + + public string Title + { + get => (string)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public IList Links => _stack.Children; + + public bool IsOpen { get; private set; } + + // Raised when an item without links is pressed. + public event EventHandler? Clicked; + + private readonly Border _trigger; + private readonly Label _label; + private readonly Icon _chevron; + private readonly VerticalStackLayout _stack; + private readonly Border _panel; + private ShellPopupHandle? _handle; + private bool _hovered; + + public NavigationMenuItem() + { + _label = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, LineBreakMode = LineBreakMode.NoWrap }; + _label.Token(Label.TextColorProperty, ShellToken.Foreground); + _chevron = new Icon { Name = IconName.ChevronDown, Size = 12, Token = ShellToken.Foreground, VerticalOptions = LayoutOptions.Center, IsVisible = false }; + _trigger = new Border + { + Content = new HorizontalStackLayout { Spacing = 4, VerticalOptions = LayoutOptions.Center, Children = { _label, _chevron } }, + HeightRequest = 40, + Padding = new Thickness(16, 0), + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, + BackgroundColor = Colors.Transparent + }; + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => { _hovered = true; Update(); }; + pointer.PointerExited += (_, _) => { _hovered = false; Update(); }; + _trigger.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); Press(); }; + _trigger.GestureRecognizers.Add(tap); + ShellFocus.MakeFocusable(this, Press); + + _stack = new VerticalStackLayout { Spacing = 0 }; + _stack.ChildAdded += (_, _) => Update(); + _stack.ChildRemoved += (_, _) => Update(); + _panel = new Border + { + Content = _stack, + Padding = new Thickness(4), + WidthRequest = 224, + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, + Shadow = ShellPopups.PanelShadow() + }; + _panel.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover); + _panel.Token(Border.StrokeProperty, ShellToken.Border); + // Taps on the panel padding must not fall through to the click-outside catcher. + _panel.GestureRecognizers.Add(new TapGestureRecognizer()); + + Content = _trigger; + Update(); + } + + private bool HasLinks => _stack.Children.Count > 0; + + private void Press() + { + if (!IsEnabled) return; + if (HasLinks) SetOpen(!IsOpen); + else Clicked?.Invoke(this, EventArgs.Empty); + } + + public void SetOpen(bool open) + { + if (IsOpen == open || (open && !HasLinks)) return; + IsOpen = open; + Update(); + _ = _chevron.RotateToAsync(open ? 180 : 0, 200, Easing.CubicOut); + if (open) + { + // The panel floats outside this view, so links can't find their item by walking up. + foreach (var link in _stack.Children.OfType()) link.Owner = this; + ShellPopups.Opened(this); + _handle = ShellPortal.ShowPopup(_trigger, _panel, new ShellPopupOptions { Owner = this, OnDismiss = Close }); + } + else + { + ShellPopups.Closed(this); + var handle = _handle; + _handle = null; + if (handle != null) _ = handle.CloseAsync(); + } + } + + public void Close() => SetOpen(false); + + protected override void OnPropertyChanged(string? propertyName = null) + { + base.OnPropertyChanged(propertyName); + if (propertyName == IsEnabledProperty.PropertyName) + Opacity = IsEnabled ? 1.0 : 0.5; + } + + private void Update() + { + _label.Text = Title ?? string.Empty; + _chevron.IsVisible = HasLinks; + SemanticProperties.SetDescription(this, Title ?? string.Empty); + if (IsOpen || (_hovered && IsEnabled)) + _trigger.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); + else + { + _trigger.ClearValue(VisualElement.BackgroundColorProperty); + _trigger.BackgroundColor = Colors.Transparent; + } + } +} + +// A link in an item's panel — rounded-sm p-2, hover:bg-accent: a text-sm font-medium title over +// an optional muted description, with an optional icon. +public partial class NavigationMenuLink : ContentView +{ + public static readonly BindableProperty TitleProperty = + BindableProperty.Create(nameof(Title), typeof(string), typeof(NavigationMenuLink), string.Empty, + propertyChanged: (b, o, n) => ((NavigationMenuLink)b).Update()); + + public static readonly BindableProperty DescriptionProperty = + BindableProperty.Create(nameof(Description), typeof(string), typeof(NavigationMenuLink), string.Empty, + propertyChanged: (b, o, n) => ((NavigationMenuLink)b).Update()); + + public static readonly BindableProperty IconProperty = + BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(NavigationMenuLink), IconName.None, + propertyChanged: (b, o, n) => ((NavigationMenuLink)b).Update()); + + public string Title + { + get => (string)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string Description + { + get => (string)GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public IconName Icon + { + get => (IconName)GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public event EventHandler? Clicked; + + internal NavigationMenuItem? Owner { get; set; } + + private readonly Border _row; + private readonly Icon _icon; + private readonly Label _title; + private readonly Label _description; + + public NavigationMenuLink() + { + _icon = new Icon { Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground, VerticalOptions = LayoutOptions.Start, Margin = new Thickness(0, 2, 0, 0) }; + _title = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold }; + _title.Token(Label.TextColorProperty, ShellToken.PopoverForeground); + _description = new Label { FontSize = 13, IsVisible = false, LineBreakMode = LineBreakMode.WordWrap }; + _description.Token(Label.TextColorProperty, ShellToken.MutedForeground); + + var grid = new Grid + { + ColumnSpacing = 8, + ColumnDefinitions = { new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star) } + }; + grid.Add(_icon, 0, 0); + grid.Add(new VerticalStackLayout { Spacing = 2, Children = { _title, _description } }, 1, 0); + + _row = new Border + { + Content = grid, + Padding = new Thickness(8), + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm }, + BackgroundColor = Colors.Transparent + }; + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => { if (IsEnabled) _row.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); }; + pointer.PointerExited += (_, _) => { _row.ClearValue(VisualElement.BackgroundColorProperty); _row.BackgroundColor = Colors.Transparent; }; + _row.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => Press(); + _row.GestureRecognizers.Add(tap); + + Content = _row; + Update(); + } + + protected override void OnPropertyChanged(string? propertyName = null) + { + base.OnPropertyChanged(propertyName); + if (propertyName == nameof(IsEnabled)) Opacity = IsEnabled ? 1 : 0.5; + } + + private void Press() + { + if (!IsEnabled) return; + Owner?.Close(); + Clicked?.Invoke(this, EventArgs.Empty); + } + + private void Update() + { + _icon.Name = Icon; + _icon.IsVisible = Icon != IconName.None; + _title.Text = Title ?? string.Empty; + _description.Text = Description ?? string.Empty; + _description.IsVisible = !string.IsNullOrEmpty(Description); + SemanticProperties.SetDescription(this, Title ?? string.Empty); + } +} diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml index 88c600d..14da721 100644 --- a/examples/MAUI.Demo/MainPage.xaml +++ b/examples/MAUI.Demo/MainPage.xaml @@ -493,6 +493,50 @@ + +