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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -173,6 +192,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -462,6 +493,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/MAUI.Demo/MainPage.xaml.cs b/examples/MAUI.Demo/MainPage.xaml.cs
index 9d374ae..b3e0268 100644
--- a/examples/MAUI.Demo/MainPage.xaml.cs
+++ b/examples/MAUI.Demo/MainPage.xaml.cs
@@ -142,6 +142,8 @@ private void OnTableRowTapped(object? sender, EventArgs e)
Report($"Table: {row.Children.OfType().FirstOrDefault()?.Text}");
}
+ private void OnMenubarItem(object? sender, EventArgs e) => Report($"Menubar: {(sender as MenubarItem)?.Text}");
+ private void OnInputGroupChanged(object? sender, TextChangedEventArgs e) => Report($"Input group: {e.NewTextValue}");
private void OnNavbarChanged(object? sender, string? value) => Report($"Navbar: {value}");
private void OnSidebarChanged(object? sender, string? value)
{
diff --git a/scripts/sync-templates.py b/scripts/sync-templates.py
index b9a8a16..e20d360 100644
--- a/scripts/sync-templates.py
+++ b/scripts/sync-templates.py
@@ -90,6 +90,12 @@
["navbar", "navigation", "header", "menu"]),
"Sidebar": ("sidebar", "Sidebar", "App sidebar with groups and items that collapses to an icon rail", "Navigation",
["sidebar", "navigation", "menu", "layout"]),
+ "ButtonGroup": ("button-group", "Button Group", "Buttons joined into one control", "Form",
+ ["button", "group", "segmented", "toolbar"]),
+ "InputGroup": ("input-group", "Input Group", "Input with an icon, text or view before or after it", "Form",
+ ["input", "group", "prefix", "suffix", "addon"]),
+ "Menubar": ("menubar", "Menubar", "Row of menus that open under their titles", "Navigation",
+ ["menubar", "menu", "navigation"]),
"DateRangePicker": ("date-range-picker", "Date Range Picker", "Picks a start and an end date from one calendar", "Form",
["date", "range", "picker", "calendar"]),
"FileUpload": ("file-upload", "File Upload", "Drop zone that opens the file picker and lists the picked files", "Form",
@@ -101,6 +107,7 @@
"HoverCard": ["hover-card-trigger", "hover-card-content"],
"DatePicker": ["calendar"],
"DateRangePicker": ["calendar"],
+ "ButtonGroup": ["button"],
"TagInput": ["wrap-layout"],
"MultiSelect": ["wrap-layout"],
}
diff --git a/src/ShellUI.Native.Templates/ComponentRegistry.cs b/src/ShellUI.Native.Templates/ComponentRegistry.cs
index 2d715c6..39438d1 100644
--- a/src/ShellUI.Native.Templates/ComponentRegistry.cs
+++ b/src/ShellUI.Native.Templates/ComponentRegistry.cs
@@ -126,7 +126,10 @@ public static class ComponentRegistry
{ "navbar", (NavbarTemplate.Metadata, NavbarTemplate.Contents) },
{ "sidebar", (SidebarTemplate.Metadata, SidebarTemplate.Contents) },
{ "date-range-picker", (DateRangePickerTemplate.Metadata, DateRangePickerTemplate.Contents) },
- { "file-upload", (FileUploadTemplate.Metadata, FileUploadTemplate.Contents) }
+ { "file-upload", (FileUploadTemplate.Metadata, FileUploadTemplate.Contents) },
+ { "button-group", (ButtonGroupTemplate.Metadata, ButtonGroupTemplate.Contents) },
+ { "input-group", (InputGroupTemplate.Metadata, InputGroupTemplate.Contents) },
+ { "menubar", (MenubarTemplate.Metadata, MenubarTemplate.Contents) }
};
// Metadata-only view for callers that don't need the per-platform payload.
diff --git a/src/ShellUI.Native.Templates/Templates/ButtonGroupTemplate.cs b/src/ShellUI.Native.Templates/Templates/ButtonGroupTemplate.cs
new file mode 100644
index 0000000..b3d32b0
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/ButtonGroupTemplate.cs
@@ -0,0 +1,143 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class ButtonGroupTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "button-group",
+ DisplayName = "Button Group",
+ Description = "Buttons joined into one control",
+ Category = ComponentCategory.Form,
+ FilePath = "ButtonGroup.cs",
+ Dependencies = new List { "shell", "button" },
+ Tags = new List { "button", "group", "segmented", "toolbar" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"namespace YourProjectNamespace.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));
+ }
+ }
+}
+",
+ [NativePlatform.Avalonia] = @"using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Metadata;
+
+namespace YourProjectNamespace.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/src/ShellUI.Native.Templates/Templates/ButtonTemplate.cs b/src/ShellUI.Native.Templates/Templates/ButtonTemplate.cs
index 177301e..efb64e4 100644
--- a/src/ShellUI.Native.Templates/Templates/ButtonTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/ButtonTemplate.cs
@@ -60,6 +60,7 @@ public partial class Button : ContentView, IShellFocusable
private readonly Label _label;
private ButtonStyle _style = new();
private bool _hovered;
+ private CornerRadius? _corners;
public ButtonVariant Variant
{
@@ -142,6 +143,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;
@@ -167,7 +175,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);
@@ -272,6 +280,7 @@ public class Button : Border, IShellFocusable
private DispatcherTimer? _spinning;
private bool _hovered;
private bool _pressed;
+ private CornerRadius? _corners;
static Button()
{
@@ -408,6 +417,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);
@@ -415,7 +431,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/src/ShellUI.Native.Templates/Templates/InputGroupTemplate.cs b/src/ShellUI.Native.Templates/Templates/InputGroupTemplate.cs
new file mode 100644
index 0000000..5ea5214
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/InputGroupTemplate.cs
@@ -0,0 +1,507 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class InputGroupTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "input-group",
+ DisplayName = "Input Group",
+ Description = "Input with an icon, text or view before or after it",
+ Category = ComponentCategory.Form,
+ FilePath = "InputGroup.cs",
+ Dependencies = new List { "shell", "icon" },
+ Tags = new List { "input", "group", "prefix", "suffix", "addon" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.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);
+ }
+ }
+}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace YourProjectNamespace.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/src/ShellUI.Native.Templates/Templates/MenubarTemplate.cs b/src/ShellUI.Native.Templates/Templates/MenubarTemplate.cs
new file mode 100644
index 0000000..2187523
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/MenubarTemplate.cs
@@ -0,0 +1,667 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class MenubarTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "menubar",
+ DisplayName = "Menubar",
+ Description = "Row of menus that open under their titles",
+ Category = ComponentCategory.Navigation,
+ FilePath = "Menubar.cs",
+ Dependencies = new List { "shell", "icon" },
+ Tags = new List { "menubar", "menu", "navigation" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.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;
+ }
+}
+",
+ [NativePlatform.Avalonia] = @"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 YourProjectNamespace.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/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs
index b21606e..f541e4f 100644
--- a/tests/ShellUI.Native.Tests/TemplateContentTests.cs
+++ b/tests/ShellUI.Native.Tests/TemplateContentTests.cs
@@ -119,6 +119,9 @@ public static IEnumerable