From f1aa820986e622fc5aaad8a51e81601bd49f9f74 Mon Sep 17 00:00:00 2001 From: Deadpikle Date: Tue, 26 May 2026 17:57:59 +0900 Subject: [PATCH] Feature: Add custom CalendarDatePicker date parsing to allow for multiple user input formats (#21193) * Add new CDPicker props for custom text conversion * Use TextConverter if not null for date parsing * Fix comment on CustomDateFormatString * Add unit test for custom date parsing * CDPicker: When text set ensure right format * Add test for invalid date input * Fix failure on new test for invalid date input * Swap Convert and ConvertBack See review comment: https://github.com/AvaloniaUI/Avalonia/pull/21193#discussion_r3265891798 * Add failing test for TextConverter.Convert * Tweak Convert test based on InvokeAsync used in prop update * Use TextConverter if avail in DateTimeToString https://github.com/AvaloniaUI/Avalonia/pull/21193#discussion_r3265887941 * Add clarifying comment * Rename converter for clarity * Fix failing tests due to DateTimeToString change * Update docs for CalendarDatePicker.TextConverter https://github.com/AvaloniaUI/Avalonia/pull/21193#discussion_r3265904591 --------- Co-authored-by: Julien Lebosquain --- .../CalendarDatePicker.Properties.cs | 31 ++++++ .../CalendarDatePicker/CalendarDatePicker.cs | 23 ++++- .../CalendarDatePickerTests.cs | 97 +++++++++++++++++++ 3 files changed, 146 insertions(+), 5 deletions(-) diff --git a/src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.Properties.cs b/src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.Properties.cs index 864ba86ef0..5209744bb3 100644 --- a/src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.Properties.cs +++ b/src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.Properties.cs @@ -1,6 +1,7 @@ using System; using Avalonia.Controls.Primitives; using Avalonia.Data; +using Avalonia.Data.Converters; using Avalonia.Layout; namespace Avalonia.Controls @@ -74,6 +75,14 @@ namespace Avalonia.Controls defaultValue: "d", validate: IsValidDateFormatString); + /// + /// Defines the property. + /// + public static readonly StyledProperty TextConverterProperty = + AvaloniaProperty.Register( + nameof(TextConverter), + defaultBindingMode: BindingMode.OneWay); + /// /// Defines the property. /// @@ -265,11 +274,33 @@ namespace Avalonia.Controls set => SetValue(SelectedDateFormatProperty, value); } + /// + /// Gets or sets the custom date format used for display (and/or parsing) + /// the date displayed to the user. Not used unless + /// is . + /// + /// + /// The default is "d". + /// public string CustomDateFormatString { get => GetValue(CustomDateFormatStringProperty); set => SetValue(CustomDateFormatStringProperty, value); } + + /// + /// Gets or sets the custom bidirectional Text-Value converter for custom date + /// string formatting (e.g. for converting user input formats to DateTime or + /// displaying a DateTime in a custom string format). + /// If set, has priority for formatting over . + /// Convert: DateTime -> string + /// ConvertBack: string -> DateTIme + /// + public IValueConverter? TextConverter + { + get => GetValue(TextConverterProperty); + set => SetValue(TextConverterProperty, value); + } /// /// Gets or sets the text that is displayed by the . diff --git a/src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.cs b/src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.cs index 6194d4c14e..a96dd013c7 100644 --- a/src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.cs +++ b/src/Avalonia.Controls/CalendarDatePicker/CalendarDatePicker.cs @@ -716,15 +716,22 @@ namespace Avalonia.Controls /// private DateTime? ParseText(string text) { - DateTime newSelectedDate; + DateTime? newSelectedDate; // TryParse is not used in order to be able to pass the exception to // the TextParseError event try { - newSelectedDate = SelectedDateFormat == CalendarDatePickerFormat.Custom && !string.IsNullOrEmpty(CustomDateFormatString) ? - DateTime.ParseExact(text, CustomDateFormatString, DateTimeHelper.GetCurrentDateFormat()) : - DateTime.Parse(text, DateTimeHelper.GetCurrentDateFormat()); + if (TextConverter != null) + { + newSelectedDate = TextConverter.ConvertBack(text, typeof(DateTime?), null, CultureInfo.CurrentCulture) as DateTime?; + } + else + { + newSelectedDate = SelectedDateFormat == CalendarDatePickerFormat.Custom && !string.IsNullOrEmpty(CustomDateFormatString) ? + DateTime.ParseExact(text, CustomDateFormatString, DateTimeHelper.GetCurrentDateFormat()) : + DateTime.Parse(text, DateTimeHelper.GetCurrentDateFormat()); + } if (Calendar.IsValidDateSelection(this._calendar!, newSelectedDate)) { @@ -757,6 +764,10 @@ namespace Avalonia.Controls private string? DateTimeToString(DateTime d) { + if (TextConverter != null) + { + return TextConverter.Convert(d, typeof(string), null, CultureInfo.CurrentCulture) as string; + } DateTimeFormatInfo dtfi = DateTimeHelper.GetCurrentDateFormat(); switch (SelectedDateFormat) @@ -856,7 +867,9 @@ namespace Avalonia.Controls DateTime? d = ParseText(s); if (d != null) { - SetValue(TextProperty, s); + // make sure displayed text is reformatted to correct date format + string? newtext = DateTimeToString((DateTime)d); + SetValue(TextProperty, newtext); return d; } else diff --git a/tests/Avalonia.Controls.UnitTests/CalendarDatePickerTests.cs b/tests/Avalonia.Controls.UnitTests/CalendarDatePickerTests.cs index b620601f71..0ec8bd5ef8 100644 --- a/tests/Avalonia.Controls.UnitTests/CalendarDatePickerTests.cs +++ b/tests/Avalonia.Controls.UnitTests/CalendarDatePickerTests.cs @@ -2,6 +2,7 @@ using System; using System.Linq; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; +using Avalonia.Data.Converters; using Avalonia.Input; using Avalonia.Platform; using Avalonia.UnitTests; @@ -98,6 +99,102 @@ namespace Avalonia.Controls.UnitTests } } + private class CalendarDatePickerTextConverter : IValueConverter + { + // date to text + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is DateTime d) + return d.ToString("yyyy-MM-dd"); // always return a single format (for this test) + return AvaloniaProperty.UnsetValue; + } + + // text to date + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var str = value?.ToString(); + if (str == null) + return AvaloniaProperty.UnsetValue; + // allow for a few different date formats + string[] formats = ["yyyy-MM-dd", "MM dd yyyy", "dd.MM.yyyy"]; + if (DateTime.TryParseExact(str, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime dateValue)) + { + return dateValue; + } + return AvaloniaProperty.UnsetValue; + } + } + + [Fact] + public void Setting_Date_Manually_Uses_Text_Converter() + { + CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en-US"); + using (UnitTestApplication.Start(Services)) + { + CalendarDatePicker datePicker = CreateControl(); + datePicker.SelectedDateFormat = CalendarDatePickerFormat.Custom; + datePicker.CustomDateFormatString = "dd.MM.yyyy"; + datePicker.TextConverter = new CalendarDatePickerTextConverter(); + var tb = GetTextBox(datePicker); + + datePicker.SelectedDate = new DateTime(2024, 2, 13); + // DateTimeToString called async so need to let that complete before testing value + Threading.Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + Assert.Equal("2024-02-13", datePicker.Text); + Assert.True(CompareDates(datePicker.SelectedDate!.Value, new DateTime(2024, 2, 13))); + + // null input results in empty string for text + datePicker.SelectedDate = null; + + Assert.Equal("", datePicker.Text); + Assert.Null(datePicker.SelectedDate); + } + } + + [Fact] + public void Setting_Date_String_Manually_Can_Accept_Multiple_Formats() + { + CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en-US"); + using (UnitTestApplication.Start(Services)) + { + CalendarDatePicker datePicker = CreateControl(); + datePicker.SelectedDateFormat = CalendarDatePickerFormat.Custom; + datePicker.CustomDateFormatString = "dd.MM.yyyy"; + datePicker.TextConverter = new CalendarDatePickerTextConverter(); + var tb = GetTextBox(datePicker); + + // parser can work with same format as CustomDateFormatString (but TextConverter must handle it) + tb.Clear(); + RaiseTextEvent(tb, "17.10.2024"); + RaiseKeyEvent(tb, Key.Enter, KeyModifiers.None); + Assert.Equal("2024-10-17", datePicker.Text); + Assert.True(CompareDates(datePicker.SelectedDate!.Value, new DateTime(2024, 10, 17))); + + // can also handle parsing other formats that the user enters, too + tb.Clear(); + RaiseTextEvent(tb, "2024-02-13"); + RaiseKeyEvent(tb, Key.Enter, KeyModifiers.None); + + Assert.Equal("2024-02-13", datePicker.Text); + Assert.True(CompareDates(datePicker.SelectedDate.Value, new DateTime(2024, 2, 13))); + + tb.Clear(); + RaiseTextEvent(tb, "04 22 2026"); + RaiseKeyEvent(tb, Key.Enter, KeyModifiers.None); + + Assert.Equal("2026-04-22", datePicker.Text); + Assert.True(CompareDates(datePicker.SelectedDate.Value, new DateTime(2026, 4, 22))); + + // invalid input results in going back to last known (valid) date + tb.Clear(); + RaiseTextEvent(tb, "Not A Valid Date"); + RaiseKeyEvent(tb, Key.Enter, KeyModifiers.None); + + Assert.Equal("2026-04-22", datePicker.Text); + Assert.True(CompareDates(datePicker.SelectedDate.Value, new DateTime(2026, 4, 22))); + } + } + private static TestServices Services => TestServices.MockThreadingInterface.With( standardCursorFactory: Mock.Of());