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