diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md
index 9d53b47..6da1126 100644
--- a/docs/COMPONENTS.md
+++ b/docs/COMPONENTS.md
@@ -71,9 +71,10 @@ Ported so far: `shell`, `icon`, `theme-toggle`, P0 (`button`, `input`, `label`,
`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`), 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.
+`tabs`, `breadcrumb` and their parts, `skeleton`, `scroll-area`), P4 (`spinner`, `tooltip`,
+`toast`, `alert-dialog`, `hover-card` and its parts), P5 (`avatar`, `toggle`, `callout`,
+`table`, `empty-state`, `pagination`) and P6 (`context-menu`, `carousel`, `stepper`). 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
@@ -107,6 +108,12 @@ Same names, properties and variants as MAUI, except:
| 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 |
+| Avatar `Source` | `ImageSource` (`Source="profile.png"`) | `IImage`, an Avalonia resource path (`Source="/Assets/profile.png"`) |
+| TableRow clicks | `Tapped` event on the row | Avalonia's own `Tapped` event (`TappedEventArgs`) |
+| ContextMenu | Right-click, or long-press on touch | Also the Menu key / Shift+F10 while focus is inside the trigger; a right-click while it is open moves it; items are tab stops; children are collected in `Items` |
+| Carousel | Swipe, arrows, dots | Drag with the mouse or touch; the arrows are tab stops and Left / Right move while one has focus; with `AspectRatio="0"` set `Height` |
+| Stepper | Shows the active step's view | Every step stays in the tree and only the active one is visible; reached steps are tab stops (Enter / Space) |
+| Pagination | Labels hidden on phones | `ShowLabels` defaults to true; every page and arrow is a tab stop (Enter / Space) |
---
diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md
index c557d6d..4d3a322 100644
--- a/docs/DEVELOPMENT_PLAN.md
+++ b/docs/DEVELOPMENT_PLAN.md
@@ -35,8 +35,9 @@ 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–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 |
+| `main` | — | Phase 1a–1e, release `0.1.0-alpha.1`, Phase 2 foundation and P0–P4 merged | 92 registry entries (89 CLI targets, 58 families); Avalonia templates for the foundation and P0–P4 |
+| `feat/avalonia-p5-data-display` | `main` | Pushed, PR next | Phase 2 P5: Avalonia data display |
+| `feat/avalonia-p6-advanced` | `feat/avalonia-p5-data-display` | **Active** | Phase 2 P6: Avalonia context menu, carousel and stepper |
---
@@ -434,7 +435,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-p4-feedback` now)
+## Phase 2 — Avalonia (active; one branch per tier, `feat/avalonia-p6-advanced` now)
Cross-desktop (Windows + macOS + Linux) from one XAML codebase.
@@ -477,6 +478,18 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase.
`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
+- [x] P5: avatar, toggle, callout, table (one template: Table, TableHeader, TableRow, TableHead,
+ TableCell), empty-state, pagination. Each installs alone into a fresh `dotnet new avalonia.app`
+ and builds with 0 warnings; checked in the demo in light and dark. Both demos now use ShellUI
+ content: Shewart Shepherd / @shewart, shellui-dev repositories, and the Shell family marks as
+ avatar images
+- [x] P6: context-menu (one template: ContextMenu, ContextMenuTrigger, ContextMenuContent,
+ ContextMenuItem, ContextMenuSeparator), carousel, stepper (Stepper + StepperStep). The context
+ menu opens on Avalonia's `ContextRequested` (right-click, Menu key / Shift+F10) and on a touch
+ hold, at the pointer through `Popup.PlacementRect`; the carousel animates on the window's
+ animation frames. Each installs alone into a fresh Avalonia app and builds with 0 warnings;
+ checked in the demo in light and dark with real pointer and key input. resizable, navbar and
+ sidebar wait for their MAUI versions
- [ ] 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 ba68dfe..5df1f32 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -54,7 +54,7 @@ shellui-native list
| Platform | Status | .NET Version |
|----------|--------|--------------|
| .NET MAUI | Available (`0.1.0-alpha.1`) — Android, iOS, Mac Catalyst, Windows | .NET 10.0 |
-| Avalonia UI | In progress (Phase 2): theme, icons, P0–P4 components; not on NuGet yet | .NET 10.0 (Avalonia 12) |
+| Avalonia UI | In progress (Phase 2): theme, icons, P0–P5 components and P6's context menu, carousel and stepper; 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/Assets/Brand/shelldocs.png b/examples/Avalonia.Demo/Assets/Brand/shelldocs.png
new file mode 100644
index 0000000..d28aafa
Binary files /dev/null and b/examples/Avalonia.Demo/Assets/Brand/shelldocs.png differ
diff --git a/examples/Avalonia.Demo/Assets/Brand/shellicons.png b/examples/Avalonia.Demo/Assets/Brand/shellicons.png
new file mode 100644
index 0000000..efeb9fb
Binary files /dev/null and b/examples/Avalonia.Demo/Assets/Brand/shellicons.png differ
diff --git a/examples/Avalonia.Demo/Assets/Brand/shellui.png b/examples/Avalonia.Demo/Assets/Brand/shellui.png
new file mode 100644
index 0000000..48b09d4
Binary files /dev/null and b/examples/Avalonia.Demo/Assets/Brand/shellui.png differ
diff --git a/examples/Avalonia.Demo/Assets/Brand/shellui_native.png b/examples/Avalonia.Demo/Assets/Brand/shellui_native.png
new file mode 100644
index 0000000..b5277e4
Binary files /dev/null and b/examples/Avalonia.Demo/Assets/Brand/shellui_native.png differ
diff --git a/examples/Avalonia.Demo/Components/UI/Avatar.cs b/examples/Avalonia.Demo/Components/UI/Avatar.cs
new file mode 100644
index 0000000..9b457b7
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Avatar.cs
@@ -0,0 +1,105 @@
+using System;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Avatar — rounded-full image over a bg-muted fallback (initials, or a user icon when no
+// Fallback is set). The fallback shows wherever the image doesn't cover it.
+// Usage:
+public class Avatar : Border
+{
+ public static readonly StyledProperty SourceProperty =
+ AvaloniaProperty.Register(nameof(Source));
+
+ public static readonly StyledProperty FallbackProperty =
+ AvaloniaProperty.Register(nameof(Fallback));
+
+ public static readonly StyledProperty SizeProperty =
+ AvaloniaProperty.Register(nameof(Size), AvatarSize.Default);
+
+ private readonly TextBlock _initials;
+ private readonly Icon _userIcon;
+ private readonly Image _image;
+
+ static Avatar()
+ {
+ SourceProperty.Changed.AddClassHandler((a, _) =>
+ {
+ a._image.Source = a.Source;
+ a._image.IsVisible = a.Source != null;
+ });
+ FallbackProperty.Changed.AddClassHandler((a, _) => a.UpdateFallback());
+ SizeProperty.Changed.AddClassHandler((a, _) => a.UpdateSize());
+ }
+
+ public IImage? Source
+ {
+ get => GetValue(SourceProperty);
+ set => SetValue(SourceProperty, value);
+ }
+
+ public string? Fallback
+ {
+ get => GetValue(FallbackProperty);
+ set => SetValue(FallbackProperty, value);
+ }
+
+ public AvatarSize Size
+ {
+ get => GetValue(SizeProperty);
+ set => SetValue(SizeProperty, value);
+ }
+
+ public Avatar()
+ {
+ _initials = new TextBlock
+ {
+ FontWeight = FontWeight.SemiBold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ _initials.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ _userIcon = new Icon { Kind = IconName.User, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ _image = new Image { Stretch = Stretch.UniformToFill, IsVisible = false };
+
+ Child = new Panel { Children = { _initials, _userIcon, _image } };
+ // Clips the image to the circle.
+ ClipToBounds = true;
+ HorizontalAlignment = HorizontalAlignment.Left;
+ VerticalAlignment = VerticalAlignment.Center;
+ this.Token(BackgroundProperty, ShellToken.Muted);
+ UpdateSize();
+ UpdateFallback();
+ }
+
+ private void UpdateSize()
+ {
+ var size = Size switch
+ {
+ AvatarSize.Sm => 32,
+ AvatarSize.Lg => 48,
+ AvatarSize.Xl => 64,
+ _ => 40
+ };
+ Width = size;
+ Height = size;
+ CornerRadius = new CornerRadius(size / 2.0);
+ _initials.FontSize = Math.Round(size * 0.36);
+ _userIcon.Size = size / 2.0;
+ }
+
+ private void UpdateFallback()
+ {
+ var hasText = !string.IsNullOrWhiteSpace(Fallback);
+ _initials.Text = Fallback ?? string.Empty;
+ _initials.IsVisible = hasText;
+ _userIcon.IsVisible = !hasText;
+ AutomationProperties.SetName(this, hasText ? Fallback : "Avatar");
+ }
+}
+
+public enum AvatarSize { Sm, Default, Lg, Xl }
diff --git a/examples/Avalonia.Demo/Components/UI/Callout.cs b/examples/Avalonia.Demo/Components/UI/Callout.cs
new file mode 100644
index 0000000..a2d7b98
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Callout.cs
@@ -0,0 +1,137 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Highlighted note — rounded-lg border px-4 py-3 on a faint tint of the variant color, with an
+// icon, an optional title and text and/or any content.
+//
+//
+public class Callout : Border
+{
+ public static readonly StyledProperty VariantProperty =
+ AvaloniaProperty.Register(nameof(Variant), CalloutVariant.Info);
+
+ public static readonly StyledProperty TitleProperty =
+ AvaloniaProperty.Register(nameof(Title));
+
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ // None uses the variant's own icon.
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon), IconName.None);
+
+ public static readonly StyledProperty BodyProperty =
+ AvaloniaProperty.Register(nameof(Body));
+
+ private readonly Border _tint;
+ private readonly Icon _icon;
+ private readonly TextBlock _title;
+ private readonly TextBlock _text;
+ private readonly ContentControl _body = new();
+
+ static Callout()
+ {
+ VariantProperty.Changed.AddClassHandler((c, _) => c.Update());
+ TitleProperty.Changed.AddClassHandler((c, _) => c.Update());
+ TextProperty.Changed.AddClassHandler((c, _) => c.Update());
+ IconProperty.Changed.AddClassHandler((c, _) => c.Update());
+ BodyProperty.Changed.AddClassHandler((c, _) => c.Update());
+ }
+
+ public CalloutVariant Variant
+ {
+ get => GetValue(VariantProperty);
+ set => SetValue(VariantProperty, value);
+ }
+
+ public string? Title
+ {
+ get => GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ [Content]
+ public Control? Body
+ {
+ get => GetValue(BodyProperty);
+ set => SetValue(BodyProperty, value);
+ }
+
+ public Callout()
+ {
+ _tint = new Border { CornerRadius = new CornerRadius(ShellTheme.RadiusLg - 1) };
+ _icon = new Icon { Size = 16, VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 2, 0, 0) };
+ _title = new TextBlock { FontSize = 14, FontWeight = FontWeight.SemiBold, TextWrapping = TextWrapping.Wrap };
+ _text = new TextBlock { FontSize = 14, LineHeight = 20, TextWrapping = TextWrapping.Wrap };
+ _text.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+
+ var text = new StackPanel { Spacing = 6, Children = { _title, _text, _body } };
+ Grid.SetColumn(text, 1);
+ var row = new Grid
+ {
+ Margin = new Thickness(16, 12),
+ ColumnSpacing = 12,
+ ColumnDefinitions = new ColumnDefinitions("Auto,*"),
+ Children = { _icon, text }
+ };
+
+ Child = new Panel { Children = { _tint, row } };
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusLg);
+ this.Token(BorderBrushProperty, ShellToken.Border);
+ Update();
+ }
+
+ private void Update()
+ {
+ var (icon, accent) = Variant switch
+ {
+ CalloutVariant.Warning => (IconName.TriangleAlert, ShellToken.Warning),
+ CalloutVariant.Danger => (IconName.CircleAlert, ShellToken.Destructive),
+ CalloutVariant.Tip => (IconName.Lightbulb, ShellToken.Success),
+ CalloutVariant.Default => (IconName.Info, ShellToken.MutedForeground),
+ _ => (IconName.Info, ShellToken.Info)
+ };
+ var neutral = Variant == CalloutVariant.Default;
+
+ // bg-/8 for the tinted variants, bg-muted/50 for the neutral one.
+ _tint.Token(BackgroundProperty, neutral ? ShellToken.Muted : accent);
+ _tint.Opacity = neutral ? 0.5 : 0.08;
+
+ _icon.Kind = Icon == IconName.None ? icon : Icon;
+ _icon.Token = accent;
+ _title.Text = Title ?? string.Empty;
+ _title.IsVisible = !string.IsNullOrEmpty(Title);
+ _title.Token(TextBlock.ForegroundProperty, neutral ? ShellToken.Foreground : accent);
+ _text.Text = Text ?? string.Empty;
+ _text.IsVisible = !string.IsNullOrEmpty(Text);
+ _body.Content = Body;
+ _body.IsVisible = Body != null;
+ }
+}
+
+public enum CalloutVariant
+{
+ Info,
+ Warning,
+ Danger,
+ Tip,
+ Default
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Carousel.cs b/examples/Avalonia.Demo/Components/UI/Carousel.cs
new file mode 100644
index 0000000..87d6a81
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Carousel.cs
@@ -0,0 +1,406 @@
+using System;
+using Avalonia;
+using Avalonia.Animation.Easings;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+using Avalonia.Threading;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Carousel — one slide at a time in a rounded-lg frame; drag, use the round arrow buttons, the
+// Left / Right keys or click a dot to move. Each child control is a slide.
+//
+//
+//
+//
+public class Carousel : Border
+{
+ public static readonly StyledProperty PositionProperty =
+ AvaloniaProperty.Register(nameof(Position), defaultBindingMode: BindingMode.TwoWay);
+
+ // At the last slide, Next wraps to the first (and Previous the other way).
+ public static readonly StyledProperty LoopProperty =
+ AvaloniaProperty.Register(nameof(Loop), true);
+
+ public static readonly StyledProperty ShowArrowsProperty =
+ AvaloniaProperty.Register(nameof(ShowArrows), true);
+
+ public static readonly StyledProperty ShowDotsProperty =
+ AvaloniaProperty.Register(nameof(ShowDots), true);
+
+ public static readonly StyledProperty AutoPlayProperty =
+ AvaloniaProperty.Register(nameof(AutoPlay));
+
+ // Milliseconds between slides while AutoPlay is on.
+ public static readonly StyledProperty AutoPlayIntervalProperty =
+ AvaloniaProperty.Register(nameof(AutoPlayInterval), 3000);
+
+ // Width / height of the frame. 0 turns it off: set Height on the carousel instead.
+ public static readonly StyledProperty AspectRatioProperty =
+ AvaloniaProperty.Register(nameof(AspectRatio), 16d / 9d);
+
+ private const double DragThreshold = 6;
+
+ private readonly Panel _track;
+ private readonly Border _previous;
+ private readonly Border _next;
+ private readonly StackPanel _dots;
+ private readonly Easing _easing = new CubicEaseOut();
+ private DispatcherTimer? _timer;
+ // Where the track is, in slides: 1.5 is halfway between the second and third slide.
+ private double _offset;
+ private int _animation;
+ private Point? _pressedAt;
+ private bool _dragging;
+ private double _dragX;
+
+ static Carousel()
+ {
+ PositionProperty.Changed.AddClassHandler((c, _) => c.OnPositionChanged());
+ LoopProperty.Changed.AddClassHandler((c, _) => c.UpdateControls());
+ ShowArrowsProperty.Changed.AddClassHandler((c, _) => c.UpdateControls());
+ ShowDotsProperty.Changed.AddClassHandler((c, _) => c.UpdateControls());
+ AutoPlayProperty.Changed.AddClassHandler((c, _) => c.UpdateTimer());
+ AutoPlayIntervalProperty.Changed.AddClassHandler((c, _) => c.UpdateTimer());
+ AffectsMeasure(AspectRatioProperty);
+ }
+
+ public int Position
+ {
+ get => GetValue(PositionProperty);
+ set => SetValue(PositionProperty, value);
+ }
+
+ public bool Loop
+ {
+ get => GetValue(LoopProperty);
+ set => SetValue(LoopProperty, value);
+ }
+
+ public bool ShowArrows
+ {
+ get => GetValue(ShowArrowsProperty);
+ set => SetValue(ShowArrowsProperty, value);
+ }
+
+ public bool ShowDots
+ {
+ get => GetValue(ShowDotsProperty);
+ set => SetValue(ShowDotsProperty, value);
+ }
+
+ public bool AutoPlay
+ {
+ get => GetValue(AutoPlayProperty);
+ set => SetValue(AutoPlayProperty, value);
+ }
+
+ public int AutoPlayInterval
+ {
+ get => GetValue(AutoPlayIntervalProperty);
+ set => SetValue(AutoPlayIntervalProperty, value);
+ }
+
+ public double AspectRatio
+ {
+ get => GetValue(AspectRatioProperty);
+ set => SetValue(AspectRatioProperty, value);
+ }
+
+ [Content]
+ public Controls Slides => _track.Children;
+
+ public event EventHandler? PositionChanged;
+
+ public Carousel()
+ {
+ // Transparent, not null, so a drag can start between the slides' own content.
+ _track = new Panel { ClipToBounds = true, Background = Brushes.Transparent };
+ _track.Children.CollectionChanged += (_, _) => OnSlidesChanged();
+ _track.SizeChanged += (_, _) => Render();
+ _track.PointerPressed += OnTrackPressed;
+ _track.PointerMoved += OnTrackMoved;
+ _track.PointerReleased += OnTrackReleased;
+ _track.PointerCaptureLost += (_, _) => EndDrag(cancelled: true);
+
+ _previous = Arrow(IconName.ChevronLeft, HorizontalAlignment.Left, "Previous slide", Previous);
+ _next = Arrow(IconName.ChevronRight, HorizontalAlignment.Right, "Next slide", Next);
+ foreach (var arrow in new[] { _previous, _next })
+ {
+ arrow.PointerEntered += (_, _) => UpdateControls();
+ arrow.PointerExited += (_, _) => UpdateControls();
+ }
+ _dots = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Bottom,
+ Margin = new Thickness(0, 0, 0, 8)
+ };
+
+ Child = new Panel { Children = { _track, _previous, _next, _dots } };
+ CornerRadius = new CornerRadius(ShellTheme.RadiusLg);
+ // Slides stay inside the rounded corners.
+ ClipToBounds = true;
+ UpdateControls();
+ }
+
+ private int Count => _track.Children.Count;
+ private int Current => Math.Clamp(Position, 0, Math.Max(0, Count - 1));
+
+ public void Next()
+ {
+ if (Current < Count - 1) Position = Current + 1;
+ else if (Loop && Count > 1) Position = 0;
+ }
+
+ public void Previous()
+ {
+ if (Current > 0) Position = Current - 1;
+ else if (Loop && Count > 1) Position = Count - 1;
+ }
+
+ // The frame takes the width it is given and derives its height from AspectRatio.
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ if (AspectRatio <= 0) return base.MeasureOverride(availableSize);
+ var width = double.IsInfinity(availableSize.Width) ? base.MeasureOverride(availableSize).Width : availableSize.Width;
+ var size = new Size(width, width / AspectRatio);
+ base.MeasureOverride(size);
+ return size;
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ UpdateTimer();
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ StopTimer();
+ _animation++;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Handled || e.Source is TextBox) return;
+ if (e.Key == Key.Left) Previous();
+ else if (e.Key == Key.Right) Next();
+ else return;
+ e.Handled = true;
+ }
+
+ // Round outline button over the slides; a tab stop that activates with Enter or Space.
+ private static Border Arrow(IconName icon, HorizontalAlignment side, string name, Action press)
+ {
+ var shadow = new BoxShadows(new BoxShadow { OffsetY = 2, Blur = 6, Color = ShellTheme.Shadow(0.15) });
+ var button = new Border
+ {
+ Child = new Icon { Kind = icon, Size = 16, Token = ShellToken.Foreground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center },
+ Width = 32,
+ Height = 32,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(16),
+ HorizontalAlignment = side,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(12, 0),
+ BoxShadow = shadow,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ button.Token(BackgroundProperty, ShellToken.Background).Token(BorderBrushProperty, ShellToken.Border);
+ AutomationProperties.SetName(button, name);
+ ShellFocus.Ring(button, button);
+ // The focus ring is painted as a shadow and cleared on blur: put the drop shadow back.
+ button.LostFocus += (_, _) => button.BoxShadow = shadow;
+ button.PointerPressed += (_, e) => e.Handled = true;
+ button.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ press();
+ e.Handled = true;
+ };
+ button.KeyDown += (_, e) =>
+ {
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ press();
+ e.Handled = true;
+ };
+ return button;
+ }
+
+ private void OnSlidesChanged()
+ {
+ _dots.Children.Clear();
+ for (var i = 0; i < Count; i++)
+ {
+ var index = i;
+ var dot = new Border
+ {
+ Width = 8,
+ Height = 8,
+ CornerRadius = new CornerRadius(4),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ dot.Token(BackgroundProperty, ShellToken.Primary);
+ // 8px dot in a 16px click target.
+ var target = new Border { Width = 16, Height = 16, Background = Brushes.Transparent, Child = dot, Cursor = new Cursor(StandardCursorType.Hand) };
+ AutomationProperties.SetName(target, $"Go to slide {i + 1}");
+ target.PointerPressed += (_, e) => e.Handled = true;
+ target.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Position = index;
+ e.Handled = true;
+ };
+ _dots.Children.Add(target);
+ }
+ _animation++;
+ _offset = Current;
+ Render();
+ UpdateControls();
+ UpdateTimer();
+ }
+
+ private void OnPositionChanged()
+ {
+ UpdateControls();
+ AnimateTo(Current, 300);
+ PositionChanged?.Invoke(this, Current);
+ // A manual move restarts the auto-play countdown.
+ UpdateTimer();
+ }
+
+ // Moves the track to a slide over the next frames; a newer move or a drag takes over.
+ private void AnimateTo(double target, int milliseconds)
+ {
+ var run = ++_animation;
+ var from = _offset;
+ if (TopLevel.GetTopLevel(this) is not { } top || from == target)
+ {
+ _offset = target;
+ Render();
+ return;
+ }
+
+ TimeSpan? started = null;
+ void Frame(TimeSpan now)
+ {
+ if (run != _animation) return;
+ started ??= now;
+ var progress = Math.Min(1, (now - started.Value).TotalMilliseconds / milliseconds);
+ _offset = from + (target - from) * _easing.Ease(progress);
+ Render();
+ if (progress < 1) top.RequestAnimationFrame(Frame);
+ }
+ top.RequestAnimationFrame(Frame);
+ }
+
+ // Slides are stacked in one cell and spread out sideways by translation.
+ private void Render()
+ {
+ var width = _track.Bounds.Width;
+ if (width <= 0) return;
+ for (var i = 0; i < Count; i++)
+ {
+ var x = (i - _offset) * width;
+ if (_track.Children[i].RenderTransform is TranslateTransform shift) shift.X = x;
+ else _track.Children[i].RenderTransform = new TranslateTransform(x, 0);
+ }
+ }
+
+ private void UpdateControls()
+ {
+ var current = Current;
+ var many = Count > 1;
+ var canGoBack = Loop || current > 0;
+ var canGoOn = Loop || current < Count - 1;
+ _previous.IsVisible = ShowArrows && many;
+ _next.IsVisible = ShowArrows && many;
+ _previous.Opacity = !canGoBack ? 0.4 : _previous.IsPointerOver ? 1 : 0.9;
+ _next.Opacity = !canGoOn ? 0.4 : _next.IsPointerOver ? 1 : 0.9;
+ _previous.Focusable = canGoBack;
+ _next.Focusable = canGoOn;
+ _dots.IsVisible = ShowDots && many;
+ for (var i = 0; i < _dots.Children.Count; i++)
+ {
+ // One color at two strengths, so the dots stay readable over any slide.
+ if (_dots.Children[i] is Border { Child: { } dot })
+ dot.Opacity = i == current ? 1 : 0.3;
+ }
+ }
+
+ private void OnTrackPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (Count < 2 || !e.GetCurrentPoint(_track).Properties.IsLeftButtonPressed) return;
+ _pressedAt = e.GetPosition(_track);
+ _dragging = false;
+ _dragX = 0;
+ }
+
+ private void OnTrackMoved(object? sender, PointerEventArgs e)
+ {
+ var width = _track.Bounds.Width;
+ if (_pressedAt is not { } start || width <= 0) return;
+ _dragX = e.GetPosition(_track).X - start.X;
+ if (!_dragging)
+ {
+ // A small move is still a click on the slide's own content.
+ if (Math.Abs(_dragX) < DragThreshold) return;
+ _dragging = true;
+ _animation++;
+ e.Pointer.Capture(_track);
+ }
+ // Follow the pointer, with a little give past the first and last slide.
+ _offset = Math.Clamp(Current - _dragX / width, -0.25, Count - 0.75);
+ Render();
+ e.Handled = true;
+ }
+
+ private void OnTrackReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (_dragging) e.Handled = true;
+ EndDrag(cancelled: false);
+ e.Pointer.Capture(null);
+ }
+
+ private void EndDrag(bool cancelled)
+ {
+ var width = _track.Bounds.Width;
+ var wasDragging = _dragging;
+ var moved = width > 0 ? _dragX / width : 0;
+ _pressedAt = null;
+ _dragging = false;
+ _dragX = 0;
+ if (!wasDragging) return;
+
+ var current = Current;
+ if (!cancelled && moved <= -0.2 && current < Count - 1) Position = current + 1;
+ else if (!cancelled && moved >= 0.2 && current > 0) Position = current - 1;
+ // Not far enough: settle back.
+ else AnimateTo(current, 200);
+ }
+
+ private void UpdateTimer()
+ {
+ StopTimer();
+ if (!AutoPlay || Count < 2 || TopLevel.GetTopLevel(this) is null) return;
+ _timer = new DispatcherTimer(TimeSpan.FromMilliseconds(Math.Max(500, AutoPlayInterval)), DispatcherPriority.Normal,
+ (_, _) => Position = Current < Count - 1 ? Current + 1 : 0);
+ _timer.Start();
+ }
+
+ private void StopTimer()
+ {
+ _timer?.Stop();
+ _timer = null;
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/ContextMenu.cs b/examples/Avalonia.Demo/Components/UI/ContextMenu.cs
new file mode 100644
index 0000000..4218144
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/ContextMenu.cs
@@ -0,0 +1,238 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Context menu — right-click the trigger (long-press on touch, or the Menu key / Shift+F10) to
+// open a menu at the pointer. Clicking outside, picking an item or pressing Escape closes it.
+//
+// ...any control...
+//
+//
+//
+//
+//
+//
+public class ContextMenu : ShellPopoverHost
+{
+ private bool _atPoint;
+
+ public ContextMenu()
+ {
+ // The trigger usually is a surface (a card, a row): let it fill its container.
+ HorizontalAlignment = HorizontalAlignment.Stretch;
+ // Clicks on the trigger reach it while the menu is open, so a second right-click moves the menu.
+ Popup.OverlayInputPassThroughElement = this;
+ }
+
+ protected override bool IsContent(Control child) => child is ContextMenuContent;
+ protected override double Offset => _atPoint ? 0 : 4;
+
+ // Opens the menu at a point relative to this control, or below the trigger when null.
+ public void OpenAt(Point? point)
+ {
+ _atPoint = point.HasValue;
+ Popup.PlacementRect = point is { } at ? new Rect(at, new Size(1, 1)) : null;
+ if (IsOpen) Popup.VerticalOffset = Offset;
+ else IsOpen = true;
+ }
+}
+
+// The surface that opens the enclosing ContextMenu.
+public class ContextMenuTrigger : Border
+{
+ public ContextMenuTrigger()
+ {
+ // Transparent, not null, so empty areas of the surface take the right-click too.
+ Background = Brushes.Transparent;
+ AddHandler(ContextRequestedEvent, OnContextRequested);
+ }
+
+ private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
+ {
+ if (this.FindParentOfType() is not { } menu) return;
+ menu.OpenAt(e.TryGetPosition(menu, out var point) ? point : null);
+ e.Handled = true;
+ }
+
+ // A long-press on touch opens the menu under the trigger.
+ protected override void OnHolding(HoldingRoutedEventArgs e)
+ {
+ base.OnHolding(e);
+ if (e.HoldingState != HoldingState.Started || this.FindParentOfType() is not { IsOpen: false } menu) return;
+ menu.OpenAt(null);
+ e.Handled = true;
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ this.FindParentOfType()?.Close();
+ }
+}
+
+// Menu panel — min-w-[13rem] rounded-md border bg-popover p-1 shadow-md. XAML children go to Items.
+public class ContextMenuContent : Border
+{
+ private readonly StackPanel _stack = new();
+
+ [Content]
+ public Controls Items => _stack.Children;
+
+ public ContextMenuContent()
+ {
+ Child = _stack;
+ Padding = new Thickness(4);
+ MinWidth = 208;
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ BoxShadow = ShellPopups.PanelShadow();
+ this.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+ }
+}
+
+// Menu row — rounded-sm px-2 py-1.5 text-sm, hover:bg-accent, optional icon and shortcut hint.
+public class ContextMenuItem : Border
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ // Keyboard hint shown at the end of the row (display only).
+ public static readonly StyledProperty ShortcutProperty =
+ AvaloniaProperty.Register(nameof(Shortcut));
+
+ public static readonly StyledProperty IsDestructiveProperty =
+ AvaloniaProperty.Register(nameof(IsDestructive));
+
+ private readonly Icon _icon;
+ private readonly TextBlock _label;
+ private readonly TextBlock _shortcut;
+
+ static ContextMenuItem()
+ {
+ TextProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IconProperty.Changed.AddClassHandler((i, _) => i.Update());
+ ShortcutProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IsDestructiveProperty.Changed.AddClassHandler((i, _) => i.Update());
+ 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 string? Shortcut
+ {
+ get => GetValue(ShortcutProperty);
+ set => SetValue(ShortcutProperty, value);
+ }
+
+ public bool IsDestructive
+ {
+ get => GetValue(IsDestructiveProperty);
+ set => SetValue(IsDestructiveProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ public ContextMenuItem()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, VerticalAlignment = VerticalAlignment.Center };
+ _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center };
+ _shortcut = new TextBlock { FontSize = 12, VerticalAlignment = VerticalAlignment.Center, IsVisible = false };
+ _shortcut.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+
+ var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), ColumnSpacing = 8 };
+ Grid.SetColumn(_label, 1);
+ Grid.SetColumn(_shortcut, 2);
+ grid.Children.Add(_icon);
+ grid.Children.Add(_label);
+ grid.Children.Add(_shortcut);
+
+ Child = grid;
+ Height = 32;
+ Padding = new Thickness(8, 0);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm);
+ Background = Brushes.Transparent;
+ Cursor = new Cursor(StandardCursorType.Hand);
+ ShellFocus.Ring(this, this);
+ Update();
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ if (IsEffectivelyEnabled) 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 Update()
+ {
+ var color = IsDestructive ? ShellToken.Destructive : ShellToken.PopoverForeground;
+ _icon.Kind = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _icon.Token = color;
+ _label.Text = Text ?? string.Empty;
+ _label.Token(TextBlock.ForegroundProperty, color);
+ _shortcut.Text = Shortcut ?? string.Empty;
+ _shortcut.IsVisible = !string.IsNullOrEmpty(Shortcut);
+ }
+}
+
+// Divider between groups of items — -mx-1 my-1 h-px bg-border.
+public class ContextMenuSeparator : Border
+{
+ public ContextMenuSeparator()
+ {
+ Height = 1;
+ Margin = new Thickness(-4, 4);
+ this.Token(BackgroundProperty, ShellToken.Border);
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/EmptyState.cs b/examples/Avalonia.Demo/Components/UI/EmptyState.cs
new file mode 100644
index 0000000..235327b
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/EmptyState.cs
@@ -0,0 +1,123 @@
+using Avalonia;
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Placeholder for an empty list or screen: icon in a muted tile, title, description and
+// optional actions, centered. Bordered="True" adds shadcn's dashed outline.
+//
+//
+//
+public class EmptyState : Border
+{
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon), IconName.Inbox);
+
+ public static readonly StyledProperty TitleProperty =
+ AvaloniaProperty.Register(nameof(Title));
+
+ public static readonly StyledProperty DescriptionProperty =
+ AvaloniaProperty.Register(nameof(Description));
+
+ public static readonly StyledProperty BorderedProperty =
+ AvaloniaProperty.Register(nameof(Bordered));
+
+ private readonly Rectangle _outline;
+ private readonly Icon _icon;
+ private readonly TextBlock _title;
+ private readonly TextBlock _description;
+ private readonly StackPanel _actions;
+
+ static EmptyState()
+ {
+ IconProperty.Changed.AddClassHandler((s, _) => s.Update());
+ TitleProperty.Changed.AddClassHandler((s, _) => s.Update());
+ DescriptionProperty.Changed.AddClassHandler((s, _) => s.Update());
+ BorderedProperty.Changed.AddClassHandler((s, _) => s.Update());
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public string? Title
+ {
+ get => GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string? Description
+ {
+ get => GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public bool Bordered
+ {
+ get => GetValue(BorderedProperty);
+ set => SetValue(BorderedProperty, value);
+ }
+
+ // The action buttons, in a centered row.
+ [Content]
+ public Controls Actions => _actions.Children;
+
+ public EmptyState()
+ {
+ _icon = new Icon { Size = 24, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ var badge = new Border
+ {
+ Child = _icon,
+ Width = 48,
+ Height = 48,
+ CornerRadius = new CornerRadius(ShellTheme.RadiusLg),
+ HorizontalAlignment = HorizontalAlignment.Center
+ };
+ badge.Token(BackgroundProperty, ShellToken.Muted);
+
+ _title = new TextBlock { FontSize = 18, FontWeight = FontWeight.SemiBold, TextAlignment = TextAlignment.Center, TextWrapping = TextWrapping.Wrap };
+ _title.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ _description = new TextBlock
+ {
+ FontSize = 14,
+ TextAlignment = TextAlignment.Center,
+ TextWrapping = TextWrapping.Wrap,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ MaxWidth = 360
+ };
+ _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+
+ _actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 8, 0, 0) };
+
+ // Border can't dash its stroke, so the outline is a shape behind the content.
+ _outline = new Rectangle
+ {
+ StrokeThickness = 1,
+ StrokeDashArray = new AvaloniaList { 4, 4 },
+ RadiusX = ShellTheme.RadiusLg,
+ RadiusY = ShellTheme.RadiusLg
+ };
+ _outline.Token(Shape.StrokeProperty, ShellToken.Border);
+
+ var content = new StackPanel { Spacing = 8, Margin = new Thickness(24, 32), Children = { badge, _title, _description, _actions } };
+ Child = new Panel { Children = { _outline, content } };
+ Update();
+ }
+
+ private void Update()
+ {
+ _icon.Kind = Icon;
+ _title.Text = Title ?? string.Empty;
+ _title.IsVisible = !string.IsNullOrEmpty(Title);
+ _description.Text = Description ?? string.Empty;
+ _description.IsVisible = !string.IsNullOrEmpty(Description);
+ _outline.IsVisible = Bordered;
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Pagination.cs b/examples/Avalonia.Demo/Components/UI/Pagination.cs
new file mode 100644
index 0000000..b870ab2
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Pagination.cs
@@ -0,0 +1,194 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Page navigation — Previous / 1 … 4 [5] 6 … 20 / Next. Page buttons are 36px ghost buttons;
+// the current page is outlined. Collapses long ranges into ellipses around the current page.
+// Every item is a tab stop that activates with Enter or Space.
+// Usage:
+public class Pagination : Border
+{
+ public static readonly StyledProperty PageProperty =
+ AvaloniaProperty.Register(nameof(Page), 1, defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty TotalPagesProperty =
+ AvaloniaProperty.Register(nameof(TotalPages), 1);
+
+ // Pages shown on each side of the current page.
+ public static readonly StyledProperty SiblingCountProperty =
+ AvaloniaProperty.Register(nameof(SiblingCount), 1);
+
+ // "Previous" / "Next" text next to the chevrons.
+ public static readonly StyledProperty ShowLabelsProperty =
+ AvaloniaProperty.Register(nameof(ShowLabels), true);
+
+ private const double ItemSize = 36;
+
+ private readonly StackPanel _row = new() { Orientation = Orientation.Horizontal, Spacing = 4 };
+ private Control? _currentItem;
+
+ static Pagination()
+ {
+ PageProperty.Changed.AddClassHandler((p, _) =>
+ {
+ p.Rebuild();
+ p.PageChanged?.Invoke(p, p.Page);
+ });
+ TotalPagesProperty.Changed.AddClassHandler((p, _) => p.Rebuild());
+ SiblingCountProperty.Changed.AddClassHandler((p, _) => p.Rebuild());
+ ShowLabelsProperty.Changed.AddClassHandler((p, _) => p.Rebuild());
+ }
+
+ public int Page
+ {
+ get => GetValue(PageProperty);
+ set => SetValue(PageProperty, value);
+ }
+
+ public int TotalPages
+ {
+ get => GetValue(TotalPagesProperty);
+ set => SetValue(TotalPagesProperty, value);
+ }
+
+ public int SiblingCount
+ {
+ get => GetValue(SiblingCountProperty);
+ set => SetValue(SiblingCountProperty, value);
+ }
+
+ public bool ShowLabels
+ {
+ get => GetValue(ShowLabelsProperty);
+ set => SetValue(ShowLabelsProperty, value);
+ }
+
+ public event EventHandler? PageChanged;
+
+ public Pagination()
+ {
+ Child = _row;
+ HorizontalAlignment = HorizontalAlignment.Left;
+ Rebuild();
+ }
+
+ // Page is not clamped when set (XAML may set Page before TotalPages), only when shown.
+ private int Total => Math.Max(1, TotalPages);
+ private int Current => Math.Clamp(Page, 1, Total);
+
+ // First, last, and the current page with its siblings; 0 marks an ellipsis.
+ private List VisiblePages()
+ {
+ var total = Total;
+ var siblings = Math.Max(0, SiblingCount);
+ var pages = new List();
+ var from = Math.Max(2, Current - siblings);
+ var to = Math.Min(total - 1, Current + siblings);
+
+ pages.Add(1);
+ if (from > 2) pages.Add(from == 3 ? 2 : 0);
+ for (var p = from; p <= to; p++) pages.Add(p);
+ if (to < total - 1) pages.Add(to == total - 2 ? total - 1 : 0);
+ if (total > 1) pages.Add(total);
+ return pages;
+ }
+
+ private void Rebuild()
+ {
+ // The rebuild replaces the focused item, so keyboard focus moves to the new current page.
+ var hadFocus = _row.IsKeyboardFocusWithin;
+ _row.Children.Clear();
+ _row.Children.Add(NavItem(IconName.ChevronLeft, "Previous", iconFirst: true, enabled: Current > 1, () => Page = Current - 1));
+ foreach (var page in VisiblePages())
+ _row.Children.Add(page == 0 ? Ellipsis() : PageItem(page));
+ _row.Children.Add(NavItem(IconName.ChevronRight, "Next", iconFirst: false, enabled: Current < Total, () => Page = Current + 1));
+ if (hadFocus) _currentItem?.Focus(NavigationMethod.Tab);
+ }
+
+ private Control PageItem(int page)
+ {
+ var current = page == Current;
+ var label = new TextBlock { Text = page.ToString(CultureInfo.CurrentCulture), FontSize = 14, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ var item = Item(label, ItemSize, new Thickness(0), current ? null : () => Page = page, focusable: true);
+ AutomationProperties.SetName(item, current ? $"Page {page}, current page" : $"Go to page {page}");
+ if (current)
+ {
+ // Outline variant for the current page; no hover change.
+ item.BorderThickness = new Thickness(1);
+ item.Token(BorderBrushProperty, ShellToken.Input).Token(BackgroundProperty, ShellToken.Background);
+ _currentItem = item;
+ }
+ return item;
+ }
+
+ private Control NavItem(IconName icon, string text, bool iconFirst, bool enabled, Action go)
+ {
+ var chevron = new Icon { Kind = icon, Size = 16, VerticalAlignment = VerticalAlignment.Center };
+ var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ if (ShowLabels)
+ {
+ var label = new TextBlock { Text = text, FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center };
+ label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ if (iconFirst) { row.Children.Add(chevron); row.Children.Add(label); }
+ else { row.Children.Add(label); row.Children.Add(chevron); }
+ }
+ else row.Children.Add(chevron);
+
+ var item = Item(row, double.NaN, new Thickness(ShowLabels ? 10 : 0, 0), enabled ? go : null, focusable: enabled);
+ AutomationProperties.SetName(item, $"Go to {text.ToLowerInvariant()} page");
+ if (!enabled) item.Opacity = 0.5;
+ return item;
+ }
+
+ private static Control Ellipsis() => new Border
+ {
+ Width = ItemSize,
+ Height = ItemSize,
+ Child = new Icon { Kind = IconName.Ellipsis, Size = 16, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }
+ };
+
+ // Ghost button: transparent, hover:bg-accent. With no action it doesn't react (the current
+ // page, or Previous / Next at the ends); the current page is still a tab stop.
+ private static Border Item(Control content, double width, Thickness padding, Action? onPress, bool focusable)
+ {
+ var item = new Border
+ {
+ Child = content,
+ Width = width,
+ MinWidth = ItemSize,
+ Height = ItemSize,
+ Padding = padding,
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ Background = Brushes.Transparent
+ };
+ if (focusable) ShellFocus.Ring(item, item);
+ if (onPress is null) return item;
+
+ item.Cursor = new Cursor(StandardCursorType.Hand);
+ item.PointerEntered += (_, _) => item.Token(BackgroundProperty, ShellToken.Accent);
+ item.PointerExited += (_, _) => { item.ClearToken(BackgroundProperty); item.Background = Brushes.Transparent; };
+ item.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ onPress();
+ e.Handled = true;
+ };
+ item.KeyDown += (_, e) =>
+ {
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ onPress();
+ e.Handled = true;
+ };
+ return item;
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Stepper.cs b/examples/Avalonia.Demo/Components/UI/Stepper.cs
new file mode 100644
index 0000000..f30fd31
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Stepper.cs
@@ -0,0 +1,321 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+using AvaloniaDemo.Components.UI.Variants;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Step-by-step flow — numbered circles joined by lines (active filled, completed checked), the
+// active step's content, and Previous / Next / Confirm buttons. A step becomes clickable (and a
+// tab stop) once it has been reached.
+//
+// ...any control...
+// ...
+// ...
+//
+public class Stepper : Border
+{
+ public static readonly StyledProperty CurrentStepProperty =
+ AvaloniaProperty.Register(nameof(CurrentStep), defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty ShowNavigationProperty =
+ AvaloniaProperty.Register(nameof(ShowNavigation), true);
+
+ public static readonly StyledProperty PreviousTextProperty =
+ AvaloniaProperty.Register(nameof(PreviousText), "Previous");
+
+ public static readonly StyledProperty NextTextProperty =
+ AvaloniaProperty.Register(nameof(NextText), "Next");
+
+ public static readonly StyledProperty ConfirmTextProperty =
+ AvaloniaProperty.Register(nameof(ConfirmText), "Confirm");
+
+ private const double CircleSize = 32;
+
+ private sealed class Marker
+ {
+ public Marker(Border circle, TextBlock number, Icon check, TextBlock title, Border before, Border after)
+ {
+ Circle = circle;
+ Number = number;
+ Check = check;
+ Title = title;
+ Before = before;
+ After = after;
+ }
+
+ public Border Circle { get; }
+ public TextBlock Number { get; }
+ public Icon Check { get; }
+ public TextBlock Title { get; }
+ public Border Before { get; }
+ public Border After { get; }
+ }
+
+ private readonly List _markers = new();
+ private readonly Grid _header = new();
+ private readonly Panel _body = new() { Margin = new Thickness(0, 24, 0, 0) };
+ private readonly Panel _navigation;
+ private readonly Button _previous;
+ private readonly Button _next;
+ private int _highest;
+
+ static Stepper()
+ {
+ CurrentStepProperty.Changed.AddClassHandler((s, _) => s.OnCurrentStepChanged());
+ ShowNavigationProperty.Changed.AddClassHandler((s, _) => s.UpdateState());
+ PreviousTextProperty.Changed.AddClassHandler((s, _) => s.UpdateState());
+ NextTextProperty.Changed.AddClassHandler((s, _) => s.UpdateState());
+ ConfirmTextProperty.Changed.AddClassHandler((s, _) => s.UpdateState());
+ }
+
+ public int CurrentStep
+ {
+ get => GetValue(CurrentStepProperty);
+ set => SetValue(CurrentStepProperty, value);
+ }
+
+ public bool ShowNavigation
+ {
+ get => GetValue(ShowNavigationProperty);
+ set => SetValue(ShowNavigationProperty, value);
+ }
+
+ public string PreviousText
+ {
+ get => GetValue(PreviousTextProperty);
+ set => SetValue(PreviousTextProperty, value);
+ }
+
+ public string NextText
+ {
+ get => GetValue(NextTextProperty);
+ set => SetValue(NextTextProperty, value);
+ }
+
+ public string ConfirmText
+ {
+ get => GetValue(ConfirmTextProperty);
+ set => SetValue(ConfirmTextProperty, value);
+ }
+
+ // The StepperStep children. Every step stays in the tree; only the active one is visible.
+ [Content]
+ public Controls Steps => _body.Children;
+
+ public event EventHandler? CurrentStepChanged;
+ // Raised by the Confirm button on the last step.
+ public event EventHandler? Confirmed;
+
+ public Stepper()
+ {
+ _previous = new Button { Variant = ButtonVariant.Outline, HorizontalAlignment = HorizontalAlignment.Left };
+ _previous.Clicked += (_, _) => Previous();
+ _next = new Button { HorizontalAlignment = HorizontalAlignment.Right };
+ _next.Clicked += (_, _) =>
+ {
+ if (IsLast)
+ {
+ _highest = Math.Max(_highest, StepList.Count - 1);
+ Confirmed?.Invoke(this, EventArgs.Empty);
+ }
+ else Next();
+ };
+ _navigation = new Panel { Margin = new Thickness(0, 24, 0, 0), Children = { _previous, _next } };
+
+ Child = new StackPanel { Children = { _header, _body, _navigation } };
+ _body.Children.CollectionChanged += (_, _) => Rebuild();
+ Rebuild();
+ }
+
+ private List StepList => _body.Children.OfType().ToList();
+ private int Current => Math.Clamp(CurrentStep, 0, Math.Max(0, StepList.Count - 1));
+ private bool IsLast => Current >= StepList.Count - 1;
+
+ public void Next()
+ {
+ if (!IsLast) CurrentStep = Current + 1;
+ }
+
+ public void Previous()
+ {
+ if (Current > 0) CurrentStep = Current - 1;
+ }
+
+ private void OnCurrentStepChanged()
+ {
+ _highest = Math.Max(_highest, Current);
+ UpdateState();
+ CurrentStepChanged?.Invoke(this, Current);
+ }
+
+ private void Rebuild()
+ {
+ var steps = StepList;
+ _header.Children.Clear();
+ _header.ColumnDefinitions.Clear();
+ _markers.Clear();
+
+ for (var i = 0; i < steps.Count; i++)
+ {
+ var index = i;
+ var step = steps[i];
+ step.PropertyChanged -= OnStepPropertyChanged;
+ step.PropertyChanged += OnStepPropertyChanged;
+
+ var number = new TextBlock
+ {
+ Text = (i + 1).ToString(CultureInfo.CurrentCulture),
+ FontSize = 14,
+ FontWeight = FontWeight.Bold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ var check = new Icon { Kind = IconName.Check, Size = 16, Token = ShellToken.Primary, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ var circle = new Border
+ {
+ Child = new Panel { Children = { number, check } },
+ Width = CircleSize,
+ Height = CircleSize,
+ BorderThickness = new Thickness(2),
+ CornerRadius = new CornerRadius(CircleSize / 2)
+ };
+ AutomationProperties.SetName(circle, string.IsNullOrEmpty(step.Title) ? $"Step {i + 1}" : step.Title);
+ ShellFocus.Ring(circle, circle);
+ circle.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left || index > _highest) return;
+ CurrentStep = index;
+ e.Handled = true;
+ };
+ circle.KeyDown += (_, e) =>
+ {
+ if (e.Key is not (Key.Enter or Key.Space) || index > _highest) return;
+ CurrentStep = index;
+ e.Handled = true;
+ };
+
+ // Lines run from each circle to the edges of its column, so neighbors meet.
+ var before = Line(i > 0);
+ var after = Line(i < steps.Count - 1);
+ var rail = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto,*") };
+ Grid.SetColumn(circle, 1);
+ Grid.SetColumn(after, 2);
+ rail.Children.Add(before);
+ rail.Children.Add(circle);
+ rail.Children.Add(after);
+
+ var title = new TextBlock
+ {
+ Text = step.Title,
+ FontSize = 14,
+ FontWeight = FontWeight.Bold,
+ TextAlignment = TextAlignment.Center,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(4, 8, 4, 0),
+ IsVisible = !string.IsNullOrEmpty(step.Title)
+ };
+ var description = new TextBlock
+ {
+ Text = step.Description,
+ FontSize = 12,
+ TextAlignment = TextAlignment.Center,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(4, 2, 4, 0),
+ IsVisible = !string.IsNullOrEmpty(step.Description)
+ };
+ description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+
+ var column = new StackPanel { Children = { rail, title, description } };
+ Grid.SetColumn(column, i);
+ _header.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+ _header.Children.Add(column);
+ _markers.Add(new Marker(circle, number, check, title, before, after));
+ }
+
+ _highest = Math.Min(_highest, Math.Max(0, steps.Count - 1));
+ UpdateState();
+
+ static Border Line(bool visible) => new()
+ {
+ Height = 2,
+ VerticalAlignment = VerticalAlignment.Center,
+ Opacity = visible ? 1 : 0
+ };
+ }
+
+ private void OnStepPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
+ {
+ if (e.Property == StepperStep.TitleProperty || e.Property == StepperStep.DescriptionProperty) Rebuild();
+ }
+
+ private void UpdateState()
+ {
+ var steps = StepList;
+ var current = Current;
+ for (var i = 0; i < _markers.Count; i++)
+ {
+ var marker = _markers[i];
+ var active = i == current;
+ var completed = i < _highest && !active;
+ var reached = i <= _highest;
+
+ if (active) marker.Circle.Token(BackgroundProperty, ShellToken.Primary);
+ else
+ {
+ // Transparent, not null, so the whole circle stays hit-testable.
+ marker.Circle.ClearToken(BackgroundProperty);
+ marker.Circle.Background = Brushes.Transparent;
+ }
+ marker.Circle.Token(BorderBrushProperty, active || completed ? ShellToken.Primary : ShellToken.Border);
+ marker.Circle.Focusable = reached;
+ marker.Circle.Cursor = reached ? new Cursor(StandardCursorType.Hand) : null;
+ marker.Number.IsVisible = !completed;
+ marker.Number.Token(TextBlock.ForegroundProperty, active ? ShellToken.PrimaryForeground : ShellToken.MutedForeground);
+ marker.Check.IsVisible = completed;
+ marker.Title.Token(TextBlock.ForegroundProperty, active || completed ? ShellToken.Foreground : ShellToken.MutedForeground);
+ marker.Before.Token(BackgroundProperty, reached ? ShellToken.Primary : ShellToken.Border);
+ marker.After.Token(BackgroundProperty, i < _highest ? ShellToken.Primary : ShellToken.Border);
+ }
+
+ for (var i = 0; i < steps.Count; i++)
+ steps[i].IsVisible = i == current;
+
+ _navigation.IsVisible = ShowNavigation && steps.Count > 0;
+ _previous.Text = PreviousText;
+ _previous.IsEnabled = current > 0;
+ _next.Text = IsLast ? ConfirmText : NextText;
+ }
+}
+
+// One step of a Stepper: its Title and Description label the step, its content shows while the
+// step is active.
+public class StepperStep : Border
+{
+ public static readonly StyledProperty TitleProperty =
+ AvaloniaProperty.Register(nameof(Title));
+
+ public static readonly StyledProperty DescriptionProperty =
+ AvaloniaProperty.Register(nameof(Description));
+
+ public string? Title
+ {
+ get => GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string? Description
+ {
+ get => GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Table.cs b/examples/Avalonia.Demo/Components/UI/Table.cs
new file mode 100644
index 0000000..bd7b0a7
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Table.cs
@@ -0,0 +1,338 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Data table — text-sm rows inside a rounded-md border: a header row in the muted foreground,
+// rows divided by border-b that tint on hover, optional caption.
+//
+//
+// ...
+//
+//
+// ...
+//
+//
+// Columns takes Grid column widths. Use star and fixed widths: every row is its own grid, so an
+// Auto column would size per row. For long lists, use TableRow (with its own Columns) as the item
+// template of an ItemsControl under a TableHeader.
+public class Table : Border
+{
+ public static readonly StyledProperty ColumnsProperty =
+ AvaloniaProperty.Register(nameof(Columns));
+
+ public static readonly StyledProperty CaptionProperty =
+ AvaloniaProperty.Register(nameof(Caption));
+
+ public static readonly StyledProperty BorderedProperty =
+ AvaloniaProperty.Register(nameof(Bordered), true);
+
+ // Narrower than this, the table scrolls sideways instead of squeezing its columns.
+ public static readonly StyledProperty MinimumContentWidthProperty =
+ AvaloniaProperty.Register(nameof(MinimumContentWidth));
+
+ private readonly StackPanel _rows = new();
+ private readonly ScrollViewer _scroll;
+ private readonly Border _frame;
+ private readonly TextBlock _caption;
+
+ static Table()
+ {
+ ColumnsProperty.Changed.AddClassHandler((t, _) => t.Refresh());
+ CaptionProperty.Changed.AddClassHandler((t, _) => t.UpdateChrome());
+ BorderedProperty.Changed.AddClassHandler((t, _) => t.UpdateChrome());
+ MinimumContentWidthProperty.Changed.AddClassHandler((t, _) => t.UpdateScrolling());
+ }
+
+ public string? Columns
+ {
+ get => GetValue(ColumnsProperty);
+ set => SetValue(ColumnsProperty, value);
+ }
+
+ public string? Caption
+ {
+ get => GetValue(CaptionProperty);
+ set => SetValue(CaptionProperty, value);
+ }
+
+ public bool Bordered
+ {
+ get => GetValue(BorderedProperty);
+ set => SetValue(BorderedProperty, value);
+ }
+
+ public double MinimumContentWidth
+ {
+ get => GetValue(MinimumContentWidthProperty);
+ set => SetValue(MinimumContentWidthProperty, value);
+ }
+
+ [Content]
+ public Controls Rows => _rows.Children;
+
+ public Table()
+ {
+ _rows.Children.CollectionChanged += (_, _) => Refresh();
+ _scroll = new ScrollViewer
+ {
+ Content = _rows,
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Disabled
+ };
+ _scroll.SizeChanged += (_, _) => FitScrollContent();
+
+ _frame = new Border
+ {
+ Child = _scroll,
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ // Row tints stay inside the rounded corners.
+ ClipToBounds = true
+ };
+ _frame.Token(BorderBrushProperty, ShellToken.Border);
+
+ _caption = new TextBlock { FontSize = 14, TextAlignment = TextAlignment.Center, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 12, 0, 0) };
+ _caption.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+
+ Child = new StackPanel { Children = { _frame, _caption } };
+ UpdateChrome();
+ }
+
+ // Parses "2*,*,100" into column widths. Empty means equal star columns.
+ internal static IReadOnlyList ParseColumns(string? columns)
+ {
+ if (string.IsNullOrWhiteSpace(columns)) return Array.Empty();
+ return columns.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(ParseWidth)
+ .ToList();
+
+ static GridLength ParseWidth(string part)
+ {
+ if (part.Equals("auto", StringComparison.OrdinalIgnoreCase)) return GridLength.Auto;
+ if (part.EndsWith('*'))
+ {
+ var factor = part[..^1];
+ return new GridLength(factor.Length > 0 && double.TryParse(factor, NumberStyles.Float, CultureInfo.InvariantCulture, out var stars) ? stars : 1, GridUnitType.Star);
+ }
+ return double.TryParse(part, NumberStyles.Float, CultureInfo.InvariantCulture, out var width) ? new GridLength(width) : GridLength.Star;
+ }
+ }
+
+ // The table hands rows their columns and tells the last one to drop its divider.
+ private void Refresh()
+ {
+ var widths = ParseColumns(Columns);
+ var rows = _rows.Children.OfType().ToList();
+ for (var i = 0; i < rows.Count; i++)
+ {
+ if (string.IsNullOrWhiteSpace(rows[i].Columns)) rows[i].Apply(widths);
+ rows[i].ShowDivider = i < rows.Count - 1;
+ }
+ }
+
+ private void UpdateChrome()
+ {
+ _frame.BorderThickness = new Thickness(Bordered ? 1 : 0);
+ _caption.Text = Caption ?? string.Empty;
+ _caption.IsVisible = !string.IsNullOrEmpty(Caption);
+ }
+
+ private void UpdateScrolling()
+ {
+ _scroll.HorizontalScrollBarVisibility = MinimumContentWidth > 0 ? ScrollBarVisibility.Auto : ScrollBarVisibility.Disabled;
+ FitScrollContent();
+ }
+
+ // A sideways ScrollViewer measures rows at their smallest width; give them a real one.
+ private void FitScrollContent()
+ {
+ _rows.Width = MinimumContentWidth > 0 && _scroll.Bounds.Width > 0
+ ? Math.Max(_scroll.Bounds.Width, MinimumContentWidth)
+ : double.NaN;
+ }
+}
+
+// Body row — border-b, hover:bg-muted/50, bg-muted when selected. Its children are the cells,
+// placed in columns in order. Handle clicks and taps with Avalonia's own Tapped event.
+public class TableRow : Grid
+{
+ // Set only for rows used outside a Table (e.g. an ItemsControl item template).
+ public static readonly StyledProperty ColumnsProperty =
+ AvaloniaProperty.Register(nameof(Columns));
+
+ public static readonly StyledProperty IsSelectedProperty =
+ AvaloniaProperty.Register(nameof(IsSelected));
+
+ private readonly Border _fill = new() { Opacity = 0, IsHitTestVisible = false, ZIndex = -1 };
+ private readonly Border _divider = new() { Height = 1, VerticalAlignment = VerticalAlignment.Bottom, IsHitTestVisible = false };
+ private IReadOnlyList _widths = Array.Empty();
+
+ static TableRow()
+ {
+ ColumnsProperty.Changed.AddClassHandler((r, _) => r.Apply(Table.ParseColumns(r.Columns)));
+ IsSelectedProperty.Changed.AddClassHandler((r, _) => r.UpdateFill());
+ }
+
+ public string? Columns
+ {
+ get => GetValue(ColumnsProperty);
+ set => SetValue(ColumnsProperty, value);
+ }
+
+ public bool IsSelected
+ {
+ get => GetValue(IsSelectedProperty);
+ set => SetValue(IsSelectedProperty, value);
+ }
+
+ internal bool ShowDivider
+ {
+ get => _divider.IsVisible;
+ set => _divider.IsVisible = value;
+ }
+
+ protected virtual bool Interactive => true;
+
+ public TableRow()
+ {
+ _fill.Token(Border.BackgroundProperty, ShellToken.Muted);
+ _divider.Token(Border.BackgroundProperty, ShellToken.Border);
+ Children.Add(_fill);
+ Children.Add(_divider);
+ // A panel with no background only takes pointer input over its children.
+ Background = Brushes.Transparent;
+ Children.CollectionChanged += (_, _) => Apply(_widths);
+ if (Interactive) Cursor = new Cursor(StandardCursorType.Hand);
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ UpdateFill();
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ UpdateFill();
+ }
+
+ internal void Apply(IReadOnlyList widths)
+ {
+ _widths = widths;
+ var cells = Children.Where(c => c != _fill && c != _divider).ToList();
+ var count = Math.Max(1, Math.Max(cells.Count, widths.Count));
+
+ ColumnDefinitions.Clear();
+ for (var i = 0; i < count; i++)
+ ColumnDefinitions.Add(new ColumnDefinition(i < widths.Count ? widths[i] : GridLength.Star));
+ for (var i = 0; i < cells.Count; i++)
+ SetColumn(cells[i], i);
+ SetColumnSpan(_fill, count);
+ SetColumnSpan(_divider, count);
+ }
+
+ private void UpdateFill() => _fill.Opacity = IsSelected ? 1 : Interactive && IsPointerOver ? 0.5 : 0;
+}
+
+// Header row: holds TableHead cells, no hover.
+public class TableHeader : TableRow
+{
+ protected override bool Interactive => false;
+}
+
+// Header cell — h-11 px-4 font-medium text-muted-foreground.
+public class TableHead : Border
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ public static readonly StyledProperty HorizontalTextAlignmentProperty =
+ AvaloniaProperty.Register(nameof(HorizontalTextAlignment), TextAlignment.Start);
+
+ private readonly TextBlock _label;
+
+ static TableHead()
+ {
+ TextProperty.Changed.AddClassHandler((h, _) => h._label.Text = h.Text ?? string.Empty);
+ HorizontalTextAlignmentProperty.Changed.AddClassHandler((h, _) => h._label.TextAlignment = h.HorizontalTextAlignment);
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public TextAlignment HorizontalTextAlignment
+ {
+ get => GetValue(HorizontalTextAlignmentProperty);
+ set => SetValue(HorizontalTextAlignmentProperty, value);
+ }
+
+ public TableHead()
+ {
+ _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ _label.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ Height = 44;
+ Padding = new Thickness(16, 0);
+ Child = _label;
+ }
+}
+
+// Body cell — p-4 text-sm. Set Text, or put any control inside.
+public class TableCell : Border
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ public static readonly StyledProperty HorizontalTextAlignmentProperty =
+ AvaloniaProperty.Register(nameof(HorizontalTextAlignment), TextAlignment.Start);
+
+ public static readonly StyledProperty IsBoldProperty =
+ AvaloniaProperty.Register(nameof(IsBold));
+
+ private readonly TextBlock _label;
+
+ static TableCell()
+ {
+ TextProperty.Changed.AddClassHandler((c, _) => c._label.Text = c.Text ?? string.Empty);
+ HorizontalTextAlignmentProperty.Changed.AddClassHandler((c, _) => c._label.TextAlignment = c.HorizontalTextAlignment);
+ IsBoldProperty.Changed.AddClassHandler((c, _) => c._label.FontWeight = c.IsBold ? FontWeight.Medium : FontWeight.Normal);
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public TextAlignment HorizontalTextAlignment
+ {
+ get => GetValue(HorizontalTextAlignmentProperty);
+ set => SetValue(HorizontalTextAlignmentProperty, value);
+ }
+
+ public bool IsBold
+ {
+ get => GetValue(IsBoldProperty);
+ set => SetValue(IsBoldProperty, value);
+ }
+
+ public TableCell()
+ {
+ _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ _label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ Padding = new Thickness(16, 12);
+ Child = _label;
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Toggle.cs b/examples/Avalonia.Demo/Components/UI/Toggle.cs
new file mode 100644
index 0000000..4f30b73
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Toggle.cs
@@ -0,0 +1,168 @@
+using System;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Two-state button — h-10 px-3 rounded-md text-sm font-medium; hover:bg-muted, and
+// bg-accent text-accent-foreground while pressed. Variant Outline adds border-input.
+// Usage:
+public class Toggle : Border, IShellFocusable
+{
+ public static readonly StyledProperty IsPressedProperty =
+ AvaloniaProperty.Register(nameof(IsPressed), defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon), IconName.None);
+
+ public static readonly StyledProperty VariantProperty =
+ AvaloniaProperty.Register(nameof(Variant));
+
+ public static readonly StyledProperty SizeProperty =
+ AvaloniaProperty.Register(nameof(Size), ToggleSize.Default);
+
+ private readonly Icon _icon;
+ private readonly TextBlock _label;
+
+ static Toggle()
+ {
+ IsPressedProperty.Changed.AddClassHandler((t, _) =>
+ {
+ t.UpdateVisualState();
+ t.PressedChanged?.Invoke(t, t.IsPressed);
+ });
+ TextProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState());
+ IconProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState());
+ VariantProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState());
+ SizeProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState());
+ IsEnabledProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState());
+ }
+
+ public bool IsPressed
+ {
+ get => GetValue(IsPressedProperty);
+ set => SetValue(IsPressedProperty, value);
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public ToggleVariant Variant
+ {
+ get => GetValue(VariantProperty);
+ set => SetValue(VariantProperty, value);
+ }
+
+ public ToggleSize Size
+ {
+ get => GetValue(SizeProperty);
+ set => SetValue(SizeProperty, value);
+ }
+
+ public event EventHandler? PressedChanged;
+
+ public Toggle()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, VerticalAlignment = VerticalAlignment.Center };
+ _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center };
+ Child = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 8,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Children = { _icon, _label }
+ };
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ HorizontalAlignment = HorizontalAlignment.Left;
+ Cursor = new Cursor(StandardCursorType.Hand);
+ ShellFocus.Ring(this, this);
+ UpdateVisualState();
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ UpdateVisualState();
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ UpdateVisualState();
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return;
+ IsPressed = !IsPressed;
+ e.Handled = true;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Key is not (Key.Enter or Key.Space) || !IsEffectivelyEnabled) return;
+ IsPressed = !IsPressed;
+ e.Handled = true;
+ }
+
+ private void UpdateVisualState()
+ {
+ var (height, padding) = Size switch
+ {
+ ToggleSize.Sm => (36.0, 10.0),
+ ToggleSize.Lg => (44.0, 20.0),
+ _ => (40.0, 12.0)
+ };
+ Height = height;
+ MinWidth = height;
+ Padding = new Thickness(padding, 0);
+ BorderThickness = new Thickness(Variant == ToggleVariant.Outline ? 1 : 0);
+ this.Token(BorderBrushProperty, ShellToken.Input);
+
+ _label.Text = Text ?? string.Empty;
+ _label.IsVisible = !string.IsNullOrEmpty(Text);
+ _icon.Kind = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ AutomationProperties.SetName(this, string.IsNullOrEmpty(Text) ? Icon.ToString() : Text);
+
+ var hovered = IsPointerOver && IsEffectivelyEnabled;
+ var foreground = IsPressed ? ShellToken.AccentForeground
+ : hovered ? ShellToken.MutedForeground
+ : ShellToken.Foreground;
+ _label.Token(TextBlock.ForegroundProperty, foreground);
+ _icon.Token = foreground;
+
+ if (IsPressed) this.Token(BackgroundProperty, ShellToken.Accent);
+ else if (hovered) this.Token(BackgroundProperty, ShellToken.Muted);
+ else
+ {
+ // Transparent, not null, so the whole toggle stays hit-testable.
+ this.ClearToken(BackgroundProperty);
+ Background = Brushes.Transparent;
+ }
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+}
+
+public enum ToggleVariant { Default, Outline }
+public enum ToggleSize { Sm, Default, Lg }
diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml
index 198c610..2dbe813 100644
--- a/examples/Avalonia.Demo/MainWindow.axaml
+++ b/examples/Avalonia.Demo/MainWindow.axaml
@@ -149,7 +149,7 @@
-
+
@@ -212,7 +212,7 @@
-
+
@@ -240,9 +240,9 @@
-
-
-
+
+
+
@@ -291,14 +291,142 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs
index 34c5f3d..0013f69 100644
--- a/examples/Avalonia.Demo/MainWindow.axaml.cs
+++ b/examples/Avalonia.Demo/MainWindow.axaml.cs
@@ -36,6 +36,20 @@ public MainWindow()
if (confirmed) Toast.Success("Account deleted", "This was only a demo.");
};
+ foreach (var row in DemoTable.Rows.OfType().Where(r => r is not TableHeader))
+ row.Tapped += (_, _) =>
+ {
+ foreach (var other in DemoTable.Rows.OfType()) other.IsSelected = other == row;
+ DataChoice.Text = $"Selected {((TableCell)row.Children.OfType().First()).Text}.";
+ };
+ DemoPagination.PageChanged += (_, page) => DataChoice.Text = $"Page {page} of 20.";
+
+ foreach (var item in DemoContextMenu.FindDescendantsOfType())
+ item.Clicked += (_, _) => AdvancedChoice.Text = $"Context menu: {item.Text}.";
+ DemoCarousel.PositionChanged += (_, position) => AdvancedChoice.Text = $"Slide {position + 1} of 4.";
+ DemoStepper.CurrentStepChanged += (_, step) => AdvancedChoice.Text = $"Step {step + 1} of 3.";
+ DemoStepper.Confirmed += (_, _) => Toast.Success("Workspace created");
+
for (var i = 1; i <= 30; i++)
{
var tag = new TextBlock { Text = $"v1.2.0-beta.{31 - i}", FontSize = 14 };
diff --git a/examples/MAUI.Demo/MAUI.Demo.csproj b/examples/MAUI.Demo/MAUI.Demo.csproj
index 27bd530..4f93298 100644
--- a/examples/MAUI.Demo/MAUI.Demo.csproj
+++ b/examples/MAUI.Demo/MAUI.Demo.csproj
@@ -56,7 +56,6 @@
-
diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml
index f15b7d6..3aa413c 100644
--- a/examples/MAUI.Demo/MainPage.xaml
+++ b/examples/MAUI.Demo/MainPage.xaml
@@ -309,12 +309,12 @@
-
-
+
+
-
-
-
+
+
+
@@ -348,7 +348,7 @@
-
+
@@ -374,11 +374,11 @@
-
+
-
+
@@ -398,7 +398,7 @@
-
+
@@ -460,16 +460,16 @@
-
+
-
+
-
-
+
+
@@ -508,7 +508,7 @@
-
+
diff --git a/examples/MAUI.Demo/Resources/Images/dotnet_bot.png b/examples/MAUI.Demo/Resources/Images/dotnet_bot.png
deleted file mode 100644
index 054167e..0000000
Binary files a/examples/MAUI.Demo/Resources/Images/dotnet_bot.png and /dev/null differ
diff --git a/examples/MAUI.Demo/Resources/Images/shelldocs.svg b/examples/MAUI.Demo/Resources/Images/shelldocs.svg
new file mode 100644
index 0000000..bb66a7c
--- /dev/null
+++ b/examples/MAUI.Demo/Resources/Images/shelldocs.svg
@@ -0,0 +1 @@
+
diff --git a/examples/MAUI.Demo/Resources/Images/shellicons.svg b/examples/MAUI.Demo/Resources/Images/shellicons.svg
new file mode 100644
index 0000000..9cf0007
--- /dev/null
+++ b/examples/MAUI.Demo/Resources/Images/shellicons.svg
@@ -0,0 +1 @@
+
diff --git a/examples/MAUI.Demo/Resources/Images/shellui.svg b/examples/MAUI.Demo/Resources/Images/shellui.svg
new file mode 100644
index 0000000..af69057
--- /dev/null
+++ b/examples/MAUI.Demo/Resources/Images/shellui.svg
@@ -0,0 +1 @@
+
diff --git a/examples/MAUI.Demo/Resources/Images/shellui_native.svg b/examples/MAUI.Demo/Resources/Images/shellui_native.svg
new file mode 100644
index 0000000..e87c6bb
--- /dev/null
+++ b/examples/MAUI.Demo/Resources/Images/shellui_native.svg
@@ -0,0 +1 @@
+
diff --git a/src/ShellUI.Native.Templates/Templates/AvatarTemplate.cs b/src/ShellUI.Native.Templates/Templates/AvatarTemplate.cs
index 2b6860f..bc410f9 100644
--- a/src/ShellUI.Native.Templates/Templates/AvatarTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/AvatarTemplate.cs
@@ -115,6 +115,112 @@ private void UpdateFallback()
}
}
+public enum AvatarSize { Sm, Default, Lg, Xl }
+",
+ [NativePlatform.Avalonia] = @"using System;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Avatar — rounded-full image over a bg-muted fallback (initials, or a user icon when no
+// Fallback is set). The fallback shows wherever the image doesn't cover it.
+// Usage:
+public class Avatar : Border
+{
+ public static readonly StyledProperty SourceProperty =
+ AvaloniaProperty.Register(nameof(Source));
+
+ public static readonly StyledProperty FallbackProperty =
+ AvaloniaProperty.Register(nameof(Fallback));
+
+ public static readonly StyledProperty SizeProperty =
+ AvaloniaProperty.Register(nameof(Size), AvatarSize.Default);
+
+ private readonly TextBlock _initials;
+ private readonly Icon _userIcon;
+ private readonly Image _image;
+
+ static Avatar()
+ {
+ SourceProperty.Changed.AddClassHandler((a, _) =>
+ {
+ a._image.Source = a.Source;
+ a._image.IsVisible = a.Source != null;
+ });
+ FallbackProperty.Changed.AddClassHandler((a, _) => a.UpdateFallback());
+ SizeProperty.Changed.AddClassHandler((a, _) => a.UpdateSize());
+ }
+
+ public IImage? Source
+ {
+ get => GetValue(SourceProperty);
+ set => SetValue(SourceProperty, value);
+ }
+
+ public string? Fallback
+ {
+ get => GetValue(FallbackProperty);
+ set => SetValue(FallbackProperty, value);
+ }
+
+ public AvatarSize Size
+ {
+ get => GetValue(SizeProperty);
+ set => SetValue(SizeProperty, value);
+ }
+
+ public Avatar()
+ {
+ _initials = new TextBlock
+ {
+ FontWeight = FontWeight.SemiBold,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ _initials.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ _userIcon = new Icon { Kind = IconName.User, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ _image = new Image { Stretch = Stretch.UniformToFill, IsVisible = false };
+
+ Child = new Panel { Children = { _initials, _userIcon, _image } };
+ // Clips the image to the circle.
+ ClipToBounds = true;
+ HorizontalAlignment = HorizontalAlignment.Left;
+ VerticalAlignment = VerticalAlignment.Center;
+ this.Token(BackgroundProperty, ShellToken.Muted);
+ UpdateSize();
+ UpdateFallback();
+ }
+
+ private void UpdateSize()
+ {
+ var size = Size switch
+ {
+ AvatarSize.Sm => 32,
+ AvatarSize.Lg => 48,
+ AvatarSize.Xl => 64,
+ _ => 40
+ };
+ Width = size;
+ Height = size;
+ CornerRadius = new CornerRadius(size / 2.0);
+ _initials.FontSize = Math.Round(size * 0.36);
+ _userIcon.Size = size / 2.0;
+ }
+
+ private void UpdateFallback()
+ {
+ var hasText = !string.IsNullOrWhiteSpace(Fallback);
+ _initials.Text = Fallback ?? string.Empty;
+ _initials.IsVisible = hasText;
+ _userIcon.IsVisible = !hasText;
+ AutomationProperties.SetName(this, hasText ? Fallback : ""Avatar"");
+ }
+}
+
public enum AvatarSize { Sm, Default, Lg, Xl }
"
};
diff --git a/src/ShellUI.Native.Templates/Templates/CalloutTemplate.cs b/src/ShellUI.Native.Templates/Templates/CalloutTemplate.cs
index 965e0c0..382fa9e 100644
--- a/src/ShellUI.Native.Templates/Templates/CalloutTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/CalloutTemplate.cs
@@ -143,6 +143,144 @@ private void Update()
}
}
+public enum CalloutVariant
+{
+ Info,
+ Warning,
+ Danger,
+ Tip,
+ Default
+}
+",
+ [NativePlatform.Avalonia] = @"using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Highlighted note — rounded-lg border px-4 py-3 on a faint tint of the variant color, with an
+// icon, an optional title and text and/or any content.
+//
+//
+public class Callout : Border
+{
+ public static readonly StyledProperty VariantProperty =
+ AvaloniaProperty.Register(nameof(Variant), CalloutVariant.Info);
+
+ public static readonly StyledProperty TitleProperty =
+ AvaloniaProperty.Register(nameof(Title));
+
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ // None uses the variant's own icon.
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon), IconName.None);
+
+ public static readonly StyledProperty BodyProperty =
+ AvaloniaProperty.Register(nameof(Body));
+
+ private readonly Border _tint;
+ private readonly Icon _icon;
+ private readonly TextBlock _title;
+ private readonly TextBlock _text;
+ private readonly ContentControl _body = new();
+
+ static Callout()
+ {
+ VariantProperty.Changed.AddClassHandler((c, _) => c.Update());
+ TitleProperty.Changed.AddClassHandler((c, _) => c.Update());
+ TextProperty.Changed.AddClassHandler((c, _) => c.Update());
+ IconProperty.Changed.AddClassHandler((c, _) => c.Update());
+ BodyProperty.Changed.AddClassHandler((c, _) => c.Update());
+ }
+
+ public CalloutVariant Variant
+ {
+ get => GetValue(VariantProperty);
+ set => SetValue(VariantProperty, value);
+ }
+
+ public string? Title
+ {
+ get => GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ [Content]
+ public Control? Body
+ {
+ get => GetValue(BodyProperty);
+ set => SetValue(BodyProperty, value);
+ }
+
+ public Callout()
+ {
+ _tint = new Border { CornerRadius = new CornerRadius(ShellTheme.RadiusLg - 1) };
+ _icon = new Icon { Size = 16, VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 2, 0, 0) };
+ _title = new TextBlock { FontSize = 14, FontWeight = FontWeight.SemiBold, TextWrapping = TextWrapping.Wrap };
+ _text = new TextBlock { FontSize = 14, LineHeight = 20, TextWrapping = TextWrapping.Wrap };
+ _text.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+
+ var text = new StackPanel { Spacing = 6, Children = { _title, _text, _body } };
+ Grid.SetColumn(text, 1);
+ var row = new Grid
+ {
+ Margin = new Thickness(16, 12),
+ ColumnSpacing = 12,
+ ColumnDefinitions = new ColumnDefinitions(""Auto,*""),
+ Children = { _icon, text }
+ };
+
+ Child = new Panel { Children = { _tint, row } };
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusLg);
+ this.Token(BorderBrushProperty, ShellToken.Border);
+ Update();
+ }
+
+ private void Update()
+ {
+ var (icon, accent) = Variant switch
+ {
+ CalloutVariant.Warning => (IconName.TriangleAlert, ShellToken.Warning),
+ CalloutVariant.Danger => (IconName.CircleAlert, ShellToken.Destructive),
+ CalloutVariant.Tip => (IconName.Lightbulb, ShellToken.Success),
+ CalloutVariant.Default => (IconName.Info, ShellToken.MutedForeground),
+ _ => (IconName.Info, ShellToken.Info)
+ };
+ var neutral = Variant == CalloutVariant.Default;
+
+ // bg-/8 for the tinted variants, bg-muted/50 for the neutral one.
+ _tint.Token(BackgroundProperty, neutral ? ShellToken.Muted : accent);
+ _tint.Opacity = neutral ? 0.5 : 0.08;
+
+ _icon.Kind = Icon == IconName.None ? icon : Icon;
+ _icon.Token = accent;
+ _title.Text = Title ?? string.Empty;
+ _title.IsVisible = !string.IsNullOrEmpty(Title);
+ _title.Token(TextBlock.ForegroundProperty, neutral ? ShellToken.Foreground : accent);
+ _text.Text = Text ?? string.Empty;
+ _text.IsVisible = !string.IsNullOrEmpty(Text);
+ _body.Content = Body;
+ _body.IsVisible = Body != null;
+ }
+}
+
public enum CalloutVariant
{
Info,
diff --git a/src/ShellUI.Native.Templates/Templates/CarouselTemplate.cs b/src/ShellUI.Native.Templates/Templates/CarouselTemplate.cs
index d5c3064..7440c84 100644
--- a/src/ShellUI.Native.Templates/Templates/CarouselTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/CarouselTemplate.cs
@@ -319,6 +319,413 @@ private void StopTimer()
_timer = null;
}
}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using Avalonia;
+using Avalonia.Animation.Easings;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+using Avalonia.Threading;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Carousel — one slide at a time in a rounded-lg frame; drag, use the round arrow buttons, the
+// Left / Right keys or click a dot to move. Each child control is a slide.
+//
+//
+//
+//
+public class Carousel : Border
+{
+ public static readonly StyledProperty PositionProperty =
+ AvaloniaProperty.Register(nameof(Position), defaultBindingMode: BindingMode.TwoWay);
+
+ // At the last slide, Next wraps to the first (and Previous the other way).
+ public static readonly StyledProperty LoopProperty =
+ AvaloniaProperty.Register(nameof(Loop), true);
+
+ public static readonly StyledProperty ShowArrowsProperty =
+ AvaloniaProperty.Register(nameof(ShowArrows), true);
+
+ public static readonly StyledProperty ShowDotsProperty =
+ AvaloniaProperty.Register(nameof(ShowDots), true);
+
+ public static readonly StyledProperty AutoPlayProperty =
+ AvaloniaProperty.Register(nameof(AutoPlay));
+
+ // Milliseconds between slides while AutoPlay is on.
+ public static readonly StyledProperty AutoPlayIntervalProperty =
+ AvaloniaProperty.Register(nameof(AutoPlayInterval), 3000);
+
+ // Width / height of the frame. 0 turns it off: set Height on the carousel instead.
+ public static readonly StyledProperty AspectRatioProperty =
+ AvaloniaProperty.Register(nameof(AspectRatio), 16d / 9d);
+
+ private const double DragThreshold = 6;
+
+ private readonly Panel _track;
+ private readonly Border _previous;
+ private readonly Border _next;
+ private readonly StackPanel _dots;
+ private readonly Easing _easing = new CubicEaseOut();
+ private DispatcherTimer? _timer;
+ // Where the track is, in slides: 1.5 is halfway between the second and third slide.
+ private double _offset;
+ private int _animation;
+ private Point? _pressedAt;
+ private bool _dragging;
+ private double _dragX;
+
+ static Carousel()
+ {
+ PositionProperty.Changed.AddClassHandler((c, _) => c.OnPositionChanged());
+ LoopProperty.Changed.AddClassHandler((c, _) => c.UpdateControls());
+ ShowArrowsProperty.Changed.AddClassHandler((c, _) => c.UpdateControls());
+ ShowDotsProperty.Changed.AddClassHandler((c, _) => c.UpdateControls());
+ AutoPlayProperty.Changed.AddClassHandler((c, _) => c.UpdateTimer());
+ AutoPlayIntervalProperty.Changed.AddClassHandler((c, _) => c.UpdateTimer());
+ AffectsMeasure(AspectRatioProperty);
+ }
+
+ public int Position
+ {
+ get => GetValue(PositionProperty);
+ set => SetValue(PositionProperty, value);
+ }
+
+ public bool Loop
+ {
+ get => GetValue(LoopProperty);
+ set => SetValue(LoopProperty, value);
+ }
+
+ public bool ShowArrows
+ {
+ get => GetValue(ShowArrowsProperty);
+ set => SetValue(ShowArrowsProperty, value);
+ }
+
+ public bool ShowDots
+ {
+ get => GetValue(ShowDotsProperty);
+ set => SetValue(ShowDotsProperty, value);
+ }
+
+ public bool AutoPlay
+ {
+ get => GetValue(AutoPlayProperty);
+ set => SetValue(AutoPlayProperty, value);
+ }
+
+ public int AutoPlayInterval
+ {
+ get => GetValue(AutoPlayIntervalProperty);
+ set => SetValue(AutoPlayIntervalProperty, value);
+ }
+
+ public double AspectRatio
+ {
+ get => GetValue(AspectRatioProperty);
+ set => SetValue(AspectRatioProperty, value);
+ }
+
+ [Content]
+ public Controls Slides => _track.Children;
+
+ public event EventHandler? PositionChanged;
+
+ public Carousel()
+ {
+ // Transparent, not null, so a drag can start between the slides' own content.
+ _track = new Panel { ClipToBounds = true, Background = Brushes.Transparent };
+ _track.Children.CollectionChanged += (_, _) => OnSlidesChanged();
+ _track.SizeChanged += (_, _) => Render();
+ _track.PointerPressed += OnTrackPressed;
+ _track.PointerMoved += OnTrackMoved;
+ _track.PointerReleased += OnTrackReleased;
+ _track.PointerCaptureLost += (_, _) => EndDrag(cancelled: true);
+
+ _previous = Arrow(IconName.ChevronLeft, HorizontalAlignment.Left, ""Previous slide"", Previous);
+ _next = Arrow(IconName.ChevronRight, HorizontalAlignment.Right, ""Next slide"", Next);
+ foreach (var arrow in new[] { _previous, _next })
+ {
+ arrow.PointerEntered += (_, _) => UpdateControls();
+ arrow.PointerExited += (_, _) => UpdateControls();
+ }
+ _dots = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Bottom,
+ Margin = new Thickness(0, 0, 0, 8)
+ };
+
+ Child = new Panel { Children = { _track, _previous, _next, _dots } };
+ CornerRadius = new CornerRadius(ShellTheme.RadiusLg);
+ // Slides stay inside the rounded corners.
+ ClipToBounds = true;
+ UpdateControls();
+ }
+
+ private int Count => _track.Children.Count;
+ private int Current => Math.Clamp(Position, 0, Math.Max(0, Count - 1));
+
+ public void Next()
+ {
+ if (Current < Count - 1) Position = Current + 1;
+ else if (Loop && Count > 1) Position = 0;
+ }
+
+ public void Previous()
+ {
+ if (Current > 0) Position = Current - 1;
+ else if (Loop && Count > 1) Position = Count - 1;
+ }
+
+ // The frame takes the width it is given and derives its height from AspectRatio.
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ if (AspectRatio <= 0) return base.MeasureOverride(availableSize);
+ var width = double.IsInfinity(availableSize.Width) ? base.MeasureOverride(availableSize).Width : availableSize.Width;
+ var size = new Size(width, width / AspectRatio);
+ base.MeasureOverride(size);
+ return size;
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ UpdateTimer();
+ }
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ StopTimer();
+ _animation++;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Handled || e.Source is TextBox) return;
+ if (e.Key == Key.Left) Previous();
+ else if (e.Key == Key.Right) Next();
+ else return;
+ e.Handled = true;
+ }
+
+ // Round outline button over the slides; a tab stop that activates with Enter or Space.
+ private static Border Arrow(IconName icon, HorizontalAlignment side, string name, Action press)
+ {
+ var shadow = new BoxShadows(new BoxShadow { OffsetY = 2, Blur = 6, Color = ShellTheme.Shadow(0.15) });
+ var button = new Border
+ {
+ Child = new Icon { Kind = icon, Size = 16, Token = ShellToken.Foreground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center },
+ Width = 32,
+ Height = 32,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(16),
+ HorizontalAlignment = side,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(12, 0),
+ BoxShadow = shadow,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ button.Token(BackgroundProperty, ShellToken.Background).Token(BorderBrushProperty, ShellToken.Border);
+ AutomationProperties.SetName(button, name);
+ ShellFocus.Ring(button, button);
+ // The focus ring is painted as a shadow and cleared on blur: put the drop shadow back.
+ button.LostFocus += (_, _) => button.BoxShadow = shadow;
+ button.PointerPressed += (_, e) => e.Handled = true;
+ button.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ press();
+ e.Handled = true;
+ };
+ button.KeyDown += (_, e) =>
+ {
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ press();
+ e.Handled = true;
+ };
+ return button;
+ }
+
+ private void OnSlidesChanged()
+ {
+ _dots.Children.Clear();
+ for (var i = 0; i < Count; i++)
+ {
+ var index = i;
+ var dot = new Border
+ {
+ Width = 8,
+ Height = 8,
+ CornerRadius = new CornerRadius(4),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ dot.Token(BackgroundProperty, ShellToken.Primary);
+ // 8px dot in a 16px click target.
+ var target = new Border { Width = 16, Height = 16, Background = Brushes.Transparent, Child = dot, Cursor = new Cursor(StandardCursorType.Hand) };
+ AutomationProperties.SetName(target, $""Go to slide {i + 1}"");
+ target.PointerPressed += (_, e) => e.Handled = true;
+ target.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Position = index;
+ e.Handled = true;
+ };
+ _dots.Children.Add(target);
+ }
+ _animation++;
+ _offset = Current;
+ Render();
+ UpdateControls();
+ UpdateTimer();
+ }
+
+ private void OnPositionChanged()
+ {
+ UpdateControls();
+ AnimateTo(Current, 300);
+ PositionChanged?.Invoke(this, Current);
+ // A manual move restarts the auto-play countdown.
+ UpdateTimer();
+ }
+
+ // Moves the track to a slide over the next frames; a newer move or a drag takes over.
+ private void AnimateTo(double target, int milliseconds)
+ {
+ var run = ++_animation;
+ var from = _offset;
+ if (TopLevel.GetTopLevel(this) is not { } top || from == target)
+ {
+ _offset = target;
+ Render();
+ return;
+ }
+
+ TimeSpan? started = null;
+ void Frame(TimeSpan now)
+ {
+ if (run != _animation) return;
+ started ??= now;
+ var progress = Math.Min(1, (now - started.Value).TotalMilliseconds / milliseconds);
+ _offset = from + (target - from) * _easing.Ease(progress);
+ Render();
+ if (progress < 1) top.RequestAnimationFrame(Frame);
+ }
+ top.RequestAnimationFrame(Frame);
+ }
+
+ // Slides are stacked in one cell and spread out sideways by translation.
+ private void Render()
+ {
+ var width = _track.Bounds.Width;
+ if (width <= 0) return;
+ for (var i = 0; i < Count; i++)
+ {
+ var x = (i - _offset) * width;
+ if (_track.Children[i].RenderTransform is TranslateTransform shift) shift.X = x;
+ else _track.Children[i].RenderTransform = new TranslateTransform(x, 0);
+ }
+ }
+
+ private void UpdateControls()
+ {
+ var current = Current;
+ var many = Count > 1;
+ var canGoBack = Loop || current > 0;
+ var canGoOn = Loop || current < Count - 1;
+ _previous.IsVisible = ShowArrows && many;
+ _next.IsVisible = ShowArrows && many;
+ _previous.Opacity = !canGoBack ? 0.4 : _previous.IsPointerOver ? 1 : 0.9;
+ _next.Opacity = !canGoOn ? 0.4 : _next.IsPointerOver ? 1 : 0.9;
+ _previous.Focusable = canGoBack;
+ _next.Focusable = canGoOn;
+ _dots.IsVisible = ShowDots && many;
+ for (var i = 0; i < _dots.Children.Count; i++)
+ {
+ // One color at two strengths, so the dots stay readable over any slide.
+ if (_dots.Children[i] is Border { Child: { } dot })
+ dot.Opacity = i == current ? 1 : 0.3;
+ }
+ }
+
+ private void OnTrackPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (Count < 2 || !e.GetCurrentPoint(_track).Properties.IsLeftButtonPressed) return;
+ _pressedAt = e.GetPosition(_track);
+ _dragging = false;
+ _dragX = 0;
+ }
+
+ private void OnTrackMoved(object? sender, PointerEventArgs e)
+ {
+ var width = _track.Bounds.Width;
+ if (_pressedAt is not { } start || width <= 0) return;
+ _dragX = e.GetPosition(_track).X - start.X;
+ if (!_dragging)
+ {
+ // A small move is still a click on the slide's own content.
+ if (Math.Abs(_dragX) < DragThreshold) return;
+ _dragging = true;
+ _animation++;
+ e.Pointer.Capture(_track);
+ }
+ // Follow the pointer, with a little give past the first and last slide.
+ _offset = Math.Clamp(Current - _dragX / width, -0.25, Count - 0.75);
+ Render();
+ e.Handled = true;
+ }
+
+ private void OnTrackReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (_dragging) e.Handled = true;
+ EndDrag(cancelled: false);
+ e.Pointer.Capture(null);
+ }
+
+ private void EndDrag(bool cancelled)
+ {
+ var width = _track.Bounds.Width;
+ var wasDragging = _dragging;
+ var moved = width > 0 ? _dragX / width : 0;
+ _pressedAt = null;
+ _dragging = false;
+ _dragX = 0;
+ if (!wasDragging) return;
+
+ var current = Current;
+ if (!cancelled && moved <= -0.2 && current < Count - 1) Position = current + 1;
+ else if (!cancelled && moved >= 0.2 && current > 0) Position = current - 1;
+ // Not far enough: settle back.
+ else AnimateTo(current, 200);
+ }
+
+ private void UpdateTimer()
+ {
+ StopTimer();
+ if (!AutoPlay || Count < 2 || TopLevel.GetTopLevel(this) is null) return;
+ _timer = new DispatcherTimer(TimeSpan.FromMilliseconds(Math.Max(500, AutoPlayInterval)), DispatcherPriority.Normal,
+ (_, _) => Position = Current < Count - 1 ? Current + 1 : 0);
+ _timer.Start();
+ }
+
+ private void StopTimer()
+ {
+ _timer?.Stop();
+ _timer = null;
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/ContextMenuTemplate.cs b/src/ShellUI.Native.Templates/Templates/ContextMenuTemplate.cs
index 01950af..9041163 100644
--- a/src/ShellUI.Native.Templates/Templates/ContextMenuTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/ContextMenuTemplate.cs
@@ -261,6 +261,245 @@ public ContextMenuSeparator()
Content = line;
}
}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Context menu — right-click the trigger (long-press on touch, or the Menu key / Shift+F10) to
+// open a menu at the pointer. Clicking outside, picking an item or pressing Escape closes it.
+//
+// ...any control...
+//
+//
+//
+//
+//
+//
+public class ContextMenu : ShellPopoverHost
+{
+ private bool _atPoint;
+
+ public ContextMenu()
+ {
+ // The trigger usually is a surface (a card, a row): let it fill its container.
+ HorizontalAlignment = HorizontalAlignment.Stretch;
+ // Clicks on the trigger reach it while the menu is open, so a second right-click moves the menu.
+ Popup.OverlayInputPassThroughElement = this;
+ }
+
+ protected override bool IsContent(Control child) => child is ContextMenuContent;
+ protected override double Offset => _atPoint ? 0 : 4;
+
+ // Opens the menu at a point relative to this control, or below the trigger when null.
+ public void OpenAt(Point? point)
+ {
+ _atPoint = point.HasValue;
+ Popup.PlacementRect = point is { } at ? new Rect(at, new Size(1, 1)) : null;
+ if (IsOpen) Popup.VerticalOffset = Offset;
+ else IsOpen = true;
+ }
+}
+
+// The surface that opens the enclosing ContextMenu.
+public class ContextMenuTrigger : Border
+{
+ public ContextMenuTrigger()
+ {
+ // Transparent, not null, so empty areas of the surface take the right-click too.
+ Background = Brushes.Transparent;
+ AddHandler(ContextRequestedEvent, OnContextRequested);
+ }
+
+ private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
+ {
+ if (this.FindParentOfType() is not { } menu) return;
+ menu.OpenAt(e.TryGetPosition(menu, out var point) ? point : null);
+ e.Handled = true;
+ }
+
+ // A long-press on touch opens the menu under the trigger.
+ protected override void OnHolding(HoldingRoutedEventArgs e)
+ {
+ base.OnHolding(e);
+ if (e.HoldingState != HoldingState.Started || this.FindParentOfType() is not { IsOpen: false } menu) return;
+ menu.OpenAt(null);
+ e.Handled = true;
+ }
+
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ this.FindParentOfType()?.Close();
+ }
+}
+
+// Menu panel — min-w-[13rem] rounded-md border bg-popover p-1 shadow-md. XAML children go to Items.
+public class ContextMenuContent : Border
+{
+ private readonly StackPanel _stack = new();
+
+ [Content]
+ public Controls Items => _stack.Children;
+
+ public ContextMenuContent()
+ {
+ Child = _stack;
+ Padding = new Thickness(4);
+ MinWidth = 208;
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ BoxShadow = ShellPopups.PanelShadow();
+ this.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+ }
+}
+
+// Menu row — rounded-sm px-2 py-1.5 text-sm, hover:bg-accent, optional icon and shortcut hint.
+public class ContextMenuItem : Border
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ // Keyboard hint shown at the end of the row (display only).
+ public static readonly StyledProperty ShortcutProperty =
+ AvaloniaProperty.Register(nameof(Shortcut));
+
+ public static readonly StyledProperty IsDestructiveProperty =
+ AvaloniaProperty.Register(nameof(IsDestructive));
+
+ private readonly Icon _icon;
+ private readonly TextBlock _label;
+ private readonly TextBlock _shortcut;
+
+ static ContextMenuItem()
+ {
+ TextProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IconProperty.Changed.AddClassHandler((i, _) => i.Update());
+ ShortcutProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IsDestructiveProperty.Changed.AddClassHandler((i, _) => i.Update());
+ 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 string? Shortcut
+ {
+ get => GetValue(ShortcutProperty);
+ set => SetValue(ShortcutProperty, value);
+ }
+
+ public bool IsDestructive
+ {
+ get => GetValue(IsDestructiveProperty);
+ set => SetValue(IsDestructiveProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ public ContextMenuItem()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, VerticalAlignment = VerticalAlignment.Center };
+ _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center };
+ _shortcut = new TextBlock { FontSize = 12, VerticalAlignment = VerticalAlignment.Center, IsVisible = false };
+ _shortcut.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+
+ var grid = new Grid { ColumnDefinitions = new ColumnDefinitions(""Auto,*,Auto""), ColumnSpacing = 8 };
+ Grid.SetColumn(_label, 1);
+ Grid.SetColumn(_shortcut, 2);
+ grid.Children.Add(_icon);
+ grid.Children.Add(_label);
+ grid.Children.Add(_shortcut);
+
+ Child = grid;
+ Height = 32;
+ Padding = new Thickness(8, 0);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm);
+ Background = Brushes.Transparent;
+ Cursor = new Cursor(StandardCursorType.Hand);
+ ShellFocus.Ring(this, this);
+ Update();
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ if (IsEffectivelyEnabled) 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 Update()
+ {
+ var color = IsDestructive ? ShellToken.Destructive : ShellToken.PopoverForeground;
+ _icon.Kind = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _icon.Token = color;
+ _label.Text = Text ?? string.Empty;
+ _label.Token(TextBlock.ForegroundProperty, color);
+ _shortcut.Text = Shortcut ?? string.Empty;
+ _shortcut.IsVisible = !string.IsNullOrEmpty(Shortcut);
+ }
+}
+
+// Divider between groups of items — -mx-1 my-1 h-px bg-border.
+public class ContextMenuSeparator : Border
+{
+ public ContextMenuSeparator()
+ {
+ Height = 1;
+ Margin = new Thickness(-4, 4);
+ this.Token(BackgroundProperty, ShellToken.Border);
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/EmptyStateTemplate.cs b/src/ShellUI.Native.Templates/Templates/EmptyStateTemplate.cs
index bf25f14..87bdab9 100644
--- a/src/ShellUI.Native.Templates/Templates/EmptyStateTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/EmptyStateTemplate.cs
@@ -127,6 +127,130 @@ private void Update()
_frame.StrokeThickness = Bordered ? 1 : 0;
}
}
+",
+ [NativePlatform.Avalonia] = @"using Avalonia;
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Placeholder for an empty list or screen: icon in a muted tile, title, description and
+// optional actions, centered. Bordered=""True"" adds shadcn's dashed outline.
+//
+//
+//
+public class EmptyState : Border
+{
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon), IconName.Inbox);
+
+ public static readonly StyledProperty TitleProperty =
+ AvaloniaProperty.Register(nameof(Title));
+
+ public static readonly StyledProperty DescriptionProperty =
+ AvaloniaProperty.Register(nameof(Description));
+
+ public static readonly StyledProperty BorderedProperty =
+ AvaloniaProperty.Register(nameof(Bordered));
+
+ private readonly Rectangle _outline;
+ private readonly Icon _icon;
+ private readonly TextBlock _title;
+ private readonly TextBlock _description;
+ private readonly StackPanel _actions;
+
+ static EmptyState()
+ {
+ IconProperty.Changed.AddClassHandler