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
-
-
-
-
-
-
-
-
-
-
-
-
+
+