diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md
index 9a69fd7..ed15b90 100644
--- a/docs/COMPONENTS.md
+++ b/docs/COMPONENTS.md
@@ -68,8 +68,9 @@ your background so the splash-to-app transition doesn't flash the template's pur
## Avalonia (in progress)
Ported so far: `shell`, `icon`, `theme-toggle`, P0 (`button`, `input`, `label`, `checkbox`,
-`switch`, `card` and its parts, `separator`, `badge`, `progress`, `alert`) and P1 (`dialog`,
-`drawer`, `sheet`, `dropdown`, `popover` and their parts). Not yet published to NuGet. In an
+`switch`, `card` and its parts, `separator`, `badge`, `progress`, `alert`), P1 (`dialog`,
+`drawer`, `sheet`, `dropdown`, `popover` and their parts) and P2 (`textarea`, `select`, `slider`,
+`radio-group`, `calendar`, `date-picker`, `time-picker`). Not yet published to NuGet. In an
Avalonia project, `shellui-native list` shows only these.
Overlays use Avalonia's `Popup` in the window's overlay layer: the content stays in the logical
@@ -95,6 +96,8 @@ Same names, properties and variants as MAUI, except:
| Overlay children | XAML children | XAML children, collected in `Items` (`Dialog`, `DialogContent`, `Dropdown`, …) |
| Dropdown / Popover | `ToggleAsync()` / `CloseAsync()` kept for older code | `Toggle()` / `Close()` only |
| Flipped popup gap | 4px above the trigger | Flush with the trigger (Avalonia doesn't mirror the offset) |
+| Slider | Platform slider tinted with tokens | Custom-drawn shadcn slider; adds `Step` for the arrow keys; `ValueChanged` passes the new `double` |
+| DatePicker `DateChanged` | `DateChangedEventArgs` | The new `DateTime` |
---
diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md
index dc11d05..b88f185 100644
--- a/docs/DEVELOPMENT_PLAN.md
+++ b/docs/DEVELOPMENT_PLAN.md
@@ -462,6 +462,9 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase.
`ShellTriggerView`, `ShellDismiss` for Escape). Each installs alone into a fresh
`dotnet new avalonia.app`; checked in the demo: opening from triggers, placement and flip,
Escape, picking an item, `DialogClose`, light and dark. Open: focus trapping in dialogs
+- [x] P2: textarea, select, slider (custom-drawn), radio-group, calendar, date-picker, time-picker.
+ Pickers float their panel with `ShellAnchoredPopup` (shared with Dropdown and Popover);
+ `ShellPlatform.StripNativeChrome` clears Fluent's TextBox chrome for Input and Textarea
- [ ] 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 6bf175e..3c13a7e 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 and P1 components; not on NuGet yet | .NET 10.0 (Avalonia 12) |
+| Avalonia UI | In progress (Phase 2): theme, icons, P0–P2 components; not on NuGet yet | .NET 10.0 (Avalonia 12) |
| WinUI 3 | Conditional (Phase 3) | .NET 10.0 |
WPF is intentionally not on this list — it's recognized for project detection only, not an
diff --git a/examples/Avalonia.Demo/Components/UI/Calendar.cs b/examples/Avalonia.Demo/Components/UI/Calendar.cs
new file mode 100644
index 0000000..0004b52
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Calendar.cs
@@ -0,0 +1,214 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Month calendar — prev/next header, weekday row, 6x7 day grid of 32px cells. Selected day is
+// bg-primary, today is bg-accent, days outside the month are muted. Month names, weekday names
+// and the first day of the week follow the current culture.
+// Usage:
+public class Calendar : Border
+{
+ public static readonly StyledProperty SelectedDateProperty =
+ AvaloniaProperty.Register(nameof(SelectedDate), defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty MinimumDateProperty =
+ AvaloniaProperty.Register(nameof(MinimumDate));
+
+ public static readonly StyledProperty MaximumDateProperty =
+ AvaloniaProperty.Register(nameof(MaximumDate));
+
+ private const double Cell = 32;
+
+ private DateTime _month = new(DateTime.Today.Year, DateTime.Today.Month, 1);
+ private readonly TextBlock _title;
+ private readonly List<(Border Box, TextBlock Text)> _cells = new();
+ private readonly DateTime[] _dates = new DateTime[42];
+ private int _hovered = -1;
+
+ static Calendar()
+ {
+ SelectedDateProperty.Changed.AddClassHandler((c, _) => c.OnSelectedDateChanged());
+ MinimumDateProperty.Changed.AddClassHandler((c, _) => c.Refresh());
+ MaximumDateProperty.Changed.AddClassHandler((c, _) => c.Refresh());
+ }
+
+ public DateTime? SelectedDate
+ {
+ get => GetValue(SelectedDateProperty);
+ set => SetValue(SelectedDateProperty, value);
+ }
+
+ public DateTime? MinimumDate
+ {
+ get => GetValue(MinimumDateProperty);
+ set => SetValue(MinimumDateProperty, value);
+ }
+
+ public DateTime? MaximumDate
+ {
+ get => GetValue(MaximumDateProperty);
+ set => SetValue(MaximumDateProperty, value);
+ }
+
+ // First day of the month currently shown.
+ public DateTime DisplayMonth
+ {
+ get => _month;
+ set { _month = new DateTime(value.Year, value.Month, 1); Refresh(); }
+ }
+
+ public event EventHandler? DateSelected;
+
+ public Calendar()
+ {
+ _title = new TextBlock { FontSize = 14, FontWeight = FontWeight.SemiBold, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ _title.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+
+ var next = NavButton(IconName.ChevronRight, 1, "Next month");
+ Grid.SetColumn(_title, 1);
+ Grid.SetColumn(next, 2);
+ var header = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
+ Children = { NavButton(IconName.ChevronLeft, -1, "Previous month"), _title, next }
+ };
+
+ var format = CultureInfo.CurrentCulture.DateTimeFormat;
+ var first = (int)format.FirstDayOfWeek;
+ var weekdays = new Grid();
+ var days = new Grid { RowSpacing = 2 };
+ for (var c = 0; c < 7; c++)
+ {
+ weekdays.ColumnDefinitions.Add(new ColumnDefinition(Cell, GridUnitType.Pixel));
+ days.ColumnDefinitions.Add(new ColumnDefinition(Cell, GridUnitType.Pixel));
+ var name = format.AbbreviatedDayNames[(first + c) % 7];
+ var label = new TextBlock { Text = name.Length > 2 ? name[..2] : name, FontSize = 12, TextAlignment = TextAlignment.Center };
+ label.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ Grid.SetColumn(label, c);
+ weekdays.Children.Add(label);
+ }
+
+ for (var i = 0; i < 42; i++)
+ {
+ var index = i;
+ if (i % 7 == 0) days.RowDefinitions.Add(new RowDefinition(Cell, GridUnitType.Pixel));
+ var text = new TextBlock { FontSize = 13, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ var box = new Border
+ {
+ Child = text,
+ Width = Cell,
+ Height = Cell,
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ box.PointerEntered += (_, _) => { _hovered = index; StyleCell(index); };
+ box.PointerExited += (_, _) => { if (_hovered == index) _hovered = -1; StyleCell(index); };
+ box.PointerReleased += (_, e) =>
+ {
+ Select(index);
+ e.Handled = true;
+ };
+ Grid.SetColumn(box, i % 7);
+ Grid.SetRow(box, i / 7);
+ _cells.Add((box, text));
+ days.Children.Add(box);
+ }
+
+ Child = new StackPanel { Spacing = 8, Children = { header, weekdays, days } };
+ HorizontalAlignment = HorizontalAlignment.Left;
+ Refresh();
+ }
+
+ private Control NavButton(IconName icon, int direction, string description)
+ {
+ var button = new Border
+ {
+ Child = new Icon { Kind = icon, Size = 16, Token = ShellToken.MutedForeground },
+ Width = 28,
+ Height = 28,
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ button.Token(BorderBrushProperty, ShellToken.Input);
+ AutomationProperties.SetName(button, description);
+ button.PointerEntered += (_, _) => button.Token(BackgroundProperty, ShellToken.Accent);
+ button.PointerExited += (_, _) => { button.ClearToken(BackgroundProperty); button.Background = Brushes.Transparent; };
+ button.PointerReleased += (_, e) =>
+ {
+ DisplayMonth = _month.AddMonths(direction);
+ e.Handled = true;
+ };
+ return button;
+ }
+
+ private bool IsSelectable(DateTime date) =>
+ (MinimumDate is null || date >= MinimumDate.Value.Date) &&
+ (MaximumDate is null || date <= MaximumDate.Value.Date);
+
+ private void Select(int index)
+ {
+ var date = _dates[index];
+ if (!IsSelectable(date)) return;
+ SelectedDate = date;
+ DateSelected?.Invoke(this, date);
+ }
+
+ private void OnSelectedDateChanged()
+ {
+ // Follow the selection when it lands in another month (e.g. set from code).
+ if (SelectedDate is { } date && (date.Year != _month.Year || date.Month != _month.Month))
+ _month = new DateTime(date.Year, date.Month, 1);
+ Refresh();
+ }
+
+ private void Refresh()
+ {
+ if (_cells.Count == 0) return; // property set before the grid exists
+ _title.Text = _month.ToString("MMMM yyyy", CultureInfo.CurrentCulture);
+ var first = (int)CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek;
+ var offset = ((int)_month.DayOfWeek - first + 7) % 7;
+ var start = _month.AddDays(-offset);
+ for (var i = 0; i < 42; i++)
+ {
+ _dates[i] = start.AddDays(i);
+ _cells[i].Text.Text = _dates[i].Day.ToString(CultureInfo.CurrentCulture);
+ StyleCell(i);
+ }
+ }
+
+ private void StyleCell(int index)
+ {
+ var (box, text) = _cells[index];
+ var date = _dates[index];
+ var inMonth = date.Month == _month.Month;
+ var selectable = IsSelectable(date);
+ var selected = SelectedDate?.Date == date;
+ var today = date == DateTime.Today;
+
+ if (selected)
+ box.Token(BackgroundProperty, ShellToken.Primary);
+ else if (today || (index == _hovered && selectable))
+ box.Token(BackgroundProperty, ShellToken.Accent);
+ else
+ {
+ box.ClearToken(BackgroundProperty);
+ box.Background = Brushes.Transparent;
+ }
+
+ text.Token(TextBlock.ForegroundProperty,
+ selected ? ShellToken.PrimaryForeground : inMonth ? ShellToken.Foreground : ShellToken.MutedForeground);
+ box.Opacity = !selectable ? 0.3 : inMonth || selected ? 1 : 0.5;
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/DatePicker.cs b/examples/Avalonia.Demo/Components/UI/DatePicker.cs
new file mode 100644
index 0000000..82495fb
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/DatePicker.cs
@@ -0,0 +1,160 @@
+using System;
+using System.Globalization;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Date picker — shadcn-style trigger (calendar icon + formatted date, h-10 rounded-md border)
+// that opens a Calendar floating below it. A click outside or Escape closes it.
+// Usage:
+public class DatePicker : Border
+{
+ public static readonly StyledProperty DateProperty =
+ AvaloniaProperty.Register(nameof(Date), DateTime.Today, defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty MinimumDateProperty =
+ AvaloniaProperty.Register(nameof(MinimumDate));
+
+ public static readonly StyledProperty MaximumDateProperty =
+ AvaloniaProperty.Register(nameof(MaximumDate));
+
+ // .NET date format string for the trigger text.
+ public static readonly StyledProperty FormatProperty =
+ AvaloniaProperty.Register(nameof(Format), "MMMM d, yyyy");
+
+ private readonly Border _trigger;
+ private readonly TextBlock _text;
+ private readonly Calendar _calendar;
+ private readonly ShellAnchoredPopup _popup;
+
+ static DatePicker()
+ {
+ DateProperty.Changed.AddClassHandler((p, e) =>
+ {
+ p.UpdateText();
+ p._calendar.SelectedDate = (DateTime)e.NewValue!;
+ p.DateChanged?.Invoke(p, (DateTime)e.NewValue!);
+ });
+ MinimumDateProperty.Changed.AddClassHandler((p, e) => p._calendar.MinimumDate = (DateTime?)e.NewValue);
+ MaximumDateProperty.Changed.AddClassHandler((p, e) => p._calendar.MaximumDate = (DateTime?)e.NewValue);
+ FormatProperty.Changed.AddClassHandler((p, _) => p.UpdateText());
+ IsEnabledProperty.Changed.AddClassHandler((p, _) => p.Opacity = p.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public DateTime Date
+ {
+ get => GetValue(DateProperty);
+ set => SetValue(DateProperty, value);
+ }
+
+ public DateTime? MinimumDate
+ {
+ get => GetValue(MinimumDateProperty);
+ set => SetValue(MinimumDateProperty, value);
+ }
+
+ public DateTime? MaximumDate
+ {
+ get => GetValue(MaximumDateProperty);
+ set => SetValue(MaximumDateProperty, value);
+ }
+
+ public string Format
+ {
+ get => GetValue(FormatProperty);
+ set => SetValue(FormatProperty, value);
+ }
+
+ public bool IsOpen => _popup.IsOpen;
+
+ // The new date.
+ public event EventHandler? DateChanged;
+
+ public DatePicker()
+ {
+ _text = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center };
+ _text.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+
+ _trigger = new Border
+ {
+ Child = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 8,
+ Children = { new Icon { Kind = IconName.Calendar, Size = 16, Token = ShellToken.MutedForeground }, _text }
+ },
+ Height = 40,
+ MinWidth = 200,
+ 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);
+
+ _calendar = new Calendar { SelectedDate = Date };
+ _calendar.DateSelected += (_, date) =>
+ {
+ Date = date;
+ SetOpen(false);
+ };
+ var panel = new Border
+ {
+ Child = _calendar,
+ Padding = new Thickness(12),
+ 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);
+
+ Child = new Panel { Children = { _trigger, _popup.Popup } };
+ HorizontalAlignment = HorizontalAlignment.Left;
+ ShellFocus.Ring(this, _trigger);
+ UpdateText();
+ }
+
+ public void SetOpen(bool open)
+ {
+ if (open == _popup.IsOpen) return;
+ if (!open)
+ {
+ _popup.Close();
+ return;
+ }
+ _calendar.SelectedDate = Date;
+ _calendar.DisplayMonth = Date;
+ _trigger.Token(BorderBrushProperty, ShellToken.Ring);
+ _popup.Open();
+ }
+
+ public void Close() => SetOpen(false);
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return;
+ SetOpen(!IsOpen);
+ e.Handled = true;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Key is not (Key.Enter or Key.Space or Key.Down)) return;
+ SetOpen(true);
+ e.Handled = true;
+ }
+
+ private void UpdateText() => _text.Text = Date.ToString(Format, CultureInfo.CurrentCulture);
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Input.cs b/examples/Avalonia.Demo/Components/UI/Input.cs
index 38fa8e0..cafd44f 100644
--- a/examples/Avalonia.Demo/Components/UI/Input.cs
+++ b/examples/Avalonia.Demo/Components/UI/Input.cs
@@ -10,7 +10,7 @@ namespace AvaloniaDemo.Components.UI;
// Text input — h-10 rounded-md border border-input px-3 text-sm.
// Focus: border-ring + soft ring glow. Error: border-destructive.
-// The inner TextBox's Fluent chrome is cleared so this Border is the only frame.
+// The inner TextBox's Fluent chrome is stripped so this Border is the only frame.
public class Input : Border
{
public static readonly StyledProperty TextProperty =
@@ -31,14 +31,6 @@ public class Input : Border
public static readonly StyledProperty MaxLengthProperty =
AvaloniaProperty.Register(nameof(MaxLength));
- // Fluent's TextBox template takes its background and border brushes from these resources in
- // every state (hover, focus, disabled); overriding them on the TextBox clears that chrome.
- private static readonly string[] ClearedBrushes =
- {
- "TextControlBackground", "TextControlBackgroundPointerOver", "TextControlBackgroundFocused", "TextControlBackgroundDisabled",
- "TextControlBorderBrush", "TextControlBorderBrushPointerOver", "TextControlBorderBrushFocused", "TextControlBorderBrushDisabled",
- };
-
private readonly TextBox _textBox;
private bool _isFocused;
@@ -103,20 +95,10 @@ public Input()
_textBox = new TextBox
{
FontSize = 14,
- BorderThickness = new Thickness(0),
- Padding = new Thickness(0),
- MinHeight = 0,
VerticalAlignment = VerticalAlignment.Center,
VerticalContentAlignment = VerticalAlignment.Center
};
- foreach (var key in ClearedBrushes)
- _textBox.Resources[key] = Brushes.Transparent;
- _textBox.Resources["TextControlBorderThemeThickness"] = new Thickness(0);
- _textBox.Resources["TextControlBorderThemeThicknessFocused"] = new Thickness(0);
- _textBox.Resources["TextControlThemePadding"] = new Thickness(0);
- _textBox.Token(TextBox.ForegroundProperty, ShellToken.Foreground)
- .Token(TextBox.CaretBrushProperty, ShellToken.Foreground)
- .Token(TextBox.PlaceholderForegroundProperty, ShellToken.MutedForeground);
+ ShellPlatform.StripNativeChrome(_textBox);
_textBox.TextChanged += (_, e) =>
{
diff --git a/examples/Avalonia.Demo/Components/UI/RadioGroup.cs b/examples/Avalonia.Demo/Components/UI/RadioGroup.cs
new file mode 100644
index 0000000..27fefbe
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/RadioGroup.cs
@@ -0,0 +1,53 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Single-choice group. Usage:
+// ...
+public class RadioGroup : StackPanel
+{
+ public static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay);
+
+ static RadioGroup()
+ {
+ ValueProperty.Changed.AddClassHandler((g, e) =>
+ {
+ g.RefreshItems();
+ g.ValueChanged?.Invoke(g, (string?)e.NewValue ?? string.Empty);
+ });
+ }
+
+ public string? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public event EventHandler? ValueChanged;
+
+ public RadioGroup()
+ {
+ Spacing = 12;
+ Children.CollectionChanged += (_, _) => RefreshItems();
+ }
+
+ public void SetValue(string? value) => Value = value;
+
+ // Items inside a nested panel may arrive after this group's own children.
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ RefreshItems();
+ }
+
+ // The group drives its items, so item state never depends on when an item found its parent.
+ internal void RefreshItems()
+ {
+ foreach (var item in this.FindDescendantsOfType(e => e is RadioGroup))
+ item.SetChecked(item.Value == Value);
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/RadioGroupItem.cs b/examples/Avalonia.Demo/Components/UI/RadioGroupItem.cs
new file mode 100644
index 0000000..e0c1346
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/RadioGroupItem.cs
@@ -0,0 +1,107 @@
+using System;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Media.Transformation;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Radio option — h-4 w-4 rounded-full border border-primary; checked shows a filled dot.
+public class RadioGroupItem : Border
+{
+ public static readonly StyledProperty ValueProperty =
+ AvaloniaProperty.Register(nameof(Value));
+
+ public static readonly StyledProperty TextProperty =
+ AvaloniaProperty.Register(nameof(Text));
+
+ private readonly Border _ring;
+ private readonly Border _dot;
+ private readonly TextBlock _label;
+
+ static RadioGroupItem()
+ {
+ TextProperty.Changed.AddClassHandler((i, e) => i._label.Text = (string?)e.NewValue ?? string.Empty);
+ IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Opacity = i.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public string? Value
+ {
+ get => GetValue(ValueProperty);
+ set => SetValue(ValueProperty, value);
+ }
+
+ public string? Text
+ {
+ get => GetValue(TextProperty);
+ set => SetValue(TextProperty, value);
+ }
+
+ public bool IsChecked { get; private set; }
+
+ public RadioGroupItem()
+ {
+ _dot = new Border
+ {
+ Width = 8,
+ Height = 8,
+ CornerRadius = new CornerRadius(4),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ RenderTransform = TransformOperations.Parse("scale(0)")
+ };
+ _dot.Token(BackgroundProperty, ShellToken.Primary);
+ _dot.Transitions = new Transitions
+ {
+ new TransformOperationsTransition { Property = RenderTransformProperty, Duration = TimeSpan.FromMilliseconds(120), Easing = new CubicEaseOut() }
+ };
+
+ _ring = new Border
+ {
+ Child = _dot,
+ Width = 16,
+ Height = 16,
+ CornerRadius = new CornerRadius(8),
+ BorderThickness = new Thickness(1),
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ _ring.Token(BorderBrushProperty, ShellToken.Primary);
+
+ _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center };
+ _label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+
+ Child = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { _ring, _label } };
+ Background = Brushes.Transparent;
+ HorizontalAlignment = HorizontalAlignment.Left;
+ Cursor = new Cursor(StandardCursorType.Hand);
+ ShellFocus.Ring(this, _ring);
+ }
+
+ protected override void OnPointerReleased(PointerReleasedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return;
+ Select();
+ e.Handled = true;
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Key != Key.Space) return;
+ Select();
+ e.Handled = true;
+ }
+
+ private void Select() => this.FindParentOfType()?.SetValue(Value);
+
+ internal void SetChecked(bool isChecked)
+ {
+ IsChecked = isChecked;
+ _dot.RenderTransform = TransformOperations.Parse(isChecked ? "scale(1)" : "scale(0)");
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/Select.cs b/examples/Avalonia.Demo/Components/UI/Select.cs
new file mode 100644
index 0000000..86d0afd
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/Select.cs
@@ -0,0 +1,192 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Select — shadcn-style trigger (h-10 rounded-md border, chevrons icon) and a list that floats
+// below it, with a check on the selected item. A click outside or Escape closes it.
+// Usage: then set ItemsSource in code or by binding.
+public class Select : Border
+{
+ public static readonly StyledProperty SelectedIndexProperty =
+ AvaloniaProperty.Register