Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ your background so the splash-to-app transition doesn't flash the template's pur
Ported so far: `shell`, `icon`, `theme-toggle`, P0 (`button`, `input`, `label`, `checkbox`,
`switch`, `card` and its parts, `separator`, `badge`, `progress`, `alert`), P1 (`dialog`,
`drawer`, `sheet`, `dropdown`, `popover` and their parts), P2 (`textarea`, `select`, `slider`,
`radio-group`, `calendar`, `date-picker`, `time-picker`) and P3 (`collapsible`, `accordion`,
`tabs`, `breadcrumb` and their parts, `skeleton`, `scroll-area`). Not yet published to NuGet. In an
`radio-group`, `calendar`, `date-picker`, `time-picker`), P3 (`collapsible`, `accordion`,
`tabs`, `breadcrumb` and their parts, `skeleton`, `scroll-area`) and P4 (`spinner`, `tooltip`,
`toast`, `alert-dialog`, `hover-card` and its parts). Not yet published to NuGet. In an
Avalonia project, `shellui-native list` shows only these.

Overlays use Avalonia's `Popup` in the window's overlay layer: the content stays in the logical
Expand Down Expand Up @@ -102,6 +103,10 @@ Same names, properties and variants as MAUI, except:
| Tabs keyboard | Tab and Enter | Also Left / Right to select the neighbouring tab |
| Skeleton `CornerRadius` | `double` | Border's `CornerRadius` (`CornerRadius="24"` still works) |
| ScrollArea scrollbar | Platform scrollbar | Fluent's thin scrollbar, hidden while idle |
| Tooltip | Shows on hover | Also shows on keyboard focus; `Placement` takes Avalonia's `PlacementMode` (`Top`, `Bottom`, `Left`, `Right`) |
| Toaster | Declared on a page | Declared in a window; `Toast.Show` without one uses the active window |
| HoverCard on touch | Tap toggles | Touch, pen and Enter / Space toggle; a mouse click acts on the wrapped control only |
| AlertDialog extra content | One `Body` view | Any XAML children, stacked between the description and the buttons |

---

