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(nameof(SelectedIndex), -1, defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty?> ItemsSourceProperty = + AvaloniaProperty.Register?>(nameof(ItemsSource)); + + public static readonly StyledProperty PlaceholderProperty = + AvaloniaProperty.Register(nameof(Placeholder), "Select..."); + + private const double ItemHeight = 32; + private const double MaxListHeight = 280; + + private readonly Border _trigger; + private readonly TextBlock _value; + private readonly StackPanel _list = new(); + private readonly Border _panel; + private readonly ShellAnchoredPopup _popup; + + static Select() + { + SelectedIndexProperty.Changed.AddClassHandler((s, _) => s.RebuildItems()); + PlaceholderProperty.Changed.AddClassHandler((s, _) => s.Opacity = s.IsEnabled ? 1.0 : 0.5); + } + + public int SelectedIndex + { + get => GetValue(SelectedIndexProperty); + set => SetValue(SelectedIndexProperty, value); + } + + public IList? ItemsSource + { + get => GetValue(ItemsSourceProperty); + set => SetValue(ItemsSourceProperty, value); + } + + public string? Placeholder + { + get => GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public string? SelectedItem => SelectedIndex >= 0 && ItemsSource != null && SelectedIndex < ItemsSource.Count + ? ItemsSource[SelectedIndex] : null; + + public bool IsOpen => _popup.IsOpen; + + public event EventHandler? SelectedIndexChanged; + + public Select() + { + _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); + + _panel = new Border + { + Child = new ScrollViewer { Content = _list, MaxHeight = MaxListHeight }, + Padding = new Thickness(4), + 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 } }; + ShellFocus.Ring(this, _trigger); + UpdateTrigger(); + } + + public void SetOpen(bool open) + { + if (open == _popup.IsOpen) return; + if (!open) + { + _popup.Close(); + return; + } + // The list matches the trigger's width (shadcn's w-[--radix-select-trigger-width]). + _panel.Width = _trigger.Bounds.Width; + _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 UpdateTrigger() + { + var item = SelectedItem; + _value.Text = item ?? Placeholder ?? string.Empty; + _value.Token(TextBlock.ForegroundProperty, item != null ? ShellToken.Foreground : ShellToken.MutedForeground); + } + + private void RebuildItems() + { + _list.Children.Clear(); + if (ItemsSource is { } items) + for (var i = 0; i < items.Count; i++) + _list.Children.Add(CreateItem(items[i], i)); + UpdateTrigger(); + UpdateItemStates(); + } + + private Control CreateItem(string text, int index) + { + var label = new TextBlock { Text = text, FontSize = 14, VerticalAlignment = VerticalAlignment.Center }; + 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), + Tag = check + }; + item.PointerEntered += (_, _) => item.Token(BackgroundProperty, ShellToken.Accent); + item.PointerExited += (_, _) => { item.ClearToken(BackgroundProperty); item.Background = Brushes.Transparent; }; + item.PointerReleased += (_, e) => + { + SelectedIndex = index; + SetOpen(false); + e.Handled = true; + }; + return item; + } + + private void UpdateItemStates() + { + for (var i = 0; i < _list.Children.Count; i++) + if (_list.Children[i].Tag is Icon check) + check.IsVisible = i == SelectedIndex; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Shell.cs b/examples/Avalonia.Demo/Components/UI/Shell.cs index a1f5ca5..3827e28 100644 --- a/examples/Avalonia.Demo/Components/UI/Shell.cs +++ b/examples/Avalonia.Demo/Components/UI/Shell.cs @@ -381,12 +381,72 @@ private void OnItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) } } +/* A panel floating under an anchor in the window's overlay layer (Dropdown, Popover, Select, + DatePicker, TimePicker). A click outside or Escape closes it, and only one is open at a time. + The owner puts Popup in its tree, so the panel inherits the owner's theme and DataContext. */ +public sealed class ShellAnchoredPopup : IShellPopup +{ + private bool _open; + + public Popup Popup { get; } + + public bool IsOpen => _open; + + // Raised when it closes for any reason: Close(), a click outside, Escape or another menu opening. + public event EventHandler? Closed; + + public ShellAnchoredPopup(Control anchor, Popup? popup = null) + { + Popup = popup ?? new Popup { ShouldUseOverlayLayer = true }; + Popup.PlacementTarget = anchor; + Popup.Placement = PlacementMode.BottomEdgeAlignedLeft; + Popup.VerticalOffset = 4; + Popup.IsLightDismissEnabled = true; + // The click that dismisses (even on the trigger) does nothing else, so it can't reopen. + Popup.OverlayDismissEventPassThrough = false; + Popup.Closed += (_, _) => + { + if (_open) Finish(); + }; + } + + public async void Open() + { + if (_open || Popup.Child is not Control panel || Popup.PlacementTarget is not { } anchor) return; + _open = true; + ShellPopups.Opened(this); + ShellDismiss.Push(this, Close, anchor); + panel.RenderTransformOrigin = new RelativePoint(0.5, 0, RelativeUnit.Relative); + ShellMotion.Set(panel, 0, "scale(0.95)"); + Popup.IsOpen = true; + await ShellMotion.To(panel, 1, "scale(1)", 120, new CubicEaseOut()); + } + + public async void Close() + { + if (!_open) return; + Finish(); + if (Popup.Child is Control panel) await ShellMotion.To(panel, 0, "scale(0.95)", 90, new CubicEaseIn()); + if (!_open) Popup.IsOpen = false; + } + + private void Finish() + { + _open = false; + ShellPopups.Closed(this); + ShellDismiss.Remove(this); + Closed?.Invoke(this, EventArgs.Empty); + } +} + // Shared host for trigger + floating panel (Dropdown, Popover). Clicking outside closes it. -public abstract class ShellPopoverHost : ShellFloatingHost, IShellPopup +public abstract class ShellPopoverHost : ShellFloatingHost { public static readonly StyledProperty IsOpenProperty = AvaloniaProperty.Register(nameof(IsOpen), defaultBindingMode: BindingMode.TwoWay); + private readonly ShellAnchoredPopup _anchored; + static ShellPopoverHost() { IsOpenProperty.Changed.AddClassHandler((h, _) => h.OnOpenChanged()); @@ -402,11 +462,8 @@ public bool IsOpen protected ShellPopoverHost() { - Popup.PlacementTarget = this; - Popup.IsLightDismissEnabled = true; - // The click that dismisses (even on the trigger) does nothing else, so it can't reopen. - Popup.OverlayDismissEventPassThrough = false; - Popup.Closed += (_, _) => IsOpen = false; + _anchored = new ShellAnchoredPopup(this, Popup); + _anchored.Closed += (_, _) => IsOpen = false; } protected virtual PlacementMode Placement => PlacementMode.BottomEdgeAlignedLeft; @@ -416,29 +473,18 @@ protected ShellPopoverHost() public void Toggle() => IsOpen = !IsOpen; public void Close() => IsOpen = false; - private async void OnOpenChanged() + private void OnOpenChanged() { IsOpenChanged?.Invoke(this, IsOpen); - if (FloatingContent is not { } content) return; - if (IsOpen) - { - ShellPopups.Opened(this); - ShellDismiss.Push(this, Close, this); - Popup.Placement = Placement; - Popup.VerticalOffset = Offset; - content.RenderTransformOrigin = new RelativePoint(0.5, 0, RelativeUnit.Relative); - ShellMotion.Set(content, 0, "scale(0.95)"); - Popup.IsOpen = true; - await ShellMotion.To(content, 1, "scale(1)", 120, new CubicEaseOut()); - } - else + if (!IsOpen) { - ShellPopups.Closed(this); - ShellDismiss.Remove(this); - if (!Popup.IsOpen) return; // already closed by a click outside - await ShellMotion.To(content, 0, "scale(0.95)", 90, new CubicEaseIn()); - if (!IsOpen) Popup.IsOpen = false; + _anchored.Close(); + return; } + if (FloatingContent is null) return; + Popup.Placement = Placement; + Popup.VerticalOffset = Offset; + _anchored.Open(); } } @@ -588,3 +634,31 @@ public static void ActivateAncestor(StyledElement from) } } } + +// Native-control helpers: Fluent draws its own chrome inside the controls a ShellUI Border wraps. +public static class ShellPlatform +{ + // 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[] TextBoxBrushes = + { + "TextControlBackground", "TextControlBackgroundPointerOver", "TextControlBackgroundFocused", "TextControlBackgroundDisabled", + "TextControlBorderBrush", "TextControlBorderBrushPointerOver", "TextControlBorderBrushFocused", "TextControlBorderBrushDisabled", + }; + + // Leaves the wrapping Border as the only frame, with text, caret and placeholder in the theme tokens. + public static void StripNativeChrome(TextBox textBox) + { + foreach (var key in TextBoxBrushes) + textBox.Resources[key] = Brushes.Transparent; + textBox.Resources["TextControlBorderThemeThickness"] = new Thickness(0); + textBox.Resources["TextControlBorderThemeThicknessFocused"] = new Thickness(0); + textBox.Resources["TextControlThemePadding"] = new Thickness(0); + textBox.BorderThickness = new Thickness(0); + textBox.Padding = new Thickness(0); + textBox.MinHeight = 0; + textBox.Token(TextBox.ForegroundProperty, ShellToken.Foreground) + .Token(TextBox.CaretBrushProperty, ShellToken.Foreground) + .Token(TextBox.PlaceholderForegroundProperty, ShellToken.MutedForeground); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Slider.cs b/examples/Avalonia.Demo/Components/UI/Slider.cs new file mode 100644 index 0000000..81fe049 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Slider.cs @@ -0,0 +1,167 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; + +namespace AvaloniaDemo.Components.UI; + +// Range slider — h-2 rounded-full bg-secondary track, bg-primary range, h-5 w-5 rounded-full +// border-2 border-primary bg-background thumb. Custom-drawn: drag or click the track, or use the +// arrow keys (Step), Page Up/Down (10 steps), Home and End. +public class Slider : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value), 50.0, defaultBindingMode: BindingMode.TwoWay, + coerce: (o, v) => o is Slider s ? Math.Clamp(v, s.Minimum, Math.Max(s.Minimum, s.Maximum)) : v); + + public static readonly StyledProperty MinimumProperty = + AvaloniaProperty.Register(nameof(Minimum), 0.0); + + public static readonly StyledProperty MaximumProperty = + AvaloniaProperty.Register(nameof(Maximum), 100.0); + + // Keyboard increment. Pointer input is continuous. + public static readonly StyledProperty StepProperty = + AvaloniaProperty.Register(nameof(Step), 1.0); + + private const double ThumbSize = 20; + private const double TrackHeight = 8; + + private readonly Border _track; + private readonly Border _range; + private readonly Border _thumb; + private bool _dragging; + + static Slider() + { + ValueProperty.Changed.AddClassHandler((s, e) => + { + s.UpdateLayoutOfParts(); + s.ValueChanged?.Invoke(s, (double)e.NewValue!); + }); + MinimumProperty.Changed.AddClassHandler((s, _) => { s.CoerceValue(ValueProperty); s.UpdateLayoutOfParts(); }); + MaximumProperty.Changed.AddClassHandler((s, _) => { s.CoerceValue(ValueProperty); s.UpdateLayoutOfParts(); }); + IsEnabledProperty.Changed.AddClassHandler((s, _) => s.Opacity = s.IsEnabled ? 1.0 : 0.5); + } + + public double Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public double Minimum + { + get => GetValue(MinimumProperty); + set => SetValue(MinimumProperty, value); + } + + public double Maximum + { + get => GetValue(MaximumProperty); + set => SetValue(MaximumProperty, value); + } + + public double Step + { + get => GetValue(StepProperty); + set => SetValue(StepProperty, value); + } + + public event EventHandler? ValueChanged; + + public Slider() + { + _track = new Border { Height = TrackHeight, CornerRadius = new CornerRadius(TrackHeight / 2), VerticalAlignment = VerticalAlignment.Center }; + _track.Token(BackgroundProperty, ShellToken.Secondary); + _range = new Border { Height = TrackHeight, CornerRadius = new CornerRadius(TrackHeight / 2), HorizontalAlignment = HorizontalAlignment.Left, VerticalAlignment = VerticalAlignment.Center }; + _range.Token(BackgroundProperty, ShellToken.Primary); + _thumb = new Border + { + Width = ThumbSize, + Height = ThumbSize, + CornerRadius = new CornerRadius(ThumbSize / 2), + BorderThickness = new Thickness(2), + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center + }; + _thumb.Token(BackgroundProperty, ShellToken.Background).Token(BorderBrushProperty, ShellToken.Primary); + + Child = new Panel { Children = { _track, _range, _thumb } }; + Height = ThumbSize; + Background = Avalonia.Media.Brushes.Transparent; + Cursor = new Cursor(StandardCursorType.Hand); + ShellFocus.Ring(this, _thumb); + SizeChanged += (_, _) => UpdateLayoutOfParts(); + } + + private double Fraction => Maximum > Minimum ? (Value - Minimum) / (Maximum - Minimum) : 0; + + // The thumb travels the track width minus its own size, so it never overhangs the ends. + private void UpdateLayoutOfParts() + { + var travel = Math.Max(0, Bounds.Width - ThumbSize); + var x = travel * Fraction; + _thumb.Margin = new Thickness(x, 0, 0, 0); + _range.Width = x + ThumbSize / 2; + } + + private void SetFromPointer(PointerEventArgs e) + { + var travel = Bounds.Width - ThumbSize; + if (travel <= 0) return; + var fraction = Math.Clamp((e.GetPosition(this).X - ThumbSize / 2) / travel, 0, 1); + Value = Minimum + fraction * (Maximum - Minimum); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return; + _dragging = true; + e.Pointer.Capture(this); + SetFromPointer(e); + e.Handled = true; + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (_dragging) SetFromPointer(e); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (!_dragging) return; + _dragging = false; + e.Pointer.Capture(null); + e.Handled = true; + } + + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + base.OnPointerCaptureLost(e); + _dragging = false; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + double? target = e.Key switch + { + Key.Left or Key.Down => Value - Step, + Key.Right or Key.Up => Value + Step, + Key.PageDown => Value - Step * 10, + Key.PageUp => Value + Step * 10, + Key.Home => Minimum, + Key.End => Maximum, + _ => null + }; + if (target is not { } value) return; + Value = value; + e.Handled = true; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Textarea.cs b/examples/Avalonia.Demo/Components/UI/Textarea.cs new file mode 100644 index 0000000..d4596bd --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Textarea.cs @@ -0,0 +1,124 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Multi-line input — min-h-[80px] rounded-md border border-input px-3 py-2 text-sm. +// Focus: border-ring + soft ring glow. Error: border-destructive. Grows with its text. +public class Textarea : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty PlaceholderProperty = + AvaloniaProperty.Register(nameof(Placeholder)); + + public static readonly StyledProperty HasErrorProperty = + AvaloniaProperty.Register(nameof(HasError)); + + public static readonly StyledProperty MaxLengthProperty = + AvaloniaProperty.Register(nameof(MaxLength)); + + private readonly TextBox _textBox; + private bool _isFocused; + + static Textarea() + { + TextProperty.Changed.AddClassHandler