+
TitleClickHandlerAsync(titles))">
@@ -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(@