diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md
index 63a5c9b..332a4f7 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) |
+| Command | Enter picks the highlighted item (the first match) | Up / Down move the highlight and Enter picks it; `Value`, `Text` and the other strings are nullable; children are collected in `Groups` and `Items` |
+| CommandPalette | Open it from code | Also `Hotkey`, a key gesture that toggles it anywhere in the window (Ctrl+K by default; `Hotkey="{x:Null}"` for none) |
+| NavigationMenu | Tap an item to open its panel | Items are tab stops: Enter / Space / Down open a panel and Left / Right move between items; in a panel Up / Down move and Enter picks; children are collected in `Items` and `Links` |
| 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` |
@@ -796,6 +799,78 @@ shellui-native add accordion
## Navigation Components
+### Navigation Menu
+A row of top-level links for a site or app header. An item with links inside shows a chevron
+and opens a panel of them, each with a title, an optional description and an optional icon. An
+item without links is a plain link.
+
+```bash
+shellui-native add navigation-menu
+```
+
+**NavigationMenuItem:** `Title`, `IsOpen` (read-only), `IsEnabled` · **Events:** `Clicked` (an
+item without links) · **Methods:** `SetOpen(bool)`, `Close()`
+
+**NavigationMenuLink:** `Title`, `Description`, `Icon`, `IsEnabled` · **Events:** `Clicked`
+
+```xml
+
+
+
+
+
+
+
+```
+
+---
+
+### Command
+A search field over a list of grouped items. Typing filters the list by each item's `Text` and
+`Keywords`, and Enter picks the highlighted item. `CommandPalette`, in the same file, shows the
+list in a dialog near the top of the page.
+
+```bash
+shellui-native add command
+```
+
+**Properties (Command):** `Placeholder`, `EmptyText` ("No results found."), `MaxListHeight`
+(300), `Query` (read-only) · **Events:** `ItemSelected` (the item) · **Methods:** `Focus()`,
+`Reset()`, `Items()`
+
+**CommandGroup:** `Heading` · **CommandItem:** `Text`, `Value`, `Icon`, `Shortcut` (a hint at
+the end of the row; display only), `Keywords`, `IsEnabled` · **Events:** `Clicked` ·
+**CommandSeparator:** a divider, hidden while the list is filtered
+
+```xml
+
+
+
+
+
+
+```
+
+**CommandPalette:** `Open` (two-way), `Placeholder`, `EmptyText`, `Command` (the list inside) ·
+**Events:** `ItemSelected`, `OpenChanged` · **Methods:** `SetOpen(bool)`. Picking an item,
+tapping the backdrop or pressing Escape closes it, and it opens with an empty search.
+
+```xml
+
+ ...
+
+```
+
+```csharp
+Palette.SetOpen(true);
+```
+
+On MAUI the name matches `Microsoft.Maui.Controls.Command` (the `ICommand` helper). In C# that
+imports both namespaces, qualify one of them or add an alias:
+`using UI = YourApp.Components.UI;`.
+
+---
+
### 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
diff --git a/docs/COMPONENTS_ROADMAP.md b/docs/COMPONENTS_ROADMAP.md
index 2e50722..6f0883c 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 · since then: button-group, input-group, menubar.
+**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, command, navigation-menu.
---
@@ -294,7 +294,8 @@ resizable, navbar, sidebar, date-range-picker and file-upload on both platforms
94 CLI targets. From here on a component lands on both platforms together.
**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).
+input-group ✅ and menubar ✅ (2026-10-11), then command ✅ (with a command palette) and
+navigation-menu ✅ (2026-10-12; 68 families, 99 CLI targets).
-**Backlog:** chart (P7); and the ShellUI Blazor targets not yet ported (command /
-command-palette, navigation-menu, data-table, …).
+**Backlog:** chart (P7); and the ShellUI Blazor targets not yet ported (data-table, chat,
+form, image-viewer, qr-code, …).
diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md
index dc903a9..37a074a 100644
--- a/docs/DEVELOPMENT_PLAN.md
+++ b/docs/DEVELOPMENT_PLAN.md
@@ -18,8 +18,9 @@ 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
- 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)
+ release 0.1.0-alpha.2 (PR #15), CI action bumps (PR #16), button-group,
+ input-group and menubar (PR #17) merged
+ └─ feat/command-navigation-menu ← second batch from ShellUI's Blazor set (active)
└─ feat/winui ← Phase 3 (conditional)
```
@@ -36,8 +37,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, 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) |
+| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PR #17 merged since | 97 registry entries (94 CLI targets, 63 families), each with MAUI and Avalonia content |
+| `feat/command-navigation-menu` | `main` | **Active** | command (with a command palette) and navigation-menu for MAUI and Avalonia (102 registry entries, 99 CLI targets, 68 families) |
---
@@ -524,6 +525,10 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase.
(`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)
+- [x] Second batch (`feat/command-navigation-menu`): command (one template: Command,
+ CommandGroup, CommandItem, CommandSeparator and CommandPalette, which hosts a Command in a
+ `ShellOverlayHost`; Avalonia adds a window-wide hotkey) and navigation-menu (NavigationMenu,
+ NavigationMenuItem, NavigationMenuLink)
- [ ] 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/Command.cs b/examples/Avalonia.Demo/Components/UI/Command.cs
new file mode 100644
index 0000000..9e4518d
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Command.cs
@@ -0,0 +1,560 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation.Easings;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Command menu — a search field over a list of grouped items (rounded-md border bg-popover).
+// Typing filters the list; Up / Down move the highlight and Enter picks it.
+//
+//
+//
+//
+//
+//
+// ...
+//
+// CommandPalette shows the same list in a dialog over the window.
+public class Command : Border
+{
+ public static readonly StyledProperty PlaceholderProperty =
+ AvaloniaProperty.Register(nameof(Placeholder), "Type a command or search...");
+
+ // Shown when nothing matches the search.
+ public static readonly StyledProperty EmptyTextProperty =
+ AvaloniaProperty.Register(nameof(EmptyText), "No results found.");
+
+ // The list scrolls past this height.
+ public static readonly StyledProperty MaxListHeightProperty =
+ AvaloniaProperty.Register(nameof(MaxListHeight), 300);
+
+ private readonly TextBox _textBox;
+ private readonly TextBlock _empty;
+ private readonly StackPanel _list = new();
+ private readonly ScrollViewer _scroll;
+ private CommandItem? _active;
+
+ static Command()
+ {
+ PlaceholderProperty.Changed.AddClassHandler((c, e) => c._textBox.PlaceholderText = (string?)e.NewValue);
+ EmptyTextProperty.Changed.AddClassHandler((c, e) => c._empty.Text = (string?)e.NewValue);
+ MaxListHeightProperty.Changed.AddClassHandler((c, e) => c._scroll.MaxHeight = (double)e.NewValue!);
+ }
+
+ public string? Placeholder
+ {
+ get => GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string? EmptyText
+ {
+ get => GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public double MaxListHeight
+ {
+ get => GetValue(MaxListHeightProperty);
+ set => SetValue(MaxListHeightProperty, value);
+ }
+
+ // CommandGroup, CommandItem and CommandSeparator children.
+ [Content]
+ public Controls Groups => _list.Children;
+
+ // The search text.
+ public string Query => _textBox.Text ?? string.Empty;
+
+ public event EventHandler? ItemSelected;
+
+ public Command()
+ {
+ _textBox = new TextBox
+ {
+ FontSize = 14,
+ PlaceholderText = Placeholder,
+ VerticalAlignment = VerticalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center
+ };
+ ShellPlatform.StripNativeChrome(_textBox);
+ _textBox.TextChanged += (_, _) => Filter();
+ // Tunnel: the text box would use Up / Down itself.
+ _textBox.AddHandler(KeyDownEvent, OnSearchKeyDown, RoutingStrategies.Tunnel);
+
+ Grid.SetColumn(_textBox, 1);
+ var search = new Grid
+ {
+ Height = 44,
+ Margin = new Thickness(12, 0),
+ ColumnDefinitions = new ColumnDefinitions("Auto,*"),
+ ColumnSpacing = 8,
+ Children = { new Icon { Kind = IconName.Search, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }, _textBox }
+ };
+ var line = new Border { Height = 1 };
+ line.Token(BackgroundProperty, ShellToken.Border);
+
+ _empty = new TextBlock { Text = EmptyText, FontSize = 14, TextAlignment = TextAlignment.Center, Margin = new Thickness(0, 24), IsVisible = false };
+ _empty.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ _scroll = new ScrollViewer
+ {
+ Content = new StackPanel { Margin = new Thickness(4), Children = { _empty, _list } },
+ MaxHeight = MaxListHeight,
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled
+ };
+
+ Child = new StackPanel { Children = { search, line, _scroll } };
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ ClipToBounds = true;
+ this.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+ _list.Children.CollectionChanged += (_, _) => Filter();
+ }
+
+ // The Command an item or group sits in.
+ internal static Command? Of(StyledElement? element)
+ {
+ for (var parent = element?.Parent; parent != null; parent = parent.Parent)
+ if (parent is Command command) return command;
+ return null;
+ }
+
+ // Used by CommandPalette, which draws the border itself.
+ internal void RemoveBorder()
+ {
+ BorderThickness = default;
+ CornerRadius = default;
+ this.ClearToken(BackgroundProperty);
+ Background = null;
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ Filter();
+ }
+
+ public new bool Focus(NavigationMethod method = NavigationMethod.Unspecified, KeyModifiers keyModifiers = KeyModifiers.None)
+ => _textBox.Focus(method, keyModifiers);
+
+ // Clears the search.
+ public void Reset() => _textBox.Text = string.Empty;
+
+ // Every item, in order, whether it sits in a group or directly in the list.
+ public IEnumerable Items()
+ {
+ foreach (var child in _list.Children)
+ {
+ if (child is CommandItem item) yield return item;
+ else if (child is CommandGroup group)
+ foreach (var grouped in group.Items.OfType()) yield return grouped;
+ }
+ }
+
+ internal void Select(CommandItem item)
+ {
+ if (!item.IsEffectivelyEnabled) return;
+ ItemSelected?.Invoke(this, item);
+ item.RaiseClicked();
+ }
+
+ internal void SetActive(CommandItem? item)
+ {
+ if (ReferenceEquals(_active, item)) return;
+ _active?.Apply(false);
+ _active = item;
+ _active?.Apply(true);
+ }
+
+ private void OnSearchKeyDown(object? sender, KeyEventArgs e)
+ {
+ switch (e.Key)
+ {
+ case Key.Down or Key.Up:
+ var items = Items().Where(i => i.IsVisible && i.IsEnabled).ToList();
+ if (items.Count == 0) break;
+ var index = _active is null ? -1 : items.IndexOf(_active);
+ var next = e.Key == Key.Down ? (index + 1) % items.Count : (index <= 0 ? items.Count : index) - 1;
+ SetActive(items[next]);
+ items[next].BringIntoView();
+ break;
+ case Key.Enter:
+ if (_active != null) Select(_active);
+ break;
+ default:
+ return;
+ }
+ e.Handled = true;
+ }
+
+ private void Filter()
+ {
+ var words = Query.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var any = false;
+ foreach (var item in Items())
+ {
+ item.IsVisible = words.All(item.Matches);
+ any |= item.IsVisible;
+ }
+ foreach (var child in _list.Children)
+ {
+ if (child is CommandGroup group) group.IsVisible = group.Items.OfType().Any(i => i.IsVisible);
+ // Dividers only make sense between groups, so they go while the list is filtered.
+ else if (child is CommandSeparator separator) separator.IsVisible = words.Length == 0;
+ }
+ _empty.IsVisible = !any;
+ SetActive(Items().FirstOrDefault(i => i.IsVisible && i.IsEnabled));
+ }
+}
+
+// A labelled section of a Command — the heading is px-2 py-1.5 text-xs font-medium muted.
+public class CommandGroup : Border
+{
+ public static readonly StyledProperty HeadingProperty =
+ AvaloniaProperty.Register(nameof(Heading));
+
+ private readonly TextBlock _heading;
+ private readonly StackPanel _items = new();
+
+ static CommandGroup()
+ {
+ HeadingProperty.Changed.AddClassHandler((g, _) => g.Update());
+ }
+
+ public string? Heading
+ {
+ get => GetValue(HeadingProperty);
+ set => SetValue(HeadingProperty, value);
+ }
+
+ [Content]
+ public Controls Items => _items.Children;
+
+ public CommandGroup()
+ {
+ _heading = new TextBlock { FontSize = 12, FontWeight = FontWeight.Medium, Margin = new Thickness(8, 6), IsVisible = false };
+ _heading.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ Child = new StackPanel { Children = { _heading, _items } };
+ }
+
+ private void Update()
+ {
+ _heading.Text = Heading ?? string.Empty;
+ _heading.IsVisible = !string.IsNullOrEmpty(Heading);
+ }
+}
+
+// One row of a Command — rounded-sm px-2 h-9 text-sm; the highlighted row is bg-accent.
+public class CommandItem : Border
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ // Identifies the item to your code.
+ public static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ // Keyboard hint shown at the end of the row (display only).
+ public static readonly StyledProperty ShortcutProperty =
+ AvaloniaProperty.Register(nameof(Shortcut));
+
+ // Extra words the search matches besides Text.
+ public static readonly StyledProperty KeywordsProperty =
+ AvaloniaProperty.Register(nameof(Keywords));
+
+ private readonly Icon _icon;
+ private readonly TextBlock _label;
+ private readonly TextBlock _shortcut;
+
+ static CommandItem()
+ {
+ TextProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IconProperty.Changed.AddClassHandler((i, _) => i.Update());
+ ShortcutProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Opacity = i.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public string? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public string? Shortcut
+ {
+ get => GetValue(ShortcutProperty);
+ set => SetValue(ShortcutProperty, value);
+ }
+
+ public string? Keywords
+ {
+ get => GetValue(KeywordsProperty);
+ set => SetValue(KeywordsProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ public CommandItem()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground, VerticalAlignment = VerticalAlignment.Center };
+ _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ _label.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground);
+ _shortcut = new TextBlock { FontSize = 12, VerticalAlignment = VerticalAlignment.Center, IsVisible = false };
+ _shortcut.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+
+ var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), ColumnSpacing = 8 };
+ Grid.SetColumn(_label, 1);
+ Grid.SetColumn(_shortcut, 2);
+ grid.Children.Add(_icon);
+ grid.Children.Add(_label);
+ grid.Children.Add(_shortcut);
+
+ Child = grid;
+ Height = 36;
+ Padding = new Thickness(8, 0);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm);
+ // Transparent, not null, so the whole row takes the pointer.
+ Background = Brushes.Transparent;
+ Cursor = new Cursor(StandardCursorType.Hand);
+ Update();
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ if (IsEffectivelyEnabled) Command.Of(this)?.SetActive(this);
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Press();
+ e.Handled = true;
+ }
+
+ // Picks the item in code, as a click or Enter would.
+ public void Press()
+ {
+ if (!IsEffectivelyEnabled) return;
+ if (Command.Of(this) is { } command) command.Select(this);
+ else RaiseClicked();
+ }
+
+ internal bool Matches(string word) =>
+ (Text ?? string.Empty).Contains(word, StringComparison.CurrentCultureIgnoreCase) ||
+ (Keywords ?? string.Empty).Contains(word, StringComparison.CurrentCultureIgnoreCase);
+
+ internal void Apply(bool active)
+ {
+ if (active) this.Token(BackgroundProperty, ShellToken.Accent);
+ else
+ {
+ this.ClearToken(BackgroundProperty);
+ Background = Brushes.Transparent;
+ }
+ }
+
+ internal void RaiseClicked() => Clicked?.Invoke(this, EventArgs.Empty);
+
+ private void Update()
+ {
+ _icon.Kind = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _label.Text = Text ?? string.Empty;
+ _shortcut.Text = Shortcut ?? string.Empty;
+ _shortcut.IsVisible = !string.IsNullOrEmpty(Shortcut);
+ AutomationProperties.SetName(this, Text ?? string.Empty);
+ }
+}
+
+// Divider between groups — -mx-1 my-1 h-px bg-border.
+public class CommandSeparator : Border
+{
+ public CommandSeparator()
+ {
+ Height = 1;
+ Margin = new Thickness(-4, 4);
+ this.Token(BackgroundProperty, ShellToken.Border);
+ }
+}
+
+// Command palette — a Command in a dialog near the top of the window (max-w-xl rounded-xl border
+// bg-popover shadow), over a dimmed backdrop. Hotkey (Ctrl+K) toggles it; picking an item,
+// clicking the backdrop or pressing Escape closes it.
+//
+// ...
+//
+// Open it from code with Palette.SetOpen(true).
+public class CommandPalette : ShellOverlayHost
+{
+ public static readonly StyledProperty PlaceholderProperty =
+ AvaloniaProperty.Register(nameof(Placeholder), "Type a command or search...");
+
+ public static readonly StyledProperty EmptyTextProperty =
+ AvaloniaProperty.Register(nameof(EmptyText), "No results found.");
+
+ // The key gesture that opens and closes the palette anywhere in the window; null for none.
+ public static readonly StyledProperty HotkeyProperty =
+ AvaloniaProperty.Register(nameof(Hotkey), new KeyGesture(Key.K, KeyModifiers.Control));
+
+ private readonly Command _command = new() { MaxListHeight = 360 };
+ private TopLevel? _window;
+
+ static CommandPalette()
+ {
+ PlaceholderProperty.Changed.AddClassHandler((p, e) => p._command.Placeholder = (string?)e.NewValue);
+ EmptyTextProperty.Changed.AddClassHandler((p, e) => p._command.EmptyText = (string?)e.NewValue);
+ }
+
+ public string? Placeholder
+ {
+ get => GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string? EmptyText
+ {
+ get => GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public KeyGesture? Hotkey
+ {
+ get => GetValue(HotkeyProperty);
+ set => SetValue(HotkeyProperty, value);
+ }
+
+ // The Command inside the dialog.
+ public Command Command => _command;
+
+ public event EventHandler? ItemSelected;
+
+ public CommandPalette()
+ {
+ _command.RemoveBorder();
+ _command.ItemSelected += (_, item) =>
+ {
+ SetOpen(false);
+ ItemSelected?.Invoke(this, item);
+ };
+ Items.Add(new CommandPaletteContent(this, _command));
+ // The groups declared inside the palette belong to its Command.
+ Items.CollectionChanged += (_, _) =>
+ {
+ foreach (var child in Items.Where(c => c is not IShellOverlayContent && !_command.Groups.Contains(c)).ToList())
+ _command.Groups.Add(child);
+ };
+ OpenChanged += (_, open) =>
+ {
+ if (open) _command.Reset();
+ };
+ }
+
+ protected override bool ClaimsChild(Control child) => child is not IShellOverlayContent;
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ _window = TopLevel.GetTopLevel(this);
+ _window?.AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel);
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ _window?.RemoveHandler(KeyDownEvent, OnWindowKeyDown);
+ _window = null;
+ }
+
+ private void OnWindowKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (Hotkey is not { } hotkey || !hotkey.Matches(e) || !IsEffectivelyEnabled) return;
+ SetOpen(!Open);
+ e.Handled = true;
+ }
+}
+
+// The backdrop and box of a CommandPalette.
+internal sealed class CommandPaletteContent : Border, IShellOverlayContent
+{
+ private readonly Border _backdrop;
+ private readonly Border _box;
+
+ public CommandPaletteContent(CommandPalette palette, Command command)
+ {
+ _backdrop = new Border();
+ _backdrop.Token(BackgroundProperty, ShellToken.Overlay);
+ _backdrop.PointerPressed += (_, e) =>
+ {
+ palette.SetOpen(false);
+ e.Handled = true;
+ };
+
+ _box = new Border
+ {
+ Child = command,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(12),
+ ClipToBounds = true,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Top,
+ BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 16, Blur = 32, Color = ShellTheme.Shadow(0.25) })
+ };
+ _box.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+ // Clicks on the box must not reach the backdrop behind it.
+ _box.PointerPressed += (_, e) => e.Handled = true;
+
+ Child = new Panel { Children = { _backdrop, _box } };
+ SizeChanged += (_, e) =>
+ {
+ _box.Width = Math.Max(0, Math.Min(576, e.NewSize.Width - 32));
+ // top-[15vh]
+ _box.Margin = new Thickness(0, Math.Round(e.NewSize.Height * 0.15), 0, 0);
+ };
+ }
+
+ public async Task AnimateAsync(bool open)
+ {
+ if (open)
+ {
+ ShellMotion.Set(_backdrop, 0, "scale(1)");
+ ShellMotion.Set(_box, 0, "scale(0.95)");
+ await Task.WhenAll(
+ ShellMotion.To(_backdrop, 1, "scale(1)", 150, new CubicEaseOut()),
+ ShellMotion.To(_box, 1, "scale(1)", 150, new CubicEaseOut()));
+ }
+ else
+ {
+ await Task.WhenAll(
+ ShellMotion.To(_backdrop, 0, "scale(1)", 120, new CubicEaseIn()),
+ ShellMotion.To(_box, 0, "scale(0.95)", 120, new CubicEaseIn()));
+ }
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/NavigationMenu.cs b/examples/Avalonia.Demo/Components/UI/NavigationMenu.cs
new file mode 100644
index 0000000..91bc8e7
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/NavigationMenu.cs
@@ -0,0 +1,353 @@
+using System;
+using System.Linq;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Navigation menu — a row of top-level links for a site or app header. An item with links
+// inside shows a chevron and opens a panel of them (title and description); an item without
+// is a plain link. Items are tab stops: Enter, Space or Down opens one, Left / Right move
+// between them, and Up / Down move through an open panel.
+//
+//
+//
+//
+//
+//
+//
+public class NavigationMenu : Border
+{
+ private readonly StackPanel _row = new() { Orientation = Orientation.Horizontal, Spacing = 4 };
+
+ // The NavigationMenuItem children.
+ [Content]
+ public Controls Items => _row.Children;
+
+ public NavigationMenu()
+ {
+ Child = _row;
+ HorizontalAlignment = HorizontalAlignment.Left;
+ _row.Children.CollectionChanged += (_, _) =>
+ {
+ foreach (var item in _row.Children.OfType()) item.Menu = this;
+ };
+ }
+
+ // Moves keyboard focus to the item before or after `from`, wrapping around.
+ internal void FocusNeighbour(NavigationMenuItem from, int direction)
+ {
+ var items = _row.Children.OfType().Where(i => i.IsEffectivelyEnabled).ToList();
+ var index = items.IndexOf(from);
+ if (index < 0 || items.Count < 2) return;
+ items[(index + direction + items.Count) % items.Count].Focus(NavigationMethod.Directional);
+ }
+}
+
+// A top-level entry — h-10 rounded-md px-4 text-sm font-medium, hover:bg-accent. With
+// NavigationMenuLink children it opens a w-56 panel (rounded-md border bg-popover p-1 shadow-lg).
+public class NavigationMenuItem : Border, IShellFocusable
+{
+ public static readonly StyledProperty TitleProperty =
+ AvaloniaProperty.Register(nameof(Title));
+
+ private readonly TextBlock _label;
+ private readonly Icon _chevron;
+ private readonly RotateTransform _turn = new();
+ private readonly Border _trigger;
+ private readonly StackPanel _stack = new();
+ private readonly ShellAnchoredPopup _popup;
+
+ static NavigationMenuItem()
+ {
+ TitleProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Opacity = i.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public string? Title
+ {
+ get => GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ // The NavigationMenuLink children.
+ [Content]
+ public Controls Links => _stack.Children;
+
+ public bool IsOpen => _popup.IsOpen;
+
+ // Raised when an item without links is pressed.
+ public event EventHandler? Clicked;
+
+ internal NavigationMenu? Menu { get; set; }
+
+ public NavigationMenuItem()
+ {
+ _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center };
+ _label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ _chevron = new Icon
+ {
+ Kind = IconName.ChevronDown,
+ Size = 12,
+ Token = ShellToken.Foreground,
+ VerticalAlignment = VerticalAlignment.Center,
+ RenderTransform = _turn,
+ RenderTransformOrigin = RelativePoint.Center,
+ IsVisible = false
+ };
+ _trigger = new Border
+ {
+ Child = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4, VerticalAlignment = VerticalAlignment.Center, Children = { _label, _chevron } },
+ Height = 40,
+ Padding = new Thickness(16, 0),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ // Transparent, not null, so the whole item takes the pointer.
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+
+ var panel = new Border
+ {
+ Child = _stack,
+ Padding = new Thickness(4),
+ Width = 224,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ BoxShadow = ShellPopups.PanelShadow()
+ };
+ panel.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+
+ _popup = new ShellAnchoredPopup(_trigger);
+ _popup.Popup.Child = panel;
+ _popup.Closed += (_, _) => Update();
+ _stack.Children.CollectionChanged += (_, _) => Update();
+
+ Child = new Panel { Children = { _trigger, _popup.Popup } };
+ ShellFocus.Ring(this, _trigger);
+ Update();
+ }
+
+ private bool HasLinks => _stack.Children.Count > 0;
+
+ // Opens the panel, or raises Clicked when there are no links, as a click or Enter would.
+ public void Press()
+ {
+ if (!IsEffectivelyEnabled) return;
+ if (HasLinks) SetOpen(!IsOpen);
+ else Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ public void SetOpen(bool open)
+ {
+ if (open == _popup.IsOpen || (open && !HasLinks)) return;
+ if (!open)
+ {
+ _popup.Close();
+ return;
+ }
+ foreach (var link in _stack.Children.OfType()) link.Owner = this;
+ _popup.Open();
+ Update();
+ }
+
+ public void Close() => SetOpen(false);
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ Update();
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ Update();
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Press();
+ e.Handled = true;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (!ReferenceEquals(e.Source, this)) return;
+ switch (e.Key)
+ {
+ case Key.Enter or Key.Space:
+ Press();
+ FocusFirstLink();
+ break;
+ case Key.Down:
+ SetOpen(true);
+ FocusFirstLink();
+ break;
+ case Key.Left:
+ Menu?.FocusNeighbour(this, -1);
+ break;
+ case Key.Right:
+ Menu?.FocusNeighbour(this, 1);
+ break;
+ default:
+ return;
+ }
+ e.Handled = true;
+ }
+
+ // Into the panel, so the arrow keys and Enter work on the links.
+ private void FocusFirstLink()
+ {
+ if (IsOpen) _stack.Children.OfType().FirstOrDefault(l => l.IsEffectivelyEnabled)?.Focus(NavigationMethod.Directional);
+ }
+
+ private void Update()
+ {
+ _label.Text = Title ?? string.Empty;
+ _chevron.IsVisible = HasLinks;
+ _turn.Angle = IsOpen ? 180 : 0;
+ AutomationProperties.SetName(this, Title ?? string.Empty);
+ if (IsOpen || (IsPointerOver && IsEffectivelyEnabled)) _trigger.Token(BackgroundProperty, ShellToken.Accent);
+ else
+ {
+ _trigger.ClearToken(BackgroundProperty);
+ _trigger.Background = Brushes.Transparent;
+ }
+ }
+}
+
+// A link in an item's panel — rounded-sm p-2, hover:bg-accent: a text-sm font-medium title over
+// an optional muted description, with an optional icon. A tab stop that activates with Enter or
+// Space; Up and Down move between the links.
+public class NavigationMenuLink : Border
+{
+ public static readonly StyledProperty TitleProperty =
+ AvaloniaProperty.Register(nameof(Title));
+
+ public static readonly StyledProperty DescriptionProperty =
+ AvaloniaProperty.Register(nameof(Description));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ private readonly Icon _icon;
+ private readonly TextBlock _title;
+ private readonly TextBlock _description;
+
+ static NavigationMenuLink()
+ {
+ TitleProperty.Changed.AddClassHandler((l, _) => l.Update());
+ DescriptionProperty.Changed.AddClassHandler((l, _) => l.Update());
+ IconProperty.Changed.AddClassHandler((l, _) => l.Update());
+ IsEnabledProperty.Changed.AddClassHandler((l, _) => l.Opacity = l.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public string? Title
+ {
+ get => GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string? Description
+ {
+ get => GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ internal NavigationMenuItem? Owner { get; set; }
+
+ public NavigationMenuLink()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground, VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 2, 0, 0) };
+ _title = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium };
+ _title.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground);
+ _description = new TextBlock { FontSize = 13, IsVisible = false, TextWrapping = TextWrapping.Wrap };
+ _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+
+ var text = new StackPanel { Spacing = 2, Children = { _title, _description } };
+ Grid.SetColumn(text, 1);
+ Child = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*"), ColumnSpacing = 8, Children = { _icon, text } };
+ Padding = new Thickness(8);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm);
+ Background = Brushes.Transparent;
+ Cursor = new Cursor(StandardCursorType.Hand);
+ ShellFocus.Ring(this, this);
+ Update();
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ if (IsEffectivelyEnabled) this.Token(BackgroundProperty, ShellToken.Accent);
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ this.ClearToken(BackgroundProperty);
+ Background = Brushes.Transparent;
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Press();
+ e.Handled = true;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ switch (e.Key)
+ {
+ case Key.Enter or Key.Space:
+ Press();
+ break;
+ case Key.Up or Key.Down:
+ if (Parent is not Panel rows) return;
+ var links = rows.Children.OfType().Where(l => l.IsEffectivelyEnabled).ToList();
+ var index = links.IndexOf(this);
+ if (index < 0) return;
+ links[(index + (e.Key == Key.Down ? 1 : -1) + links.Count) % links.Count].Focus(NavigationMethod.Directional);
+ break;
+ default:
+ return;
+ }
+ e.Handled = true;
+ }
+
+ // Picks the link in code, as a click or Enter/Space would.
+ public void Press()
+ {
+ if (!IsEffectivelyEnabled) return;
+ Owner?.Close();
+ Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void Update()
+ {
+ _icon.Kind = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _title.Text = Title ?? string.Empty;
+ _description.Text = Description ?? string.Empty;
+ _description.IsVisible = !string.IsNullOrEmpty(Description);
+ AutomationProperties.SetName(this, Title ?? string.Empty);
+ }
+}
diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml
index 4521e2f..8b4b689 100644
--- a/examples/Avalonia.Demo/MainWindow.axaml
+++ b/examples/Avalonia.Demo/MainWindow.axaml
@@ -631,6 +631,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs
index 986d217..1686f8f 100644
--- a/examples/Avalonia.Demo/MainWindow.axaml.cs
+++ b/examples/Avalonia.Demo/MainWindow.axaml.cs
@@ -75,6 +75,15 @@ public MainWindow()
foreach (var menu in DemoMenubar.Menus.OfType())
foreach (var item in menu.Items.OfType())
item.Clicked += (_, _) => GroupChoice.Text = $"Menubar: {menu.Title} > {item.Text}.";
+ foreach (var item in DemoNavigationMenu.Items.OfType())
+ {
+ item.Clicked += (_, _) => CommandChoice.Text = $"Navigation menu: {item.Title}.";
+ foreach (var link in item.Links.OfType())
+ link.Clicked += (_, _) => CommandChoice.Text = $"Navigation menu: {item.Title} > {link.Title}.";
+ }
+ DemoCommand.ItemSelected += (_, item) => CommandChoice.Text = $"Command: {item.Value}.";
+ DemoPalette.ItemSelected += (_, item) => CommandChoice.Text = $"Palette: {item.Value}.";
+ OpenPalette.Clicked += (_, _) => DemoPalette.SetOpen(true);
DemoNavbar.SelectedValueChanged += (_, value) => LayoutChoice.Text = $"Navbar: {value}.";
DemoSidebar.SelectedValueChanged += (_, value) =>
{
diff --git a/examples/MAUI.Demo/Components/UI/Command.cs b/examples/MAUI.Demo/Components/UI/Command.cs
new file mode 100644
index 0000000..6e590c6
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/Command.cs
@@ -0,0 +1,495 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Command menu — a search field over a list of grouped items (rounded-md border bg-popover).
+// Typing filters the list; Enter picks the highlighted item.
+//
+//
+//
+//
+//
+//
+// ...
+//
+// CommandPalette shows the same list in a dialog over the page.
+[ContentProperty(nameof(Groups))]
+public partial class Command : ContentView
+{
+ public static readonly BindableProperty PlaceholderProperty =
+ BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(Command), "Type a command or search...",
+ propertyChanged: (b, o, n) => ((Command)b)._entry.Placeholder = n as string ?? string.Empty);
+
+ // Shown when nothing matches the search.
+ public static readonly BindableProperty EmptyTextProperty =
+ BindableProperty.Create(nameof(EmptyText), typeof(string), typeof(Command), "No results found.",
+ propertyChanged: (b, o, n) => ((Command)b)._empty.Text = n as string ?? string.Empty);
+
+ // The list scrolls past this height.
+ public static readonly BindableProperty MaxListHeightProperty =
+ BindableProperty.Create(nameof(MaxListHeight), typeof(double), typeof(Command), 300d,
+ propertyChanged: (b, o, n) => ((Command)b)._scroll.MaximumHeightRequest = (double)n);
+
+ public string Placeholder
+ {
+ get => (string)GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string EmptyText
+ {
+ get => (string)GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public double MaxListHeight
+ {
+ get => (double)GetValue(MaxListHeightProperty);
+ set => SetValue(MaxListHeightProperty, value);
+ }
+
+ // CommandGroup, CommandItem and CommandSeparator children.
+ public IList Groups => _list.Children;
+
+ // The search text.
+ public string Query => _entry.Text ?? string.Empty;
+
+ public event EventHandler? ItemSelected;
+
+ private readonly Border _border;
+ private readonly Entry _entry;
+ private readonly Label _empty;
+ private readonly VerticalStackLayout _list;
+ private readonly ScrollView _scroll;
+ private CommandItem? _active;
+
+ public Command()
+ {
+ _entry = new Entry
+ {
+ FontSize = 14,
+ Placeholder = Placeholder,
+ BackgroundColor = Colors.Transparent,
+ VerticalOptions = LayoutOptions.Center,
+ HorizontalOptions = LayoutOptions.Fill,
+ ClearButtonVisibility = ClearButtonVisibility.Never
+ };
+ _entry.Token(Entry.TextColorProperty, ShellToken.Foreground);
+ _entry.Token(Entry.PlaceholderColorProperty, ShellToken.MutedForeground);
+ ShellPlatform.StripNativeChrome(_entry);
+ ShellFocus.Track(_entry);
+ _entry.TextChanged += (_, _) => Filter();
+ _entry.Completed += (_, _) => { if (_active != null) Select(_active); };
+
+ var search = new Grid
+ {
+ HeightRequest = 44,
+ Padding = new Thickness(12, 0),
+ ColumnSpacing = 8,
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star) }
+ };
+ search.Add(new Icon { Name = IconName.Search, Size = 16, Token = ShellToken.MutedForeground, VerticalOptions = LayoutOptions.Center }, 0, 0);
+ search.Add(_entry, 1, 0);
+ var line = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent };
+ line.Token(BoxView.ColorProperty, ShellToken.Border);
+
+ _empty = new Label { Text = EmptyText, FontSize = 14, HorizontalTextAlignment = TextAlignment.Center, Padding = new Thickness(0, 24), IsVisible = false };
+ _empty.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ _list = new VerticalStackLayout { Spacing = 0 };
+ _list.ChildAdded += (_, _) => Filter();
+ _scroll = new ScrollView
+ {
+ Content = new VerticalStackLayout { Padding = new Thickness(4), Children = { _empty, _list } },
+ MaximumHeightRequest = MaxListHeight,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Default
+ };
+
+ _border = new Border
+ {
+ Content = new VerticalStackLayout { Spacing = 0, Children = { search, line, _scroll } },
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }
+ };
+ _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover);
+ _border.Token(Border.StrokeProperty, ShellToken.Border);
+ Content = _border;
+ Loaded += (_, _) => Filter();
+ }
+
+ // The Command an item or group sits in.
+ internal static Command? Of(Element? element)
+ {
+ for (var parent = element?.Parent; parent != null; parent = parent.Parent)
+ if (parent is Command command) return command;
+ return null;
+ }
+
+ // Used by CommandPalette, which draws the border itself.
+ internal void RemoveBorder()
+ {
+ _border.StrokeThickness = 0;
+ _border.ClearValue(VisualElement.BackgroundColorProperty);
+ _border.BackgroundColor = Colors.Transparent;
+ }
+
+ public new void Focus() => _entry.Focus();
+
+ // Clears the search.
+ public void Reset() => _entry.Text = string.Empty;
+
+ // Every item, in order, whether it sits in a group or directly in the list.
+ public IEnumerable Items()
+ {
+ foreach (var child in _list.Children)
+ {
+ if (child is CommandItem item) yield return item;
+ else if (child is CommandGroup group)
+ foreach (var grouped in group.Items.OfType()) yield return grouped;
+ }
+ }
+
+ internal void Select(CommandItem item)
+ {
+ if (!item.IsEnabled) return;
+ ItemSelected?.Invoke(this, item);
+ item.RaiseClicked();
+ }
+
+ internal void SetActive(CommandItem? item)
+ {
+ if (ReferenceEquals(_active, item)) return;
+ _active?.Apply(false);
+ _active = item;
+ _active?.Apply(true);
+ }
+
+ private void Filter()
+ {
+ var words = Query.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var any = false;
+ foreach (var item in Items())
+ {
+ item.IsVisible = words.All(item.Matches);
+ any |= item.IsVisible;
+ }
+ foreach (var child in _list.Children)
+ {
+ if (child is CommandGroup group) group.IsVisible = group.Items.OfType().Any(i => i.IsVisible);
+ // Dividers only make sense between groups, so they go while the list is filtered.
+ else if (child is CommandSeparator separator) separator.IsVisible = words.Length == 0;
+ }
+ _empty.IsVisible = !any;
+ SetActive(Items().FirstOrDefault(i => i.IsVisible && i.IsEnabled));
+ }
+}
+
+// A labelled section of a Command — the heading is px-2 py-1.5 text-xs font-medium muted.
+[ContentProperty(nameof(Items))]
+public partial class CommandGroup : ContentView
+{
+ public static readonly BindableProperty HeadingProperty =
+ BindableProperty.Create(nameof(Heading), typeof(string), typeof(CommandGroup), string.Empty,
+ propertyChanged: (b, o, n) => ((CommandGroup)b).Update());
+
+ public string Heading
+ {
+ get => (string)GetValue(HeadingProperty);
+ set => SetValue(HeadingProperty, value);
+ }
+
+ public IList Items => _items.Children;
+
+ private readonly Label _heading;
+ private readonly VerticalStackLayout _items;
+
+ public CommandGroup()
+ {
+ _heading = new Label { FontSize = 12, FontAttributes = FontAttributes.Bold, Padding = new Thickness(8, 6), IsVisible = false };
+ _heading.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ _items = new VerticalStackLayout { Spacing = 0 };
+ Content = new VerticalStackLayout { Spacing = 0, Children = { _heading, _items } };
+ }
+
+ private void Update()
+ {
+ _heading.Text = Heading ?? string.Empty;
+ _heading.IsVisible = !string.IsNullOrEmpty(Heading);
+ }
+}
+
+// One row of a Command — rounded-sm px-2 h-9 text-sm; the highlighted row is bg-accent.
+public partial class CommandItem : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(CommandItem), string.Empty,
+ propertyChanged: (b, o, n) => ((CommandItem)b).Update());
+
+ // Identifies the item to your code.
+ public static readonly BindableProperty ValueProperty =
+ BindableProperty.Create(nameof(Value), typeof(string), typeof(CommandItem), null);
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(CommandItem), IconName.None,
+ propertyChanged: (b, o, n) => ((CommandItem)b).Update());
+
+ // Keyboard hint shown at the end of the row (display only).
+ public static readonly BindableProperty ShortcutProperty =
+ BindableProperty.Create(nameof(Shortcut), typeof(string), typeof(CommandItem), string.Empty,
+ propertyChanged: (b, o, n) => ((CommandItem)b).Update());
+
+ // Extra words the search matches besides Text.
+ public static readonly BindableProperty KeywordsProperty =
+ BindableProperty.Create(nameof(Keywords), typeof(string), typeof(CommandItem), string.Empty);
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public string? Value
+ {
+ get => (string?)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public string Shortcut
+ {
+ get => (string)GetValue(ShortcutProperty);
+ set => SetValue(ShortcutProperty, value);
+ }
+
+ public string Keywords
+ {
+ get => (string)GetValue(KeywordsProperty);
+ set => SetValue(KeywordsProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ private readonly Border _row;
+ private readonly Icon _icon;
+ private readonly Label _label;
+ private readonly Label _shortcut;
+
+ public CommandItem()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground, VerticalOptions = LayoutOptions.Center };
+ _label = new Label { FontSize = 14, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.TailTruncation };
+ _label.Token(Label.TextColorProperty, ShellToken.PopoverForeground);
+ _shortcut = new Label { FontSize = 12, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, IsVisible = false };
+ _shortcut.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ var grid = new Grid
+ {
+ ColumnSpacing = 8,
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto)
+ }
+ };
+ grid.Add(_icon, 0, 0);
+ grid.Add(_label, 1, 0);
+ grid.Add(_shortcut, 2, 0);
+
+ _row = new Border
+ {
+ Content = grid,
+ HeightRequest = 36,
+ Padding = new Thickness(8, 0),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { if (IsEnabled) Command.Of(this)?.SetActive(this); };
+ _row.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => Press();
+ _row.GestureRecognizers.Add(tap);
+
+ Content = _row;
+ Update();
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == nameof(IsEnabled)) Opacity = IsEnabled ? 1 : 0.5;
+ }
+
+ internal bool Matches(string word) =>
+ (Text ?? string.Empty).Contains(word, StringComparison.CurrentCultureIgnoreCase) ||
+ (Keywords ?? string.Empty).Contains(word, StringComparison.CurrentCultureIgnoreCase);
+
+ internal void Apply(bool active)
+ {
+ if (active) _row.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ else
+ {
+ _row.ClearValue(VisualElement.BackgroundColorProperty);
+ _row.BackgroundColor = Colors.Transparent;
+ }
+ }
+
+ internal void RaiseClicked() => Clicked?.Invoke(this, EventArgs.Empty);
+
+ private void Press()
+ {
+ if (!IsEnabled) return;
+ if (Command.Of(this) is { } command) command.Select(this);
+ else RaiseClicked();
+ }
+
+ private void Update()
+ {
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _label.Text = Text ?? string.Empty;
+ _shortcut.Text = Shortcut ?? string.Empty;
+ _shortcut.IsVisible = !string.IsNullOrEmpty(Shortcut);
+ SemanticProperties.SetDescription(this, Text ?? string.Empty);
+ }
+}
+
+// Divider between groups — -mx-1 my-1 h-px bg-border.
+public partial class CommandSeparator : ContentView
+{
+ public CommandSeparator()
+ {
+ var line = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent };
+ line.Token(BoxView.ColorProperty, ShellToken.Border);
+ Margin = new Thickness(-4, 4);
+ Content = line;
+ }
+}
+
+// Command palette — a Command in a dialog near the top of the page (max-w-xl rounded-xl border
+// bg-popover shadow), over a dimmed backdrop. Picking an item, tapping the backdrop or pressing
+// Escape closes it. Declare it where a Dialog would go and open it from code:
+//
+// ...
+//
+// Palette.SetOpen(true);
+[ContentProperty(nameof(Groups))]
+public partial class CommandPalette : ShellOverlayHost
+{
+ public static readonly BindableProperty PlaceholderProperty =
+ BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(CommandPalette), "Type a command or search...",
+ propertyChanged: (b, o, n) => ((CommandPalette)b)._command.Placeholder = n as string ?? string.Empty);
+
+ public static readonly BindableProperty EmptyTextProperty =
+ BindableProperty.Create(nameof(EmptyText), typeof(string), typeof(CommandPalette), "No results found.",
+ propertyChanged: (b, o, n) => ((CommandPalette)b)._command.EmptyText = n as string ?? string.Empty);
+
+ public string Placeholder
+ {
+ get => (string)GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string EmptyText
+ {
+ get => (string)GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public IList Groups => _command.Groups;
+
+ // The Command inside the dialog.
+ public Command Command => _command;
+
+ public event EventHandler? ItemSelected;
+
+ private readonly Command _command;
+
+ public CommandPalette()
+ {
+ _command = new Command { MaxListHeight = 360 };
+ _command.RemoveBorder();
+ _command.ItemSelected += (_, item) =>
+ {
+ SetOpen(false);
+ ItemSelected?.Invoke(this, item);
+ };
+ Children.Add(new CommandPaletteContent(this, _command));
+ OpenChanged += async (_, open) =>
+ {
+ if (!open) return;
+ _command.Reset();
+ // The field can take focus once the dialog is on screen.
+ await Task.Delay(120);
+ if (Open) _command.Focus();
+ };
+ }
+
+ protected override bool IsTrigger(Element child) => false;
+}
+
+// The backdrop and box of a CommandPalette.
+internal sealed partial class CommandPaletteContent : ContentView, IShellOverlayContent
+{
+ private readonly BoxView _backdrop;
+ private readonly Border _box;
+
+ public CommandPaletteContent(CommandPalette palette, Command command)
+ {
+ _backdrop = new BoxView { BackgroundColor = Colors.Transparent };
+ _backdrop.Token(BoxView.ColorProperty, ShellToken.Overlay);
+ var tapBackdrop = new TapGestureRecognizer();
+ tapBackdrop.Tapped += (_, _) => palette.SetOpen(false);
+ _backdrop.GestureRecognizers.Add(tapBackdrop);
+
+ _box = new Border
+ {
+ Content = command,
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = 12 },
+ VerticalOptions = LayoutOptions.Start,
+ HorizontalOptions = LayoutOptions.Center,
+ Shadow = new Shadow { Brush = new SolidColorBrush(Colors.Black), Offset = new Point(0, 16), Radius = 32, Opacity = 0.25f }
+ };
+ _box.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover);
+ _box.Token(Border.StrokeProperty, ShellToken.Border);
+ // Swallow taps on the box so they don't fall through to the backdrop.
+ _box.GestureRecognizers.Add(new TapGestureRecognizer());
+
+ var root = new Grid { Children = { _backdrop, _box } };
+ ShellPortal.EdgeToEdge(this, root);
+ root.SizeChanged += (_, _) =>
+ {
+ _box.WidthRequest = Math.Max(0, Math.Min(576, root.Width - 32));
+ // top-[15vh]
+ _box.Margin = new Thickness(0, Math.Round(root.Height * 0.15), 0, 0);
+ };
+ Content = root;
+ }
+
+ public async Task AnimateAsync(bool open)
+ {
+ _box.AbortAnimation("FadeTo");
+ if (open)
+ {
+ _backdrop.Opacity = 0;
+ _box.Opacity = 0;
+ _box.Scale = 0.95;
+ await Task.WhenAll(
+ _backdrop.FadeToAsync(1, 150, Easing.CubicOut),
+ _box.FadeToAsync(1, 150, Easing.CubicOut),
+ _box.ScaleToAsync(1, 150, Easing.CubicOut));
+ }
+ else
+ {
+ await Task.WhenAll(
+ _backdrop.FadeToAsync(0, 120, Easing.CubicIn),
+ _box.FadeToAsync(0, 120, Easing.CubicIn),
+ _box.ScaleToAsync(0.95, 120, Easing.CubicIn));
+ }
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/NavigationMenu.cs b/examples/MAUI.Demo/Components/UI/NavigationMenu.cs
new file mode 100644
index 0000000..797a9e6
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/NavigationMenu.cs
@@ -0,0 +1,260 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Navigation menu — a row of top-level links for a site or app header. An item with links
+// inside shows a chevron and opens a panel of them (title and description); an item without
+// is a plain link.
+//
+//
+//
+//
+//
+//
+//
+[ContentProperty(nameof(Items))]
+public partial class NavigationMenu : ContentView
+{
+ public IList Items => _row.Children;
+
+ private readonly HorizontalStackLayout _row;
+
+ public NavigationMenu()
+ {
+ _row = new HorizontalStackLayout { Spacing = 4 };
+ Content = _row;
+ HorizontalOptions = LayoutOptions.Start;
+ }
+}
+
+// A top-level entry — h-10 rounded-md px-4 text-sm font-medium, hover:bg-accent. With
+// NavigationMenuLink children it opens a w-56 panel (rounded-md border bg-popover p-1 shadow-lg).
+[ContentProperty(nameof(Links))]
+public partial class NavigationMenuItem : ContentView, IShellPopup
+{
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.Create(nameof(Title), typeof(string), typeof(NavigationMenuItem), string.Empty,
+ propertyChanged: (b, o, n) => ((NavigationMenuItem)b).Update());
+
+ public string Title
+ {
+ get => (string)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public IList Links => _stack.Children;
+
+ public bool IsOpen { get; private set; }
+
+ // Raised when an item without links is pressed.
+ public event EventHandler? Clicked;
+
+ private readonly Border _trigger;
+ private readonly Label _label;
+ private readonly Icon _chevron;
+ private readonly VerticalStackLayout _stack;
+ private readonly Border _panel;
+ private ShellPopupHandle? _handle;
+ private bool _hovered;
+
+ public NavigationMenuItem()
+ {
+ _label = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, LineBreakMode = LineBreakMode.NoWrap };
+ _label.Token(Label.TextColorProperty, ShellToken.Foreground);
+ _chevron = new Icon { Name = IconName.ChevronDown, Size = 12, Token = ShellToken.Foreground, VerticalOptions = LayoutOptions.Center, IsVisible = false };
+ _trigger = new Border
+ {
+ Content = new HorizontalStackLayout { Spacing = 4, VerticalOptions = LayoutOptions.Center, Children = { _label, _chevron } },
+ HeightRequest = 40,
+ Padding = new Thickness(16, 0),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { _hovered = true; Update(); };
+ pointer.PointerExited += (_, _) => { _hovered = false; Update(); };
+ _trigger.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); Press(); };
+ _trigger.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, Press);
+
+ _stack = new VerticalStackLayout { Spacing = 0 };
+ _stack.ChildAdded += (_, _) => Update();
+ _stack.ChildRemoved += (_, _) => Update();
+ _panel = new Border
+ {
+ Content = _stack,
+ Padding = new Thickness(4),
+ WidthRequest = 224,
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ Shadow = ShellPopups.PanelShadow()
+ };
+ _panel.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover);
+ _panel.Token(Border.StrokeProperty, ShellToken.Border);
+ // Taps on the panel padding must not fall through to the click-outside catcher.
+ _panel.GestureRecognizers.Add(new TapGestureRecognizer());
+
+ Content = _trigger;
+ Update();
+ }
+
+ private bool HasLinks => _stack.Children.Count > 0;
+
+ private void Press()
+ {
+ if (!IsEnabled) return;
+ if (HasLinks) SetOpen(!IsOpen);
+ else Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ public void SetOpen(bool open)
+ {
+ if (IsOpen == open || (open && !HasLinks)) return;
+ IsOpen = open;
+ Update();
+ _ = _chevron.RotateToAsync(open ? 180 : 0, 200, Easing.CubicOut);
+ if (open)
+ {
+ // The panel floats outside this view, so links can't find their item by walking up.
+ foreach (var link in _stack.Children.OfType()) link.Owner = this;
+ ShellPopups.Opened(this);
+ _handle = ShellPortal.ShowPopup(_trigger, _panel, new ShellPopupOptions { Owner = this, OnDismiss = Close });
+ }
+ else
+ {
+ ShellPopups.Closed(this);
+ var handle = _handle;
+ _handle = null;
+ if (handle != null) _ = handle.CloseAsync();
+ }
+ }
+
+ public void Close() => SetOpen(false);
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName)
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+
+ private void Update()
+ {
+ _label.Text = Title ?? string.Empty;
+ _chevron.IsVisible = HasLinks;
+ SemanticProperties.SetDescription(this, Title ?? string.Empty);
+ if (IsOpen || (_hovered && IsEnabled))
+ _trigger.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ else
+ {
+ _trigger.ClearValue(VisualElement.BackgroundColorProperty);
+ _trigger.BackgroundColor = Colors.Transparent;
+ }
+ }
+}
+
+// A link in an item's panel — rounded-sm p-2, hover:bg-accent: a text-sm font-medium title over
+// an optional muted description, with an optional icon.
+public partial class NavigationMenuLink : ContentView
+{
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.Create(nameof(Title), typeof(string), typeof(NavigationMenuLink), string.Empty,
+ propertyChanged: (b, o, n) => ((NavigationMenuLink)b).Update());
+
+ public static readonly BindableProperty DescriptionProperty =
+ BindableProperty.Create(nameof(Description), typeof(string), typeof(NavigationMenuLink), string.Empty,
+ propertyChanged: (b, o, n) => ((NavigationMenuLink)b).Update());
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(NavigationMenuLink), IconName.None,
+ propertyChanged: (b, o, n) => ((NavigationMenuLink)b).Update());
+
+ public string Title
+ {
+ get => (string)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string Description
+ {
+ get => (string)GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ internal NavigationMenuItem? Owner { get; set; }
+
+ private readonly Border _row;
+ private readonly Icon _icon;
+ private readonly Label _title;
+ private readonly Label _description;
+
+ public NavigationMenuLink()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground, VerticalOptions = LayoutOptions.Start, Margin = new Thickness(0, 2, 0, 0) };
+ _title = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold };
+ _title.Token(Label.TextColorProperty, ShellToken.PopoverForeground);
+ _description = new Label { FontSize = 13, IsVisible = false, LineBreakMode = LineBreakMode.WordWrap };
+ _description.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ var grid = new Grid
+ {
+ ColumnSpacing = 8,
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star) }
+ };
+ grid.Add(_icon, 0, 0);
+ grid.Add(new VerticalStackLayout { Spacing = 2, Children = { _title, _description } }, 1, 0);
+
+ _row = new Border
+ {
+ Content = grid,
+ Padding = new Thickness(8),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { if (IsEnabled) _row.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); };
+ pointer.PointerExited += (_, _) => { _row.ClearValue(VisualElement.BackgroundColorProperty); _row.BackgroundColor = Colors.Transparent; };
+ _row.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => Press();
+ _row.GestureRecognizers.Add(tap);
+
+ Content = _row;
+ Update();
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == nameof(IsEnabled)) Opacity = IsEnabled ? 1 : 0.5;
+ }
+
+ private void Press()
+ {
+ if (!IsEnabled) return;
+ Owner?.Close();
+ Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void Update()
+ {
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _title.Text = Title ?? string.Empty;
+ _description.Text = Description ?? string.Empty;
+ _description.IsVisible = !string.IsNullOrEmpty(Description);
+ SemanticProperties.SetDescription(this, Title ?? string.Empty);
+ }
+}
diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml
index 88c600d..14da721 100644
--- a/examples/MAUI.Demo/MainPage.xaml
+++ b/examples/MAUI.Demo/MainPage.xaml
@@ -493,6 +493,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/MAUI.Demo/MainPage.xaml.cs b/examples/MAUI.Demo/MainPage.xaml.cs
index b3e0268..8787c73 100644
--- a/examples/MAUI.Demo/MainPage.xaml.cs
+++ b/examples/MAUI.Demo/MainPage.xaml.cs
@@ -142,6 +142,10 @@ private void OnTableRowTapped(object? sender, EventArgs e)
Report($"Table: {row.Children.OfType().FirstOrDefault()?.Text}");
}
+ private void OnNavigationMenuLink(object? sender, EventArgs e) => Report($"Navigation menu: {(sender as NavigationMenuLink)?.Title}");
+ private void OnNavigationMenuItem(object? sender, EventArgs e) => Report($"Navigation menu: {(sender as NavigationMenuItem)?.Title}");
+ private void OnCommandSelected(object? sender, CommandItem item) => Report($"Command: {item.Value}");
+ private void OnOpenCommandPalette(object? sender, EventArgs e) => CommandPaletteDemo.SetOpen(true);
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}");
diff --git a/scripts/sync-templates.py b/scripts/sync-templates.py
index e20d360..6e7714f 100644
--- a/scripts/sync-templates.py
+++ b/scripts/sync-templates.py
@@ -96,6 +96,10 @@
["input", "group", "prefix", "suffix", "addon"]),
"Menubar": ("menubar", "Menubar", "Row of menus that open under their titles", "Navigation",
["menubar", "menu", "navigation"]),
+ "Command": ("command", "Command", "Searchable list of grouped commands, inline or as a command palette", "Navigation",
+ ["command", "palette", "search", "menu"]),
+ "NavigationMenu": ("navigation-menu", "Navigation Menu", "Row of top-level links that open panels of links", "Navigation",
+ ["navigation", "menu", "header", "links"]),
"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",
diff --git a/src/ShellUI.Native.Templates/ComponentRegistry.cs b/src/ShellUI.Native.Templates/ComponentRegistry.cs
index 39438d1..7d540c4 100644
--- a/src/ShellUI.Native.Templates/ComponentRegistry.cs
+++ b/src/ShellUI.Native.Templates/ComponentRegistry.cs
@@ -129,7 +129,9 @@ public static class ComponentRegistry
{ "file-upload", (FileUploadTemplate.Metadata, FileUploadTemplate.Contents) },
{ "button-group", (ButtonGroupTemplate.Metadata, ButtonGroupTemplate.Contents) },
{ "input-group", (InputGroupTemplate.Metadata, InputGroupTemplate.Contents) },
- { "menubar", (MenubarTemplate.Metadata, MenubarTemplate.Contents) }
+ { "menubar", (MenubarTemplate.Metadata, MenubarTemplate.Contents) },
+ { "command", (CommandTemplate.Metadata, CommandTemplate.Contents) },
+ { "navigation-menu", (NavigationMenuTemplate.Metadata, NavigationMenuTemplate.Contents) }
};
// Metadata-only view for callers that don't need the per-platform payload.
diff --git a/src/ShellUI.Native.Templates/Templates/CommandTemplate.cs b/src/ShellUI.Native.Templates/Templates/CommandTemplate.cs
new file mode 100644
index 0000000..fbc0103
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/CommandTemplate.cs
@@ -0,0 +1,1078 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class CommandTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "command",
+ DisplayName = "Command",
+ Description = "Searchable list of grouped commands, inline or as a command palette",
+ Category = ComponentCategory.Navigation,
+ FilePath = "Command.cs",
+ Dependencies = new List { "shell", "icon" },
+ Tags = new List { "command", "palette", "search", "menu" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Command menu — a search field over a list of grouped items (rounded-md border bg-popover).
+// Typing filters the list; Enter picks the highlighted item.
+//
+//
+//
+//
+//
+//
+// ...
+//
+// CommandPalette shows the same list in a dialog over the page.
+[ContentProperty(nameof(Groups))]
+public partial class Command : ContentView
+{
+ public static readonly BindableProperty PlaceholderProperty =
+ BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(Command), ""Type a command or search..."",
+ propertyChanged: (b, o, n) => ((Command)b)._entry.Placeholder = n as string ?? string.Empty);
+
+ // Shown when nothing matches the search.
+ public static readonly BindableProperty EmptyTextProperty =
+ BindableProperty.Create(nameof(EmptyText), typeof(string), typeof(Command), ""No results found."",
+ propertyChanged: (b, o, n) => ((Command)b)._empty.Text = n as string ?? string.Empty);
+
+ // The list scrolls past this height.
+ public static readonly BindableProperty MaxListHeightProperty =
+ BindableProperty.Create(nameof(MaxListHeight), typeof(double), typeof(Command), 300d,
+ propertyChanged: (b, o, n) => ((Command)b)._scroll.MaximumHeightRequest = (double)n);
+
+ public string Placeholder
+ {
+ get => (string)GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string EmptyText
+ {
+ get => (string)GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public double MaxListHeight
+ {
+ get => (double)GetValue(MaxListHeightProperty);
+ set => SetValue(MaxListHeightProperty, value);
+ }
+
+ // CommandGroup, CommandItem and CommandSeparator children.
+ public IList Groups => _list.Children;
+
+ // The search text.
+ public string Query => _entry.Text ?? string.Empty;
+
+ public event EventHandler? ItemSelected;
+
+ private readonly Border _border;
+ private readonly Entry _entry;
+ private readonly Label _empty;
+ private readonly VerticalStackLayout _list;
+ private readonly ScrollView _scroll;
+ private CommandItem? _active;
+
+ public Command()
+ {
+ _entry = new Entry
+ {
+ FontSize = 14,
+ Placeholder = Placeholder,
+ BackgroundColor = Colors.Transparent,
+ VerticalOptions = LayoutOptions.Center,
+ HorizontalOptions = LayoutOptions.Fill,
+ ClearButtonVisibility = ClearButtonVisibility.Never
+ };
+ _entry.Token(Entry.TextColorProperty, ShellToken.Foreground);
+ _entry.Token(Entry.PlaceholderColorProperty, ShellToken.MutedForeground);
+ ShellPlatform.StripNativeChrome(_entry);
+ ShellFocus.Track(_entry);
+ _entry.TextChanged += (_, _) => Filter();
+ _entry.Completed += (_, _) => { if (_active != null) Select(_active); };
+
+ var search = new Grid
+ {
+ HeightRequest = 44,
+ Padding = new Thickness(12, 0),
+ ColumnSpacing = 8,
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star) }
+ };
+ search.Add(new Icon { Name = IconName.Search, Size = 16, Token = ShellToken.MutedForeground, VerticalOptions = LayoutOptions.Center }, 0, 0);
+ search.Add(_entry, 1, 0);
+ var line = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent };
+ line.Token(BoxView.ColorProperty, ShellToken.Border);
+
+ _empty = new Label { Text = EmptyText, FontSize = 14, HorizontalTextAlignment = TextAlignment.Center, Padding = new Thickness(0, 24), IsVisible = false };
+ _empty.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ _list = new VerticalStackLayout { Spacing = 0 };
+ _list.ChildAdded += (_, _) => Filter();
+ _scroll = new ScrollView
+ {
+ Content = new VerticalStackLayout { Padding = new Thickness(4), Children = { _empty, _list } },
+ MaximumHeightRequest = MaxListHeight,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Default
+ };
+
+ _border = new Border
+ {
+ Content = new VerticalStackLayout { Spacing = 0, Children = { search, line, _scroll } },
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }
+ };
+ _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover);
+ _border.Token(Border.StrokeProperty, ShellToken.Border);
+ Content = _border;
+ Loaded += (_, _) => Filter();
+ }
+
+ // The Command an item or group sits in.
+ internal static Command? Of(Element? element)
+ {
+ for (var parent = element?.Parent; parent != null; parent = parent.Parent)
+ if (parent is Command command) return command;
+ return null;
+ }
+
+ // Used by CommandPalette, which draws the border itself.
+ internal void RemoveBorder()
+ {
+ _border.StrokeThickness = 0;
+ _border.ClearValue(VisualElement.BackgroundColorProperty);
+ _border.BackgroundColor = Colors.Transparent;
+ }
+
+ public new void Focus() => _entry.Focus();
+
+ // Clears the search.
+ public void Reset() => _entry.Text = string.Empty;
+
+ // Every item, in order, whether it sits in a group or directly in the list.
+ public IEnumerable Items()
+ {
+ foreach (var child in _list.Children)
+ {
+ if (child is CommandItem item) yield return item;
+ else if (child is CommandGroup group)
+ foreach (var grouped in group.Items.OfType()) yield return grouped;
+ }
+ }
+
+ internal void Select(CommandItem item)
+ {
+ if (!item.IsEnabled) return;
+ ItemSelected?.Invoke(this, item);
+ item.RaiseClicked();
+ }
+
+ internal void SetActive(CommandItem? item)
+ {
+ if (ReferenceEquals(_active, item)) return;
+ _active?.Apply(false);
+ _active = item;
+ _active?.Apply(true);
+ }
+
+ private void Filter()
+ {
+ var words = Query.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var any = false;
+ foreach (var item in Items())
+ {
+ item.IsVisible = words.All(item.Matches);
+ any |= item.IsVisible;
+ }
+ foreach (var child in _list.Children)
+ {
+ if (child is CommandGroup group) group.IsVisible = group.Items.OfType().Any(i => i.IsVisible);
+ // Dividers only make sense between groups, so they go while the list is filtered.
+ else if (child is CommandSeparator separator) separator.IsVisible = words.Length == 0;
+ }
+ _empty.IsVisible = !any;
+ SetActive(Items().FirstOrDefault(i => i.IsVisible && i.IsEnabled));
+ }
+}
+
+// A labelled section of a Command — the heading is px-2 py-1.5 text-xs font-medium muted.
+[ContentProperty(nameof(Items))]
+public partial class CommandGroup : ContentView
+{
+ public static readonly BindableProperty HeadingProperty =
+ BindableProperty.Create(nameof(Heading), typeof(string), typeof(CommandGroup), string.Empty,
+ propertyChanged: (b, o, n) => ((CommandGroup)b).Update());
+
+ public string Heading
+ {
+ get => (string)GetValue(HeadingProperty);
+ set => SetValue(HeadingProperty, value);
+ }
+
+ public IList Items => _items.Children;
+
+ private readonly Label _heading;
+ private readonly VerticalStackLayout _items;
+
+ public CommandGroup()
+ {
+ _heading = new Label { FontSize = 12, FontAttributes = FontAttributes.Bold, Padding = new Thickness(8, 6), IsVisible = false };
+ _heading.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ _items = new VerticalStackLayout { Spacing = 0 };
+ Content = new VerticalStackLayout { Spacing = 0, Children = { _heading, _items } };
+ }
+
+ private void Update()
+ {
+ _heading.Text = Heading ?? string.Empty;
+ _heading.IsVisible = !string.IsNullOrEmpty(Heading);
+ }
+}
+
+// One row of a Command — rounded-sm px-2 h-9 text-sm; the highlighted row is bg-accent.
+public partial class CommandItem : ContentView
+{
+ public static readonly BindableProperty TextProperty =
+ BindableProperty.Create(nameof(Text), typeof(string), typeof(CommandItem), string.Empty,
+ propertyChanged: (b, o, n) => ((CommandItem)b).Update());
+
+ // Identifies the item to your code.
+ public static readonly BindableProperty ValueProperty =
+ BindableProperty.Create(nameof(Value), typeof(string), typeof(CommandItem), null);
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(CommandItem), IconName.None,
+ propertyChanged: (b, o, n) => ((CommandItem)b).Update());
+
+ // Keyboard hint shown at the end of the row (display only).
+ public static readonly BindableProperty ShortcutProperty =
+ BindableProperty.Create(nameof(Shortcut), typeof(string), typeof(CommandItem), string.Empty,
+ propertyChanged: (b, o, n) => ((CommandItem)b).Update());
+
+ // Extra words the search matches besides Text.
+ public static readonly BindableProperty KeywordsProperty =
+ BindableProperty.Create(nameof(Keywords), typeof(string), typeof(CommandItem), string.Empty);
+
+ public string Text
+ {
+ get => (string)GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public string? Value
+ {
+ get => (string?)GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public string Shortcut
+ {
+ get => (string)GetValue(ShortcutProperty);
+ set => SetValue(ShortcutProperty, value);
+ }
+
+ public string Keywords
+ {
+ get => (string)GetValue(KeywordsProperty);
+ set => SetValue(KeywordsProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ private readonly Border _row;
+ private readonly Icon _icon;
+ private readonly Label _label;
+ private readonly Label _shortcut;
+
+ public CommandItem()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground, VerticalOptions = LayoutOptions.Center };
+ _label = new Label { FontSize = 14, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.TailTruncation };
+ _label.Token(Label.TextColorProperty, ShellToken.PopoverForeground);
+ _shortcut = new Label { FontSize = 12, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, IsVisible = false };
+ _shortcut.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ var grid = new Grid
+ {
+ ColumnSpacing = 8,
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto)
+ }
+ };
+ grid.Add(_icon, 0, 0);
+ grid.Add(_label, 1, 0);
+ grid.Add(_shortcut, 2, 0);
+
+ _row = new Border
+ {
+ Content = grid,
+ HeightRequest = 36,
+ Padding = new Thickness(8, 0),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { if (IsEnabled) Command.Of(this)?.SetActive(this); };
+ _row.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => Press();
+ _row.GestureRecognizers.Add(tap);
+
+ Content = _row;
+ Update();
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == nameof(IsEnabled)) Opacity = IsEnabled ? 1 : 0.5;
+ }
+
+ internal bool Matches(string word) =>
+ (Text ?? string.Empty).Contains(word, StringComparison.CurrentCultureIgnoreCase) ||
+ (Keywords ?? string.Empty).Contains(word, StringComparison.CurrentCultureIgnoreCase);
+
+ internal void Apply(bool active)
+ {
+ if (active) _row.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ else
+ {
+ _row.ClearValue(VisualElement.BackgroundColorProperty);
+ _row.BackgroundColor = Colors.Transparent;
+ }
+ }
+
+ internal void RaiseClicked() => Clicked?.Invoke(this, EventArgs.Empty);
+
+ private void Press()
+ {
+ if (!IsEnabled) return;
+ if (Command.Of(this) is { } command) command.Select(this);
+ else RaiseClicked();
+ }
+
+ private void Update()
+ {
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _label.Text = Text ?? string.Empty;
+ _shortcut.Text = Shortcut ?? string.Empty;
+ _shortcut.IsVisible = !string.IsNullOrEmpty(Shortcut);
+ SemanticProperties.SetDescription(this, Text ?? string.Empty);
+ }
+}
+
+// Divider between groups — -mx-1 my-1 h-px bg-border.
+public partial class CommandSeparator : ContentView
+{
+ public CommandSeparator()
+ {
+ var line = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent };
+ line.Token(BoxView.ColorProperty, ShellToken.Border);
+ Margin = new Thickness(-4, 4);
+ Content = line;
+ }
+}
+
+// Command palette — a Command in a dialog near the top of the page (max-w-xl rounded-xl border
+// bg-popover shadow), over a dimmed backdrop. Picking an item, tapping the backdrop or pressing
+// Escape closes it. Declare it where a Dialog would go and open it from code:
+//
+// ...
+//
+// Palette.SetOpen(true);
+[ContentProperty(nameof(Groups))]
+public partial class CommandPalette : ShellOverlayHost
+{
+ public static readonly BindableProperty PlaceholderProperty =
+ BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(CommandPalette), ""Type a command or search..."",
+ propertyChanged: (b, o, n) => ((CommandPalette)b)._command.Placeholder = n as string ?? string.Empty);
+
+ public static readonly BindableProperty EmptyTextProperty =
+ BindableProperty.Create(nameof(EmptyText), typeof(string), typeof(CommandPalette), ""No results found."",
+ propertyChanged: (b, o, n) => ((CommandPalette)b)._command.EmptyText = n as string ?? string.Empty);
+
+ public string Placeholder
+ {
+ get => (string)GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string EmptyText
+ {
+ get => (string)GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public IList Groups => _command.Groups;
+
+ // The Command inside the dialog.
+ public Command Command => _command;
+
+ public event EventHandler? ItemSelected;
+
+ private readonly Command _command;
+
+ public CommandPalette()
+ {
+ _command = new Command { MaxListHeight = 360 };
+ _command.RemoveBorder();
+ _command.ItemSelected += (_, item) =>
+ {
+ SetOpen(false);
+ ItemSelected?.Invoke(this, item);
+ };
+ Children.Add(new CommandPaletteContent(this, _command));
+ OpenChanged += async (_, open) =>
+ {
+ if (!open) return;
+ _command.Reset();
+ // The field can take focus once the dialog is on screen.
+ await Task.Delay(120);
+ if (Open) _command.Focus();
+ };
+ }
+
+ protected override bool IsTrigger(Element child) => false;
+}
+
+// The backdrop and box of a CommandPalette.
+internal sealed partial class CommandPaletteContent : ContentView, IShellOverlayContent
+{
+ private readonly BoxView _backdrop;
+ private readonly Border _box;
+
+ public CommandPaletteContent(CommandPalette palette, Command command)
+ {
+ _backdrop = new BoxView { BackgroundColor = Colors.Transparent };
+ _backdrop.Token(BoxView.ColorProperty, ShellToken.Overlay);
+ var tapBackdrop = new TapGestureRecognizer();
+ tapBackdrop.Tapped += (_, _) => palette.SetOpen(false);
+ _backdrop.GestureRecognizers.Add(tapBackdrop);
+
+ _box = new Border
+ {
+ Content = command,
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = 12 },
+ VerticalOptions = LayoutOptions.Start,
+ HorizontalOptions = LayoutOptions.Center,
+ Shadow = new Shadow { Brush = new SolidColorBrush(Colors.Black), Offset = new Point(0, 16), Radius = 32, Opacity = 0.25f }
+ };
+ _box.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover);
+ _box.Token(Border.StrokeProperty, ShellToken.Border);
+ // Swallow taps on the box so they don't fall through to the backdrop.
+ _box.GestureRecognizers.Add(new TapGestureRecognizer());
+
+ var root = new Grid { Children = { _backdrop, _box } };
+ ShellPortal.EdgeToEdge(this, root);
+ root.SizeChanged += (_, _) =>
+ {
+ _box.WidthRequest = Math.Max(0, Math.Min(576, root.Width - 32));
+ // top-[15vh]
+ _box.Margin = new Thickness(0, Math.Round(root.Height * 0.15), 0, 0);
+ };
+ Content = root;
+ }
+
+ public async Task AnimateAsync(bool open)
+ {
+ _box.AbortAnimation(""FadeTo"");
+ if (open)
+ {
+ _backdrop.Opacity = 0;
+ _box.Opacity = 0;
+ _box.Scale = 0.95;
+ await Task.WhenAll(
+ _backdrop.FadeToAsync(1, 150, Easing.CubicOut),
+ _box.FadeToAsync(1, 150, Easing.CubicOut),
+ _box.ScaleToAsync(1, 150, Easing.CubicOut));
+ }
+ else
+ {
+ await Task.WhenAll(
+ _backdrop.FadeToAsync(0, 120, Easing.CubicIn),
+ _box.FadeToAsync(0, 120, Easing.CubicIn),
+ _box.ScaleToAsync(0.95, 120, Easing.CubicIn));
+ }
+ }
+}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation.Easings;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Command menu — a search field over a list of grouped items (rounded-md border bg-popover).
+// Typing filters the list; Up / Down move the highlight and Enter picks it.
+//
+//
+//
+//
+//
+//
+// ...
+//
+// CommandPalette shows the same list in a dialog over the window.
+public class Command : Border
+{
+ public static readonly StyledProperty PlaceholderProperty =
+ AvaloniaProperty.Register(nameof(Placeholder), ""Type a command or search..."");
+
+ // Shown when nothing matches the search.
+ public static readonly StyledProperty EmptyTextProperty =
+ AvaloniaProperty.Register(nameof(EmptyText), ""No results found."");
+
+ // The list scrolls past this height.
+ public static readonly StyledProperty MaxListHeightProperty =
+ AvaloniaProperty.Register(nameof(MaxListHeight), 300);
+
+ private readonly TextBox _textBox;
+ private readonly TextBlock _empty;
+ private readonly StackPanel _list = new();
+ private readonly ScrollViewer _scroll;
+ private CommandItem? _active;
+
+ static Command()
+ {
+ PlaceholderProperty.Changed.AddClassHandler((c, e) => c._textBox.PlaceholderText = (string?)e.NewValue);
+ EmptyTextProperty.Changed.AddClassHandler((c, e) => c._empty.Text = (string?)e.NewValue);
+ MaxListHeightProperty.Changed.AddClassHandler((c, e) => c._scroll.MaxHeight = (double)e.NewValue!);
+ }
+
+ public string? Placeholder
+ {
+ get => GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string? EmptyText
+ {
+ get => GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public double MaxListHeight
+ {
+ get => GetValue(MaxListHeightProperty);
+ set => SetValue(MaxListHeightProperty, value);
+ }
+
+ // CommandGroup, CommandItem and CommandSeparator children.
+ [Content]
+ public Controls Groups => _list.Children;
+
+ // The search text.
+ public string Query => _textBox.Text ?? string.Empty;
+
+ public event EventHandler? ItemSelected;
+
+ public Command()
+ {
+ _textBox = new TextBox
+ {
+ FontSize = 14,
+ PlaceholderText = Placeholder,
+ VerticalAlignment = VerticalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center
+ };
+ ShellPlatform.StripNativeChrome(_textBox);
+ _textBox.TextChanged += (_, _) => Filter();
+ // Tunnel: the text box would use Up / Down itself.
+ _textBox.AddHandler(KeyDownEvent, OnSearchKeyDown, RoutingStrategies.Tunnel);
+
+ Grid.SetColumn(_textBox, 1);
+ var search = new Grid
+ {
+ Height = 44,
+ Margin = new Thickness(12, 0),
+ ColumnDefinitions = new ColumnDefinitions(""Auto,*""),
+ ColumnSpacing = 8,
+ Children = { new Icon { Kind = IconName.Search, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }, _textBox }
+ };
+ var line = new Border { Height = 1 };
+ line.Token(BackgroundProperty, ShellToken.Border);
+
+ _empty = new TextBlock { Text = EmptyText, FontSize = 14, TextAlignment = TextAlignment.Center, Margin = new Thickness(0, 24), IsVisible = false };
+ _empty.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ _scroll = new ScrollViewer
+ {
+ Content = new StackPanel { Margin = new Thickness(4), Children = { _empty, _list } },
+ MaxHeight = MaxListHeight,
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled
+ };
+
+ Child = new StackPanel { Children = { search, line, _scroll } };
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ ClipToBounds = true;
+ this.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+ _list.Children.CollectionChanged += (_, _) => Filter();
+ }
+
+ // The Command an item or group sits in.
+ internal static Command? Of(StyledElement? element)
+ {
+ for (var parent = element?.Parent; parent != null; parent = parent.Parent)
+ if (parent is Command command) return command;
+ return null;
+ }
+
+ // Used by CommandPalette, which draws the border itself.
+ internal void RemoveBorder()
+ {
+ BorderThickness = default;
+ CornerRadius = default;
+ this.ClearToken(BackgroundProperty);
+ Background = null;
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ Filter();
+ }
+
+ public new bool Focus(NavigationMethod method = NavigationMethod.Unspecified, KeyModifiers keyModifiers = KeyModifiers.None)
+ => _textBox.Focus(method, keyModifiers);
+
+ // Clears the search.
+ public void Reset() => _textBox.Text = string.Empty;
+
+ // Every item, in order, whether it sits in a group or directly in the list.
+ public IEnumerable Items()
+ {
+ foreach (var child in _list.Children)
+ {
+ if (child is CommandItem item) yield return item;
+ else if (child is CommandGroup group)
+ foreach (var grouped in group.Items.OfType()) yield return grouped;
+ }
+ }
+
+ internal void Select(CommandItem item)
+ {
+ if (!item.IsEffectivelyEnabled) return;
+ ItemSelected?.Invoke(this, item);
+ item.RaiseClicked();
+ }
+
+ internal void SetActive(CommandItem? item)
+ {
+ if (ReferenceEquals(_active, item)) return;
+ _active?.Apply(false);
+ _active = item;
+ _active?.Apply(true);
+ }
+
+ private void OnSearchKeyDown(object? sender, KeyEventArgs e)
+ {
+ switch (e.Key)
+ {
+ case Key.Down or Key.Up:
+ var items = Items().Where(i => i.IsVisible && i.IsEnabled).ToList();
+ if (items.Count == 0) break;
+ var index = _active is null ? -1 : items.IndexOf(_active);
+ var next = e.Key == Key.Down ? (index + 1) % items.Count : (index <= 0 ? items.Count : index) - 1;
+ SetActive(items[next]);
+ items[next].BringIntoView();
+ break;
+ case Key.Enter:
+ if (_active != null) Select(_active);
+ break;
+ default:
+ return;
+ }
+ e.Handled = true;
+ }
+
+ private void Filter()
+ {
+ var words = Query.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var any = false;
+ foreach (var item in Items())
+ {
+ item.IsVisible = words.All(item.Matches);
+ any |= item.IsVisible;
+ }
+ foreach (var child in _list.Children)
+ {
+ if (child is CommandGroup group) group.IsVisible = group.Items.OfType().Any(i => i.IsVisible);
+ // Dividers only make sense between groups, so they go while the list is filtered.
+ else if (child is CommandSeparator separator) separator.IsVisible = words.Length == 0;
+ }
+ _empty.IsVisible = !any;
+ SetActive(Items().FirstOrDefault(i => i.IsVisible && i.IsEnabled));
+ }
+}
+
+// A labelled section of a Command — the heading is px-2 py-1.5 text-xs font-medium muted.
+public class CommandGroup : Border
+{
+ public static readonly StyledProperty HeadingProperty =
+ AvaloniaProperty.Register(nameof(Heading));
+
+ private readonly TextBlock _heading;
+ private readonly StackPanel _items = new();
+
+ static CommandGroup()
+ {
+ HeadingProperty.Changed.AddClassHandler((g, _) => g.Update());
+ }
+
+ public string? Heading
+ {
+ get => GetValue(HeadingProperty);
+ set => SetValue(HeadingProperty, value);
+ }
+
+ [Content]
+ public Controls Items => _items.Children;
+
+ public CommandGroup()
+ {
+ _heading = new TextBlock { FontSize = 12, FontWeight = FontWeight.Medium, Margin = new Thickness(8, 6), IsVisible = false };
+ _heading.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ Child = new StackPanel { Children = { _heading, _items } };
+ }
+
+ private void Update()
+ {
+ _heading.Text = Heading ?? string.Empty;
+ _heading.IsVisible = !string.IsNullOrEmpty(Heading);
+ }
+}
+
+// One row of a Command — rounded-sm px-2 h-9 text-sm; the highlighted row is bg-accent.
+public class CommandItem : Border
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ // Identifies the item to your code.
+ public static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ // Keyboard hint shown at the end of the row (display only).
+ public static readonly StyledProperty ShortcutProperty =
+ AvaloniaProperty.Register(nameof(Shortcut));
+
+ // Extra words the search matches besides Text.
+ public static readonly StyledProperty KeywordsProperty =
+ AvaloniaProperty.Register(nameof(Keywords));
+
+ private readonly Icon _icon;
+ private readonly TextBlock _label;
+ private readonly TextBlock _shortcut;
+
+ static CommandItem()
+ {
+ TextProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IconProperty.Changed.AddClassHandler((i, _) => i.Update());
+ ShortcutProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Opacity = i.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public string? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public string? Shortcut
+ {
+ get => GetValue(ShortcutProperty);
+ set => SetValue(ShortcutProperty, value);
+ }
+
+ public string? Keywords
+ {
+ get => GetValue(KeywordsProperty);
+ set => SetValue(KeywordsProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ public CommandItem()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground, VerticalAlignment = VerticalAlignment.Center };
+ _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ _label.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground);
+ _shortcut = new TextBlock { FontSize = 12, VerticalAlignment = VerticalAlignment.Center, IsVisible = false };
+ _shortcut.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+
+ var grid = new Grid { ColumnDefinitions = new ColumnDefinitions(""Auto,*,Auto""), ColumnSpacing = 8 };
+ Grid.SetColumn(_label, 1);
+ Grid.SetColumn(_shortcut, 2);
+ grid.Children.Add(_icon);
+ grid.Children.Add(_label);
+ grid.Children.Add(_shortcut);
+
+ Child = grid;
+ Height = 36;
+ Padding = new Thickness(8, 0);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm);
+ // Transparent, not null, so the whole row takes the pointer.
+ Background = Brushes.Transparent;
+ Cursor = new Cursor(StandardCursorType.Hand);
+ Update();
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ if (IsEffectivelyEnabled) Command.Of(this)?.SetActive(this);
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Press();
+ e.Handled = true;
+ }
+
+ // Picks the item in code, as a click or Enter would.
+ public void Press()
+ {
+ if (!IsEffectivelyEnabled) return;
+ if (Command.Of(this) is { } command) command.Select(this);
+ else RaiseClicked();
+ }
+
+ internal bool Matches(string word) =>
+ (Text ?? string.Empty).Contains(word, StringComparison.CurrentCultureIgnoreCase) ||
+ (Keywords ?? string.Empty).Contains(word, StringComparison.CurrentCultureIgnoreCase);
+
+ internal void Apply(bool active)
+ {
+ if (active) this.Token(BackgroundProperty, ShellToken.Accent);
+ else
+ {
+ this.ClearToken(BackgroundProperty);
+ Background = Brushes.Transparent;
+ }
+ }
+
+ internal void RaiseClicked() => Clicked?.Invoke(this, EventArgs.Empty);
+
+ private void Update()
+ {
+ _icon.Kind = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _label.Text = Text ?? string.Empty;
+ _shortcut.Text = Shortcut ?? string.Empty;
+ _shortcut.IsVisible = !string.IsNullOrEmpty(Shortcut);
+ AutomationProperties.SetName(this, Text ?? string.Empty);
+ }
+}
+
+// Divider between groups — -mx-1 my-1 h-px bg-border.
+public class CommandSeparator : Border
+{
+ public CommandSeparator()
+ {
+ Height = 1;
+ Margin = new Thickness(-4, 4);
+ this.Token(BackgroundProperty, ShellToken.Border);
+ }
+}
+
+// Command palette — a Command in a dialog near the top of the window (max-w-xl rounded-xl border
+// bg-popover shadow), over a dimmed backdrop. Hotkey (Ctrl+K) toggles it; picking an item,
+// clicking the backdrop or pressing Escape closes it.
+//
+// ...
+//
+// Open it from code with Palette.SetOpen(true).
+public class CommandPalette : ShellOverlayHost
+{
+ public static readonly StyledProperty PlaceholderProperty =
+ AvaloniaProperty.Register(nameof(Placeholder), ""Type a command or search..."");
+
+ public static readonly StyledProperty EmptyTextProperty =
+ AvaloniaProperty.Register(nameof(EmptyText), ""No results found."");
+
+ // The key gesture that opens and closes the palette anywhere in the window; null for none.
+ public static readonly StyledProperty HotkeyProperty =
+ AvaloniaProperty.Register(nameof(Hotkey), new KeyGesture(Key.K, KeyModifiers.Control));
+
+ private readonly Command _command = new() { MaxListHeight = 360 };
+ private TopLevel? _window;
+
+ static CommandPalette()
+ {
+ PlaceholderProperty.Changed.AddClassHandler((p, e) => p._command.Placeholder = (string?)e.NewValue);
+ EmptyTextProperty.Changed.AddClassHandler((p, e) => p._command.EmptyText = (string?)e.NewValue);
+ }
+
+ public string? Placeholder
+ {
+ get => GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string? EmptyText
+ {
+ get => GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public KeyGesture? Hotkey
+ {
+ get => GetValue(HotkeyProperty);
+ set => SetValue(HotkeyProperty, value);
+ }
+
+ // The Command inside the dialog.
+ public Command Command => _command;
+
+ public event EventHandler? ItemSelected;
+
+ public CommandPalette()
+ {
+ _command.RemoveBorder();
+ _command.ItemSelected += (_, item) =>
+ {
+ SetOpen(false);
+ ItemSelected?.Invoke(this, item);
+ };
+ Items.Add(new CommandPaletteContent(this, _command));
+ // The groups declared inside the palette belong to its Command.
+ Items.CollectionChanged += (_, _) =>
+ {
+ foreach (var child in Items.Where(c => c is not IShellOverlayContent && !_command.Groups.Contains(c)).ToList())
+ _command.Groups.Add(child);
+ };
+ OpenChanged += (_, open) =>
+ {
+ if (open) _command.Reset();
+ };
+ }
+
+ protected override bool ClaimsChild(Control child) => child is not IShellOverlayContent;
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ _window = TopLevel.GetTopLevel(this);
+ _window?.AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel);
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ _window?.RemoveHandler(KeyDownEvent, OnWindowKeyDown);
+ _window = null;
+ }
+
+ private void OnWindowKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (Hotkey is not { } hotkey || !hotkey.Matches(e) || !IsEffectivelyEnabled) return;
+ SetOpen(!Open);
+ e.Handled = true;
+ }
+}
+
+// The backdrop and box of a CommandPalette.
+internal sealed class CommandPaletteContent : Border, IShellOverlayContent
+{
+ private readonly Border _backdrop;
+ private readonly Border _box;
+
+ public CommandPaletteContent(CommandPalette palette, Command command)
+ {
+ _backdrop = new Border();
+ _backdrop.Token(BackgroundProperty, ShellToken.Overlay);
+ _backdrop.PointerPressed += (_, e) =>
+ {
+ palette.SetOpen(false);
+ e.Handled = true;
+ };
+
+ _box = new Border
+ {
+ Child = command,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(12),
+ ClipToBounds = true,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Top,
+ BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 16, Blur = 32, Color = ShellTheme.Shadow(0.25) })
+ };
+ _box.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+ // Clicks on the box must not reach the backdrop behind it.
+ _box.PointerPressed += (_, e) => e.Handled = true;
+
+ Child = new Panel { Children = { _backdrop, _box } };
+ SizeChanged += (_, e) =>
+ {
+ _box.Width = Math.Max(0, Math.Min(576, e.NewSize.Width - 32));
+ // top-[15vh]
+ _box.Margin = new Thickness(0, Math.Round(e.NewSize.Height * 0.15), 0, 0);
+ };
+ }
+
+ public async Task AnimateAsync(bool open)
+ {
+ if (open)
+ {
+ ShellMotion.Set(_backdrop, 0, ""scale(1)"");
+ ShellMotion.Set(_box, 0, ""scale(0.95)"");
+ await Task.WhenAll(
+ ShellMotion.To(_backdrop, 1, ""scale(1)"", 150, new CubicEaseOut()),
+ ShellMotion.To(_box, 1, ""scale(1)"", 150, new CubicEaseOut()));
+ }
+ else
+ {
+ await Task.WhenAll(
+ ShellMotion.To(_backdrop, 0, ""scale(1)"", 120, new CubicEaseIn()),
+ ShellMotion.To(_box, 0, ""scale(0.95)"", 120, new CubicEaseIn()));
+ }
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/NavigationMenuTemplate.cs b/src/ShellUI.Native.Templates/Templates/NavigationMenuTemplate.cs
new file mode 100644
index 0000000..6867b5c
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/NavigationMenuTemplate.cs
@@ -0,0 +1,636 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class NavigationMenuTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "navigation-menu",
+ DisplayName = "Navigation Menu",
+ Description = "Row of top-level links that open panels of links",
+ Category = ComponentCategory.Navigation,
+ FilePath = "NavigationMenu.cs",
+ Dependencies = new List { "shell", "icon" },
+ Tags = new List { "navigation", "menu", "header", "links" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Navigation menu — a row of top-level links for a site or app header. An item with links
+// inside shows a chevron and opens a panel of them (title and description); an item without
+// is a plain link.
+//
+//
+//
+//
+//
+//
+//
+[ContentProperty(nameof(Items))]
+public partial class NavigationMenu : ContentView
+{
+ public IList Items => _row.Children;
+
+ private readonly HorizontalStackLayout _row;
+
+ public NavigationMenu()
+ {
+ _row = new HorizontalStackLayout { Spacing = 4 };
+ Content = _row;
+ HorizontalOptions = LayoutOptions.Start;
+ }
+}
+
+// A top-level entry — h-10 rounded-md px-4 text-sm font-medium, hover:bg-accent. With
+// NavigationMenuLink children it opens a w-56 panel (rounded-md border bg-popover p-1 shadow-lg).
+[ContentProperty(nameof(Links))]
+public partial class NavigationMenuItem : ContentView, IShellPopup
+{
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.Create(nameof(Title), typeof(string), typeof(NavigationMenuItem), string.Empty,
+ propertyChanged: (b, o, n) => ((NavigationMenuItem)b).Update());
+
+ public string Title
+ {
+ get => (string)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public IList Links => _stack.Children;
+
+ public bool IsOpen { get; private set; }
+
+ // Raised when an item without links is pressed.
+ public event EventHandler? Clicked;
+
+ private readonly Border _trigger;
+ private readonly Label _label;
+ private readonly Icon _chevron;
+ private readonly VerticalStackLayout _stack;
+ private readonly Border _panel;
+ private ShellPopupHandle? _handle;
+ private bool _hovered;
+
+ public NavigationMenuItem()
+ {
+ _label = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, VerticalTextAlignment = TextAlignment.Center, VerticalOptions = LayoutOptions.Center, LineBreakMode = LineBreakMode.NoWrap };
+ _label.Token(Label.TextColorProperty, ShellToken.Foreground);
+ _chevron = new Icon { Name = IconName.ChevronDown, Size = 12, Token = ShellToken.Foreground, VerticalOptions = LayoutOptions.Center, IsVisible = false };
+ _trigger = new Border
+ {
+ Content = new HorizontalStackLayout { Spacing = 4, VerticalOptions = LayoutOptions.Center, Children = { _label, _chevron } },
+ HeightRequest = 40,
+ Padding = new Thickness(16, 0),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { _hovered = true; Update(); };
+ pointer.PointerExited += (_, _) => { _hovered = false; Update(); };
+ _trigger.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); Press(); };
+ _trigger.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, Press);
+
+ _stack = new VerticalStackLayout { Spacing = 0 };
+ _stack.ChildAdded += (_, _) => Update();
+ _stack.ChildRemoved += (_, _) => Update();
+ _panel = new Border
+ {
+ Content = _stack,
+ Padding = new Thickness(4),
+ WidthRequest = 224,
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ Shadow = ShellPopups.PanelShadow()
+ };
+ _panel.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover);
+ _panel.Token(Border.StrokeProperty, ShellToken.Border);
+ // Taps on the panel padding must not fall through to the click-outside catcher.
+ _panel.GestureRecognizers.Add(new TapGestureRecognizer());
+
+ Content = _trigger;
+ Update();
+ }
+
+ private bool HasLinks => _stack.Children.Count > 0;
+
+ private void Press()
+ {
+ if (!IsEnabled) return;
+ if (HasLinks) SetOpen(!IsOpen);
+ else Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ public void SetOpen(bool open)
+ {
+ if (IsOpen == open || (open && !HasLinks)) return;
+ IsOpen = open;
+ Update();
+ _ = _chevron.RotateToAsync(open ? 180 : 0, 200, Easing.CubicOut);
+ if (open)
+ {
+ // The panel floats outside this view, so links can't find their item by walking up.
+ foreach (var link in _stack.Children.OfType()) link.Owner = this;
+ ShellPopups.Opened(this);
+ _handle = ShellPortal.ShowPopup(_trigger, _panel, new ShellPopupOptions { Owner = this, OnDismiss = Close });
+ }
+ else
+ {
+ ShellPopups.Closed(this);
+ var handle = _handle;
+ _handle = null;
+ if (handle != null) _ = handle.CloseAsync();
+ }
+ }
+
+ public void Close() => SetOpen(false);
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName)
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+
+ private void Update()
+ {
+ _label.Text = Title ?? string.Empty;
+ _chevron.IsVisible = HasLinks;
+ SemanticProperties.SetDescription(this, Title ?? string.Empty);
+ if (IsOpen || (_hovered && IsEnabled))
+ _trigger.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent);
+ else
+ {
+ _trigger.ClearValue(VisualElement.BackgroundColorProperty);
+ _trigger.BackgroundColor = Colors.Transparent;
+ }
+ }
+}
+
+// A link in an item's panel — rounded-sm p-2, hover:bg-accent: a text-sm font-medium title over
+// an optional muted description, with an optional icon.
+public partial class NavigationMenuLink : ContentView
+{
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.Create(nameof(Title), typeof(string), typeof(NavigationMenuLink), string.Empty,
+ propertyChanged: (b, o, n) => ((NavigationMenuLink)b).Update());
+
+ public static readonly BindableProperty DescriptionProperty =
+ BindableProperty.Create(nameof(Description), typeof(string), typeof(NavigationMenuLink), string.Empty,
+ propertyChanged: (b, o, n) => ((NavigationMenuLink)b).Update());
+
+ public static readonly BindableProperty IconProperty =
+ BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(NavigationMenuLink), IconName.None,
+ propertyChanged: (b, o, n) => ((NavigationMenuLink)b).Update());
+
+ public string Title
+ {
+ get => (string)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string Description
+ {
+ get => (string)GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => (IconName)GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ internal NavigationMenuItem? Owner { get; set; }
+
+ private readonly Border _row;
+ private readonly Icon _icon;
+ private readonly Label _title;
+ private readonly Label _description;
+
+ public NavigationMenuLink()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground, VerticalOptions = LayoutOptions.Start, Margin = new Thickness(0, 2, 0, 0) };
+ _title = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold };
+ _title.Token(Label.TextColorProperty, ShellToken.PopoverForeground);
+ _description = new Label { FontSize = 13, IsVisible = false, LineBreakMode = LineBreakMode.WordWrap };
+ _description.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+
+ var grid = new Grid
+ {
+ ColumnSpacing = 8,
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star) }
+ };
+ grid.Add(_icon, 0, 0);
+ grid.Add(new VerticalStackLayout { Spacing = 2, Children = { _title, _description } }, 1, 0);
+
+ _row = new Border
+ {
+ Content = grid,
+ Padding = new Thickness(8),
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm },
+ BackgroundColor = Colors.Transparent
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { if (IsEnabled) _row.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); };
+ pointer.PointerExited += (_, _) => { _row.ClearValue(VisualElement.BackgroundColorProperty); _row.BackgroundColor = Colors.Transparent; };
+ _row.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => Press();
+ _row.GestureRecognizers.Add(tap);
+
+ Content = _row;
+ Update();
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == nameof(IsEnabled)) Opacity = IsEnabled ? 1 : 0.5;
+ }
+
+ private void Press()
+ {
+ if (!IsEnabled) return;
+ Owner?.Close();
+ Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void Update()
+ {
+ _icon.Name = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _title.Text = Title ?? string.Empty;
+ _description.Text = Description ?? string.Empty;
+ _description.IsVisible = !string.IsNullOrEmpty(Description);
+ SemanticProperties.SetDescription(this, Title ?? string.Empty);
+ }
+}
+",
+ [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;
+
+// Navigation menu — a row of top-level links for a site or app header. An item with links
+// inside shows a chevron and opens a panel of them (title and description); an item without
+// is a plain link. Items are tab stops: Enter, Space or Down opens one, Left / Right move
+// between them, and Up / Down move through an open panel.
+//
+//
+//
+//
+//
+//
+//
+public class NavigationMenu : Border
+{
+ private readonly StackPanel _row = new() { Orientation = Orientation.Horizontal, Spacing = 4 };
+
+ // The NavigationMenuItem children.
+ [Content]
+ public Controls Items => _row.Children;
+
+ public NavigationMenu()
+ {
+ Child = _row;
+ HorizontalAlignment = HorizontalAlignment.Left;
+ _row.Children.CollectionChanged += (_, _) =>
+ {
+ foreach (var item in _row.Children.OfType()) item.Menu = this;
+ };
+ }
+
+ // Moves keyboard focus to the item before or after `from`, wrapping around.
+ internal void FocusNeighbour(NavigationMenuItem from, int direction)
+ {
+ var items = _row.Children.OfType().Where(i => i.IsEffectivelyEnabled).ToList();
+ var index = items.IndexOf(from);
+ if (index < 0 || items.Count < 2) return;
+ items[(index + direction + items.Count) % items.Count].Focus(NavigationMethod.Directional);
+ }
+}
+
+// A top-level entry — h-10 rounded-md px-4 text-sm font-medium, hover:bg-accent. With
+// NavigationMenuLink children it opens a w-56 panel (rounded-md border bg-popover p-1 shadow-lg).
+public class NavigationMenuItem : Border, IShellFocusable
+{
+ public static readonly StyledProperty TitleProperty =
+ AvaloniaProperty.Register(nameof(Title));
+
+ private readonly TextBlock _label;
+ private readonly Icon _chevron;
+ private readonly RotateTransform _turn = new();
+ private readonly Border _trigger;
+ private readonly StackPanel _stack = new();
+ private readonly ShellAnchoredPopup _popup;
+
+ static NavigationMenuItem()
+ {
+ TitleProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Opacity = i.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public string? Title
+ {
+ get => GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ // The NavigationMenuLink children.
+ [Content]
+ public Controls Links => _stack.Children;
+
+ public bool IsOpen => _popup.IsOpen;
+
+ // Raised when an item without links is pressed.
+ public event EventHandler? Clicked;
+
+ internal NavigationMenu? Menu { get; set; }
+
+ public NavigationMenuItem()
+ {
+ _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center };
+ _label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ _chevron = new Icon
+ {
+ Kind = IconName.ChevronDown,
+ Size = 12,
+ Token = ShellToken.Foreground,
+ VerticalAlignment = VerticalAlignment.Center,
+ RenderTransform = _turn,
+ RenderTransformOrigin = RelativePoint.Center,
+ IsVisible = false
+ };
+ _trigger = new Border
+ {
+ Child = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4, VerticalAlignment = VerticalAlignment.Center, Children = { _label, _chevron } },
+ Height = 40,
+ Padding = new Thickness(16, 0),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ // Transparent, not null, so the whole item takes the pointer.
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+
+ var panel = new Border
+ {
+ Child = _stack,
+ Padding = new Thickness(4),
+ Width = 224,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ BoxShadow = ShellPopups.PanelShadow()
+ };
+ panel.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+
+ _popup = new ShellAnchoredPopup(_trigger);
+ _popup.Popup.Child = panel;
+ _popup.Closed += (_, _) => Update();
+ _stack.Children.CollectionChanged += (_, _) => Update();
+
+ Child = new Panel { Children = { _trigger, _popup.Popup } };
+ ShellFocus.Ring(this, _trigger);
+ Update();
+ }
+
+ private bool HasLinks => _stack.Children.Count > 0;
+
+ // Opens the panel, or raises Clicked when there are no links, as a click or Enter would.
+ public void Press()
+ {
+ if (!IsEffectivelyEnabled) return;
+ if (HasLinks) SetOpen(!IsOpen);
+ else Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ public void SetOpen(bool open)
+ {
+ if (open == _popup.IsOpen || (open && !HasLinks)) return;
+ if (!open)
+ {
+ _popup.Close();
+ return;
+ }
+ foreach (var link in _stack.Children.OfType()) link.Owner = this;
+ _popup.Open();
+ Update();
+ }
+
+ public void Close() => SetOpen(false);
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ Update();
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ Update();
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Press();
+ e.Handled = true;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (!ReferenceEquals(e.Source, this)) return;
+ switch (e.Key)
+ {
+ case Key.Enter or Key.Space:
+ Press();
+ FocusFirstLink();
+ break;
+ case Key.Down:
+ SetOpen(true);
+ FocusFirstLink();
+ break;
+ case Key.Left:
+ Menu?.FocusNeighbour(this, -1);
+ break;
+ case Key.Right:
+ Menu?.FocusNeighbour(this, 1);
+ break;
+ default:
+ return;
+ }
+ e.Handled = true;
+ }
+
+ // Into the panel, so the arrow keys and Enter work on the links.
+ private void FocusFirstLink()
+ {
+ if (IsOpen) _stack.Children.OfType().FirstOrDefault(l => l.IsEffectivelyEnabled)?.Focus(NavigationMethod.Directional);
+ }
+
+ private void Update()
+ {
+ _label.Text = Title ?? string.Empty;
+ _chevron.IsVisible = HasLinks;
+ _turn.Angle = IsOpen ? 180 : 0;
+ AutomationProperties.SetName(this, Title ?? string.Empty);
+ if (IsOpen || (IsPointerOver && IsEffectivelyEnabled)) _trigger.Token(BackgroundProperty, ShellToken.Accent);
+ else
+ {
+ _trigger.ClearToken(BackgroundProperty);
+ _trigger.Background = Brushes.Transparent;
+ }
+ }
+}
+
+// A link in an item's panel — rounded-sm p-2, hover:bg-accent: a text-sm font-medium title over
+// an optional muted description, with an optional icon. A tab stop that activates with Enter or
+// Space; Up and Down move between the links.
+public class NavigationMenuLink : Border
+{
+ public static readonly StyledProperty TitleProperty =
+ AvaloniaProperty.Register(nameof(Title));
+
+ public static readonly StyledProperty DescriptionProperty =
+ AvaloniaProperty.Register(nameof(Description));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ private readonly Icon _icon;
+ private readonly TextBlock _title;
+ private readonly TextBlock _description;
+
+ static NavigationMenuLink()
+ {
+ TitleProperty.Changed.AddClassHandler((l, _) => l.Update());
+ DescriptionProperty.Changed.AddClassHandler((l, _) => l.Update());
+ IconProperty.Changed.AddClassHandler((l, _) => l.Update());
+ IsEnabledProperty.Changed.AddClassHandler((l, _) => l.Opacity = l.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public string? Title
+ {
+ get => GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string? Description
+ {
+ get => GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ internal NavigationMenuItem? Owner { get; set; }
+
+ public NavigationMenuLink()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground, VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 2, 0, 0) };
+ _title = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium };
+ _title.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground);
+ _description = new TextBlock { FontSize = 13, IsVisible = false, TextWrapping = TextWrapping.Wrap };
+ _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+
+ var text = new StackPanel { Spacing = 2, Children = { _title, _description } };
+ Grid.SetColumn(text, 1);
+ Child = new Grid { ColumnDefinitions = new ColumnDefinitions(""Auto,*""), ColumnSpacing = 8, Children = { _icon, text } };
+ Padding = new Thickness(8);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm);
+ Background = Brushes.Transparent;
+ Cursor = new Cursor(StandardCursorType.Hand);
+ ShellFocus.Ring(this, this);
+ Update();
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ if (IsEffectivelyEnabled) this.Token(BackgroundProperty, ShellToken.Accent);
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ this.ClearToken(BackgroundProperty);
+ Background = Brushes.Transparent;
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Press();
+ e.Handled = true;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ switch (e.Key)
+ {
+ case Key.Enter or Key.Space:
+ Press();
+ break;
+ case Key.Up or Key.Down:
+ if (Parent is not Panel rows) return;
+ var links = rows.Children.OfType().Where(l => l.IsEffectivelyEnabled).ToList();
+ var index = links.IndexOf(this);
+ if (index < 0) return;
+ links[(index + (e.Key == Key.Down ? 1 : -1) + links.Count) % links.Count].Focus(NavigationMethod.Directional);
+ break;
+ default:
+ return;
+ }
+ e.Handled = true;
+ }
+
+ // Picks the link in code, as a click or Enter/Space would.
+ public void Press()
+ {
+ if (!IsEffectivelyEnabled) return;
+ Owner?.Close();
+ Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void Update()
+ {
+ _icon.Kind = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _title.Text = Title ?? string.Empty;
+ _description.Text = Description ?? string.Empty;
+ _description.IsVisible = !string.IsNullOrEmpty(Description);
+ AutomationProperties.SetName(this, Title ?? string.Empty);
+ }
+}
+"
+ };
+}
diff --git a/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs
index f541e4f..8e0736c 100644
--- a/tests/ShellUI.Native.Tests/TemplateContentTests.cs
+++ b/tests/ShellUI.Native.Tests/TemplateContentTests.cs
@@ -122,6 +122,8 @@ public static IEnumerable