From ed47d3d01c78a98f86e8b5b8c85f931baf512b38 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Thu, 8 Oct 2026 10:00:39 +0200 Subject: [PATCH 1/3] refactor(avalonia): shared anchored popup and TextBox chrome stripping in shell ShellAnchoredPopup is the floating-panel logic the popover host had (overlay-layer Popup under an anchor, click outside and Escape close it, one open at a time, open/close motion), now usable by the P2 pickers too; ShellPopoverHost delegates to it. ShellPlatform.StripNativeChrome(TextBox) is the Fluent chrome clearing Input did inline, shared with Textarea, as MAUI shares its StripNativeChrome. --- examples/Avalonia.Demo/Components/UI/Input.cs | 22 +--- examples/Avalonia.Demo/Components/UI/Shell.cs | 124 ++++++++++++++---- .../Templates/InputTemplate.cs | 22 +--- .../Templates/ShellTemplate.cs | 124 ++++++++++++++---- 4 files changed, 202 insertions(+), 90 deletions(-) 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/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/src/ShellUI.Native.Templates/Templates/InputTemplate.cs b/src/ShellUI.Native.Templates/Templates/InputTemplate.cs index dfaeef6..ee4e25c 100644 --- a/src/ShellUI.Native.Templates/Templates/InputTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/InputTemplate.cs @@ -197,7 +197,7 @@ namespace YourProjectNamespace.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 = @@ -218,14 +218,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; @@ -290,20 +282,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/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs index 6ae57f1..0f42ce5 100644 --- a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs @@ -1443,12 +1443,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()); @@ -1464,11 +1524,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; @@ -1478,29 +1535,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(); } } @@ -1650,6 +1696,34 @@ 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); + } +} " }; } From 6dcfeb292915c461882ed4e5dc94da527cc62273 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Thu, 8 Oct 2026 10:00:40 +0200 Subject: [PATCH 2/3] feat(avalonia): port the P2 form controls textarea, select, slider, radio-group (+ item), calendar, date-picker and time-picker, with the MAUI names, properties and sizes. Select, DatePicker and TimePicker float their panel with ShellAnchoredPopup; the select list matches the trigger's width. The slider is custom-drawn in shadcn's style (track, range, ring thumb) instead of a re-tinted Fluent slider, whose thumb can't be made to match; it adds Step for the arrow keys. Checked: each P2 component installs alone into a fresh 'dotnet new avalonia.app' and builds with 0 warnings; all Avalonia components together build; in the demo the values apply from code, the select list matches its trigger and flips above it, the date picker opens on today, the time picker scrolls to the current hour; dropdown pick and popover Escape still work on the shared popup. --- .../Avalonia.Demo/Components/UI/Calendar.cs | 214 +++++++++++++ .../Avalonia.Demo/Components/UI/DatePicker.cs | 160 ++++++++++ .../Avalonia.Demo/Components/UI/RadioGroup.cs | 53 ++++ .../Components/UI/RadioGroupItem.cs | 107 +++++++ .../Avalonia.Demo/Components/UI/Select.cs | 192 +++++++++++ .../Avalonia.Demo/Components/UI/Slider.cs | 167 ++++++++++ .../Avalonia.Demo/Components/UI/Textarea.cs | 124 ++++++++ .../Avalonia.Demo/Components/UI/TimePicker.cs | 297 +++++++++++++++++ examples/Avalonia.Demo/MainWindow.axaml | 18 ++ examples/Avalonia.Demo/MainWindow.axaml.cs | 3 + .../Templates/CalendarTemplate.cs | 215 +++++++++++++ .../Templates/DatePickerTemplate.cs | 161 ++++++++++ .../Templates/RadioGroupItemTemplate.cs | 108 +++++++ .../Templates/RadioGroupTemplate.cs | 54 ++++ .../Templates/SelectTemplate.cs | 193 ++++++++++++ .../Templates/SliderTemplate.cs | 168 ++++++++++ .../Templates/TextareaTemplate.cs | 125 ++++++++ .../Templates/TimePickerTemplate.cs | 298 ++++++++++++++++++ .../TemplateContentTests.cs | 10 +- 19 files changed, 2666 insertions(+), 1 deletion(-) create mode 100644 examples/Avalonia.Demo/Components/UI/Calendar.cs create mode 100644 examples/Avalonia.Demo/Components/UI/DatePicker.cs create mode 100644 examples/Avalonia.Demo/Components/UI/RadioGroup.cs create mode 100644 examples/Avalonia.Demo/Components/UI/RadioGroupItem.cs create mode 100644 examples/Avalonia.Demo/Components/UI/Select.cs create mode 100644 examples/Avalonia.Demo/Components/UI/Slider.cs create mode 100644 examples/Avalonia.Demo/Components/UI/Textarea.cs create mode 100644 examples/Avalonia.Demo/Components/UI/TimePicker.cs 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/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/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