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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions docs/COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |

---

Expand Down
19 changes: 13 additions & 6 deletions docs/DEVELOPMENT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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.

Expand All @@ -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.

Expand All @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ shellui-native list
| Platform | Status | .NET Version |
|----------|--------|--------------|
| .NET MAUI | Available (`0.1.0-alpha.1`) — Android, iOS, Mac Catalyst, Windows | .NET 10.0 |
| Avalonia UI | In progress (Phase 2): theme, icons, P0–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
Expand Down
Binary file added examples/Avalonia.Demo/Assets/icon.ico
Binary file not shown.
6 changes: 6 additions & 0 deletions examples/Avalonia.Demo/AvaloniaDemo.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@
<!-- Off, as in `dotnet new avalonia.app`, so a component missing a using fails here first. -->
<ImplicitUsings>disable</ImplicitUsings>
<ApplicationManifest>app.manifest</ApplicationManifest>
<!-- The ShellUI Native tile (assets/icon.svg) for the exe and the window. -->
<ApplicationIcon>Assets\icon.ico</ApplicationIcon>
</PropertyGroup>

<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.3" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.3" />
Expand Down
105 changes: 105 additions & 0 deletions examples/Avalonia.Demo/Components/UI/Accordion.cs
Original file line number Diff line number Diff line change
@@ -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.
// <ui:Accordion Type="Single" Value="a">
// <ui:AccordionItem Value="a">
// <ui:AccordionTrigger Text="Is it accessible?" />
// <ui:AccordionContent><TextBlock Text="Yes." /></ui:AccordionContent>
// </ui:AccordionItem>
// </ui:Accordion>
public class Accordion : StackPanel
{
public static readonly StyledProperty<AccordionType> TypeProperty =
AvaloniaProperty.Register<Accordion, AccordionType>(nameof(Type));

// Initially open item (Single) — or comma-separated items (Multiple).
public static readonly StyledProperty<string?> ValueProperty =
AvaloniaProperty.Register<Accordion, string?>(nameof(Value));

private readonly HashSet<string> _openValues = new();

static Accordion()
{
ValueProperty.Changed.AddClassHandler<Accordion>((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<AccordionItem>(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);
}
}
}
21 changes: 21 additions & 0 deletions examples/Avalonia.Demo/Components/UI/AccordionContent.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
50 changes: 50 additions & 0 deletions examples/Avalonia.Demo/Components/UI/AccordionItem.cs
Original file line number Diff line number Diff line change
@@ -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<string?> ValueProperty =
AvaloniaProperty.Register<AccordionItem, string?>(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<bool>? OpenChanged;

public AccordionItem()
{
_divider.Token(BackgroundProperty, ShellToken.Border);
Child = new StackPanel { Children = { _stack, _divider } };
}

public void Toggle() => this.FindParentOfType<Accordion>()?.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<AccordionTrigger>(e => e is AccordionItem))
trigger.SetOpen(open, animate);
foreach (var content in this.FindDescendantsOfType<AccordionContent>(e => e is AccordionItem))
content.Apply(open, animate);
if (changed) OpenChanged?.Invoke(this, open);
}
}
91 changes: 91 additions & 0 deletions examples/Avalonia.Demo/Components/UI/AccordionTrigger.cs
Original file line number Diff line number Diff line change
@@ -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<string?> TextProperty =
AvaloniaProperty.Register<AccordionTrigger, string?>(nameof(Text));

public static readonly StyledProperty<Control?> BodyProperty =
AvaloniaProperty.Register<AccordionTrigger, Control?>(nameof(Body));

private readonly Grid _row;
private readonly TextBlock _label;
private readonly Icon _chevron;

static AccordionTrigger()
{
TextProperty.Changed.AddClassHandler<AccordionTrigger>((t, _) => t._label.Text = t.Text ?? string.Empty);
BodyProperty.Changed.AddClassHandler<AccordionTrigger>((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<AccordionItem>()?.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)");
}
}
Loading
Loading