diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index b351040..63a5c9b 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -112,6 +112,9 @@ Same names, properties and variants as MAUI, except: | Carousel | Swipe, arrows, dots | Drag with the mouse or touch; the arrows are tab stops and Left / Right move while one has focus; with `AspectRatio="0"` set `Height` | | Stepper | Shows the active step's view | Every step stays in the tree and only the active one is visible; reached steps are tab stops (Enter / Space) | | Pagination | Labels hidden on phones | `ShowLabels` defaults to true; every page and arrow is a tab stop (Enter / Space) | +| ButtonGroup | Children are the buttons | Children are collected in `Items` | +| InputGroup | `Keyboard` picks the on-screen keyboard | No `Keyboard`; a click on a control in the prefix or suffix is left to that control | +| Menubar | Tap a title to open its menu | Titles are tab stops: Enter / Space / Down open a menu and Left / Right move between titles; in a menu Up / Down move and Enter picks; children are collected in `Menus` and `Items` | | Resizable | Drag a divider | Dividers are also tab stops: the arrow keys move them 16px; the pointer shows a resize cursor | | Navbar | Links and the menu button react to taps | They are also tab stops (Enter / Space); children are collected in `Items` | | Sidebar | Width animates on collapse | Same, with items as tab stops; children are collected in `Groups` and `Items` | @@ -190,6 +193,29 @@ shellui-native add button --- +### Button Group +Buttons joined into one control: only the outer corners are rounded, and neighbouring borders +overlap so `Outline` buttons share one line between them. Each button keeps its own `Clicked`. + +```bash +shellui-native add button-group +``` + +**Properties:** `Orientation` (Horizontal, Vertical) · its children are the buttons + +```xml + + + + + +``` + +It needs the `Button` that ships with it. If `button` is already in your project from an older +CLI version, run `shellui-native update button` first. + +--- + ### Toggle Two-state button that stays pressed — transparent (or outlined) when off, `Accent` when on. @@ -261,6 +287,30 @@ shellui-native add input --- +### Input Group +An input with something before or after the text, inside the same border: an icon, a short +text, or any view. Same height, focus ring and error state as `Input`. + +```bash +shellui-native add input-group +``` + +**Properties:** `Text` (two-way), `Placeholder`, `IsPassword`, `HasError`, `Keyboard` (MAUI), +`PrefixIcon`, `SuffixIcon`, `PrefixText`, `SuffixText`, `Prefix`, `Suffix` (a view; replaces the +icon and text on that side) · **Events:** `TextChanged`, `Completed` · **Methods:** `Focus()` + +```xml + + + + + + + +``` + +--- + ### Input OTP One-time-code input: a row of 40px slots over one hidden text field, so paste, autofill and the platform keyboard all work. The active slot shows the ring color and a blinking caret. @@ -746,6 +796,34 @@ shellui-native add accordion ## Navigation Components +### Menubar +A row of menus, like a desktop app's File / Edit / View. Each menu opens a panel of items under +its title; clicking outside, picking an item or pressing Escape closes it, and opening one menu +closes the other. + +```bash +shellui-native add menubar +``` + +**MenubarMenu:** `Title`, `IsOpen` (read-only) · **Events:** `Opened`, `Closed` · **Methods:** +`SetOpen(bool)`, `Close()` + +**MenubarItem:** `Text`, `Icon`, `Shortcut` (a hint at the end of the row; display only), +`IsDestructive`, `IsEnabled` · **Events:** `Clicked` · **MenubarSeparator:** a divider + +```xml + + + + + + + ... + +``` + +--- + ### Navbar Top bar (56px, border below): a brand, links and actions at the end. Narrower than `CollapseBelow`, the links move into a menu that a button opens under the bar; picking a link diff --git a/docs/COMPONENTS_ROADMAP.md b/docs/COMPONENTS_ROADMAP.md index b76b872..2e50722 100644 --- a/docs/COMPONENTS_ROADMAP.md +++ b/docs/COMPONENTS_ROADMAP.md @@ -4,7 +4,7 @@ Prioritized list of components to create for ShellUI Native, aligned with [Shell Last revised: **2026-10-11** (second prerelease, `0.1.0-alpha.2`). -**Status at a glance:** P0–P6 ✅ done · P7 done except chart · `0.1.0-alpha.2`: 63 families, 94 CLI targets, each for MAUI and Avalonia. +**Status at a glance:** P0–P6 ✅ done · P7 done except chart · `0.1.0-alpha.2`: 63 families, 94 CLI targets, each for MAUI and Avalonia · since then: button-group, input-group, menubar. --- @@ -293,5 +293,8 @@ P7 items marked ✅ above — 58 families, 89 CLI targets, MAUI only. resizable, navbar, sidebar, date-range-picker and file-upload on both platforms — 63 families, 94 CLI targets. From here on a component lands on both platforms together. -**Backlog:** chart (P7); and the ShellUI Blazor targets not yet ported (button-group, -input-group, command / command-palette, menubar, navigation-menu, data-table, …). +**After `0.1.0-alpha.2`, from ShellUI's Blazor set** (both platforms): button-group ✅, +input-group ✅ and menubar ✅ (2026-10-11; 66 families, 97 CLI targets). + +**Backlog:** chart (P7); and the ShellUI Blazor targets not yet ported (command / +command-palette, navigation-menu, data-table, …). diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index a500c04..dc903a9 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -18,7 +18,8 @@ into it, not directly into `main`. ``` main ← Phase 1 (PRs #1–#5), release 0.1.0-alpha.1 (PR #6), Phase 2 Avalonia (PRs #7–#13), layout, navigation and pickers on both platforms (PR #14) merged - └─ chore/release-v0.1.0-alpha.2 ← second prerelease, the first with Avalonia (active) + release 0.1.0-alpha.2 (PR #15), CI action bumps (PR #16) merged + └─ feat/button-group-input-group-menubar ← first batch from ShellUI's Blazor set (active) └─ feat/winui ← Phase 3 (conditional) ``` @@ -35,8 +36,8 @@ main ← Phase 1 (PRs #1–#5), release 0.1.0-alpha.1 (PR #6), Phase 2 Avaloni | Branch | Base | Status | Purpose | |--------|------|--------|---------| -| `main` | — | Phase 1, release `0.1.0-alpha.1`, Phase 2 and PR #14 merged | 97 registry entries (94 CLI targets, 63 families), each with MAUI and Avalonia content | -| `chore/release-v0.1.0-alpha.2` | `main` | **Active** | Second prerelease: the first with Avalonia, plus five new components | +| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2` | 97 registry entries (94 CLI targets, 63 families), each with MAUI and Avalonia content | +| `feat/button-group-input-group-menubar` | `main` | **Active** | button-group, input-group and menubar for MAUI and Avalonia (100 registry entries, 97 CLI targets, 66 families) | --- @@ -434,7 +435,7 @@ After merge: tag `v0.1.0-alpha.1` on `main` and push the tag. --- -## Release `0.1.0-alpha.2` — `chore/release-v0.1.0-alpha.2` (**active**) +## Release `0.1.0-alpha.2` — `chore/release-v0.1.0-alpha.2` (**released**; tag `v0.1.0-alpha.2`) Second NuGet prerelease of `ShellUI.Native.CLI`: the first with Avalonia templates, plus resizable, navbar, sidebar, date-range-picker and file-upload on both platforms. @@ -449,7 +450,7 @@ resizable, navbar, sidebar, date-range-picker and file-upload on both platforms. target), each building with 0 warnings; `update calendar` fixes a project that has the alpha.1 Calendar -After merge: tag `v0.1.0-alpha.2` on `main` and push the tag. +Tagged `v0.1.0-alpha.2` on `main` 2026-10-11; published to NuGet by the Release workflow. --- @@ -519,6 +520,10 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase. (`feat/p6-layout-navigation`): resizable, navbar and sidebar, which finish P6, then date-range-picker (Calendar gains `RangeStart` / `RangeEnd`) and file-upload from P7; 97 registry entries. A release branch follows +- [x] After the release, the first batch from ShellUI's Blazor set + (`feat/button-group-input-group-menubar`): button-group (Button gains an internal corner + override, so an older `button` needs `shellui-native update button`), input-group and + menubar (one template: Menubar, MenubarMenu, MenubarItem, MenubarSeparator) - [ ] Replace the generated `Icon.cs` with the `ShellIcons.Maui` / `ShellIcons.Avalonia` packages once they are on NuGet. Their names already match (`IconName`, `Icon`, `Kind` on Avalonia), so components change little: token tinting binds the package's `Color` (MAUI) or `Foreground` 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 @@ + +