diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md
index 9de62b3..9d53b47 100644
--- a/docs/COMPONENTS.md
+++ b/docs/COMPONENTS.md
@@ -70,8 +70,9 @@ your background so the splash-to-app transition doesn't flash the template's pur
Ported so far: `shell`, `icon`, `theme-toggle`, P0 (`button`, `input`, `label`, `checkbox`,
`switch`, `card` and its parts, `separator`, `badge`, `progress`, `alert`), P1 (`dialog`,
`drawer`, `sheet`, `dropdown`, `popover` and their parts), P2 (`textarea`, `select`, `slider`,
-`radio-group`, `calendar`, `date-picker`, `time-picker`) and P3 (`collapsible`, `accordion`,
-`tabs`, `breadcrumb` and their parts, `skeleton`, `scroll-area`). Not yet published to NuGet. In an
+`radio-group`, `calendar`, `date-picker`, `time-picker`), P3 (`collapsible`, `accordion`,
+`tabs`, `breadcrumb` and their parts, `skeleton`, `scroll-area`) and P4 (`spinner`, `tooltip`,
+`toast`, `alert-dialog`, `hover-card` and its 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
@@ -102,6 +103,10 @@ Same names, properties and variants as MAUI, except:
| Tabs keyboard | Tab and Enter | Also Left / Right to select the neighbouring tab |
| Skeleton `CornerRadius` | `double` | Border's `CornerRadius` (`CornerRadius="24"` still works) |
| ScrollArea scrollbar | Platform scrollbar | Fluent's thin scrollbar, hidden while idle |
+| Tooltip | Shows on hover | Also shows on keyboard focus; `Placement` takes Avalonia's `PlacementMode` (`Top`, `Bottom`, `Left`, `Right`) |
+| Toaster | Declared on a page | Declared in a window; `Toast.Show` without one uses the active window |
+| HoverCard on touch | Tap toggles | Touch, pen and Enter / Space toggle; a mouse click acts on the wrapped control only |
+| AlertDialog extra content | One `Body` view | Any XAML children, stacked between the description and the buttons |
---
diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md
index 910d241..c557d6d 100644
--- a/docs/DEVELOPMENT_PLAN.md
+++ b/docs/DEVELOPMENT_PLAN.md
@@ -35,8 +35,8 @@ main ← Phase 1a (2026-07-05), 1b (PR #2), 1c + 1d (PR #3), 1e (PR #4), brand
| Branch | Base | Status | Purpose |
|--------|------|--------|---------|
-| `main` | — | Phase 1a–1e, release `0.1.0-alpha.1`, Phase 2 foundation and P0–P2 merged | 92 registry entries (89 CLI targets, 58 families); Avalonia templates for the foundation and P0–P2 |
-| `feat/avalonia-p3-navigation` | `main` | **Active** | Phase 2 P3: Avalonia navigation and layout |
+| `main` | — | Phase 1a–1e, release `0.1.0-alpha.1`, Phase 2 foundation and P0–P3 merged | 92 registry entries (89 CLI targets, 58 families); Avalonia templates for the foundation and P0–P3 |
+| `feat/avalonia-p4-feedback` | `main` | **Active** | Phase 2 P4: Avalonia feedback and overlays |
---
@@ -434,7 +434,7 @@ After merge: tag `v0.1.0-alpha.1` on `main` and push the tag.
---
-## Phase 2 — Avalonia (active; one branch per tier, `feat/avalonia-p3-navigation` now)
+## Phase 2 — Avalonia (active; one branch per tier, `feat/avalonia-p4-feedback` now)
Cross-desktop (Windows + macOS + Linux) from one XAML codebase.
@@ -472,6 +472,11 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase.
builds with 0 warnings; checked in the demo in light and dark, including keyboard tab switching.
Also on this branch: floating panels keep their 4px gap when they flip above the trigger, and
both demos use the ShellUI Native tile as their app icon (Android: adaptive, grid in the safe zone)
+- [x] P4: spinner, tooltip, toast, alert-dialog, hover-card (and its parts). Toasts live in the
+ window's overlay layer; AlertDialog builds on `ShellOverlayHost` (focus trap, Escape = Cancel);
+ `ShellFloatingHost.ClaimsChild` lets a host place XAML children itself. `ShellLabel` now wraps
+ like MAUI's Label. Each installs alone into a fresh `dotnet new avalonia.app` and builds with 0
+ warnings; checked in the demo in light and dark
- [ ] 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 dcaf482..ba68dfe 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, P0–P3 components; not on NuGet yet | .NET 10.0 (Avalonia 12) |
+| Avalonia UI | In progress (Phase 2): theme, icons, P0–P4 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/AlertDialog.cs b/examples/Avalonia.Demo/Components/UI/AlertDialog.cs
new file mode 100644
index 0000000..89afa47
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/AlertDialog.cs
@@ -0,0 +1,225 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using AvaloniaDemo.Components.UI.Variants;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Confirmation dialog that requires a choice — no close button, and the backdrop doesn't
+// dismiss it (shadcn AlertDialog). Declare it anywhere; it covers the window while open.
+//
+// From code: if (await DeleteDialog.ShowAsync()) { ... }
+// An optional renders in place; any other XAML children go between the
+// description and the buttons.
+public class AlertDialog : ShellOverlayHost
+{
+ public static readonly StyledProperty TitleProperty =
+ AvaloniaProperty.Register(nameof(Title));
+
+ public static readonly StyledProperty DescriptionProperty =
+ AvaloniaProperty.Register(nameof(Description));
+
+ public static readonly StyledProperty ConfirmTextProperty =
+ AvaloniaProperty.Register(nameof(ConfirmText), "Continue");
+
+ // Empty hides the cancel button.
+ public static readonly StyledProperty CancelTextProperty =
+ AvaloniaProperty.Register(nameof(CancelText), "Cancel");
+
+ public static readonly StyledProperty ConfirmVariantProperty =
+ AvaloniaProperty.Register(nameof(ConfirmVariant));
+
+ private readonly AlertDialogPanel _panel;
+ private TaskCompletionSource? _pending;
+
+ static AlertDialog()
+ {
+ TitleProperty.Changed.AddClassHandler((d, _) => d._panel.Update());
+ DescriptionProperty.Changed.AddClassHandler((d, _) => d._panel.Update());
+ ConfirmTextProperty.Changed.AddClassHandler((d, _) => d._panel.Update());
+ CancelTextProperty.Changed.AddClassHandler((d, _) => d._panel.Update());
+ ConfirmVariantProperty.Changed.AddClassHandler((d, _) => d._panel.Update());
+ }
+
+ public string? Title
+ {
+ get => GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string? Description
+ {
+ get => GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public string? ConfirmText
+ {
+ get => GetValue(ConfirmTextProperty);
+ set => SetValue(ConfirmTextProperty, value);
+ }
+
+ public string? CancelText
+ {
+ get => GetValue(CancelTextProperty);
+ set => SetValue(CancelTextProperty, value);
+ }
+
+ public ButtonVariant ConfirmVariant
+ {
+ get => GetValue(ConfirmVariantProperty);
+ set => SetValue(ConfirmVariantProperty, value);
+ }
+
+ public event EventHandler? Confirmed;
+ public event EventHandler? Cancelled;
+
+ public AlertDialog()
+ {
+ _panel = new AlertDialogPanel(this);
+ Items.Add(_panel);
+ // Runs after the host has sorted Items, so the body gets every child it claimed.
+ Items.CollectionChanged += (_, _) => _panel.SetBody(Items.Where(c => ClaimsChild(c)));
+ _panel.Update();
+ }
+
+ protected override bool IsContent(Control child) => child is AlertDialogPanel;
+
+ protected override bool ClaimsChild(Control child) => child is not (AlertDialogTrigger or AlertDialogPanel);
+
+ // Escape counts as Cancel.
+ protected override void Dismiss() => Resolve(false);
+
+ // Opens the dialog and completes with true (confirm) or false (cancel).
+ public Task ShowAsync()
+ {
+ _pending?.TrySetResult(false);
+ _pending = new TaskCompletionSource();
+ SetOpen(true);
+ return _pending.Task;
+ }
+
+ internal void Resolve(bool confirmed)
+ {
+ SetOpen(false);
+ if (confirmed) Confirmed?.Invoke(this, EventArgs.Empty);
+ else Cancelled?.Invoke(this, EventArgs.Empty);
+ _pending?.TrySetResult(confirmed);
+ _pending = null;
+ }
+}
+
+// Opens the enclosing AlertDialog. Usage:
+public class AlertDialogTrigger : ShellTriggerView
+{
+ protected override void OnActivated() => this.FindParentOfType()?.SetOpen(true);
+}
+
+// Backdrop + centered box: max-w-lg rounded-lg border bg-background p-6 shadow-lg, fade + zoom-in-95.
+internal sealed class AlertDialogPanel : Border, IShellOverlayContent
+{
+ private readonly AlertDialog _owner;
+ private readonly Border _backdrop;
+ private readonly Border _box;
+ private readonly TextBlock _title;
+ private readonly TextBlock _description;
+ private readonly StackPanel _body = new() { Spacing = 8, IsVisible = false };
+ private readonly Button _cancel;
+ private readonly Button _confirm;
+
+ public AlertDialogPanel(AlertDialog owner)
+ {
+ _owner = owner;
+ _backdrop = new Border();
+ _backdrop.Token(BackgroundProperty, ShellToken.Overlay);
+ // Alert dialogs require a choice: a click on the backdrop does nothing.
+ _backdrop.PointerPressed += (_, e) => e.Handled = true;
+
+ _title = new TextBlock { FontSize = 18, FontWeight = FontWeight.SemiBold, TextWrapping = TextWrapping.Wrap };
+ _title.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ _description = new TextBlock { FontSize = 14, TextWrapping = TextWrapping.Wrap };
+ _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+
+ _cancel = new Button { Variant = ButtonVariant.Outline };
+ _cancel.Clicked += (_, _) => _owner.Resolve(false);
+ _confirm = new Button();
+ _confirm.Clicked += (_, _) => _owner.Resolve(true);
+
+ _box = new Border
+ {
+ Child = new StackPanel
+ {
+ Spacing = 8,
+ Children =
+ {
+ _title,
+ _description,
+ _body,
+ new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 8,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Margin = new Thickness(0, 8, 0, 0),
+ Children = { _cancel, _confirm }
+ }
+ }
+ },
+ 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);
+ _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 void Update()
+ {
+ _title.Text = _owner.Title;
+ _description.Text = _owner.Description;
+ _description.IsVisible = !string.IsNullOrEmpty(_owner.Description);
+ _cancel.Text = _owner.CancelText;
+ _cancel.IsVisible = !string.IsNullOrEmpty(_owner.CancelText);
+ _confirm.Text = _owner.ConfirmText;
+ _confirm.Variant = _owner.ConfirmVariant;
+ }
+
+ internal void SetBody(IEnumerable children)
+ {
+ _body.Children.Clear();
+ _body.Children.AddRange(children.ToList());
+ _body.IsVisible = _body.Children.Count > 0;
+ }
+
+ public async Task AnimateAsync(bool open)
+ {
+ if (open)
+ {
+ ShellMotion.Set(_backdrop, 0, "scale(1)");
+ ShellMotion.Set(_box, 0, "scale(0.95)");
+ await Task.WhenAll(
+ ShellMotion.To(_backdrop, 1, "scale(1)", 150, new CubicEaseOut()),
+ ShellMotion.To(_box, 1, "scale(1)", 150, new CubicEaseOut()));
+ }
+ else
+ {
+ await Task.WhenAll(
+ ShellMotion.To(_backdrop, 0, "scale(1)", 120, new CubicEaseIn()),
+ ShellMotion.To(_box, 0, "scale(0.95)", 120, new CubicEaseIn()));
+ }
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/HoverCard.cs b/examples/Avalonia.Demo/Components/UI/HoverCard.cs
new file mode 100644
index 0000000..9464152
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/HoverCard.cs
@@ -0,0 +1,65 @@
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Hover card — rich content that floats under its trigger while the pointer is over either.
+// Touch, pen and the keyboard (Enter / Space on the trigger) toggle it instead.
+//
+//
+// ...
+//
+public class HoverCard : ShellPopoverHost
+{
+ // Milliseconds before the card opens / closes after the pointer enters / leaves.
+ public static readonly StyledProperty OpenDelayProperty =
+ AvaloniaProperty.Register(nameof(OpenDelay), 300);
+
+ public static readonly StyledProperty CloseDelayProperty =
+ AvaloniaProperty.Register(nameof(CloseDelay), 200);
+
+ private int _version;
+
+ public HoverCard()
+ {
+ // Light dismiss lays a window-wide layer under the card, which takes the pointer off the
+ // trigger and closes a card opened by hover; leaving both closes it instead.
+ Popup.IsLightDismissEnabled = false;
+ // When a tap did open it, a click elsewhere closes it and still reaches what was clicked.
+ Popup.OverlayDismissEventPassThrough = true;
+ IsOpenChanged += (_, open) =>
+ {
+ if (!open) Popup.IsLightDismissEnabled = false;
+ };
+ }
+
+ public int OpenDelay
+ {
+ get => GetValue(OpenDelayProperty);
+ set => SetValue(OpenDelayProperty, value);
+ }
+
+ public int CloseDelay
+ {
+ get => GetValue(CloseDelayProperty);
+ set => SetValue(CloseDelayProperty, value);
+ }
+
+ protected override bool IsContent(Control child) => child is HoverCardContent;
+
+ // Touch, pen or keyboard: with no hover to leave, a tap outside closes it.
+ internal void ToggleFromTap()
+ {
+ Popup.IsLightDismissEnabled = !IsOpen;
+ Toggle();
+ }
+
+ // Called by the trigger and the content as the pointer enters / leaves them.
+ internal async void SetHovered(bool hovered)
+ {
+ var version = ++_version;
+ await Task.Delay(hovered ? OpenDelay : CloseDelay);
+ if (version == _version) IsOpen = hovered;
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/HoverCardContent.cs b/examples/Avalonia.Demo/Components/UI/HoverCardContent.cs
new file mode 100644
index 0000000..34a71fb
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/HoverCardContent.cs
@@ -0,0 +1,39 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Hover card panel — w-64 rounded-md border bg-popover p-4 shadow-md. Stays open while hovered.
+// XAML children go to Items.
+public class HoverCardContent : Border
+{
+ private readonly StackPanel _stack = new() { Spacing = 8 };
+
+ [Content]
+ public Controls Items => _stack.Children;
+
+ public HoverCardContent()
+ {
+ Child = _stack;
+ Width = 256;
+ Padding = new Thickness(16);
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ BoxShadow = ShellPopups.PanelShadow();
+ this.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ if (e.Pointer.Type == PointerType.Mouse) this.FindParentOfType()?.SetHovered(true);
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ if (e.Pointer.Type == PointerType.Mouse) this.FindParentOfType()?.SetHovered(false);
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/HoverCardTrigger.cs b/examples/Avalonia.Demo/Components/UI/HoverCardTrigger.cs
new file mode 100644
index 0000000..84fd2f4
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/HoverCardTrigger.cs
@@ -0,0 +1,36 @@
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace AvaloniaDemo.Components.UI;
+
+// The control that opens the enclosing HoverCard: on hover with a mouse; touch, pen and the
+// keyboard toggle it. Wrap a link, an avatar, any control.
+public class HoverCardTrigger : ShellTriggerView
+{
+ private bool _mouse;
+
+ public HoverCardTrigger()
+ {
+ // Tunnel, so a child that handles the press (Button) doesn't hide the pointer type.
+ AddHandler(PointerPressedEvent, (_, e) => _mouse = e.Pointer.Type == PointerType.Mouse, RoutingStrategies.Tunnel);
+ AddHandler(KeyDownEvent, (_, _) => _mouse = false, RoutingStrategies.Tunnel);
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ if (e.Pointer.Type == PointerType.Mouse) this.FindParentOfType()?.SetHovered(true);
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ if (e.Pointer.Type == PointerType.Mouse) this.FindParentOfType()?.SetHovered(false);
+ }
+
+ // A mouse click acts on the wrapped control only; hover already shows the card.
+ protected override void OnActivated()
+ {
+ if (!_mouse) this.FindParentOfType()?.ToggleFromTap();
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Label.cs b/examples/Avalonia.Demo/Components/UI/Label.cs
index 4726ebe..938bc44 100644
--- a/examples/Avalonia.Demo/Components/UI/Label.cs
+++ b/examples/Avalonia.Demo/Components/UI/Label.cs
@@ -42,7 +42,12 @@ public LabelVariant Variant
set => SetValue(VariantProperty, value);
}
- public ShellLabel() => UpdateVisualState();
+ // Wraps like MAUI's Label; TextWrapping="NoWrap" keeps it on one line.
+ public ShellLabel()
+ {
+ TextWrapping = TextWrapping.Wrap;
+ UpdateVisualState();
+ }
private void UpdateVisualState()
{
diff --git a/examples/Avalonia.Demo/Components/UI/Shell.cs b/examples/Avalonia.Demo/Components/UI/Shell.cs
index 085e5a9..dd325d6 100644
--- a/examples/Avalonia.Demo/Components/UI/Shell.cs
+++ b/examples/Avalonia.Demo/Components/UI/Shell.cs
@@ -369,6 +369,9 @@ protected ShellFloatingHost()
protected abstract bool IsContent(Control child);
+ // A child the host places itself (AlertDialog's body) instead of rendering it in place.
+ protected virtual bool ClaimsChild(Control child) => false;
+
// Rebuilt from Items on every change; Clear reports no old items, so diffing isn't worth it.
private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
@@ -378,7 +381,7 @@ private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e)
foreach (var child in Items)
{
if (IsContent(child)) Popup.Child = FloatingContent = child;
- else _inline.Children.Insert(_inline.Children.Count - 1, child);
+ else if (!ClaimsChild(child)) _inline.Children.Insert(_inline.Children.Count - 1, child);
}
}
}
diff --git a/examples/Avalonia.Demo/Components/UI/Spinner.cs b/examples/Avalonia.Demo/Components/UI/Spinner.cs
new file mode 100644
index 0000000..995cb6b
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Spinner.cs
@@ -0,0 +1,104 @@
+using System;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Loading spinner — a rotating loader-circle (ShellUI Loading, spinner variant). Spins only while
+// on screen and IsRunning. Usage:
+public class Spinner : Border
+{
+ public static readonly StyledProperty SizeProperty =
+ AvaloniaProperty.Register(nameof(Size), SpinnerSize.Default);
+
+ public static readonly StyledProperty TokenProperty =
+ AvaloniaProperty.Register(nameof(Token), ShellToken.Foreground);
+
+ public static readonly StyledProperty IsRunningProperty =
+ AvaloniaProperty.Register(nameof(IsRunning), true);
+
+ private readonly Icon _icon;
+ private readonly RotateTransform _spin = new();
+ private DispatcherTimer? _timer;
+
+ static Spinner()
+ {
+ SizeProperty.Changed.AddClassHandler((s, _) => s.UpdateSize());
+ TokenProperty.Changed.AddClassHandler((s, _) => s._icon.Token = s.Token);
+ IsRunningProperty.Changed.AddClassHandler((s, _) => s.UpdateRunning());
+ }
+
+ public SpinnerSize Size
+ {
+ get => GetValue(SizeProperty);
+ set => SetValue(SizeProperty, value);
+ }
+
+ public ShellToken Token
+ {
+ get => GetValue(TokenProperty);
+ set => SetValue(TokenProperty, value);
+ }
+
+ public bool IsRunning
+ {
+ get => GetValue(IsRunningProperty);
+ set => SetValue(IsRunningProperty, value);
+ }
+
+ public Spinner()
+ {
+ _icon = new Icon { Kind = IconName.LoaderCircle, Token = ShellToken.Foreground, RenderTransform = _spin };
+ Child = _icon;
+ HorizontalAlignment = HorizontalAlignment.Left;
+ VerticalAlignment = VerticalAlignment.Center;
+ AutomationProperties.SetName(this, "Loading");
+ UpdateSize();
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ UpdateRunning();
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ Stop();
+ }
+
+ private void UpdateSize() => _icon.Size = Size switch
+ {
+ SpinnerSize.Sm => 16,
+ SpinnerSize.Lg => 32,
+ _ => 24
+ };
+
+ // One turn per 800 ms, advanced once per frame (as Button's loading state).
+ private void UpdateRunning()
+ {
+ IsVisible = IsRunning;
+ if (!IsRunning || TopLevel.GetTopLevel(this) is null)
+ {
+ Stop();
+ return;
+ }
+ if (_timer != null) return;
+ _timer = new DispatcherTimer(TimeSpan.FromMilliseconds(16), DispatcherPriority.Render,
+ (_, _) => _spin.Angle = (_spin.Angle + 360 * 16 / 800.0) % 360);
+ _timer.Start();
+ }
+
+ private void Stop()
+ {
+ _timer?.Stop();
+ _timer = null;
+ }
+}
+
+public enum SpinnerSize { Sm, Default, Lg }
diff --git a/examples/Avalonia.Demo/Components/UI/Toast.cs b/examples/Avalonia.Demo/Components/UI/Toast.cs
new file mode 100644
index 0000000..11a1075
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Toast.cs
@@ -0,0 +1,330 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation.Easings;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+using AvaloniaDemo.Components.UI.Variants;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Sonner-style toasts. Call from anywhere — toasts float above the window's content:
+// Toast.Show("Event created", "Sunday, December 03 at 9:00 AM");
+// Toast.Success("Profile saved");
+// Toast.Error("Upload failed", actionText: "Retry", action: Retry);
+// Toasts stack in a corner, slide in, pause while hovered and dismiss themselves.
+// Optional: declare anywhere in a window to
+// configure them; without one, a default Toaster (bottom-right, 3 visible) is created on demand
+// in the active window.
+public static class Toast
+{
+ private static WeakReference? _toaster;
+
+ internal static void Register(Toaster toaster) => _toaster = new WeakReference(toaster);
+
+ internal static void Unregister(Toaster toaster)
+ {
+ if (_toaster != null && _toaster.TryGetTarget(out var current) && current == toaster)
+ _toaster = null;
+ }
+
+ public static string Show(string message, string? description = null, ToastVariant variant = ToastVariant.Default,
+ TimeSpan? duration = null, string? actionText = null, Action? action = null)
+ {
+ var id = Guid.NewGuid().ToString("N");
+ if (_toaster == null || !_toaster.TryGetTarget(out var toaster) || !toaster.IsAttached)
+ {
+ toaster = new Toaster();
+ if (ActiveTopLevel() is not { } top || !toaster.Attach(top))
+ {
+ System.Diagnostics.Debug.WriteLine("ShellUI: Toast.Show called before any window is on screen.");
+ return id;
+ }
+ Register(toaster);
+ }
+ toaster.Add(new ToastItem(id, message, description, variant, duration ?? TimeSpan.FromSeconds(4), actionText, action));
+ return id;
+ }
+
+ public static string Success(string message, string? description = null, TimeSpan? duration = null,
+ string? actionText = null, Action? action = null)
+ => Show(message, description, ToastVariant.Success, duration, actionText, action);
+
+ public static string Error(string message, string? description = null, TimeSpan? duration = null,
+ string? actionText = null, Action? action = null)
+ => Show(message, description, ToastVariant.Error, duration, actionText, action);
+
+ public static string Warning(string message, string? description = null, TimeSpan? duration = null,
+ string? actionText = null, Action? action = null)
+ => Show(message, description, ToastVariant.Warning, duration, actionText, action);
+
+ public static string Info(string message, string? description = null, TimeSpan? duration = null,
+ string? actionText = null, Action? action = null)
+ => Show(message, description, ToastVariant.Info, duration, actionText, action);
+
+ public static void Dismiss(string id)
+ {
+ if (_toaster != null && _toaster.TryGetTarget(out var toaster))
+ toaster.Remove(id);
+ }
+
+ private static TopLevel? ActiveTopLevel() => Application.Current?.ApplicationLifetime switch
+ {
+ IClassicDesktopStyleApplicationLifetime desktop => desktop.Windows.FirstOrDefault(w => w.IsActive) ?? desktop.MainWindow,
+ ISingleViewApplicationLifetime single when single.MainView is { } view => TopLevel.GetTopLevel(view),
+ _ => null
+ };
+}
+
+public enum ToastVariant { Default, Success, Error, Warning, Info }
+
+public enum ToasterPosition { BottomRight, BottomCenter, TopRight, TopCenter }
+
+internal sealed record ToastItem(string Id, string Message, string? Description, ToastVariant Variant,
+ TimeSpan Duration, string? ActionText, Action? Action);
+
+// Configures where toasts appear. Its stack sits in the window's overlay layer, so the Toaster
+// itself takes no space and can be declared anywhere in the window.
+public class Toaster : Control
+{
+ public static readonly StyledProperty PositionProperty =
+ AvaloniaProperty.Register(nameof(Position));
+
+ public static readonly StyledProperty MaxVisibleProperty =
+ AvaloniaProperty.Register(nameof(MaxVisible), 3);
+
+ // Fills the overlay layer; only the toasts take input.
+ private readonly Panel _layer = new() { ZIndex = 1000 };
+ private readonly StackPanel _stack = new() { Spacing = 8, Margin = new Thickness(16) };
+ private OverlayLayer? _overlay;
+
+ static Toaster()
+ {
+ PositionProperty.Changed.AddClassHandler((t, _) => t.UpdatePosition());
+ }
+
+ public ToasterPosition Position
+ {
+ get => GetValue(PositionProperty);
+ set => SetValue(PositionProperty, value);
+ }
+
+ public int MaxVisible
+ {
+ get => GetValue(MaxVisibleProperty);
+ set => SetValue(MaxVisibleProperty, value);
+ }
+
+ public Toaster()
+ {
+ _layer.Children.Add(_stack);
+ IsHitTestVisible = false;
+ UpdatePosition();
+ }
+
+ internal bool IsAttached => _overlay != null;
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ if (TopLevel.GetTopLevel(this) is { } top && Attach(top)) Toast.Register(this);
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ Detach();
+ Toast.Unregister(this);
+ }
+
+ // Puts the toast stack in the overlay layer of `top`, sized to it.
+ internal bool Attach(TopLevel top)
+ {
+ if (_overlay != null) return true;
+ if (OverlayLayer.GetOverlayLayer(top) is not { } overlay) return false;
+ _overlay = overlay;
+ overlay.Children.Add(_layer);
+ overlay.SizeChanged += OnOverlaySizeChanged;
+ Fit(overlay.Bounds.Size);
+ return true;
+ }
+
+ private void Detach()
+ {
+ if (_overlay == null) return;
+ _overlay.SizeChanged -= OnOverlaySizeChanged;
+ _overlay.Children.Remove(_layer);
+ _overlay = null;
+ }
+
+ private void OnOverlaySizeChanged(object? sender, SizeChangedEventArgs e) => Fit(e.NewSize);
+
+ private void Fit(Size size)
+ {
+ _layer.Width = size.Width;
+ _layer.Height = size.Height;
+ _stack.Width = Math.Max(0, Math.Min(356, size.Width - 32));
+ }
+
+ private bool IsTop => Position is ToasterPosition.TopRight or ToasterPosition.TopCenter;
+
+ private void UpdatePosition()
+ {
+ _stack.VerticalAlignment = IsTop ? VerticalAlignment.Top : VerticalAlignment.Bottom;
+ _stack.HorizontalAlignment = Position is ToasterPosition.BottomCenter or ToasterPosition.TopCenter
+ ? HorizontalAlignment.Center
+ : HorizontalAlignment.Right;
+ }
+
+ internal void Add(ToastItem item)
+ {
+ var view = new ToastView(item, this);
+ // Newest toast sits nearest the edge it enters from.
+ if (IsTop) _stack.Children.Insert(0, view);
+ else _stack.Children.Add(view);
+
+ var views = _stack.Children.OfType().Where(v => !v.IsDismissing).ToList();
+ var overflow = views.Count - Math.Max(1, MaxVisible);
+ foreach (var old in (IsTop ? views.AsEnumerable().Reverse() : views).Take(Math.Max(0, overflow)))
+ _ = old.DismissAsync();
+
+ _ = view.EnterAsync(IsTop);
+ }
+
+ internal void Remove(string id)
+ {
+ var view = _stack.Children.OfType().FirstOrDefault(v => v.ToastId == id);
+ if (view != null) _ = view.DismissAsync();
+ }
+
+ internal void Detach(ToastView view) => _stack.Children.Remove(view);
+}
+
+// One toast — rounded-lg border bg-popover p-4 shadow-lg, variant icon, optional action, close X
+// shown on hover.
+internal sealed class ToastView : Panel
+{
+ public string ToastId { get; }
+ public bool IsDismissing { get; private set; }
+
+ private readonly ToastItem _item;
+ private readonly Toaster _host;
+ private readonly Border _close;
+ private DispatcherTimer? _timer;
+ private DateTime _deadline;
+
+ public ToastView(ToastItem item, Toaster host)
+ {
+ ToastId = item.Id;
+ _item = item;
+ _host = host;
+
+ var (icon, token) = item.Variant switch
+ {
+ ToastVariant.Success => (IconName.CircleCheck, ShellToken.Success),
+ ToastVariant.Error => (IconName.CircleX, ShellToken.Destructive),
+ ToastVariant.Warning => (IconName.TriangleAlert, ShellToken.Warning),
+ ToastVariant.Info => (IconName.Info, ShellToken.Info),
+ _ => (IconName.None, ShellToken.Foreground)
+ };
+
+ var title = new TextBlock { Text = item.Message, FontSize = 14, FontWeight = FontWeight.SemiBold, TextWrapping = TextWrapping.Wrap };
+ title.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground);
+ var text = new StackPanel { Spacing = 2, VerticalAlignment = VerticalAlignment.Center, Children = { title } };
+ if (!string.IsNullOrEmpty(item.Description))
+ {
+ var description = new TextBlock { Text = item.Description, FontSize = 13, TextWrapping = TextWrapping.Wrap };
+ description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ text.Children.Add(description);
+ }
+
+ var row = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), ColumnSpacing = 12 };
+ if (icon != IconName.None)
+ row.Children.Add(new Icon { Kind = icon, Size = 18, Token = token, VerticalAlignment = VerticalAlignment.Center });
+ Grid.SetColumn(text, 1);
+ row.Children.Add(text);
+
+ if (!string.IsNullOrEmpty(item.ActionText))
+ {
+ var action = new Button { Text = item.ActionText, Size = ButtonSize.Sm, VerticalAlignment = VerticalAlignment.Center };
+ action.Clicked += (_, _) => { item.Action?.Invoke(); _ = DismissAsync(); };
+ Grid.SetColumn(action, 2);
+ row.Children.Add(action);
+ }
+
+ _close = new Border
+ {
+ Child = new Icon { Kind = IconName.X, Size = 14, Token = ShellToken.MutedForeground },
+ Width = 22,
+ Height = 22,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(11),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(-8, -8, 0, 0),
+ Opacity = 0,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ _close.Token(Border.BackgroundProperty, ShellToken.Popover).Token(Border.BorderBrushProperty, ShellToken.Border);
+ AutomationProperties.SetName(_close, "Close");
+ _close.PointerReleased += (_, e) => { _ = DismissAsync(); e.Handled = true; };
+
+ var card = new Border
+ {
+ Child = row,
+ Padding = new Thickness(16),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusLg),
+ BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 4, Blur = 16, Color = ShellTheme.Shadow(0.12) })
+ };
+ card.Token(Border.BackgroundProperty, ShellToken.Popover).Token(Border.BorderBrushProperty, ShellToken.Border);
+
+ Children.Add(card);
+ Children.Add(_close);
+ Opacity = 0;
+ }
+
+ // Hovering pauses the timer and reveals the close button (Sonner behavior).
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ _ = ShellMotion.To(_close, 1, "scale(1)", 120, new CubicEaseOut());
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ _deadline = DateTime.UtcNow + TimeSpan.FromSeconds(1.5);
+ _ = ShellMotion.To(_close, 0, "scale(1)", 120, new CubicEaseIn());
+ }
+
+ public async Task EnterAsync(bool fromTop)
+ {
+ ShellMotion.Set(this, 0, fromTop ? "translateY(-16px)" : "translateY(16px)");
+ await ShellMotion.To(this, 1, "translateY(0px)", 250, new CubicEaseOut());
+
+ _deadline = DateTime.UtcNow + _item.Duration;
+ _timer = new DispatcherTimer(TimeSpan.FromMilliseconds(200), DispatcherPriority.Background, (_, _) =>
+ {
+ if (IsPointerOver || DateTime.UtcNow < _deadline) return;
+ _ = DismissAsync();
+ });
+ _timer.Start();
+ }
+
+ public async Task DismissAsync()
+ {
+ if (IsDismissing) return;
+ IsDismissing = true;
+ _timer?.Stop();
+ await ShellMotion.To(this, 0, "translateX(24px)", 150, new CubicEaseIn());
+ _host.Detach(this);
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Tooltip.cs b/examples/Avalonia.Demo/Components/UI/Tooltip.cs
new file mode 100644
index 0000000..83c207a
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Tooltip.cs
@@ -0,0 +1,184 @@
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+using Avalonia.Threading;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Hover tooltip — rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground, shown next to
+// the wrapped control after a short delay, or at once when it gets keyboard focus.
+// Usage:
+public class Tooltip : Border
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ // Top (default), Bottom, Left or Right.
+ public static readonly StyledProperty PlacementProperty =
+ AvaloniaProperty.Register(nameof(Placement), PlacementMode.Top);
+
+ public static readonly StyledProperty ContentProperty =
+ AvaloniaProperty.Register(nameof(Content));
+
+ // Milliseconds the pointer must rest before the tooltip shows.
+ public static readonly StyledProperty DelayProperty =
+ AvaloniaProperty.Register(nameof(Delay), 400);
+
+ private const double Gap = 6;
+
+ // The wrapped control and the popup, which has to sit in the tree next to it.
+ private readonly Panel _root = new();
+ private readonly Popup _popup;
+ private readonly TextBlock _label;
+ private int _version;
+
+ static Tooltip()
+ {
+ TextProperty.Changed.AddClassHandler((t, _) =>
+ {
+ t._label.Text = t.Text ?? string.Empty;
+ AutomationProperties.SetHelpText(t, t.Text);
+ });
+ ContentProperty.Changed.AddClassHandler((t, e) =>
+ {
+ if (e.OldValue is Control old) t._root.Children.Remove(old);
+ if (e.NewValue is Control content) t._root.Children.Insert(0, content);
+ });
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ // The wrapped control.
+ [Content]
+ public Control? Content
+ {
+ get => GetValue(ContentProperty);
+ set => SetValue(ContentProperty, value);
+ }
+
+ public PlacementMode Placement
+ {
+ get => GetValue(PlacementProperty);
+ set => SetValue(PlacementProperty, value);
+ }
+
+ public int Delay
+ {
+ get => GetValue(DelayProperty);
+ set => SetValue(DelayProperty, value);
+ }
+
+ public Tooltip()
+ {
+ _label = new TextBlock { FontSize = 12 };
+ _label.Token(TextBlock.ForegroundProperty, ShellToken.PrimaryForeground);
+ var bubble = new Border
+ {
+ Child = _label,
+ Padding = new Thickness(12, 6),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ IsHitTestVisible = false
+ };
+ bubble.Token(BackgroundProperty, ShellToken.Primary);
+
+ _popup = new Popup
+ {
+ ShouldUseOverlayLayer = true,
+ IsLightDismissEnabled = false,
+ PlacementTarget = this,
+ Child = bubble,
+ IsHitTestVisible = false
+ };
+ _root.Children.Add(_popup);
+ Child = _root;
+ HorizontalAlignment = HorizontalAlignment.Left;
+ VerticalAlignment = VerticalAlignment.Top;
+
+ // Tunnel, so a child that handles the press (Button) still hides the tooltip.
+ AddHandler(PointerPressedEvent, (_, _) => Hide(), RoutingStrategies.Tunnel);
+ AddHandler(GotFocusEvent, (_, e) =>
+ {
+ if (e.NavigationMethod is NavigationMethod.Tab or NavigationMethod.Directional) Show();
+ });
+ AddHandler(LostFocusEvent, (_, _) => Hide());
+ AddHandler(KeyDownEvent, (_, e) =>
+ {
+ if (e.Key == Key.Escape && _popup.IsOpen) { Hide(); e.Handled = true; }
+ }, RoutingStrategies.Tunnel);
+ }
+
+ protected override async void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ var version = ++_version;
+ await Task.Delay(Delay);
+ if (version == _version && IsPointerOver) Show();
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ Hide();
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ Hide();
+ }
+
+ private void Show()
+ {
+ if (_popup.IsOpen || string.IsNullOrEmpty(Text)) return;
+ _popup.Placement = Placement;
+ SetOffset(1);
+ _popup.IsOpen = true;
+ // Avalonia flips a bubble with no room on its side but keeps the offset's sign; mirror it.
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (_popup.IsOpen && _popup.Child is Visual bubble && IsFlipped(bubble)) SetOffset(-1);
+ }, DispatcherPriority.Loaded);
+ }
+
+ private void Hide()
+ {
+ _version++;
+ _popup.IsOpen = false;
+ }
+
+ private void SetOffset(int sign)
+ {
+ var (x, y) = Placement switch
+ {
+ PlacementMode.Bottom => (0.0, Gap),
+ PlacementMode.Left => (-Gap, 0.0),
+ PlacementMode.Right => (Gap, 0.0),
+ _ => (0.0, -Gap)
+ };
+ _popup.HorizontalOffset = x * sign;
+ _popup.VerticalOffset = y * sign;
+ }
+
+ private bool IsFlipped(Visual bubble)
+ {
+ if (bubble.TranslatePoint(default, this) is not { } at) return false;
+ return Placement switch
+ {
+ PlacementMode.Bottom => at.Y < 0,
+ PlacementMode.Left => at.X >= 0,
+ PlacementMode.Right => at.X < 0,
+ _ => at.Y >= 0
+ };
+ }
+}
diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml
index ae876ae..198c610 100644
--- a/examples/Avalonia.Demo/MainWindow.axaml
+++ b/examples/Avalonia.Demo/MainWindow.axaml
@@ -262,6 +262,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs
index 0cafe7d..34c5f3d 100644
--- a/examples/Avalonia.Demo/MainWindow.axaml.cs
+++ b/examples/Avalonia.Demo/MainWindow.axaml.cs
@@ -23,6 +23,19 @@ public MainWindow()
crumb.Clicked += (_, _) => NavChoice.Text = $"Went to {crumb.Text}.";
DemoAccordion.ItemToggled += (_, e) => NavChoice.Text = $"Section {e.Value} {(e.IsOpen ? "opened" : "closed")}.";
DemoTabs.ValueChanged += (_, e) => NavChoice.Text = $"Tab {e.Old} → {e.New}.";
+ ToastDefault.Clicked += (_, _) => Toast.Show("Event has been created", "Sunday, December 03, 2023 at 9:00 AM");
+ ToastSuccess.Clicked += (_, _) => Toast.Success("Profile saved");
+ ToastError.Clicked += (_, _) => Toast.Error("Upload failed", "The file is larger than 10 MB.");
+ ToastWarning.Clicked += (_, _) => Toast.Warning("Storage almost full", "92% of your quota used.");
+ ToastInfo.Clicked += (_, _) => Toast.Info("New version available");
+ ToastAction.Clicked += (_, _) => Toast.Show("Message archived", actionText: "Undo", action: () => FeedbackChoice.Text = "Undo clicked.");
+ DeleteAccount.Clicked += async (_, _) =>
+ {
+ var confirmed = await DeleteDialog.ShowAsync();
+ FeedbackChoice.Text = confirmed ? "Account deleted (demo only)." : "Kept the account.";
+ if (confirmed) Toast.Success("Account deleted", "This was only a demo.");
+ };
+
for (var i = 1; i <= 30; i++)
{
var tag = new TextBlock { Text = $"v1.2.0-beta.{31 - i}", FontSize = 14 };
diff --git a/src/ShellUI.Native.Templates/Templates/AlertDialogTemplate.cs b/src/ShellUI.Native.Templates/Templates/AlertDialogTemplate.cs
index f66361e..821ef14 100644
--- a/src/ShellUI.Native.Templates/Templates/AlertDialogTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/AlertDialogTemplate.cs
@@ -234,6 +234,232 @@ await Task.WhenAll(
}
}
}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using YourProjectNamespace.Components.UI.Variants;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Confirmation dialog that requires a choice — no close button, and the backdrop doesn't
+// dismiss it (shadcn AlertDialog). Declare it anywhere; it covers the window while open.
+//
+// From code: if (await DeleteDialog.ShowAsync()) { ... }
+// An optional renders in place; any other XAML children go between the
+// description and the buttons.
+public class AlertDialog : ShellOverlayHost
+{
+ public static readonly StyledProperty TitleProperty =
+ AvaloniaProperty.Register(nameof(Title));
+
+ public static readonly StyledProperty DescriptionProperty =
+ AvaloniaProperty.Register(nameof(Description));
+
+ public static readonly StyledProperty ConfirmTextProperty =
+ AvaloniaProperty.Register(nameof(ConfirmText), ""Continue"");
+
+ // Empty hides the cancel button.
+ public static readonly StyledProperty CancelTextProperty =
+ AvaloniaProperty.Register(nameof(CancelText), ""Cancel"");
+
+ public static readonly StyledProperty ConfirmVariantProperty =
+ AvaloniaProperty.Register(nameof(ConfirmVariant));
+
+ private readonly AlertDialogPanel _panel;
+ private TaskCompletionSource? _pending;
+
+ static AlertDialog()
+ {
+ TitleProperty.Changed.AddClassHandler((d, _) => d._panel.Update());
+ DescriptionProperty.Changed.AddClassHandler((d, _) => d._panel.Update());
+ ConfirmTextProperty.Changed.AddClassHandler((d, _) => d._panel.Update());
+ CancelTextProperty.Changed.AddClassHandler((d, _) => d._panel.Update());
+ ConfirmVariantProperty.Changed.AddClassHandler((d, _) => d._panel.Update());
+ }
+
+ public string? Title
+ {
+ get => GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string? Description
+ {
+ get => GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public string? ConfirmText
+ {
+ get => GetValue(ConfirmTextProperty);
+ set => SetValue(ConfirmTextProperty, value);
+ }
+
+ public string? CancelText
+ {
+ get => GetValue(CancelTextProperty);
+ set => SetValue(CancelTextProperty, value);
+ }
+
+ public ButtonVariant ConfirmVariant
+ {
+ get => GetValue(ConfirmVariantProperty);
+ set => SetValue(ConfirmVariantProperty, value);
+ }
+
+ public event EventHandler? Confirmed;
+ public event EventHandler? Cancelled;
+
+ public AlertDialog()
+ {
+ _panel = new AlertDialogPanel(this);
+ Items.Add(_panel);
+ // Runs after the host has sorted Items, so the body gets every child it claimed.
+ Items.CollectionChanged += (_, _) => _panel.SetBody(Items.Where(c => ClaimsChild(c)));
+ _panel.Update();
+ }
+
+ protected override bool IsContent(Control child) => child is AlertDialogPanel;
+
+ protected override bool ClaimsChild(Control child) => child is not (AlertDialogTrigger or AlertDialogPanel);
+
+ // Escape counts as Cancel.
+ protected override void Dismiss() => Resolve(false);
+
+ // Opens the dialog and completes with true (confirm) or false (cancel).
+ public Task ShowAsync()
+ {
+ _pending?.TrySetResult(false);
+ _pending = new TaskCompletionSource();
+ SetOpen(true);
+ return _pending.Task;
+ }
+
+ internal void Resolve(bool confirmed)
+ {
+ SetOpen(false);
+ if (confirmed) Confirmed?.Invoke(this, EventArgs.Empty);
+ else Cancelled?.Invoke(this, EventArgs.Empty);
+ _pending?.TrySetResult(confirmed);
+ _pending = null;
+ }
+}
+
+// Opens the enclosing AlertDialog. Usage:
+public class AlertDialogTrigger : ShellTriggerView
+{
+ protected override void OnActivated() => this.FindParentOfType()?.SetOpen(true);
+}
+
+// Backdrop + centered box: max-w-lg rounded-lg border bg-background p-6 shadow-lg, fade + zoom-in-95.
+internal sealed class AlertDialogPanel : Border, IShellOverlayContent
+{
+ private readonly AlertDialog _owner;
+ private readonly Border _backdrop;
+ private readonly Border _box;
+ private readonly TextBlock _title;
+ private readonly TextBlock _description;
+ private readonly StackPanel _body = new() { Spacing = 8, IsVisible = false };
+ private readonly Button _cancel;
+ private readonly Button _confirm;
+
+ public AlertDialogPanel(AlertDialog owner)
+ {
+ _owner = owner;
+ _backdrop = new Border();
+ _backdrop.Token(BackgroundProperty, ShellToken.Overlay);
+ // Alert dialogs require a choice: a click on the backdrop does nothing.
+ _backdrop.PointerPressed += (_, e) => e.Handled = true;
+
+ _title = new TextBlock { FontSize = 18, FontWeight = FontWeight.SemiBold, TextWrapping = TextWrapping.Wrap };
+ _title.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ _description = new TextBlock { FontSize = 14, TextWrapping = TextWrapping.Wrap };
+ _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+
+ _cancel = new Button { Variant = ButtonVariant.Outline };
+ _cancel.Clicked += (_, _) => _owner.Resolve(false);
+ _confirm = new Button();
+ _confirm.Clicked += (_, _) => _owner.Resolve(true);
+
+ _box = new Border
+ {
+ Child = new StackPanel
+ {
+ Spacing = 8,
+ Children =
+ {
+ _title,
+ _description,
+ _body,
+ new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 8,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Margin = new Thickness(0, 8, 0, 0),
+ Children = { _cancel, _confirm }
+ }
+ }
+ },
+ 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);
+ _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 void Update()
+ {
+ _title.Text = _owner.Title;
+ _description.Text = _owner.Description;
+ _description.IsVisible = !string.IsNullOrEmpty(_owner.Description);
+ _cancel.Text = _owner.CancelText;
+ _cancel.IsVisible = !string.IsNullOrEmpty(_owner.CancelText);
+ _confirm.Text = _owner.ConfirmText;
+ _confirm.Variant = _owner.ConfirmVariant;
+ }
+
+ internal void SetBody(IEnumerable children)
+ {
+ _body.Children.Clear();
+ _body.Children.AddRange(children.ToList());
+ _body.IsVisible = _body.Children.Count > 0;
+ }
+
+ public async Task AnimateAsync(bool open)
+ {
+ if (open)
+ {
+ ShellMotion.Set(_backdrop, 0, ""scale(1)"");
+ ShellMotion.Set(_box, 0, ""scale(0.95)"");
+ await Task.WhenAll(
+ ShellMotion.To(_backdrop, 1, ""scale(1)"", 150, new CubicEaseOut()),
+ ShellMotion.To(_box, 1, ""scale(1)"", 150, new CubicEaseOut()));
+ }
+ else
+ {
+ await Task.WhenAll(
+ ShellMotion.To(_backdrop, 0, ""scale(1)"", 120, new CubicEaseIn()),
+ ShellMotion.To(_box, 0, ""scale(0.95)"", 120, new CubicEaseIn()));
+ }
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/HoverCardContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/HoverCardContentTemplate.cs
index 2c2bbfa..033ad2e 100644
--- a/src/ShellUI.Native.Templates/Templates/HoverCardContentTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/HoverCardContentTemplate.cs
@@ -51,6 +51,46 @@ public HoverCardContent()
GestureRecognizers.Add(pointer);
}
}
+",
+ [NativePlatform.Avalonia] = @"using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Metadata;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Hover card panel — w-64 rounded-md border bg-popover p-4 shadow-md. Stays open while hovered.
+// XAML children go to Items.
+public class HoverCardContent : Border
+{
+ private readonly StackPanel _stack = new() { Spacing = 8 };
+
+ [Content]
+ public Controls Items => _stack.Children;
+
+ public HoverCardContent()
+ {
+ Child = _stack;
+ Width = 256;
+ Padding = new Thickness(16);
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ BoxShadow = ShellPopups.PanelShadow();
+ this.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ if (e.Pointer.Type == PointerType.Mouse) this.FindParentOfType()?.SetHovered(true);
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ if (e.Pointer.Type == PointerType.Mouse) this.FindParentOfType()?.SetHovered(false);
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/HoverCardTemplate.cs b/src/ShellUI.Native.Templates/Templates/HoverCardTemplate.cs
index 0d40078..a76efab 100644
--- a/src/ShellUI.Native.Templates/Templates/HoverCardTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/HoverCardTemplate.cs
@@ -64,6 +64,72 @@ internal async void SetHovered(bool hovered)
if (version == _version) IsOpen = hovered;
}
}
+",
+ [NativePlatform.Avalonia] = @"using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Hover card — rich content that floats under its trigger while the pointer is over either.
+// Touch, pen and the keyboard (Enter / Space on the trigger) toggle it instead.
+//
+//
+// ...
+//
+public class HoverCard : ShellPopoverHost
+{
+ // Milliseconds before the card opens / closes after the pointer enters / leaves.
+ public static readonly StyledProperty OpenDelayProperty =
+ AvaloniaProperty.Register(nameof(OpenDelay), 300);
+
+ public static readonly StyledProperty CloseDelayProperty =
+ AvaloniaProperty.Register(nameof(CloseDelay), 200);
+
+ private int _version;
+
+ public HoverCard()
+ {
+ // Light dismiss lays a window-wide layer under the card, which takes the pointer off the
+ // trigger and closes a card opened by hover; leaving both closes it instead.
+ Popup.IsLightDismissEnabled = false;
+ // When a tap did open it, a click elsewhere closes it and still reaches what was clicked.
+ Popup.OverlayDismissEventPassThrough = true;
+ IsOpenChanged += (_, open) =>
+ {
+ if (!open) Popup.IsLightDismissEnabled = false;
+ };
+ }
+
+ public int OpenDelay
+ {
+ get => GetValue(OpenDelayProperty);
+ set => SetValue(OpenDelayProperty, value);
+ }
+
+ public int CloseDelay
+ {
+ get => GetValue(CloseDelayProperty);
+ set => SetValue(CloseDelayProperty, value);
+ }
+
+ protected override bool IsContent(Control child) => child is HoverCardContent;
+
+ // Touch, pen or keyboard: with no hover to leave, a tap outside closes it.
+ internal void ToggleFromTap()
+ {
+ Popup.IsLightDismissEnabled = !IsOpen;
+ Toggle();
+ }
+
+ // Called by the trigger and the content as the pointer enters / leaves them.
+ internal async void SetHovered(bool hovered)
+ {
+ var version = ++_version;
+ await Task.Delay(hovered ? OpenDelay : CloseDelay);
+ if (version == _version) IsOpen = hovered;
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/HoverCardTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/HoverCardTriggerTemplate.cs
index 2fc47be..07556ed 100644
--- a/src/ShellUI.Native.Templates/Templates/HoverCardTriggerTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/HoverCardTriggerTemplate.cs
@@ -36,6 +36,43 @@ protected override void OnActivated()
if (HoverCard.IsTouch) this.FindParentOfType()?.Toggle();
}
}
+",
+ [NativePlatform.Avalonia] = @"using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace YourProjectNamespace.Components.UI;
+
+// The control that opens the enclosing HoverCard: on hover with a mouse; touch, pen and the
+// keyboard toggle it. Wrap a link, an avatar, any control.
+public class HoverCardTrigger : ShellTriggerView
+{
+ private bool _mouse;
+
+ public HoverCardTrigger()
+ {
+ // Tunnel, so a child that handles the press (Button) doesn't hide the pointer type.
+ AddHandler(PointerPressedEvent, (_, e) => _mouse = e.Pointer.Type == PointerType.Mouse, RoutingStrategies.Tunnel);
+ AddHandler(KeyDownEvent, (_, _) => _mouse = false, RoutingStrategies.Tunnel);
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ if (e.Pointer.Type == PointerType.Mouse) this.FindParentOfType()?.SetHovered(true);
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ if (e.Pointer.Type == PointerType.Mouse) this.FindParentOfType()?.SetHovered(false);
+ }
+
+ // A mouse click acts on the wrapped control only; hover already shows the card.
+ protected override void OnActivated()
+ {
+ if (!_mouse) this.FindParentOfType()?.ToggleFromTap();
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/LabelTemplate.cs b/src/ShellUI.Native.Templates/Templates/LabelTemplate.cs
index 7addbe6..6c59485 100644
--- a/src/ShellUI.Native.Templates/Templates/LabelTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/LabelTemplate.cs
@@ -159,7 +159,12 @@ public LabelVariant Variant
set => SetValue(VariantProperty, value);
}
- public ShellLabel() => UpdateVisualState();
+ // Wraps like MAUI's Label; TextWrapping=""NoWrap"" keeps it on one line.
+ public ShellLabel()
+ {
+ TextWrapping = TextWrapping.Wrap;
+ UpdateVisualState();
+ }
private void UpdateVisualState()
{
diff --git a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs
index e419b50..7de2144 100644
--- a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs
@@ -1431,6 +1431,9 @@ protected ShellFloatingHost()
protected abstract bool IsContent(Control child);
+ // A child the host places itself (AlertDialog's body) instead of rendering it in place.
+ protected virtual bool ClaimsChild(Control child) => false;
+
// Rebuilt from Items on every change; Clear reports no old items, so diffing isn't worth it.
private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
@@ -1440,7 +1443,7 @@ private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e)
foreach (var child in Items)
{
if (IsContent(child)) Popup.Child = FloatingContent = child;
- else _inline.Children.Insert(_inline.Children.Count - 1, child);
+ else if (!ClaimsChild(child)) _inline.Children.Insert(_inline.Children.Count - 1, child);
}
}
}
diff --git a/src/ShellUI.Native.Templates/Templates/SpinnerTemplate.cs b/src/ShellUI.Native.Templates/Templates/SpinnerTemplate.cs
index 672e2bf..5f9326d 100644
--- a/src/ShellUI.Native.Templates/Templates/SpinnerTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/SpinnerTemplate.cs
@@ -88,6 +88,111 @@ private void UpdateRunning()
}
}
+public enum SpinnerSize { Sm, Default, Lg }
+",
+ [NativePlatform.Avalonia] = @"using System;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Loading spinner — a rotating loader-circle (ShellUI Loading, spinner variant). Spins only while
+// on screen and IsRunning. Usage:
+public class Spinner : Border
+{
+ public static readonly StyledProperty SizeProperty =
+ AvaloniaProperty.Register(nameof(Size), SpinnerSize.Default);
+
+ public static readonly StyledProperty TokenProperty =
+ AvaloniaProperty.Register(nameof(Token), ShellToken.Foreground);
+
+ public static readonly StyledProperty IsRunningProperty =
+ AvaloniaProperty.Register(nameof(IsRunning), true);
+
+ private readonly Icon _icon;
+ private readonly RotateTransform _spin = new();
+ private DispatcherTimer? _timer;
+
+ static Spinner()
+ {
+ SizeProperty.Changed.AddClassHandler((s, _) => s.UpdateSize());
+ TokenProperty.Changed.AddClassHandler((s, _) => s._icon.Token = s.Token);
+ IsRunningProperty.Changed.AddClassHandler((s, _) => s.UpdateRunning());
+ }
+
+ public SpinnerSize Size
+ {
+ get => GetValue(SizeProperty);
+ set => SetValue(SizeProperty, value);
+ }
+
+ public ShellToken Token
+ {
+ get => GetValue(TokenProperty);
+ set => SetValue(TokenProperty, value);
+ }
+
+ public bool IsRunning
+ {
+ get => GetValue(IsRunningProperty);
+ set => SetValue(IsRunningProperty, value);
+ }
+
+ public Spinner()
+ {
+ _icon = new Icon { Kind = IconName.LoaderCircle, Token = ShellToken.Foreground, RenderTransform = _spin };
+ Child = _icon;
+ HorizontalAlignment = HorizontalAlignment.Left;
+ VerticalAlignment = VerticalAlignment.Center;
+ AutomationProperties.SetName(this, ""Loading"");
+ UpdateSize();
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ UpdateRunning();
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ Stop();
+ }
+
+ private void UpdateSize() => _icon.Size = Size switch
+ {
+ SpinnerSize.Sm => 16,
+ SpinnerSize.Lg => 32,
+ _ => 24
+ };
+
+ // One turn per 800 ms, advanced once per frame (as Button's loading state).
+ private void UpdateRunning()
+ {
+ IsVisible = IsRunning;
+ if (!IsRunning || TopLevel.GetTopLevel(this) is null)
+ {
+ Stop();
+ return;
+ }
+ if (_timer != null) return;
+ _timer = new DispatcherTimer(TimeSpan.FromMilliseconds(16), DispatcherPriority.Render,
+ (_, _) => _spin.Angle = (_spin.Angle + 360 * 16 / 800.0) % 360);
+ _timer.Start();
+ }
+
+ private void Stop()
+ {
+ _timer?.Stop();
+ _timer = null;
+ }
+}
+
public enum SpinnerSize { Sm, Default, Lg }
"
};
diff --git a/src/ShellUI.Native.Templates/Templates/ToastTemplate.cs b/src/ShellUI.Native.Templates/Templates/ToastTemplate.cs
index 3fef025..54c70d4 100644
--- a/src/ShellUI.Native.Templates/Templates/ToastTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/ToastTemplate.cs
@@ -311,6 +311,337 @@ public async Task DismissAsync()
_host.Detach(this);
}
}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation.Easings;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+using YourProjectNamespace.Components.UI.Variants;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Sonner-style toasts. Call from anywhere — toasts float above the window's content:
+// Toast.Show(""Event created"", ""Sunday, December 03 at 9:00 AM"");
+// Toast.Success(""Profile saved"");
+// Toast.Error(""Upload failed"", actionText: ""Retry"", action: Retry);
+// Toasts stack in a corner, slide in, pause while hovered and dismiss themselves.
+// Optional: declare anywhere in a window to
+// configure them; without one, a default Toaster (bottom-right, 3 visible) is created on demand
+// in the active window.
+public static class Toast
+{
+ private static WeakReference? _toaster;
+
+ internal static void Register(Toaster toaster) => _toaster = new WeakReference(toaster);
+
+ internal static void Unregister(Toaster toaster)
+ {
+ if (_toaster != null && _toaster.TryGetTarget(out var current) && current == toaster)
+ _toaster = null;
+ }
+
+ public static string Show(string message, string? description = null, ToastVariant variant = ToastVariant.Default,
+ TimeSpan? duration = null, string? actionText = null, Action? action = null)
+ {
+ var id = Guid.NewGuid().ToString(""N"");
+ if (_toaster == null || !_toaster.TryGetTarget(out var toaster) || !toaster.IsAttached)
+ {
+ toaster = new Toaster();
+ if (ActiveTopLevel() is not { } top || !toaster.Attach(top))
+ {
+ System.Diagnostics.Debug.WriteLine(""ShellUI: Toast.Show called before any window is on screen."");
+ return id;
+ }
+ Register(toaster);
+ }
+ toaster.Add(new ToastItem(id, message, description, variant, duration ?? TimeSpan.FromSeconds(4), actionText, action));
+ return id;
+ }
+
+ public static string Success(string message, string? description = null, TimeSpan? duration = null,
+ string? actionText = null, Action? action = null)
+ => Show(message, description, ToastVariant.Success, duration, actionText, action);
+
+ public static string Error(string message, string? description = null, TimeSpan? duration = null,
+ string? actionText = null, Action? action = null)
+ => Show(message, description, ToastVariant.Error, duration, actionText, action);
+
+ public static string Warning(string message, string? description = null, TimeSpan? duration = null,
+ string? actionText = null, Action? action = null)
+ => Show(message, description, ToastVariant.Warning, duration, actionText, action);
+
+ public static string Info(string message, string? description = null, TimeSpan? duration = null,
+ string? actionText = null, Action? action = null)
+ => Show(message, description, ToastVariant.Info, duration, actionText, action);
+
+ public static void Dismiss(string id)
+ {
+ if (_toaster != null && _toaster.TryGetTarget(out var toaster))
+ toaster.Remove(id);
+ }
+
+ private static TopLevel? ActiveTopLevel() => Application.Current?.ApplicationLifetime switch
+ {
+ IClassicDesktopStyleApplicationLifetime desktop => desktop.Windows.FirstOrDefault(w => w.IsActive) ?? desktop.MainWindow,
+ ISingleViewApplicationLifetime single when single.MainView is { } view => TopLevel.GetTopLevel(view),
+ _ => null
+ };
+}
+
+public enum ToastVariant { Default, Success, Error, Warning, Info }
+
+public enum ToasterPosition { BottomRight, BottomCenter, TopRight, TopCenter }
+
+internal sealed record ToastItem(string Id, string Message, string? Description, ToastVariant Variant,
+ TimeSpan Duration, string? ActionText, Action? Action);
+
+// Configures where toasts appear. Its stack sits in the window's overlay layer, so the Toaster
+// itself takes no space and can be declared anywhere in the window.
+public class Toaster : Control
+{
+ public static readonly StyledProperty PositionProperty =
+ AvaloniaProperty.Register(nameof(Position));
+
+ public static readonly StyledProperty MaxVisibleProperty =
+ AvaloniaProperty.Register(nameof(MaxVisible), 3);
+
+ // Fills the overlay layer; only the toasts take input.
+ private readonly Panel _layer = new() { ZIndex = 1000 };
+ private readonly StackPanel _stack = new() { Spacing = 8, Margin = new Thickness(16) };
+ private OverlayLayer? _overlay;
+
+ static Toaster()
+ {
+ PositionProperty.Changed.AddClassHandler((t, _) => t.UpdatePosition());
+ }
+
+ public ToasterPosition Position
+ {
+ get => GetValue(PositionProperty);
+ set => SetValue(PositionProperty, value);
+ }
+
+ public int MaxVisible
+ {
+ get => GetValue(MaxVisibleProperty);
+ set => SetValue(MaxVisibleProperty, value);
+ }
+
+ public Toaster()
+ {
+ _layer.Children.Add(_stack);
+ IsHitTestVisible = false;
+ UpdatePosition();
+ }
+
+ internal bool IsAttached => _overlay != null;
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ if (TopLevel.GetTopLevel(this) is { } top && Attach(top)) Toast.Register(this);
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ Detach();
+ Toast.Unregister(this);
+ }
+
+ // Puts the toast stack in the overlay layer of `top`, sized to it.
+ internal bool Attach(TopLevel top)
+ {
+ if (_overlay != null) return true;
+ if (OverlayLayer.GetOverlayLayer(top) is not { } overlay) return false;
+ _overlay = overlay;
+ overlay.Children.Add(_layer);
+ overlay.SizeChanged += OnOverlaySizeChanged;
+ Fit(overlay.Bounds.Size);
+ return true;
+ }
+
+ private void Detach()
+ {
+ if (_overlay == null) return;
+ _overlay.SizeChanged -= OnOverlaySizeChanged;
+ _overlay.Children.Remove(_layer);
+ _overlay = null;
+ }
+
+ private void OnOverlaySizeChanged(object? sender, SizeChangedEventArgs e) => Fit(e.NewSize);
+
+ private void Fit(Size size)
+ {
+ _layer.Width = size.Width;
+ _layer.Height = size.Height;
+ _stack.Width = Math.Max(0, Math.Min(356, size.Width - 32));
+ }
+
+ private bool IsTop => Position is ToasterPosition.TopRight or ToasterPosition.TopCenter;
+
+ private void UpdatePosition()
+ {
+ _stack.VerticalAlignment = IsTop ? VerticalAlignment.Top : VerticalAlignment.Bottom;
+ _stack.HorizontalAlignment = Position is ToasterPosition.BottomCenter or ToasterPosition.TopCenter
+ ? HorizontalAlignment.Center
+ : HorizontalAlignment.Right;
+ }
+
+ internal void Add(ToastItem item)
+ {
+ var view = new ToastView(item, this);
+ // Newest toast sits nearest the edge it enters from.
+ if (IsTop) _stack.Children.Insert(0, view);
+ else _stack.Children.Add(view);
+
+ var views = _stack.Children.OfType().Where(v => !v.IsDismissing).ToList();
+ var overflow = views.Count - Math.Max(1, MaxVisible);
+ foreach (var old in (IsTop ? views.AsEnumerable().Reverse() : views).Take(Math.Max(0, overflow)))
+ _ = old.DismissAsync();
+
+ _ = view.EnterAsync(IsTop);
+ }
+
+ internal void Remove(string id)
+ {
+ var view = _stack.Children.OfType().FirstOrDefault(v => v.ToastId == id);
+ if (view != null) _ = view.DismissAsync();
+ }
+
+ internal void Detach(ToastView view) => _stack.Children.Remove(view);
+}
+
+// One toast — rounded-lg border bg-popover p-4 shadow-lg, variant icon, optional action, close X
+// shown on hover.
+internal sealed class ToastView : Panel
+{
+ public string ToastId { get; }
+ public bool IsDismissing { get; private set; }
+
+ private readonly ToastItem _item;
+ private readonly Toaster _host;
+ private readonly Border _close;
+ private DispatcherTimer? _timer;
+ private DateTime _deadline;
+
+ public ToastView(ToastItem item, Toaster host)
+ {
+ ToastId = item.Id;
+ _item = item;
+ _host = host;
+
+ var (icon, token) = item.Variant switch
+ {
+ ToastVariant.Success => (IconName.CircleCheck, ShellToken.Success),
+ ToastVariant.Error => (IconName.CircleX, ShellToken.Destructive),
+ ToastVariant.Warning => (IconName.TriangleAlert, ShellToken.Warning),
+ ToastVariant.Info => (IconName.Info, ShellToken.Info),
+ _ => (IconName.None, ShellToken.Foreground)
+ };
+
+ var title = new TextBlock { Text = item.Message, FontSize = 14, FontWeight = FontWeight.SemiBold, TextWrapping = TextWrapping.Wrap };
+ title.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground);
+ var text = new StackPanel { Spacing = 2, VerticalAlignment = VerticalAlignment.Center, Children = { title } };
+ if (!string.IsNullOrEmpty(item.Description))
+ {
+ var description = new TextBlock { Text = item.Description, FontSize = 13, TextWrapping = TextWrapping.Wrap };
+ description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ text.Children.Add(description);
+ }
+
+ var row = new Grid { ColumnDefinitions = new ColumnDefinitions(""Auto,*,Auto""), ColumnSpacing = 12 };
+ if (icon != IconName.None)
+ row.Children.Add(new Icon { Kind = icon, Size = 18, Token = token, VerticalAlignment = VerticalAlignment.Center });
+ Grid.SetColumn(text, 1);
+ row.Children.Add(text);
+
+ if (!string.IsNullOrEmpty(item.ActionText))
+ {
+ var action = new Button { Text = item.ActionText, Size = ButtonSize.Sm, VerticalAlignment = VerticalAlignment.Center };
+ action.Clicked += (_, _) => { item.Action?.Invoke(); _ = DismissAsync(); };
+ Grid.SetColumn(action, 2);
+ row.Children.Add(action);
+ }
+
+ _close = new Border
+ {
+ Child = new Icon { Kind = IconName.X, Size = 14, Token = ShellToken.MutedForeground },
+ Width = 22,
+ Height = 22,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(11),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(-8, -8, 0, 0),
+ Opacity = 0,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ _close.Token(Border.BackgroundProperty, ShellToken.Popover).Token(Border.BorderBrushProperty, ShellToken.Border);
+ AutomationProperties.SetName(_close, ""Close"");
+ _close.PointerReleased += (_, e) => { _ = DismissAsync(); e.Handled = true; };
+
+ var card = new Border
+ {
+ Child = row,
+ Padding = new Thickness(16),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusLg),
+ BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 4, Blur = 16, Color = ShellTheme.Shadow(0.12) })
+ };
+ card.Token(Border.BackgroundProperty, ShellToken.Popover).Token(Border.BorderBrushProperty, ShellToken.Border);
+
+ Children.Add(card);
+ Children.Add(_close);
+ Opacity = 0;
+ }
+
+ // Hovering pauses the timer and reveals the close button (Sonner behavior).
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ _ = ShellMotion.To(_close, 1, ""scale(1)"", 120, new CubicEaseOut());
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ _deadline = DateTime.UtcNow + TimeSpan.FromSeconds(1.5);
+ _ = ShellMotion.To(_close, 0, ""scale(1)"", 120, new CubicEaseIn());
+ }
+
+ public async Task EnterAsync(bool fromTop)
+ {
+ ShellMotion.Set(this, 0, fromTop ? ""translateY(-16px)"" : ""translateY(16px)"");
+ await ShellMotion.To(this, 1, ""translateY(0px)"", 250, new CubicEaseOut());
+
+ _deadline = DateTime.UtcNow + _item.Duration;
+ _timer = new DispatcherTimer(TimeSpan.FromMilliseconds(200), DispatcherPriority.Background, (_, _) =>
+ {
+ if (IsPointerOver || DateTime.UtcNow < _deadline) return;
+ _ = DismissAsync();
+ });
+ _timer.Start();
+ }
+
+ public async Task DismissAsync()
+ {
+ if (IsDismissing) return;
+ IsDismissing = true;
+ _timer?.Stop();
+ await ShellMotion.To(this, 0, ""translateX(24px)"", 150, new CubicEaseIn());
+ _host.Detach(this);
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/TooltipTemplate.cs b/src/ShellUI.Native.Templates/Templates/TooltipTemplate.cs
index 29660cb..c43fa8b 100644
--- a/src/ShellUI.Native.Templates/Templates/TooltipTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/TooltipTemplate.cs
@@ -111,6 +111,191 @@ private void Hide()
if (handle != null) _ = handle.CloseAsync();
}
}
+",
+ [NativePlatform.Avalonia] = @"using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+using Avalonia.Threading;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Hover tooltip — rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground, shown next to
+// the wrapped control after a short delay, or at once when it gets keyboard focus.
+// Usage:
+public class Tooltip : Border
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ // Top (default), Bottom, Left or Right.
+ public static readonly StyledProperty PlacementProperty =
+ AvaloniaProperty.Register(nameof(Placement), PlacementMode.Top);
+
+ public static readonly StyledProperty ContentProperty =
+ AvaloniaProperty.Register(nameof(Content));
+
+ // Milliseconds the pointer must rest before the tooltip shows.
+ public static readonly StyledProperty DelayProperty =
+ AvaloniaProperty.Register(nameof(Delay), 400);
+
+ private const double Gap = 6;
+
+ // The wrapped control and the popup, which has to sit in the tree next to it.
+ private readonly Panel _root = new();
+ private readonly Popup _popup;
+ private readonly TextBlock _label;
+ private int _version;
+
+ static Tooltip()
+ {
+ TextProperty.Changed.AddClassHandler((t, _) =>
+ {
+ t._label.Text = t.Text ?? string.Empty;
+ AutomationProperties.SetHelpText(t, t.Text);
+ });
+ ContentProperty.Changed.AddClassHandler((t, e) =>
+ {
+ if (e.OldValue is Control old) t._root.Children.Remove(old);
+ if (e.NewValue is Control content) t._root.Children.Insert(0, content);
+ });
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ // The wrapped control.
+ [Content]
+ public Control? Content
+ {
+ get => GetValue(ContentProperty);
+ set => SetValue(ContentProperty, value);
+ }
+
+ public PlacementMode Placement
+ {
+ get => GetValue(PlacementProperty);
+ set => SetValue(PlacementProperty, value);
+ }
+
+ public int Delay
+ {
+ get => GetValue(DelayProperty);
+ set => SetValue(DelayProperty, value);
+ }
+
+ public Tooltip()
+ {
+ _label = new TextBlock { FontSize = 12 };
+ _label.Token(TextBlock.ForegroundProperty, ShellToken.PrimaryForeground);
+ var bubble = new Border
+ {
+ Child = _label,
+ Padding = new Thickness(12, 6),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ IsHitTestVisible = false
+ };
+ bubble.Token(BackgroundProperty, ShellToken.Primary);
+
+ _popup = new Popup
+ {
+ ShouldUseOverlayLayer = true,
+ IsLightDismissEnabled = false,
+ PlacementTarget = this,
+ Child = bubble,
+ IsHitTestVisible = false
+ };
+ _root.Children.Add(_popup);
+ Child = _root;
+ HorizontalAlignment = HorizontalAlignment.Left;
+ VerticalAlignment = VerticalAlignment.Top;
+
+ // Tunnel, so a child that handles the press (Button) still hides the tooltip.
+ AddHandler(PointerPressedEvent, (_, _) => Hide(), RoutingStrategies.Tunnel);
+ AddHandler(GotFocusEvent, (_, e) =>
+ {
+ if (e.NavigationMethod is NavigationMethod.Tab or NavigationMethod.Directional) Show();
+ });
+ AddHandler(LostFocusEvent, (_, _) => Hide());
+ AddHandler(KeyDownEvent, (_, e) =>
+ {
+ if (e.Key == Key.Escape && _popup.IsOpen) { Hide(); e.Handled = true; }
+ }, RoutingStrategies.Tunnel);
+ }
+
+ protected override async void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ var version = ++_version;
+ await Task.Delay(Delay);
+ if (version == _version && IsPointerOver) Show();
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ Hide();
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ Hide();
+ }
+
+ private void Show()
+ {
+ if (_popup.IsOpen || string.IsNullOrEmpty(Text)) return;
+ _popup.Placement = Placement;
+ SetOffset(1);
+ _popup.IsOpen = true;
+ // Avalonia flips a bubble with no room on its side but keeps the offset's sign; mirror it.
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (_popup.IsOpen && _popup.Child is Visual bubble && IsFlipped(bubble)) SetOffset(-1);
+ }, DispatcherPriority.Loaded);
+ }
+
+ private void Hide()
+ {
+ _version++;
+ _popup.IsOpen = false;
+ }
+
+ private void SetOffset(int sign)
+ {
+ var (x, y) = Placement switch
+ {
+ PlacementMode.Bottom => (0.0, Gap),
+ PlacementMode.Left => (-Gap, 0.0),
+ PlacementMode.Right => (Gap, 0.0),
+ _ => (0.0, -Gap)
+ };
+ _popup.HorizontalOffset = x * sign;
+ _popup.VerticalOffset = y * sign;
+ }
+
+ private bool IsFlipped(Visual bubble)
+ {
+ if (bubble.TranslatePoint(default, this) is not { } at) return false;
+ return Placement switch
+ {
+ PlacementMode.Bottom => at.Y < 0,
+ PlacementMode.Left => at.X >= 0,
+ PlacementMode.Right => at.X < 0,
+ _ => at.Y >= 0
+ };
+ }
+}
"
};
}
diff --git a/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs
index 1cec4d4..12674ea 100644
--- a/tests/ShellUI.Native.Tests/TemplateContentTests.cs
+++ b/tests/ShellUI.Native.Tests/TemplateContentTests.cs
@@ -20,7 +20,7 @@ public static IEnumerable