Expand Down
11 changes: 8 additions & 3 deletions docs/DEVELOPMENT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ main ← Phase 1a (2026-07-05), 1b (PR #2), 1c + 1d (PR #3), 1e (PR #4), brand

| Branch | Base | Status | Purpose |
|--------|------|--------|---------|
| `main` | — | Phase 1a–1e, release `0.1.0-alpha.1`, Phase 2 foundation and P0–P2 merged | 92 registry entries (89 CLI targets, 58 families); Avalonia templates for the foundation and P0–P2 |
| `feat/avalonia-p3-navigation` | `main` | **Active** | Phase 2 P3: Avalonia navigation and layout |
| `main` | — | Phase 1a–1e, release `0.1.0-alpha.1`, Phase 2 foundation and P0–P3 merged | 92 registry entries (89 CLI targets, 58 families); Avalonia templates for the foundation and P0–P3 |
| `feat/avalonia-p4-feedback` | `main` | **Active** | Phase 2 P4: Avalonia feedback and overlays |

---

Expand Down Expand Up @@ -434,7 +434,7 @@ After merge: tag `v0.1.0-alpha.1` on `main` and push the tag.

---

## Phase 2 — Avalonia (active; one branch per tier, `feat/avalonia-p3-navigation` now)
## Phase 2 — Avalonia (active; one branch per tier, `feat/avalonia-p4-feedback` now)

Cross-desktop (Windows + macOS + Linux) from one XAML codebase.

Expand Down Expand Up @@ -472,6 +472,11 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase.
builds with 0 warnings; checked in the demo in light and dark, including keyboard tab switching.
Also on this branch: floating panels keep their 4px gap when they flip above the trigger, and
both demos use the ShellUI Native tile as their app icon (Android: adaptive, grid in the safe zone)
- [x] P4: spinner, tooltip, toast, alert-dialog, hover-card (and its parts). Toasts live in the
window's overlay layer; AlertDialog builds on `ShellOverlayHost` (focus trap, Escape = Cancel);
`ShellFloatingHost.ClaimsChild` lets a host place XAML children itself. `ShellLabel` now wraps
like MAUI's Label. Each installs alone into a fresh `dotnet new avalonia.app` and builds with 0
warnings; checked in the demo in light and dark
- [ ] Replace the generated `Icon.cs` with the `ShellIcons.Maui` / `ShellIcons.Avalonia` packages
once they are on NuGet. Their names already match (`IconName`, `Icon`, `Kind` on Avalonia), so
components change little: token tinting binds the package's `Color` (MAUI) or `Foreground`
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ shellui-native list
| Platform | Status | .NET Version |
|----------|--------|--------------|
| .NET MAUI | Available (`0.1.0-alpha.1`) — Android, iOS, Mac Catalyst, Windows | .NET 10.0 |
| Avalonia UI | In progress (Phase 2): theme, icons, P0–P3 components; not on NuGet yet | .NET 10.0 (Avalonia 12) |
| Avalonia UI | In progress (Phase 2): theme, icons, P0–P4 components; not on NuGet yet | .NET 10.0 (Avalonia 12) |
| WinUI 3 | Conditional (Phase 3) | .NET 10.0 |

WPF is intentionally not on this list — it's recognized for project detection only, not an
Expand Down
225 changes: 225 additions & 0 deletions examples/Avalonia.Demo/Components/UI/AlertDialog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using AvaloniaDemo.Components.UI.Variants;

namespace AvaloniaDemo.Components.UI;

// Confirmation dialog that requires a choice — no close button, and the backdrop doesn't
// dismiss it (shadcn AlertDialog). Declare it anywhere; it covers the window while open.
// <ui:AlertDialog x:Name="DeleteDialog" Title="Are you absolutely sure?"
// Description="This action cannot be undone." ConfirmText="Delete"
// ConfirmVariant="Destructive" Confirmed="OnDelete" />
// From code: if (await DeleteDialog.ShowAsync()) { ... }
// An optional <ui:AlertDialogTrigger> renders in place; any other XAML children go between the
// description and the buttons.
public class AlertDialog : ShellOverlayHost
{
public static readonly StyledProperty<string?> TitleProperty =
AvaloniaProperty.Register<AlertDialog, string?>(nameof(Title));

public static readonly StyledProperty<string?> DescriptionProperty =
AvaloniaProperty.Register<AlertDialog, string?>(nameof(Description));

public static readonly StyledProperty<string?> ConfirmTextProperty =
AvaloniaProperty.Register<AlertDialog, string?>(nameof(ConfirmText), "Continue");

// Empty hides the cancel button.
public static readonly StyledProperty<string?> CancelTextProperty =
AvaloniaProperty.Register<AlertDialog, string?>(nameof(CancelText), "Cancel");

public static readonly StyledProperty<ButtonVariant> ConfirmVariantProperty =
AvaloniaProperty.Register<AlertDialog, ButtonVariant>(nameof(ConfirmVariant));

private readonly AlertDialogPanel _panel;
private TaskCompletionSource<bool>? _pending;

static AlertDialog()
{
TitleProperty.Changed.AddClassHandler<AlertDialog>((d, _) => d._panel.Update());
DescriptionProperty.Changed.AddClassHandler<AlertDialog>((d, _) => d._panel.Update());
ConfirmTextProperty.Changed.AddClassHandler<AlertDialog>((d, _) => d._panel.Update());
CancelTextProperty.Changed.AddClassHandler<AlertDialog>((d, _) => d._panel.Update());
ConfirmVariantProperty.Changed.AddClassHandler<AlertDialog>((d, _) => d._panel.Update());
}

public string? Title
{
get => GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}

public string? Description
{
get => GetValue(DescriptionProperty);
set => SetValue(DescriptionProperty, value);
}

public string? ConfirmText
{
get => GetValue(ConfirmTextProperty);
set => SetValue(ConfirmTextProperty, value);
}

public string? CancelText
{
get => GetValue(CancelTextProperty);
set => SetValue(CancelTextProperty, value);
}

public ButtonVariant ConfirmVariant
{
get => GetValue(ConfirmVariantProperty);
set => SetValue(ConfirmVariantProperty, value);
}

public event EventHandler? Confirmed;
public event EventHandler? Cancelled;

public AlertDialog()
{
_panel = new AlertDialogPanel(this);
Items.Add(_panel);
// Runs after the host has sorted Items, so the body gets every child it claimed.
Items.CollectionChanged += (_, _) => _panel.SetBody(Items.Where(c => ClaimsChild(c)));
_panel.Update();
}

protected override bool IsContent(Control child) => child is AlertDialogPanel;

protected override bool ClaimsChild(Control child) => child is not (AlertDialogTrigger or AlertDialogPanel);

// Escape counts as Cancel.
protected override void Dismiss() => Resolve(false);

// Opens the dialog and completes with true (confirm) or false (cancel).
public Task<bool> ShowAsync()
{
_pending?.TrySetResult(false);
_pending = new TaskCompletionSource<bool>();
SetOpen(true);
return _pending.Task;
}

internal void Resolve(bool confirmed)
{
SetOpen(false);
if (confirmed) Confirmed?.Invoke(this, EventArgs.Empty);
else Cancelled?.Invoke(this, EventArgs.Empty);
_pending?.TrySetResult(confirmed);
_pending = null;
}
}

// Opens the enclosing AlertDialog. Usage: <ui:AlertDialogTrigger><ui:Button Text="Delete" /></ui:AlertDialogTrigger>
public class AlertDialogTrigger : ShellTriggerView
{
protected override void OnActivated() => this.FindParentOfType<AlertDialog>()?.SetOpen(true);
}

// Backdrop + centered box: max-w-lg rounded-lg border bg-background p-6 shadow-lg, fade + zoom-in-95.
internal sealed class AlertDialogPanel : Border, IShellOverlayContent
{
private readonly AlertDialog _owner;
private readonly Border _backdrop;
private readonly Border _box;
private readonly TextBlock _title;
private readonly TextBlock _description;
private readonly StackPanel _body = new() { Spacing = 8, IsVisible = false };
private readonly Button _cancel;
private readonly Button _confirm;

public AlertDialogPanel(AlertDialog owner)
{
_owner = owner;
_backdrop = new Border();
_backdrop.Token(BackgroundProperty, ShellToken.Overlay);
// Alert dialogs require a choice: a click on the backdrop does nothing.
_backdrop.PointerPressed += (_, e) => e.Handled = true;

_title = new TextBlock { FontSize = 18, FontWeight = FontWeight.SemiBold, TextWrapping = TextWrapping.Wrap };
_title.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
_description = new TextBlock { FontSize = 14, TextWrapping = TextWrapping.Wrap };
_description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);

_cancel = new Button { Variant = ButtonVariant.Outline };
_cancel.Clicked += (_, _) => _owner.Resolve(false);
_confirm = new Button();
_confirm.Clicked += (_, _) => _owner.Resolve(true);

_box = new Border
{
Child = new StackPanel
{
Spacing = 8,
Children =
{
_title,
_description,
_body,
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(0, 8, 0, 0),
Children = { _cancel, _confirm }
}
}
},
Padding = new Thickness(24),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(ShellTheme.RadiusLg),
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 10, Blur = 24, Color = ShellTheme.Shadow(0.2) })
};
_box.Token(BackgroundProperty, ShellToken.Background).Token(BorderBrushProperty, ShellToken.Border);
_box.PointerPressed += (_, e) => e.Handled = true;

