diff --git a/samples/SampleApp/DemoPages/CalendarDatePickerDemo.axaml b/samples/SampleApp/DemoPages/CalendarDatePickerDemo.axaml index 183c2ccb3..5ddefdc9e 100644 --- a/samples/SampleApp/DemoPages/CalendarDatePickerDemo.axaml +++ b/samples/SampleApp/DemoPages/CalendarDatePickerDemo.axaml @@ -27,7 +27,7 @@ RowSpacing="30"> @@ -37,6 +37,7 @@ + @@ -58,6 +59,20 @@ + + + + + + + + + + + + + + this.SelectedBrush = Brushes.Green; + + [RelayCommand] + private void Orange() => this.SelectedBrush = Brushes.Orange; + + [RelayCommand] + private void Red() => this.SelectedBrush = Brushes.Red; } \ No newline at end of file diff --git a/src/Devolutions.AvaloniaControls/AttachedProperties/CalendarDatePickerContent.cs b/src/Devolutions.AvaloniaControls/AttachedProperties/CalendarDatePickerContent.cs new file mode 100644 index 000000000..b51aba3bd --- /dev/null +++ b/src/Devolutions.AvaloniaControls/AttachedProperties/CalendarDatePickerContent.cs @@ -0,0 +1,48 @@ +namespace Devolutions.AvaloniaControls.AttachedProperties; + +using System.Collections; +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; + +public static class CalendarDatePickerContent +{ + public static readonly AttachedProperty InnerLeftContentProperty = + AvaloniaProperty.RegisterAttached("InnerLeftContent", typeof(CalendarDatePickerContent)); + + public static readonly AttachedProperty InnerLeftOfCalendarButtonContentProperty = + AvaloniaProperty.RegisterAttached("InnerLeftOfCalendarButtonContent", typeof(CalendarDatePickerContent)); + + public static readonly AttachedProperty InnerRightContentProperty = + AvaloniaProperty.RegisterAttached("InnerRightContent", typeof(CalendarDatePickerContent)); + + public static IEnumerable GetInnerLeftContent(CalendarDatePicker element) => GetOrCreate(element, InnerLeftContentProperty); + + public static void SetInnerLeftContent(CalendarDatePicker element, IEnumerable value) => element.SetValue(InnerLeftContentProperty, value); + + public static IEnumerable GetInnerLeftOfCalendarButtonContent(CalendarDatePicker element) => + GetOrCreate(element, InnerLeftOfCalendarButtonContentProperty); + + public static void SetInnerLeftOfCalendarButtonContent(CalendarDatePicker element, IEnumerable value) => + element.SetValue(InnerLeftOfCalendarButtonContentProperty, value); + + public static IEnumerable GetInnerRightContent(CalendarDatePicker element) => GetOrCreate(element, InnerRightContentProperty); + + public static void SetInnerRightContent(CalendarDatePicker element, IEnumerable value) => element.SetValue(InnerRightContentProperty, value); + + // Attached-property defaults are shared across all instances, which is not what we want for + // mutable collections. Lazily create a per-instance AvaloniaList on first read so + // XAML element-syntax (...) can Add into it. + private static IEnumerable GetOrCreate(CalendarDatePicker element, AttachedProperty property) + { + if (element.GetValue(property) is { } existing) + { + return existing; + } + + // ReSharper disable once CollectionNeverUpdated.Local + AvaloniaList list = []; + element.SetValue(property, list); + return list; + } +} diff --git a/src/Devolutions.AvaloniaControls/Behaviors/CalendarDatePickerBehavior.cs b/src/Devolutions.AvaloniaControls/Behaviors/CalendarDatePickerBehavior.cs index e6b436626..c58355f06 100644 --- a/src/Devolutions.AvaloniaControls/Behaviors/CalendarDatePickerBehavior.cs +++ b/src/Devolutions.AvaloniaControls/Behaviors/CalendarDatePickerBehavior.cs @@ -1,9 +1,12 @@ -using Avalonia; +using Avalonia; namespace Devolutions.AvaloniaControls.Behaviors; +using System; +using System.Linq; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Threading; using Avalonia.VisualTree; public static class CalendarDatePickerBehavior @@ -15,30 +18,62 @@ static CalendarDatePickerBehavior() { OpenOnSelectedDateProperty.Changed.Subscribe(args => { - if (args.Sender is CalendarDatePicker datePicker) + if (args.Sender is not CalendarDatePicker datePicker) return; + + if (args.NewValue.GetValueOrDefault()) + { + datePicker.CalendarOpened += OnCalendarOpened; + datePicker.CalendarClosed += OnCalendarClosed; + } + else { - if (args.NewValue.GetValueOrDefault()) - { - datePicker.CalendarOpened += OnCalendarOpen; - } - else - { - datePicker.CalendarOpened -= OnCalendarOpen; - } + datePicker.CalendarOpened -= OnCalendarOpened; + datePicker.CalendarClosed -= OnCalendarClosed; } }); } - private static void OnCalendarOpen(object? sender, EventArgs e) + private static void OnCalendarOpened(object? sender, EventArgs e) { - if (sender is CalendarDatePicker datePicker) - { - Popup? popup = datePicker.GetVisualDescendants().OfType().FirstOrDefault(); - Calendar? calendar = popup?.Child?.FindDescendantOfType() ?? popup?.Child as Calendar; + if (sender is not CalendarDatePicker datePicker) return; - if (calendar == null) return; + Popup? popup = datePicker.GetVisualDescendants().OfType().FirstOrDefault(); + Calendar? calendar = popup?.Child?.FindDescendantOfType() ?? popup?.Child as Calendar; + + if (calendar == null) return; + + calendar.DisplayDate = datePicker.SelectedDate ?? DateTime.Now; + + // Move keyboard focus into the calendar when the popup opens. The calendar button now lives inside + // PART_TextBox (to host inner-content slots), so opening no longer takes focus off the text box on + // its own — focus would otherwise stay in the box and its segmented arrow-keys would look like + // calendar input. Focusing the popup also lets Avalonia's light-dismiss close it on Tab again. + // (PART_Calendar sets Focusable="True" in each theme so this Focus() takes effect.) + Dispatcher.UIThread.Post(() => calendar.Focus(), DispatcherPriority.Input); + } - calendar.DisplayDate = datePicker.SelectedDate ?? DateTime.Now; + private static void OnCalendarClosed(object? sender, EventArgs e) + { + if (sender is not CalendarDatePicker datePicker) return; + + // Focus was moved into the popup calendar on open (see OnCalendarOpened). The calendar lives in the + // popup's own tree, outside the window's tab order, so once the popup closes we return focus to the + // picker's text box — restoring the original tab flow (Tab then continues from the picker). + Dispatcher.UIThread.Post(() => ReturnFocusToPicker(datePicker), DispatcherPriority.Background); + } + + // Returns focus to the picker's primary control. Prefers the text box, but on themes where it is hidden + // and non-focusable (Yaru shows a button instead) falls back to the button. + private static void ReturnFocusToPicker(CalendarDatePicker picker) + { + TextBox? textBox = picker.GetVisualDescendants().OfType().FirstOrDefault(tb => tb.Name == "PART_TextBox"); + if (textBox is { Focusable: true, IsVisible: true }) + { + textBox.Focus(); + return; } + + Button? button = picker.GetVisualDescendants().OfType + + + + + + + + - - - + - - - - - - - - - - + + + + diff --git a/src/Devolutions.AvaloniaTheme.Linux/Controls/CalendarDatePicker.axaml b/src/Devolutions.AvaloniaTheme.Linux/Controls/CalendarDatePicker.axaml index f800050ef..b81fad9d7 100644 --- a/src/Devolutions.AvaloniaTheme.Linux/Controls/CalendarDatePicker.axaml +++ b/src/Devolutions.AvaloniaTheme.Linux/Controls/CalendarDatePicker.axaml @@ -70,14 +70,77 @@ VerticalAlignment="Stretch" HorizontalContentAlignment="Center" /> + Foreground="{Binding Path=#PART_TextBox.Foreground}" + MinWidth="{Binding #SlotOverlay.Bounds.Width}" + MinHeight="{Binding #SlotOverlay.Bounds.Height}" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + - - - - - - - - + +