diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md
index 909c74e..9a69fd7 100644
--- a/docs/COMPONENTS.md
+++ b/docs/COMPONENTS.md
@@ -67,9 +67,16 @@ your background so the splash-to-app transition doesn't flash the template's pur
## Avalonia (in progress)
-Ported so far: `shell`, `icon`, `theme-toggle` and P0 (`button`, `input`, `label`, `checkbox`,
-`switch`, `card` and its parts, `separator`, `badge`, `progress`, `alert`). Not yet published to
-NuGet. In an Avalonia project, `shellui-native list` shows only these.
+Ported so far: `shell`, `icon`, `theme-toggle`, P0 (`button`, `input`, `label`, `checkbox`,
+`switch`, `card` and its parts, `separator`, `badge`, `progress`, `alert`) and P1 (`dialog`,
+`drawer`, `sheet`, `dropdown`, `popover` and their parts). Not yet published to NuGet. In an
+Avalonia project, `shellui-native list` shows only these.
+
+Overlays use Avalonia's `Popup` in the window's overlay layer: the content stays in the logical
+tree (theme, DataContext and `FindParentOfType` work as declared), dropdowns and popovers get
+Avalonia's placement and flip above the trigger when there is no room below, and a click outside
+closes them. Escape closes the overlay on top. Keyboard focus is not trapped inside an open
+dialog yet: Tab can still reach controls behind it.
Setup after `shellui-native init`: call `Components.UI.ShellTheme.EnsureInitialized();` in
`App.Initialize` after `AvaloniaXamlLoader.Load(this);`, and add
@@ -85,6 +92,9 @@ Same names, properties and variants as MAUI, except:
| Card children | XAML children | XAML children, collected in `Card.Items` |
| Block button | `HorizontalOptions="Fill"` | `HorizontalAlignment="Stretch"` |
| Focus ring | Platform focus visuals | shadcn's ring, for keyboard focus only |
+| Overlay children | XAML children | XAML children, collected in `Items` (`Dialog`, `DialogContent`, `Dropdown`, …) |
+| Dropdown / Popover | `ToggleAsync()` / `CloseAsync()` kept for older code | `Toggle()` / `Close()` only |
+| Flipped popup gap | 4px above the trigger | Flush with the trigger (Avalonia doesn't mirror the offset) |
---
diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md
index b83956b..dc11d05 100644
--- a/docs/DEVELOPMENT_PLAN.md
+++ b/docs/DEVELOPMENT_PLAN.md
@@ -457,7 +457,11 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase.
- [x] P0: button (+ button-variants), input, label, checkbox, switch, card family, separator, badge,
progress, alert. Each installs alone into a fresh `dotnet new avalonia.app` and builds with 0
warnings; checked in the demo in light and dark, with the keyboard focus ring and loading state
-- [ ] P1: dialog, drawer, sheet, dropdown, popover (Avalonia's overlay layer and popups)
+- [x] P1: dialog, drawer, sheet, dropdown, popover and their parts, on Avalonia's `Popup` in the
+ overlay layer (shared hosts in `shell`: `ShellOverlayHost`, `ShellPopoverHost`,
+ `ShellTriggerView`, `ShellDismiss` for Escape). Each installs alone into a fresh
+ `dotnet new avalonia.app`; checked in the demo: opening from triggers, placement and flip,
+ Escape, picking an item, `DialogClose`, light and dark. Open: focus trapping in dialogs
- [ ] 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/docs/README.md b/docs/README.md
index 0cd04f0..6bf175e 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -54,7 +54,7 @@ shellui-native list
| Platform | Status | .NET Version |
|----------|--------|--------------|
| .NET MAUI | Available (`0.1.0-alpha.1`) — Android, iOS, Mac Catalyst, Windows | .NET 10.0 |
-| Avalonia UI | In progress (Phase 2): theme, icons and P0 components; not on NuGet yet | .NET 10.0 (Avalonia 12) |
+| Avalonia UI | In progress (Phase 2): theme, icons, P0 and P1 components; not on NuGet yet | .NET 10.0 (Avalonia 12) |
| WinUI 3 | Conditional (Phase 3) | .NET 10.0 |
WPF is intentionally not on this list — it's recognized for project detection only, not an
diff --git a/examples/Avalonia.Demo/Components/UI/Button.cs b/examples/Avalonia.Demo/Components/UI/Button.cs
index f2c21ca..fce97bb 100644
--- a/examples/Avalonia.Demo/Components/UI/Button.cs
+++ b/examples/Avalonia.Demo/Components/UI/Button.cs
@@ -15,7 +15,7 @@ namespace AvaloniaDemo.Components.UI;
// Button with ShellUI variants, sizes, optional icon and loading state.
// Sizes to its content like an inline-flex button; set HorizontalAlignment="Stretch" for a block button.
// Usage:
-public class Button : Border
+public class Button : Border, IShellFocusable
{
public static readonly StyledProperty VariantProperty =
AvaloniaProperty.Register(nameof(Variant));
@@ -125,7 +125,10 @@ public Button()
// Clicks in code, as a pointer click or Enter/Space would.
public void Press()
{
- if (CanClick) Clicked?.Invoke(this, EventArgs.Empty);
+ if (!CanClick) return;
+ Clicked?.Invoke(this, EventArgs.Empty);
+ // Inside a DialogTrigger, DropdownTrigger, ... the click also activates the trigger.
+ ShellTriggerView.ActivateAncestor(this);
}
protected override void OnPointerEntered(PointerEventArgs e)
diff --git a/examples/Avalonia.Demo/Components/UI/Dialog.cs b/examples/Avalonia.Demo/Components/UI/Dialog.cs
new file mode 100644
index 0000000..b9dc8ae
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Dialog.cs
@@ -0,0 +1,11 @@
+namespace AvaloniaDemo.Components.UI;
+
+// Modal dialog. Declare it anywhere; the content covers the window while open:
+//
+// (optional)
+// ...
+//
+// Open from code with ConfirmDialog.SetOpen(true).
+public class Dialog : ShellOverlayHost
+{
+}
diff --git a/examples/Avalonia.Demo/Components/UI/DialogClose.cs b/examples/Avalonia.Demo/Components/UI/DialogClose.cs
new file mode 100644
index 0000000..8eae5f5
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/DialogClose.cs
@@ -0,0 +1,7 @@
+namespace AvaloniaDemo.Components.UI;
+
+// Closes the enclosing Dialog. Wrap a Button:
+public class DialogClose : ShellTriggerView
+{
+ protected override void OnActivated() => this.FindParentOfType()?.SetOpen(false);
+}
diff --git a/examples/Avalonia.Demo/Components/UI/DialogContent.cs b/examples/Avalonia.Demo/Components/UI/DialogContent.cs
new file mode 100644
index 0000000..1eb34d4
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/DialogContent.cs
@@ -0,0 +1,106 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation.Easings;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Dialog panel — backdrop + centered box: max-w-lg rounded-lg border bg-background p-6
+// shadow-lg, close button top-right; fades and zooms in from 95%. XAML children go to Items.
+public class DialogContent : Border, IShellOverlayContent
+{
+ public static readonly StyledProperty ShowCloseButtonProperty =
+ AvaloniaProperty.Register(nameof(ShowCloseButton), true);
+
+ private readonly Border _backdrop;
+ private readonly Border _box;
+ private readonly Border _close;
+ private readonly StackPanel _body = new() { Spacing = 16 };
+
+ static DialogContent()
+ {
+ ShowCloseButtonProperty.Changed.AddClassHandler((c, e) => c._close.IsVisible = (bool)e.NewValue!);
+ }
+
+ public bool ShowCloseButton
+ {
+ get => GetValue(ShowCloseButtonProperty);
+ set => SetValue(ShowCloseButtonProperty, value);
+ }
+
+ [Content]
+ public Controls Items => _body.Children;
+
+ public DialogContent()
+ {
+ _backdrop = new Border();
+ _backdrop.Token(BackgroundProperty, ShellToken.Overlay);
+ _backdrop.PointerPressed += (_, e) =>
+ {
+ this.FindParentOfType()?.SetOpen(false);
+ e.Handled = true;
+ };
+
+ _close = new Border
+ {
+ Child = new Icon { Kind = IconName.X, Size = 16, Token = ShellToken.MutedForeground },
+ Width = 24,
+ Height = 24,
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm),
+ Background = Brushes.Transparent,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, -8, -8, 0),
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ AutomationProperties.SetName(_close, "Close");
+ _close.PointerEntered += (_, _) => _close.Token(BackgroundProperty, ShellToken.Accent);
+ _close.PointerExited += (_, _) => { _close.ClearToken(BackgroundProperty); _close.Background = Brushes.Transparent; };
+ _close.PointerReleased += (_, e) =>
+ {
+ this.FindParentOfType()?.SetOpen(false);
+ e.Handled = true;
+ };
+
+ _box = new Border
+ {
+ Child = new Panel { Children = { _body, _close } },
+ Padding = new Thickness(24),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusLg),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 10, Blur = 24, Color = ShellTheme.Shadow(0.2) })
+ };
+ _box.Token(BackgroundProperty, ShellToken.Background).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(512, e.NewSize.Width - 32));
+ }
+
+ 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/DialogDescription.cs b/examples/Avalonia.Demo/Components/UI/DialogDescription.cs
new file mode 100644
index 0000000..e98c0ca
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/DialogDescription.cs
@@ -0,0 +1,15 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// text-sm text-muted-foreground
+public class DialogDescription : TextBlock
+{
+ public DialogDescription()
+ {
+ FontSize = 14;
+ TextWrapping = TextWrapping.Wrap;
+ this.Token(ForegroundProperty, ShellToken.MutedForeground);
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/DialogFooter.cs b/examples/Avalonia.Demo/Components/UI/DialogFooter.cs
new file mode 100644
index 0000000..af6bfb9
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/DialogFooter.cs
@@ -0,0 +1,17 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Actions row — flex justify-end gap-2.
+public class DialogFooter : StackPanel
+{
+ public DialogFooter()
+ {
+ Orientation = Orientation.Horizontal;
+ Spacing = 8;
+ HorizontalAlignment = HorizontalAlignment.Right;
+ Margin = new Thickness(0, 8, 0, 0);
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/DialogHeader.cs b/examples/Avalonia.Demo/Components/UI/DialogHeader.cs
new file mode 100644
index 0000000..740c0ca
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/DialogHeader.cs
@@ -0,0 +1,14 @@
+using Avalonia;
+using Avalonia.Controls;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Title + description stack — flex flex-col space-y-1.5. Leaves room for the close button.
+public class DialogHeader : StackPanel
+{
+ public DialogHeader()
+ {
+ Spacing = 6;
+ Margin = new Thickness(0, 0, 24, 0);
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/DialogTitle.cs b/examples/Avalonia.Demo/Components/UI/DialogTitle.cs
new file mode 100644
index 0000000..110e6cf
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/DialogTitle.cs
@@ -0,0 +1,16 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// text-lg font-semibold leading-none
+public class DialogTitle : TextBlock
+{
+ public DialogTitle()
+ {
+ FontSize = 18;
+ FontWeight = FontWeight.SemiBold;
+ TextWrapping = TextWrapping.Wrap;
+ this.Token(ForegroundProperty, ShellToken.Foreground);
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/DialogTrigger.cs b/examples/Avalonia.Demo/Components/UI/DialogTrigger.cs
new file mode 100644
index 0000000..a506a6e
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/DialogTrigger.cs
@@ -0,0 +1,7 @@
+namespace AvaloniaDemo.Components.UI;
+
+// Opens the enclosing Dialog. Usage:
+public class DialogTrigger : ShellTriggerView
+{
+ protected override void OnActivated() => this.FindParentOfType()?.SetOpen(true);
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Drawer.cs b/examples/Avalonia.Demo/Components/UI/Drawer.cs
new file mode 100644
index 0000000..2740b92
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Drawer.cs
@@ -0,0 +1,19 @@
+using Avalonia;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Edge drawer (defaults to bottom, like vaul). Declare it anywhere:
+// ...
+public class Drawer : ShellOverlayHost
+{
+ public static readonly StyledProperty SideProperty =
+ AvaloniaProperty.Register(nameof(Side), DrawerSide.Bottom);
+
+ public DrawerSide Side
+ {
+ get => GetValue(SideProperty);
+ set => SetValue(SideProperty, value);
+ }
+}
+
+public enum DrawerSide { Left, Right, Top, Bottom }
diff --git a/examples/Avalonia.Demo/Components/UI/DrawerContent.cs b/examples/Avalonia.Demo/Components/UI/DrawerContent.cs
new file mode 100644
index 0000000..e810a80
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/DrawerContent.cs
@@ -0,0 +1,127 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Drawer panel — rounded-t-[10px] bg-background with a grab handle (h-2 w-[100px] bg-muted),
+// sized to its content (max 80% of the window), sliding in from its edge over a dimmed backdrop.
+// XAML children go to Items.
+public class DrawerContent : Border, IShellOverlayContent
+{
+ private readonly Border _backdrop;
+ private readonly Border _panel;
+ private readonly Border _handle;
+ private readonly StackPanel _body = new() { Spacing = 16, Margin = new Thickness(16, 16, 16, 24) };
+
+ [Content]
+ public Controls Items => _body.Children;
+
+ public DrawerContent()
+ {
+ _backdrop = new Border();
+ _backdrop.Token(BackgroundProperty, ShellToken.Overlay);
+ _backdrop.PointerPressed += (_, e) =>
+ {
+ this.FindParentOfType()?.SetOpen(false);
+ e.Handled = true;
+ };
+
+ _handle = new Border
+ {
+ Height = 6,
+ Width = 100,
+ CornerRadius = new CornerRadius(3),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Margin = new Thickness(0, 12, 0, 0)
+ };
+ _handle.Token(BackgroundProperty, ShellToken.Muted);
+
+ var stack = new DockPanel();
+ DockPanel.SetDock(_handle, Dock.Top);
+ stack.Children.Add(_handle);
+ stack.Children.Add(new ScrollViewer { Content = _body });
+
+ _panel = new Border
+ {
+ Child = stack,
+ BorderThickness = new Thickness(1),
+ BoxShadow = new BoxShadows(new BoxShadow { OffsetY = -4, Blur = 16, Color = ShellTheme.Shadow(0.15) })
+ };
+ _panel.Token(BackgroundProperty, ShellToken.Background).Token(BorderBrushProperty, ShellToken.Border);
+ _panel.PointerPressed += (_, e) => e.Handled = true;
+
+ Child = new Panel { Children = { _backdrop, _panel } };
+ SizeChanged += (_, _) => ApplySide();
+ }
+
+ private DrawerSide Side => this.FindParentOfType()?.Side ?? DrawerSide.Bottom;
+
+ private void ApplySide()
+ {
+ var side = Side;
+ var horizontal = side is DrawerSide.Top or DrawerSide.Bottom;
+ _panel.VerticalAlignment = side switch
+ {
+ DrawerSide.Top => VerticalAlignment.Top,
+ DrawerSide.Bottom => VerticalAlignment.Bottom,
+ _ => VerticalAlignment.Stretch
+ };
+ _panel.HorizontalAlignment = side switch
+ {
+ DrawerSide.Left => HorizontalAlignment.Left,
+ DrawerSide.Right => HorizontalAlignment.Right,
+ _ => HorizontalAlignment.Stretch
+ };
+ _panel.Width = horizontal ? double.NaN : Math.Min(320, Bounds.Width * 0.85);
+ _panel.MaxHeight = horizontal && Bounds.Height > 0 ? Bounds.Height * 0.8 : double.PositiveInfinity;
+ _handle.IsVisible = side == DrawerSide.Bottom;
+ const double r = 10;
+ _panel.CornerRadius = side switch
+ {
+ DrawerSide.Bottom => new CornerRadius(r, r, 0, 0),
+ DrawerSide.Top => new CornerRadius(0, 0, r, r),
+ DrawerSide.Left => new CornerRadius(0, r, r, 0),
+ _ => new CornerRadius(r, 0, 0, r)
+ };
+ }
+
+ // translate() that puts the panel just past its edge; invariant, so it parses in any culture.
+ private string Offscreen()
+ {
+ var (x, y) = Side switch
+ {
+ DrawerSide.Bottom => (0, _panel.Bounds.Height + 24),
+ DrawerSide.Top => (0, -_panel.Bounds.Height - 24),
+ DrawerSide.Left => (-_panel.Bounds.Width - 24, 0),
+ _ => (_panel.Bounds.Width + 24, 0d)
+ };
+ return FormattableString.Invariant($"translate({x}px, {y}px)");
+ }
+
+ public async Task AnimateAsync(bool open)
+ {
+ if (open)
+ {
+ ShellMotion.Set(_backdrop, 0, "scale(1)");
+ ShellMotion.Set(_panel, 0, "scale(1)");
+ ApplySide();
+ await ShellMotion.WaitForLayoutAsync(_panel);
+ ShellMotion.Set(_panel, 1, Offscreen());
+ await Task.WhenAll(
+ ShellMotion.To(_backdrop, 1, "scale(1)", 200, new CubicEaseOut()),
+ ShellMotion.To(_panel, 1, "translate(0px, 0px)", 300, new CubicEaseOut()));
+ }
+ else
+ {
+ await Task.WhenAll(
+ ShellMotion.To(_backdrop, 0, "scale(1)", 200, new CubicEaseIn()),
+ ShellMotion.To(_panel, 1, Offscreen(), 220, new CubicEaseIn()));
+ }
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/DrawerTrigger.cs b/examples/Avalonia.Demo/Components/UI/DrawerTrigger.cs
new file mode 100644
index 0000000..587401a
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/DrawerTrigger.cs
@@ -0,0 +1,7 @@
+namespace AvaloniaDemo.Components.UI;
+
+// Opens the enclosing Drawer. Usage:
+public class DrawerTrigger : ShellTriggerView
+{
+ protected override void OnActivated() => this.FindParentOfType()?.SetOpen(true);
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Dropdown.cs b/examples/Avalonia.Demo/Components/UI/Dropdown.cs
new file mode 100644
index 0000000..3f42175
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Dropdown.cs
@@ -0,0 +1,16 @@
+using Avalonia.Controls;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Dropdown menu. The content floats below the trigger (above it when there is no room);
+// clicking outside, picking an item, Escape or opening another menu closes it.
+//
+//
+//
+//
+//
+//
+public class Dropdown : ShellPopoverHost
+{
+ protected override bool IsContent(Control child) => child is DropdownContent;
+}
diff --git a/examples/Avalonia.Demo/Components/UI/DropdownContent.cs b/examples/Avalonia.Demo/Components/UI/DropdownContent.cs
new file mode 100644
index 0000000..fd9996f
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/DropdownContent.cs
@@ -0,0 +1,25 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Menu panel — min-w-[8rem] rounded-md border bg-popover p-1 shadow-md. XAML children go to Items.
+public class DropdownContent : Border
+{
+ private readonly StackPanel _stack = new();
+
+ [Content]
+ public Controls Items => _stack.Children;
+
+ public DropdownContent()
+ {
+ Child = _stack;
+ Padding = new Thickness(4);
+ MinWidth = 180;
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ BoxShadow = ShellPopups.PanelShadow();
+ this.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/DropdownItem.cs b/examples/Avalonia.Demo/Components/UI/DropdownItem.cs
new file mode 100644
index 0000000..ad5780a
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/DropdownItem.cs
@@ -0,0 +1,100 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Menu row — rounded-sm px-2 py-1.5 text-sm, hover:bg-accent, optional leading icon.
+public class DropdownItem : Border
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ private readonly Icon _icon;
+ private readonly TextBlock _label;
+
+ static DropdownItem()
+ {
+ TextProperty.Changed.AddClassHandler((i, e) => i._label.Text = (string?)e.NewValue ?? string.Empty);
+ IconProperty.Changed.AddClassHandler((i, _) => i.UpdateIcon());
+ IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Opacity = i.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ public DropdownItem()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground };
+ _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center };
+ _label.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground);
+
+ Child = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { _icon, _label } };
+ Height = 32;
+ Padding = new Thickness(8, 0);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm);
+ Background = Brushes.Transparent;
+ Cursor = new Cursor(StandardCursorType.Hand);
+ ShellFocus.Ring(this, this);
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ 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);
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ Press();
+ e.Handled = true;
+ }
+
+ // Picks the item in code, as a click or Enter/Space would.
+ public void Press()
+ {
+ if (!IsEffectivelyEnabled) return;
+ this.FindParentOfType()?.Close();
+ Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void UpdateIcon()
+ {
+ _icon.Kind = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/DropdownTrigger.cs b/examples/Avalonia.Demo/Components/UI/DropdownTrigger.cs
new file mode 100644
index 0000000..5093fa7
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/DropdownTrigger.cs
@@ -0,0 +1,7 @@
+namespace AvaloniaDemo.Components.UI;
+
+// Toggles the enclosing Dropdown. Wrap a Button or any control.
+public class DropdownTrigger : ShellTriggerView
+{
+ protected override void OnActivated() => this.FindParentOfType()?.Toggle();
+}
diff --git a/examples/Avalonia.Demo/Components/UI/ElementExtensions.cs b/examples/Avalonia.Demo/Components/UI/ElementExtensions.cs
new file mode 100644
index 0000000..c7759fe
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/ElementExtensions.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.LogicalTree;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Logical-tree helpers for compositional components (Dialog, Dropdown, ...).
+public static class ElementExtensions
+{
+ // Nearest logical ancestor of type T. Floating content's logical parent is its Popup, so a
+ // part shown in the overlay layer (DialogClose, DropdownItem) still finds its component.
+ public static T? FindParentOfType(this StyledElement element) where T : class
+ {
+ for (var p = element.Parent; p != null; p = p.Parent)
+ if (p is T match) return match;
+ return null;
+ }
+
+ // Every logical descendant of type T, depth-first. Stops descending into an element that
+ // matches `stopAt` (a Tabs inside a Tabs keeps its own triggers).
+ public static IEnumerable FindDescendantsOfType(this ILogical root, Func? stopAt = null)
+ {
+ foreach (var child in root.LogicalChildren)
+ {
+ if (child is T match) yield return match;
+ if (stopAt != null && stopAt(child)) continue;
+ foreach (var nested in child.FindDescendantsOfType(stopAt))
+ yield return nested;
+ }
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Popover.cs b/examples/Avalonia.Demo/Components/UI/Popover.cs
new file mode 100644
index 0000000..0e9b304
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Popover.cs
@@ -0,0 +1,13 @@
+using Avalonia.Controls;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Popover — a panel that floats next to its trigger; clicking outside or Escape closes it.
+//
+//
+// ...
+//
+public class Popover : ShellPopoverHost
+{
+ protected override bool IsContent(Control child) => child is PopoverContent;
+}
diff --git a/examples/Avalonia.Demo/Components/UI/PopoverContent.cs b/examples/Avalonia.Demo/Components/UI/PopoverContent.cs
new file mode 100644
index 0000000..e280654
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/PopoverContent.cs
@@ -0,0 +1,25 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Popover panel — w-72 rounded-md border bg-popover p-4 shadow-md. XAML children go to Items.
+public class PopoverContent : Border
+{
+ private readonly StackPanel _stack = new() { Spacing = 8 };
+
+ [Content]
+ public Controls Items => _stack.Children;
+
+ public PopoverContent()
+ {
+ Child = _stack;
+ Padding = new Thickness(16);
+ Width = 288;
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ BoxShadow = ShellPopups.PanelShadow();
+ this.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/PopoverTrigger.cs b/examples/Avalonia.Demo/Components/UI/PopoverTrigger.cs
new file mode 100644
index 0000000..5b7e677
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/PopoverTrigger.cs
@@ -0,0 +1,7 @@
+namespace AvaloniaDemo.Components.UI;
+
+// Toggles the enclosing Popover. Wrap a Button or any control.
+public class PopoverTrigger : ShellTriggerView
+{
+ protected override void OnActivated() => this.FindParentOfType()?.Toggle();
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Sheet.cs b/examples/Avalonia.Demo/Components/UI/Sheet.cs
new file mode 100644
index 0000000..9d3eb27
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Sheet.cs
@@ -0,0 +1,19 @@
+using Avalonia;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Side sheet (defaults to right). Declare it anywhere:
+// ...
+public class Sheet : ShellOverlayHost
+{
+ public static readonly StyledProperty SideProperty =
+ AvaloniaProperty.Register(nameof(Side), SheetSide.Right);
+
+ public SheetSide Side
+ {
+ get => GetValue(SideProperty);
+ set => SetValue(SideProperty, value);
+ }
+}
+
+public enum SheetSide { Left, Right, Top, Bottom }
diff --git a/examples/Avalonia.Demo/Components/UI/SheetContent.cs b/examples/Avalonia.Demo/Components/UI/SheetContent.cs
new file mode 100644
index 0000000..f67d0a8
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/SheetContent.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation.Easings;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Sheet panel — full-height edge panel, w-3/4 sm:max-w-sm, bg-background p-6 shadow-lg,
+// close button top-right, slides in from its side over a dimmed backdrop. XAML children go to Items.
+public class SheetContent : Border, IShellOverlayContent
+{
+ private readonly Border _backdrop;
+ private readonly Border _panel;
+ private readonly StackPanel _body = new() { Spacing = 16, Margin = new Thickness(0, 0, 24, 0) };
+
+ [Content]
+ public Controls Items => _body.Children;
+
+ public SheetContent()
+ {
+ _backdrop = new Border();
+ _backdrop.Token(BackgroundProperty, ShellToken.Overlay);
+ _backdrop.PointerPressed += (_, e) =>
+ {
+ this.FindParentOfType()?.SetOpen(false);
+ e.Handled = true;
+ };
+
+ var close = new Border
+ {
+ Child = new Icon { Kind = IconName.X, Size = 16, Token = ShellToken.MutedForeground },
+ Width = 24,
+ Height = 24,
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm),
+ Background = Brushes.Transparent,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, -8, -8, 0),
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ AutomationProperties.SetName(close, "Close");
+ close.PointerEntered += (_, _) => close.Token(BackgroundProperty, ShellToken.Accent);
+ close.PointerExited += (_, _) => { close.ClearToken(BackgroundProperty); close.Background = Brushes.Transparent; };
+ close.PointerReleased += (_, e) =>
+ {
+ this.FindParentOfType()?.SetOpen(false);
+ e.Handled = true;
+ };
+
+ _panel = new Border
+ {
+ Child = new Panel { Children = { new ScrollViewer { Content = _body }, close } },
+ Padding = new Thickness(24),
+ BorderThickness = new Thickness(1),
+ BoxShadow = new BoxShadows(new BoxShadow { Blur = 24, Color = ShellTheme.Shadow(0.2) })
+ };
+ _panel.Token(BackgroundProperty, ShellToken.Background).Token(BorderBrushProperty, ShellToken.Border);
+ _panel.PointerPressed += (_, e) => e.Handled = true;
+
+ Child = new Panel { Children = { _backdrop, _panel } };
+ SizeChanged += (_, _) => ApplySide();
+ }
+
+ private SheetSide Side => this.FindParentOfType()?.Side ?? SheetSide.Right;
+
+ private void ApplySide()
+ {
+ var side = Side;
+ var vertical = side is SheetSide.Left or SheetSide.Right;
+ _panel.VerticalAlignment = side switch
+ {
+ SheetSide.Top => VerticalAlignment.Top,
+ SheetSide.Bottom => VerticalAlignment.Bottom,
+ _ => VerticalAlignment.Stretch
+ };
+ _panel.HorizontalAlignment = side switch
+ {
+ SheetSide.Left => HorizontalAlignment.Left,
+ SheetSide.Right => HorizontalAlignment.Right,
+ _ => HorizontalAlignment.Stretch
+ };
+ _panel.Width = vertical ? Math.Min(384, Bounds.Width * 0.75) : double.NaN;
+ }
+
+ // translate() that puts the panel just past its edge; invariant, so it parses in any culture.
+ private string Offscreen()
+ {
+ var (x, y) = Side switch
+ {
+ SheetSide.Left => (-_panel.Bounds.Width - 32, 0),
+ SheetSide.Top => (0, -_panel.Bounds.Height - 32),
+ SheetSide.Bottom => (0, _panel.Bounds.Height + 32),
+ _ => (_panel.Bounds.Width + 32, 0d)
+ };
+ return FormattableString.Invariant($"translate({x}px, {y}px)");
+ }
+
+ public async Task AnimateAsync(bool open)
+ {
+ if (open)
+ {
+ ShellMotion.Set(_backdrop, 0, "scale(1)");
+ ShellMotion.Set(_panel, 0, "scale(1)");
+ ApplySide();
+ await ShellMotion.WaitForLayoutAsync(_panel);
+ ShellMotion.Set(_panel, 1, Offscreen());
+ await Task.WhenAll(
+ ShellMotion.To(_backdrop, 1, "scale(1)", 200, new CubicEaseOut()),
+ ShellMotion.To(_panel, 1, "translate(0px, 0px)", 350, new CubicEaseOut()));
+ }
+ else
+ {
+ await Task.WhenAll(
+ ShellMotion.To(_backdrop, 0, "scale(1)", 200, new CubicEaseIn()),
+ ShellMotion.To(_panel, 1, Offscreen(), 250, new CubicEaseIn()));
+ }
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/SheetTrigger.cs b/examples/Avalonia.Demo/Components/UI/SheetTrigger.cs
new file mode 100644
index 0000000..83d3a0e
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/SheetTrigger.cs
@@ -0,0 +1,7 @@
+namespace AvaloniaDemo.Components.UI;
+
+// Opens the enclosing Sheet. Usage:
+public class SheetTrigger : ShellTriggerView
+{
+ protected override void OnActivated() => this.FindParentOfType()?.SetOpen(true);
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Shell.cs b/examples/Avalonia.Demo/Components/UI/Shell.cs
index a1294aa..a1f5ca5 100644
--- a/examples/Avalonia.Demo/Components/UI/Shell.cs
+++ b/examples/Avalonia.Demo/Components/UI/Shell.cs
@@ -1,11 +1,21 @@
using System;
using System.Collections.Generic;
+using System.Collections.Specialized;
using System.Linq;
using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Data;
using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
using Avalonia.Media;
+using Avalonia.Media.Transformation;
+using Avalonia.Metadata;
using Avalonia.Styling;
namespace AvaloniaDemo.Components.UI;
@@ -237,3 +247,344 @@ public static void Ring(Control control, Border target)
new BoxShadow { Spread = 2, Color = ShellTheme.Get(ShellToken.Background) },
new[] { new BoxShadow { Spread = 4, Color = ShellTheme.Get(ShellToken.Ring) } });
}
+
+// Fade + transform transitions for overlays opening and closing.
+public static class ShellMotion
+{
+ // Jumps to the values without animating (the starting point of an entrance).
+ public static void Set(Visual visual, double opacity, string transform)
+ {
+ visual.Transitions = null;
+ visual.Opacity = opacity;
+ visual.RenderTransform = TransformOperations.Parse(transform);
+ }
+
+ // Animates from the current values; completes when the transition has run.
+ public static Task To(Visual visual, double opacity, string transform, int milliseconds, Easing easing)
+ {
+ var duration = TimeSpan.FromMilliseconds(milliseconds);
+ visual.Transitions = new Transitions
+ {
+ new DoubleTransition { Property = Visual.OpacityProperty, Duration = duration, Easing = easing },
+ new TransformOperationsTransition { Property = Visual.RenderTransformProperty, Duration = duration, Easing = easing }
+ };
+ visual.Opacity = opacity;
+ visual.RenderTransform = TransformOperations.Parse(transform);
+ return Task.Delay(duration);
+ }
+
+ // Waits until a freshly shown control has a size (slide-ins start one size away).
+ public static async Task WaitForLayoutAsync(Control control)
+ {
+ for (var i = 0; i < 20 && (control.Bounds.Width <= 0 || control.Bounds.Height <= 0); i++)
+ await Task.Delay(16);
+ }
+}
+
+// Open overlays and popups, newest last. Escape closes the top one; with nothing open the key
+// keeps its normal behavior.
+public static class ShellDismiss
+{
+ private static readonly List<(object Key, Action Close)> Open = new();
+ private static readonly ConditionalWeakTable Hooked = new();
+
+ public static void Push(object key, Action close, Visual from)
+ {
+ Open.RemoveAll(entry => ReferenceEquals(entry.Key, key));
+ Open.Add((key, close));
+ if (TopLevel.GetTopLevel(from) is not { } top || Hooked.TryGetValue(top, out _)) return;
+ Hooked.Add(top, new object());
+ // Tunnel, so Escape reaches us before a focused control (e.g. a TextBox) handles it.
+ top.AddHandler(InputElement.KeyDownEvent, (_, e) =>
+ {
+ if (e.Key == Key.Escape && DismissTop()) e.Handled = true;
+ }, RoutingStrategies.Tunnel);
+ }
+
+ public static void Remove(object key) => Open.RemoveAll(entry => ReferenceEquals(entry.Key, key));
+
+ // Closes the most recently opened overlay. False when nothing is open.
+ public static bool DismissTop()
+ {
+ if (Open.Count == 0) return false;
+ var top = Open[^1];
+ Open.RemoveAt(Open.Count - 1);
+ top.Close();
+ return true;
+ }
+}
+
+// Floating panels that close on a click outside (Dropdown, Popover). Only one is open at a time:
+// opening another closes the previous one.
+public interface IShellPopup
+{
+ void Close();
+}
+
+public static class ShellPopups
+{
+ private static WeakReference? _open;
+
+ public static void Opened(IShellPopup popup)
+ {
+ if (_open != null && _open.TryGetTarget(out var previous) && !ReferenceEquals(previous, popup))
+ previous.Close();
+ _open = new WeakReference(popup);
+ }
+
+ public static void Closed(IShellPopup popup)
+ {
+ if (_open != null && _open.TryGetTarget(out var current) && ReferenceEquals(current, popup))
+ _open = null;
+ }
+
+ // shadow-md
+ public static BoxShadows PanelShadow() => new(new BoxShadow { OffsetY = 4, Blur = 12, Color = ShellTheme.Shadow(0.12) });
+}
+
+/* Base for a component with a trigger and floating content. XAML children go to Items: the
+ content part is shown in a Popup in the window's overlay layer, everything else (the trigger)
+ renders in place. The Popup keeps the content in the logical tree, so it inherits the theme
+ and DataContext, and its parts find the component with FindParentOfType. */
+public abstract class ShellFloatingHost : Border
+{
+ private readonly Panel _inline = new();
+ protected readonly Popup Popup = new() { ShouldUseOverlayLayer = true };
+
+ protected Control? FloatingContent { get; private set; }
+
+ [Content]
+ public Controls Items { get; } = new();
+
+ protected ShellFloatingHost()
+ {
+ _inline.Children.Add(Popup);
+ Child = _inline;
+ HorizontalAlignment = HorizontalAlignment.Left;
+ VerticalAlignment = VerticalAlignment.Top;
+ Items.CollectionChanged += OnItemsChanged;
+ }
+
+ protected abstract bool IsContent(Control child);
+
+ // Rebuilt from Items on every change; Clear reports no old items, so diffing isn't worth it.
+ private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ Popup.Child = null;
+ FloatingContent = null;
+ _inline.Children.RemoveAll(_inline.Children.Where(c => c != Popup).ToList());
+ foreach (var child in Items)
+ {
+ if (IsContent(child)) Popup.Child = FloatingContent = child;
+ else _inline.Children.Insert(_inline.Children.Count - 1, child);
+ }
+ }
+}
+
+// Shared host for trigger + floating panel (Dropdown, Popover). Clicking outside closes it.
+public abstract class ShellPopoverHost : ShellFloatingHost, IShellPopup
+{
+ public static readonly StyledProperty IsOpenProperty =
+ AvaloniaProperty.Register(nameof(IsOpen), defaultBindingMode: BindingMode.TwoWay);
+
+ static ShellPopoverHost()
+ {
+ IsOpenProperty.Changed.AddClassHandler((h, _) => h.OnOpenChanged());
+ }
+
+ public bool IsOpen
+ {
+ get => GetValue(IsOpenProperty);
+ set => SetValue(IsOpenProperty, value);
+ }
+
+ public event EventHandler? IsOpenChanged;
+
+ protected ShellPopoverHost()
+ {
+ Popup.PlacementTarget = this;
+ Popup.IsLightDismissEnabled = true;
+ // The click that dismisses (even on the trigger) does nothing else, so it can't reopen.
+ Popup.OverlayDismissEventPassThrough = false;
+ Popup.Closed += (_, _) => IsOpen = false;
+ }
+
+ protected virtual PlacementMode Placement => PlacementMode.BottomEdgeAlignedLeft;
+ protected virtual double Offset => 4;
+
+ public void SetOpen(bool value) => IsOpen = value;
+ public void Toggle() => IsOpen = !IsOpen;
+ public void Close() => IsOpen = false;
+
+ private async void OnOpenChanged()
+ {
+ IsOpenChanged?.Invoke(this, IsOpen);
+ if (FloatingContent is not { } content) return;
+ if (IsOpen)
+ {
+ ShellPopups.Opened(this);
+ ShellDismiss.Push(this, Close, this);
+ Popup.Placement = Placement;
+ Popup.VerticalOffset = Offset;
+ content.RenderTransformOrigin = new RelativePoint(0.5, 0, RelativeUnit.Relative);
+ ShellMotion.Set(content, 0, "scale(0.95)");
+ Popup.IsOpen = true;
+ await ShellMotion.To(content, 1, "scale(1)", 120, new CubicEaseOut());
+ }
+ else
+ {
+ ShellPopups.Closed(this);
+ ShellDismiss.Remove(this);
+ if (!Popup.IsOpen) return; // already closed by a click outside
+ await ShellMotion.To(content, 0, "scale(0.95)", 90, new CubicEaseIn());
+ if (!IsOpen) Popup.IsOpen = false;
+ }
+ }
+}
+
+// Content of a modal overlay (DialogContent, DrawerContent, SheetContent): animates itself in and out.
+public interface IShellOverlayContent
+{
+ Task AnimateAsync(bool open);
+}
+
+/* Shared host for Dialog / Drawer / Sheet. Children: an optional *Trigger (rendered in place) and
+ the *Content, which covers the window while Open, so the host can sit next to the button that
+ opens it. */
+public abstract class ShellOverlayHost : ShellFloatingHost
+{
+ public static readonly StyledProperty OpenProperty =
+ AvaloniaProperty.Register(nameof(Open), defaultBindingMode: BindingMode.TwoWay);
+
+ private TopLevel? _top;
+ private int _version;
+
+ static ShellOverlayHost()
+ {
+ OpenProperty.Changed.AddClassHandler((h, _) => h.OnOpenChanged());
+ }
+
+ public bool Open
+ {
+ get => GetValue(OpenProperty);
+ set => SetValue(OpenProperty, value);
+ }
+
+ public event EventHandler? OpenChanged;
+
+ protected ShellOverlayHost()
+ {
+ Popup.IsLightDismissEnabled = false;
+ Popup.Placement = PlacementMode.Center;
+ }
+
+ protected override bool IsContent(Control child) => child is IShellOverlayContent;
+
+ public void SetOpen(bool value) => Open = value;
+
+ // What Escape does while this overlay is on top.
+ protected virtual void Dismiss() => Open = false;
+
+ private async void OnOpenChanged()
+ {
+ var version = ++_version;
+ if (Open) ShellDismiss.Push(this, Dismiss, this);
+ else ShellDismiss.Remove(this);
+ OpenChanged?.Invoke(this, Open);
+ if (FloatingContent is not { } content) return;
+
+ if (Open)
+ {
+ if (TopLevel.GetTopLevel(this) is not { } top) return;
+ if (_top is null)
+ {
+ _top = top;
+ _top.SizeChanged += OnWindowSizeChanged;
+ }
+ Fit(content);
+ Popup.PlacementTarget = top;
+ Popup.IsOpen = true;
+ if (content is IShellOverlayContent animated) await animated.AnimateAsync(true);
+ }
+ else
+ {
+ if (content is IShellOverlayContent animated) await animated.AnimateAsync(false);
+ if (version != _version) return; // reopened while closing
+ Popup.IsOpen = false;
+ if (_top != null) _top.SizeChanged -= OnWindowSizeChanged;
+ _top = null;
+ }
+ }
+
+ private void OnWindowSizeChanged(object? sender, SizeChangedEventArgs e)
+ {
+ if (FloatingContent is { } content) Fit(content);
+ }
+
+ // The content (backdrop + panel) covers the whole window.
+ private void Fit(Control content)
+ {
+ if (_top is null) return;
+ content.Width = _top.ClientSize.Width;
+ content.Height = _top.ClientSize.Height;
+ }
+}
+
+// Implemented by compositional triggers (DialogTrigger, DropdownTrigger, ...).
+public interface IShellTrigger
+{
+ void Activate();
+}
+
+// Marks a control that is itself a tab stop (Button), so a trigger wrapping it doesn't add a second one.
+public interface IShellFocusable { }
+
+/* Wraps the control that opens a component: .
+ A ShellUI Button inside handles its own click and then activates the trigger, the Avalonia
+ take on shadcn's asChild; any other content activates it through the trigger itself. */
+public abstract class ShellTriggerView : Border, IShellTrigger
+{
+ static ShellTriggerView()
+ {
+ ChildProperty.Changed.AddClassHandler((t, _) => t.Focusable = t.Child is not IShellFocusable);
+ }
+
+ protected ShellTriggerView()
+ {
+ Background = Brushes.Transparent;
+ HorizontalAlignment = HorizontalAlignment.Left;
+ Cursor = new Cursor(StandardCursorType.Hand);
+ ShellFocus.Ring(this, this);
+ }
+
+ public void Activate() => OnActivated();
+
+ protected abstract void OnActivated();
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left || !new Rect(Bounds.Size).Contains(e.GetPosition(this))) return;
+ Activate();
+ e.Handled = true;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ Activate();
+ e.Handled = true;
+ }
+
+ // Called by interactive children (Button) after they handle a click.
+ public static void ActivateAncestor(StyledElement from)
+ {
+ for (var p = from.Parent; p != null; p = p.Parent)
+ {
+ if (p is not IShellTrigger trigger) continue;
+ trigger.Activate();
+ return;
+ }
+ }
+}
diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml
index f26baba..76180fd 100644
--- a/examples/Avalonia.Demo/MainWindow.axaml
+++ b/examples/Avalonia.Demo/MainWindow.axaml
@@ -119,6 +119,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs
index 1949666..9377ae5 100644
--- a/examples/Avalonia.Demo/MainWindow.axaml.cs
+++ b/examples/Avalonia.Demo/MainWindow.axaml.cs
@@ -13,6 +13,9 @@ public MainWindow()
{
InitializeComponent();
+ foreach (var item in DemoDropdown.FindDescendantsOfType())
+ item.Clicked += (_, _) => MenuChoice.Text = $"Picked {item.Text}.";
+
var clicks = 0;
LoadingButton.Clicked += async (_, _) =>
{
diff --git a/src/ShellUI.Native.CLI/Program.cs b/src/ShellUI.Native.CLI/Program.cs
index 830ffbd..e835d65 100644
--- a/src/ShellUI.Native.CLI/Program.cs
+++ b/src/ShellUI.Native.CLI/Program.cs
@@ -43,7 +43,7 @@ static Command CreateInitCommand()
{
try
{
- LogoLoader.WriteHeader("Setting up your MAUI project");
+ LogoLoader.WriteHeader("Setting up your project");
await InitService.InitializeAsync(style, force, nonInteractive);
}
catch (Exception ex)
diff --git a/src/ShellUI.Native.Templates/Templates/ButtonTemplate.cs b/src/ShellUI.Native.Templates/Templates/ButtonTemplate.cs
index fba83e4..177301e 100644
--- a/src/ShellUI.Native.Templates/Templates/ButtonTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/ButtonTemplate.cs
@@ -243,7 +243,7 @@ namespace YourProjectNamespace.Components.UI;
// Button with ShellUI variants, sizes, optional icon and loading state.
// Sizes to its content like an inline-flex button; set HorizontalAlignment=""Stretch"" for a block button.
// Usage:
-public class Button : Border
+public class Button : Border, IShellFocusable
{
public static readonly StyledProperty VariantProperty =
AvaloniaProperty.Register(nameof(Variant));
@@ -353,7 +353,10 @@ public Button()
// Clicks in code, as a pointer click or Enter/Space would.
public void Press()
{
- if (CanClick) Clicked?.Invoke(this, EventArgs.Empty);
+ if (!CanClick) return;
+ Clicked?.Invoke(this, EventArgs.Empty);
+ // Inside a DialogTrigger, DropdownTrigger, ... the click also activates the trigger.
+ ShellTriggerView.ActivateAncestor(this);
}
protected override void OnPointerEntered(PointerEventArgs e)
diff --git a/src/ShellUI.Native.Templates/Templates/DialogCloseTemplate.cs b/src/ShellUI.Native.Templates/Templates/DialogCloseTemplate.cs
index 0bca7d3..8a83cc5 100644
--- a/src/ShellUI.Native.Templates/Templates/DialogCloseTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/DialogCloseTemplate.cs
@@ -24,6 +24,14 @@ public partial class DialogClose : ShellTriggerView
{
protected override void OnActivated() => this.FindParentOfType()?.SetOpen(false);
}
+",
+ [NativePlatform.Avalonia] = @"namespace YourProjectNamespace.Components.UI;
+
+// Closes the enclosing Dialog. Wrap a Button:
+public class DialogClose : ShellTriggerView
+{
+ protected override void OnActivated() => this.FindParentOfType()?.SetOpen(false);
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/DialogContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/DialogContentTemplate.cs
index ea8ca43..8407f6e 100644
--- a/src/ShellUI.Native.Templates/Templates/DialogContentTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/DialogContentTemplate.cs
@@ -118,6 +118,113 @@ await Task.WhenAll(
}
}
}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation.Easings;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Dialog panel — backdrop + centered box: max-w-lg rounded-lg border bg-background p-6
+// shadow-lg, close button top-right; fades and zooms in from 95%. XAML children go to Items.
+public class DialogContent : Border, IShellOverlayContent
+{
+ public static readonly StyledProperty ShowCloseButtonProperty =
+ AvaloniaProperty.Register(nameof(ShowCloseButton), true);
+
+ private readonly Border _backdrop;
+ private readonly Border _box;
+ private readonly Border _close;
+ private readonly StackPanel _body = new() { Spacing = 16 };
+
+ static DialogContent()
+ {
+ ShowCloseButtonProperty.Changed.AddClassHandler((c, e) => c._close.IsVisible = (bool)e.NewValue!);
+ }
+
+ public bool ShowCloseButton
+ {
+ get => GetValue(ShowCloseButtonProperty);
+ set => SetValue(ShowCloseButtonProperty, value);
+ }
+
+ [Content]
+ public Controls Items => _body.Children;
+
+ public DialogContent()
+ {
+ _backdrop = new Border();
+ _backdrop.Token(BackgroundProperty, ShellToken.Overlay);
+ _backdrop.PointerPressed += (_, e) =>
+ {
+ this.FindParentOfType()?.SetOpen(false);
+ e.Handled = true;
+ };
+
+ _close = new Border
+ {
+ Child = new Icon { Kind = IconName.X, Size = 16, Token = ShellToken.MutedForeground },
+ Width = 24,
+ Height = 24,
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm),
+ Background = Brushes.Transparent,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, -8, -8, 0),
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ AutomationProperties.SetName(_close, ""Close"");
+ _close.PointerEntered += (_, _) => _close.Token(BackgroundProperty, ShellToken.Accent);
+ _close.PointerExited += (_, _) => { _close.ClearToken(BackgroundProperty); _close.Background = Brushes.Transparent; };
+ _close.PointerReleased += (_, e) =>
+ {
+ this.FindParentOfType()?.SetOpen(false);
+ e.Handled = true;
+ };
+
+ _box = new Border
+ {
+ Child = new Panel { Children = { _body, _close } },
+ Padding = new Thickness(24),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusLg),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 10, Blur = 24, Color = ShellTheme.Shadow(0.2) })
+ };
+ _box.Token(BackgroundProperty, ShellToken.Background).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(512, e.NewSize.Width - 32));
+ }
+
+ 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/DialogDescriptionTemplate.cs b/src/ShellUI.Native.Templates/Templates/DialogDescriptionTemplate.cs
index 2ef460d..72fba22 100644
--- a/src/ShellUI.Native.Templates/Templates/DialogDescriptionTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/DialogDescriptionTemplate.cs
@@ -28,6 +28,22 @@ public DialogDescription()
this.Token(TextColorProperty, ShellToken.MutedForeground);
}
}
+",
+ [NativePlatform.Avalonia] = @"using Avalonia.Controls;
+using Avalonia.Media;
+
+namespace YourProjectNamespace.Components.UI;
+
+// text-sm text-muted-foreground
+public class DialogDescription : TextBlock
+{
+ public DialogDescription()
+ {
+ FontSize = 14;
+ TextWrapping = TextWrapping.Wrap;
+ this.Token(ForegroundProperty, ShellToken.MutedForeground);
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/DialogFooterTemplate.cs b/src/ShellUI.Native.Templates/Templates/DialogFooterTemplate.cs
index 8bd3d1a..20f66aa 100644
--- a/src/ShellUI.Native.Templates/Templates/DialogFooterTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/DialogFooterTemplate.cs
@@ -38,6 +38,24 @@ public DialogFooter()
Content = _stack;
}
}
+",
+ [NativePlatform.Avalonia] = @"using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Actions row — flex justify-end gap-2.
+public class DialogFooter : StackPanel
+{
+ public DialogFooter()
+ {
+ Orientation = Orientation.Horizontal;
+ Spacing = 8;
+ HorizontalAlignment = HorizontalAlignment.Right;
+ Margin = new Thickness(0, 8, 0, 0);
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/DialogHeaderTemplate.cs b/src/ShellUI.Native.Templates/Templates/DialogHeaderTemplate.cs
index bab18ed..124898c 100644
--- a/src/ShellUI.Native.Templates/Templates/DialogHeaderTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/DialogHeaderTemplate.cs
@@ -33,6 +33,21 @@ public DialogHeader()
Content = _stack;
}
}
+",
+ [NativePlatform.Avalonia] = @"using Avalonia;
+using Avalonia.Controls;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Title + description stack — flex flex-col space-y-1.5. Leaves room for the close button.
+public class DialogHeader : StackPanel
+{
+ public DialogHeader()
+ {
+ Spacing = 6;
+ Margin = new Thickness(0, 0, 24, 0);
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/DialogTemplate.cs b/src/ShellUI.Native.Templates/Templates/DialogTemplate.cs
index 0a36f7e..f00ca9f 100644
--- a/src/ShellUI.Native.Templates/Templates/DialogTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/DialogTemplate.cs
@@ -29,6 +29,18 @@ public partial class Dialog : ShellOverlayHost
{
protected override bool IsTrigger(Element child) => child is DialogTrigger;
}
+",
+ [NativePlatform.Avalonia] = @"namespace YourProjectNamespace.Components.UI;
+
+// Modal dialog. Declare it anywhere; the content covers the window while open:
+//
+// (optional)
+// ...
+//
+// Open from code with ConfirmDialog.SetOpen(true).
+public class Dialog : ShellOverlayHost
+{
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/DialogTitleTemplate.cs b/src/ShellUI.Native.Templates/Templates/DialogTitleTemplate.cs
index c70afe2..98b6dd1 100644
--- a/src/ShellUI.Native.Templates/Templates/DialogTitleTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/DialogTitleTemplate.cs
@@ -29,6 +29,23 @@ public DialogTitle()
this.Token(TextColorProperty, ShellToken.Foreground);
}
}
+",
+ [NativePlatform.Avalonia] = @"using Avalonia.Controls;
+using Avalonia.Media;
+
+namespace YourProjectNamespace.Components.UI;
+
+// text-lg font-semibold leading-none
+public class DialogTitle : TextBlock
+{
+ public DialogTitle()
+ {
+ FontSize = 18;
+ FontWeight = FontWeight.SemiBold;
+ TextWrapping = TextWrapping.Wrap;
+ this.Token(ForegroundProperty, ShellToken.Foreground);
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/DialogTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/DialogTriggerTemplate.cs
index 5e900fe..c60e4a3 100644
--- a/src/ShellUI.Native.Templates/Templates/DialogTriggerTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/DialogTriggerTemplate.cs
@@ -24,6 +24,14 @@ public partial class DialogTrigger : ShellTriggerView
{
protected override void OnActivated() => this.FindParentOfType()?.SetOpen(true);
}
+",
+ [NativePlatform.Avalonia] = @"namespace YourProjectNamespace.Components.UI;
+
+// Opens the enclosing Dialog. Usage:
+public class DialogTrigger : ShellTriggerView
+{
+ protected override void OnActivated() => this.FindParentOfType()?.SetOpen(true);
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/DrawerContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/DrawerContentTemplate.cs
index b866d41..7ae8a37 100644
--- a/src/ShellUI.Native.Templates/Templates/DrawerContentTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/DrawerContentTemplate.cs
@@ -147,6 +147,134 @@ await Task.WhenAll(
}
}
}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Drawer panel — rounded-t-[10px] bg-background with a grab handle (h-2 w-[100px] bg-muted),
+// sized to its content (max 80% of the window), sliding in from its edge over a dimmed backdrop.
+// XAML children go to Items.
+public class DrawerContent : Border, IShellOverlayContent
+{
+ private readonly Border _backdrop;
+ private readonly Border _panel;
+ private readonly Border _handle;
+ private readonly StackPanel _body = new() { Spacing = 16, Margin = new Thickness(16, 16, 16, 24) };
+
+ [Content]
+ public Controls Items => _body.Children;
+
+ public DrawerContent()
+ {
+ _backdrop = new Border();
+ _backdrop.Token(BackgroundProperty, ShellToken.Overlay);
+ _backdrop.PointerPressed += (_, e) =>
+ {
+ this.FindParentOfType()?.SetOpen(false);
+ e.Handled = true;
+ };
+
+ _handle = new Border
+ {
+ Height = 6,
+ Width = 100,
+ CornerRadius = new CornerRadius(3),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Margin = new Thickness(0, 12, 0, 0)
+ };
+ _handle.Token(BackgroundProperty, ShellToken.Muted);
+
+ var stack = new DockPanel();
+ DockPanel.SetDock(_handle, Dock.Top);
+ stack.Children.Add(_handle);
+ stack.Children.Add(new ScrollViewer { Content = _body });
+
+ _panel = new Border
+ {
+ Child = stack,
+ BorderThickness = new Thickness(1),
+ BoxShadow = new BoxShadows(new BoxShadow { OffsetY = -4, Blur = 16, Color = ShellTheme.Shadow(0.15) })
+ };
+ _panel.Token(BackgroundProperty, ShellToken.Background).Token(BorderBrushProperty, ShellToken.Border);
+ _panel.PointerPressed += (_, e) => e.Handled = true;
+
+ Child = new Panel { Children = { _backdrop, _panel } };
+ SizeChanged += (_, _) => ApplySide();
+ }
+
+ private DrawerSide Side => this.FindParentOfType()?.Side ?? DrawerSide.Bottom;
+
+ private void ApplySide()
+ {
+ var side = Side;
+ var horizontal = side is DrawerSide.Top or DrawerSide.Bottom;
+ _panel.VerticalAlignment = side switch
+ {
+ DrawerSide.Top => VerticalAlignment.Top,
+ DrawerSide.Bottom => VerticalAlignment.Bottom,
+ _ => VerticalAlignment.Stretch
+ };
+ _panel.HorizontalAlignment = side switch
+ {
+ DrawerSide.Left => HorizontalAlignment.Left,
+ DrawerSide.Right => HorizontalAlignment.Right,
+ _ => HorizontalAlignment.Stretch
+ };
+ _panel.Width = horizontal ? double.NaN : Math.Min(320, Bounds.Width * 0.85);
+ _panel.MaxHeight = horizontal && Bounds.Height > 0 ? Bounds.Height * 0.8 : double.PositiveInfinity;
+ _handle.IsVisible = side == DrawerSide.Bottom;
+ const double r = 10;
+ _panel.CornerRadius = side switch
+ {
+ DrawerSide.Bottom => new CornerRadius(r, r, 0, 0),
+ DrawerSide.Top => new CornerRadius(0, 0, r, r),
+ DrawerSide.Left => new CornerRadius(0, r, r, 0),
+ _ => new CornerRadius(r, 0, 0, r)
+ };
+ }
+
+ // translate() that puts the panel just past its edge; invariant, so it parses in any culture.
+ private string Offscreen()
+ {
+ var (x, y) = Side switch
+ {
+ DrawerSide.Bottom => (0, _panel.Bounds.Height + 24),
+ DrawerSide.Top => (0, -_panel.Bounds.Height - 24),
+ DrawerSide.Left => (-_panel.Bounds.Width - 24, 0),
+ _ => (_panel.Bounds.Width + 24, 0d)
+ };
+ return FormattableString.Invariant($""translate({x}px, {y}px)"");
+ }
+
+ public async Task AnimateAsync(bool open)
+ {
+ if (open)
+ {
+ ShellMotion.Set(_backdrop, 0, ""scale(1)"");
+ ShellMotion.Set(_panel, 0, ""scale(1)"");
+ ApplySide();
+ await ShellMotion.WaitForLayoutAsync(_panel);
+ ShellMotion.Set(_panel, 1, Offscreen());
+ await Task.WhenAll(
+ ShellMotion.To(_backdrop, 1, ""scale(1)"", 200, new CubicEaseOut()),
+ ShellMotion.To(_panel, 1, ""translate(0px, 0px)"", 300, new CubicEaseOut()));
+ }
+ else
+ {
+ await Task.WhenAll(
+ ShellMotion.To(_backdrop, 0, ""scale(1)"", 200, new CubicEaseIn()),
+ ShellMotion.To(_panel, 1, Offscreen(), 220, new CubicEaseIn()));
+ }
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/DrawerTemplate.cs b/src/ShellUI.Native.Templates/Templates/DrawerTemplate.cs
index d321371..a44526c 100644
--- a/src/ShellUI.Native.Templates/Templates/DrawerTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/DrawerTemplate.cs
@@ -36,6 +36,26 @@ public DrawerSide Side
protected override bool IsTrigger(Element child) => child is DrawerTrigger;
}
+public enum DrawerSide { Left, Right, Top, Bottom }
+",
+ [NativePlatform.Avalonia] = @"using Avalonia;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Edge drawer (defaults to bottom, like vaul). Declare it anywhere:
+// ...
+public class Drawer : ShellOverlayHost
+{
+ public static readonly StyledProperty SideProperty =
+ AvaloniaProperty.Register(nameof(Side), DrawerSide.Bottom);
+
+ public DrawerSide Side
+ {
+ get => GetValue(SideProperty);
+ set => SetValue(SideProperty, value);
+ }
+}
+
public enum DrawerSide { Left, Right, Top, Bottom }
"
};
diff --git a/src/ShellUI.Native.Templates/Templates/DrawerTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/DrawerTriggerTemplate.cs
index 49bb0c1..3b1386e 100644
--- a/src/ShellUI.Native.Templates/Templates/DrawerTriggerTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/DrawerTriggerTemplate.cs
@@ -24,6 +24,14 @@ public partial class DrawerTrigger : ShellTriggerView
{
protected override void OnActivated() => this.FindParentOfType()?.SetOpen(true);
}
+",
+ [NativePlatform.Avalonia] = @"namespace YourProjectNamespace.Components.UI;
+
+// Opens the enclosing Drawer. Usage:
+public class DrawerTrigger : ShellTriggerView
+{
+ protected override void OnActivated() => this.FindParentOfType()?.SetOpen(true);
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/DropdownContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/DropdownContentTemplate.cs
index bcb42df..fd303c6 100644
--- a/src/ShellUI.Native.Templates/Templates/DropdownContentTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/DropdownContentTemplate.cs
@@ -46,6 +46,32 @@ public DropdownContent()
Content = panel;
}
}
+",
+ [NativePlatform.Avalonia] = @"using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Metadata;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Menu panel — min-w-[8rem] rounded-md border bg-popover p-1 shadow-md. XAML children go to Items.
+public class DropdownContent : Border
+{
+ private readonly StackPanel _stack = new();
+
+ [Content]
+ public Controls Items => _stack.Children;
+
+ public DropdownContent()
+ {
+ Child = _stack;
+ Padding = new Thickness(4);
+ MinWidth = 180;
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ BoxShadow = ShellPopups.PanelShadow();
+ this.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/DropdownItemTemplate.cs b/src/ShellUI.Native.Templates/Templates/DropdownItemTemplate.cs
index 36a282f..1a5ee03 100644
--- a/src/ShellUI.Native.Templates/Templates/DropdownItemTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/DropdownItemTemplate.cs
@@ -91,6 +91,107 @@ private void UpdateIcon()
_icon.IsVisible = Icon != IconName.None;
}
}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Menu row — rounded-sm px-2 py-1.5 text-sm, hover:bg-accent, optional leading icon.
+public class DropdownItem : Border
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ private readonly Icon _icon;
+ private readonly TextBlock _label;
+
+ static DropdownItem()
+ {
+ TextProperty.Changed.AddClassHandler((i, e) => i._label.Text = (string?)e.NewValue ?? string.Empty);
+ IconProperty.Changed.AddClassHandler((i, _) => i.UpdateIcon());
+ IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Opacity = i.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ public DropdownItem()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground };
+ _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center };
+ _label.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground);
+
+ Child = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { _icon, _label } };
+ Height = 32;
+ Padding = new Thickness(8, 0);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm);
+ Background = Brushes.Transparent;
+ Cursor = new Cursor(StandardCursorType.Hand);
+ ShellFocus.Ring(this, this);
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ 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);
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ Press();
+ e.Handled = true;
+ }
+
+ // Picks the item in code, as a click or Enter/Space would.
+ public void Press()
+ {
+ if (!IsEffectivelyEnabled) return;
+ this.FindParentOfType()?.Close();
+ Clicked?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void UpdateIcon()
+ {
+ _icon.Kind = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/DropdownTemplate.cs b/src/ShellUI.Native.Templates/Templates/DropdownTemplate.cs
index 326bca7..881c6e0 100644
--- a/src/ShellUI.Native.Templates/Templates/DropdownTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/DropdownTemplate.cs
@@ -35,6 +35,23 @@ public partial class Dropdown : ShellPopoverHost
public void ToggleAsync() => Toggle();
public void CloseAsync() => Close();
}
+",
+ [NativePlatform.Avalonia] = @"using Avalonia.Controls;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Dropdown menu. The content floats below the trigger (above it when there is no room);
+// clicking outside, picking an item, Escape or opening another menu closes it.
+//
+//
+//
+//
+//
+//
+public class Dropdown : ShellPopoverHost
+{
+ protected override bool IsContent(Control child) => child is DropdownContent;
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/DropdownTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/DropdownTriggerTemplate.cs
index 3373782..4ef5e51 100644
--- a/src/ShellUI.Native.Templates/Templates/DropdownTriggerTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/DropdownTriggerTemplate.cs
@@ -24,6 +24,14 @@ public partial class DropdownTrigger : ShellTriggerView
{
protected override void OnActivated() => this.FindParentOfType()?.Toggle();
}
+",
+ [NativePlatform.Avalonia] = @"namespace YourProjectNamespace.Components.UI;
+
+// Toggles the enclosing Dropdown. Wrap a Button or any control.
+public class DropdownTrigger : ShellTriggerView
+{
+ protected override void OnActivated() => this.FindParentOfType()?.Toggle();
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/ElementExtensionsTemplate.cs b/src/ShellUI.Native.Templates/Templates/ElementExtensionsTemplate.cs
index 549baeb..c656e8b 100644
--- a/src/ShellUI.Native.Templates/Templates/ElementExtensionsTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/ElementExtensionsTemplate.cs
@@ -98,6 +98,39 @@ public static Task AnimateExpandAsync(this View view, bool open, uint length = 2
return tcs.Task;
}
}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.LogicalTree;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Logical-tree helpers for compositional components (Dialog, Dropdown, ...).
+public static class ElementExtensions
+{
+ // Nearest logical ancestor of type T. Floating content's logical parent is its Popup, so a
+ // part shown in the overlay layer (DialogClose, DropdownItem) still finds its component.
+ public static T? FindParentOfType(this StyledElement element) where T : class
+ {
+ for (var p = element.Parent; p != null; p = p.Parent)
+ if (p is T match) return match;
+ return null;
+ }
+
+ // Every logical descendant of type T, depth-first. Stops descending into an element that
+ // matches `stopAt` (a Tabs inside a Tabs keeps its own triggers).
+ public static IEnumerable FindDescendantsOfType(this ILogical root, Func? stopAt = null)
+ {
+ foreach (var child in root.LogicalChildren)
+ {
+ if (child is T match) yield return match;
+ if (stopAt != null && stopAt(child)) continue;
+ foreach (var nested in child.FindDescendantsOfType(stopAt))
+ yield return nested;
+ }
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/PopoverContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/PopoverContentTemplate.cs
index 3deea92..4eda0fc 100644
--- a/src/ShellUI.Native.Templates/Templates/PopoverContentTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/PopoverContentTemplate.cs
@@ -46,6 +46,32 @@ public PopoverContent()
Content = panel;
}
}
+",
+ [NativePlatform.Avalonia] = @"using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Metadata;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Popover panel — w-72 rounded-md border bg-popover p-4 shadow-md. XAML children go to Items.
+public class PopoverContent : Border
+{
+ private readonly StackPanel _stack = new() { Spacing = 8 };
+
+ [Content]
+ public Controls Items => _stack.Children;
+
+ public PopoverContent()
+ {
+ Child = _stack;
+ Padding = new Thickness(16);
+ Width = 288;
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ BoxShadow = ShellPopups.PanelShadow();
+ this.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/PopoverTemplate.cs b/src/ShellUI.Native.Templates/Templates/PopoverTemplate.cs
index a6d02e5..5b3e325 100644
--- a/src/ShellUI.Native.Templates/Templates/PopoverTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/PopoverTemplate.cs
@@ -32,6 +32,20 @@ public partial class Popover : ShellPopoverHost
public void ToggleAsync() => Toggle();
public void CloseAsync() => Close();
}
+",
+ [NativePlatform.Avalonia] = @"using Avalonia.Controls;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Popover — a panel that floats next to its trigger; clicking outside or Escape closes it.
+//
+//
+// ...
+//
+public class Popover : ShellPopoverHost
+{
+ protected override bool IsContent(Control child) => child is PopoverContent;
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/PopoverTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/PopoverTriggerTemplate.cs
index 6f56d15..258e981 100644
--- a/src/ShellUI.Native.Templates/Templates/PopoverTriggerTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/PopoverTriggerTemplate.cs
@@ -24,6 +24,14 @@ public partial class PopoverTrigger : ShellTriggerView
{
protected override void OnActivated() => this.FindParentOfType()?.Toggle();
}
+",
+ [NativePlatform.Avalonia] = @"namespace YourProjectNamespace.Components.UI;
+
+// Toggles the enclosing Popover. Wrap a Button or any control.
+public class PopoverTrigger : ShellTriggerView
+{
+ protected override void OnActivated() => this.FindParentOfType()?.Toggle();
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/SheetContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/SheetContentTemplate.cs
index b730ecf..b5d325a 100644
--- a/src/ShellUI.Native.Templates/Templates/SheetContentTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/SheetContentTemplate.cs
@@ -145,6 +145,131 @@ await Task.WhenAll(
}
}
}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation.Easings;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Sheet panel — full-height edge panel, w-3/4 sm:max-w-sm, bg-background p-6 shadow-lg,
+// close button top-right, slides in from its side over a dimmed backdrop. XAML children go to Items.
+public class SheetContent : Border, IShellOverlayContent
+{
+ private readonly Border _backdrop;
+ private readonly Border _panel;
+ private readonly StackPanel _body = new() { Spacing = 16, Margin = new Thickness(0, 0, 24, 0) };
+
+ [Content]
+ public Controls Items => _body.Children;
+
+ public SheetContent()
+ {
+ _backdrop = new Border();
+ _backdrop.Token(BackgroundProperty, ShellToken.Overlay);
+ _backdrop.PointerPressed += (_, e) =>
+ {
+ this.FindParentOfType()?.SetOpen(false);
+ e.Handled = true;
+ };
+
+ var close = new Border
+ {
+ Child = new Icon { Kind = IconName.X, Size = 16, Token = ShellToken.MutedForeground },
+ Width = 24,
+ Height = 24,
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm),
+ Background = Brushes.Transparent,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, -8, -8, 0),
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ AutomationProperties.SetName(close, ""Close"");
+ close.PointerEntered += (_, _) => close.Token(BackgroundProperty, ShellToken.Accent);
+ close.PointerExited += (_, _) => { close.ClearToken(BackgroundProperty); close.Background = Brushes.Transparent; };
+ close.PointerReleased += (_, e) =>
+ {
+ this.FindParentOfType()?.SetOpen(false);
+ e.Handled = true;
+ };
+
+ _panel = new Border
+ {
+ Child = new Panel { Children = { new ScrollViewer { Content = _body }, close } },
+ Padding = new Thickness(24),
+ BorderThickness = new Thickness(1),
+ BoxShadow = new BoxShadows(new BoxShadow { Blur = 24, Color = ShellTheme.Shadow(0.2) })
+ };
+ _panel.Token(BackgroundProperty, ShellToken.Background).Token(BorderBrushProperty, ShellToken.Border);
+ _panel.PointerPressed += (_, e) => e.Handled = true;
+
+ Child = new Panel { Children = { _backdrop, _panel } };
+ SizeChanged += (_, _) => ApplySide();
+ }
+
+ private SheetSide Side => this.FindParentOfType()?.Side ?? SheetSide.Right;
+
+ private void ApplySide()
+ {
+ var side = Side;
+ var vertical = side is SheetSide.Left or SheetSide.Right;
+ _panel.VerticalAlignment = side switch
+ {
+ SheetSide.Top => VerticalAlignment.Top,
+ SheetSide.Bottom => VerticalAlignment.Bottom,
+ _ => VerticalAlignment.Stretch
+ };
+ _panel.HorizontalAlignment = side switch
+ {
+ SheetSide.Left => HorizontalAlignment.Left,
+ SheetSide.Right => HorizontalAlignment.Right,
+ _ => HorizontalAlignment.Stretch
+ };
+ _panel.Width = vertical ? Math.Min(384, Bounds.Width * 0.75) : double.NaN;
+ }
+
+ // translate() that puts the panel just past its edge; invariant, so it parses in any culture.
+ private string Offscreen()
+ {
+ var (x, y) = Side switch
+ {
+ SheetSide.Left => (-_panel.Bounds.Width - 32, 0),
+ SheetSide.Top => (0, -_panel.Bounds.Height - 32),
+ SheetSide.Bottom => (0, _panel.Bounds.Height + 32),
+ _ => (_panel.Bounds.Width + 32, 0d)
+ };
+ return FormattableString.Invariant($""translate({x}px, {y}px)"");
+ }
+
+ public async Task AnimateAsync(bool open)
+ {
+ if (open)
+ {
+ ShellMotion.Set(_backdrop, 0, ""scale(1)"");
+ ShellMotion.Set(_panel, 0, ""scale(1)"");
+ ApplySide();
+ await ShellMotion.WaitForLayoutAsync(_panel);
+ ShellMotion.Set(_panel, 1, Offscreen());
+ await Task.WhenAll(
+ ShellMotion.To(_backdrop, 1, ""scale(1)"", 200, new CubicEaseOut()),
+ ShellMotion.To(_panel, 1, ""translate(0px, 0px)"", 350, new CubicEaseOut()));
+ }
+ else
+ {
+ await Task.WhenAll(
+ ShellMotion.To(_backdrop, 0, ""scale(1)"", 200, new CubicEaseIn()),
+ ShellMotion.To(_panel, 1, Offscreen(), 250, new CubicEaseIn()));
+ }
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/SheetTemplate.cs b/src/ShellUI.Native.Templates/Templates/SheetTemplate.cs
index 1edb032..0373e76 100644
--- a/src/ShellUI.Native.Templates/Templates/SheetTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/SheetTemplate.cs
@@ -36,6 +36,26 @@ public SheetSide Side
protected override bool IsTrigger(Element child) => child is SheetTrigger;
}
+public enum SheetSide { Left, Right, Top, Bottom }
+",
+ [NativePlatform.Avalonia] = @"using Avalonia;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Side sheet (defaults to right). Declare it anywhere:
+// ...
+public class Sheet : ShellOverlayHost
+{
+ public static readonly StyledProperty SideProperty =
+ AvaloniaProperty.Register(nameof(Side), SheetSide.Right);
+
+ public SheetSide Side
+ {
+ get => GetValue(SideProperty);
+ set => SetValue(SideProperty, value);
+ }
+}
+
public enum SheetSide { Left, Right, Top, Bottom }
"
};
diff --git a/src/ShellUI.Native.Templates/Templates/SheetTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/SheetTriggerTemplate.cs
index 2767d59..9f80041 100644
--- a/src/ShellUI.Native.Templates/Templates/SheetTriggerTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/SheetTriggerTemplate.cs
@@ -24,6 +24,14 @@ public partial class SheetTrigger : ShellTriggerView
{
protected override void OnActivated() => this.FindParentOfType()?.SetOpen(true);
}
+",
+ [NativePlatform.Avalonia] = @"namespace YourProjectNamespace.Components.UI;
+
+// Opens the enclosing Sheet. Usage:
+public class SheetTrigger : ShellTriggerView
+{
+ protected override void OnActivated() => this.FindParentOfType()?.SetOpen(true);
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs
index b678353..6ae57f1 100644
--- a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs
@@ -1062,12 +1062,22 @@ public static void ActivateAncestor(Element from)
",
[NativePlatform.Avalonia] = @"using System;
using System.Collections.Generic;
+using System.Collections.Specialized;
using System.Linq;
using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Data;
using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
using Avalonia.Media;
+using Avalonia.Media.Transformation;
+using Avalonia.Metadata;
using Avalonia.Styling;
namespace YourProjectNamespace.Components.UI;
@@ -1299,6 +1309,347 @@ public static void Ring(Control control, Border target)
new BoxShadow { Spread = 2, Color = ShellTheme.Get(ShellToken.Background) },
new[] { new BoxShadow { Spread = 4, Color = ShellTheme.Get(ShellToken.Ring) } });
}
+
+// Fade + transform transitions for overlays opening and closing.
+public static class ShellMotion
+{
+ // Jumps to the values without animating (the starting point of an entrance).
+ public static void Set(Visual visual, double opacity, string transform)
+ {
+ visual.Transitions = null;
+ visual.Opacity = opacity;
+ visual.RenderTransform = TransformOperations.Parse(transform);
+ }
+
+ // Animates from the current values; completes when the transition has run.
+ public static Task To(Visual visual, double opacity, string transform, int milliseconds, Easing easing)
+ {
+ var duration = TimeSpan.FromMilliseconds(milliseconds);
+ visual.Transitions = new Transitions
+ {
+ new DoubleTransition { Property = Visual.OpacityProperty, Duration = duration, Easing = easing },
+ new TransformOperationsTransition { Property = Visual.RenderTransformProperty, Duration = duration, Easing = easing }
+ };
+ visual.Opacity = opacity;
+ visual.RenderTransform = TransformOperations.Parse(transform);
+ return Task.Delay(duration);
+ }
+
+ // Waits until a freshly shown control has a size (slide-ins start one size away).
+ public static async Task WaitForLayoutAsync(Control control)
+ {
+ for (var i = 0; i < 20 && (control.Bounds.Width <= 0 || control.Bounds.Height <= 0); i++)
+ await Task.Delay(16);
+ }
+}
+
+// Open overlays and popups, newest last. Escape closes the top one; with nothing open the key
+// keeps its normal behavior.
+public static class ShellDismiss
+{
+ private static readonly List<(object Key, Action Close)> Open = new();
+ private static readonly ConditionalWeakTable Hooked = new();
+
+ public static void Push(object key, Action close, Visual from)
+ {
+ Open.RemoveAll(entry => ReferenceEquals(entry.Key, key));
+ Open.Add((key, close));
+ if (TopLevel.GetTopLevel(from) is not { } top || Hooked.TryGetValue(top, out _)) return;
+ Hooked.Add(top, new object());
+ // Tunnel, so Escape reaches us before a focused control (e.g. a TextBox) handles it.
+ top.AddHandler(InputElement.KeyDownEvent, (_, e) =>
+ {
+ if (e.Key == Key.Escape && DismissTop()) e.Handled = true;
+ }, RoutingStrategies.Tunnel);
+ }
+
+ public static void Remove(object key) => Open.RemoveAll(entry => ReferenceEquals(entry.Key, key));
+
+ // Closes the most recently opened overlay. False when nothing is open.
+ public static bool DismissTop()
+ {
+ if (Open.Count == 0) return false;
+ var top = Open[^1];
+ Open.RemoveAt(Open.Count - 1);
+ top.Close();
+ return true;
+ }
+}
+
+// Floating panels that close on a click outside (Dropdown, Popover). Only one is open at a time:
+// opening another closes the previous one.
+public interface IShellPopup
+{
+ void Close();
+}
+
+public static class ShellPopups
+{
+ private static WeakReference? _open;
+
+ public static void Opened(IShellPopup popup)
+ {
+ if (_open != null && _open.TryGetTarget(out var previous) && !ReferenceEquals(previous, popup))
+ previous.Close();
+ _open = new WeakReference(popup);
+ }
+
+ public static void Closed(IShellPopup popup)
+ {
+ if (_open != null && _open.TryGetTarget(out var current) && ReferenceEquals(current, popup))
+ _open = null;
+ }
+
+ // shadow-md
+ public static BoxShadows PanelShadow() => new(new BoxShadow { OffsetY = 4, Blur = 12, Color = ShellTheme.Shadow(0.12) });
+}
+
+/* Base for a component with a trigger and floating content. XAML children go to Items: the
+ content part is shown in a Popup in the window's overlay layer, everything else (the trigger)
+ renders in place. The Popup keeps the content in the logical tree, so it inherits the theme
+ and DataContext, and its parts find the component with FindParentOfType. */
+public abstract class ShellFloatingHost : Border
+{
+ private readonly Panel _inline = new();
+ protected readonly Popup Popup = new() { ShouldUseOverlayLayer = true };
+
+ protected Control? FloatingContent { get; private set; }
+
+ [Content]
+ public Controls Items { get; } = new();
+
+ protected ShellFloatingHost()
+ {
+ _inline.Children.Add(Popup);
+ Child = _inline;
+ HorizontalAlignment = HorizontalAlignment.Left;
+ VerticalAlignment = VerticalAlignment.Top;
+ Items.CollectionChanged += OnItemsChanged;
+ }
+
+ protected abstract bool IsContent(Control child);
+
+ // Rebuilt from Items on every change; Clear reports no old items, so diffing isn't worth it.
+ private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ {
+ Popup.Child = null;
+ FloatingContent = null;
+ _inline.Children.RemoveAll(_inline.Children.Where(c => c != Popup).ToList());
+ foreach (var child in Items)
+ {
+ if (IsContent(child)) Popup.Child = FloatingContent = child;
+ else _inline.Children.Insert(_inline.Children.Count - 1, child);
+ }
+ }
+}
+
+// Shared host for trigger + floating panel (Dropdown, Popover). Clicking outside closes it.
+public abstract class ShellPopoverHost : ShellFloatingHost, IShellPopup
+{
+ public static readonly StyledProperty IsOpenProperty =
+ AvaloniaProperty.Register(nameof(IsOpen), defaultBindingMode: BindingMode.TwoWay);
+
+ static ShellPopoverHost()
+ {
+ IsOpenProperty.Changed.AddClassHandler((h, _) => h.OnOpenChanged());
+ }
+
+ public bool IsOpen
+ {
+ get => GetValue(IsOpenProperty);
+ set => SetValue(IsOpenProperty, value);
+ }
+
+ public event EventHandler? IsOpenChanged;
+
+ protected ShellPopoverHost()
+ {
+ Popup.PlacementTarget = this;
+ Popup.IsLightDismissEnabled = true;
+ // The click that dismisses (even on the trigger) does nothing else, so it can't reopen.
+ Popup.OverlayDismissEventPassThrough = false;
+ Popup.Closed += (_, _) => IsOpen = false;
+ }
+
+ protected virtual PlacementMode Placement => PlacementMode.BottomEdgeAlignedLeft;
+ protected virtual double Offset => 4;
+
+ public void SetOpen(bool value) => IsOpen = value;
+ public void Toggle() => IsOpen = !IsOpen;
+ public void Close() => IsOpen = false;
+
+ private async void OnOpenChanged()
+ {
+ IsOpenChanged?.Invoke(this, IsOpen);
+ if (FloatingContent is not { } content) return;
+ if (IsOpen)
+ {
+ ShellPopups.Opened(this);
+ ShellDismiss.Push(this, Close, this);
+ Popup.Placement = Placement;
+ Popup.VerticalOffset = Offset;
+ content.RenderTransformOrigin = new RelativePoint(0.5, 0, RelativeUnit.Relative);
+ ShellMotion.Set(content, 0, ""scale(0.95)"");
+ Popup.IsOpen = true;
+ await ShellMotion.To(content, 1, ""scale(1)"", 120, new CubicEaseOut());
+ }
+ else
+ {
+ ShellPopups.Closed(this);
+ ShellDismiss.Remove(this);
+ if (!Popup.IsOpen) return; // already closed by a click outside
+ await ShellMotion.To(content, 0, ""scale(0.95)"", 90, new CubicEaseIn());
+ if (!IsOpen) Popup.IsOpen = false;
+ }
+ }
+}
+
+// Content of a modal overlay (DialogContent, DrawerContent, SheetContent): animates itself in and out.
+public interface IShellOverlayContent
+{
+ Task AnimateAsync(bool open);
+}
+
+/* Shared host for Dialog / Drawer / Sheet. Children: an optional *Trigger (rendered in place) and
+ the *Content, which covers the window while Open, so the host can sit next to the button that
+ opens it. */
+public abstract class ShellOverlayHost : ShellFloatingHost
+{
+ public static readonly StyledProperty OpenProperty =
+ AvaloniaProperty.Register(nameof(Open), defaultBindingMode: BindingMode.TwoWay);
+
+ private TopLevel? _top;
+ private int _version;
+
+ static ShellOverlayHost()
+ {
+ OpenProperty.Changed.AddClassHandler((h, _) => h.OnOpenChanged());
+ }
+
+ public bool Open
+ {
+ get => GetValue(OpenProperty);
+ set => SetValue(OpenProperty, value);
+ }
+
+ public event EventHandler? OpenChanged;
+
+ protected ShellOverlayHost()
+ {
+ Popup.IsLightDismissEnabled = false;
+ Popup.Placement = PlacementMode.Center;
+ }
+
+ protected override bool IsContent(Control child) => child is IShellOverlayContent;
+
+ public void SetOpen(bool value) => Open = value;
+
+ // What Escape does while this overlay is on top.
+ protected virtual void Dismiss() => Open = false;
+
+ private async void OnOpenChanged()
+ {
+ var version = ++_version;
+ if (Open) ShellDismiss.Push(this, Dismiss, this);
+ else ShellDismiss.Remove(this);
+ OpenChanged?.Invoke(this, Open);
+ if (FloatingContent is not { } content) return;
+
+ if (Open)
+ {
+ if (TopLevel.GetTopLevel(this) is not { } top) return;
+ if (_top is null)
+ {
+ _top = top;
+ _top.SizeChanged += OnWindowSizeChanged;
+ }
+ Fit(content);
+ Popup.PlacementTarget = top;
+ Popup.IsOpen = true;
+ if (content is IShellOverlayContent animated) await animated.AnimateAsync(true);
+ }
+ else
+ {
+ if (content is IShellOverlayContent animated) await animated.AnimateAsync(false);
+ if (version != _version) return; // reopened while closing
+ Popup.IsOpen = false;
+ if (_top != null) _top.SizeChanged -= OnWindowSizeChanged;
+ _top = null;
+ }
+ }
+
+ private void OnWindowSizeChanged(object? sender, SizeChangedEventArgs e)
+ {
+ if (FloatingContent is { } content) Fit(content);
+ }
+
+ // The content (backdrop + panel) covers the whole window.
+ private void Fit(Control content)
+ {
+ if (_top is null) return;
+ content.Width = _top.ClientSize.Width;
+ content.Height = _top.ClientSize.Height;
+ }
+}
+
+// Implemented by compositional triggers (DialogTrigger, DropdownTrigger, ...).
+public interface IShellTrigger
+{
+ void Activate();
+}
+
+// Marks a control that is itself a tab stop (Button), so a trigger wrapping it doesn't add a second one.
+public interface IShellFocusable { }
+
+/* Wraps the control that opens a component: .
+ A ShellUI Button inside handles its own click and then activates the trigger, the Avalonia
+ take on shadcn's asChild; any other content activates it through the trigger itself. */
+public abstract class ShellTriggerView : Border, IShellTrigger
+{
+ static ShellTriggerView()
+ {
+ ChildProperty.Changed.AddClassHandler((t, _) => t.Focusable = t.Child is not IShellFocusable);
+ }
+
+ protected ShellTriggerView()
+ {
+ Background = Brushes.Transparent;
+ HorizontalAlignment = HorizontalAlignment.Left;
+ Cursor = new Cursor(StandardCursorType.Hand);
+ ShellFocus.Ring(this, this);
+ }
+
+ public void Activate() => OnActivated();
+
+ protected abstract void OnActivated();
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left || !new Rect(Bounds.Size).Contains(e.GetPosition(this))) return;
+ Activate();
+ e.Handled = true;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ Activate();
+ e.Handled = true;
+ }
+
+ // Called by interactive children (Button) after they handle a click.
+ public static void ActivateAncestor(StyledElement from)
+ {
+ for (var p = from.Parent; p != null; p = p.Parent)
+ {
+ if (p is not IShellTrigger trigger) continue;
+ trigger.Activate();
+ return;
+ }
+ }
+}
"
};
}
diff --git a/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs
index fb3a609..462c292 100644
--- a/tests/ShellUI.Native.Tests/TemplateContentTests.cs
+++ b/tests/ShellUI.Native.Tests/TemplateContentTests.cs
@@ -20,7 +20,7 @@ public static IEnumerable AvaloniaComponents()
.Where(name => ComponentRegistry.SupportsPlatform(name, NativePlatform.Avalonia))
.Select(name => new object[] { name });
- // Ported so far: the Phase 2 foundation and P0. Grows as components are ported.
+ // Ported so far: the Phase 2 foundation, P0 and P1. Grows as components are ported.
[Theory]
[InlineData("shell")]
[InlineData("icon")]
@@ -39,6 +39,28 @@ public static IEnumerable AvaloniaComponents()
[InlineData("badge")]
[InlineData("progress")]
[InlineData("alert")]
+ [InlineData("element-extensions")]
+ [InlineData("dialog")]
+ [InlineData("dialog-trigger")]
+ [InlineData("dialog-content")]
+ [InlineData("dialog-header")]
+ [InlineData("dialog-footer")]
+ [InlineData("dialog-title")]
+ [InlineData("dialog-description")]
+ [InlineData("dialog-close")]
+ [InlineData("drawer")]
+ [InlineData("drawer-trigger")]
+ [InlineData("drawer-content")]
+ [InlineData("sheet")]
+ [InlineData("sheet-trigger")]
+ [InlineData("sheet-content")]
+ [InlineData("dropdown")]
+ [InlineData("dropdown-trigger")]
+ [InlineData("dropdown-content")]
+ [InlineData("dropdown-item")]
+ [InlineData("popover")]
+ [InlineData("popover-trigger")]
+ [InlineData("popover-content")]
public void Ported_components_have_Avalonia_content(string name)
{
Assert.False(string.IsNullOrWhiteSpace(ComponentRegistry.GetComponentContent(name, NativePlatform.Avalonia)));
@@ -50,8 +72,10 @@ public void Avalonia_template_is_Avalonia_code_with_the_namespace_placeholder(st
{
var content = ComponentRegistry.GetComponentContent(name, NativePlatform.Avalonia)!;
Assert.Contains("namespace YourProjectNamespace.Components.UI", content); // .Variants for button-variants
- Assert.Contains("using Avalonia", content);
+ // Small parts (Dialog, triggers) need no Avalonia using, so check for MAUI types instead.
Assert.DoesNotContain("Microsoft.Maui", content);
+ Assert.DoesNotContain("BindableProperty", content);
+ Assert.DoesNotContain("ContentView", content);
Assert.DoesNotContain("AvaloniaDemo", content);
}