From 4c354fbb87090184283ad549a6428ff09875b9c3 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Fri, 9 Oct 2026 10:00:52 +0200 Subject: [PATCH 1/6] feat(avalonia): port the P3 navigation and layout components Collapsible, accordion, tabs and breadcrumb with their parts, skeleton and scroll-area. Expanding content animates its height through AnimateExpandAsync in element-extensions, as on MAUI. Tabs also switch with Left and Right. --- .../Avalonia.Demo/Components/UI/Accordion.cs | 105 +++++++++++++++++ .../Components/UI/AccordionContent.cs | 21 ++++ .../Components/UI/AccordionItem.cs | 50 +++++++++ .../Components/UI/AccordionTrigger.cs | 91 +++++++++++++++ .../Avalonia.Demo/Components/UI/Breadcrumb.cs | 26 +++++ .../Components/UI/BreadcrumbItem.cs | 83 ++++++++++++++ .../Components/UI/Collapsible.cs | 57 ++++++++++ .../Components/UI/CollapsibleContent.cs | 19 ++++ .../Components/UI/CollapsibleTrigger.cs | 7 ++ .../Components/UI/ElementExtensions.cs | 66 +++++++++++ .../Avalonia.Demo/Components/UI/ScrollArea.cs | 20 ++++ .../Avalonia.Demo/Components/UI/Skeleton.cs | 52 +++++++++ examples/Avalonia.Demo/Components/UI/Tabs.cs | 62 ++++++++++ .../Components/UI/TabsContent.cs | 43 +++++++ .../Avalonia.Demo/Components/UI/TabsList.cs | 25 +++++ .../Components/UI/TabsTrigger.cs | 103 +++++++++++++++++ examples/Avalonia.Demo/MainWindow.axaml | 66 +++++++++++ examples/Avalonia.Demo/MainWindow.axaml.cs | 11 ++ scripts/sync-templates.py | 2 +- .../Templates/AccordionContentTemplate.cs | 22 ++++ .../Templates/AccordionItemTemplate.cs | 51 +++++++++ .../Templates/AccordionTemplate.cs | 106 ++++++++++++++++++ .../Templates/AccordionTriggerTemplate.cs | 92 +++++++++++++++ .../Templates/BreadcrumbItemTemplate.cs | 84 ++++++++++++++ .../Templates/BreadcrumbTemplate.cs | 27 +++++ .../Templates/CollapsibleContentTemplate.cs | 20 ++++ .../Templates/CollapsibleTemplate.cs | 58 ++++++++++ .../Templates/CollapsibleTriggerTemplate.cs | 8 ++ .../Templates/ElementExtensionsTemplate.cs | 66 +++++++++++ .../Templates/ScrollAreaTemplate.cs | 21 ++++ .../Templates/SkeletonTemplate.cs | 53 +++++++++ .../Templates/TabsContentTemplate.cs | 44 ++++++++ .../Templates/TabsListTemplate.cs | 26 +++++ .../Templates/TabsTemplate.cs | 63 +++++++++++ .../Templates/TabsTriggerTemplate.cs | 104 +++++++++++++++++ .../TemplateContentTests.cs | 17 ++- 36 files changed, 1769 insertions(+), 2 deletions(-) create mode 100644 examples/Avalonia.Demo/Components/UI/Accordion.cs create mode 100644 examples/Avalonia.Demo/Components/UI/AccordionContent.cs create mode 100644 examples/Avalonia.Demo/Components/UI/AccordionItem.cs create mode 100644 examples/Avalonia.Demo/Components/UI/AccordionTrigger.cs create mode 100644 examples/Avalonia.Demo/Components/UI/Breadcrumb.cs create mode 100644 examples/Avalonia.Demo/Components/UI/BreadcrumbItem.cs create mode 100644 examples/Avalonia.Demo/Components/UI/Collapsible.cs create mode 100644 examples/Avalonia.Demo/Components/UI/CollapsibleContent.cs create mode 100644 examples/Avalonia.Demo/Components/UI/CollapsibleTrigger.cs create mode 100644 examples/Avalonia.Demo/Components/UI/ScrollArea.cs create mode 100644 examples/Avalonia.Demo/Components/UI/Skeleton.cs create mode 100644 examples/Avalonia.Demo/Components/UI/Tabs.cs create mode 100644 examples/Avalonia.Demo/Components/UI/TabsContent.cs create mode 100644 examples/Avalonia.Demo/Components/UI/TabsList.cs create mode 100644 examples/Avalonia.Demo/Components/UI/TabsTrigger.cs diff --git a/examples/Avalonia.Demo/Components/UI/Accordion.cs b/examples/Avalonia.Demo/Components/UI/Accordion.cs new file mode 100644 index 0000000..d3245d0 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Accordion.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; + +namespace AvaloniaDemo.Components.UI; + +public enum AccordionType { Single, Multiple } + +// Stacked expandable sections with dividers and rotating chevrons. +// +// +// +// +// +// +public class Accordion : StackPanel +{ + public static readonly StyledProperty TypeProperty = + AvaloniaProperty.Register(nameof(Type)); + + // Initially open item (Single) — or comma-separated items (Multiple). + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + private readonly HashSet _openValues = new(); + + static Accordion() + { + ValueProperty.Changed.AddClassHandler((a, _) => a.OnValueChanged()); + } + + public AccordionType Type + { + get => GetValue(TypeProperty); + set => SetValue(TypeProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + // Fired when any item's open state changes: (value, isOpen). + public event EventHandler<(string Value, bool IsOpen)>? ItemToggled; + + public Accordion() + { + Children.CollectionChanged += (_, _) => Refresh(animate: false); + } + + public bool IsOpen(string value) => _openValues.Contains(value); + + public void Toggle(string value) + { + var wasOpen = _openValues.Contains(value); + var changed = new List<(string, bool)>(); + + if (Type == AccordionType.Single) + { + foreach (var other in _openValues.Where(v => v != value).ToList()) + { + _openValues.Remove(other); + changed.Add((other, false)); + } + } + + if (wasOpen) _openValues.Remove(value); + else _openValues.Add(value); + changed.Add((value, !wasOpen)); + + Refresh(animate: true); + foreach (var change in changed) ItemToggled?.Invoke(this, change); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Refresh(animate: false); + } + + private void OnValueChanged() + { + _openValues.Clear(); + foreach (var v in (Value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + _openValues.Add(v); + if (Type == AccordionType.Single) break; + } + Refresh(animate: IsLoaded); + } + + // The accordion drives its items, so state never depends on when a child found its parent. + private void Refresh(bool animate) + { + var items = this.FindDescendantsOfType(e => e is Accordion).ToList(); + for (var i = 0; i < items.Count; i++) + { + items[i].ApplyOpen(_openValues.Contains(items[i].Value ?? string.Empty), animate); + items[i].SetDividerVisible(i < items.Count - 1); + } + } +} diff --git a/examples/Avalonia.Demo/Components/UI/AccordionContent.cs b/examples/Avalonia.Demo/Components/UI/AccordionContent.cs new file mode 100644 index 0000000..b8c91e6 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/AccordionContent.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls; + +namespace AvaloniaDemo.Components.UI; + +// Section body — pb-4 text-sm; expands/collapses its height with the item. +public class AccordionContent : Border +{ + public AccordionContent() + { + IsVisible = false; + Opacity = 0; + Padding = new Thickness(0, 0, 0, 16); + } + + internal void Apply(bool open, bool animate) + { + if (animate) _ = this.AnimateExpandAsync(open); + else this.SetExpanded(open); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/AccordionItem.cs b/examples/Avalonia.Demo/Components/UI/AccordionItem.cs new file mode 100644 index 0000000..2e8ac08 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/AccordionItem.cs @@ -0,0 +1,50 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// One section: trigger + content, with a bottom divider (border-b, none on the last item). +public class AccordionItem : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + private readonly StackPanel _stack = new(); + private readonly Border _divider = new() { Height = 1 }; + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + [Content] + public Controls Items => _stack.Children; + + public bool IsOpen { get; private set; } + + public event EventHandler? OpenChanged; + + public AccordionItem() + { + _divider.Token(BackgroundProperty, ShellToken.Border); + Child = new StackPanel { Children = { _stack, _divider } }; + } + + public void Toggle() => this.FindParentOfType()?.Toggle(Value ?? string.Empty); + + internal void SetDividerVisible(bool visible) => _divider.IsVisible = visible; + + internal void ApplyOpen(bool open, bool animate) + { + var changed = IsOpen != open; + IsOpen = open; + foreach (var trigger in this.FindDescendantsOfType(e => e is AccordionItem)) + trigger.SetOpen(open, animate); + foreach (var content in this.FindDescendantsOfType(e => e is AccordionItem)) + content.Apply(open, animate); + if (changed) OpenChanged?.Invoke(this, open); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/AccordionTrigger.cs b/examples/Avalonia.Demo/Components/UI/AccordionTrigger.cs new file mode 100644 index 0000000..f0aff14 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/AccordionTrigger.cs @@ -0,0 +1,91 @@ +using System; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Transformation; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// Section header — flex justify-between py-4 font-medium hover:underline, with a chevron +// that rotates 180° when open. Use Text for a plain title, or put any control inside. +public class AccordionTrigger : ShellTriggerView +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty BodyProperty = + AvaloniaProperty.Register(nameof(Body)); + + private readonly Grid _row; + private readonly TextBlock _label; + private readonly Icon _chevron; + + static AccordionTrigger() + { + TextProperty.Changed.AddClassHandler((t, _) => t._label.Text = t.Text ?? string.Empty); + BodyProperty.Changed.AddClassHandler((t, e) => + { + t._row.Children.Remove((Control?)e.OldValue ?? t._label); + t._row.Children.Insert(0, t.Body ?? t._label); + }); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + [Content] + public Control? Body + { + get => GetValue(BodyProperty); + set => SetValue(BodyProperty, value); + } + + public AccordionTrigger() + { + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap }; + _label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + _chevron = new Icon { Kind = IconName.ChevronDown, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }; + Grid.SetColumn(_chevron, 1); + + _row = new Grid + { + ColumnDefinitions = new ColumnDefinitions("*,Auto"), + ColumnSpacing = 16, + Margin = new Thickness(0, 16), + Children = { _label, _chevron } + }; + + Child = _row; + HorizontalAlignment = HorizontalAlignment.Stretch; + } + + protected override void OnActivated() => this.FindParentOfType()?.Toggle(); + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + _label.TextDecorations = TextDecorations.Underline; + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + _label.TextDecorations = null; + } + + internal void SetOpen(bool open, bool animate) + { + _chevron.Transitions = animate + ? new Transitions { new TransformOperationsTransition { Property = RenderTransformProperty, Duration = TimeSpan.FromMilliseconds(200), Easing = new CubicEaseOut() } } + : null; + _chevron.RenderTransform = TransformOperations.Parse(open ? "rotate(180deg)" : "rotate(0deg)"); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Breadcrumb.cs b/examples/Avalonia.Demo/Components/UI/Breadcrumb.cs new file mode 100644 index 0000000..dc7b920 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Breadcrumb.cs @@ -0,0 +1,26 @@ +using System.Linq; +using Avalonia.Controls; +using Avalonia.Layout; + +namespace AvaloniaDemo.Components.UI; + +// Navigation trail — items separated by chevrons; the last item hides its separator. +// +// +// +// +public class Breadcrumb : StackPanel +{ + public Breadcrumb() + { + Orientation = Orientation.Horizontal; + Children.CollectionChanged += (_, _) => RefreshItemSeparators(); + } + + private void RefreshItemSeparators() + { + var items = Children.OfType().ToList(); + for (var i = 0; i < items.Count; i++) + items[i].SetSeparatorVisible(i < items.Count - 1); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/BreadcrumbItem.cs b/examples/Avalonia.Demo/Components/UI/BreadcrumbItem.cs new file mode 100644 index 0000000..2c1db1e --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/BreadcrumbItem.cs @@ -0,0 +1,83 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Breadcrumb link — text-sm text-muted-foreground hover:text-foreground; the current page is +// text-foreground and not clickable. A chevron separator follows every item but the last. +public class BreadcrumbItem : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty IsCurrentProperty = + AvaloniaProperty.Register(nameof(IsCurrent)); + + private readonly Border _link; + private readonly TextBlock _text; + private readonly Icon _separator; + private bool _hovered; + + static BreadcrumbItem() + { + TextProperty.Changed.AddClassHandler((i, _) => i.UpdateVisualState()); + IsCurrentProperty.Changed.AddClassHandler((i, _) => i.UpdateVisualState()); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public bool IsCurrent + { + get => GetValue(IsCurrentProperty); + set => SetValue(IsCurrentProperty, value); + } + + public event EventHandler? Clicked; + + public BreadcrumbItem() + { + _text = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center }; + // Takes the hover, click and focus ring, so the separator stays inert. + _link = new Border { Child = _text, CornerRadius = new CornerRadius(ShellTheme.RadiusSm), Background = Brushes.Transparent }; + _link.PointerEntered += (_, _) => { _hovered = true; UpdateVisualState(); }; + _link.PointerExited += (_, _) => { _hovered = false; UpdateVisualState(); }; + _link.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left || IsCurrent) return; + Clicked?.Invoke(this, EventArgs.Empty); + e.Handled = true; + }; + + _separator = new Icon { Kind = IconName.ChevronRight, Size = 14, Token = ShellToken.MutedForeground, Margin = new Thickness(6, 0), VerticalAlignment = VerticalAlignment.Center }; + Child = new StackPanel { Orientation = Orientation.Horizontal, Children = { _link, _separator } }; + ShellFocus.Ring(this, _link); + UpdateVisualState(); + } + + public void SetSeparatorVisible(bool visible) => _separator.IsVisible = visible; + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key is not (Key.Enter or Key.Space) || IsCurrent) return; + Clicked?.Invoke(this, EventArgs.Empty); + e.Handled = true; + } + + private void UpdateVisualState() + { + _text.Text = Text ?? string.Empty; + _text.Token(TextBlock.ForegroundProperty, IsCurrent || _hovered ? ShellToken.Foreground : ShellToken.MutedForeground); + // The current page is a label, not a link: no tab stop and no hand cursor. + Focusable = !IsCurrent; + _link.Cursor = IsCurrent ? Cursor.Default : new Cursor(StandardCursorType.Hand); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Collapsible.cs b/examples/Avalonia.Demo/Components/UI/Collapsible.cs new file mode 100644 index 0000000..09f9de7 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Collapsible.cs @@ -0,0 +1,57 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; + +namespace AvaloniaDemo.Components.UI; + +// Expand/collapse. The trigger can wrap a Button or any control; content animates its height. +// +// +// +// +public class Collapsible : StackPanel +{ + public static readonly StyledProperty OpenProperty = + AvaloniaProperty.Register(nameof(Open), defaultBindingMode: BindingMode.TwoWay); + + static Collapsible() + { + OpenProperty.Changed.AddClassHandler((c, _) => + { + c.Refresh(animate: c.IsLoaded); + c.OpenChanged?.Invoke(c, c.Open); + }); + } + + public bool Open + { + get => GetValue(OpenProperty); + set => SetValue(OpenProperty, value); + } + + public event EventHandler? OpenChanged; + + public Collapsible() + { + Spacing = 8; + Children.CollectionChanged += (_, _) => Refresh(animate: false); + } + + public void SetOpen(bool value) => Open = value; + + public void Toggle() => Open = !Open; + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Refresh(animate: false); + } + + // The collapsible drives its content, so state never depends on when a child found its parent. + private void Refresh(bool animate) + { + foreach (var content in this.FindDescendantsOfType(e => e is Collapsible)) + content.Apply(Open, animate); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/CollapsibleContent.cs b/examples/Avalonia.Demo/Components/UI/CollapsibleContent.cs new file mode 100644 index 0000000..feb3956 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/CollapsibleContent.cs @@ -0,0 +1,19 @@ +using Avalonia.Controls; + +namespace AvaloniaDemo.Components.UI; + +// Content shown while the enclosing Collapsible is open; expands/collapses its height. +public class CollapsibleContent : Border +{ + public CollapsibleContent() + { + IsVisible = false; + Opacity = 0; + } + + internal void Apply(bool open, bool animate) + { + if (animate) _ = this.AnimateExpandAsync(open); + else this.SetExpanded(open); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/CollapsibleTrigger.cs b/examples/Avalonia.Demo/Components/UI/CollapsibleTrigger.cs new file mode 100644 index 0000000..98f1172 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/CollapsibleTrigger.cs @@ -0,0 +1,7 @@ +namespace AvaloniaDemo.Components.UI; + +// Toggles the enclosing Collapsible. Wrap a Button or any control (a row, an icon, a label). +public class CollapsibleTrigger : ShellTriggerView +{ + protected override void OnActivated() => this.FindParentOfType()?.Toggle(); +} diff --git a/examples/Avalonia.Demo/Components/UI/ElementExtensions.cs b/examples/Avalonia.Demo/Components/UI/ElementExtensions.cs index c7759fe..2d48960 100644 --- a/examples/Avalonia.Demo/Components/UI/ElementExtensions.cs +++ b/examples/Avalonia.Demo/Components/UI/ElementExtensions.cs @@ -1,7 +1,14 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Layout; using Avalonia.LogicalTree; +using Avalonia.VisualTree; namespace AvaloniaDemo.Components.UI; @@ -29,4 +36,63 @@ public static IEnumerable FindDescendantsOfType(this ILogical root, Func Expanding = new(); + + // Grows the control from its current height to its natural height and fades it in, or the + // reverse, then hides it. Collapsible and accordion content. + public static async Task AnimateExpandAsync(this Control control, bool open, int milliseconds = 200) + { + var run = new object(); + Expanding.AddOrUpdate(control, run); + control.ClipToBounds = true; + control.Transitions = null; + + double start; + double target; + if (open) + { + start = control.IsVisible ? control.Bounds.Height : 0; + control.Height = double.NaN; + control.IsVisible = true; + // Hidden content has no width yet: measure it at its parent's. + var width = control.GetVisualParent() is Layoutable parent ? parent.Bounds.Width : double.PositiveInfinity; + control.Measure(new Size(width, double.PositiveInfinity)); + target = control.DesiredSize.Height - control.Margin.Top - control.Margin.Bottom; + } + else + { + if (!control.IsVisible) return; + start = control.Bounds.Height; + target = 0; + } + + control.Height = start; + var duration = TimeSpan.FromMilliseconds(milliseconds); + Easing easing = open ? new CubicEaseOut() : new CubicEaseIn(); + control.Transitions = new Transitions + { + new DoubleTransition { Property = Layoutable.HeightProperty, Duration = duration, Easing = easing }, + new DoubleTransition { Property = Visual.OpacityProperty, Duration = duration, Easing = easing } + }; + control.Height = target; + control.Opacity = open ? 1 : 0; + await Task.Delay(duration); + + if (!Expanding.TryGetValue(control, out var latest) || latest != run) return; + control.Transitions = null; + control.Height = double.NaN; + if (!open) control.IsVisible = false; + } + + // Jumps to the open or closed state without animating. + public static void SetExpanded(this Control control, bool open) + { + Expanding.Remove(control); + control.Transitions = null; + control.Height = double.NaN; + control.IsVisible = open; + control.Opacity = open ? 1 : 0; + } } diff --git a/examples/Avalonia.Demo/Components/UI/ScrollArea.cs b/examples/Avalonia.Demo/Components/UI/ScrollArea.cs new file mode 100644 index 0000000..1f1d0dd --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/ScrollArea.cs @@ -0,0 +1,20 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; + +namespace AvaloniaDemo.Components.UI; + +// Scrollable region with Fluent's thin scrollbar, which widens on hover and hides when idle. +// For the shadcn look, wrap it in a 1px border with 6px corners: +// ... +public class ScrollArea : ScrollViewer +{ + // Keeps ScrollViewer's theme; a subclass would otherwise have none. + protected override Type StyleKeyOverride => typeof(ScrollViewer); + + public ScrollArea() + { + VerticalScrollBarVisibility = ScrollBarVisibility.Auto; + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Skeleton.cs b/examples/Avalonia.Demo/Components/UI/Skeleton.cs new file mode 100644 index 0000000..892e1fa --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Skeleton.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Styling; + +namespace AvaloniaDemo.Components.UI; + +// Loading placeholder — rounded-md bg-muted animate-pulse (opacity 1 → 0.5 → 1 every 2s). +// Sized by its parent; set Width, Height (default 20) and CornerRadius for other shapes. +public class Skeleton : Border +{ + private static readonly Animation Pulse = new() + { + Duration = TimeSpan.FromSeconds(1), + IterationCount = IterationCount.Infinite, + PlaybackDirection = PlaybackDirection.Alternate, + Easing = new SineEaseInOut(), + Children = + { + new KeyFrame { Cue = new Cue(0), Setters = { new Setter(OpacityProperty, 1.0) } }, + new KeyFrame { Cue = new Cue(1), Setters = { new Setter(OpacityProperty, 0.5) } } + } + }; + + private CancellationTokenSource? _pulse; + + public Skeleton() + { + Height = 20; + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + this.Token(BackgroundProperty, ShellToken.Muted); + } + + // Only animates while on screen. + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _pulse?.Cancel(); + _pulse = new CancellationTokenSource(); + _ = Pulse.RunAsync(this, _pulse.Token); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _pulse?.Cancel(); + _pulse = null; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Tabs.cs b/examples/Avalonia.Demo/Components/UI/Tabs.cs new file mode 100644 index 0000000..080c74f --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Tabs.cs @@ -0,0 +1,62 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; + +namespace AvaloniaDemo.Components.UI; + +// Usage: +// +// +// +// +// +// ... +// ... +// +public class Tabs : StackPanel +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay); + + static Tabs() + { + ValueProperty.Changed.AddClassHandler((t, e) => + { + t.Refresh(animate: t.IsLoaded); + t.ValueChanged?.Invoke(t, ((string?)e.OldValue ?? string.Empty, (string?)e.NewValue ?? string.Empty)); + }); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + // (oldValue, newValue) + public event EventHandler<(string Old, string New)>? ValueChanged; + + public Tabs() + { + Spacing = 8; + Children.CollectionChanged += (_, _) => Refresh(animate: false); + } + + public void SetValue(string? value) => Value = value; + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Refresh(animate: false); + } + + // Tabs drives its triggers and panels, so state never depends on when a child found its parent. + private void Refresh(bool animate) + { + foreach (var trigger in this.FindDescendantsOfType(e => e is Tabs)) + trigger.SetActive(trigger.Value == Value); + foreach (var content in this.FindDescendantsOfType(e => e is Tabs)) + content.SetActive(content.Value == Value, animate); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/TabsContent.cs b/examples/Avalonia.Demo/Components/UI/TabsContent.cs new file mode 100644 index 0000000..70ee663 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/TabsContent.cs @@ -0,0 +1,43 @@ +using System; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; + +namespace AvaloniaDemo.Components.UI; + +// Panel shown while its Value is the active tab; fades in on switch. +public class TabsContent : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public TabsContent() + { + IsVisible = false; + } + + internal void SetActive(bool active, bool animate) + { + if (IsVisible == active) return; + Transitions = null; + IsVisible = active; + if (!active || !animate) + { + Opacity = 1; + return; + } + Opacity = 0; + Transitions = new Transitions + { + new DoubleTransition { Property = OpacityProperty, Duration = TimeSpan.FromMilliseconds(150), Easing = new CubicEaseOut() } + }; + Opacity = 1; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/TabsList.cs b/examples/Avalonia.Demo/Components/UI/TabsList.cs new file mode 100644 index 0000000..42b2cda --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/TabsList.cs @@ -0,0 +1,25 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// Tab strip — inline-flex h-10 rounded-lg bg-muted p-1. +public class TabsList : Border +{ + private readonly StackPanel _stack = new() { Orientation = Orientation.Horizontal }; + + [Content] + public Controls Items => _stack.Children; + + public TabsList() + { + Child = _stack; + Height = 40; + Padding = new Thickness(4); + CornerRadius = new CornerRadius(ShellTheme.RadiusLg); + HorizontalAlignment = HorizontalAlignment.Left; + this.Token(BackgroundProperty, ShellToken.Muted); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/TabsTrigger.cs b/examples/Avalonia.Demo/Components/UI/TabsTrigger.cs new file mode 100644 index 0000000..15d9386 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/TabsTrigger.cs @@ -0,0 +1,103 @@ +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Tab button — rounded-md px-3 text-sm font-medium; active: bg-background text-foreground +// shadow-sm; inactive: text-muted-foreground. Left and Right select the neighbouring tab. +public class TabsTrigger : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + private readonly Border _pill; + private readonly TextBlock _label; + + static TabsTrigger() + { + TextProperty.Changed.AddClassHandler((t, _) => t._label.Text = t.Text ?? string.Empty); + IsEnabledProperty.Changed.AddClassHandler((t, _) => t.Opacity = t.IsEnabled ? 1.0 : 0.5); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public bool IsActive { get; private set; } + + public TabsTrigger() + { + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Center }; + // The ring and the active shadow are both box shadows, so each gets its own Border. + _pill = new Border { Child = _label, Padding = new Thickness(12, 0), CornerRadius = new CornerRadius(ShellTheme.RadiusMd) }; + Child = _pill; + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + Background = Brushes.Transparent; + Cursor = new Cursor(StandardCursorType.Hand); + ShellFocus.Ring(this, this); + SetActive(false); + } + + private void Select() => this.FindParentOfType()?.SetValue(Value); + + internal void SetActive(bool active) + { + IsActive = active; + _label.Token(TextBlock.ForegroundProperty, active ? ShellToken.Foreground : ShellToken.MutedForeground); + if (active) + { + _pill.Token(BackgroundProperty, ShellToken.Background); + _pill.BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 1, Blur = 2, Color = ShellTheme.Shadow(0.1) }); + } + else + { + _pill.ClearToken(BackgroundProperty); + _pill.Background = Brushes.Transparent; + _pill.BoxShadow = default; + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; + Select(); + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + switch (e.Key) + { + case Key.Enter or Key.Space: + Select(); + break; + case Key.Left or Key.Right when Parent is Panel strip: + var tabs = strip.Children.OfType().Where(t => t.IsEffectivelyEnabled).ToList(); + var index = tabs.IndexOf(this) + (e.Key == Key.Right ? 1 : -1); + if (index < 0 || index >= tabs.Count) return; + tabs[index].Focus(NavigationMethod.Directional); + tabs[index].Select(); + break; + default: + return; + } + e.Handled = true; + } +} diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml index 71466bd..187f4c0 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml +++ b/examples/Avalonia.Demo/MainWindow.axaml @@ -195,6 +195,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs index 27ba210..0cafe7d 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml.cs +++ b/examples/Avalonia.Demo/MainWindow.axaml.cs @@ -19,6 +19,17 @@ public MainWindow() CountrySelect.ItemsSource = new[] { "Argentina", "Brazil", "Japan", "Kenya", "Norway", "South Africa", "Zimbabwe" }; VolumeSlider.ValueChanged += (_, value) => VolumeText.Text = $"Volume {value:0}"; + foreach (var crumb in DemoBreadcrumb.Children.OfType()) + crumb.Clicked += (_, _) => NavChoice.Text = $"Went to {crumb.Text}."; + DemoAccordion.ItemToggled += (_, e) => NavChoice.Text = $"Section {e.Value} {(e.IsOpen ? "opened" : "closed")}."; + DemoTabs.ValueChanged += (_, e) => NavChoice.Text = $"Tab {e.Old} → {e.New}."; + for (var i = 1; i <= 30; i++) + { + var tag = new TextBlock { Text = $"v1.2.0-beta.{31 - i}", FontSize = 14 }; + tag.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + Tags.Children.Add(tag); + } + var clicks = 0; LoadingButton.Clicked += async (_, _) => { diff --git a/scripts/sync-templates.py b/scripts/sync-templates.py index 2d2b88d..eebda55 100644 --- a/scripts/sync-templates.py +++ b/scripts/sync-templates.py @@ -98,7 +98,7 @@ r"\bShellTriggerView\b|\bShellOverlayHost\b|\bShellPopoverHost\b|\bShellPortal\b|" r"\bShellPopup\w+\b|\bIShellFocusable\b|\bIShellPopup\b|\bIShellOverlayContent\b") ICON_API = re.compile(r"\bnew Icon\b|\bIconName\b") -EXT_API = re.compile(r"\bFindParentOfType\b|\bFindDescendantsOfType\b|\bAnimateExpandAsync\b") +EXT_API = re.compile(r"\bFindParentOfType\b|\bFindDescendantsOfType\b|\bAnimateExpandAsync\b|\bSetExpanded\b") # A block ends at the closing quote that is followed by the next entry or the end of the dictionary. MAUI_BLOCK = re.compile(r'(\[NativePlatform\.MAUI\] = @")(.*?)(\n"(?=,\n|\n \};))', re.S) diff --git a/src/ShellUI.Native.Templates/Templates/AccordionContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/AccordionContentTemplate.cs index 88f9ac6..57c5626 100644 --- a/src/ShellUI.Native.Templates/Templates/AccordionContentTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/AccordionContentTemplate.cs @@ -44,6 +44,28 @@ internal void Apply(bool open, bool animate) HeightRequest = -1; } } +", + [NativePlatform.Avalonia] = @"using Avalonia; +using Avalonia.Controls; + +namespace YourProjectNamespace.Components.UI; + +// Section body — pb-4 text-sm; expands/collapses its height with the item. +public class AccordionContent : Border +{ + public AccordionContent() + { + IsVisible = false; + Opacity = 0; + Padding = new Thickness(0, 0, 0, 16); + } + + internal void Apply(bool open, bool animate) + { + if (animate) _ = this.AnimateExpandAsync(open); + else this.SetExpanded(open); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/AccordionItemTemplate.cs b/src/ShellUI.Native.Templates/Templates/AccordionItemTemplate.cs index 5d91a6a..78adfd5 100644 --- a/src/ShellUI.Native.Templates/Templates/AccordionItemTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/AccordionItemTemplate.cs @@ -64,6 +64,57 @@ internal void ApplyOpen(bool open, bool animate) if (changed) OpenChanged?.Invoke(this, open); } } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Metadata; + +namespace YourProjectNamespace.Components.UI; + +// One section: trigger + content, with a bottom divider (border-b, none on the last item). +public class AccordionItem : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + private readonly StackPanel _stack = new(); + private readonly Border _divider = new() { Height = 1 }; + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + [Content] + public Controls Items => _stack.Children; + + public bool IsOpen { get; private set; } + + public event EventHandler? OpenChanged; + + public AccordionItem() + { + _divider.Token(BackgroundProperty, ShellToken.Border); + Child = new StackPanel { Children = { _stack, _divider } }; + } + + public void Toggle() => this.FindParentOfType()?.Toggle(Value ?? string.Empty); + + internal void SetDividerVisible(bool visible) => _divider.IsVisible = visible; + + internal void ApplyOpen(bool open, bool animate) + { + var changed = IsOpen != open; + IsOpen = open; + foreach (var trigger in this.FindDescendantsOfType(e => e is AccordionItem)) + trigger.SetOpen(open, animate); + foreach (var content in this.FindDescendantsOfType(e => e is AccordionItem)) + content.Apply(open, animate); + if (changed) OpenChanged?.Invoke(this, open); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/AccordionTemplate.cs b/src/ShellUI.Native.Templates/Templates/AccordionTemplate.cs index 751b77e..a58cfd1 100644 --- a/src/ShellUI.Native.Templates/Templates/AccordionTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/AccordionTemplate.cs @@ -110,6 +110,112 @@ private void Refresh(bool animate) } } } +", + [NativePlatform.Avalonia] = @"using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; + +namespace YourProjectNamespace.Components.UI; + +public enum AccordionType { Single, Multiple } + +// Stacked expandable sections with dividers and rotating chevrons. +// +// +// +// +// +// +public class Accordion : StackPanel +{ + public static readonly StyledProperty TypeProperty = + AvaloniaProperty.Register(nameof(Type)); + + // Initially open item (Single) — or comma-separated items (Multiple). + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + private readonly HashSet _openValues = new(); + + static Accordion() + { + ValueProperty.Changed.AddClassHandler((a, _) => a.OnValueChanged()); + } + + public AccordionType Type + { + get => GetValue(TypeProperty); + set => SetValue(TypeProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + // Fired when any item's open state changes: (value, isOpen). + public event EventHandler<(string Value, bool IsOpen)>? ItemToggled; + + public Accordion() + { + Children.CollectionChanged += (_, _) => Refresh(animate: false); + } + + public bool IsOpen(string value) => _openValues.Contains(value); + + public void Toggle(string value) + { + var wasOpen = _openValues.Contains(value); + var changed = new List<(string, bool)>(); + + if (Type == AccordionType.Single) + { + foreach (var other in _openValues.Where(v => v != value).ToList()) + { + _openValues.Remove(other); + changed.Add((other, false)); + } + } + + if (wasOpen) _openValues.Remove(value); + else _openValues.Add(value); + changed.Add((value, !wasOpen)); + + Refresh(animate: true); + foreach (var change in changed) ItemToggled?.Invoke(this, change); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Refresh(animate: false); + } + + private void OnValueChanged() + { + _openValues.Clear(); + foreach (var v in (Value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + _openValues.Add(v); + if (Type == AccordionType.Single) break; + } + Refresh(animate: IsLoaded); + } + + // The accordion drives its items, so state never depends on when a child found its parent. + private void Refresh(bool animate) + { + var items = this.FindDescendantsOfType(e => e is Accordion).ToList(); + for (var i = 0; i < items.Count; i++) + { + items[i].ApplyOpen(_openValues.Contains(items[i].Value ?? string.Empty), animate); + items[i].SetDividerVisible(i < items.Count - 1); + } + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/AccordionTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/AccordionTriggerTemplate.cs index 6ee935f..b26b640 100644 --- a/src/ShellUI.Native.Templates/Templates/AccordionTriggerTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/AccordionTriggerTemplate.cs @@ -91,6 +91,98 @@ private void UpdateBody() _row.Add(view, 0, 0); } } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Transformation; +using Avalonia.Metadata; + +namespace YourProjectNamespace.Components.UI; + +// Section header — flex justify-between py-4 font-medium hover:underline, with a chevron +// that rotates 180° when open. Use Text for a plain title, or put any control inside. +public class AccordionTrigger : ShellTriggerView +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty BodyProperty = + AvaloniaProperty.Register(nameof(Body)); + + private readonly Grid _row; + private readonly TextBlock _label; + private readonly Icon _chevron; + + static AccordionTrigger() + { + TextProperty.Changed.AddClassHandler((t, _) => t._label.Text = t.Text ?? string.Empty); + BodyProperty.Changed.AddClassHandler((t, e) => + { + t._row.Children.Remove((Control?)e.OldValue ?? t._label); + t._row.Children.Insert(0, t.Body ?? t._label); + }); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + [Content] + public Control? Body + { + get => GetValue(BodyProperty); + set => SetValue(BodyProperty, value); + } + + public AccordionTrigger() + { + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap }; + _label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + _chevron = new Icon { Kind = IconName.ChevronDown, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }; + Grid.SetColumn(_chevron, 1); + + _row = new Grid + { + ColumnDefinitions = new ColumnDefinitions(""*,Auto""), + ColumnSpacing = 16, + Margin = new Thickness(0, 16), + Children = { _label, _chevron } + }; + + Child = _row; + HorizontalAlignment = HorizontalAlignment.Stretch; + } + + protected override void OnActivated() => this.FindParentOfType()?.Toggle(); + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + _label.TextDecorations = TextDecorations.Underline; + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + _label.TextDecorations = null; + } + + internal void SetOpen(bool open, bool animate) + { + _chevron.Transitions = animate + ? new Transitions { new TransformOperationsTransition { Property = RenderTransformProperty, Duration = TimeSpan.FromMilliseconds(200), Easing = new CubicEaseOut() } } + : null; + _chevron.RenderTransform = TransformOperations.Parse(open ? ""rotate(180deg)"" : ""rotate(0deg)""); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/BreadcrumbItemTemplate.cs b/src/ShellUI.Native.Templates/Templates/BreadcrumbItemTemplate.cs index 7b18354..6504976 100644 --- a/src/ShellUI.Native.Templates/Templates/BreadcrumbItemTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/BreadcrumbItemTemplate.cs @@ -83,6 +83,90 @@ private void UpdateVisualState() IsCurrent || _hovered ? ShellToken.Foreground : ShellToken.MutedForeground); } } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace YourProjectNamespace.Components.UI; + +// Breadcrumb link — text-sm text-muted-foreground hover:text-foreground; the current page is +// text-foreground and not clickable. A chevron separator follows every item but the last. +public class BreadcrumbItem : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty IsCurrentProperty = + AvaloniaProperty.Register(nameof(IsCurrent)); + + private readonly Border _link; + private readonly TextBlock _text; + private readonly Icon _separator; + private bool _hovered; + + static BreadcrumbItem() + { + TextProperty.Changed.AddClassHandler((i, _) => i.UpdateVisualState()); + IsCurrentProperty.Changed.AddClassHandler((i, _) => i.UpdateVisualState()); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public bool IsCurrent + { + get => GetValue(IsCurrentProperty); + set => SetValue(IsCurrentProperty, value); + } + + public event EventHandler? Clicked; + + public BreadcrumbItem() + { + _text = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center }; + // Takes the hover, click and focus ring, so the separator stays inert. + _link = new Border { Child = _text, CornerRadius = new CornerRadius(ShellTheme.RadiusSm), Background = Brushes.Transparent }; + _link.PointerEntered += (_, _) => { _hovered = true; UpdateVisualState(); }; + _link.PointerExited += (_, _) => { _hovered = false; UpdateVisualState(); }; + _link.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left || IsCurrent) return; + Clicked?.Invoke(this, EventArgs.Empty); + e.Handled = true; + }; + + _separator = new Icon { Kind = IconName.ChevronRight, Size = 14, Token = ShellToken.MutedForeground, Margin = new Thickness(6, 0), VerticalAlignment = VerticalAlignment.Center }; + Child = new StackPanel { Orientation = Orientation.Horizontal, Children = { _link, _separator } }; + ShellFocus.Ring(this, _link); + UpdateVisualState(); + } + + public void SetSeparatorVisible(bool visible) => _separator.IsVisible = visible; + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key is not (Key.Enter or Key.Space) || IsCurrent) return; + Clicked?.Invoke(this, EventArgs.Empty); + e.Handled = true; + } + + private void UpdateVisualState() + { + _text.Text = Text ?? string.Empty; + _text.Token(TextBlock.ForegroundProperty, IsCurrent || _hovered ? ShellToken.Foreground : ShellToken.MutedForeground); + // The current page is a label, not a link: no tab stop and no hand cursor. + Focusable = !IsCurrent; + _link.Cursor = IsCurrent ? Cursor.Default : new Cursor(StandardCursorType.Hand); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/BreadcrumbTemplate.cs b/src/ShellUI.Native.Templates/Templates/BreadcrumbTemplate.cs index bfaba8a..4b69b8f 100644 --- a/src/ShellUI.Native.Templates/Templates/BreadcrumbTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/BreadcrumbTemplate.cs @@ -49,6 +49,33 @@ private void RefreshItemSeparators() items[i].SetSeparatorVisible(i < items.Count - 1); } } +", + [NativePlatform.Avalonia] = @"using System.Linq; +using Avalonia.Controls; +using Avalonia.Layout; + +namespace YourProjectNamespace.Components.UI; + +// Navigation trail — items separated by chevrons; the last item hides its separator. +// +// +// +// +public class Breadcrumb : StackPanel +{ + public Breadcrumb() + { + Orientation = Orientation.Horizontal; + Children.CollectionChanged += (_, _) => RefreshItemSeparators(); + } + + private void RefreshItemSeparators() + { + var items = Children.OfType().ToList(); + for (var i = 0; i < items.Count; i++) + items[i].SetSeparatorVisible(i < items.Count - 1); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/CollapsibleContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/CollapsibleContentTemplate.cs index 80d59ff..815f988 100644 --- a/src/ShellUI.Native.Templates/Templates/CollapsibleContentTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/CollapsibleContentTemplate.cs @@ -46,6 +46,26 @@ internal void Apply(bool open, bool animate) HeightRequest = -1; } } +", + [NativePlatform.Avalonia] = @"using Avalonia.Controls; + +namespace YourProjectNamespace.Components.UI; + +// Content shown while the enclosing Collapsible is open; expands/collapses its height. +public class CollapsibleContent : Border +{ + public CollapsibleContent() + { + IsVisible = false; + Opacity = 0; + } + + internal void Apply(bool open, bool animate) + { + if (animate) _ = this.AnimateExpandAsync(open); + else this.SetExpanded(open); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/CollapsibleTemplate.cs b/src/ShellUI.Native.Templates/Templates/CollapsibleTemplate.cs index 97b90ec..d62e109 100644 --- a/src/ShellUI.Native.Templates/Templates/CollapsibleTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/CollapsibleTemplate.cs @@ -64,6 +64,64 @@ private void Refresh(bool animate) content.Apply(Open, animate); } } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; + +namespace YourProjectNamespace.Components.UI; + +// Expand/collapse. The trigger can wrap a Button or any control; content animates its height. +// +// +// +// +public class Collapsible : StackPanel +{ + public static readonly StyledProperty OpenProperty = + AvaloniaProperty.Register(nameof(Open), defaultBindingMode: BindingMode.TwoWay); + + static Collapsible() + { + OpenProperty.Changed.AddClassHandler((c, _) => + { + c.Refresh(animate: c.IsLoaded); + c.OpenChanged?.Invoke(c, c.Open); + }); + } + + public bool Open + { + get => GetValue(OpenProperty); + set => SetValue(OpenProperty, value); + } + + public event EventHandler? OpenChanged; + + public Collapsible() + { + Spacing = 8; + Children.CollectionChanged += (_, _) => Refresh(animate: false); + } + + public void SetOpen(bool value) => Open = value; + + public void Toggle() => Open = !Open; + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Refresh(animate: false); + } + + // The collapsible drives its content, so state never depends on when a child found its parent. + private void Refresh(bool animate) + { + foreach (var content in this.FindDescendantsOfType(e => e is Collapsible)) + content.Apply(Open, animate); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/CollapsibleTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/CollapsibleTriggerTemplate.cs index 238d43c..1015765 100644 --- a/src/ShellUI.Native.Templates/Templates/CollapsibleTriggerTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/CollapsibleTriggerTemplate.cs @@ -26,6 +26,14 @@ public partial class CollapsibleTrigger : ShellTriggerView { protected override void OnActivated() => this.FindParentOfType()?.Toggle(); } +", + [NativePlatform.Avalonia] = @"namespace YourProjectNamespace.Components.UI; + +// Toggles the enclosing Collapsible. Wrap a Button or any control (a row, an icon, a label). +public class CollapsibleTrigger : ShellTriggerView +{ + protected override void OnActivated() => this.FindParentOfType()?.Toggle(); +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/ElementExtensionsTemplate.cs b/src/ShellUI.Native.Templates/Templates/ElementExtensionsTemplate.cs index c656e8b..617d01e 100644 --- a/src/ShellUI.Native.Templates/Templates/ElementExtensionsTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ElementExtensionsTemplate.cs @@ -101,8 +101,15 @@ public static Task AnimateExpandAsync(this View view, bool open, uint length = 2 ", [NativePlatform.Avalonia] = @"using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Layout; using Avalonia.LogicalTree; +using Avalonia.VisualTree; namespace YourProjectNamespace.Components.UI; @@ -130,6 +137,65 @@ public static IEnumerable FindDescendantsOfType(this ILogical root, Func Expanding = new(); + + // Grows the control from its current height to its natural height and fades it in, or the + // reverse, then hides it. Collapsible and accordion content. + public static async Task AnimateExpandAsync(this Control control, bool open, int milliseconds = 200) + { + var run = new object(); + Expanding.AddOrUpdate(control, run); + control.ClipToBounds = true; + control.Transitions = null; + + double start; + double target; + if (open) + { + start = control.IsVisible ? control.Bounds.Height : 0; + control.Height = double.NaN; + control.IsVisible = true; + // Hidden content has no width yet: measure it at its parent's. + var width = control.GetVisualParent() is Layoutable parent ? parent.Bounds.Width : double.PositiveInfinity; + control.Measure(new Size(width, double.PositiveInfinity)); + target = control.DesiredSize.Height - control.Margin.Top - control.Margin.Bottom; + } + else + { + if (!control.IsVisible) return; + start = control.Bounds.Height; + target = 0; + } + + control.Height = start; + var duration = TimeSpan.FromMilliseconds(milliseconds); + Easing easing = open ? new CubicEaseOut() : new CubicEaseIn(); + control.Transitions = new Transitions + { + new DoubleTransition { Property = Layoutable.HeightProperty, Duration = duration, Easing = easing }, + new DoubleTransition { Property = Visual.OpacityProperty, Duration = duration, Easing = easing } + }; + control.Height = target; + control.Opacity = open ? 1 : 0; + await Task.Delay(duration); + + if (!Expanding.TryGetValue(control, out var latest) || latest != run) return; + control.Transitions = null; + control.Height = double.NaN; + if (!open) control.IsVisible = false; + } + + // Jumps to the open or closed state without animating. + public static void SetExpanded(this Control control, bool open) + { + Expanding.Remove(control); + control.Transitions = null; + control.Height = double.NaN; + control.IsVisible = open; + control.Opacity = open ? 1 : 0; + } } " }; diff --git a/src/ShellUI.Native.Templates/Templates/ScrollAreaTemplate.cs b/src/ShellUI.Native.Templates/Templates/ScrollAreaTemplate.cs index 5b7a0b4..64fac79 100644 --- a/src/ShellUI.Native.Templates/Templates/ScrollAreaTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ScrollAreaTemplate.cs @@ -33,6 +33,27 @@ public ScrollArea() VerticalScrollBarVisibility = ScrollBarVisibility.Default; } } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; + +namespace YourProjectNamespace.Components.UI; + +// Scrollable region with Fluent's thin scrollbar, which widens on hover and hides when idle. +// For the shadcn look, wrap it in a 1px border with 6px corners: +// ... +public class ScrollArea : ScrollViewer +{ + // Keeps ScrollViewer's theme; a subclass would otherwise have none. + protected override Type StyleKeyOverride => typeof(ScrollViewer); + + public ScrollArea() + { + VerticalScrollBarVisibility = ScrollBarVisibility.Auto; + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/SkeletonTemplate.cs b/src/ShellUI.Native.Templates/Templates/SkeletonTemplate.cs index f0db19f..e7dbb7e 100644 --- a/src/ShellUI.Native.Templates/Templates/SkeletonTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/SkeletonTemplate.cs @@ -60,6 +60,59 @@ private void StartPulse() .Commit(this, ""pulse"", 16, 2000, Easing.Linear, repeat: () => true); } } +", + [NativePlatform.Avalonia] = @"using System; +using System.Threading; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Styling; + +namespace YourProjectNamespace.Components.UI; + +// Loading placeholder — rounded-md bg-muted animate-pulse (opacity 1 → 0.5 → 1 every 2s). +// Sized by its parent; set Width, Height (default 20) and CornerRadius for other shapes. +public class Skeleton : Border +{ + private static readonly Animation Pulse = new() + { + Duration = TimeSpan.FromSeconds(1), + IterationCount = IterationCount.Infinite, + PlaybackDirection = PlaybackDirection.Alternate, + Easing = new SineEaseInOut(), + Children = + { + new KeyFrame { Cue = new Cue(0), Setters = { new Setter(OpacityProperty, 1.0) } }, + new KeyFrame { Cue = new Cue(1), Setters = { new Setter(OpacityProperty, 0.5) } } + } + }; + + private CancellationTokenSource? _pulse; + + public Skeleton() + { + Height = 20; + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + this.Token(BackgroundProperty, ShellToken.Muted); + } + + // Only animates while on screen. + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _pulse?.Cancel(); + _pulse = new CancellationTokenSource(); + _ = Pulse.RunAsync(this, _pulse.Token); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _pulse?.Cancel(); + _pulse = null; + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/TabsContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/TabsContentTemplate.cs index 4e7db09..d64ff74 100644 --- a/src/ShellUI.Native.Templates/Templates/TabsContentTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TabsContentTemplate.cs @@ -52,6 +52,50 @@ internal void SetActive(bool active, bool animate) } } } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; + +namespace YourProjectNamespace.Components.UI; + +// Panel shown while its Value is the active tab; fades in on switch. +public class TabsContent : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public TabsContent() + { + IsVisible = false; + } + + internal void SetActive(bool active, bool animate) + { + if (IsVisible == active) return; + Transitions = null; + IsVisible = active; + if (!active || !animate) + { + Opacity = 1; + return; + } + Opacity = 0; + Transitions = new Transitions + { + new DoubleTransition { Property = OpacityProperty, Duration = TimeSpan.FromMilliseconds(150), Easing = new CubicEaseOut() } + }; + Opacity = 1; + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/TabsListTemplate.cs b/src/ShellUI.Native.Templates/Templates/TabsListTemplate.cs index 379f2ab..35fca0a 100644 --- a/src/ShellUI.Native.Templates/Templates/TabsListTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TabsListTemplate.cs @@ -47,6 +47,32 @@ public TabsList() HorizontalOptions = LayoutOptions.Start; } } +", + [NativePlatform.Avalonia] = @"using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Metadata; + +namespace YourProjectNamespace.Components.UI; + +// Tab strip — inline-flex h-10 rounded-lg bg-muted p-1. +public class TabsList : Border +{ + private readonly StackPanel _stack = new() { Orientation = Orientation.Horizontal }; + + [Content] + public Controls Items => _stack.Children; + + public TabsList() + { + Child = _stack; + Height = 40; + Padding = new Thickness(4); + CornerRadius = new CornerRadius(ShellTheme.RadiusLg); + HorizontalAlignment = HorizontalAlignment.Left; + this.Token(BackgroundProperty, ShellToken.Muted); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/TabsTemplate.cs b/src/ShellUI.Native.Templates/Templates/TabsTemplate.cs index f27b72d..6c2f9a0 100644 --- a/src/ShellUI.Native.Templates/Templates/TabsTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TabsTemplate.cs @@ -68,6 +68,69 @@ private void Refresh(bool animate) content.SetActive(content.Value == Value, animate); } } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; + +namespace YourProjectNamespace.Components.UI; + +// Usage: +// +// +// +// +// +// ... +// ... +// +public class Tabs : StackPanel +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay); + + static Tabs() + { + ValueProperty.Changed.AddClassHandler((t, e) => + { + t.Refresh(animate: t.IsLoaded); + t.ValueChanged?.Invoke(t, ((string?)e.OldValue ?? string.Empty, (string?)e.NewValue ?? string.Empty)); + }); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + // (oldValue, newValue) + public event EventHandler<(string Old, string New)>? ValueChanged; + + public Tabs() + { + Spacing = 8; + Children.CollectionChanged += (_, _) => Refresh(animate: false); + } + + public void SetValue(string? value) => Value = value; + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Refresh(animate: false); + } + + // Tabs drives its triggers and panels, so state never depends on when a child found its parent. + private void Refresh(bool animate) + { + foreach (var trigger in this.FindDescendantsOfType(e => e is Tabs)) + trigger.SetActive(trigger.Value == Value); + foreach (var content in this.FindDescendantsOfType(e => e is Tabs)) + content.SetActive(content.Value == Value, animate); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/TabsTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/TabsTriggerTemplate.cs index a7a7c54..132b427 100644 --- a/src/ShellUI.Native.Templates/Templates/TabsTriggerTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TabsTriggerTemplate.cs @@ -98,6 +98,110 @@ internal void SetActive(bool active) } } } +", + [NativePlatform.Avalonia] = @"using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace YourProjectNamespace.Components.UI; + +// Tab button — rounded-md px-3 text-sm font-medium; active: bg-background text-foreground +// shadow-sm; inactive: text-muted-foreground. Left and Right select the neighbouring tab. +public class TabsTrigger : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + private readonly Border _pill; + private readonly TextBlock _label; + + static TabsTrigger() + { + TextProperty.Changed.AddClassHandler((t, _) => t._label.Text = t.Text ?? string.Empty); + IsEnabledProperty.Changed.AddClassHandler((t, _) => t.Opacity = t.IsEnabled ? 1.0 : 0.5); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public bool IsActive { get; private set; } + + public TabsTrigger() + { + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Center }; + // The ring and the active shadow are both box shadows, so each gets its own Border. + _pill = new Border { Child = _label, Padding = new Thickness(12, 0), CornerRadius = new CornerRadius(ShellTheme.RadiusMd) }; + Child = _pill; + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + Background = Brushes.Transparent; + Cursor = new Cursor(StandardCursorType.Hand); + ShellFocus.Ring(this, this); + SetActive(false); + } + + private void Select() => this.FindParentOfType()?.SetValue(Value); + + internal void SetActive(bool active) + { + IsActive = active; + _label.Token(TextBlock.ForegroundProperty, active ? ShellToken.Foreground : ShellToken.MutedForeground); + if (active) + { + _pill.Token(BackgroundProperty, ShellToken.Background); + _pill.BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 1, Blur = 2, Color = ShellTheme.Shadow(0.1) }); + } + else + { + _pill.ClearToken(BackgroundProperty); + _pill.Background = Brushes.Transparent; + _pill.BoxShadow = default; + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; + Select(); + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + switch (e.Key) + { + case Key.Enter or Key.Space: + Select(); + break; + case Key.Left or Key.Right when Parent is Panel strip: + var tabs = strip.Children.OfType().Where(t => t.IsEffectivelyEnabled).ToList(); + var index = tabs.IndexOf(this) + (e.Key == Key.Right ? 1 : -1); + if (index < 0 || index >= tabs.Count) return; + tabs[index].Focus(NavigationMethod.Directional); + tabs[index].Select(); + break; + default: + return; + } + e.Handled = true; + } +} " }; } diff --git a/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs index 32938c5..1cec4d4 100644 --- a/tests/ShellUI.Native.Tests/TemplateContentTests.cs +++ b/tests/ShellUI.Native.Tests/TemplateContentTests.cs @@ -20,7 +20,7 @@ public static IEnumerable AvaloniaComponents() .Where(name => ComponentRegistry.SupportsPlatform(name, NativePlatform.Avalonia)) .Select(name => new object[] { name }); - // Ported so far: the Phase 2 foundation, P0, P1 and P2. Grows as components are ported. + // Ported so far: the Phase 2 foundation, P0, P1, P2 and P3. Grows as components are ported. [Theory] [InlineData("shell")] [InlineData("icon")] @@ -69,6 +69,21 @@ public static IEnumerable AvaloniaComponents() [InlineData("calendar")] [InlineData("date-picker")] [InlineData("time-picker")] + [InlineData("collapsible")] + [InlineData("collapsible-trigger")] + [InlineData("collapsible-content")] + [InlineData("accordion")] + [InlineData("accordion-item")] + [InlineData("accordion-trigger")] + [InlineData("accordion-content")] + [InlineData("tabs")] + [InlineData("tabs-list")] + [InlineData("tabs-trigger")] + [InlineData("tabs-content")] + [InlineData("breadcrumb")] + [InlineData("breadcrumb-item")] + [InlineData("skeleton")] + [InlineData("scroll-area")] public void Ported_components_have_Avalonia_content(string name) { Assert.False(string.IsNullOrWhiteSpace(ComponentRegistry.GetComponentContent(name, NativePlatform.Avalonia))); From de57867da3ad6f3951208881cf32d2c415ce0751 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Fri, 9 Oct 2026 10:00:52 +0200 Subject: [PATCH 2/6] docs: Avalonia P3 components and their differences from MAUI --- docs/COMPONENTS.md | 10 +++++++--- docs/DEVELOPMENT_PLAN.md | 13 ++++++++----- docs/README.md | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index ed15b90..ee87be9 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -69,8 +69,9 @@ your background so the splash-to-app transition doesn't flash the template's pur Ported so far: `shell`, `icon`, `theme-toggle`, P0 (`button`, `input`, `label`, `checkbox`, `switch`, `card` and its parts, `separator`, `badge`, `progress`, `alert`), P1 (`dialog`, -`drawer`, `sheet`, `dropdown`, `popover` and their parts) and P2 (`textarea`, `select`, `slider`, -`radio-group`, `calendar`, `date-picker`, `time-picker`). Not yet published to NuGet. In an +`drawer`, `sheet`, `dropdown`, `popover` and their parts), P2 (`textarea`, `select`, `slider`, +`radio-group`, `calendar`, `date-picker`, `time-picker`) and P3 (`collapsible`, `accordion`, +`tabs`, `breadcrumb` and their parts, `skeleton`, `scroll-area`). Not yet published to NuGet. In an 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 @@ -93,11 +94,14 @@ Same names, properties and variants as MAUI, except: | Card children | XAML children | XAML children, collected in `Card.Items` | | Block button | `HorizontalOptions="Fill"` | `HorizontalAlignment="Stretch"` | | Focus ring | Platform focus visuals | shadcn's ring, for keyboard focus only | -| Overlay children | XAML children | XAML children, collected in `Items` (`Dialog`, `DialogContent`, `Dropdown`, …) | +| Part children | XAML children | XAML children, collected in `Items` (`Dialog`, `DialogContent`, `Dropdown`, `AccordionItem`, `TabsList`, …) | | Dropdown / Popover | `ToggleAsync()` / `CloseAsync()` kept for older code | `Toggle()` / `Close()` only | | Flipped popup gap | 4px above the trigger | Flush with the trigger (Avalonia doesn't mirror the offset) | | Slider | Platform slider tinted with tokens | Custom-drawn shadcn slider; adds `Step` for the arrow keys; `ValueChanged` passes the new `double` | | DatePicker `DateChanged` | `DateChangedEventArgs` | The new `DateTime` | +| Tabs keyboard | Tab and Enter | Also Left / Right to select the neighbouring tab | +| Skeleton `CornerRadius` | `double` | Border's `CornerRadius` (`CornerRadius="24"` still works) | +| ScrollArea scrollbar | Platform scrollbar | Fluent's thin scrollbar, hidden while idle | --- diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index b88f185..de77280 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -35,9 +35,8 @@ main ← Phase 1a (2026-07-05), 1b (PR #2), 1c + 1d (PR #3), 1e (PR #4), brand | Branch | Base | Status | Purpose | |--------|------|--------|---------| -| `main` | — | Phase 1a–1e merged | 92 registry entries (89 CLI targets, 58 families), MAUI only | -| `chore/release-v0.1.0-alpha.1` | `main` | **Active** | Version `0.1.0-alpha.1`, fixed release workflow, release notes, docs refresh, per-component CLI check | -| `feat/avalonia-implementation` | `main` | Next | Phase 2 | +| `main` | — | Phase 1a–1e, release `0.1.0-alpha.1`, Phase 2 foundation and P0–P2 merged | 92 registry entries (89 CLI targets, 58 families); Avalonia templates for the foundation and P0–P2 | +| `feat/avalonia-p3-navigation` | `main` | **Active** | Phase 2 P3: Avalonia navigation and layout | --- @@ -418,7 +417,7 @@ and TableCell) rather than one template per part. --- -## Release `0.1.0-alpha.1` — `chore/release-v0.1.0-alpha.1` (active) +## Release `0.1.0-alpha.1` — `chore/release-v0.1.0-alpha.1` (**released**; tag `v0.1.0-alpha.1`) First NuGet prerelease of `ShellUI.Native.CLI`, MAUI only. @@ -435,7 +434,7 @@ After merge: tag `v0.1.0-alpha.1` on `main` and push the tag. --- -## Phase 2 — `feat/avalonia-implementation` (next, after `0.1.0-alpha.1`) +## Phase 2 — Avalonia (active; one branch per tier, `feat/avalonia-p3-navigation` now) Cross-desktop (Windows + macOS + Linux) from one XAML codebase. @@ -465,6 +464,10 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase. - [x] P2: textarea, select, slider (custom-drawn), radio-group, calendar, date-picker, time-picker. Pickers float their panel with `ShellAnchoredPopup` (shared with Dropdown and Popover); `ShellPlatform.StripNativeChrome` clears Fluent's TextBox chrome for Input and Textarea +- [x] P3: collapsible, accordion, tabs, breadcrumb (and their parts), skeleton, scroll-area. + Collapsible and accordion content animate their height with `AnimateExpandAsync` in + `element-extensions`, as on MAUI. Each installs alone into a fresh `dotnet new avalonia.app` and + builds with 0 warnings; checked in the demo in light and dark, including keyboard tab switching - [ ] 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 3c13a7e..dcaf482 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–P2 components; not on NuGet yet | .NET 10.0 (Avalonia 12) | +| Avalonia UI | In progress (Phase 2): theme, icons, P0–P3 components; not on NuGet yet | .NET 10.0 (Avalonia 12) | | WinUI 3 | Conditional (Phase 3) | .NET 10.0 | WPF is intentionally not on this list — it's recognized for project detection only, not an From be9057c9c87f59e3427894279fb02a12d2231230 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Fri, 9 Oct 2026 11:21:40 +0200 Subject: [PATCH 3/6] feat(avalonia): keep keyboard focus inside open dialogs, drawers and sheets Focus moves to the first control in the content, Tab and Shift+Tab cycle inside it, and on close focus returns to the control that opened it. The Dialog and Sheet close buttons are tab stops that close on Enter or Space. --- .../Components/UI/DialogContent.cs | 8 ++++++ .../Components/UI/SheetContent.cs | 8 ++++++ examples/Avalonia.Demo/Components/UI/Shell.cs | 25 ++++++++++++++++++- .../Templates/DialogContentTemplate.cs | 8 ++++++ .../Templates/SheetContentTemplate.cs | 8 ++++++ .../Templates/ShellTemplate.cs | 25 ++++++++++++++++++- 6 files changed, 80 insertions(+), 2 deletions(-) diff --git a/examples/Avalonia.Demo/Components/UI/DialogContent.cs b/examples/Avalonia.Demo/Components/UI/DialogContent.cs index 1eb34d4..248eed5 100644 --- a/examples/Avalonia.Demo/Components/UI/DialogContent.cs +++ b/examples/Avalonia.Demo/Components/UI/DialogContent.cs @@ -67,6 +67,14 @@ public DialogContent() this.FindParentOfType()?.SetOpen(false); e.Handled = true; }; + // A tab stop inside the focus trap, as in shadcn; Enter or Space closes. + ShellFocus.Ring(_close, _close); + _close.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + this.FindParentOfType()?.SetOpen(false); + e.Handled = true; + }; _box = new Border { diff --git a/examples/Avalonia.Demo/Components/UI/SheetContent.cs b/examples/Avalonia.Demo/Components/UI/SheetContent.cs index f67d0a8..7081a32 100644 --- a/examples/Avalonia.Demo/Components/UI/SheetContent.cs +++ b/examples/Avalonia.Demo/Components/UI/SheetContent.cs @@ -52,6 +52,14 @@ public SheetContent() this.FindParentOfType()?.SetOpen(false); e.Handled = true; }; + // A tab stop inside the focus trap, as in shadcn; Enter or Space closes. + ShellFocus.Ring(close, close); + close.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + this.FindParentOfType()?.SetOpen(false); + e.Handled = true; + }; _panel = new Border { diff --git a/examples/Avalonia.Demo/Components/UI/Shell.cs b/examples/Avalonia.Demo/Components/UI/Shell.cs index 3827e28..588b7ef 100644 --- a/examples/Avalonia.Demo/Components/UI/Shell.cs +++ b/examples/Avalonia.Demo/Components/UI/Shell.cs @@ -17,6 +17,7 @@ using Avalonia.Media.Transformation; using Avalonia.Metadata; using Avalonia.Styling; +using Avalonia.VisualTree; namespace AvaloniaDemo.Components.UI; @@ -496,13 +497,15 @@ public interface IShellOverlayContent /* Shared host for Dialog / Drawer / Sheet. Children: an optional *Trigger (rendered in place) and the *Content, which covers the window while Open, so the host can sit next to the button that - opens it. */ + opens it. While open, keyboard focus stays inside the content (Tab cycles through it); on close + it returns to the control that had it, usually the trigger. */ public abstract class ShellOverlayHost : ShellFloatingHost { public static readonly StyledProperty OpenProperty = AvaloniaProperty.Register(nameof(Open), defaultBindingMode: BindingMode.TwoWay); private TopLevel? _top; + private IInputElement? _returnFocus; private int _version; static ShellOverlayHost() @@ -549,11 +552,15 @@ private async void OnOpenChanged() } Fit(content); Popup.PlacementTarget = top; + _returnFocus = top.FocusManager?.GetFocusedElement(); Popup.IsOpen = true; + TrapFocus(content); if (content is IShellOverlayContent animated) await animated.AnimateAsync(true); } else { + if (_returnFocus is InputElement previous && TopLevel.GetTopLevel(previous) != null) previous.Focus(); + _returnFocus = null; if (content is IShellOverlayContent animated) await animated.AnimateAsync(false); if (version != _version) return; // reopened while closing Popup.IsOpen = false; @@ -562,6 +569,22 @@ private async void OnOpenChanged() } } + // Tab and Shift+Tab wrap inside the content, and focus starts on its first tab stop (the + // content itself when it has none), so nothing behind the backdrop can be reached. + private static void TrapFocus(Control content) + { + KeyboardNavigation.SetTabNavigation(content, KeyboardNavigationMode.Cycle); + var first = content.GetVisualDescendants().OfType().FirstOrDefault(e => + e.Focusable && e.IsEffectivelyEnabled && e.IsEffectivelyVisible && KeyboardNavigation.GetIsTabStop(e)); + if (first is null) + { + content.Focusable = true; + content.FocusAdorner = null; + first = content; + } + first.Focus(); + } + private void OnWindowSizeChanged(object? sender, SizeChangedEventArgs e) { if (FloatingContent is { } content) Fit(content); diff --git a/src/ShellUI.Native.Templates/Templates/DialogContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/DialogContentTemplate.cs index 8407f6e..bff6eea 100644 --- a/src/ShellUI.Native.Templates/Templates/DialogContentTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/DialogContentTemplate.cs @@ -188,6 +188,14 @@ public DialogContent() this.FindParentOfType()?.SetOpen(false); e.Handled = true; }; + // A tab stop inside the focus trap, as in shadcn; Enter or Space closes. + ShellFocus.Ring(_close, _close); + _close.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + this.FindParentOfType()?.SetOpen(false); + e.Handled = true; + }; _box = new Border { diff --git a/src/ShellUI.Native.Templates/Templates/SheetContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/SheetContentTemplate.cs index b5d325a..bc1757a 100644 --- a/src/ShellUI.Native.Templates/Templates/SheetContentTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/SheetContentTemplate.cs @@ -200,6 +200,14 @@ public SheetContent() this.FindParentOfType()?.SetOpen(false); e.Handled = true; }; + // A tab stop inside the focus trap, as in shadcn; Enter or Space closes. + ShellFocus.Ring(close, close); + close.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + this.FindParentOfType()?.SetOpen(false); + e.Handled = true; + }; _panel = new Border { diff --git a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs index 0f42ce5..249b85f 100644 --- a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs @@ -1079,6 +1079,7 @@ public static void ActivateAncestor(Element from) using Avalonia.Media.Transformation; using Avalonia.Metadata; using Avalonia.Styling; +using Avalonia.VisualTree; namespace YourProjectNamespace.Components.UI; @@ -1558,13 +1559,15 @@ public interface IShellOverlayContent /* Shared host for Dialog / Drawer / Sheet. Children: an optional *Trigger (rendered in place) and the *Content, which covers the window while Open, so the host can sit next to the button that - opens it. */ + opens it. While open, keyboard focus stays inside the content (Tab cycles through it); on close + it returns to the control that had it, usually the trigger. */ public abstract class ShellOverlayHost : ShellFloatingHost { public static readonly StyledProperty OpenProperty = AvaloniaProperty.Register(nameof(Open), defaultBindingMode: BindingMode.TwoWay); private TopLevel? _top; + private IInputElement? _returnFocus; private int _version; static ShellOverlayHost() @@ -1611,11 +1614,15 @@ private async void OnOpenChanged() } Fit(content); Popup.PlacementTarget = top; + _returnFocus = top.FocusManager?.GetFocusedElement(); Popup.IsOpen = true; + TrapFocus(content); if (content is IShellOverlayContent animated) await animated.AnimateAsync(true); } else { + if (_returnFocus is InputElement previous && TopLevel.GetTopLevel(previous) != null) previous.Focus(); + _returnFocus = null; if (content is IShellOverlayContent animated) await animated.AnimateAsync(false); if (version != _version) return; // reopened while closing Popup.IsOpen = false; @@ -1624,6 +1631,22 @@ private async void OnOpenChanged() } } + // Tab and Shift+Tab wrap inside the content, and focus starts on its first tab stop (the + // content itself when it has none), so nothing behind the backdrop can be reached. + private static void TrapFocus(Control content) + { + KeyboardNavigation.SetTabNavigation(content, KeyboardNavigationMode.Cycle); + var first = content.GetVisualDescendants().OfType().FirstOrDefault(e => + e.Focusable && e.IsEffectivelyEnabled && e.IsEffectivelyVisible && KeyboardNavigation.GetIsTabStop(e)); + if (first is null) + { + content.Focusable = true; + content.FocusAdorner = null; + first = content; + } + first.Focus(); + } + private void OnWindowSizeChanged(object? sender, SizeChangedEventArgs e) { if (FloatingContent is { } content) Fit(content); From 0e3fbb1c8e61db841f9376a9aa2f4878aa036b0b Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Fri, 9 Oct 2026 11:23:47 +0200 Subject: [PATCH 4/6] fix(avalonia): keep the 4px gap when a floating panel flips above its trigger Avalonia flips a panel that doesn't fit below but keeps the offset's sign, which pushed it onto the trigger. The offset is now mirrored once the flip is known. Applies to Dropdown, Popover, Select, DatePicker and TimePicker. --- examples/Avalonia.Demo/Components/UI/Shell.cs | 14 ++++++++++++-- .../Templates/ShellTemplate.cs | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/examples/Avalonia.Demo/Components/UI/Shell.cs b/examples/Avalonia.Demo/Components/UI/Shell.cs index 588b7ef..085e5a9 100644 --- a/examples/Avalonia.Demo/Components/UI/Shell.cs +++ b/examples/Avalonia.Demo/Components/UI/Shell.cs @@ -17,6 +17,7 @@ using Avalonia.Media.Transformation; using Avalonia.Metadata; using Avalonia.Styling; +using Avalonia.Threading; using Avalonia.VisualTree; namespace AvaloniaDemo.Components.UI; @@ -393,6 +394,9 @@ public sealed class ShellAnchoredPopup : IShellPopup public bool IsOpen => _open; + // Space between the anchor and the panel, on whichever side the panel opens. + public double Gap { get; set; } = 4; + // Raised when it closes for any reason: Close(), a click outside, Escape or another menu opening. public event EventHandler? Closed; @@ -401,7 +405,6 @@ public ShellAnchoredPopup(Control anchor, Popup? popup = null) Popup = popup ?? new Popup { ShouldUseOverlayLayer = true }; Popup.PlacementTarget = anchor; Popup.Placement = PlacementMode.BottomEdgeAlignedLeft; - Popup.VerticalOffset = 4; Popup.IsLightDismissEnabled = true; // The click that dismisses (even on the trigger) does nothing else, so it can't reopen. Popup.OverlayDismissEventPassThrough = false; @@ -419,7 +422,14 @@ public async void Open() ShellDismiss.Push(this, Close, anchor); panel.RenderTransformOrigin = new RelativePoint(0.5, 0, RelativeUnit.Relative); ShellMotion.Set(panel, 0, "scale(0.95)"); + Popup.VerticalOffset = Gap; Popup.IsOpen = true; + // Avalonia flips a panel that doesn't fit below to above the anchor but keeps the offset's + // sign, which pushes it onto the anchor; mirror the offset once the flip is known. + Dispatcher.UIThread.Post(() => + { + if (_open && panel.TranslatePoint(default, anchor) is { Y: < 0 }) Popup.VerticalOffset = -Gap; + }, DispatcherPriority.Loaded); await ShellMotion.To(panel, 1, "scale(1)", 120, new CubicEaseOut()); } @@ -484,7 +494,7 @@ private void OnOpenChanged() } if (FloatingContent is null) return; Popup.Placement = Placement; - Popup.VerticalOffset = Offset; + _anchored.Gap = Offset; _anchored.Open(); } } diff --git a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs index 249b85f..e419b50 100644 --- a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs @@ -1079,6 +1079,7 @@ public static void ActivateAncestor(Element from) using Avalonia.Media.Transformation; using Avalonia.Metadata; using Avalonia.Styling; +using Avalonia.Threading; using Avalonia.VisualTree; namespace YourProjectNamespace.Components.UI; @@ -1455,6 +1456,9 @@ public sealed class ShellAnchoredPopup : IShellPopup public bool IsOpen => _open; + // Space between the anchor and the panel, on whichever side the panel opens. + public double Gap { get; set; } = 4; + // Raised when it closes for any reason: Close(), a click outside, Escape or another menu opening. public event EventHandler? Closed; @@ -1463,7 +1467,6 @@ public ShellAnchoredPopup(Control anchor, Popup? popup = null) Popup = popup ?? new Popup { ShouldUseOverlayLayer = true }; Popup.PlacementTarget = anchor; Popup.Placement = PlacementMode.BottomEdgeAlignedLeft; - Popup.VerticalOffset = 4; Popup.IsLightDismissEnabled = true; // The click that dismisses (even on the trigger) does nothing else, so it can't reopen. Popup.OverlayDismissEventPassThrough = false; @@ -1481,7 +1484,14 @@ public async void Open() ShellDismiss.Push(this, Close, anchor); panel.RenderTransformOrigin = new RelativePoint(0.5, 0, RelativeUnit.Relative); ShellMotion.Set(panel, 0, ""scale(0.95)""); + Popup.VerticalOffset = Gap; Popup.IsOpen = true; + // Avalonia flips a panel that doesn't fit below to above the anchor but keeps the offset's + // sign, which pushes it onto the anchor; mirror the offset once the flip is known. + Dispatcher.UIThread.Post(() => + { + if (_open && panel.TranslatePoint(default, anchor) is { Y: < 0 }) Popup.VerticalOffset = -Gap; + }, DispatcherPriority.Loaded); await ShellMotion.To(panel, 1, ""scale(1)"", 120, new CubicEaseOut()); } @@ -1546,7 +1556,7 @@ private void OnOpenChanged() } if (FloatingContent is null) return; Popup.Placement = Placement; - Popup.VerticalOffset = Offset; + _anchored.Gap = Offset; _anchored.Open(); } } From 93e050b0feb2ec718c5ed409ba87ff43d8ed5f78 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Fri, 9 Oct 2026 11:23:49 +0200 Subject: [PATCH 5/6] feat(demo): use the ShellUI Native tile as the app icon on every platform Same tile as the favicon (assets/icon.svg). Android gets an adaptive icon with the grid inside the launcher's safe zone; iOS fills the rounded corners because its icons must be opaque. The Avalonia demo gets an .ico for the exe and the window. --- examples/Avalonia.Demo/Assets/icon.ico | Bin 0 -> 12408 bytes examples/Avalonia.Demo/AvaloniaDemo.csproj | 6 ++++++ examples/Avalonia.Demo/MainWindow.axaml | 1 + examples/MAUI.Demo/MAUI.Demo.csproj | 8 ++++++-- .../Resources/AppIcon/Android/appicon.svg | 1 + .../Resources/AppIcon/Android/appiconfg.svg | 1 + .../MAUI.Demo/Resources/AppIcon/appicon.svg | 2 +- .../MAUI.Demo/Resources/AppIcon/appiconfg.svg | 1 - 8 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 examples/Avalonia.Demo/Assets/icon.ico create mode 100644 examples/MAUI.Demo/Resources/AppIcon/Android/appicon.svg create mode 100644 examples/MAUI.Demo/Resources/AppIcon/Android/appiconfg.svg delete mode 100644 examples/MAUI.Demo/Resources/AppIcon/appiconfg.svg diff --git a/examples/Avalonia.Demo/Assets/icon.ico b/examples/Avalonia.Demo/Assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..15411a825302f0b78a715ddc99615e79f58c2f33 GIT binary patch literal 12408 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 disable app.manifest + + Assets\icon.ico + + + + diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml index 187f4c0..ae876ae 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml +++ b/examples/Avalonia.Demo/MainWindow.axaml @@ -3,6 +3,7 @@ xmlns:ui="using:AvaloniaDemo.Components.UI" x:Class="AvaloniaDemo.MainWindow" Title="ShellUI Native (Avalonia)" + Icon="/Assets/icon.ico" Width="1000" Height="760" Background="{DynamicResource ShellUIBackgroundBrush}"> diff --git a/examples/MAUI.Demo/MAUI.Demo.csproj b/examples/MAUI.Demo/MAUI.Demo.csproj index 9b0b63e..27bd530 100644 --- a/examples/MAUI.Demo/MAUI.Demo.csproj +++ b/examples/MAUI.Demo/MAUI.Demo.csproj @@ -44,8 +44,12 @@ - - + + + + diff --git a/examples/MAUI.Demo/Resources/AppIcon/Android/appicon.svg b/examples/MAUI.Demo/Resources/AppIcon/Android/appicon.svg new file mode 100644 index 0000000..9c34848 --- /dev/null +++ b/examples/MAUI.Demo/Resources/AppIcon/Android/appicon.svg @@ -0,0 +1 @@ + diff --git a/examples/MAUI.Demo/Resources/AppIcon/Android/appiconfg.svg b/examples/MAUI.Demo/Resources/AppIcon/Android/appiconfg.svg new file mode 100644 index 0000000..1329a3b --- /dev/null +++ b/examples/MAUI.Demo/Resources/AppIcon/Android/appiconfg.svg @@ -0,0 +1 @@ + diff --git a/examples/MAUI.Demo/Resources/AppIcon/appicon.svg b/examples/MAUI.Demo/Resources/AppIcon/appicon.svg index 9c34848..747d213 100644 --- a/examples/MAUI.Demo/Resources/AppIcon/appicon.svg +++ b/examples/MAUI.Demo/Resources/AppIcon/appicon.svg @@ -1 +1 @@ - + diff --git a/examples/MAUI.Demo/Resources/AppIcon/appiconfg.svg b/examples/MAUI.Demo/Resources/AppIcon/appiconfg.svg deleted file mode 100644 index efbdca4..0000000 --- a/examples/MAUI.Demo/Resources/AppIcon/appiconfg.svg +++ /dev/null @@ -1 +0,0 @@ - From 6b30a3f402a3a40c7a53d3da5cf55b0ac2be6f22 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Fri, 9 Oct 2026 11:23:50 +0200 Subject: [PATCH 6/6] docs: Avalonia focus trap, flipped popup gap and demo icons --- docs/COMPONENTS.md | 6 +++--- docs/DEVELOPMENT_PLAN.md | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index ee87be9..9de62b3 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -77,8 +77,9 @@ Avalonia project, `shellui-native list` shows only these. Overlays use Avalonia's `Popup` in the window's overlay layer: the content stays in the logical tree (theme, DataContext and `FindParentOfType` work as declared), dropdowns and popovers get Avalonia's placement and flip above the trigger when there is no room below, and a click outside -closes them. Escape closes the overlay on top. Keyboard focus is not trapped inside an open -dialog yet: Tab can still reach controls behind it. +closes them. Escape closes the overlay on top. An open dialog, drawer or sheet keeps keyboard +focus inside it: focus moves to its first control, Tab and Shift+Tab wrap around, and on close +focus returns to the control that opened it. Setup after `shellui-native init`: call `Components.UI.ShellTheme.EnsureInitialized();` in `App.Initialize` after `AvaloniaXamlLoader.Load(this);`, and add @@ -96,7 +97,6 @@ Same names, properties and variants as MAUI, except: | Focus ring | Platform focus visuals | shadcn's ring, for keyboard focus only | | Part children | XAML children | XAML children, collected in `Items` (`Dialog`, `DialogContent`, `Dropdown`, `AccordionItem`, `TabsList`, …) | | Dropdown / Popover | `ToggleAsync()` / `CloseAsync()` kept for older code | `Toggle()` / `Close()` only | -| Flipped popup gap | 4px above the trigger | Flush with the trigger (Avalonia doesn't mirror the offset) | | Slider | Platform slider tinted with tokens | Custom-drawn shadcn slider; adds `Step` for the arrow keys; `ValueChanged` passes the new `double` | | DatePicker `DateChanged` | `DateChangedEventArgs` | The new `DateTime` | | Tabs keyboard | Tab and Enter | Also Left / Right to select the neighbouring tab | diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index de77280..910d241 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -460,14 +460,18 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase. overlay layer (shared hosts in `shell`: `ShellOverlayHost`, `ShellPopoverHost`, `ShellTriggerView`, `ShellDismiss` for Escape). Each installs alone into a fresh `dotnet new avalonia.app`; checked in the demo: opening from triggers, placement and flip, - Escape, picking an item, `DialogClose`, light and dark. Open: focus trapping in dialogs + Escape, picking an item, `DialogClose`, light and dark. Focus trap added on the P3 branch: focus + moves into the content, Tab cycles inside it and returns to the trigger on close (checked with + real key presses); the Dialog and Sheet close buttons are tab stops - [x] P2: textarea, select, slider (custom-drawn), radio-group, calendar, date-picker, time-picker. Pickers float their panel with `ShellAnchoredPopup` (shared with Dropdown and Popover); `ShellPlatform.StripNativeChrome` clears Fluent's TextBox chrome for Input and Textarea - [x] P3: collapsible, accordion, tabs, breadcrumb (and their parts), skeleton, scroll-area. Collapsible and accordion content animate their height with `AnimateExpandAsync` in `element-extensions`, as on MAUI. Each installs alone into a fresh `dotnet new avalonia.app` and - builds with 0 warnings; checked in the demo in light and dark, including keyboard tab switching + builds with 0 warnings; checked in the demo in light and dark, including keyboard tab switching. + Also on this branch: floating panels keep their 4px gap when they flip above the trigger, and + both demos use the ShellUI Native tile as their app icon (Android: adaptive, grid in the safe zone) - [ ] 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`