Skip to content

Commit be36099

Browse files
authored
Merge pull request #8 from shellui-dev/feat/avalonia-p1-overlays
feat(avalonia): P1 overlays : dialog, drawer, sheet, dropdown, popover
2 parents a82197c + a2bdd85 commit be36099

55 files changed

Lines changed: 2283 additions & 12 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/COMPONENTS.md‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,16 @@ your background so the splash-to-app transition doesn't flash the template's pur
6767

6868
## Avalonia (in progress)
6969

70-
Ported so far: `shell`, `icon`, `theme-toggle` and P0 (`button`, `input`, `label`, `checkbox`,
71-
`switch`, `card` and its parts, `separator`, `badge`, `progress`, `alert`). Not yet published to
72-
NuGet. In an Avalonia project, `shellui-native list` shows only these.
70+
Ported so far: `shell`, `icon`, `theme-toggle`, P0 (`button`, `input`, `label`, `checkbox`,
71+
`switch`, `card` and its parts, `separator`, `badge`, `progress`, `alert`) and P1 (`dialog`,
72+
`drawer`, `sheet`, `dropdown`, `popover` and their parts). Not yet published to NuGet. In an
73+
Avalonia project, `shellui-native list` shows only these.
74+
75+
Overlays use Avalonia's `Popup` in the window's overlay layer: the content stays in the logical
76+
tree (theme, DataContext and `FindParentOfType` work as declared), dropdowns and popovers get
77+
Avalonia's placement and flip above the trigger when there is no room below, and a click outside
78+
closes them. Escape closes the overlay on top. Keyboard focus is not trapped inside an open
79+
dialog yet: Tab can still reach controls behind it.
7380

