diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md
index 6da1126..788600b 100644
--- a/docs/COMPONENTS.md
+++ b/docs/COMPONENTS.md
@@ -65,16 +65,10 @@ your background so the splash-to-app transition doesn't flash the template's pur
---
-## Avalonia (in progress)
+## Avalonia
-Ported so far: `shell`, `icon`, `theme-toggle`, P0 (`button`, `input`, `label`, `checkbox`,
-`switch`, `card` and its parts, `separator`, `badge`, `progress`, `alert`), P1 (`dialog`,
-`drawer`, `sheet`, `dropdown`, `popover` and their parts), P2 (`textarea`, `select`, `slider`,
-`radio-group`, `calendar`, `date-picker`, `time-picker`), P3 (`collapsible`, `accordion`,
-`tabs`, `breadcrumb` and their parts, `skeleton`, `scroll-area`), P4 (`spinner`, `tooltip`,
-`toast`, `alert-dialog`, `hover-card` and its parts), P5 (`avatar`, `toggle`, `callout`,
-`table`, `empty-state`, `pagination`) and P6 (`context-menu`, `carousel`, `stepper`). Not yet
-published to NuGet. In an Avalonia project, `shellui-native list` shows only these.
+Every component on this page has an Avalonia version, installed by the same CLI commands. The
+Avalonia templates are not on NuGet yet: they ship with the next release.
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
@@ -88,6 +82,10 @@ Setup after `shellui-native init`: call `Components.UI.ShellTheme.EnsureInitiali
`xmlns:ui="using:YourApp.Components.UI"` to your XAML. Switch theme with
`ShellTheme.SetTheme(ThemeVariant.Dark)` or `ShellTheme.ToggleTheme()`.
+Some names match Avalonia's own controls (`Button`, `Calendar`, `Carousel`, `ContextMenu`,
+`TreeView`, …). In XAML the `ui:` prefix picks the ShellUI one. In C# that imports both
+namespaces, qualify the name or add an alias: `using UI = YourApp.Components.UI;`.
+
Same names, properties and variants as MAUI, except:
| | MAUI | Avalonia |
@@ -114,6 +112,17 @@ Same names, properties and variants as MAUI, except:
| Carousel | Swipe, arrows, dots | Drag with the mouse or touch; the arrows are tab stops and Left / Right move while one has focus; with `AspectRatio="0"` set `Height` |
| Stepper | Shows the active step's view | Every step stays in the tree and only the active one is visible; reached steps are tab stops (Enter / Space) |
| Pagination | Labels hidden on phones | `ShowLabels` defaults to true; every page and arrow is a tab stop (Enter / Space) |
+| ToggleGroup | Items toggle on tap | Items are also tab stops (Enter / Space); children are collected in `Items` |
+| NumberInput | Numeric keyboard | Up / Down step the value while the field has focus |
+| TagInput | Enter, comma or semicolon adds a tag | Backspace in the empty field also removes the last tag |
+| InputOtp | Hidden entry with the numeric keyboard | Invisible text box over the slots; `Focus()` focuses it |
+| Combobox / MultiSelect | Type to filter, Enter takes the first match | Up / Down move a highlight and Enter takes it; the search field has focus as soon as the panel opens; `Value` is `string?` |
+| TreeView | Rows are tab stops | Also Right to expand and Left to collapse |
+| TimelineItem content | One `Body` view | One child control, also reachable as `Body` |
+| CopyButton | `Clipboard.Default` | The window's clipboard; does nothing until the button is in a window |
+| LinkCard `Url` | `Launcher.Default` | The window's `Launcher` |
+| AspectRatio | `ContentView` | A `Border`, so `CornerRadius` rounds the content |
+| WrapLayout | Custom layout with `Padding` | A `Panel` (no `Padding`); Avalonia's own `WrapPanel` covers the plain case, `LastChildFill` is what this adds |
---
diff --git a/docs/COMPONENTS_ROADMAP.md b/docs/COMPONENTS_ROADMAP.md
index 86b4bfd..44070df 100644
--- a/docs/COMPONENTS_ROADMAP.md
+++ b/docs/COMPONENTS_ROADMAP.md
@@ -4,7 +4,7 @@ Prioritized list of components to create for ShellUI Native, aligned with [Shell
Last revised: **2026-10-07** (first prerelease, `0.1.0-alpha.1`).
-**Status at a glance:** P0–P5 ✅ done · P6 partly done (resizable, navbar, sidebar open) · P7 partly done · shipped as `0.1.0-alpha.1` (58 families, 89 CLI targets) · **Avalonia (Phase 2) ⏭️ next**.
+**Status at a glance:** P0–P5 ✅ done · P6 partly done (resizable, navbar, sidebar open) · P7 partly done · shipped as `0.1.0-alpha.1` (58 families, 89 CLI targets) · **Avalonia (Phase 2): every component ported, not released yet**.
---
diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md
index 4d3a322..5503aea 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, release `0.1.0-alpha.1`, Phase 2 foundation and P0–P4 merged | 92 registry entries (89 CLI targets, 58 families); Avalonia templates for the foundation and P0–P4 |
-| `feat/avalonia-p5-data-display` | `main` | Pushed, PR next | Phase 2 P5: Avalonia data display |
-| `feat/avalonia-p6-advanced` | `feat/avalonia-p5-data-display` | **Active** | Phase 2 P6: Avalonia context menu, carousel and stepper |
+| `main` | — | Phase 1a–1e, release `0.1.0-alpha.1`, Phase 2 foundation and P0–P6 merged | 92 registry entries (89 CLI targets, 58 families); Avalonia templates for the foundation and P0–P6 |
+| `feat/avalonia-p7-remaining` | `main` | **Active** | Phase 2: the 14 components that were still MAUI-only |
---
@@ -435,7 +434,7 @@ After merge: tag `v0.1.0-alpha.1` on `main` and push the tag.
---
-## Phase 2 — Avalonia (active; one branch per tier, `feat/avalonia-p6-advanced` now)
+## Phase 2 — Avalonia (active; one branch per tier, `feat/avalonia-p7-remaining` now)
Cross-desktop (Windows + macOS + Linux) from one XAML codebase.
@@ -490,6 +489,13 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase.
animation frames. Each installs alone into a fresh Avalonia app and builds with 0 warnings;
checked in the demo in light and dark with real pointer and key input. resizable, navbar and
sidebar wait for their MAUI versions
+- [x] The rest (P7 and the P5 leftovers): toggle-group, number-input, tag-input, input-otp,
+ combobox, multi-select, kbd, copy-button, stat-card, link-card, tree-view, timeline,
+ aspect-ratio, wrap-layout. Every registry entry now has Avalonia content. Combobox and
+ multi-select float their panel with `ShellAnchoredPopup` and add an Up / Down highlight;
+ wrap-layout is a `Panel` with the MAUI layout's `LastChildFill`. Each installs alone into a
+ fresh Avalonia app and builds with 0 warnings; checked in the demo in light and dark with
+ pointer and key input
- [ ] 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 5df1f32..f622a80 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–P5 components and P6's context menu, carousel and stepper; not on NuGet yet | .NET 10.0 (Avalonia 12) |
+| Avalonia UI | Every component ported (Phase 2); not on NuGet yet, ships with the next release | .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/Components/UI/AspectRatio.cs b/examples/Avalonia.Demo/Components/UI/AspectRatio.cs
new file mode 100644
index 0000000..e0d9c7d
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/AspectRatio.cs
@@ -0,0 +1,42 @@
+using Avalonia;
+using Avalonia.Controls;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Keeps its content at a fixed width / height ratio: the height follows the width.
+//
+//
+//
+public class AspectRatio : Border
+{
+ // Width divided by height. 16:9 is 1.7778, 4:3 is 1.3333, a square is 1.
+ public static readonly StyledProperty RatioProperty =
+ AvaloniaProperty.Register(nameof(Ratio), 16d / 9d);
+
+ static AspectRatio()
+ {
+ AffectsMeasure(RatioProperty);
+ }
+
+ public double Ratio
+ {
+ get => GetValue(RatioProperty);
+ set => SetValue(RatioProperty, value);
+ }
+
+ public AspectRatio()
+ {
+ // With a CornerRadius the content is clipped to the rounded shape.
+ ClipToBounds = true;
+ }
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ if (Ratio <= 0) return base.MeasureOverride(availableSize);
+ // Without a width to fill (e.g. in a horizontal stack) it takes the content's width.
+ var width = double.IsInfinity(availableSize.Width) ? base.MeasureOverride(availableSize).Width : availableSize.Width;
+ var size = new Size(width, width / Ratio);
+ base.MeasureOverride(size);
+ return size;
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Combobox.cs b/examples/Avalonia.Demo/Components/UI/Combobox.cs
new file mode 100644
index 0000000..ee93735
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Combobox.cs
@@ -0,0 +1,317 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Combobox — a Select with a search field: h-10 trigger, and a floating panel with a filter
+// input above the option list (check on the selected option, "no results" when nothing matches).
+// Type to filter, Up / Down to move, Enter to pick, Escape to close.
+// Usage: then set
+// ItemsSource in code or by binding.
+public class Combobox : Border
+{
+ public static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty?> ItemsSourceProperty =
+ AvaloniaProperty.Register?>(nameof(ItemsSource));
+
+ public static readonly StyledProperty PlaceholderProperty =
+ AvaloniaProperty.Register(nameof(Placeholder), "Select...");
+
+ public static readonly StyledProperty SearchPlaceholderProperty =
+ AvaloniaProperty.Register(nameof(SearchPlaceholder), "Search...");
+
+ public static readonly StyledProperty EmptyTextProperty =
+ AvaloniaProperty.Register(nameof(EmptyText), "No results found.");
+
+ private const double ItemHeight = 32;
+ private const double MaxListHeight = 240;
+
+ private readonly Border _trigger;
+ private readonly TextBlock _value;
+ private readonly Border _panel;
+ private readonly TextBox _search;
+ private readonly StackPanel _list = new();
+ private readonly Panel _listArea;
+ private readonly TextBlock _empty;
+ private readonly List<(string Text, Border Row, Icon Check)> _rows = new();
+ private readonly ShellAnchoredPopup _popup;
+ private int _active = -1;
+
+ static Combobox()
+ {
+ ValueProperty.Changed.AddClassHandler((c, _) =>
+ {
+ c.UpdateTrigger();
+ c.UpdateChecks();
+ c.ValueChanged?.Invoke(c, c.Value ?? string.Empty);
+ });
+ ItemsSourceProperty.Changed.AddClassHandler((c, _) => c.RebuildItems());
+ PlaceholderProperty.Changed.AddClassHandler((c, _) => c.UpdateTrigger());
+ SearchPlaceholderProperty.Changed.AddClassHandler((c, e) => c._search.PlaceholderText = (string?)e.NewValue);
+ EmptyTextProperty.Changed.AddClassHandler((c, e) => c._empty.Text = (string?)e.NewValue);
+ IsEnabledProperty.Changed.AddClassHandler((c, _) => c.Opacity = c.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public string? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IList? ItemsSource
+ {
+ get => GetValue(ItemsSourceProperty);
+ set => SetValue(ItemsSourceProperty, value);
+ }
+
+ public string? Placeholder
+ {
+ get => GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string? SearchPlaceholder
+ {
+ get => GetValue(SearchPlaceholderProperty);
+ set => SetValue(SearchPlaceholderProperty, value);
+ }
+
+ public string? EmptyText
+ {
+ get => GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public bool IsOpen => _popup.IsOpen;
+
+ public event EventHandler? ValueChanged;
+
+ public Combobox()
+ {
+ _value = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ var chevrons = new Icon { Kind = IconName.ChevronsUpDown, Size = 16, Token = ShellToken.MutedForeground };
+ Grid.SetColumn(chevrons, 1);
+ _trigger = new Border
+ {
+ Child = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), ColumnSpacing = 8, Children = { _value, chevrons } },
+ Height = 40,
+ Padding = new Thickness(12, 0),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ _trigger.Token(BorderBrushProperty, ShellToken.Input);
+ _trigger.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return;
+ SetOpen(!IsOpen);
+ e.Handled = true;
+ };
+
+ _search = new TextBox { FontSize = 14, PlaceholderText = SearchPlaceholder, VerticalAlignment = VerticalAlignment.Center, VerticalContentAlignment = VerticalAlignment.Center };
+ ShellPlatform.StripNativeChrome(_search);
+ _search.TextChanged += (_, _) => Filter();
+ _search.KeyDown += OnSearchKeyDown;
+ Grid.SetColumn(_search, 1);
+ var searchRow = new Border
+ {
+ Child = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("Auto,*"),
+ ColumnSpacing = 8,
+ Children = { new Icon { Kind = IconName.Search, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }, _search }
+ },
+ Height = 40,
+ Padding = new Thickness(12, 0)
+ };
+
+ var divider = new Border { Height = 1 };
+ divider.Token(BackgroundProperty, ShellToken.Border);
+
+ _empty = new TextBlock { Text = EmptyText, FontSize = 14, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsVisible = false };
+ _empty.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ // Fixed height (set in RebuildItems): the panel keeps its size and position while filtering.
+ _listArea = new Panel { Margin = new Thickness(4), Children = { new ScrollViewer { Content = _list }, _empty } };
+
+ _panel = new Border
+ {
+ Child = new StackPanel { Children = { searchRow, divider, _listArea } },
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ BoxShadow = ShellPopups.PanelShadow()
+ };
+ _panel.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+
+ _popup = new ShellAnchoredPopup(_trigger);
+ _popup.Popup.Child = _panel;
+ _popup.Closed += (_, _) =>
+ {
+ _trigger.Token(BorderBrushProperty, ShellToken.Input);
+ // Focus goes back to the trigger instead of being lost with the panel.
+ if (_search.IsFocused) Focus();
+ };
+
+ Child = new Panel { Children = { _trigger, _popup.Popup } };
+ ShellFocus.Ring(this, _trigger);
+ RebuildItems();
+ }
+
+ public void SetOpen(bool open)
+ {
+ if (open == _popup.IsOpen) return;
+ if (!open)
+ {
+ _popup.Close();
+ return;
+ }
+ _search.Text = string.Empty;
+ Filter();
+ // The panel matches the trigger's width.
+ _panel.Width = _trigger.Bounds.Width;
+ _trigger.Token(BorderBrushProperty, ShellToken.Ring);
+ _popup.Open();
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (IsOpen) _search.Focus();
+ }, DispatcherPriority.Loaded);
+ }
+
+ public void Close() => SetOpen(false);
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ // Only keys pressed on the closed trigger: the search field handles its own.
+ if (IsOpen || !ReferenceEquals(e.Source, this) || e.Key is not (Key.Enter or Key.Space or Key.Down)) return;
+ SetOpen(true);
+ e.Handled = true;
+ }
+
+ private void OnSearchKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter)
+ {
+ if (_active >= 0) Pick(_rows[_active].Text);
+ }
+ else if (e.Key == Key.Down) MoveActive(1);
+ else if (e.Key == Key.Up) MoveActive(-1);
+ else return;
+ e.Handled = true;
+ }
+
+ private void Pick(string text)
+ {
+ Value = text;
+ SetOpen(false);
+ }
+
+ private void UpdateTrigger()
+ {
+ var hasValue = !string.IsNullOrEmpty(Value);
+ _value.Text = hasValue ? Value : Placeholder ?? string.Empty;
+ _value.Token(TextBlock.ForegroundProperty, hasValue ? ShellToken.Foreground : ShellToken.MutedForeground);
+ }
+
+ private void RebuildItems()
+ {
+ _list.Children.Clear();
+ _rows.Clear();
+ _active = -1;
+ var items = ItemsSource;
+ _listArea.Height = Math.Min(Math.Max(items?.Count ?? 0, 2) * ItemHeight, MaxListHeight);
+ if (items != null)
+ {
+ foreach (var text in items)
+ _list.Children.Add(CreateItem(text, _rows.Count));
+ }
+ UpdateTrigger();
+ UpdateChecks();
+ Filter();
+ }
+
+ private Control CreateItem(string text, int index)
+ {
+ var label = new TextBlock { Text = text, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ label.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground);
+ var check = new Icon { Kind = IconName.Check, Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground };
+ Grid.SetColumn(check, 1);
+
+ var item = new Border
+ {
+ Child = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), ColumnSpacing = 8, Children = { label, check } },
+ Height = ItemHeight,
+ Padding = new Thickness(8, 0),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm),
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ // The pointer and the arrow keys move the same highlight.
+ item.PointerMoved += (_, _) => SetActive(index);
+ item.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Pick(text);
+ e.Handled = true;
+ };
+
+ _rows.Add((text, item, check));
+ return item;
+ }
+
+ private void UpdateChecks()
+ {
+ foreach (var (text, _, check) in _rows)
+ check.IsVisible = text == Value;
+ }
+
+ private void Filter()
+ {
+ var query = _search.Text?.Trim() ?? string.Empty;
+ var first = -1;
+ for (var i = 0; i < _rows.Count; i++)
+ {
+ var (text, row, _) = _rows[i];
+ row.IsVisible = query.Length == 0 || text.Contains(query, StringComparison.OrdinalIgnoreCase);
+ if (row.IsVisible && first < 0) first = i;
+ }
+ _empty.IsVisible = first < 0;
+ SetActive(first);
+ }
+
+ private void MoveActive(int direction)
+ {
+ for (var i = _active + direction; i >= 0 && i < _rows.Count; i += direction)
+ {
+ if (!_rows[i].Row.IsVisible) continue;
+ SetActive(i);
+ _rows[i].Row.BringIntoView();
+ return;
+ }
+ }
+
+ private void SetActive(int index)
+ {
+ if (index == _active) return;
+ _active = index;
+ for (var i = 0; i < _rows.Count; i++)
+ {
+ var row = _rows[i].Row;
+ if (i == index) row.Token(BackgroundProperty, ShellToken.Accent);
+ else
+ {
+ row.ClearToken(BackgroundProperty);
+ row.Background = Brushes.Transparent;
+ }
+ }
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/CopyButton.cs b/examples/Avalonia.Demo/Components/UI/CopyButton.cs
new file mode 100644
index 0000000..bf9c9b2
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/CopyButton.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Copies Text to the clipboard — a ghost icon button (h-9 w-9 rounded-md, hover:bg-accent) whose
+// icon turns into a check for two seconds after copying. Label adds text next to the icon.
+// Usage:
+public class CopyButton : Border, IShellFocusable
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ public static readonly StyledProperty LabelProperty =
+ AvaloniaProperty.Register(nameof(Label));
+
+ private readonly Icon _icon;
+ private readonly TextBlock _label;
+ private bool _copied;
+ private int _version;
+
+ static CopyButton()
+ {
+ TextProperty.Changed.AddClassHandler((b, _) => b.Update());
+ LabelProperty.Changed.AddClassHandler((b, _) => b.Update());
+ IsEnabledProperty.Changed.AddClassHandler((b, _) => b.Update());
+ }
+
+ // The text placed on the clipboard.
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public string? Label
+ {
+ get => GetValue(LabelProperty);
+ set => SetValue(LabelProperty, value);
+ }
+
+ public event EventHandler? Copied;
+
+ public CopyButton()
+ {
+ _icon = new Icon { Kind = IconName.Copy, Size = 16, VerticalAlignment = VerticalAlignment.Center };
+ _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, IsVisible = false };
+ Child = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 8,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Children = { _icon, _label }
+ };
+ Height = 36;
+ MinWidth = 36;
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ HorizontalAlignment = HorizontalAlignment.Left;
+ Cursor = new Cursor(StandardCursorType.Hand);
+ ShellFocus.Ring(this, this);
+ Update();
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ Update();
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ Update();
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left || !new Rect(Bounds.Size).Contains(e.GetPosition(this))) return;
+ _ = CopyAsync();
+ e.Handled = true;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ _ = CopyAsync();
+ e.Handled = true;
+ }
+
+ public async Task CopyAsync()
+ {
+ if (!IsEffectivelyEnabled || string.IsNullOrEmpty(Text)) return;
+ if (TopLevel.GetTopLevel(this)?.Clipboard is not { } clipboard) return;
+ try
+ {
+ await clipboard.SetTextAsync(Text);
+ }
+ catch (Exception)
+ {
+ // No clipboard access: leave the icon as it is.
+ return;
+ }
+
+ var version = ++_version;
+ _copied = true;
+ Update();
+ Copied?.Invoke(this, EventArgs.Empty);
+ await Task.Delay(2000);
+ if (version != _version) return;
+ _copied = false;
+ Update();
+ }
+
+ private void Update()
+ {
+ var hasLabel = !string.IsNullOrEmpty(Label);
+ _label.Text = Label ?? string.Empty;
+ _label.IsVisible = hasLabel;
+ Padding = new Thickness(hasLabel ? 12 : 0, 0);
+
+ var hovered = IsPointerOver && IsEffectivelyEnabled;
+ _icon.Kind = _copied ? IconName.Check : IconName.Copy;
+ _icon.Token = _copied ? ShellToken.Success : hovered ? ShellToken.AccentForeground : ShellToken.Foreground;
+ _label.Token(TextBlock.ForegroundProperty, hovered ? ShellToken.AccentForeground : ShellToken.Foreground);
+
+ if (hovered) this.Token(BackgroundProperty, ShellToken.Accent);
+ else
+ {
+ // Transparent, not null, so the whole button stays hit-testable.
+ this.ClearToken(BackgroundProperty);
+ Background = Brushes.Transparent;
+ }
+ Opacity = IsEnabled && !string.IsNullOrEmpty(Text) ? 1.0 : 0.5;
+ AutomationProperties.SetName(this, _copied ? "Copied" : "Copy to clipboard");
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/InputOtp.cs b/examples/Avalonia.Demo/Components/UI/InputOtp.cs
new file mode 100644
index 0000000..8d8c982
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/InputOtp.cs
@@ -0,0 +1,199 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Threading;
+
+namespace AvaloniaDemo.Components.UI;
+
+// One-time-code input — a row of 40px slots (rounded-md border border-input) over one invisible
+// text box, so typing, paste and input methods all work. The active slot shows the ring color
+// and a blinking caret.
+// Usage:
+public class InputOtp : Border
+{
+ public static readonly StyledProperty LengthProperty =
+ AvaloniaProperty.Register(nameof(Length), 6);
+
+ public static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value), string.Empty, defaultBindingMode: BindingMode.TwoWay);
+
+ // Digits only. False allows letters too.
+ public static readonly StyledProperty IsNumericProperty =
+ AvaloniaProperty.Register(nameof(IsNumeric), true);
+
+ public static readonly StyledProperty HasErrorProperty =
+ AvaloniaProperty.Register(nameof(HasError));
+
+ private const double SlotSize = 40;
+
+ private readonly TextBox _textBox;
+ private readonly StackPanel _slots = new() { Orientation = Orientation.Horizontal, Spacing = 8, IsHitTestVisible = false };
+ private readonly List<(Border Box, TextBlock Text, Border Caret)> _items = new();
+ private DispatcherTimer? _blink;
+ private bool _focused;
+
+ static InputOtp()
+ {
+ LengthProperty.Changed.AddClassHandler((o, _) => o.BuildSlots());
+ ValueProperty.Changed.AddClassHandler((o, _) => o.OnValueChanged());
+ IsNumericProperty.Changed.AddClassHandler((o, _) => o.OnValueChanged());
+ HasErrorProperty.Changed.AddClassHandler((o, _) => o.UpdateSlots());
+ IsEnabledProperty.Changed.AddClassHandler((o, _) => o.Opacity = o.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public int Length
+ {
+ get => GetValue(LengthProperty);
+ set => SetValue(LengthProperty, value);
+ }
+
+ public string? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public bool IsNumeric
+ {
+ get => GetValue(IsNumericProperty);
+ set => SetValue(IsNumericProperty, value);
+ }
+
+ public bool HasError
+ {
+ get => GetValue(HasErrorProperty);
+ set => SetValue(HasErrorProperty, value);
+ }
+
+ public event EventHandler? ValueChanged;
+ // Raised when every slot is filled.
+ public event EventHandler? Completed;
+
+ public InputOtp()
+ {
+ // The real input: stretched over the slots and invisible, so a click anywhere focuses it.
+ _textBox = new TextBox { Opacity = 0, Cursor = new Cursor(StandardCursorType.Ibeam) };
+ ShellPlatform.StripNativeChrome(_textBox);
+ AutomationProperties.SetName(_textBox, "Verification code");
+ _textBox.TextChanged += (_, _) =>
+ {
+ // Characters that don't belong (or don't fit) never stay in the hidden text.
+ var value = Sanitize(_textBox.Text);
+ if (_textBox.Text != value)
+ {
+ _textBox.Text = value;
+ _textBox.CaretIndex = value.Length;
+ }
+ Value = value;
+ };
+ _textBox.GotFocus += (_, _) =>
+ {
+ _focused = true;
+ _textBox.CaretIndex = _textBox.Text?.Length ?? 0;
+ UpdateSlots();
+ };
+ _textBox.LostFocus += (_, _) =>
+ {
+ _focused = false;
+ UpdateSlots();
+ };
+
+ Child = new Panel { Children = { _slots, _textBox } };
+ HorizontalAlignment = HorizontalAlignment.Left;
+ BuildSlots();
+ }
+
+ public new bool Focus(NavigationMethod method = NavigationMethod.Unspecified, KeyModifiers keyModifiers = KeyModifiers.None)
+ => _textBox.Focus(method, keyModifiers);
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ StopBlink();
+ }
+
+ private int SlotCount => Math.Max(1, Length);
+
+ private string Sanitize(string? text)
+ {
+ var filtered = new string((text ?? string.Empty).Where(c => IsNumeric ? char.IsDigit(c) : char.IsLetterOrDigit(c)).ToArray());
+ return filtered.Length > SlotCount ? filtered[..SlotCount] : filtered;
+ }
+
+ private void BuildSlots()
+ {
+ _slots.Children.Clear();
+ _items.Clear();
+ for (var i = 0; i < SlotCount; i++)
+ {
+ var text = new TextBlock { FontSize = 16, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ text.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ var caret = new Border { Width = 1.5, Height = 18, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsVisible = false };
+ caret.Token(BackgroundProperty, ShellToken.Foreground);
+ var box = new Border
+ {
+ Child = new Panel { Children = { text, caret } },
+ Width = SlotSize,
+ Height = SlotSize,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd)
+ };
+ _items.Add((box, text, caret));
+ _slots.Children.Add(box);
+ }
+ OnValueChanged();
+ }
+
+ private void OnValueChanged()
+ {
+ var value = Sanitize(Value);
+ if (value != Value)
+ {
+ Value = value;
+ return;
+ }
+ if (_textBox.Text != value)
+ {
+ _textBox.Text = value;
+ _textBox.CaretIndex = value.Length;
+ }
+ UpdateSlots();
+ ValueChanged?.Invoke(this, value);
+ if (value.Length == Length && Length > 0) Completed?.Invoke(this, value);
+ }
+
+ private void UpdateSlots()
+ {
+ var value = Value ?? string.Empty;
+ // The caret sits in the next empty slot (the last one when the code is complete).
+ var active = _focused ? Math.Min(value.Length, _items.Count - 1) : -1;
+ for (var i = 0; i < _items.Count; i++)
+ {
+ var (box, text, caret) = _items[i];
+ text.Text = i < value.Length ? value[i].ToString() : string.Empty;
+ var isActive = i == active;
+ box.Token(BorderBrushProperty, HasError ? ShellToken.Destructive : isActive ? ShellToken.Ring : ShellToken.Input);
+ caret.IsVisible = isActive && i >= value.Length;
+ caret.Opacity = 1;
+ }
+
+ StopBlink();
+ if (active < 0 || active < value.Length) return;
+ var blinking = _items[active].Caret;
+ _blink = new DispatcherTimer(TimeSpan.FromMilliseconds(500), DispatcherPriority.Normal,
+ (_, _) => blinking.Opacity = blinking.Opacity > 0.5 ? 0 : 1);
+ _blink.Start();
+ }
+
+ private void StopBlink()
+ {
+ _blink?.Stop();
+ _blink = null;
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Kbd.cs b/examples/Avalonia.Demo/Components/UI/Kbd.cs
new file mode 100644
index 0000000..4054dd9
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Kbd.cs
@@ -0,0 +1,49 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Keyboard key — h-5 rounded border bg-muted px-1.5 text-[11px] font-medium text-muted-foreground.
+// Usage:
+public class Kbd : Border
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ private readonly TextBlock _label;
+
+ static Kbd()
+ {
+ TextProperty.Changed.AddClassHandler((k, e) => k._label.Text = (string?)e.NewValue ?? string.Empty);
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public Kbd()
+ {
+ _label = new TextBlock
+ {
+ FontSize = 11,
+ FontWeight = FontWeight.Medium,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ _label.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+
+ Child = _label;
+ Height = 22;
+ MinWidth = 22;
+ Padding = new Thickness(6, 0);
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm);
+ HorizontalAlignment = HorizontalAlignment.Left;
+ VerticalAlignment = VerticalAlignment.Center;
+ this.Token(BackgroundProperty, ShellToken.Muted).Token(BorderBrushProperty, ShellToken.Border);
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/LinkCard.cs b/examples/Avalonia.Demo/Components/UI/LinkCard.cs
new file mode 100644
index 0000000..b36351f
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/LinkCard.cs
@@ -0,0 +1,146 @@
+using System;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Clickable card — rounded-lg border bg-card p-4, hover:bg-accent: an optional icon, a title, a
+// muted description and an arrow. A click (or Enter / Space) raises Clicked and opens Url when
+// one is set.
+// Usage:
+public class LinkCard : Border, IShellFocusable
+{
+ public static readonly StyledProperty TitleProperty =
+ AvaloniaProperty.Register(nameof(Title));
+
+ public static readonly StyledProperty DescriptionProperty =
+ AvaloniaProperty.Register(nameof(Description));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ // Opened in the system browser (or the app registered for the link) when the card is clicked.
+ public static readonly StyledProperty UrlProperty =
+ AvaloniaProperty.Register(nameof(Url));
+
+ private readonly Icon _icon;
+ private readonly TextBlock _title;
+ private readonly TextBlock _description;
+
+ static LinkCard()
+ {
+ TitleProperty.Changed.AddClassHandler((c, _) => c.Update());
+ DescriptionProperty.Changed.AddClassHandler((c, _) => c.Update());
+ IconProperty.Changed.AddClassHandler((c, _) => c.Update());
+ IsEnabledProperty.Changed.AddClassHandler((c, _) => c.Opacity = c.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public string? Title
+ {
+ get => GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string? Description
+ {
+ get => GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public string? Url
+ {
+ get => GetValue(UrlProperty);
+ set => SetValue(UrlProperty, value);
+ }
+
+ public event EventHandler? Clicked;
+
+ public LinkCard()
+ {
+ _icon = new Icon { Size = 20, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 1, 0, 0) };
+ _title = new TextBlock { FontSize = 15, FontWeight = FontWeight.SemiBold, TextTrimming = TextTrimming.CharacterEllipsis };
+ _title.Token(TextBlock.ForegroundProperty, ShellToken.CardForeground);
+ _description = new TextBlock { FontSize = 14, TextWrapping = TextWrapping.Wrap };
+ _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ var text = new StackPanel { Spacing = 4, Children = { _title, _description } };
+ Grid.SetColumn(text, 1);
+ var arrow = new Icon { Kind = IconName.ArrowUpRight, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 2, 0, 0) };
+ Grid.SetColumn(arrow, 2);
+
+ Child = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), ColumnSpacing = 12, Children = { _icon, text, arrow } };
+ Padding = new Thickness(16);
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusLg);
+ Cursor = new Cursor(StandardCursorType.Hand);
+ this.Token(BackgroundProperty, ShellToken.Card).Token(BorderBrushProperty, ShellToken.Border);
+ ShellFocus.Ring(this, this);
+ Update();
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ if (IsEffectivelyEnabled) this.Token(BackgroundProperty, ShellToken.Accent);
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ this.Token(BackgroundProperty, ShellToken.Card);
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left || !new Rect(Bounds.Size).Contains(e.GetPosition(this))) return;
+ Press();
+ e.Handled = true;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ Press();
+ e.Handled = true;
+ }
+
+ // Clicks in code, as a pointer click or Enter/Space would.
+ public async void Press()
+ {
+ if (!IsEffectivelyEnabled) return;
+ Clicked?.Invoke(this, EventArgs.Empty);
+ if (string.IsNullOrWhiteSpace(Url) || !Uri.TryCreate(Url, UriKind.Absolute, out var uri)) return;
+ if (TopLevel.GetTopLevel(this) is not { } top) return;
+ try
+ {
+ await top.Launcher.LaunchUriAsync(uri);
+ }
+ catch (Exception)
+ {
+ // Nothing on the device can open this link.
+ }
+ }
+
+ private void Update()
+ {
+ _icon.Kind = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _title.Text = Title ?? string.Empty;
+ _title.IsVisible = !string.IsNullOrEmpty(Title);
+ _description.Text = Description ?? string.Empty;
+ _description.IsVisible = !string.IsNullOrEmpty(Description);
+ AutomationProperties.SetName(this, Title ?? string.Empty);
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/MultiSelect.cs b/examples/Avalonia.Demo/Components/UI/MultiSelect.cs
new file mode 100644
index 0000000..26da092
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/MultiSelect.cs
@@ -0,0 +1,377 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Linq;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Multi-select — a min-h-10 trigger that shows the picked options as removable chips, and a
+// floating panel with a search field and a checked list. Picking toggles an option and leaves
+// the panel open; click outside or press Escape to close it. Type to filter, Up / Down to move,
+// Enter to toggle.
+// Usage: then
+// set ItemsSource in code or by binding.
+public class MultiSelect : Border
+{
+ public static readonly StyledProperty?> ItemsSourceProperty =
+ AvaloniaProperty.Register?>(nameof(ItemsSource));
+
+ public static readonly StyledProperty?> ValuesProperty =
+ AvaloniaProperty.Register?>(nameof(Values), defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty PlaceholderProperty =
+ AvaloniaProperty.Register(nameof(Placeholder), "Select...");
+
+ public static readonly StyledProperty SearchPlaceholderProperty =
+ AvaloniaProperty.Register(nameof(SearchPlaceholder), "Search...");
+
+ public static readonly StyledProperty EmptyTextProperty =
+ AvaloniaProperty.Register(nameof(EmptyText), "No results found.");
+
+ private const double ItemHeight = 32;
+ private const double MaxListHeight = 240;
+
+ private readonly Border _trigger;
+ private readonly WrapLayout _chips;
+ private readonly TextBlock _placeholder;
+ private readonly Border _panel;
+ private readonly TextBox _search;
+ private readonly StackPanel _list = new();
+ private readonly Panel _listArea;
+ private readonly TextBlock _empty;
+ private readonly List<(string Text, Border Row, Icon Check)> _rows = new();
+ private readonly ShellAnchoredPopup _popup;
+ private int _active = -1;
+
+ static MultiSelect()
+ {
+ ItemsSourceProperty.Changed.AddClassHandler((m, _) => m.RebuildItems());
+ ValuesProperty.Changed.AddClassHandler((m, e) => m.OnValuesReplaced(e.OldValue, e.NewValue));
+ PlaceholderProperty.Changed.AddClassHandler((m, e) => m._placeholder.Text = (string?)e.NewValue);
+ SearchPlaceholderProperty.Changed.AddClassHandler((m, e) => m._search.PlaceholderText = (string?)e.NewValue);
+ EmptyTextProperty.Changed.AddClassHandler((m, e) => m._empty.Text = (string?)e.NewValue);
+ IsEnabledProperty.Changed.AddClassHandler((m, _) => m.Opacity = m.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public IList? ItemsSource
+ {
+ get => GetValue(ItemsSourceProperty);
+ set => SetValue(ItemsSourceProperty, value);
+ }
+
+ public IList? Values
+ {
+ get => GetValue(ValuesProperty);
+ set => SetValue(ValuesProperty, value);
+ }
+
+ public string? Placeholder
+ {
+ get => GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string? SearchPlaceholder
+ {
+ get => GetValue(SearchPlaceholderProperty);
+ set => SetValue(SearchPlaceholderProperty, value);
+ }
+
+ public string? EmptyText
+ {
+ get => GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public bool IsOpen => _popup.IsOpen;
+
+ public event EventHandler>? ValuesChanged;
+
+ public MultiSelect()
+ {
+ _placeholder = new TextBlock { Text = Placeholder, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(4, 0, 0, 0) };
+ _placeholder.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ _chips = new WrapLayout { Spacing = 4, LineSpacing = 4, VerticalAlignment = VerticalAlignment.Center };
+ var chevrons = new Icon { Kind = IconName.ChevronsUpDown, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center };
+ Grid.SetColumn(chevrons, 1);
+
+ _trigger = new Border
+ {
+ Child = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("*,Auto"),
+ ColumnSpacing = 8,
+ Children = { new Panel { Children = { _placeholder, _chips } }, chevrons }
+ },
+ MinHeight = 40,
+ Padding = new Thickness(8, 6, 12, 6),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ _trigger.Token(BorderBrushProperty, ShellToken.Input);
+ _trigger.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return;
+ SetOpen(!IsOpen);
+ e.Handled = true;
+ };
+
+ _search = new TextBox { FontSize = 14, PlaceholderText = SearchPlaceholder, VerticalAlignment = VerticalAlignment.Center, VerticalContentAlignment = VerticalAlignment.Center };
+ ShellPlatform.StripNativeChrome(_search);
+ _search.TextChanged += (_, _) => Filter();
+ _search.KeyDown += OnSearchKeyDown;
+ Grid.SetColumn(_search, 1);
+ var searchRow = new Border
+ {
+ Child = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("Auto,*"),
+ ColumnSpacing = 8,
+ Children = { new Icon { Kind = IconName.Search, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }, _search }
+ },
+ Height = 40,
+ Padding = new Thickness(12, 0)
+ };
+
+ var divider = new Border { Height = 1 };
+ divider.Token(BackgroundProperty, ShellToken.Border);
+
+ _empty = new TextBlock { Text = EmptyText, FontSize = 14, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsVisible = false };
+ _empty.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ // Fixed height (set in RebuildItems): the panel keeps its size and position while filtering.
+ _listArea = new Panel { Margin = new Thickness(4), Children = { new ScrollViewer { Content = _list }, _empty } };
+
+ _panel = new Border
+ {
+ Child = new StackPanel { Children = { searchRow, divider, _listArea } },
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ BoxShadow = ShellPopups.PanelShadow()
+ };
+ _panel.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+
+ _popup = new ShellAnchoredPopup(_trigger);
+ _popup.Popup.Child = _panel;
+ _popup.Closed += (_, _) =>
+ {
+ _trigger.Token(BorderBrushProperty, ShellToken.Input);
+ // Focus goes back to the trigger instead of being lost with the panel.
+ if (_search.IsFocused) Focus();
+ };
+
+ Child = new Panel { Children = { _trigger, _popup.Popup } };
+ ShellFocus.Ring(this, _trigger);
+ RebuildItems();
+ RebuildChips();
+ }
+
+ private IReadOnlyList Current => (IReadOnlyList?)Values?.ToList() ?? Array.Empty();
+
+ public void SetOpen(bool open)
+ {
+ if (open == _popup.IsOpen) return;
+ if (!open)
+ {
+ _popup.Close();
+ return;
+ }
+ _search.Text = string.Empty;
+ Filter();
+ // The panel matches the trigger's width.
+ _panel.Width = _trigger.Bounds.Width;
+ _trigger.Token(BorderBrushProperty, ShellToken.Ring);
+ _popup.Open();
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (IsOpen) _search.Focus();
+ }, DispatcherPriority.Loaded);
+ }
+
+ public void Close() => SetOpen(false);
+
+ // Adds the option when it isn't picked, removes it when it is.
+ public void Toggle(string text)
+ {
+ // A new list each time, so a two-way binding sees the change.
+ var next = new List(Current);
+ if (!next.Remove(text)) next.Add(text);
+ Values = next;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ // Only keys pressed on the closed trigger: the search field handles its own.
+ if (IsOpen || !ReferenceEquals(e.Source, this) || e.Key is not (Key.Enter or Key.Space or Key.Down)) return;
+ SetOpen(true);
+ e.Handled = true;
+ }
+
+ private void OnSearchKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter)
+ {
+ if (_active >= 0) Toggle(_rows[_active].Text);
+ }
+ else if (e.Key == Key.Down) MoveActive(1);
+ else if (e.Key == Key.Up) MoveActive(-1);
+ else return;
+ e.Handled = true;
+ }
+
+ private void OnValuesReplaced(object? oldValue, object? newValue)
+ {
+ if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnValuesMutated;
+ if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnValuesMutated;
+ OnValuesMutated(this, null);
+ }
+
+ private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e)
+ {
+ RebuildChips();
+ UpdateChecks();
+ ValuesChanged?.Invoke(this, Current);
+ }
+
+ private void RebuildChips()
+ {
+ _chips.Children.Clear();
+ var current = Current;
+ foreach (var value in current)
+ _chips.Children.Add(CreateChip(value));
+ _placeholder.IsVisible = current.Count == 0;
+ }
+
+ private Control CreateChip(string value)
+ {
+ var label = new TextBlock { Text = value, FontSize = 12, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ label.Token(TextBlock.ForegroundProperty, ShellToken.SecondaryForeground);
+ var close = new Icon { Kind = IconName.X, Size = 12, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ // 12px icon in a 20px click target. Its clicks stop here, so they don't toggle the panel.
+ var remove = new Border { Width = 20, Height = 20, Background = Brushes.Transparent, Child = close };
+ AutomationProperties.SetName(remove, $"Remove {value}");
+ remove.PointerEntered += (_, _) => close.Token = ShellToken.Destructive;
+ remove.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground;
+ remove.PointerPressed += (_, e) => e.Handled = true;
+ remove.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ if (IsEffectivelyEnabled) Toggle(value);
+ e.Handled = true;
+ };
+
+ var chip = new Border
+ {
+ Child = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2, Children = { label, remove } },
+ Padding = new Thickness(8, 0, 2, 0),
+ Height = 24,
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd)
+ };
+ chip.Token(BackgroundProperty, ShellToken.Secondary);
+ return chip;
+ }
+
+ private void RebuildItems()
+ {
+ _list.Children.Clear();
+ _rows.Clear();
+ _active = -1;
+ var items = ItemsSource;
+ _listArea.Height = Math.Min(Math.Max(items?.Count ?? 0, 2) * ItemHeight, MaxListHeight);
+ if (items != null)
+ {
+ foreach (var text in items)
+ _list.Children.Add(CreateItem(text, _rows.Count));
+ }
+ UpdateChecks();
+ Filter();
+ }
+
+ private Control CreateItem(string text, int index)
+ {
+ var check = new Icon { Kind = IconName.Check, Size = 16, Opacity = 0, Token = ShellToken.PopoverForeground, VerticalAlignment = VerticalAlignment.Center };
+ var label = new TextBlock { Text = text, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ label.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground);
+ Grid.SetColumn(label, 1);
+
+ var item = new Border
+ {
+ Child = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*"), ColumnSpacing = 8, Children = { check, label } },
+ Height = ItemHeight,
+ Padding = new Thickness(8, 0),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm),
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ // The pointer and the arrow keys move the same highlight.
+ item.PointerMoved += (_, _) => SetActive(index);
+ item.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Toggle(text);
+ e.Handled = true;
+ };
+
+ _rows.Add((text, item, check));
+ return item;
+ }
+
+ private void UpdateChecks()
+ {
+ var current = Current;
+ // The check keeps its column when hidden, so option labels stay aligned.
+ foreach (var (text, _, check) in _rows)
+ check.Opacity = current.Contains(text) ? 1 : 0;
+ }
+
+ private void Filter()
+ {
+ var query = _search.Text?.Trim() ?? string.Empty;
+ var first = -1;
+ for (var i = 0; i < _rows.Count; i++)
+ {
+ var (text, row, _) = _rows[i];
+ row.IsVisible = query.Length == 0 || text.Contains(query, StringComparison.OrdinalIgnoreCase);
+ if (row.IsVisible && first < 0) first = i;
+ }
+ _empty.IsVisible = first < 0;
+ SetActive(first);
+ }
+
+ private void MoveActive(int direction)
+ {
+ for (var i = _active + direction; i >= 0 && i < _rows.Count; i += direction)
+ {
+ if (!_rows[i].Row.IsVisible) continue;
+ SetActive(i);
+ _rows[i].Row.BringIntoView();
+ return;
+ }
+ }
+
+ private void SetActive(int index)
+ {
+ if (index == _active) return;
+ _active = index;
+ for (var i = 0; i < _rows.Count; i++)
+ {
+ var row = _rows[i].Row;
+ if (i == index) row.Token(BackgroundProperty, ShellToken.Accent);
+ else
+ {
+ row.ClearToken(BackgroundProperty);
+ row.Background = Brushes.Transparent;
+ }
+ }
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/NumberInput.cs b/examples/Avalonia.Demo/Components/UI/NumberInput.cs
new file mode 100644
index 0000000..d0be2ca
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/NumberInput.cs
@@ -0,0 +1,208 @@
+using System;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Number field with − and + buttons — h-10 rounded-md border, the value centered between them.
+// The value is clamped to Minimum / Maximum; the buttons dim at the limits. Up and Down step
+// the value while the field has focus.
+// Usage:
+public class NumberInput : Border
+{
+ public static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay,
+ coerce: (o, value) => ((NumberInput)o).Clamp(value));
+
+ public static readonly StyledProperty MinimumProperty =
+ AvaloniaProperty.Register(nameof(Minimum));
+
+ public static readonly StyledProperty MaximumProperty =
+ AvaloniaProperty.Register(nameof(Maximum));
+
+ public static readonly StyledProperty StepProperty =
+ AvaloniaProperty.Register(nameof(Step), 1m);
+
+ public static readonly StyledProperty PlaceholderProperty =
+ AvaloniaProperty.Register(nameof(Placeholder));
+
+ private readonly TextBox _textBox;
+ private readonly Border _decrease;
+ private readonly Border _increase;
+
+ static NumberInput()
+ {
+ ValueProperty.Changed.AddClassHandler((n, _) =>
+ {
+ n.UpdateText();
+ n.UpdateButtons();
+ n.ValueChanged?.Invoke(n, n.Value);
+ });
+ MinimumProperty.Changed.AddClassHandler((n, _) => n.OnLimitsChanged());
+ MaximumProperty.Changed.AddClassHandler((n, _) => n.OnLimitsChanged());
+ PlaceholderProperty.Changed.AddClassHandler((n, e) => n._textBox.PlaceholderText = (string?)e.NewValue);
+ IsEnabledProperty.Changed.AddClassHandler((n, _) => n.Opacity = n.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public decimal? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public decimal? Minimum
+ {
+ get => GetValue(MinimumProperty);
+ set => SetValue(MinimumProperty, value);
+ }
+
+ public decimal? Maximum
+ {
+ get => GetValue(MaximumProperty);
+ set => SetValue(MaximumProperty, value);
+ }
+
+ public decimal Step
+ {
+ get => GetValue(StepProperty);
+ set => SetValue(StepProperty, value);
+ }
+
+ public string? Placeholder
+ {
+ get => GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public event EventHandler? ValueChanged;
+
+ public NumberInput()
+ {
+ _textBox = new TextBox
+ {
+ FontSize = 14,
+ TextAlignment = TextAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center
+ };
+ ShellPlatform.StripNativeChrome(_textBox);
+ _textBox.GotFocus += (_, _) => this.Token(BorderBrushProperty, ShellToken.Ring);
+ _textBox.LostFocus += (_, _) =>
+ {
+ this.Token(BorderBrushProperty, ShellToken.Input);
+ Commit();
+ };
+ _textBox.KeyDown += (_, e) =>
+ {
+ if (e.Key == Key.Enter) Commit();
+ else if (e.Key == Key.Up) StepBy(1);
+ else if (e.Key == Key.Down) StepBy(-1);
+ else return;
+ e.Handled = true;
+ };
+ Grid.SetColumn(_textBox, 2);
+
+ _decrease = StepButton(IconName.Minus, "Decrease", -1);
+ _increase = StepButton(IconName.Plus, "Increase", 1);
+ Grid.SetColumn(_increase, 4);
+
+ Child = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("40,1,*,1,40"),
+ Children = { _decrease, Divider(1), _textBox, Divider(3), _increase }
+ };
+ Height = 40;
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ // The step buttons' hover tint stays inside the rounded corners.
+ ClipToBounds = true;
+ this.Token(BorderBrushProperty, ShellToken.Input);
+ UpdateText();
+ UpdateButtons();
+ }
+
+ private static Border Divider(int column)
+ {
+ var line = new Border();
+ line.Token(BackgroundProperty, ShellToken.Input);
+ Grid.SetColumn(line, column);
+ return line;
+ }
+
+ private Border StepButton(IconName icon, string name, int direction)
+ {
+ var glyph = new Icon { Kind = icon, Size = 16, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ var button = new Border { Child = glyph, Background = Brushes.Transparent, Cursor = new Cursor(StandardCursorType.Hand) };
+ AutomationProperties.SetName(button, name);
+ button.PointerEntered += (_, _) =>
+ {
+ if (button.Opacity < 0.9) return;
+ button.Token(BackgroundProperty, ShellToken.Muted);
+ glyph.Token = ShellToken.Foreground;
+ };
+ button.PointerExited += (_, _) =>
+ {
+ button.ClearToken(BackgroundProperty);
+ button.Background = Brushes.Transparent;
+ glyph.Token = ShellToken.MutedForeground;
+ };
+ button.PointerPressed += (_, e) => e.Handled = true;
+ button.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ StepBy(direction);
+ e.Handled = true;
+ };
+ return button;
+ }
+
+ private void StepBy(int direction)
+ {
+ if (!IsEffectivelyEnabled) return;
+ Commit();
+ Value = (Value ?? Minimum ?? 0) + direction * Step;
+ }
+
+ private decimal? Clamp(decimal? value)
+ {
+ if (value is not { } number) return null;
+ if (Minimum is { } min && number < min) number = min;
+ if (Maximum is { } max && number > max) number = max;
+ return number;
+ }
+
+ // A typed number is clamped and kept, empty clears the value, anything else is reverted.
+ private void Commit()
+ {
+ var text = _textBox.Text?.Trim();
+ if (string.IsNullOrEmpty(text)) Value = null;
+ else if (decimal.TryParse(text, NumberStyles.Number, CultureInfo.CurrentCulture, out var typed) ||
+ decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out typed))
+ Value = typed;
+ UpdateText();
+ }
+
+ private void OnLimitsChanged()
+ {
+ CoerceValue(ValueProperty);
+ UpdateButtons();
+ }
+
+ private void UpdateText()
+ {
+ var text = Value?.ToString(CultureInfo.CurrentCulture) ?? string.Empty;
+ if (_textBox.Text != text) _textBox.Text = text;
+ }
+
+ private void UpdateButtons()
+ {
+ _decrease.Opacity = Value is { } v && Minimum is { } min && v <= min ? 0.4 : 1;
+ _increase.Opacity = Value is { } w && Maximum is { } max && w >= max ? 0.4 : 1;
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/StatCard.cs b/examples/Avalonia.Demo/Components/UI/StatCard.cs
new file mode 100644
index 0000000..9bacd99
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/StatCard.cs
@@ -0,0 +1,145 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Dashboard metric — rounded-lg border bg-card p-5: a muted title with an optional icon, the
+// value in text-2xl bold, a change pill tinted by trend, and an optional description.
+// Usage:
+public class StatCard : Border
+{
+ public static readonly StyledProperty TitleProperty =
+ AvaloniaProperty.Register(nameof(Title));
+
+ public static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value));
+
+ public static readonly StyledProperty ChangeProperty =
+ AvaloniaProperty.Register(nameof(Change));
+
+ public static readonly StyledProperty TrendProperty =
+ AvaloniaProperty.Register(nameof(Trend));
+
+ public static readonly StyledProperty DescriptionProperty =
+ AvaloniaProperty.Register(nameof(Description));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ private readonly TextBlock _title;
+ private readonly Icon _icon;
+ private readonly TextBlock _value;
+ private readonly Panel _pill;
+ private readonly Border _pillTint;
+ private readonly TextBlock _change;
+ private readonly TextBlock _description;
+
+ static StatCard()
+ {
+ TitleProperty.Changed.AddClassHandler((c, _) => c.Update());
+ ValueProperty.Changed.AddClassHandler((c, _) => c.Update());
+ ChangeProperty.Changed.AddClassHandler((c, _) => c.Update());
+ TrendProperty.Changed.AddClassHandler((c, _) => c.Update());
+ DescriptionProperty.Changed.AddClassHandler((c, _) => c.Update());
+ IconProperty.Changed.AddClassHandler((c, _) => c.Update());
+ }
+
+ public string? Title
+ {
+ get => GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public string? Change
+ {
+ get => GetValue(ChangeProperty);
+ set => SetValue(ChangeProperty, value);
+ }
+
+ public StatTrend Trend
+ {
+ get => GetValue(TrendProperty);
+ set => SetValue(TrendProperty, value);
+ }
+
+ public string? Description
+ {
+ get => GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public StatCard()
+ {
+ _title = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ _title.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ _icon = new Icon { Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center };
+ Grid.SetColumn(_icon, 1);
+ var header = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), ColumnSpacing = 8, Children = { _title, _icon } };
+
+ _value = new TextBlock { FontSize = 24, FontWeight = FontWeight.Bold, VerticalAlignment = VerticalAlignment.Center };
+ _value.Token(TextBlock.ForegroundProperty, ShellToken.CardForeground);
+
+ // The pill is the trend color at low strength behind text in the full color.
+ _pillTint = new Border { CornerRadius = new CornerRadius(ShellTheme.RadiusMd) };
+ _change = new TextBlock { FontSize = 12, FontWeight = FontWeight.Medium, Margin = new Thickness(6, 2), VerticalAlignment = VerticalAlignment.Center };
+ _pill = new Panel { VerticalAlignment = VerticalAlignment.Center, Children = { _pillTint, _change } };
+
+ _description = new TextBlock { FontSize = 12, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 4, 0, 0) };
+ _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+
+ Child = new StackPanel
+ {
+ Children =
+ {
+ header,
+ new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Margin = new Thickness(0, 8, 0, 0), Children = { _value, _pill } },
+ _description
+ }
+ };
+ Padding = new Thickness(20);
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusLg);
+ this.Token(BackgroundProperty, ShellToken.Card).Token(BorderBrushProperty, ShellToken.Border);
+ Update();
+ }
+
+ private void Update()
+ {
+ _title.Text = Title ?? string.Empty;
+ _icon.Kind = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _value.Text = Value ?? string.Empty;
+
+ _change.Text = Change ?? string.Empty;
+ _pill.IsVisible = !string.IsNullOrEmpty(Change);
+ var (tint, strength, text) = Trend switch
+ {
+ StatTrend.Up => (ShellToken.Success, 0.12, ShellToken.Success),
+ StatTrend.Down => (ShellToken.Destructive, 0.12, ShellToken.Destructive),
+ _ => (ShellToken.Muted, 1.0, ShellToken.MutedForeground)
+ };
+ _pillTint.Token(BackgroundProperty, tint);
+ _pillTint.Opacity = strength;
+ _change.Token(TextBlock.ForegroundProperty, text);
+
+ _description.Text = Description ?? string.Empty;
+ _description.IsVisible = !string.IsNullOrEmpty(Description);
+ }
+}
+
+public enum StatTrend { Neutral, Up, Down }
diff --git a/examples/Avalonia.Demo/Components/UI/TagInput.cs b/examples/Avalonia.Demo/Components/UI/TagInput.cs
new file mode 100644
index 0000000..1b60ed3
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/TagInput.cs
@@ -0,0 +1,206 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Linq;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Tag field — min-h-10 rounded-md border holding chips (rounded-md bg-secondary text-xs) and a
+// text field that wraps with them. Enter, a comma or a semicolon turns what was typed into a
+// tag; the × on a chip removes it, and Backspace in the empty field removes the last one.
+// Usage:
+public class TagInput : Border
+{
+ public static readonly StyledProperty?> TagsProperty =
+ AvaloniaProperty.Register?>(nameof(Tags), defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty PlaceholderProperty =
+ AvaloniaProperty.Register(nameof(Placeholder), "Add tag...");
+
+ // 0 means no limit.
+ public static readonly StyledProperty MaxTagsProperty =
+ AvaloniaProperty.Register(nameof(MaxTags));
+
+ public static readonly StyledProperty AllowDuplicatesProperty =
+ AvaloniaProperty.Register(nameof(AllowDuplicates));
+
+ private static readonly char[] Separators = { ',', ';', '\n', '\t' };
+
+ private readonly WrapLayout _flow;
+ private readonly TextBox _textBox;
+
+ static TagInput()
+ {
+ TagsProperty.Changed.AddClassHandler((t, e) => t.OnTagsReplaced(e.OldValue, e.NewValue));
+ PlaceholderProperty.Changed.AddClassHandler((t, _) => t.UpdatePlaceholder());
+ IsEnabledProperty.Changed.AddClassHandler((t, _) => t.Opacity = t.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public IList? Tags
+ {
+ get => GetValue(TagsProperty);
+ set => SetValue(TagsProperty, value);
+ }
+
+ public string? Placeholder
+ {
+ get => GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public int MaxTags
+ {
+ get => GetValue(MaxTagsProperty);
+ set => SetValue(MaxTagsProperty, value);
+ }
+
+ public bool AllowDuplicates
+ {
+ get => GetValue(AllowDuplicatesProperty);
+ set => SetValue(AllowDuplicatesProperty, value);
+ }
+
+ public event EventHandler>? TagsChanged;
+
+ public TagInput()
+ {
+ _textBox = new TextBox
+ {
+ FontSize = 14,
+ Height = 28,
+ Margin = new Thickness(2),
+ VerticalContentAlignment = VerticalAlignment.Center
+ };
+ ShellPlatform.StripNativeChrome(_textBox);
+ // Tunnel, so Backspace is seen before the text box handles it.
+ _textBox.AddHandler(KeyDownEvent, (_, e) =>
+ {
+ if (e.Key == Key.Enter)
+ {
+ AddFromText();
+ e.Handled = true;
+ }
+ else if (e.Key == Key.Back && string.IsNullOrEmpty(_textBox.Text) && Current.Count > 0)
+ {
+ Remove(Current[^1]);
+ e.Handled = true;
+ }
+ }, RoutingStrategies.Tunnel);
+ _textBox.TextChanged += (_, _) =>
+ {
+ // Typing or pasting a separator commits everything before it.
+ if (_textBox.Text?.IndexOfAny(Separators) >= 0) AddFromText();
+ };
+ _textBox.GotFocus += (_, _) => this.Token(BorderBrushProperty, ShellToken.Ring);
+ _textBox.LostFocus += (_, _) => this.Token(BorderBrushProperty, ShellToken.Input);
+
+ _flow = new WrapLayout { Spacing = 0, LineSpacing = 0, LastChildFill = true, VerticalAlignment = VerticalAlignment.Center, Children = { _textBox } };
+
+ Child = _flow;
+ MinHeight = 40;
+ Padding = new Thickness(8, 3);
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ Background = Brushes.Transparent;
+ Cursor = new Cursor(StandardCursorType.Ibeam);
+ this.Token(BorderBrushProperty, ShellToken.Input);
+ UpdatePlaceholder();
+ }
+
+ // Clicking the empty part of the field focuses the text box.
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ if (IsEffectivelyEnabled && !_textBox.IsFocused) _textBox.Focus();
+ }
+
+ private IReadOnlyList Current => (IReadOnlyList?)Tags?.ToList() ?? Array.Empty();
+
+ public void Add(string tag)
+ {
+ var text = tag.Trim();
+ if (text.Length == 0) return;
+ var current = Current;
+ if (MaxTags > 0 && current.Count >= MaxTags) return;
+ if (!AllowDuplicates && current.Contains(text, StringComparer.OrdinalIgnoreCase)) return;
+ // A new list each time, so a two-way binding sees the change.
+ Tags = new List(current) { text };
+ }
+
+ public void Remove(string tag)
+ {
+ var next = new List(Current);
+ if (next.Remove(tag)) Tags = next;
+ }
+
+ private void AddFromText()
+ {
+ var text = _textBox.Text ?? string.Empty;
+ _textBox.Text = string.Empty;
+ foreach (var part in text.Split(Separators, StringSplitOptions.RemoveEmptyEntries))
+ Add(part);
+ }
+
+ private void OnTagsReplaced(object? oldValue, object? newValue)
+ {
+ if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnTagsMutated;
+ if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnTagsMutated;
+ OnTagsMutated(this, null);
+ }
+
+ private void OnTagsMutated(object? sender, NotifyCollectionChangedEventArgs? e)
+ {
+ Rebuild();
+ TagsChanged?.Invoke(this, Current);
+ }
+
+ private void Rebuild()
+ {
+ _flow.Children.RemoveAll(_flow.Children.Where(c => c != _textBox).ToList());
+ var index = 0;
+ foreach (var tag in Current)
+ _flow.Children.Insert(index++, CreateChip(tag));
+ UpdatePlaceholder();
+ }
+
+ private Control CreateChip(string tag)
+ {
+ var label = new TextBlock { Text = tag, FontSize = 12, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ label.Token(TextBlock.ForegroundProperty, ShellToken.SecondaryForeground);
+ var close = new Icon { Kind = IconName.X, Size = 12, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ // 12px icon in a 20px click target.
+ var remove = new Border { Width = 20, Height = 20, Background = Brushes.Transparent, Child = close, Cursor = new Cursor(StandardCursorType.Hand) };
+ AutomationProperties.SetName(remove, $"Remove {tag}");
+ remove.PointerEntered += (_, _) => close.Token = ShellToken.Destructive;
+ remove.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground;
+ remove.PointerPressed += (_, e) => e.Handled = true;
+ remove.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ if (IsEffectivelyEnabled) Remove(tag);
+ e.Handled = true;
+ };
+
+ var chip = new Border
+ {
+ Child = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2, Children = { label, remove } },
+ Padding = new Thickness(8, 0, 2, 0),
+ Height = 24,
+ Margin = new Thickness(2, 3),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ Cursor = new Cursor(StandardCursorType.Arrow)
+ };
+ chip.Token(BackgroundProperty, ShellToken.Secondary);
+ return chip;
+ }
+
+ private void UpdatePlaceholder() => _textBox.PlaceholderText = Current.Count == 0 ? Placeholder : null;
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Timeline.cs b/examples/Avalonia.Demo/Components/UI/Timeline.cs
new file mode 100644
index 0000000..98b2335
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Timeline.cs
@@ -0,0 +1,216 @@
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Vertical timeline — markers joined by a hairline, each with a title, a time and details.
+//
+//
+//
+//
+//
+//
+public class Timeline : Border
+{
+ private readonly StackPanel _stack = new();
+
+ [Content]
+ public Controls Items => _stack.Children;
+
+ public Timeline()
+ {
+ _stack.Children.CollectionChanged += (_, _) => Refresh();
+ Child = _stack;
+ }
+
+ // The timeline tells each item if it is first or last, so the line starts and stops at a marker.
+ private void Refresh()
+ {
+ var items = _stack.Children.OfType().ToList();
+ for (var i = 0; i < items.Count; i++)
+ items[i].Place(isFirst: i == 0, isLast: i == items.Count - 1);
+ }
+}
+
+// One entry of a Timeline. Without an Icon the marker is a dot (ringed when active); with one it
+// is a 28px circle around the icon. A child control goes under the text.
+public class TimelineItem : Border
+{
+ public static readonly StyledProperty TitleProperty =
+ AvaloniaProperty.Register(nameof(Title));
+
+ public static readonly StyledProperty TimeProperty =
+ AvaloniaProperty.Register(nameof(Time));
+
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ public static readonly StyledProperty IsActiveProperty =
+ AvaloniaProperty.Register(nameof(IsActive));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ public static readonly StyledProperty BodyProperty =
+ AvaloniaProperty.Register(nameof(Body));
+
+ private const double MarkerSize = 28;
+
+ private readonly Border _line;
+ private readonly Border _ring;
+ private readonly Border _dot;
+ private readonly Border _badge;
+ private readonly Icon _icon;
+ private readonly TextBlock _title;
+ private readonly TextBlock _time;
+ private readonly TextBlock _text;
+ private readonly Border _body;
+ private readonly StackPanel _details;
+
+ static TimelineItem()
+ {
+ TitleProperty.Changed.AddClassHandler((i, _) => i.Update());
+ TimeProperty.Changed.AddClassHandler((i, _) => i.Update());
+ TextProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IsActiveProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IconProperty.Changed.AddClassHandler((i, _) => i.Update());
+ BodyProperty.Changed.AddClassHandler((i, _) => i.Update());
+ }
+
+ public string? Title
+ {
+ get => GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public string? Time
+ {
+ get => GetValue(TimeProperty);
+ set => SetValue(TimeProperty, value);
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public bool IsActive
+ {
+ get => GetValue(IsActiveProperty);
+ set => SetValue(IsActiveProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ [Content]
+ public Control? Body
+ {
+ get => GetValue(BodyProperty);
+ set => SetValue(BodyProperty, value);
+ }
+
+ public TimelineItem()
+ {
+ _line = new Border { Width = 1, HorizontalAlignment = HorizontalAlignment.Center };
+ _line.Token(BackgroundProperty, ShellToken.Border);
+
+ // ring-4 ring-primary/20 around the active dot.
+ _ring = new Border { Width = 18, Height = 18, CornerRadius = new CornerRadius(9), Opacity = 0.2, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ _ring.Token(BackgroundProperty, ShellToken.Primary);
+ _dot = new Border { Width = 10, Height = 10, CornerRadius = new CornerRadius(5), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ _icon = new Icon { Size = 14, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ _badge = new Border
+ {
+ Child = _icon,
+ Width = MarkerSize,
+ Height = MarkerSize,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(MarkerSize / 2)
+ };
+ _badge.Token(BackgroundProperty, ShellToken.Background);
+ var marker = new Panel
+ {
+ Width = MarkerSize,
+ Height = MarkerSize,
+ VerticalAlignment = VerticalAlignment.Top,
+ Children = { _ring, _dot, _badge }
+ };
+
+ _title = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center };
+ _title.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ _time = new TextBlock { FontSize = 12, VerticalAlignment = VerticalAlignment.Center };
+ _time.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ _text = new TextBlock { FontSize = 14, TextWrapping = TextWrapping.Wrap };
+ _text.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ _body = new Border();
+ _details = new StackPanel
+ {
+ Spacing = 2,
+ Children =
+ {
+ new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, MinHeight = MarkerSize, Children = { _title, _time } },
+ _text,
+ _body
+ }
+ };
+ Grid.SetColumn(_details, 1);
+
+ Child = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("28,*"),
+ ColumnSpacing = 12,
+ Children = { _line, marker, _details }
+ };
+ Place(isFirst: true, isLast: true);
+ Update();
+ }
+
+ internal void Place(bool isFirst, bool isLast)
+ {
+ // The line runs through the marker's center: from it down (first), up to it (last), or
+ // the full height (in between). A lone item has no line.
+ _line.IsVisible = !(isFirst && isLast);
+ if (isLast)
+ {
+ _line.VerticalAlignment = VerticalAlignment.Top;
+ _line.Height = MarkerSize / 2;
+ _line.Margin = new Thickness(0);
+ }
+ else
+ {
+ _line.VerticalAlignment = VerticalAlignment.Stretch;
+ _line.Height = double.NaN;
+ _line.Margin = new Thickness(0, isFirst ? MarkerSize / 2 : 0, 0, 0);
+ }
+ _details.Margin = new Thickness(0, 0, 0, isLast ? 0 : 24);
+ }
+
+ private void Update()
+ {
+ var hasIcon = Icon != IconName.None;
+ _badge.IsVisible = hasIcon;
+ _dot.IsVisible = !hasIcon;
+ _ring.IsVisible = !hasIcon && IsActive;
+ _icon.Kind = Icon;
+ _icon.Token = IsActive ? ShellToken.Primary : ShellToken.MutedForeground;
+ _badge.Token(BorderBrushProperty, IsActive ? ShellToken.Primary : ShellToken.Border);
+ _dot.Token(BackgroundProperty, IsActive ? ShellToken.Primary : ShellToken.MutedForeground);
+
+ _title.Text = Title ?? string.Empty;
+ _time.Text = Time ?? string.Empty;
+ _time.IsVisible = !string.IsNullOrEmpty(Time);
+ _text.Text = Text ?? string.Empty;
+ _text.IsVisible = !string.IsNullOrEmpty(Text);
+ _body.Child = Body;
+ _body.IsVisible = Body != null;
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/ToggleGroup.cs b/examples/Avalonia.Demo/Components/UI/ToggleGroup.cs
new file mode 100644
index 0000000..5977b14
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/ToggleGroup.cs
@@ -0,0 +1,263 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Linq;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// A row of toggles that work as one control: pick one (default) or several (Multiple).
+//
+//
+//
+//
+// ...
+public class ToggleGroup : Border
+{
+ public static readonly StyledProperty MultipleProperty =
+ AvaloniaProperty.Register(nameof(Multiple));
+
+ // The pressed item's Value in single mode; null when nothing is pressed.
+ public static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay);
+
+ // The pressed items' Values in Multiple mode.
+ public static readonly StyledProperty?> ValuesProperty =
+ AvaloniaProperty.Register?>(nameof(Values), defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty VariantProperty =
+ AvaloniaProperty.Register(nameof(Variant));
+
+ private readonly StackPanel _row = new() { Orientation = Orientation.Horizontal, Spacing = 4 };
+
+ static ToggleGroup()
+ {
+ MultipleProperty.Changed.AddClassHandler((g, _) => g.Refresh());
+ VariantProperty.Changed.AddClassHandler((g, _) => g.Refresh());
+ ValueProperty.Changed.AddClassHandler((g, _) =>
+ {
+ g.Refresh();
+ g.ValueChanged?.Invoke(g, g.Value);
+ });
+ ValuesProperty.Changed.AddClassHandler((g, e) => g.OnValuesReplaced(e.OldValue, e.NewValue));
+ }
+
+ public bool Multiple
+ {
+ get => GetValue(MultipleProperty);
+ set => SetValue(MultipleProperty, value);
+ }
+
+ public string? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IList? Values
+ {
+ get => GetValue(ValuesProperty);
+ set => SetValue(ValuesProperty, value);
+ }
+
+ public ToggleGroupVariant Variant
+ {
+ get => GetValue(VariantProperty);
+ set => SetValue(VariantProperty, value);
+ }
+
+ [Content]
+ public Controls Items => _row.Children;
+
+ public event EventHandler? ValueChanged;
+ public event EventHandler>? ValuesChanged;
+
+ public ToggleGroup()
+ {
+ _row.Children.CollectionChanged += (_, _) => Refresh();
+ Child = _row;
+ HorizontalAlignment = HorizontalAlignment.Left;
+ }
+
+ public bool IsPressed(string? value) =>
+ value != null && (Multiple ? Values?.Contains(value) == true : Value == value);
+
+ internal void Toggle(string? value)
+ {
+ if (value is null || !IsEffectivelyEnabled) return;
+ if (Multiple)
+ {
+ // A new list each time, so a two-way binding sees the change.
+ var next = new List(Values ?? Array.Empty());
+ if (!next.Remove(value)) next.Add(value);
+ Values = next;
+ }
+ else
+ {
+ Value = Value == value ? null : value;
+ }
+ }
+
+ private void OnValuesReplaced(object? oldValue, object? newValue)
+ {
+ if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnValuesMutated;
+ if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnValuesMutated;
+ OnValuesMutated(this, null);
+ }
+
+ private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e)
+ {
+ Refresh();
+ ValuesChanged?.Invoke(this, (Values ?? Array.Empty()).ToList());
+ }
+
+ // Items don't track the group's state: the group pushes it to them.
+ private void Refresh()
+ {
+ foreach (var item in _row.Children.OfType())
+ item.Apply(IsPressed(item.Value), Variant == ToggleGroupVariant.Outline);
+ }
+}
+
+// One toggle in a ToggleGroup — h-9 min-w-9 rounded-md px-2.5, bg-accent while pressed. A tab
+// stop that toggles with Enter or Space.
+public class ToggleGroupItem : Border, IShellFocusable
+{
+ public static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value));
+
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ private readonly Icon _icon;
+ private readonly TextBlock _label;
+ private bool _pressed;
+ private bool _outline;
+
+ static ToggleGroupItem()
+ {
+ TextProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IconProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Update());
+ }
+
+ public string? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public ToggleGroupItem()
+ {
+ _icon = new Icon { Size = 16, IsVisible = false, VerticalAlignment = VerticalAlignment.Center };
+ _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center };
+ Child = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 8,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Children = { _icon, _label }
+ };
+ Height = 36;
+ MinWidth = 36;
+ Padding = new Thickness(10, 0);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ Cursor = new Cursor(StandardCursorType.Hand);
+ ShellFocus.Ring(this, this);
+ Update();
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ Update();
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ Update();
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Press();
+ e.Handled = true;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ Press();
+ e.Handled = true;
+ }
+
+ private void Press()
+ {
+ if (IsEffectivelyEnabled) this.FindParentOfType()?.Toggle(Value);
+ }
+
+ internal void Apply(bool pressed, bool outline)
+ {
+ _pressed = pressed;
+ _outline = outline;
+ Update();
+ }
+
+ private void Update()
+ {
+ _label.Text = Text ?? string.Empty;
+ _label.IsVisible = !string.IsNullOrEmpty(Text);
+ _icon.Kind = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ AutomationProperties.SetName(this, string.IsNullOrEmpty(Text) ? Icon.ToString() : Text);
+
+ BorderThickness = new Thickness(_outline ? 1 : 0);
+ this.Token(BorderBrushProperty, ShellToken.Input);
+
+ var hovered = IsPointerOver && IsEffectivelyEnabled;
+ var foreground = _pressed ? ShellToken.AccentForeground
+ : hovered ? ShellToken.MutedForeground
+ : ShellToken.Foreground;
+ _label.Token(TextBlock.ForegroundProperty, foreground);
+ _icon.Token = foreground;
+
+ if (_pressed) this.Token(BackgroundProperty, ShellToken.Accent);
+ else if (hovered) this.Token(BackgroundProperty, ShellToken.Muted);
+ else
+ {
+ // Transparent, not null, so the whole toggle stays hit-testable.
+ this.ClearToken(BackgroundProperty);
+ Background = Brushes.Transparent;
+ }
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+}
+
+public enum ToggleGroupVariant { Default, Outline }
diff --git a/examples/Avalonia.Demo/Components/UI/TreeView.cs b/examples/Avalonia.Demo/Components/UI/TreeView.cs
new file mode 100644
index 0000000..299a19a
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/TreeView.cs
@@ -0,0 +1,218 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Metadata;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Tree of expandable rows — rounded-md px-2 py-1.5 text-sm rows, hover:bg-muted, bg-accent when
+// selected; children are indented behind a hairline. Clicking a row selects it and, when it has
+// children, expands or collapses it. Rows are tab stops: Enter / Space act like a click, Right
+// expands and Left collapses.
+//
+//
+//
+//
+//
+public class TreeView : Border
+{
+ // The selected item's Value (its Text when no Value is set).
+ public static readonly StyledProperty SelectedValueProperty =
+ AvaloniaProperty.Register(nameof(SelectedValue), defaultBindingMode: BindingMode.TwoWay);
+
+ private readonly StackPanel _stack = new() { Spacing = 2 };
+
+ static TreeView()
+ {
+ SelectedValueProperty.Changed.AddClassHandler((t, _) =>
+ {
+ t.Refresh();
+ t.SelectedValueChanged?.Invoke(t, t.SelectedValue);
+ });
+ }
+
+ public string? SelectedValue
+ {
+ get => GetValue(SelectedValueProperty);
+ set => SetValue(SelectedValueProperty, value);
+ }
+
+ [Content]
+ public Controls Items => _stack.Children;
+
+ public event EventHandler? SelectedValueChanged;
+
+ public TreeView()
+ {
+ Child = _stack;
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ Refresh();
+ }
+
+ // Items don't track the selection: the tree pushes it to every item under it.
+ private void Refresh()
+ {
+ foreach (var item in this.FindDescendantsOfType())
+ item.ApplySelection(SelectedValue);
+ }
+}
+
+// One row of a TreeView. Nested TreeViewItems become its children.
+public class TreeViewItem : Border
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ // Identifies the item for selection. Defaults to Text.
+ public static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value));
+
+ public static readonly StyledProperty IconProperty =
+ AvaloniaProperty.Register(nameof(Icon));
+
+ public static readonly StyledProperty IsExpandedProperty =
+ AvaloniaProperty.Register(nameof(IsExpanded), defaultBindingMode: BindingMode.TwoWay);
+
+ private readonly Border _row;
+ private readonly Icon _chevron;
+ private readonly Icon _icon;
+ private readonly TextBlock _label;
+ private readonly Panel _childrenHost;
+ private readonly StackPanel _children = new() { Spacing = 2, Margin = new Thickness(25, 0, 0, 0) };
+ private bool _selected;
+
+ static TreeViewItem()
+ {
+ TextProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IconProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IsExpandedProperty.Changed.AddClassHandler((i, _) => i.Update());
+ IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Opacity = i.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public string? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IconName Icon
+ {
+ get => GetValue(IconProperty);
+ set => SetValue(IconProperty, value);
+ }
+
+ public bool IsExpanded
+ {
+ get => GetValue(IsExpandedProperty);
+ set => SetValue(IsExpandedProperty, value);
+ }
+
+ [Content]
+ public Controls Items => _children.Children;
+
+ public string EffectiveValue => Value ?? Text ?? string.Empty;
+
+ public TreeViewItem()
+ {
+ _chevron = new Icon { Kind = IconName.ChevronRight, Size = 16, Token = ShellToken.MutedForeground };
+ _icon = new Icon { Size = 16, Token = ShellToken.MutedForeground, IsVisible = false, VerticalAlignment = VerticalAlignment.Center };
+ Grid.SetColumn(_icon, 1);
+ _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ Grid.SetColumn(_label, 2);
+
+ // The chevron keeps its 16px slot on leaves, so labels line up within a level.
+ var slot = new Panel { Width = 16, Height = 16, VerticalAlignment = VerticalAlignment.Center, Children = { _chevron } };
+ _row = new Border
+ {
+ Child = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,Auto,*"), ColumnSpacing = 6, Children = { slot, _icon, _label } },
+ Height = 32,
+ Padding = new Thickness(8, 0),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ ShellFocus.Ring(_row, _row);
+ _row.PointerEntered += (_, _) => Paint();
+ _row.PointerExited += (_, _) => Paint();
+ _row.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Press();
+ e.Handled = true;
+ };
+ _row.KeyDown += OnRowKeyDown;
+
+ // ml-4 border-l pl-2: a hairline under the parent's chevron, children to its right.
+ var guide = new Border { Width = 1, HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(16, 0, 0, 0) };
+ guide.Token(BackgroundProperty, ShellToken.Border);
+ _children.Children.CollectionChanged += (_, _) => Update();
+ _childrenHost = new Panel { Margin = new Thickness(0, 2, 0, 0), IsVisible = false, Children = { guide, _children } };
+
+ Child = new StackPanel { Children = { _row, _childrenHost } };
+ Update();
+ }
+
+ private bool HasChildren => _children.Children.Count > 0;
+
+ private void OnRowKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (!IsEffectivelyEnabled) return;
+ if (e.Key is Key.Enter or Key.Space) Press();
+ else if (e.Key == Key.Right && HasChildren && !IsExpanded) IsExpanded = true;
+ else if (e.Key == Key.Left && HasChildren && IsExpanded) IsExpanded = false;
+ else return;
+ e.Handled = true;
+ }
+
+ private void Press()
+ {
+ if (!IsEffectivelyEnabled) return;
+ if (HasChildren) IsExpanded = !IsExpanded;
+ if (this.FindParentOfType() is { } tree) tree.SelectedValue = EffectiveValue;
+ }
+
+ internal void ApplySelection(string? selected)
+ {
+ _selected = selected != null && selected == EffectiveValue;
+ Paint();
+ }
+
+ private void Update()
+ {
+ _label.Text = Text ?? string.Empty;
+ _icon.Kind = Icon;
+ _icon.IsVisible = Icon != IconName.None;
+ _chevron.IsVisible = HasChildren;
+ _chevron.Kind = IsExpanded ? IconName.ChevronDown : IconName.ChevronRight;
+ _childrenHost.IsVisible = HasChildren && IsExpanded;
+ Paint();
+ }
+
+ private void Paint()
+ {
+ _label.FontWeight = _selected ? FontWeight.Medium : FontWeight.Normal;
+ _label.Token(TextBlock.ForegroundProperty, _selected ? ShellToken.AccentForeground : ShellToken.Foreground);
+ if (_selected) _row.Token(BackgroundProperty, ShellToken.Accent);
+ else if (_row.IsPointerOver && IsEffectivelyEnabled) _row.Token(BackgroundProperty, ShellToken.Muted);
+ else
+ {
+ // Transparent, not null, so the whole row stays hit-testable.
+ _row.ClearToken(BackgroundProperty);
+ _row.Background = Brushes.Transparent;
+ }
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/WrapLayout.cs b/examples/Avalonia.Demo/Components/UI/WrapLayout.cs
new file mode 100644
index 0000000..a330e21
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/WrapLayout.cs
@@ -0,0 +1,136 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Controls;
+
+namespace AvaloniaDemo.Components.UI;
+
+// flex flex-wrap gap-2 — children flow left to right and wrap onto new rows, centered vertically
+// within their row. Use it for button rows, chips and tag lists.
+//
+// ...
+//
+// Unlike WrapPanel, the last child can take the rest of its row (LastChildFill).
+public class WrapLayout : Panel
+{
+ // Gap between children in a row.
+ public static readonly StyledProperty SpacingProperty =
+ AvaloniaProperty.Register(nameof(Spacing), 8);
+
+ // Gap between rows.
+ public static readonly StyledProperty LineSpacingProperty =
+ AvaloniaProperty.Register(nameof(LineSpacing), 8);
+
+ // The last child takes what is left of its row (or a row of its own when less than
+ // MinimumLastChildWidth remains) — e.g. the text field after a run of chips.
+ public static readonly StyledProperty LastChildFillProperty =
+ AvaloniaProperty.Register(nameof(LastChildFill));
+
+ public static readonly StyledProperty MinimumLastChildWidthProperty =
+ AvaloniaProperty.Register(nameof(MinimumLastChildWidth), 96);
+
+ static WrapLayout()
+ {
+ AffectsMeasure(SpacingProperty, LineSpacingProperty, LastChildFillProperty, MinimumLastChildWidthProperty);
+ }
+
+ public double Spacing
+ {
+ get => GetValue(SpacingProperty);
+ set => SetValue(SpacingProperty, value);
+ }
+
+ public double LineSpacing
+ {
+ get => GetValue(LineSpacingProperty);
+ set => SetValue(LineSpacingProperty, value);
+ }
+
+ public bool LastChildFill
+ {
+ get => GetValue(LastChildFillProperty);
+ set => SetValue(LastChildFillProperty, value);
+ }
+
+ public double MinimumLastChildWidth
+ {
+ get => GetValue(MinimumLastChildWidthProperty);
+ set => SetValue(MinimumLastChildWidthProperty, value);
+ }
+
+ protected override Size MeasureOverride(Size availableSize) => Flow(availableSize.Width, arrange: false);
+
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ Flow(finalSize.Width, arrange: true);
+ return finalSize;
+ }
+
+ private Size Flow(double available, bool arrange)
+ {
+ var bounded = !double.IsInfinity(available);
+ var row = new List<(Control Child, double X, double Width, double Height)>();
+ double x = 0, y = 0, rowHeight = 0, widest = 0;
+
+ var last = -1;
+ for (var i = Children.Count - 1; i >= 0 && last < 0; i--)
+ if (Children[i].IsVisible) last = i;
+
+ void EndRow()
+ {
+ if (arrange)
+ {
+ foreach (var (child, left, width, height) in row)
+ child.Arrange(new Rect(left, y + (rowHeight - height) / 2, width, height));
+ }
+ row.Clear();
+ y += rowHeight + LineSpacing;
+ x = 0;
+ rowHeight = 0;
+ }
+
+ for (var i = 0; i <= last; i++)
+ {
+ var child = Children[i];
+ if (!child.IsVisible) continue;
+
+ var start = row.Count > 0 ? x + Spacing : 0;
+ double width, height;
+ if (LastChildFill && i == last)
+ {
+ var rest = bounded ? available - start : MinimumLastChildWidth;
+ if (bounded && rest < MinimumLastChildWidth && row.Count > 0)
+ {
+ EndRow();
+ start = 0;
+ rest = available;
+ }
+ width = Math.Max(0, rest);
+ if (!arrange) child.Measure(new Size(width, double.PositiveInfinity));
+ height = child.DesiredSize.Height;
+ }
+ else
+ {
+ if (!arrange) child.Measure(new Size(available, double.PositiveInfinity));
+ var size = child.DesiredSize;
+ width = bounded ? Math.Min(size.Width, available) : size.Width;
+ height = size.Height;
+ // Half a pixel of slack so rounding can't wrap differently between passes.
+ if (bounded && row.Count > 0 && start + width > available + 0.5)
+ {
+ EndRow();
+ start = 0;
+ }
+ }
+
+ row.Add((child, start, width, height));
+ x = start + width;
+ rowHeight = Math.Max(rowHeight, height);
+ widest = Math.Max(widest, x);
+ }
+
+ var total = y + rowHeight;
+ if (row.Count > 0) EndRow();
+ return new Size(widest, last < 0 ? 0 : total);
+ }
+}
diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml
index 2dbe813..36071f9 100644
--- a/examples/Avalonia.Demo/MainWindow.axaml
+++ b/examples/Avalonia.Demo/MainWindow.axaml
@@ -427,6 +427,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs
index 0013f69..5850417 100644
--- a/examples/Avalonia.Demo/MainWindow.axaml.cs
+++ b/examples/Avalonia.Demo/MainWindow.axaml.cs
@@ -50,6 +50,24 @@ public MainWindow()
DemoStepper.CurrentStepChanged += (_, step) => AdvancedChoice.Text = $"Step {step + 1} of 3.";
DemoStepper.Confirmed += (_, _) => Toast.Success("Workspace created");
+ var frameworks = new[] { ".NET MAUI", "Avalonia", "Blazor", "Uno Platform", "WinUI", "WPF", "Flutter", "React Native", "SwiftUI", "Jetpack Compose" };
+ FrameworkCombobox.ItemsSource = frameworks;
+ FrameworkMultiSelect.ItemsSource = frameworks;
+ FrameworkMultiSelect.Values = new[] { "Avalonia", "Blazor" };
+ DemoTags.Tags = new[] { "avalonia", "design-system" };
+ ViewToggleGroup.ValueChanged += (_, value) => FormChoice.Text = $"Toggle group: {value ?? "none"}.";
+ MarksToggleGroup.ValuesChanged += (_, values) => FormChoice.Text = $"Toggle group: {(values.Count == 0 ? "none" : string.Join(", ", values))}.";
+ DemoOtp.Completed += (_, code) => FormChoice.Text = $"OTP complete: {code}.";
+ QuantityInput.ValueChanged += (_, value) => FormChoice.Text = $"Number: {(value.HasValue ? value.ToString() : "empty")}.";
+ DemoTags.TagsChanged += (_, tags) => FormChoice.Text = $"Tags: {string.Join(", ", tags)}.";
+ FrameworkCombobox.ValueChanged += (_, value) => FormChoice.Text = $"Combobox: {value}.";
+ FrameworkMultiSelect.ValuesChanged += (_, values) => FormChoice.Text = $"Multi select: {(values.Count == 0 ? "none" : string.Join(", ", values))}.";
+ CopyCommand.Copied += (_, _) => DisplayChoice.Text = $"Copied: {CopyCommand.Text}.";
+ CopyLink.Copied += (_, _) => DisplayChoice.Text = $"Copied: {CopyLink.Text}.";
+ DocsCard.Clicked += (_, _) => DisplayChoice.Text = $"Link card: {DocsCard.Title}.";
+ SourceCard.Clicked += (_, _) => DisplayChoice.Text = $"Link card: {SourceCard.Title}.";
+ FileTree.SelectedValueChanged += (_, value) => DisplayChoice.Text = $"Tree: {value}.";
+
for (var i = 1; i <= 30; i++)
{
var tag = new TextBlock { Text = $"v1.2.0-beta.{31 - i}", FontSize = 14 };
diff --git a/src/ShellUI.Native.Templates/Templates/AspectRatioTemplate.cs b/src/ShellUI.Native.Templates/Templates/AspectRatioTemplate.cs
index 507c648..54c412c 100644
--- a/src/ShellUI.Native.Templates/Templates/AspectRatioTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/AspectRatioTemplate.cs
@@ -50,6 +50,49 @@ private void Fit()
if (Math.Abs(HeightRequest - height) > 0.5) HeightRequest = height;
}
}
+",
+ [NativePlatform.Avalonia] = @"using Avalonia;
+using Avalonia.Controls;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Keeps its content at a fixed width / height ratio: the height follows the width.
+//
+//
+//
+public class AspectRatio : Border
+{
+ // Width divided by height. 16:9 is 1.7778, 4:3 is 1.3333, a square is 1.
+ public static readonly StyledProperty RatioProperty =
+ AvaloniaProperty.Register(nameof(Ratio), 16d / 9d);
+
+ static AspectRatio()
+ {
+ AffectsMeasure(RatioProperty);
+ }
+
+ public double Ratio
+ {
+ get => GetValue(RatioProperty);
+ set => SetValue(RatioProperty, value);
+ }
+
+ public AspectRatio()
+ {
+ // With a CornerRadius the content is clipped to the rounded shape.
+ ClipToBounds = true;
+ }
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ if (Ratio <= 0) return base.MeasureOverride(availableSize);
+ // Without a width to fill (e.g. in a horizontal stack) it takes the content's width.
+ var width = double.IsInfinity(availableSize.Width) ? base.MeasureOverride(availableSize).Width : availableSize.Width;
+ var size = new Size(width, width / Ratio);
+ base.MeasureOverride(size);
+ return size;
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/ComboboxTemplate.cs b/src/ShellUI.Native.Templates/Templates/ComboboxTemplate.cs
index 1fb1291..4a79d47 100644
--- a/src/ShellUI.Native.Templates/Templates/ComboboxTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/ComboboxTemplate.cs
@@ -305,6 +305,324 @@ private void Filter()
_empty.IsVisible = !any;
}
}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Threading;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Combobox — a Select with a search field: h-10 trigger, and a floating panel with a filter
+// input above the option list (check on the selected option, ""no results"" when nothing matches).
+// Type to filter, Up / Down to move, Enter to pick, Escape to close.
+// Usage: then set
+// ItemsSource in code or by binding.
+public class Combobox : Border
+{
+ public static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty?> ItemsSourceProperty =
+ AvaloniaProperty.Register?>(nameof(ItemsSource));
+
+ public static readonly StyledProperty PlaceholderProperty =
+ AvaloniaProperty.Register(nameof(Placeholder), ""Select..."");
+
+ public static readonly StyledProperty SearchPlaceholderProperty =
+ AvaloniaProperty.Register(nameof(SearchPlaceholder), ""Search..."");
+
+ public static readonly StyledProperty EmptyTextProperty =
+ AvaloniaProperty.Register(nameof(EmptyText), ""No results found."");
+
+ private const double ItemHeight = 32;
+ private const double MaxListHeight = 240;
+
+ private readonly Border _trigger;
+ private readonly TextBlock _value;
+ private readonly Border _panel;
+ private readonly TextBox _search;
+ private readonly StackPanel _list = new();
+ private readonly Panel _listArea;
+ private readonly TextBlock _empty;
+ private readonly List<(string Text, Border Row, Icon Check)> _rows = new();
+ private readonly ShellAnchoredPopup _popup;
+ private int _active = -1;
+
+ static Combobox()
+ {
+ ValueProperty.Changed.AddClassHandler((c, _) =>
+ {
+ c.UpdateTrigger();
+ c.UpdateChecks();
+ c.ValueChanged?.Invoke(c, c.Value ?? string.Empty);
+ });
+ ItemsSourceProperty.Changed.AddClassHandler((c, _) => c.RebuildItems());
+ PlaceholderProperty.Changed.AddClassHandler((c, _) => c.UpdateTrigger());
+ SearchPlaceholderProperty.Changed.AddClassHandler((c, e) => c._search.PlaceholderText = (string?)e.NewValue);
+ EmptyTextProperty.Changed.AddClassHandler((c, e) => c._empty.Text = (string?)e.NewValue);
+ IsEnabledProperty.Changed.AddClassHandler((c, _) => c.Opacity = c.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public string? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public IList? ItemsSource
+ {
+ get => GetValue(ItemsSourceProperty);
+ set => SetValue(ItemsSourceProperty, value);
+ }
+
+ public string? Placeholder
+ {
+ get => GetValue(PlaceholderProperty);
+ set => SetValue(PlaceholderProperty, value);
+ }
+
+ public string? SearchPlaceholder
+ {
+ get => GetValue(SearchPlaceholderProperty);
+ set => SetValue(SearchPlaceholderProperty, value);
+ }
+
+ public string? EmptyText
+ {
+ get => GetValue(EmptyTextProperty);
+ set => SetValue(EmptyTextProperty, value);
+ }
+
+ public bool IsOpen => _popup.IsOpen;
+
+ public event EventHandler? ValueChanged;
+
+ public Combobox()
+ {
+ _value = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ var chevrons = new Icon { Kind = IconName.ChevronsUpDown, Size = 16, Token = ShellToken.MutedForeground };
+ Grid.SetColumn(chevrons, 1);
+ _trigger = new Border
+ {
+ Child = new Grid { ColumnDefinitions = new ColumnDefinitions(""*,Auto""), ColumnSpacing = 8, Children = { _value, chevrons } },
+ Height = 40,
+ Padding = new Thickness(12, 0),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ _trigger.Token(BorderBrushProperty, ShellToken.Input);
+ _trigger.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return;
+ SetOpen(!IsOpen);
+ e.Handled = true;
+ };
+
+ _search = new TextBox { FontSize = 14, PlaceholderText = SearchPlaceholder, VerticalAlignment = VerticalAlignment.Center, VerticalContentAlignment = VerticalAlignment.Center };
+ ShellPlatform.StripNativeChrome(_search);
+ _search.TextChanged += (_, _) => Filter();
+ _search.KeyDown += OnSearchKeyDown;
+ Grid.SetColumn(_search, 1);
+ var searchRow = new Border
+ {
+ Child = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions(""Auto,*""),
+ ColumnSpacing = 8,
+ Children = { new Icon { Kind = IconName.Search, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }, _search }
+ },
+ Height = 40,
+ Padding = new Thickness(12, 0)
+ };
+
+ var divider = new Border { Height = 1 };
+ divider.Token(BackgroundProperty, ShellToken.Border);
+
+ _empty = new TextBlock { Text = EmptyText, FontSize = 14, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsVisible = false };
+ _empty.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ // Fixed height (set in RebuildItems): the panel keeps its size and position while filtering.
+ _listArea = new Panel { Margin = new Thickness(4), Children = { new ScrollViewer { Content = _list }, _empty } };
+
+ _panel = new Border
+ {
+ Child = new StackPanel { Children = { searchRow, divider, _listArea } },
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ BoxShadow = ShellPopups.PanelShadow()
+ };
+ _panel.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border);
+
+ _popup = new ShellAnchoredPopup(_trigger);
+ _popup.Popup.Child = _panel;
+ _popup.Closed += (_, _) =>
+ {
+ _trigger.Token(BorderBrushProperty, ShellToken.Input);
+ // Focus goes back to the trigger instead of being lost with the panel.
+ if (_search.IsFocused) Focus();
+ };
+
+ Child = new Panel { Children = { _trigger, _popup.Popup } };
+ ShellFocus.Ring(this, _trigger);
+ RebuildItems();
+ }
+
+ public void SetOpen(bool open)
+ {
+ if (open == _popup.IsOpen) return;
+ if (!open)
+ {
+ _popup.Close();
+ return;
+ }
+ _search.Text = string.Empty;
+ Filter();
+ // The panel matches the trigger's width.
+ _panel.Width = _trigger.Bounds.Width;
+ _trigger.Token(BorderBrushProperty, ShellToken.Ring);
+ _popup.Open();
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (IsOpen) _search.Focus();
+ }, DispatcherPriority.Loaded);
+ }
+
+ public void Close() => SetOpen(false);
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ // Only keys pressed on the closed trigger: the search field handles its own.
+ if (IsOpen || !ReferenceEquals(e.Source, this) || e.Key is not (Key.Enter or Key.Space or Key.Down)) return;
+ SetOpen(true);
+ e.Handled = true;
+ }
+
+ private void OnSearchKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter)
+ {
+ if (_active >= 0) Pick(_rows[_active].Text);
+ }
+ else if (e.Key == Key.Down) MoveActive(1);
+ else if (e.Key == Key.Up) MoveActive(-1);
+ else return;
+ e.Handled = true;
+ }
+
+ private void Pick(string text)
+ {
+ Value = text;
+ SetOpen(false);
+ }
+
+ private void UpdateTrigger()
+ {
+ var hasValue = !string.IsNullOrEmpty(Value);
+ _value.Text = hasValue ? Value : Placeholder ?? string.Empty;
+ _value.Token(TextBlock.ForegroundProperty, hasValue ? ShellToken.Foreground : ShellToken.MutedForeground);
+ }
+
+ private void RebuildItems()
+ {
+ _list.Children.Clear();
+ _rows.Clear();
+ _active = -1;
+ var items = ItemsSource;
+ _listArea.Height = Math.Min(Math.Max(items?.Count ?? 0, 2) * ItemHeight, MaxListHeight);
+ if (items != null)
+ {
+ foreach (var text in items)
+ _list.Children.Add(CreateItem(text, _rows.Count));
+ }
+ UpdateTrigger();
+ UpdateChecks();
+ Filter();
+ }
+
+ private Control CreateItem(string text, int index)
+ {
+ var label = new TextBlock { Text = text, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis };
+ label.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground);
+ var check = new Icon { Kind = IconName.Check, Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground };
+ Grid.SetColumn(check, 1);
+
+ var item = new Border
+ {
+ Child = new Grid { ColumnDefinitions = new ColumnDefinitions(""*,Auto""), ColumnSpacing = 8, Children = { label, check } },
+ Height = ItemHeight,
+ Padding = new Thickness(8, 0),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm),
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ // The pointer and the arrow keys move the same highlight.
+ item.PointerMoved += (_, _) => SetActive(index);
+ item.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ Pick(text);
+ e.Handled = true;
+ };
+
+ _rows.Add((text, item, check));
+ return item;
+ }
+
+ private void UpdateChecks()
+ {
+ foreach (var (text, _, check) in _rows)
+ check.IsVisible = text == Value;
+ }
+
+ private void Filter()
+ {
+ var query = _search.Text?.Trim() ?? string.Empty;
+ var first = -1;
+ for (var i = 0; i < _rows.Count; i++)
+ {
+ var (text, row, _) = _rows[i];
+ row.IsVisible = query.Length == 0 || text.Contains(query, StringComparison.OrdinalIgnoreCase);
+ if (row.IsVisible && first < 0) first = i;
+ }
+ _empty.IsVisible = first < 0;
+ SetActive(first);
+ }
+
+ private void MoveActive(int direction)
+ {
+ for (var i = _active + direction; i >= 0 && i < _rows.Count; i += direction)
+ {
+ if (!_rows[i].Row.IsVisible) continue;
+ SetActive(i);
+ _rows[i].Row.BringIntoView();
+ return;
+ }
+ }
+
+ private void SetActive(int index)
+ {
+ if (index == _active) return;
+ _active = index;
+ for (var i = 0; i < _rows.Count; i++)
+ {
+ var row = _rows[i].Row;
+ if (i == index) row.Token(BackgroundProperty, ShellToken.Accent);
+ else
+ {
+ row.ClearToken(BackgroundProperty);
+ row.Background = Brushes.Transparent;
+ }
+ }
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/CopyButtonTemplate.cs b/src/ShellUI.Native.Templates/Templates/CopyButtonTemplate.cs
index f262214..d3b07f3 100644
--- a/src/ShellUI.Native.Templates/Templates/CopyButtonTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/CopyButtonTemplate.cs
@@ -149,6 +149,153 @@ protected override void OnPropertyChanged(string? propertyName = null)
if (propertyName == IsEnabledProperty.PropertyName && _border != null) Update();
}
}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Copies Text to the clipboard — a ghost icon button (h-9 w-9 rounded-md, hover:bg-accent) whose
+// icon turns into a check for two seconds after copying. Label adds text next to the icon.
+// Usage:
+public class CopyButton : Border, IShellFocusable
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ public static readonly StyledProperty LabelProperty =
+ AvaloniaProperty.Register(nameof(Label));
+
+ private readonly Icon _icon;
+ private readonly TextBlock _label;
+ private bool _copied;
+ private int _version;
+
+ static CopyButton()
+ {
+ TextProperty.Changed.AddClassHandler((b, _) => b.Update());
+ LabelProperty.Changed.AddClassHandler((b, _) => b.Update());
+ IsEnabledProperty.Changed.AddClassHandler((b, _) => b.Update());
+ }
+
+ // The text placed on the clipboard.
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public string? Label
+ {
+ get => GetValue(LabelProperty);
+ set => SetValue(LabelProperty, value);
+ }
+
+ public event EventHandler? Copied;
+
+ public CopyButton()
+ {
+ _icon = new Icon { Kind = IconName.Copy, Size = 16, VerticalAlignment = VerticalAlignment.Center };
+ _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, IsVisible = false };
+ Child = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 8,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ Children = { _icon, _label }
+ };
+ Height = 36;
+ MinWidth = 36;
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd);
+ HorizontalAlignment = HorizontalAlignment.Left;
+ Cursor = new Cursor(StandardCursorType.Hand);
+ ShellFocus.Ring(this, this);
+ Update();
+ }
+
+ protected override void OnPointerEntered(PointerEventArgs e)
+ {
+ base.OnPointerEntered(e);
+ Update();
+ }
+
+ protected override void OnPointerExited(PointerEventArgs e)
+ {
+ base.OnPointerExited(e);
+ Update();
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left || !new Rect(Bounds.Size).Contains(e.GetPosition(this))) return;
+ _ = CopyAsync();
+ e.Handled = true;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ _ = CopyAsync();
+ e.Handled = true;
+ }
+
+ public async Task CopyAsync()
+ {
+ if (!IsEffectivelyEnabled || string.IsNullOrEmpty(Text)) return;
+ if (TopLevel.GetTopLevel(this)?.Clipboard is not { } clipboard) return;
+ try
+ {
+ await clipboard.SetTextAsync(Text);
+ }
+ catch (Exception)
+ {
+ // No clipboard access: leave the icon as it is.
+ return;
+ }
+
+ var version = ++_version;
+ _copied = true;
+ Update();
+ Copied?.Invoke(this, EventArgs.Empty);
+ await Task.Delay(2000);
+ if (version != _version) return;
+ _copied = false;
+ Update();
+ }
+
+ private void Update()
+ {
+ var hasLabel = !string.IsNullOrEmpty(Label);
+ _label.Text = Label ?? string.Empty;
+ _label.IsVisible = hasLabel;
+ Padding = new Thickness(hasLabel ? 12 : 0, 0);
+
+ var hovered = IsPointerOver && IsEffectivelyEnabled;
+ _icon.Kind = _copied ? IconName.Check : IconName.Copy;
+ _icon.Token = _copied ? ShellToken.Success : hovered ? ShellToken.AccentForeground : ShellToken.Foreground;
+ _label.Token(TextBlock.ForegroundProperty, hovered ? ShellToken.AccentForeground : ShellToken.Foreground);
+
+ if (hovered) this.Token(BackgroundProperty, ShellToken.Accent);
+ else
+ {
+ // Transparent, not null, so the whole button stays hit-testable.
+ this.ClearToken(BackgroundProperty);
+ Background = Brushes.Transparent;
+ }
+ Opacity = IsEnabled && !string.IsNullOrEmpty(Text) ? 1.0 : 0.5;
+ AutomationProperties.SetName(this, _copied ? ""Copied"" : ""Copy to clipboard"");
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/InputOtpTemplate.cs b/src/ShellUI.Native.Templates/Templates/InputOtpTemplate.cs
index 6841406..0de5335 100644
--- a/src/ShellUI.Native.Templates/Templates/InputOtpTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/InputOtpTemplate.cs
@@ -192,6 +192,206 @@ private void UpdateSlots()
}
}
}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Threading;
+
+namespace YourProjectNamespace.Components.UI;
+
+// One-time-code input — a row of 40px slots (rounded-md border border-input) over one invisible
+// text box, so typing, paste and input methods all work. The active slot shows the ring color
+// and a blinking caret.
+// Usage:
+public class InputOtp : Border
+{
+ public static readonly StyledProperty LengthProperty =
+ AvaloniaProperty.Register(nameof(Length), 6);
+
+ public static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value), string.Empty, defaultBindingMode: BindingMode.TwoWay);
+
+ // Digits only. False allows letters too.
+ public static readonly StyledProperty IsNumericProperty =
+ AvaloniaProperty.Register(nameof(IsNumeric), true);
+
+ public static readonly StyledProperty HasErrorProperty =
+ AvaloniaProperty.Register(nameof(HasError));
+
+ private const double SlotSize = 40;
+
+ private readonly TextBox _textBox;
+ private readonly StackPanel _slots = new() { Orientation = Orientation.Horizontal, Spacing = 8, IsHitTestVisible = false };
+ private readonly List<(Border Box, TextBlock Text, Border Caret)> _items = new();
+ private DispatcherTimer? _blink;
+ private bool _focused;
+
+ static InputOtp()
+ {
+ LengthProperty.Changed.AddClassHandler((o, _) => o.BuildSlots());
+ ValueProperty.Changed.AddClassHandler((o, _) => o.OnValueChanged());
+ IsNumericProperty.Changed.AddClassHandler((o, _) => o.OnValueChanged());
+ HasErrorProperty.Changed.AddClassHandler((o, _) => o.UpdateSlots());
+ IsEnabledProperty.Changed.AddClassHandler((o, _) => o.Opacity = o.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public int Length
+ {
+ get => GetValue(LengthProperty);
+ set => SetValue(LengthProperty, value);
+ }
+
+ public string? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public bool IsNumeric
+ {
+ get => GetValue(IsNumericProperty);
+ set => SetValue(IsNumericProperty, value);
+ }
+
+ public bool HasError
+ {
+ get => GetValue(HasErrorProperty);
+ set => SetValue(HasErrorProperty, value);
+ }
+
+ public event EventHandler? ValueChanged;
+ // Raised when every slot is filled.
+ public event EventHandler? Completed;
+
+ public InputOtp()
+ {
+ // The real input: stretched over the slots and invisible, so a click anywhere focuses it.
+ _textBox = new TextBox { Opacity = 0, Cursor = new Cursor(StandardCursorType.Ibeam) };
+ ShellPlatform.StripNativeChrome(_textBox);
+ AutomationProperties.SetName(_textBox, ""Verification code"");
+ _textBox.TextChanged += (_, _) =>
+ {
+ // Characters that don't belong (or don't fit) never stay in the hidden text.
+ var value = Sanitize(_textBox.Text);
+ if (_textBox.Text != value)
+ {
+ _textBox.Text = value;
+ _textBox.CaretIndex = value.Length;
+ }
+ Value = value;
+ };
+ _textBox.GotFocus += (_, _) =>
+ {
+ _focused = true;
+ _textBox.CaretIndex = _textBox.Text?.Length ?? 0;
+ UpdateSlots();
+ };
+ _textBox.LostFocus += (_, _) =>
+ {
+ _focused = false;
+ UpdateSlots();
+ };
+
+ Child = new Panel { Children = { _slots, _textBox } };
+ HorizontalAlignment = HorizontalAlignment.Left;
+ BuildSlots();
+ }
+
+ public new bool Focus(NavigationMethod method = NavigationMethod.Unspecified, KeyModifiers keyModifiers = KeyModifiers.None)
+ => _textBox.Focus(method, keyModifiers);
+
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ StopBlink();
+ }
+
+ private int SlotCount => Math.Max(1, Length);
+
+ private string Sanitize(string? text)
+ {
+ var filtered = new string((text ?? string.Empty).Where(c => IsNumeric ? char.IsDigit(c) : char.IsLetterOrDigit(c)).ToArray());
+ return filtered.Length > SlotCount ? filtered[..SlotCount] : filtered;
+ }
+
+ private void BuildSlots()
+ {
+ _slots.Children.Clear();
+ _items.Clear();
+ for (var i = 0; i < SlotCount; i++)
+ {
+ var text = new TextBlock { FontSize = 16, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ text.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ var caret = new Border { Width = 1.5, Height = 18, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsVisible = false };
+ caret.Token(BackgroundProperty, ShellToken.Foreground);
+ var box = new Border
+ {
+ Child = new Panel { Children = { text, caret } },
+ Width = SlotSize,
+ Height = SlotSize,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd)
+ };
+ _items.Add((box, text, caret));
+ _slots.Children.Add(box);
+ }
+ OnValueChanged();
+ }
+
+ private void OnValueChanged()
+ {
+ var value = Sanitize(Value);
+ if (value != Value)
+ {
+ Value = value;
+ return;
+ }
+ if (_textBox.Text != value)
+ {
+ _textBox.Text = value;
+ _textBox.CaretIndex = value.Length;
+ }
+ UpdateSlots();
+ ValueChanged?.Invoke(this, value);
+ if (value.Length == Length && Length > 0) Completed?.Invoke(this, value);
+ }
+
+ private void UpdateSlots()
+ {
+ var value = Value ?? string.Empty;
+ // The caret sits in the next empty slot (the last one when the code is complete).
+ var active = _focused ? Math.Min(value.Length, _items.Count - 1) : -1;
+ for (var i = 0; i < _items.Count; i++)
+ {
+ var (box, text, caret) = _items[i];
+ text.Text = i < value.Length ? value[i].ToString() : string.Empty;
+ var isActive = i == active;
+ box.Token(BorderBrushProperty, HasError ? ShellToken.Destructive : isActive ? ShellToken.Ring : ShellToken.Input);
+ caret.IsVisible = isActive && i >= value.Length;
+ caret.Opacity = 1;
+ }
+
+ StopBlink();
+ if (active < 0 || active < value.Length) return;
+ var blinking = _items[active].Caret;
+ _blink = new DispatcherTimer(TimeSpan.FromMilliseconds(500), DispatcherPriority.Normal,
+ (_, _) => blinking.Opacity = blinking.Opacity > 0.5 ? 0 : 1);
+ _blink.Start();
+ }
+
+ private void StopBlink()
+ {
+ _blink?.Stop();
+ _blink = null;
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/KbdTemplate.cs b/src/ShellUI.Native.Templates/Templates/KbdTemplate.cs
index 1bdbda8..89dab7c 100644
--- a/src/ShellUI.Native.Templates/Templates/KbdTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/KbdTemplate.cs
@@ -66,6 +66,56 @@ public Kbd()
VerticalOptions = LayoutOptions.Center;
}
}
+",
+ [NativePlatform.Avalonia] = @"using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Keyboard key — h-5 rounded border bg-muted px-1.5 text-[11px] font-medium text-muted-foreground.
+// Usage:
+public class Kbd : Border
+{
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ private readonly TextBlock _label;
+
+ static Kbd()
+ {
+ TextProperty.Changed.AddClassHandler((k, e) => k._label.Text = (string?)e.NewValue ?? string.Empty);
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public Kbd()
+ {
+ _label = new TextBlock
+ {
+ FontSize = 11,
+ FontWeight = FontWeight.Medium,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ _label.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+
+ Child = _label;
+ Height = 22;
+ MinWidth = 22;
+ Padding = new Thickness(6, 0);
+ BorderThickness = new Thickness(1);
+ CornerRadius = new CornerRadius(ShellTheme.RadiusSm);
+ HorizontalAlignment = HorizontalAlignment.Left;
+ VerticalAlignment = VerticalAlignment.Center;
+ this.Token(BackgroundProperty, ShellToken.Muted).Token(BorderBrushProperty, ShellToken.Border);
+ }
+}
"
};
}
diff --git a/src/ShellUI.Native.Templates/Templates/LinkCardTemplate.cs b/src/ShellUI.Native.Templates/Templates/LinkCardTemplate.cs
index fc37412..93d7032 100644
--- a/src/ShellUI.Native.Templates/Templates/LinkCardTemplate.cs
+++ b/src/ShellUI.Native.Templates/Templates/LinkCardTemplate.cs
@@ -143,6 +143,153 @@ private void Update()
_description.IsVisible = !string.IsNullOrEmpty(Description);
}
}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Clickable card — rounded-lg border bg-card p-4, hover:bg-accent: an optional icon, a title, a
+// muted description and an arrow. A click (or Enter / Space) raises Clicked and opens Url when
+// one is set.
+// Usage: