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