7481
Setup after `shellui-native init`: call `Components.UI.ShellTheme.EnsureInitialized();` in
7582
`App.Initialize` after `AvaloniaXamlLoader.Load(this);`, and add
@@ -85,6 +92,9 @@ Same names, properties and variants as MAUI, except:
8592
| Card children | XAML children | XAML children, collected in `Card.Items` |
8693
| Block button | `HorizontalOptions="Fill"` | `HorizontalAlignment="Stretch"` |
8794
| Focus ring | Platform focus visuals | shadcn's ring, for keyboard focus only |
95+
| Overlay children | XAML children | XAML children, collected in `Items` (`Dialog`, `DialogContent`, `Dropdown`, …) |
96+
| Dropdown / Popover | `ToggleAsync()` / `CloseAsync()` kept for older code | `Toggle()` / `Close()` only |
97+
| Flipped popup gap | 4px above the trigger | Flush with the trigger (Avalonia doesn't mirror the offset) |
8898

8999
---
90100

‎docs/DEVELOPMENT_PLAN.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,7 +457,11 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase.
457457
- [x] P0: button (+ button-variants), input, label, checkbox, switch, card family, separator, badge,
458458
progress, alert. Each installs alone into a fresh `dotnet new avalonia.app` and builds with 0
459459
warnings; checked in the demo in light and dark, with the keyboard focus ring and loading state
460-
- [ ] P1: dialog, drawer, sheet, dropdown, popover (Avalonia's overlay layer and popups)
460+
- [x] P1: dialog, drawer, sheet, dropdown, popover and their parts, on Avalonia's `Popup` in the
461+
overlay layer (shared hosts in `shell`: `ShellOverlayHost`, `ShellPopoverHost`,
462+
`ShellTriggerView`, `ShellDismiss` for Escape). Each installs alone into a fresh
463+
`dotnet new avalonia.app`; checked in the demo: opening from triggers, placement and flip,
464+
Escape, picking an item, `DialogClose`, light and dark. Open: focus trapping in dialogs
461465
- [ ] Replace the generated `Icon.cs` with the `ShellIcons.Maui` / `ShellIcons.Avalonia` packages
462466
once they are on NuGet. Their names already match (`IconName`, `Icon`, `Kind` on Avalonia), so
463467
components change little: token tinting binds the package's `Color` (MAUI) or `Foreground`

‎docs/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ shellui-native list
5454
| Platform | Status | .NET Version |
5555
|----------|--------|--------------|
5656
| .NET MAUI | Available (`0.1.0-alpha.1`) — Android, iOS, Mac Catalyst, Windows | .NET 10.0 |
57-
| Avalonia UI | In progress (Phase 2): theme, icons and P0 components; not on NuGet yet | .NET 10.0 (Avalonia 12) |
57+
| Avalonia UI | In progress (Phase 2): theme, icons, P0 and P1 components; not on NuGet yet | .NET 10.0 (Avalonia 12) |
5858
| WinUI 3 | Conditional (Phase 3) | .NET 10.0 |
5959

6060
WPF is intentionally not on this list — it's recognized for project detection only, not an

‎examples/Avalonia.Demo/Components/UI/Button.cs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ namespace AvaloniaDemo.Components.UI;
1515
// Button with ShellUI variants, sizes, optional icon and loading state.
1616
// Sizes to its content like an inline-flex button; set HorizontalAlignment="Stretch" for a block button.
1717
// Usage: <ui:Button Text="Save" Icon="Check" Variant="Outline" Clicked="OnSave" />
18-
public class Button : Border
18+
public class Button : Border, IShellFocusable
1919
{
2020
public static readonly StyledProperty<ButtonVariant> VariantProperty =
2121
AvaloniaProperty.Register<Button, ButtonVariant>(nameof(Variant));
@@ -125,7 +125,10 @@ public Button()
125125
// Clicks in code, as a pointer click or Enter/Space would.
126126
public void Press()
127127
{
128-
if (CanClick) Clicked?.Invoke(this, EventArgs.Empty);
128+
if (!CanClick) return;
129+
Clicked?.Invoke(this, EventArgs.Empty);
130+
// Inside a DialogTrigger, DropdownTrigger, ... the click also activates the trigger.
131+
ShellTriggerView.ActivateAncestor(this);
129132
}
130133

131134
protected override void OnPointerEntered(PointerEventArgs e)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace AvaloniaDemo.Components.UI;
2+
3+
// Modal dialog. Declare it anywhere; the content covers the window while open:
4+
// <ui:Dialog x:Name="ConfirmDialog">
5+
// <ui:DialogTrigger><ui:Button Text="Open" /></ui:DialogTrigger> (optional)
6+
// <ui:DialogContent>...</ui:DialogContent>
7+
// </ui:Dialog>
8+
// Open from code with ConfirmDialog.SetOpen(true).
9+
public class Dialog : ShellOverlayHost
10+
{
11+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace AvaloniaDemo.Components.UI;
2+
3+
// Closes the enclosing Dialog. Wrap a Button: <ui:DialogClose><ui:Button Text="Cancel" /></ui:DialogClose>
4+
public class DialogClose : ShellTriggerView
5+
{
6+
protected override void OnActivated() => this.FindParentOfType<Dialog>()?.SetOpen(false);
7+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
using System;
2+
using System.Threading.Tasks;
3+
using Avalonia;
4+
using Avalonia.Animation.Easings;
5+
using Avalonia.Automation;
6+
using Avalonia.Controls;
7+
using Avalonia.Input;
8+
using Avalonia.Layout;
9+
using Avalonia.Media;
10+
using Avalonia.Metadata;
11+
12+
namespace AvaloniaDemo.Components.UI;
13+
14+
// Dialog panel — backdrop + centered box: max-w-lg rounded-lg border bg-background p-6
15+
// shadow-lg, close button top-right; fades and zooms in from 95%. XAML children go to Items.
16+
public class DialogContent : Border, IShellOverlayContent
17+
{
18+
public static readonly StyledProperty<bool> ShowCloseButtonProperty =
19+
AvaloniaProperty.Register<DialogContent, bool>(nameof(ShowCloseButton), true);
20+
21+
private readonly Border _backdrop;
22+
private readonly Border _box;
23+
private readonly Border _close;
24+
private readonly StackPanel _body = new() { Spacing = 16 };
25+
26+
static DialogContent()
27+
{
28+
ShowCloseButtonProperty.Changed.AddClassHandler<DialogContent>((c, e) => c._close.IsVisible = (bool)e.NewValue!);
29+
}
30+
31+
public bool ShowCloseButton
32+
{
33+
get => GetValue(ShowCloseButtonProperty);
34+
set => SetValue(ShowCloseButtonProperty, value);
35+
}
36+
37+
[Content]
38+
public Controls Items => _body.Children;
39+
40+
public DialogContent()
41+
{
42+
_backdrop = new Border();
43+
_backdrop.Token(BackgroundProperty, ShellToken.Overlay);
44+
_backdrop.PointerPressed += (_, e) =>
45+
{
46+
this.FindParentOfType<Dialog>()?.SetOpen(false);
47+
e.Handled = true;
48+
};
49+
50+
_close = new Border
51+
{
52+
Child = new Icon { Kind = IconName.X, Size = 16, Token = ShellToken.MutedForeground },
53+
Width = 24,
54+
Height = 24,
55+
CornerRadius = new CornerRadius(ShellTheme.RadiusSm),
56+
Background = Brushes.Transparent,
57+
HorizontalAlignment = HorizontalAlignment.Right,
58+
VerticalAlignment = VerticalAlignment.Top,
59+
Margin = new Thickness(0, -8, -8, 0),
60+
Cursor = new Cursor(StandardCursorType.Hand)
61+
};
62+
AutomationProperties.SetName(_close, "Close");
63+
_close.PointerEntered += (_, _) => _close.Token(BackgroundProperty, ShellToken.Accent);
64+
_close.PointerExited += (_, _) => { _close.ClearToken(BackgroundProperty); _close.Background = Brushes.Transparent; };
65+
_close.PointerReleased += (_, e) =>
66+
{
67+
this.FindParentOfType<Dialog>()?.SetOpen(false);
68+
e.Handled = true;
69+
};
70+
71+
_box = new Border
72+
{
73+
Child = new Panel { Children = { _body, _close } },
74+
Padding = new Thickness(24),
75+
BorderThickness = new Thickness(1),
76+
CornerRadius = new CornerRadius(ShellTheme.RadiusLg),
77+
HorizontalAlignment = HorizontalAlignment.Center,
78+
VerticalAlignment = VerticalAlignment.Center,
79+
BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 10, Blur = 24, Color = ShellTheme.Shadow(0.2) })
80+
};
81+
_box.Token(BackgroundProperty, ShellToken.Background).Token(BorderBrushProperty, ShellToken.Border);
82+
// Clicks on the box must not reach the backdrop behind it.
83+
_box.PointerPressed += (_, e) => e.Handled = true;
84+
85+
Child = new Panel { Children = { _backdrop, _box } };
86+
SizeChanged += (_, e) => _box.Width = Math.Max(0, Math.Min(512, e.NewSize.Width - 32));
87+
}
88+
89+
public async Task AnimateAsync(bool open)
90+
{
91+
if (open)
92+
{
93+
ShellMotion.Set(_backdrop, 0, "scale(1)");
94+
ShellMotion.Set(_box, 0, "scale(0.95)");
95+
await Task.WhenAll(
96+
ShellMotion.To(_backdrop, 1, "scale(1)", 150, new CubicEaseOut()),
97+
ShellMotion.To(_box, 1, "scale(1)", 150, new CubicEaseOut()));
98+
}
99+
else
100+
{
101+
await Task.WhenAll(
102+
ShellMotion.To(_backdrop, 0, "scale(1)", 120, new CubicEaseIn()),
103+
ShellMotion.To(_box, 0, "scale(0.95)", 120, new CubicEaseIn()));
104+
}
105+
}
106+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using Avalonia.Controls;
2+
using Avalonia.Media;
3+
4+
namespace AvaloniaDemo.Components.UI;
5+
6+
// text-sm text-muted-foreground
7+
public class DialogDescription : TextBlock
8+
{
9+
public DialogDescription()
10+
{
11+
FontSize = 14;
12+
TextWrapping = TextWrapping.Wrap;
13+
this.Token(ForegroundProperty, ShellToken.MutedForeground);
14+
}
15+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using Avalonia;
2+
using Avalonia.Controls;
3+
using Avalonia.Layout;
4+
5+
namespace AvaloniaDemo.Components.UI;
6+
7+
// Actions row — flex justify-end gap-2.
8+
public class DialogFooter : StackPanel
9+
{
10+
public DialogFooter()
11+
{
12+
Orientation = Orientation.Horizontal;
13+
Spacing = 8;
14+
HorizontalAlignment = HorizontalAlignment.Right;
15+
Margin = new Thickness(0, 8, 0, 0);
16+
}
17+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using Avalonia;
2+
using Avalonia.Controls;
3+
4+
namespace AvaloniaDemo.Components.UI;
5+
6+
// Title + description stack — flex flex-col space-y-1.5. Leaves room for the close button.
7+
public class DialogHeader : StackPanel
8+
{
9+
public DialogHeader()
10+
{
11+
Spacing = 6;
12+
Margin = new Thickness(0, 0, 24, 0);
13+
}
14+
}

0 commit comments

Comments
 (0)