From 14f1f8aa1554c7fe5413d05580a079d192b8a7c3 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Fri, 9 Oct 2026 15:50:04 +0200 Subject: [PATCH 1/4] fix(avalonia): ShellLabel wraps like MAUI's Label TextBlock defaults to one line, which cut off labels in fixed-width panels such as a hover card. TextWrapping="NoWrap" keeps the old behavior. --- examples/Avalonia.Demo/Components/UI/Label.cs | 7 ++++++- src/ShellUI.Native.Templates/Templates/LabelTemplate.cs | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) 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/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() { From 00e55d77724cf9a7099b563c22634ecc23cd721b Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Fri, 9 Oct 2026 15:50:04 +0200 Subject: [PATCH 2/4] feat(avalonia): port the P4 feedback components Spinner, tooltip, toast, alert-dialog and hover-card with its parts. Toasts live in the window's overlay layer, so Toast.Show works from anywhere. AlertDialog builds on the overlay host, so it gets the focus trap; ShellFloatingHost.ClaimsChild lets it place extra XAML children in its body. Tooltips also show on keyboard focus. --- .../Components/UI/AlertDialog.cs | 225 ++++++++++++ .../Avalonia.Demo/Components/UI/HoverCard.cs | 51 +++ .../Components/UI/HoverCardContent.cs | 39 +++ .../Components/UI/HoverCardTrigger.cs | 36 ++ examples/Avalonia.Demo/Components/UI/Shell.cs | 5 +- .../Avalonia.Demo/Components/UI/Spinner.cs | 104 ++++++ examples/Avalonia.Demo/Components/UI/Toast.cs | 330 +++++++++++++++++ .../Avalonia.Demo/Components/UI/Tooltip.cs | 184 ++++++++++ examples/Avalonia.Demo/MainWindow.axaml | 37 ++ examples/Avalonia.Demo/MainWindow.axaml.cs | 13 + .../Templates/AlertDialogTemplate.cs | 226 ++++++++++++ .../Templates/HoverCardContentTemplate.cs | 40 +++ .../Templates/HoverCardTemplate.cs | 52 +++ .../Templates/HoverCardTriggerTemplate.cs | 37 ++ .../Templates/ShellTemplate.cs | 5 +- .../Templates/SpinnerTemplate.cs | 105 ++++++ .../Templates/ToastTemplate.cs | 331 ++++++++++++++++++ .../Templates/TooltipTemplate.cs | 185 ++++++++++ .../TemplateContentTests.cs | 9 +- 19 files changed, 2011 insertions(+), 3 deletions(-) create mode 100644 examples/Avalonia.Demo/Components/UI/AlertDialog.cs create mode 100644 examples/Avalonia.Demo/Components/UI/HoverCard.cs create mode 100644 examples/Avalonia.Demo/Components/UI/HoverCardContent.cs create mode 100644 examples/Avalonia.Demo/Components/UI/HoverCardTrigger.cs create mode 100644 examples/Avalonia.Demo/Components/UI/Spinner.cs create mode 100644 examples/Avalonia.Demo/Components/UI/Toast.cs create mode 100644 examples/Avalonia.Demo/Components/UI/Tooltip.cs 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..0d4e315 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/HoverCard.cs @@ -0,0 +1,51 @@ +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() + { + // A click elsewhere closes the card and still reaches what was clicked. + Popup.OverlayDismissEventPassThrough = true; + } + + 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; + + // 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..67c36df --- /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()?.Toggle(); + } +} 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..8ccdd6f 100644 --- a/src/ShellUI.Native.Templates/Templates/HoverCardTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/HoverCardTemplate.cs @@ -64,6 +64,58 @@ 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() + { + // A click elsewhere closes the card and still reaches what was clicked. + Popup.OverlayDismissEventPassThrough = true; + } + + 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; + + // 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..e4960e0 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()?.Toggle(); + } +} " }; } 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 AvaloniaComponents() .Where(name => ComponentRegistry.SupportsPlatform(name, NativePlatform.Avalonia)) .Select(name => new object[] { name }); - // Ported so far: the Phase 2 foundation, P0, P1, P2 and P3. Grows as components are ported. + // Ported so far: the Phase 2 foundation, P0 to P4. Grows as components are ported. [Theory] [InlineData("shell")] [InlineData("icon")] @@ -84,6 +84,13 @@ public static IEnumerable AvaloniaComponents() [InlineData("breadcrumb-item")] [InlineData("skeleton")] [InlineData("scroll-area")] + [InlineData("spinner")] + [InlineData("tooltip")] + [InlineData("toast")] + [InlineData("alert-dialog")] + [InlineData("hover-card")] + [InlineData("hover-card-trigger")] + [InlineData("hover-card-content")] public void Ported_components_have_Avalonia_content(string name) { Assert.False(string.IsNullOrWhiteSpace(ComponentRegistry.GetComponentContent(name, NativePlatform.Avalonia))); From 8c183a586f6fae1611890dab7842d90b183f4118 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Fri, 9 Oct 2026 15:50:04 +0200 Subject: [PATCH 3/4] docs: Avalonia P4 components and their differences from MAUI --- docs/COMPONENTS.md | 9 +++++++-- docs/DEVELOPMENT_PLAN.md | 11 ++++++++--- docs/README.md | 2 +- 3 files changed, 16 insertions(+), 6 deletions(-) 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 From 34a558d91b00ba5b142283f09ef6137707aad9ab Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Fri, 9 Oct 2026 19:52:08 +0200 Subject: [PATCH 4/4] fix(avalonia): hover card no longer flickers while hovered Light dismiss put a window-wide layer under the card, which took the pointer off the trigger: the card closed, the layer went away, the trigger was hovered again and the card reopened. A card opened by hover has no light dismiss now; one opened by touch, pen or the keyboard still closes on a tap outside. --- .../Avalonia.Demo/Components/UI/HoverCard.cs | 16 +++++++++++++++- .../Components/UI/HoverCardTrigger.cs | 2 +- .../Templates/HoverCardTemplate.cs | 16 +++++++++++++++- .../Templates/HoverCardTriggerTemplate.cs | 2 +- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/examples/Avalonia.Demo/Components/UI/HoverCard.cs b/examples/Avalonia.Demo/Components/UI/HoverCard.cs index 0d4e315..9464152 100644 --- a/examples/Avalonia.Demo/Components/UI/HoverCard.cs +++ b/examples/Avalonia.Demo/Components/UI/HoverCard.cs @@ -23,8 +23,15 @@ public class HoverCard : ShellPopoverHost public HoverCard() { - // A click elsewhere closes the card and still reaches what was clicked. + // 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 @@ -41,6 +48,13 @@ public int CloseDelay 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) { diff --git a/examples/Avalonia.Demo/Components/UI/HoverCardTrigger.cs b/examples/Avalonia.Demo/Components/UI/HoverCardTrigger.cs index 67c36df..84fd2f4 100644 --- a/examples/Avalonia.Demo/Components/UI/HoverCardTrigger.cs +++ b/examples/Avalonia.Demo/Components/UI/HoverCardTrigger.cs @@ -31,6 +31,6 @@ protected override void OnPointerExited(PointerEventArgs e) // A mouse click acts on the wrapped control only; hover already shows the card. protected override void OnActivated() { - if (!_mouse) this.FindParentOfType()?.Toggle(); + if (!_mouse) this.FindParentOfType()?.ToggleFromTap(); } } diff --git a/src/ShellUI.Native.Templates/Templates/HoverCardTemplate.cs b/src/ShellUI.Native.Templates/Templates/HoverCardTemplate.cs index 8ccdd6f..a76efab 100644 --- a/src/ShellUI.Native.Templates/Templates/HoverCardTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/HoverCardTemplate.cs @@ -90,8 +90,15 @@ public class HoverCard : ShellPopoverHost public HoverCard() { - // A click elsewhere closes the card and still reaches what was clicked. + // 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 @@ -108,6 +115,13 @@ public int CloseDelay 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) { diff --git a/src/ShellUI.Native.Templates/Templates/HoverCardTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/HoverCardTriggerTemplate.cs index e4960e0..07556ed 100644 --- a/src/ShellUI.Native.Templates/Templates/HoverCardTriggerTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/HoverCardTriggerTemplate.cs @@ -70,7 +70,7 @@ protected override void OnPointerExited(PointerEventArgs e) // A mouse click acts on the wrapped control only; hover already shows the card. protected override void OnActivated() { - if (!_mouse) this.FindParentOfType()?.Toggle(); + if (!_mouse) this.FindParentOfType()?.ToggleFromTap(); } } "