Child = new Panel { Children = { _backdrop, _box } };
SizeChanged += (_, e) => _box.Width = Math.Max(0, Math.Min(512, e.NewSize.Width - 32));
}

public void Update()
{
_title.Text = _owner.Title;
_description.Text = _owner.Description;
_description.IsVisible = !string.IsNullOrEmpty(_owner.Description);
_cancel.Text = _owner.CancelText;
_cancel.IsVisible = !string.IsNullOrEmpty(_owner.CancelText);
_confirm.Text = _owner.ConfirmText;
_confirm.Variant = _owner.ConfirmVariant;
}

internal void SetBody(IEnumerable<Control> children)
{
_body.Children.Clear();
_body.Children.AddRange(children.ToList());
_body.IsVisible = _body.Children.Count > 0;
}

public async Task AnimateAsync(bool open)
{
if (open)
{
ShellMotion.Set(_backdrop, 0, "scale(1)");
ShellMotion.Set(_box, 0, "scale(0.95)");
await Task.WhenAll(
ShellMotion.To(_backdrop, 1, "scale(1)", 150, new CubicEaseOut()),
ShellMotion.To(_box, 1, "scale(1)", 150, new CubicEaseOut()));
}
else
{
await Task.WhenAll(
ShellMotion.To(_backdrop, 0, "scale(1)", 120, new CubicEaseIn()),
ShellMotion.To(_box, 0, "scale(0.95)", 120, new CubicEaseIn()));
}
}
}
65 changes: 65 additions & 0 deletions examples/Avalonia.Demo/Components/UI/HoverCard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;

