diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md
index ed15b90..9de62b3 100644
--- a/docs/COMPONENTS.md
+++ b/docs/COMPONENTS.md
@@ -69,15 +69,17 @@ 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
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
@@ -93,11 +95,13 @@ 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..910d241 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.
@@ -461,10 +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.
+ 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`
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
diff --git a/examples/Avalonia.Demo/Assets/icon.ico b/examples/Avalonia.Demo/Assets/icon.ico
new file mode 100644
index 0000000..15411a8
Binary files /dev/null and b/examples/Avalonia.Demo/Assets/icon.ico differ
diff --git a/examples/Avalonia.Demo/AvaloniaDemo.csproj b/examples/Avalonia.Demo/AvaloniaDemo.csproj
index fa533e9..387f97a 100644
--- a/examples/Avalonia.Demo/AvaloniaDemo.csproj
+++ b/examples/Avalonia.Demo/AvaloniaDemo.csproj
@@ -6,8 +6,14 @@
disable
app.manifest
+
+ Assets\icon.ico
+
+
+
+
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/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