From a1b770f7d606b353638b2834dc6870a58304be62 Mon Sep 17 00:00:00 2001 From: Shewart Date: Mon, 12 Oct 2026 00:22:10 +0200 Subject: [PATCH 1/2] feat: add button group, input group and menubar for MAUI and Avalonia The first batch of ShellUI Blazor components that had no native version, each for MAUI and Avalonia. - button-group (ButtonGroup): joins its buttons into one control. Only the outer corners are rounded and neighbouring borders overlap. Horizontal or vertical. - input-group (InputGroup): an input with an icon, a short text or any view before or after the text, inside the same border. - menubar (Menubar, MenubarMenu, MenubarItem, MenubarSeparator): a row of menus, each opening a panel of items under its title. Button gains an internal corner override that ButtonGroup sets, so button-group needs this version of button. On Avalonia the menubar titles are tab stops: Left / Right move between them, Enter / Space / Down open a menu, and Up / Down move inside it. --- .../Avalonia.Demo/Components/UI/Button.cs | 10 +- .../Components/UI/ButtonGroup.cs | 64 ++ .../Avalonia.Demo/Components/UI/InputGroup.cs | 236 +++++++ .../Avalonia.Demo/Components/UI/Menubar.cs | 358 ++++++++++ examples/Avalonia.Demo/MainWindow.axaml | 48 ++ examples/Avalonia.Demo/MainWindow.axaml.cs | 7 + examples/MAUI.Demo/Components/UI/Button.cs | 10 +- .../MAUI.Demo/Components/UI/ButtonGroup.cs | 56 ++ .../MAUI.Demo/Components/UI/InputGroup.cs | 248 +++++++ examples/MAUI.Demo/Components/UI/Menubar.cs | 286 ++++++++ examples/MAUI.Demo/MainPage.xaml | 53 ++ examples/MAUI.Demo/MainPage.xaml.cs | 2 + scripts/sync-templates.py | 7 + .../ComponentRegistry.cs | 5 +- .../Templates/ButtonGroupTemplate.cs | 143 ++++ .../Templates/ButtonTemplate.cs | 20 +- .../Templates/InputGroupTemplate.cs | 507 +++++++++++++ .../Templates/MenubarTemplate.cs | 667 ++++++++++++++++++ .../TemplateContentTests.cs | 3 + 19 files changed, 2725 insertions(+), 5 deletions(-) create mode 100644 examples/Avalonia.Demo/Components/UI/ButtonGroup.cs create mode 100644 examples/Avalonia.Demo/Components/UI/InputGroup.cs create mode 100644 examples/Avalonia.Demo/Components/UI/Menubar.cs create mode 100644 examples/MAUI.Demo/Components/UI/ButtonGroup.cs create mode 100644 examples/MAUI.Demo/Components/UI/InputGroup.cs create mode 100644 examples/MAUI.Demo/Components/UI/Menubar.cs create mode 100644 src/ShellUI.Native.Templates/Templates/ButtonGroupTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/InputGroupTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/MenubarTemplate.cs diff --git a/examples/Avalonia.Demo/Components/UI/Button.cs b/examples/Avalonia.Demo/Components/UI/Button.cs index fce97bb..1f14440 100644 --- a/examples/Avalonia.Demo/Components/UI/Button.cs +++ b/examples/Avalonia.Demo/Components/UI/Button.cs @@ -44,6 +44,7 @@ public class Button : Border, IShellFocusable private DispatcherTimer? _spinning; private bool _hovered; private bool _pressed; + private CornerRadius? _corners; static Button() { @@ -180,6 +181,13 @@ protected override void OnKeyDown(KeyEventArgs e) e.Handled = true; } + // Set by ButtonGroup so joined buttons only round their outer corners; null restores the default. + internal void SetCorners(CornerRadius? corners) + { + _corners = corners; + UpdateVisualState(); + } + private void UpdateVisualState() { _style = ButtonVariants.GetStyle(Variant, Size); @@ -187,7 +195,7 @@ private void UpdateVisualState() Height = _style.Height; Width = _style.Width; Padding = _style.Padding; - CornerRadius = new CornerRadius(_style.CornerRadius); + CornerRadius = _corners ?? new CornerRadius(_style.CornerRadius); BorderThickness = new Thickness(_style.Border.HasValue ? 1 : 0); if (_style.Border.HasValue) this.Token(BorderBrushProperty, _style.Border.Value); diff --git a/examples/Avalonia.Demo/Components/UI/ButtonGroup.cs b/examples/Avalonia.Demo/Components/UI/ButtonGroup.cs new file mode 100644 index 0000000..566c228 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/ButtonGroup.cs @@ -0,0 +1,64 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// Button group — buttons joined into one control: only the outer corners are rounded and +// neighbouring borders overlap, so Outline buttons share a single line between them. +// +// +// +// +// +public class ButtonGroup : Border +{ + public static readonly StyledProperty OrientationProperty = + AvaloniaProperty.Register(nameof(Orientation), Orientation.Horizontal); + + private readonly StackPanel _stack = new() { Orientation = Orientation.Horizontal }; + + static ButtonGroup() + { + OrientationProperty.Changed.AddClassHandler((g, _) => g.Arrange()); + } + + public Orientation Orientation + { + get => GetValue(OrientationProperty); + set => SetValue(OrientationProperty, value); + } + + [Content] + public Controls Items => _stack.Children; + + public ButtonGroup() + { + Child = _stack; + HorizontalAlignment = HorizontalAlignment.Left; + AutomationProperties.SetName(this, "Button group"); + _stack.Children.CollectionChanged += (_, _) => Arrange(); + } + + private void Arrange() + { + var vertical = Orientation == Orientation.Vertical; + _stack.Orientation = Orientation; + var radius = ShellTheme.RadiusMd; + var last = _stack.Children.Count - 1; + for (var i = 0; i <= last; i++) + { + var child = _stack.Children[i]; + var start = i == 0 ? radius : 0; + var end = i == last ? radius : 0; + // -1 so two 1px borders draw as one line. + child.Margin = i == 0 ? default : vertical ? new Thickness(0, -1, 0, 0) : new Thickness(-1, 0, 0, 0); + // Stacked buttons share the widest one's width. + child.HorizontalAlignment = vertical ? HorizontalAlignment.Stretch : HorizontalAlignment.Left; + if (child is Button button) + button.SetCorners(vertical ? new CornerRadius(start, start, end, end) : new CornerRadius(start, end, end, start)); + } + } +} diff --git a/examples/Avalonia.Demo/Components/UI/InputGroup.cs b/examples/Avalonia.Demo/Components/UI/InputGroup.cs new file mode 100644 index 0000000..4f1565c --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/InputGroup.cs @@ -0,0 +1,236 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Input with something before or after the text, inside the same border — h-10 rounded-md +// border border-input. The prefix and suffix are an icon, a short text, or any control. +// +// +// +// ...any control... +// +public class InputGroup : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty PlaceholderProperty = + AvaloniaProperty.Register(nameof(Placeholder)); + + public static readonly StyledProperty IsPasswordProperty = + AvaloniaProperty.Register(nameof(IsPassword)); + + public static readonly StyledProperty HasErrorProperty = + AvaloniaProperty.Register(nameof(HasError)); + + public static readonly StyledProperty PrefixIconProperty = + AvaloniaProperty.Register(nameof(PrefixIcon)); + + public static readonly StyledProperty SuffixIconProperty = + AvaloniaProperty.Register(nameof(SuffixIcon)); + + public static readonly StyledProperty PrefixTextProperty = + AvaloniaProperty.Register(nameof(PrefixText)); + + public static readonly StyledProperty SuffixTextProperty = + AvaloniaProperty.Register(nameof(SuffixText)); + + // Any control before the text; replaces PrefixIcon and PrefixText. + public static readonly StyledProperty PrefixProperty = + AvaloniaProperty.Register(nameof(Prefix)); + + // Any control after the text; replaces SuffixIcon and SuffixText. + public static readonly StyledProperty SuffixProperty = + AvaloniaProperty.Register(nameof(Suffix)); + + private readonly TextBox _textBox; + private readonly Border _prefix = new() { VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; + private readonly Border _suffix = new() { VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; + private bool _isFocused; + + static InputGroup() + { + TextProperty.Changed.AddClassHandler((g, e) => + { + if (g._textBox.Text != (string?)e.NewValue) g._textBox.Text = (string?)e.NewValue; + }); + PlaceholderProperty.Changed.AddClassHandler((g, e) => g._textBox.PlaceholderText = (string?)e.NewValue); + IsPasswordProperty.Changed.AddClassHandler((g, e) => g._textBox.PasswordChar = (bool)e.NewValue! ? '•' : default); + HasErrorProperty.Changed.AddClassHandler((g, _) => g.UpdateVisualState()); + IsEnabledProperty.Changed.AddClassHandler((g, _) => g.Opacity = g.IsEnabled ? 1.0 : 0.5); + PrefixIconProperty.Changed.AddClassHandler((g, _) => g.UpdateSlots()); + SuffixIconProperty.Changed.AddClassHandler((g, _) => g.UpdateSlots()); + PrefixTextProperty.Changed.AddClassHandler((g, _) => g.UpdateSlots()); + SuffixTextProperty.Changed.AddClassHandler((g, _) => g.UpdateSlots()); + PrefixProperty.Changed.AddClassHandler((g, _) => g.UpdateSlots()); + SuffixProperty.Changed.AddClassHandler((g, _) => g.UpdateSlots()); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string? Placeholder + { + get => GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public bool IsPassword + { + get => GetValue(IsPasswordProperty); + set => SetValue(IsPasswordProperty, value); + } + + public bool HasError + { + get => GetValue(HasErrorProperty); + set => SetValue(HasErrorProperty, value); + } + + public IconName PrefixIcon + { + get => GetValue(PrefixIconProperty); + set => SetValue(PrefixIconProperty, value); + } + + public IconName SuffixIcon + { + get => GetValue(SuffixIconProperty); + set => SetValue(SuffixIconProperty, value); + } + + public string? PrefixText + { + get => GetValue(PrefixTextProperty); + set => SetValue(PrefixTextProperty, value); + } + + public string? SuffixText + { + get => GetValue(SuffixTextProperty); + set => SetValue(SuffixTextProperty, value); + } + + public Control? Prefix + { + get => GetValue(PrefixProperty); + set => SetValue(PrefixProperty, value); + } + + public Control? Suffix + { + get => GetValue(SuffixProperty); + set => SetValue(SuffixProperty, value); + } + + public event EventHandler? TextChanged; + + // Raised on Enter. + public event EventHandler? Completed; + + public InputGroup() + { + _textBox = new TextBox + { + FontSize = 14, + VerticalAlignment = VerticalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center + }; + ShellPlatform.StripNativeChrome(_textBox); + _textBox.TextChanged += (_, e) => + { + Text = _textBox.Text; + TextChanged?.Invoke(this, e); + }; + _textBox.KeyDown += (_, e) => + { + if (e.Key == Key.Enter) Completed?.Invoke(this, EventArgs.Empty); + }; + _textBox.GotFocus += (_, _) => { _isFocused = true; UpdateVisualState(); }; + _textBox.LostFocus += (_, _) => { _isFocused = false; UpdateVisualState(); }; + + Grid.SetColumn(_textBox, 1); + Grid.SetColumn(_suffix, 2); + Child = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), + ColumnSpacing = 8, + Children = { _prefix, _textBox, _suffix } + }; + Height = 40; + Padding = new Thickness(12, 0); + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + Background = Brushes.Transparent; + Cursor = new Cursor(StandardCursorType.Ibeam); + UpdateVisualState(); + } + + // The glow color is a snapshot of a token, so it is repainted on theme changes. + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + ShellTheme.ThemeChanged += OnThemeChanged; + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + ShellTheme.ThemeChanged -= OnThemeChanged; + } + + private void OnThemeChanged(object? sender, EventArgs e) => UpdateVisualState(); + + // Clicking the padding focuses the text box too; a click on a control in the prefix or + // suffix (a button, say) is left to that control. + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (!e.Handled && !_textBox.IsFocused) _textBox.Focus(); + } + + public new bool Focus(NavigationMethod method = NavigationMethod.Unspecified, KeyModifiers keyModifiers = KeyModifiers.None) + => _textBox.Focus(method, keyModifiers); + + private void UpdateSlots() + { + _prefix.Child = Prefix ?? Slot(PrefixIcon, PrefixText); + _prefix.IsVisible = _prefix.Child != null; + _suffix.Child = Suffix ?? Slot(SuffixIcon, SuffixText); + _suffix.IsVisible = _suffix.Child != null; + } + + // text-muted-foreground: a 16px icon, a text-sm label, or both. + private static Control? Slot(IconName icon, string? text) + { + if (icon == IconName.None && string.IsNullOrEmpty(text)) return null; + var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6, VerticalAlignment = VerticalAlignment.Center }; + if (icon != IconName.None) + row.Children.Add(new Icon { Kind = icon, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }); + if (!string.IsNullOrEmpty(text)) + { + var label = new TextBlock { Text = text, FontSize = 14, VerticalAlignment = VerticalAlignment.Center }; + label.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + row.Children.Add(label); + } + return row; + } + + private void UpdateVisualState() + { + var stroke = HasError ? ShellToken.Destructive : _isFocused ? ShellToken.Ring : ShellToken.Input; + this.Token(BorderBrushProperty, stroke); + BoxShadow = _isFocused || HasError + ? new BoxShadows(new BoxShadow { Blur = 6, Color = ShellTheme.Get(HasError ? ShellToken.Destructive : ShellToken.Ring, 0.35) }) + : default; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Menubar.cs b/examples/Avalonia.Demo/Components/UI/Menubar.cs new file mode 100644 index 0000000..3fbf3ff --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Menubar.cs @@ -0,0 +1,358 @@ +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; + +// Menu bar — h-10 rounded-md border bg-background p-1: a row of menus, each opening a panel of +// items under its title. Clicking outside, picking an item or pressing Escape closes it. The +// titles are tab stops: Enter, Space or Down opens a menu, and Left / Right move between them. +// +// +// +// +// +// +// ... +// +public class Menubar : Border +{ + private readonly StackPanel _row = new() { Orientation = Orientation.Horizontal, Spacing = 4, VerticalAlignment = VerticalAlignment.Center }; + + // The MenubarMenu children. + [Content] + public Controls Menus => _row.Children; + + public Menubar() + { + Child = _row; + Height = 40; + Padding = new Thickness(3); + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + HorizontalAlignment = HorizontalAlignment.Left; + this.Token(BackgroundProperty, ShellToken.Background).Token(BorderBrushProperty, ShellToken.Border); + _row.Children.CollectionChanged += (_, _) => + { + foreach (var menu in _row.Children.OfType()) menu.Bar = this; + }; + } + + // Moves keyboard focus to the menu before or after `from`, wrapping around. + internal void FocusNeighbour(MenubarMenu from, int direction) + { + var menus = _row.Children.OfType().Where(m => m.IsEffectivelyEnabled).ToList(); + var index = menus.IndexOf(from); + if (index < 0 || menus.Count < 2) return; + menus[(index + direction + menus.Count) % menus.Count].Focus(NavigationMethod.Directional); + } +} + +// One menu of a Menubar: the title in the bar (rounded-sm px-3 text-sm font-medium) and the +// panel that opens under it (min-w-[12rem] rounded-md border bg-popover p-1 shadow-md). +public class MenubarMenu : Border, IShellFocusable +{ + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title)); + + private readonly TextBlock _label; + private readonly Border _trigger; + private readonly StackPanel _stack = new(); + private readonly ShellAnchoredPopup _popup; + + static MenubarMenu() + { + TitleProperty.Changed.AddClassHandler((m, _) => m.Update()); + IsEnabledProperty.Changed.AddClassHandler((m, _) => m.Opacity = m.IsEnabled ? 1.0 : 0.5); + } + + public string? Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + // The MenubarItem and MenubarSeparator children. + [Content] + public Controls Items => _stack.Children; + + public bool IsOpen => _popup.IsOpen; + + public event EventHandler? Opened; + public event EventHandler? Closed; + + internal Menubar? Bar { get; set; } + + public MenubarMenu() + { + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center }; + _label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + _trigger = new Border + { + Child = _label, + Height = 32, + Padding = new Thickness(12, 0), + CornerRadius = new CornerRadius(ShellTheme.RadiusSm), + // Transparent, not null, so the whole title takes the pointer. + Background = Brushes.Transparent, + Cursor = new Cursor(StandardCursorType.Hand) + }; + + var panel = new Border + { + Child = _stack, + Padding = new Thickness(4), + MinWidth = 192, + 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(); + Closed?.Invoke(this, EventArgs.Empty); + }; + + Child = new Panel { Children = { _trigger, _popup.Popup } }; + VerticalAlignment = VerticalAlignment.Center; + ShellFocus.Ring(this, _trigger); + Update(); + } + + public void SetOpen(bool open) + { + if (open == _popup.IsOpen) return; + if (!open) + { + _popup.Close(); + return; + } + foreach (var item in _stack.Children.OfType()) item.Menu = this; + _popup.Open(); + Update(); + Opened?.Invoke(this, EventArgs.Empty); + } + + 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 || !IsEffectivelyEnabled) return; + SetOpen(!IsOpen); + 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 or Key.Down: + SetOpen(true); + // Into the panel, so Tab and Enter work on the items. + _stack.Children.OfType().FirstOrDefault(i => i.IsEffectivelyEnabled)?.Focus(NavigationMethod.Directional); + break; + case Key.Left: + Bar?.FocusNeighbour(this, -1); + break; + case Key.Right: + Bar?.FocusNeighbour(this, 1); + break; + default: + return; + } + e.Handled = true; + } + + private void Update() + { + _label.Text = Title ?? string.Empty; + AutomationProperties.SetName(this, Title ?? string.Empty); + if (IsOpen || (IsPointerOver && IsEffectivelyEnabled)) _trigger.Token(BackgroundProperty, ShellToken.Accent); + else + { + _trigger.ClearToken(BackgroundProperty); + _trigger.Background = Brushes.Transparent; + } + } +} + +// Menu row — rounded-sm px-2 py-1.5 text-sm, hover:bg-accent, optional icon and shortcut hint. +// A tab stop that activates with Enter or Space; Up and Down move between the rows. +public class MenubarItem : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + 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)); + + public static readonly StyledProperty IsDestructiveProperty = + AvaloniaProperty.Register(nameof(IsDestructive)); + + private readonly Icon _icon; + private readonly TextBlock _label; + private readonly TextBlock _shortcut; + + static MenubarItem() + { + TextProperty.Changed.AddClassHandler((i, _) => i.Update()); + IconProperty.Changed.AddClassHandler((i, _) => i.Update()); + ShortcutProperty.Changed.AddClassHandler((i, _) => i.Update()); + IsDestructiveProperty.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 IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public string? Shortcut + { + get => GetValue(ShortcutProperty); + set => SetValue(ShortcutProperty, value); + } + + public bool IsDestructive + { + get => GetValue(IsDestructiveProperty); + set => SetValue(IsDestructiveProperty, value); + } + + public event EventHandler? Clicked; + + internal MenubarMenu? Menu { get; set; } + + public MenubarItem() + { + _icon = new Icon { Size = 16, IsVisible = false, VerticalAlignment = VerticalAlignment.Center }; + _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center }; + _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 = 32; + Padding = new Thickness(8, 0); + 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 items = rows.Children.OfType().Where(i => i.IsEffectivelyEnabled).ToList(); + var index = items.IndexOf(this); + if (index < 0) return; + items[(index + (e.Key == Key.Down ? 1 : -1) + items.Count) % items.Count].Focus(NavigationMethod.Directional); + break; + default: + return; + } + e.Handled = true; + } + + // Picks the item in code, as a click or Enter/Space would. + public void Press() + { + if (!IsEffectivelyEnabled) return; + Menu?.Close(); + Clicked?.Invoke(this, EventArgs.Empty); + } + + private void Update() + { + var color = IsDestructive ? ShellToken.Destructive : ShellToken.PopoverForeground; + _icon.Kind = Icon; + _icon.IsVisible = Icon != IconName.None; + _icon.Token = color; + _label.Text = Text ?? string.Empty; + _label.Token(TextBlock.ForegroundProperty, color); + _shortcut.Text = Shortcut ?? string.Empty; + _shortcut.IsVisible = !string.IsNullOrEmpty(Shortcut); + } +} + +// Divider between groups of items — -mx-1 my-1 h-px bg-border. +public class MenubarSeparator : Border +{ + public MenubarSeparator() + { + Height = 1; + Margin = new Thickness(-4, 4); + this.Token(BackgroundProperty, ShellToken.Border); + } +} diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml index fecb549..4521e2f 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml +++ b/examples/Avalonia.Demo/MainWindow.axaml @@ -583,6 +583,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs index 68d6c77..986d217 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml.cs +++ b/examples/Avalonia.Demo/MainWindow.axaml.cs @@ -68,6 +68,13 @@ public MainWindow() SourceCard.Clicked += (_, _) => DisplayChoice.Text = $"Link card: {SourceCard.Title}."; FileTree.SelectedValueChanged += (_, value) => DisplayChoice.Text = $"Tree: {value}."; + foreach (var button in DemoButtonGroup.Items.OfType()) + button.Clicked += (_, _) => GroupChoice.Text = $"Button group: {button.Text}."; + DemoInputSearch.TextChanged += (_, _) => GroupChoice.Text = $"Input group: {DemoInputSearch.Text}."; + DemoInputUrl.TextChanged += (_, _) => GroupChoice.Text = $"Input group: https://{DemoInputUrl.Text}.com."; + foreach (var menu in DemoMenubar.Menus.OfType()) + foreach (var item in menu.Items.OfType()) + item.Clicked += (_, _) => GroupChoice.Text = $"Menubar: {menu.Title} > {item.Text}."; DemoNavbar.SelectedValueChanged += (_, value) => LayoutChoice.Text = $"Navbar: {value}."; DemoSidebar.SelectedValueChanged += (_, value) => { diff --git a/examples/MAUI.Demo/Components/UI/Button.cs b/examples/MAUI.Demo/Components/UI/Button.cs index d524bf6..19fb54a 100644 --- a/examples/MAUI.Demo/Components/UI/Button.cs +++ b/examples/MAUI.Demo/Components/UI/Button.cs @@ -39,6 +39,7 @@ public partial class Button : ContentView, IShellFocusable private readonly Label _label; private ButtonStyle _style = new(); private bool _hovered; + private CornerRadius? _corners; public ButtonVariant Variant { @@ -121,6 +122,13 @@ public Button() private bool CanClick => IsEnabled && !IsLoading; + // Set by ButtonGroup so joined buttons only round their outer corners; null restores the default. + internal void SetCorners(CornerRadius? corners) + { + _corners = corners; + UpdateVisualState(); + } + private void Press() { if (!CanClick) return; @@ -146,7 +154,7 @@ private void UpdateVisualState() _border.HeightRequest = _style.Height; _border.WidthRequest = _style.Width; _border.Padding = _style.Padding; - _border.StrokeShape = new RoundRectangle { CornerRadius = _style.CornerRadius }; + _border.StrokeShape = new RoundRectangle { CornerRadius = _corners ?? new CornerRadius(_style.CornerRadius) }; _border.StrokeThickness = _style.Border.HasValue ? 1 : 0; if (_style.Border.HasValue) _border.Token(Border.StrokeProperty, _style.Border.Value); diff --git a/examples/MAUI.Demo/Components/UI/ButtonGroup.cs b/examples/MAUI.Demo/Components/UI/ButtonGroup.cs new file mode 100644 index 0000000..40ff94e --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/ButtonGroup.cs @@ -0,0 +1,56 @@ +namespace MAUI.Demo.Components.UI; + +// Button group — buttons joined into one control: only the outer corners are rounded and +// neighbouring borders overlap, so Outline buttons share a single line between them. +// +// +// +// +// +[ContentProperty(nameof(Items))] +public partial class ButtonGroup : ContentView +{ + public static readonly BindableProperty OrientationProperty = + BindableProperty.Create(nameof(Orientation), typeof(StackOrientation), typeof(ButtonGroup), StackOrientation.Horizontal, + propertyChanged: (b, o, n) => ((ButtonGroup)b).Arrange()); + + public StackOrientation Orientation + { + get => (StackOrientation)GetValue(OrientationProperty); + set => SetValue(OrientationProperty, value); + } + + public IList Items => _stack.Children; + + private readonly StackLayout _stack; + + public ButtonGroup() + { + _stack = new StackLayout { Orientation = StackOrientation.Horizontal, Spacing = 0 }; + _stack.ChildAdded += (_, _) => Arrange(); + _stack.ChildRemoved += (_, _) => Arrange(); + Content = _stack; + HorizontalOptions = LayoutOptions.Start; + SemanticProperties.SetDescription(this, "Button group"); + } + + private void Arrange() + { + var vertical = Orientation == StackOrientation.Vertical; + _stack.Orientation = Orientation; + var radius = ShellTheme.RadiusMd; + var last = _stack.Children.Count - 1; + for (var i = 0; i <= last; i++) + { + if (_stack.Children[i] is not View child) continue; + var start = i == 0 ? radius : 0; + var end = i == last ? radius : 0; + // -1 so two 1px borders draw as one line. + child.Margin = i == 0 ? new Thickness(0) : vertical ? new Thickness(0, -1, 0, 0) : new Thickness(-1, 0, 0, 0); + // Stacked buttons share the widest one's width. + child.HorizontalOptions = vertical ? LayoutOptions.Fill : LayoutOptions.Start; + if (child is Button button) + button.SetCorners(vertical ? new CornerRadius(start, start, end, end) : new CornerRadius(start, end, start, end)); + } + } +} diff --git a/examples/MAUI.Demo/Components/UI/InputGroup.cs b/examples/MAUI.Demo/Components/UI/InputGroup.cs new file mode 100644 index 0000000..df854c9 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/InputGroup.cs @@ -0,0 +1,248 @@ +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.Components.UI; + +// Input with something before or after the text, inside the same border — h-10 rounded-md +// border border-input. The prefix and suffix are an icon, a short text, or any view. +// +// +// +// ...any view... +// +public partial class InputGroup : ContentView +{ + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(InputGroup), string.Empty, BindingMode.TwoWay, + propertyChanged: (b, o, n) => + { + var group = (InputGroup)b; + if (group._entry.Text != n as string) group._entry.Text = n as string ?? string.Empty; + }); + + public static readonly BindableProperty PlaceholderProperty = + BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(InputGroup), string.Empty, + propertyChanged: (b, o, n) => ((InputGroup)b)._entry.Placeholder = n as string ?? string.Empty); + + public static readonly BindableProperty IsPasswordProperty = + BindableProperty.Create(nameof(IsPassword), typeof(bool), typeof(InputGroup), false, + propertyChanged: (b, o, n) => ((InputGroup)b)._entry.IsPassword = (bool)n); + + public static readonly BindableProperty HasErrorProperty = + BindableProperty.Create(nameof(HasError), typeof(bool), typeof(InputGroup), false, + propertyChanged: (b, o, n) => ((InputGroup)b).UpdateVisualState()); + + public static readonly BindableProperty KeyboardProperty = + BindableProperty.Create(nameof(Keyboard), typeof(Keyboard), typeof(InputGroup), Keyboard.Default, + propertyChanged: (b, o, n) => ((InputGroup)b)._entry.Keyboard = (Keyboard)n); + + public static readonly BindableProperty PrefixIconProperty = + BindableProperty.Create(nameof(PrefixIcon), typeof(IconName), typeof(InputGroup), IconName.None, + propertyChanged: (b, o, n) => ((InputGroup)b).UpdateSlots()); + + public static readonly BindableProperty SuffixIconProperty = + BindableProperty.Create(nameof(SuffixIcon), typeof(IconName), typeof(InputGroup), IconName.None, + propertyChanged: (b, o, n) => ((InputGroup)b).UpdateSlots()); + + public static readonly BindableProperty PrefixTextProperty = + BindableProperty.Create(nameof(PrefixText), typeof(string), typeof(InputGroup), string.Empty, + propertyChanged: (b, o, n) => ((InputGroup)b).UpdateSlots()); + + public static readonly BindableProperty SuffixTextProperty = + BindableProperty.Create(nameof(SuffixText), typeof(string), typeof(InputGroup), string.Empty, + propertyChanged: (b, o, n) => ((InputGroup)b).UpdateSlots()); + + // Any view before the text; replaces PrefixIcon and PrefixText. + public static readonly BindableProperty PrefixProperty = + BindableProperty.Create(nameof(Prefix), typeof(View), typeof(InputGroup), null, + propertyChanged: (b, o, n) => ((InputGroup)b).UpdateSlots()); + + // Any view after the text; replaces SuffixIcon and SuffixText. + public static readonly BindableProperty SuffixProperty = + BindableProperty.Create(nameof(Suffix), typeof(View), typeof(InputGroup), null, + propertyChanged: (b, o, n) => ((InputGroup)b).UpdateSlots()); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string Placeholder + { + get => (string)GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public bool IsPassword + { + get => (bool)GetValue(IsPasswordProperty); + set => SetValue(IsPasswordProperty, value); + } + + public bool HasError + { + get => (bool)GetValue(HasErrorProperty); + set => SetValue(HasErrorProperty, value); + } + + public Keyboard Keyboard + { + get => (Keyboard)GetValue(KeyboardProperty); + set => SetValue(KeyboardProperty, value); + } + + public IconName PrefixIcon + { + get => (IconName)GetValue(PrefixIconProperty); + set => SetValue(PrefixIconProperty, value); + } + + public IconName SuffixIcon + { + get => (IconName)GetValue(SuffixIconProperty); + set => SetValue(SuffixIconProperty, value); + } + + public string PrefixText + { + get => (string)GetValue(PrefixTextProperty); + set => SetValue(PrefixTextProperty, value); + } + + public string SuffixText + { + get => (string)GetValue(SuffixTextProperty); + set => SetValue(SuffixTextProperty, value); + } + + public View? Prefix + { + get => (View?)GetValue(PrefixProperty); + set => SetValue(PrefixProperty, value); + } + + public View? Suffix + { + get => (View?)GetValue(SuffixProperty); + set => SetValue(SuffixProperty, value); + } + + public event EventHandler? TextChanged; + public event EventHandler? Completed; + + private readonly Entry _entry; + private readonly Border _border; + private readonly ContentView _prefix; + private readonly ContentView _suffix; + private bool _isFocused; + + public InputGroup() + { + _entry = new Entry + { + FontSize = 14, + 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 += (_, e) => + { + Text = e.NewTextValue; + TextChanged?.Invoke(this, e); + }; + _entry.Completed += (_, e) => Completed?.Invoke(this, e); + _entry.Focused += (_, _) => { _isFocused = true; UpdateVisualState(); }; + _entry.Unfocused += (_, _) => { _isFocused = false; UpdateVisualState(); }; + + _prefix = new ContentView { VerticalOptions = LayoutOptions.Center, IsVisible = false }; + _suffix = new ContentView { VerticalOptions = LayoutOptions.Center, IsVisible = false }; + + var row = new Grid + { + ColumnSpacing = 8, + ColumnDefinitions = + { + new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) + } + }; + row.Add(_prefix, 0, 0); + row.Add(_entry, 1, 0); + row.Add(_suffix, 2, 0); + + _border = new Border + { + Content = row, + Padding = new Thickness(12, 0), + HeightRequest = 40, + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, + BackgroundColor = Colors.Transparent + }; + // Tapping the padding, the prefix or the suffix focuses the entry too. + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => _entry.Focus(); + _border.GestureRecognizers.Add(tap); + + Content = _border; + UpdateVisualState(); + } + + public new void Focus() => _entry.Focus(); + public new void Unfocus() => _entry.Unfocus(); + + protected override void OnPropertyChanged(string? propertyName = null) + { + base.OnPropertyChanged(propertyName); + if (propertyName == IsEnabledProperty.PropertyName) + Opacity = IsEnabled ? 1.0 : 0.5; + } + + private void UpdateSlots() + { + _prefix.Content = Prefix ?? Slot(PrefixIcon, PrefixText); + _prefix.IsVisible = _prefix.Content != null; + _suffix.Content = Suffix ?? Slot(SuffixIcon, SuffixText); + _suffix.IsVisible = _suffix.Content != null; + } + + // text-muted-foreground: a 16px icon, a text-sm label, or both. + private static View? Slot(IconName icon, string? text) + { + if (icon == IconName.None && string.IsNullOrEmpty(text)) return null; + var row = new HorizontalStackLayout { Spacing = 6, VerticalOptions = LayoutOptions.Center }; + if (icon != IconName.None) + row.Children.Add(new Icon { Name = icon, Size = 16, Token = ShellToken.MutedForeground, VerticalOptions = LayoutOptions.Center }); + if (!string.IsNullOrEmpty(text)) + { + var label = new Label { Text = text, FontSize = 14, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center }; + label.Token(Label.TextColorProperty, ShellToken.MutedForeground); + row.Children.Add(label); + } + return row; + } + + private void UpdateVisualState() + { + var stroke = HasError ? ShellToken.Destructive : _isFocused ? ShellToken.Ring : ShellToken.Input; + _border.Token(Border.StrokeProperty, stroke); + if (_isFocused || HasError) + { + _border.Shadow = new Shadow + { + Brush = new SolidColorBrush(ShellTheme.Get(HasError ? ShellToken.Destructive : ShellToken.Ring)), + Offset = new Point(0, 0), + Radius = 6, + Opacity = 0.35f + }; + } + else + { + _border.ClearValue(VisualElement.ShadowProperty); + } + } +} diff --git a/examples/MAUI.Demo/Components/UI/Menubar.cs b/examples/MAUI.Demo/Components/UI/Menubar.cs new file mode 100644 index 0000000..e2437e1 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Menubar.cs @@ -0,0 +1,286 @@ +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.Components.UI; + +// Menu bar — h-10 rounded-md border bg-background p-1: a row of menus, each opening a panel of +// items under its title. Clicking outside, picking an item or pressing Escape closes it. +// +// +// +// +// +// +// ... +// +[ContentProperty(nameof(Menus))] +public partial class Menubar : ContentView +{ + public IList Menus => _row.Children; + + private readonly HorizontalStackLayout _row; + + public Menubar() + { + _row = new HorizontalStackLayout { Spacing = 4, VerticalOptions = LayoutOptions.Center }; + var bar = new Border + { + Content = _row, + HeightRequest = 40, + Padding = new Thickness(4), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd } + }; + bar.Token(VisualElement.BackgroundColorProperty, ShellToken.Background); + bar.Token(Border.StrokeProperty, ShellToken.Border); + Content = bar; + HorizontalOptions = LayoutOptions.Start; + } +} + +// One menu of a Menubar: the title in the bar (rounded-sm px-3 text-sm font-medium) and the +// panel that opens under it (min-w-[12rem] rounded-md border bg-popover p-1 shadow-md). +[ContentProperty(nameof(Items))] +public partial class MenubarMenu : ContentView, IShellPopup +{ + public static readonly BindableProperty TitleProperty = + BindableProperty.Create(nameof(Title), typeof(string), typeof(MenubarMenu), string.Empty, + propertyChanged: (b, o, n) => ((MenubarMenu)b).Update()); + + public string Title + { + get => (string)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public IList Items => _stack.Children; + + public bool IsOpen { get; private set; } + + public event EventHandler? Opened; + public event EventHandler? Closed; + + private readonly Border _trigger; + private readonly Label _label; + private readonly VerticalStackLayout _stack; + private readonly Border _panel; + private ShellPopupHandle? _handle; + private bool _hovered; + + public MenubarMenu() + { + _label = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, LineBreakMode = LineBreakMode.NoWrap }; + _label.Token(Label.TextColorProperty, ShellToken.Foreground); + _trigger = new Border + { + Content = _label, + HeightRequest = 30, + Padding = new Thickness(12, 0), + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm }, + 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 += (_, _) => { if (!IsEnabled) return; ShellFocus.FocusPressed(this); SetOpen(!IsOpen); }; + _trigger.GestureRecognizers.Add(tap); + ShellFocus.MakeFocusable(this, () => { if (IsEnabled) SetOpen(!IsOpen); }); + + _stack = new VerticalStackLayout { Spacing = 0 }; + _panel = new Border + { + Content = _stack, + Padding = new Thickness(4), + MinimumWidthRequest = 192, + 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; + VerticalOptions = LayoutOptions.Center; + Update(); + } + + public void SetOpen(bool open) + { + if (IsOpen == open) return; + IsOpen = open; + Update(); + if (open) + { + // The panel floats outside this view, so items can't find their menu by walking up. + foreach (var item in _stack.Children.OfType()) item.Menu = this; + ShellPopups.Opened(this); + _handle = ShellPortal.ShowPopup(_trigger, _panel, new ShellPopupOptions { Owner = this, OnDismiss = Close }); + Opened?.Invoke(this, EventArgs.Empty); + } + else + { + ShellPopups.Closed(this); + var handle = _handle; + _handle = null; + if (handle != null) _ = handle.CloseAsync(); + Closed?.Invoke(this, EventArgs.Empty); + } + } + + 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; + 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; + } + } +} + +// Menu row — rounded-sm px-2 py-1.5 text-sm, hover:bg-accent, optional icon and shortcut hint. +public partial class MenubarItem : ContentView +{ + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(MenubarItem), string.Empty, + propertyChanged: (b, o, n) => ((MenubarItem)b).Update()); + + public static readonly BindableProperty IconProperty = + BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(MenubarItem), IconName.None, + propertyChanged: (b, o, n) => ((MenubarItem)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(MenubarItem), string.Empty, + propertyChanged: (b, o, n) => ((MenubarItem)b).Update()); + + public static readonly BindableProperty IsDestructiveProperty = + BindableProperty.Create(nameof(IsDestructive), typeof(bool), typeof(MenubarItem), false, + propertyChanged: (b, o, n) => ((MenubarItem)b).Update()); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public IconName Icon + { + get => (IconName)GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public string Shortcut + { + get => (string)GetValue(ShortcutProperty); + set => SetValue(ShortcutProperty, value); + } + + public bool IsDestructive + { + get => (bool)GetValue(IsDestructiveProperty); + set => SetValue(IsDestructiveProperty, value); + } + + public event EventHandler? Clicked; + + internal MenubarMenu? Menu { get; set; } + + private readonly Border _row; + private readonly Icon _icon; + private readonly Label _label; + private readonly Label _shortcut; + + public MenubarItem() + { + _icon = new Icon { Size = 16, IsVisible = false, VerticalOptions = LayoutOptions.Center }; + _label = new Label { FontSize = 14, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center }; + _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 = 32, + Padding = new Thickness(8, 0), + 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; + Menu?.Close(); + Clicked?.Invoke(this, EventArgs.Empty); + } + + private void Update() + { + var color = IsDestructive ? ShellToken.Destructive : ShellToken.PopoverForeground; + _icon.Name = Icon; + _icon.IsVisible = Icon != IconName.None; + _icon.Token = color; + _label.Text = Text ?? string.Empty; + _label.Token(Label.TextColorProperty, color); + _shortcut.Text = Shortcut ?? string.Empty; + _shortcut.IsVisible = !string.IsNullOrEmpty(Shortcut); + } +} + +// Divider between groups of items — -mx-1 my-1 h-px bg-border. +public partial class MenubarSeparator : ContentView +{ + public MenubarSeparator() + { + var line = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent }; + line.Token(BoxView.ColorProperty, ShellToken.Border); + Margin = new Thickness(-4, 4); + Content = line; + } +} diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml index 423366a..88c600d 100644 --- a/examples/MAUI.Demo/MainPage.xaml +++ b/examples/MAUI.Demo/MainPage.xaml @@ -98,6 +98,25 @@ + +