namespace AvaloniaDemo.Components.UI;

// Hover card — rich content that floats under its trigger while the pointer is over either.
// Touch, pen and the keyboard (Enter / Space on the trigger) toggle it instead.
// <ui:HoverCard>
// <ui:HoverCardTrigger><ui:Button Text="@nextjs" Variant="Link" /></ui:HoverCardTrigger>
// <ui:HoverCardContent>...</ui:HoverCardContent>
// </ui:HoverCard>
public class HoverCard : ShellPopoverHost
{
// Milliseconds before the card opens / closes after the pointer enters / leaves.
public static readonly StyledProperty<int> OpenDelayProperty =
AvaloniaProperty.Register<HoverCard, int>(nameof(OpenDelay), 300);

public static readonly StyledProperty<int> CloseDelayProperty =
AvaloniaProperty.Register<HoverCard, int>(nameof(CloseDelay), 200);

private int _version;

public HoverCard()
{
// Light dismiss lays a window-wide layer under the card, which takes the pointer off the
// trigger and closes a card opened by hover; leaving both closes it instead.
Popup.IsLightDismissEnabled = false;
// When a tap did open it, a click elsewhere closes it and still reaches what was clicked.
Popup.OverlayDismissEventPassThrough = true;
IsOpenChanged += (_, open) =>
{
if (!open) Popup.IsLightDismissEnabled = false;
};
}

public int OpenDelay
{
get => GetValue(OpenDelayProperty);
set => SetValue(OpenDelayProperty, value);
}

public int CloseDelay
{
get => GetValue(CloseDelayProperty);
set => SetValue(CloseDelayProperty, value);
}

protected override bool IsContent(Control child) => child is HoverCardContent;

// Touch, pen or keyboard: with no hover to leave, a tap outside closes it.
internal void ToggleFromTap()
{
Popup.IsLightDismissEnabled = !IsOpen;
Toggle();
}

// Called by the trigger and the content as the pointer enters / leaves them.
internal async void SetHovered(bool hovered)
{
var version = ++_version;
await Task.Delay(hovered ? OpenDelay : CloseDelay);
if (version == _version) IsOpen = hovered;
}
}
39 changes: 39 additions & 0 deletions examples/Avalonia.Demo/Components/UI/HoverCardContent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Metadata;

namespace AvaloniaDemo.Components.UI;

// Hover card panel — w-64 rounded-md border bg-popover p-4 shadow-md. Stays open while hovered.
// XAML children go to Items.
public class HoverCardContent : Border
{
private readonly StackPanel _stack = new() { Spacing = 8 };

[Content]
public Controls Items => _stack.Children;

public HoverCardContent()
{
Child = _stack;
Width = 256;
Padding = new Thickness(16);
BorderThickness = new Thickness(1);
CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
BoxShadow = ShellPopups.PanelShadow();
this.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
}

protected override void OnPointerEntered(PointerEventArgs e)
{
base.OnPointerEntered(e);
if (e.Pointer.Type == PointerType.Mouse) this.FindParentOfType<HoverCard>()?.SetHovered(true);
}

protected override void OnPointerExited(PointerEventArgs e)
{
base.OnPointerExited(e);
if (e.Pointer.Type == PointerType.Mouse) this.FindParentOfType<HoverCard>()?.SetHovered(false);
}
}
Loading
Loading