diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DateTime/Calendar/Examples/CalendarMinMax.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DateTime/Calendar/Examples/CalendarMinMax.razor new file mode 100644 index 0000000000..4fd8644d58 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DateTime/Calendar/Examples/CalendarMinMax.razor @@ -0,0 +1,51 @@ + + +
+ +

Selected @(SelectedDay.ToString("yyyy-MM-dd"))

+

Panel @(PickerDay.ToString("yyyy-MM-dd"))

+
+ +
+ +

Selected @(SelectedMonth.ToString("yyyy-MM-dd"))

+

Panel @(PickerMonth.ToString("yyyy-MM-dd"))

+
+ +
+ +

Selected @(SelectedYear.ToString("yyyy-MM-dd"))

+

Panel @(PickerYear.ToString("yyyy-MM-dd"))

+
+
+ +@code +{ + private static readonly DateTime Today = DateTime.Today; + private static readonly DateTime StartOfMonth = new DateTime(Today.Year, Today.Month, 1); + private static readonly DateTime EndOfMonth = new DateTime(Today.Year, Today.Month, DateTime.DaysInMonth(Today.Year, Today.Month)); + + private DateTime SelectedDay = Today; + private DateTime PickerDay = StartOfMonth; + private DateTime SelectedMonth = Today; + private DateTime PickerMonth = StartOfMonth; + private DateTime SelectedYear = Today; + private DateTime PickerYear = StartOfMonth; + + private bool DisabledDay(DateTime date) => date.Day == 3 || date.Day == 8 || date.Day == 20; + +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DateTime/Calendar/FluentCalendar.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DateTime/Calendar/FluentCalendar.md index 8f7c6b692f..3c54e6a8e7 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DateTime/Calendar/FluentCalendar.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/DateTime/Calendar/FluentCalendar.md @@ -16,6 +16,18 @@ The user can switch between these views using the title item: the month name or {{ CalendarDefault }} +## Setting a minimum and maximum date + +You can set a minimum and maximum date for the calendar using the `MinDate` and `MaxDate` parameters. + +In the first example below, the view is bound to the current month, and the user can only select dates within the current month. + +In the second example, both `MinDate` and `MaxDate` are set so only months within that period can be selected + +In the third example, only a `MaxDate` is set to limit the year selection to a specific range of years. + +{{ CalendarMinMax }} + ## Selections You can activate the selection mode by setting the `SelectMode` parameter diff --git a/src/Core/Components/DateTime/CalendarTitles.cs b/src/Core/Components/DateTime/CalendarTitles.cs index 9f11ac2220..34642a42cf 100644 --- a/src/Core/Components/DateTime/CalendarTitles.cs +++ b/src/Core/Components/DateTime/CalendarTitles.cs @@ -2,6 +2,8 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using Microsoft.FluentUI.AspNetCore.Components.Extensions; + namespace Microsoft.FluentUI.AspNetCore.Components.Calendar; /// @@ -55,7 +57,7 @@ public bool ReadOnly CalendarViews.Days => false, CalendarViews.Months => false, CalendarViews.Years => true, - _ => true + _ => true, }; } } @@ -74,7 +76,7 @@ public string Label CalendarViews.Months => CalendarExtended.GetYear(), CalendarViews.Years => CalendarExtended.GetYearsRangeLabel(Date.GetYear(_calendar.Culture) - CalendarExtended.YearShiftCentered), #pragma warning restore MA0011 - _ => string.Empty + _ => string.Empty, }; } } @@ -91,7 +93,7 @@ public string PreviousTitle CalendarViews.Days => CalendarExtended.GetMonthName(Date.AddMonths(-1, _calendar.Culture)), CalendarViews.Months => CalendarExtended.GetYear(Date.AddYears(-1, _calendar.Culture)), CalendarViews.Years => CalendarExtended.GetYearsRangeLabel(Date.GetYear(_calendar.Culture) - 12 - CalendarExtended.YearShiftCentered), - _ => string.Empty + _ => string.Empty, }; } } @@ -107,12 +109,47 @@ public bool PreviousDisabled var minDate = _calendar.Culture.Calendar.MinSupportedDateTime.AddMonths(1); #pragma warning restore MA0011 + var rangeMinDate = _calendar.MinDate.ConvertToDateTime()?.Date; + var rangePreviousDisabled = false; + if (rangeMinDate.HasValue) + { + var candidate = View switch + { + CalendarViews.Days => Date.AddMonths(-1, _calendar.Culture), + CalendarViews.Months => Date.AddYears(-1, _calendar.Culture), + CalendarViews.Years => Date.AddYears(-12, _calendar.Culture), + _ => Date, + }; + + var candidatePeriodEnd = View switch + { + CalendarViews.Days + => candidate.StartOfMonth(_calendar.Culture) + .AddMonths(1, _calendar.Culture) + .AddDays(-1), + + CalendarViews.Months + => candidate.StartOfYear(_calendar.Culture) + .AddYears(1, _calendar.Culture) + .AddDays(-1), + + CalendarViews.Years + => candidate.StartOfYear(_calendar.Culture) + .AddYears(12, _calendar.Culture) + .AddDays(-1), + + _ => candidate, + }; + + rangePreviousDisabled = candidatePeriodEnd.Date < rangeMinDate.Value; + } + return View switch { - CalendarViews.Days => Date.Year == minDate.Year && Date.Month == minDate.Month, - CalendarViews.Months => Date.Year == minDate.Year, - CalendarViews.Years => Date.Year - CalendarExtended.YearShiftCentered <= minDate.Year + 12, - _ => false + CalendarViews.Days => (Date.Year == minDate.Year && Date.Month == minDate.Month) || rangePreviousDisabled, + CalendarViews.Months => Date.Year == minDate.Year || rangePreviousDisabled, + CalendarViews.Years => Date.Year - CalendarExtended.YearShiftCentered <= minDate.Year + 12 || rangePreviousDisabled, + _ => false, }; } } @@ -129,7 +166,7 @@ public string NextTitle CalendarViews.Days => CalendarExtended.GetMonthName(Date.AddMonths(+1, _calendar.Culture)), CalendarViews.Months => CalendarExtended.GetYear(Date.AddYears(+1, _calendar.Culture)), CalendarViews.Years => CalendarExtended.GetYearsRangeLabel(Date.GetYear(_calendar.Culture) + 12 - CalendarExtended.YearShiftCentered), - _ => string.Empty + _ => string.Empty, }; } } @@ -143,12 +180,41 @@ public bool NextDisabled { var maxDate = _calendar.Culture.Calendar.MaxSupportedDateTime; + var rangeMaxDate = _calendar.MaxDate.ConvertToDateTime()?.Date; + var rangeNextDisabled = false; + if (rangeMaxDate.HasValue) + { + var candidate = View switch + { + CalendarViews.Days => Date.AddMonths(+1, _calendar.Culture), + CalendarViews.Months => Date.AddYears(+1, _calendar.Culture), + CalendarViews.Years => Date.AddYears(+12, _calendar.Culture), + _ => Date, + }; + + var candidatePeriodStart = View switch + { + CalendarViews.Days + => candidate.StartOfMonth(_calendar.Culture), + + CalendarViews.Months + => candidate.StartOfYear(_calendar.Culture), + + CalendarViews.Years + => candidate.StartOfYear(_calendar.Culture), + + _ => candidate, + }; + + rangeNextDisabled = candidatePeriodStart.Date > rangeMaxDate.Value; + } + return View switch { - CalendarViews.Days => Date.Year == maxDate.Year && Date.Month == maxDate.Month, - CalendarViews.Months => Date.Year == maxDate.Year, - CalendarViews.Years => Date.Year + 12 - CalendarExtended.YearShiftCentered >= maxDate.Year, - _ => false + CalendarViews.Days => (Date.Year == maxDate.Year && Date.Month == maxDate.Month) || rangeNextDisabled, + CalendarViews.Months => Date.Year == maxDate.Year || rangeNextDisabled, + CalendarViews.Years => Date.Year + 12 - CalendarExtended.YearShiftCentered >= maxDate.Year || rangeNextDisabled, + _ => false, }; } } diff --git a/src/Core/Components/DateTime/FluentCalendar.razor b/src/Core/Components/DateTime/FluentCalendar.razor index 957b51bb2c..cdc5b578c6 100644 --- a/src/Core/Components/DateTime/FluentCalendar.razor +++ b/src/Core/Components/DateTime/FluentCalendar.razor @@ -28,6 +28,7 @@ @* Title bar (label, previous and next buttons) *@
+
@@ -177,6 +178,8 @@ ValueChanged="@PickerMonthSelectAsync" ReadOnly="@ReadOnly" Disabled="@Disabled" + MinDate="@(MinDate.ConvertToDateTime() ?? default)" + MaxDate="@(MaxDate.ConvertToDateTime() ?? default)" Culture="@Culture" DisabledSelectable="@DisabledSelectable" AnimatePeriodChanges="@AnimatePeriodChanges" @@ -193,6 +196,8 @@ ValueChanged="@PickerYearSelectAsync" ReadOnly="@ReadOnly" Disabled="@Disabled" + MinDate="@(MinDate.ConvertToDateTime() ?? default)" + MaxDate="@(MaxDate.ConvertToDateTime() ?? default)" Culture="@Culture" DisabledSelectable="@DisabledSelectable" AnimatePeriodChanges="@AnimatePeriodChanges" diff --git a/src/Core/Components/DateTime/FluentCalendar.razor.cs b/src/Core/Components/DateTime/FluentCalendar.razor.cs index ba91911a21..524ae68054 100644 --- a/src/Core/Components/DateTime/FluentCalendar.razor.cs +++ b/src/Core/Components/DateTime/FluentCalendar.razor.cs @@ -241,48 +241,111 @@ internal CalendarTitles GetTitles() return new CalendarTitles(this); } + private (DateTime PeriodStart, DateTime PeriodEnd) GetDisplayedYearsPeriod(DateTime pivotDate) + { + // This must match CalendarExtended.GetYearsRange() logic (YearShiftCentered + supported-year clamping). + var pivotYear = pivotDate.GetYear(Culture); + var minSupportedYear = Culture.Calendar.MinSupportedDateTime.GetYear(Culture); + var maxSupportedYear = Culture.Calendar.MaxSupportedDateTime.GetYear(Culture); + + var fromYear = pivotYear - CalendarExtended.YearShiftCentered; + if (fromYear < minSupportedYear) + { + fromYear = minSupportedYear; + } + + var toYear = fromYear + 11; + if (toYear > maxSupportedYear) + { + toYear = maxSupportedYear; + } + + var periodStart = Culture.Calendar.ToDateTime(fromYear, 1, 1, 0, 0, 0, 0).StartOfYear(Culture); + var periodEnd = Culture.Calendar.ToDateTime(toYear, 1, 1, 0, 0, 0, 0).EndOfYear(Culture); + + return (periodStart, periodEnd); + } + + private (DateTime PeriodStart, DateTime PeriodEnd) GetDisplayedPeriod(DateTime pivotDate) + { + return View switch + { + CalendarViews.Days => (pivotDate.StartOfMonth(Culture), pivotDate.EndOfMonth(Culture)), + CalendarViews.Months => (pivotDate.StartOfYear(Culture), pivotDate.EndOfYear(Culture)), + CalendarViews.Years => GetDisplayedYearsPeriod(pivotDate), + _ => (pivotDate, pivotDate), + }; + } + + private DateTime GetRequestedPickerMonth(bool navigateForward) + { + var currentPickerMonth = PickerMonth.ConvertToRequiredDateTime(); + + return View switch + { + CalendarViews.Days => currentPickerMonth.AddMonths(navigateForward ? 1 : -1, Culture), + CalendarViews.Months => currentPickerMonth.AddYears(navigateForward ? 1 : -1, Culture), + CalendarViews.Years => currentPickerMonth.AddYears(navigateForward ? 12 : -12, Culture), + _ => currentPickerMonth, + }; + } + + private DateTime ClampRequestedPickerMonth(DateTime requestedPickerMonth, bool navigateForward) + { + var (periodStart, periodEnd) = GetDisplayedPeriod(requestedPickerMonth); + + if (navigateForward) + { + var maxDate = MaxDate.ConvertToDateTime()?.Date; + if (maxDate.HasValue && periodStart.Date > maxDate.Value) + { + return maxDate.Value; + } + } + else + { + var minDate = MinDate.ConvertToDateTime()?.Date; + if (minDate.HasValue && periodEnd.Date < minDate.Value) + { + return minDate.Value; + } + } + + return requestedPickerMonth; + } + /// internal async Task OnPreviousButtonHandlerAsync(MouseEventArgs _) { - await StartNewAnimationAsync(AnimationRunning.Down); - _refreshAccessibilityPending = true; + var requestedPickerMonth = GetRequestedPickerMonth(navigateForward: false); + var candidatePickerMonth = ClampRequestedPickerMonth(requestedPickerMonth, navigateForward: false).ConvertToTValue(); - switch (View) + if (candidatePickerMonth.ConvertToDateTime() == PickerMonth.ConvertToDateTime()) { - case CalendarViews.Days: - PickerMonth = PickerMonth.AddMonths(-1, Culture); - break; + return; + } - case CalendarViews.Months: - PickerMonth = PickerMonth.AddYears(-1, Culture); - break; + await StartNewAnimationAsync(AnimationRunning.Down); + _refreshAccessibilityPending = true; - case CalendarViews.Years: - PickerMonth = PickerMonth.AddYears(-12, Culture); - break; - } + PickerMonth = candidatePickerMonth; } /// internal async Task OnNextButtonHandlerAsync(MouseEventArgs _) { - await StartNewAnimationAsync(AnimationRunning.Up); - _refreshAccessibilityPending = true; + var requestedPickerMonth = GetRequestedPickerMonth(navigateForward: true); + var candidatePickerMonth = ClampRequestedPickerMonth(requestedPickerMonth, navigateForward: true).ConvertToTValue(); - switch (View) + if (candidatePickerMonth.ConvertToDateTime() == PickerMonth.ConvertToDateTime()) { - case CalendarViews.Days: - PickerMonth = PickerMonth.AddMonths(+1, Culture); - break; + return; + } - case CalendarViews.Months: - PickerMonth = PickerMonth.AddYears(+1, Culture); - break; + await StartNewAnimationAsync(AnimationRunning.Up); + _refreshAccessibilityPending = true; - case CalendarViews.Years: - PickerMonth = PickerMonth.AddYears(+12, Culture); - break; - } + PickerMonth = candidatePickerMonth; } /// @@ -339,6 +402,7 @@ private async Task OnSelectYearHandlerAsync(int year, bool isReadOnly) private FluentCalendarMonth GetMonthProperties(int? year, int? month) { var pickerDateTime = PickerMonth.ConvertToRequiredDateTime(); + return new(this, Culture.Calendar.ToDateTime(year ?? pickerDateTime.GetYear(Culture), month ?? pickerDateTime.GetMonth(Culture), 1, 0, 0, 0, 0)); } @@ -423,6 +487,9 @@ private async Task PickerYearSelectAsync(DateTime year) await Task.CompletedTask; } + private bool IsDayDisabled(DateTime value) + => AllowedRange.IsOutsideRange(value) || (DisabledDateFunc?.Invoke(value.ConvertToTValue()) ?? false); + /// private (bool IsMultiple, DateTime Min, DateTime Max, bool InProgress) GetMultipleSelection() { @@ -504,7 +571,7 @@ private async Task OnSelectMultipleDatesAsync(DateTime value) SelectedDates = range.Where(day => { var dateTime = day.ConvertToDateTime(); - return dateTime.HasValue && (DisabledDateFunc == null || !DisabledDateFunc(day)); + return dateTime.HasValue && !IsDayDisabled(dateTime.Value); }); if (SelectedDatesChanged.HasDelegate) @@ -550,7 +617,7 @@ private async Task OnSelectRangeDatesAsync(DateTime value) } SelectedDates = _rangeSelector.GetAllDates() - .Where(day => DisabledDateFunc == null || !DisabledDateFunc(day.ConvertToTValue())) + .Where(day => !IsDayDisabled(day)) .Select(day => day.ConvertToTValue()); if (SelectedDatesChanged.HasDelegate) @@ -583,9 +650,7 @@ internal Task OnSelectDayMouseOverAsync(DateTime value, bool dayDisabled) _rangeSelectorMouseOver.End = range.MaxDateTime(); } - var days = DisabledDateFunc is null - ? _rangeSelectorMouseOver.GetAllDates() - : _rangeSelectorMouseOver.GetAllDates().Where(day => !DisabledDateFunc(day.ConvertToTValue())); + var days = _rangeSelectorMouseOver.GetAllDates().Where(day => !IsDayDisabled(day)); _selectedDatesMouseOver.Clear(); _selectedDatesMouseOver.AddRange(days); @@ -611,14 +676,9 @@ public virtual Task FocusOutHandlerAsync(FocusEventArgs? e) /// internal bool AllDaysAreDisabled(DateTime start, DateTime end) { - if (DisabledDateFunc is null) - { - return false; - } - for (var day = start; day <= end; day = day.AddDays(1)) { - if (!DisabledDateFunc.Invoke(day.ConvertToTValue())) + if (!IsDayDisabled(day)) { return false; } diff --git a/src/Core/Components/DateTime/FluentCalendarBase.cs b/src/Core/Components/DateTime/FluentCalendarBase.cs index 76ffee5a79..e5fb7b254a 100644 --- a/src/Core/Components/DateTime/FluentCalendarBase.cs +++ b/src/Core/Components/DateTime/FluentCalendarBase.cs @@ -76,6 +76,20 @@ protected FluentCalendarBase(LibraryConfiguration configuration) : base(configur [Parameter] public virtual CalendarViews View { get; set; } = CalendarViews.Days; + /// + /// Gets or sets the minimum date that can be selected in the calendar. If not set, there is no minimum date. + /// + [Parameter] + public TValue? MinDate { get; set; } + + /// + /// Gets or sets the maximum date that can be selected in the calendar. If not set, there is no maximum date. + /// + [Parameter] + public TValue? MaxDate { get; set; } + + internal RangeOfDates AllowedRange => new(MinDate.ConvertToDateTime()?.Date, MaxDate.ConvertToDateTime()?.Date); + /// protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage) { @@ -100,6 +114,11 @@ protected virtual Task OnSelectedDateHandlerAsync(TValue? value) } var dateTime = value.ConvertToDateTime(); + if (dateTime.HasValue && AllowedRange.IsSelectionOutsideRange(dateTime.Value, View, Culture)) + { + return Task.CompletedTask; + } + if ((CheckIfSelectedValueHasChanged ?? true) && CurrentValue.ConvertToDateTime() == dateTime) { return Task.CompletedTask; diff --git a/src/Core/Components/DateTime/FluentCalendarDay.cs b/src/Core/Components/DateTime/FluentCalendarDay.cs index dba9b93ac6..f93e3c8ae7 100644 --- a/src/Core/Components/DateTime/FluentCalendarDay.cs +++ b/src/Core/Components/DateTime/FluentCalendarDay.cs @@ -28,7 +28,7 @@ internal FluentCalendarDay(FluentCalendar calendar, DateTime day) Date = day.Date.ConvertToTValue(); DateTime = day.Date; - _isInDisabledList = calendar.DisabledDateFunc?.Invoke(day.ConvertToTValue()) ?? false; + _isInDisabledList = calendar.AllowedRange.IsOutsideRange(day) || (calendar.DisabledDateFunc?.Invoke(day.ConvertToTValue()) ?? false); _isOutsideCurrentMonth = !calendar.CalendarExtended.IsInCurrentMonth(day); } @@ -45,7 +45,7 @@ internal FluentCalendarDay(FluentCalendar calendar, DateTime day) /// /// Gets a value indicating whether the day is disabled by the user. /// - public bool IsDisabled => IsInactive ? false : _isInDisabledList && _calendar.DisabledSelectable; + public bool IsDisabled => !IsInactive && _isInDisabledList && _calendar.DisabledSelectable; /// /// Gets a value indicating whether the day is inactive (out of the current month). diff --git a/src/Core/Components/DateTime/FluentCalendarMonth.cs b/src/Core/Components/DateTime/FluentCalendarMonth.cs index be064a64f6..42513c6947 100644 --- a/src/Core/Components/DateTime/FluentCalendarMonth.cs +++ b/src/Core/Components/DateTime/FluentCalendarMonth.cs @@ -32,7 +32,7 @@ internal FluentCalendarMonth(FluentCalendar calendar, DateTime month) } else { - _isInDisabledList = calendar.DisabledDateFunc?.Invoke(Month.ConvertToTValue()) ?? false; + _isInDisabledList = calendar.AllowedRange.IsSelectionOutsideRange(Month, calendar.View, calendar.Culture) || (calendar.DisabledDateFunc?.Invoke(Month.ConvertToTValue()) ?? false); } } @@ -59,7 +59,7 @@ internal FluentCalendarMonth(FluentCalendar calendar, DateTime month) /// /// Gets the title of the month in the format [month] [year]. /// - public string Title => $"{_calendar.CalendarExtended.GetMonthName(Month)} {Month.GetYear(_calendar.Culture):0000}"; + public string Title => $"{_calendar.CalendarExtended.GetMonthName(Month)} {Month.GetYear(_calendar.Culture):0000, _calendar.Culture}"; /// /// Gets the identifier of the month in the format yyyy-MM. diff --git a/src/Core/Components/DateTime/FluentCalendarYear.cs b/src/Core/Components/DateTime/FluentCalendarYear.cs index cd90433957..5e4557fe26 100644 --- a/src/Core/Components/DateTime/FluentCalendarYear.cs +++ b/src/Core/Components/DateTime/FluentCalendarYear.cs @@ -32,7 +32,7 @@ internal FluentCalendarYear(FluentCalendar calendar, DateTime year) } else { - _isInDisabledList = calendar.DisabledDateFunc?.Invoke(Year.ConvertToTValue()) ?? false; + _isInDisabledList = calendar.AllowedRange.IsSelectionOutsideRange(Year, calendar.View, calendar.Culture) || (calendar.DisabledDateFunc?.Invoke(Year.ConvertToTValue()) ?? false); } } diff --git a/src/Core/Components/DateTime/FluentDatePicker.razor b/src/Core/Components/DateTime/FluentDatePicker.razor index 563cad3ba8..8014a51fd3 100644 --- a/src/Core/Components/DateTime/FluentDatePicker.razor +++ b/src/Core/Components/DateTime/FluentDatePicker.razor @@ -53,6 +53,8 @@ DisabledDateFunc="@DisabledDateFunc" DisabledCheckAllDaysOfMonthYear="@DisabledCheckAllDaysOfMonthYear" DisabledSelectable="@DisabledSelectable" + MinDate="@MinDate" + MaxDate="@MaxDate" Value="@Value" ValueChanged="@OnSelectedDateAsync" DaysTemplate="@DaysTemplate" diff --git a/src/Core/Components/DateTime/RangeOfDates.cs b/src/Core/Components/DateTime/RangeOfDates.cs index 5c231c4523..54a18b259c 100644 --- a/src/Core/Components/DateTime/RangeOfDates.cs +++ b/src/Core/Components/DateTime/RangeOfDates.cs @@ -3,6 +3,7 @@ // ------------------------------------------------------------------------ using System.Globalization; +using Microsoft.FluentUI.AspNetCore.Components.Extensions; using Microsoft.FluentUI.AspNetCore.Components.Utilities; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -38,4 +39,50 @@ public override string ToString() { return $"From {Start?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? ""} to {End?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? ""}."; } + + internal bool IsOutsideRange(DateTime value) + { + var min = Start?.Date; + var max = End?.Date; + var date = value.Date; + + if (min.HasValue && date < min.Value) + { + return true; + } + + if (max.HasValue && date > max.Value) + { + return true; + } + + return false; + } + + internal bool IsPeriodOutsideRange(DateTime periodStart, DateTime periodEnd) + { + var min = Start?.Date; + var max = End?.Date; + + if (min.HasValue && periodEnd.Date < min.Value) + { + return true; + } + + if (max.HasValue && periodStart.Date > max.Value) + { + return true; + } + + return false; + } + + internal bool IsSelectionOutsideRange(DateTime value, CalendarViews view, CultureInfo culture) + => view switch + { + CalendarViews.Days => IsOutsideRange(value), + CalendarViews.Months => IsPeriodOutsideRange(value.StartOfMonth(culture), value.EndOfMonth(culture)), + CalendarViews.Years => IsPeriodOutsideRange(value.StartOfYear(culture), value.EndOfYear(culture)), + _ => false, + }; } diff --git a/tests/Core/Components/DateTimes/FluentCalendarTests.razor b/tests/Core/Components/DateTimes/FluentCalendarTests.razor index bbaa4d276a..eedf703605 100644 --- a/tests/Core/Components/DateTimes/FluentCalendarTests.razor +++ b/tests/Core/Components/DateTimes/FluentCalendarTests.razor @@ -141,6 +141,78 @@ value=""2022-02-20"">20
"); Assert.Equal(juneFirst, component.Instance.PickerMonth); } + [Fact] + public async Task FluentCalendar_PickerMonth_Setter_DoesNothing_WhenMonthDateTimeEqualsPickerMonthField() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var sameMonthDifferentDay = DateTime.Parse("2022-06-20"); + var currentPickerMonth = DateTime.Parse("2022-06-01"); + + var changedCount = 0; + DateTime lastChanged = default; + + // Arrange + var cut = Render(@); + var component = cut.FindComponent>(); + + var pickerMonthField = component.Instance.GetType().GetField("_pickerMonth", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(pickerMonthField); + + await component.InvokeAsync(() => pickerMonthField!.SetValue(component.Instance, currentPickerMonth)); + + Assert.Equal(currentPickerMonth, (DateTime)pickerMonthField!.GetValue(component.Instance)!); + + var pickerMonthProperty = component.Instance.GetType().GetProperty(nameof(FluentCalendar.PickerMonth)); + Assert.NotNull(pickerMonthProperty); + + // Act: set PickerMonth again so monthDateTime == _pickerMonth + await component.InvokeAsync(() => pickerMonthProperty!.SetValue(component.Instance, sameMonthDifferentDay)); + + await Task.Delay(50, Xunit.TestContext.Current.CancellationToken); + + // Assert: setter early-return should prevent any callback and keep _pickerMonth unchanged + Assert.Equal(0, changedCount); + Assert.Equal(currentPickerMonth, (DateTime)pickerMonthField.GetValue(component.Instance)!); + } + + [Fact] + public async Task FluentCalendar_OnSelectedDateHandlerAsync_DoesNotChangeValue_WhenSelectionOutsideRange() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var initial = new DateTime(2022, 6, 15); + var min = new DateTime(2022, 6, 10); + var outside = new DateTime(2022, 6, 5); + + var valueChangedCount = 0; + DateTime lastValueChanged = default; + + // Arrange + var cut = Render(@); + var component = cut.FindComponent>(); + + Assert.Equal(initial, component.Instance.Value); + + // Resolve the DateTime overload on the FluentCalendar class (the derived class contains + // both the DateTime adapter and the TValue overload from FluentCalendarBase). + var method = typeof(FluentCalendar).GetMethod("OnSelectedDateHandlerAsync", BindingFlags.NonPublic | BindingFlags.Instance, [typeof(DateTime)]); + Assert.NotNull(method); + + var beforeCount = valueChangedCount; + var beforeValue = component.Instance.Value; + + // Act: valid DateTime value, but outside of the allowed range (IsSelectionOutsideRange) + await component.InvokeAsync(() => (Task)method!.Invoke(component.Instance, [outside])!); + + // Assert + Assert.Equal(beforeCount, valueChangedCount); + Assert.Equal(beforeValue, component.Instance.Value); + Assert.Equal(default, lastValueChanged); + } + [Theory] [InlineData("en-US", "June 2022", "2022-06-01")] [InlineData("fa-IR", "خرداد 1401", "2022-05-22")] @@ -255,6 +327,86 @@ value=""2022-02-20"">20
"); Assert.Equal(expectedMonthName, monthName); } + [Fact] + public async Task FluentCalendar_OnPreviousButtonHandlerAsync_ClampsToMinDate_InDaysView() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var cut = Render(@); + var component = cut.FindComponent>(); + var method = typeof(FluentCalendar).GetMethod("OnPreviousButtonHandlerAsync", BindingFlags.NonPublic | BindingFlags.Instance); + + Assert.NotNull(method); + + await component.InvokeAsync(() => (Task)method!.Invoke(component.Instance, [new Microsoft.AspNetCore.Components.Web.MouseEventArgs()])!); + + Assert.Equal(new DateTime(2022, 7, 1), component.Instance.PickerMonth); + } + + [Fact] + public async Task FluentCalendar_OnNextButtonHandlerAsync_ClampsToMaxDate_InDaysView() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var cut = Render(@); + var component = cut.FindComponent>(); + var method = typeof(FluentCalendar).GetMethod("OnNextButtonHandlerAsync", BindingFlags.NonPublic | BindingFlags.Instance); + + Assert.NotNull(method); + + await component.InvokeAsync(() => (Task)method!.Invoke(component.Instance, [new Microsoft.AspNetCore.Components.Web.MouseEventArgs()])!); + + Assert.Equal(new DateTime(2022, 8, 1), component.Instance.PickerMonth); + } + + [Fact] + public async Task FluentCalendar_OnPreviousButtonHandlerAsync_ClampsToMinDate_InYearsView() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var cut = Render(@); + var component = cut.FindComponent>(); + var method = typeof(FluentCalendar).GetMethod("OnPreviousButtonHandlerAsync", BindingFlags.NonPublic | BindingFlags.Instance); + + Assert.NotNull(method); + + await component.InvokeAsync(() => (Task)method!.Invoke(component.Instance, [new Microsoft.AspNetCore.Components.Web.MouseEventArgs()])!); + + Assert.Equal(new DateTime(8000, 1, 1), component.Instance.PickerMonth); + } + + [Fact] + public async Task FluentCalendar_OnNextButtonHandlerAsync_ClampsToMaxDate_InYearsView() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var cut = Render(@); + var component = cut.FindComponent>(); + var method = typeof(FluentCalendar).GetMethod("OnNextButtonHandlerAsync", BindingFlags.NonPublic | BindingFlags.Instance); + + Assert.NotNull(method); + + await component.InvokeAsync(() => (Task)method!.Invoke(component.Instance, [new Microsoft.AspNetCore.Components.Web.MouseEventArgs()])!); + + Assert.Equal(new DateTime(1000, 1, 1), component.Instance.PickerMonth); + } + [Fact] public void FluentCalendar_GetDayOfMonthTwoDigit() { @@ -399,6 +551,217 @@ value=""2022-02-20"">20"); calendar.Verify(suffix: view.GetDescription()); } + [Fact] + public void FluentCalendar_MinMax_Days_DisablesOutsideRange_And_DisablesNavigation() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var pickerMonth = DateTime.Parse("2022-06-15"); + var minDate = DateTime.Parse("2022-06-10"); + var maxDate = DateTime.Parse("2022-06-20"); + + DateTime? value = DateTime.Parse("2022-06-15"); + Action changed = (e) => value = e; + + // Arrange + var calendar = Render(@); + + // Days outside range should be disabled + Assert.True(calendar.Find("div[value='2022-06-09']").HasAttribute("disabled")); + Assert.True(calendar.Find("div[value='2022-06-21']").HasAttribute("disabled")); + + // Prev/next navigation should be disabled when it would navigate outside the range + Assert.True(calendar.Find(".previous").HasAttribute("disabled")); + Assert.True(calendar.Find(".next").HasAttribute("disabled")); + + // Clicking an out-of-range day should not change the value + calendar.Find("div[value='2022-06-09']").Click(); + Assert.Equal(DateTime.Parse("2022-06-15"), value); + + // Clicking an in-range day should change the value + calendar.Find("div[value='2022-06-10']").Click(); + Assert.Equal(DateTime.Parse("2022-06-10"), value); + } + + [Fact] + public void FluentCalendar_MinMax_Months_DisablesOutsideRange_And_DisablesNavigation() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var pickerMonth = DateTime.Parse("2022-06-15"); + var minDate = DateTime.Parse("2022-04-15"); + var maxDate = DateTime.Parse("2022-06-20"); + + // Arrange + var calendar = Render(@); + + // Months completely outside range should be disabled + Assert.True(calendar.Find("div[value='2022-03']").HasAttribute("disabled")); + Assert.True(calendar.Find("div[value='2022-07']").HasAttribute("disabled")); + + // Months intersecting the range should be enabled + Assert.False(calendar.Find("div[value='2022-04']").HasAttribute("disabled")); + Assert.False(calendar.Find("div[value='2022-06']").HasAttribute("disabled")); + + // Prev/next year navigation should be disabled when it would navigate outside the range + Assert.True(calendar.Find(".previous").HasAttribute("disabled")); + Assert.True(calendar.Find(".next").HasAttribute("disabled")); + } + + [Fact] + public void FluentCalendar_MinMax_Years_DisablesOutsideRange() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var pickerMonth = DateTime.Parse("2022-06-15"); + var minDate = DateTime.Parse("2021-06-01"); + var maxDate = DateTime.Parse("2023-02-01"); + + // Arrange + var calendar = Render(@); + + Assert.True(calendar.Find("div[value='2020']").HasAttribute("disabled")); + Assert.False(calendar.Find("div[value='2022']").HasAttribute("disabled")); + Assert.True(calendar.Find("div[value='2024']").HasAttribute("disabled")); + } + + [Fact] + public async Task FluentCalendar_OnPreviousButtonHandlerAsync_DaysView_RespectsMinDate_WithPartialMonthOverlap() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var pickerMonth = DateTime.Parse("2022-06-15"); + var minDate = DateTime.Parse("2022-05-15"); + + // Arrange + var cut = Render(@); + var component = cut.FindComponent>(); + + // Act: June -> May (allowed because May contains dates >= MinDate) + await component.InvokeAsync(() => component.Instance.OnPreviousButtonHandlerAsync(new MouseEventArgs())); + + // Assert + Assert.Equal(DateTime.Parse("2022-05-01"), component.Instance.PickerMonth); + + // Act: May -> April (blocked because April is entirely < MinDate) + await component.InvokeAsync(() => component.Instance.OnPreviousButtonHandlerAsync(new MouseEventArgs())); + + // Assert + Assert.Equal(DateTime.Parse("2022-05-01"), component.Instance.PickerMonth); + } + + [Fact] + public async Task FluentCalendar_OnNextButtonHandlerAsync_DaysView_RespectsMaxDate_WithPartialMonthOverlap() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var pickerMonth = DateTime.Parse("2022-06-15"); + var maxDate = DateTime.Parse("2022-07-10"); + + // Arrange + var cut = Render(@); + var component = cut.FindComponent>(); + + // Act: June -> July (allowed because July contains dates <= MaxDate) + await component.InvokeAsync(() => component.Instance.OnNextButtonHandlerAsync(new MouseEventArgs())); + + // Assert + Assert.Equal(DateTime.Parse("2022-07-01"), component.Instance.PickerMonth); + + // Act: July -> August (blocked because August is entirely > MaxDate) + await component.InvokeAsync(() => component.Instance.OnNextButtonHandlerAsync(new MouseEventArgs())); + + // Assert + Assert.Equal(DateTime.Parse("2022-07-01"), component.Instance.PickerMonth); + } + + [Fact] + public async Task FluentCalendar_OnPreviousButtonHandlerAsync_MonthsView_RespectsMinDate_WithPartialYearOverlap() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var pickerMonth = DateTime.Parse("2023-06-15"); + var minDate = DateTime.Parse("2022-04-15"); + + // Arrange + var cut = Render(@); + var component = cut.FindComponent>(); + + // Act: 2023 -> 2022 (allowed because 2022 contains dates >= MinDate) + await component.InvokeAsync(() => component.Instance.OnPreviousButtonHandlerAsync(new MouseEventArgs())); + Assert.Equal(DateTime.Parse("2022-06-01"), component.Instance.PickerMonth); + + // Act: 2022 -> 2021 (blocked because 2021 is entirely < MinDate) + await component.InvokeAsync(() => component.Instance.OnPreviousButtonHandlerAsync(new MouseEventArgs())); + Assert.Equal(DateTime.Parse("2022-06-01"), component.Instance.PickerMonth); + } + + [Fact] + public async Task FluentCalendar_OnNextButtonHandlerAsync_MonthsView_RespectsMaxDate_WithPartialYearOverlap() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var pickerMonth = DateTime.Parse("2021-06-15"); + var maxDate = DateTime.Parse("2022-09-15"); + + // Arrange + var cut = Render(@); + var component = cut.FindComponent>(); + + // Act: 2021 -> 2022 (allowed because 2022 contains dates <= MaxDate) + await component.InvokeAsync(() => component.Instance.OnNextButtonHandlerAsync(new MouseEventArgs())); + Assert.Equal(DateTime.Parse("2022-06-01"), component.Instance.PickerMonth); + + // Act: 2022 -> 2023 (blocked because 2023 is entirely > MaxDate) + await component.InvokeAsync(() => component.Instance.OnNextButtonHandlerAsync(new MouseEventArgs())); + Assert.Equal(DateTime.Parse("2022-06-01"), component.Instance.PickerMonth); + } + + [Fact] + public async Task FluentCalendar_OnPreviousButtonHandlerAsync_YearsView_RespectsMinDate_For12YearRanges() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var pickerMonth = DateTime.Parse("2026-06-15"); + var minDate = DateTime.Parse("2018-01-01"); + + // Arrange + var cut = Render(@); + var component = cut.FindComponent>(); + + // Act: 2026 -> 2014 (allowed because 2014-2025 range contains dates >= MinDate) + await component.InvokeAsync(() => component.Instance.OnPreviousButtonHandlerAsync(new MouseEventArgs())); + Assert.Equal(DateTime.Parse("2014-06-01"), component.Instance.PickerMonth); + + // Act: 2014 -> 2002 (blocked because 2002-2013 range is entirely < MinDate) + await component.InvokeAsync(() => component.Instance.OnPreviousButtonHandlerAsync(new MouseEventArgs())); + Assert.Equal(DateTime.Parse("2014-06-01"), component.Instance.PickerMonth); + } + + [Fact] + public async Task FluentCalendar_OnNextButtonHandlerAsync_YearsView_RespectsMaxDate_For12YearRanges() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var pickerMonth = DateTime.Parse("2008-06-15"); + var maxDate = DateTime.Parse("2020-12-31"); + + // Arrange + var cut = Render(@); + var component = cut.FindComponent>(); + + // Act: 2008 -> 2020 (allowed because 2020-2031 range starts within MaxDate year) + await component.InvokeAsync(() => component.Instance.OnNextButtonHandlerAsync(new MouseEventArgs())); + Assert.Equal(DateTime.Parse("2020-06-01"), component.Instance.PickerMonth); + + // Act: 2020 -> 2032 (blocked because 2032-2043 range is entirely > MaxDate) + await component.InvokeAsync(() => component.Instance.OnNextButtonHandlerAsync(new MouseEventArgs())); + Assert.Equal(DateTime.Parse("2020-06-01"), component.Instance.PickerMonth); + } + [Theory] [InlineData(CalendarViews.Days)] [InlineData(CalendarViews.Months)] @@ -646,6 +1009,74 @@ value=""2022-02-20"">20"); Assert.Equal(new DateTime(2022, 7, 1), pickerMonthChanged.Value); } + [Fact] + public void FluentCalendar_GetDisplayedYearsPeriod_ClampsFromYear_ToMinSupportedYear() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var culture = InvariantCulture; + var yearShiftCentered = GetYearShiftCentered(); + + var minSupportedYear = culture.Calendar.GetYear(culture.Calendar.MinSupportedDateTime); + var pivotYear = minSupportedYear + Math.Max(0, yearShiftCentered - 1); + var pivot = culture.Calendar.ToDateTime(pivotYear, 6, 15, 0, 0, 0, 0); + + // Arrange + var cut = Render(@); + var component = cut.FindComponent>(); + var method = component.Instance.GetType().GetMethod("GetDisplayedYearsPeriod", BindingFlags.NonPublic | BindingFlags.Instance); + + // Act + var period = (ValueTuple)method!.Invoke(component.Instance, [pivot])!; + + // Assert + var expectedFromYear = minSupportedYear; + var expectedToYear = Math.Min(expectedFromYear + 11, culture.Calendar.GetYear(culture.Calendar.MaxSupportedDateTime)); + var expectedStart = culture.Calendar.ToDateTime(expectedFromYear, 1, 1, 0, 0, 0, 0); + var expectedEnd = culture.Calendar.ToDateTime(expectedToYear, 1, 1, 0, 0, 0, 0).EndOfYear(culture); + + Assert.Equal(expectedStart.Date, period.Item1.Date); + Assert.Equal(expectedEnd.Date, period.Item2.Date); + } + + [Fact] + public void FluentCalendar_GetDisplayedYearsPeriod_ClampsToYear_ToMaxSupportedYear() + { + using var context = new DateTimeProviderContext(DateTime.Now); + + var culture = InvariantCulture; + var yearShiftCentered = GetYearShiftCentered(); + + var maxSupportedYear = culture.Calendar.GetYear(culture.Calendar.MaxSupportedDateTime); + + var pivotYear = maxSupportedYear - 1; + var pivot = culture.Calendar.ToDateTime(pivotYear, 6, 15, 0, 0, 0, 0); + + // Arrange + var cut = Render(@); + var component = cut.FindComponent>(); + var method = component.Instance.GetType().GetMethod("GetDisplayedYearsPeriod", BindingFlags.NonPublic | BindingFlags.Instance); + + // Act + var period = (ValueTuple)method!.Invoke(component.Instance, [pivot])!; + + // Assert + var expectedFromYear = pivotYear - yearShiftCentered; + var expectedStart = culture.Calendar.ToDateTime(expectedFromYear, 1, 1, 0, 0, 0, 0); + var expectedEnd = culture.Calendar.ToDateTime(maxSupportedYear, 1, 1, 0, 0, 0, 0).EndOfYear(culture); + + Assert.Equal(expectedStart.Date, period.Item1.Date); + Assert.Equal(expectedEnd.Date, period.Item2.Date); + } + + private static int GetYearShiftCentered() + { + var calendarExtendedType = typeof(FluentCalendar).Assembly.GetType("Microsoft.FluentUI.AspNetCore.Components.Calendar.CalendarExtended"); + var yearShiftField = calendarExtendedType?.GetField("YearShiftCentered", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); + + return (int)(yearShiftField?.GetRawConstantValue() ?? 5); + } + [Fact] public void FluentCalendar_FocusOutHandler() { @@ -941,8 +1372,6 @@ value=""2022-02-20"">20"); Assert.Contains("path", FluentCalendar.ArrowDown.Value); } - #region OnSelectedDateHandlerAsync Behavior Tests - [Fact] public void FluentCalendar_ReadOnly_DateSelection_DoesNotChangeValue() { @@ -1193,10 +1622,6 @@ value=""2022-02-20"">20"); Assert.Equal(targetValue, actualValue); } - #endregion - - #region GetAnimationClass Tests - [Theory] [InlineData("existing-class", true, "None", "existing-class animation-none")] [InlineData("existing-class", true, "Up", "existing-class animation-running-up")] @@ -1274,10 +1699,6 @@ value=""2022-02-20"">20"); } } - #endregion - - #region StartNewAnimationAsync Tests - [Fact] public async Task StartNewAnimationAsync_WhenCanBeAnimated_SetsAnimationAndClearsFirst() { @@ -1374,10 +1795,6 @@ value=""2022-02-20"">20"); Assert.Equal(FluentCalendar.AnimationRunning.None, finalAnimationState); // Should remain None since animation is disabled } - #endregion - - #region PickerMonthSelectAsync Tests - [Fact] public async Task PickerMonthSelectAsync_WithValidMonth_UpdatesPickerMonthAndView() { @@ -1446,10 +1863,6 @@ value=""2022-02-20"">20"); Assert.Equal(new DateTime(2022, 9, 1), changedMonth); } - #endregion - - #region OnSelectDayMouseOverAsync Tests - [Fact] public async Task OnSelectDayMouseOverAsync_WithDisabledDay_ReturnsCompletedTask() { @@ -1685,10 +2098,6 @@ value=""2022-02-20"">20"); Assert.True(true); // Test passes if no exception is thrown } - #endregion - - #region TryParseValueFromString Tests - [Theory] [InlineData("2022-06-15", true, "2022-06-15")] [InlineData("2022-12-31", true, "2022-12-31")] @@ -1886,10 +2295,6 @@ value=""2022-02-20"">20"); } } - #endregion - - #region FluentCalendarBase Constructor Validation Tests - /// /// Test classes that inherit from FluentCalendarBase to test constructor validation /// @@ -1915,6 +2320,4 @@ value=""2022-02-20"">20"); Assert.Contains("The type parameter System.String is not supported", exception.Message); Assert.Contains("Supported types are DateTime, DateTime?, DateOnly, and DateOnly?", exception.Message); } - - #endregion } diff --git a/tests/Core/Components/DateTimes/Utilities/RangeOfDatesTests.cs b/tests/Core/Components/DateTimes/Utilities/RangeOfDatesTests.cs index 55260d7c9e..e60add4eb9 100644 --- a/tests/Core/Components/DateTimes/Utilities/RangeOfDatesTests.cs +++ b/tests/Core/Components/DateTimes/Utilities/RangeOfDatesTests.cs @@ -2,13 +2,8 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Globalization; using Xunit; -using static System.Runtime.InteropServices.JavaScript.JSType; namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.DateTimes.Utilities; @@ -444,4 +439,61 @@ public void Includes_WithReversedRange_ShouldStillWork() // Assert Assert.True(result); } + + [Theory] + [InlineData(2024, 1, 1, true)] + [InlineData(2024, 1, 15, false)] + [InlineData(2024, 6, 1, false)] + [InlineData(2024, 12, 15, false)] + [InlineData(2024, 12, 16, true)] + public void IsOutsideRange_ReturnsExpectedResult(int year, int month, int day, bool expected) + { + // Arrange + var range = new RangeOfDates(new DateTime(2024, 1, 15), new DateTime(2024, 12, 15)); + var value = new DateTime(year, month, day); + + // Act + var result = range.IsOutsideRange(value); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(2024, 1, 1, 2024, 1, 14, true)] + [InlineData(2024, 12, 16, 2024, 12, 31, true)] + [InlineData(2024, 1, 1, 2024, 1, 15, false)] + [InlineData(2024, 12, 15, 2024, 12, 31, false)] + [InlineData(2024, 6, 1, 2024, 6, 30, false)] + public void IsPeriodOutsideRange_ReturnsExpectedResult(int startYear, int startMonth, int startDay, int endYear, int endMonth, int endDay, bool expected) + { + // Arrange + var range = new RangeOfDates(new DateTime(2024, 1, 15), new DateTime(2024, 12, 15)); + var periodStart = new DateTime(startYear, startMonth, startDay); + var periodEnd = new DateTime(endYear, endMonth, endDay); + + // Act + var result = range.IsPeriodOutsideRange(periodStart, periodEnd); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(CalendarViews.Days, 2024, 1, 15, false)] + [InlineData(CalendarViews.Days, 2024, 1, 1, true)] + [InlineData(CalendarViews.Months, 2024, 2, 1, false)] + [InlineData(CalendarViews.Years, 2025, 1, 1, true)] + public void IsSelectionOutsideRange_ReturnsExpectedResult(CalendarViews view, int year, int month, int day, bool expected) + { + // Arrange + var range = new RangeOfDates(new DateTime(2024, 1, 15), new DateTime(2024, 12, 15)); + var value = new DateTime(year, month, day); + + // Act + var result = range.IsSelectionOutsideRange(value, view, CultureInfo.InvariantCulture); + + // Assert + Assert.Equal(expected, result); + } }