From 722c9593f966754888d67f4d44cbf1eb5281da78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 2 May 2026 17:29:56 +0300 Subject: [PATCH] New Component: MudDateTimePicker --- .../wwwroot/CodeBeam.MudBlazor.Extensions.xml | 7 +- .../DateTimePicker/DateTimePickerPage.razor | 11 + .../Examples/DateTimePickerExample1.razor | 55 ++ .../Examples/SelectExtendedExample1.razor | 4 + .../Services/MudExtensionsDocsService.cs | 1 + .../DateTimePicker/MudBaseDatePickerX.cs | 261 ++++++ .../DateTimePicker/MudDateTimePicker.razor | 343 ++++++++ .../DateTimePicker/MudDateTimePicker.razor.cs | 764 ++++++++++++++++++ .../Enums/PickerMode.cs | 8 + 9 files changed, 1453 insertions(+), 1 deletion(-) create mode 100644 docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/DateTimePickerPage.razor create mode 100644 docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/Examples/DateTimePickerExample1.razor create mode 100644 src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudBaseDatePickerX.cs create mode 100644 src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudDateTimePicker.razor create mode 100644 src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudDateTimePicker.razor.cs create mode 100644 src/CodeBeam.MudBlazor.Extensions/Enums/PickerMode.cs diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs.Wasm/wwwroot/CodeBeam.MudBlazor.Extensions.xml b/docs/CodeBeam.MudBlazor.Extensions.Docs.Wasm/wwwroot/CodeBeam.MudBlazor.Extensions.xml index d00d32ec..bef67a18 100644 --- a/docs/CodeBeam.MudBlazor.Extensions.Docs.Wasm/wwwroot/CodeBeam.MudBlazor.Extensions.xml +++ b/docs/CodeBeam.MudBlazor.Extensions.Docs.Wasm/wwwroot/CodeBeam.MudBlazor.Extensions.xml @@ -5985,8 +5985,13 @@ - + + + + + + The timezone of the watch. If null, DateTime.Now will be used. diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/DateTimePickerPage.razor b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/DateTimePickerPage.razor new file mode 100644 index 00000000..75e91d84 --- /dev/null +++ b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/DateTimePickerPage.razor @@ -0,0 +1,11 @@ +@page "/muddatetimepicker" +@namespace MudExtensions.Docs.Pages + + + + + + + + + diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/Examples/DateTimePickerExample1.razor b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/Examples/DateTimePickerExample1.razor new file mode 100644 index 00000000..982f1b09 --- /dev/null +++ b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/DateTimePicker/Examples/DateTimePickerExample1.razor @@ -0,0 +1,55 @@ +@namespace MudExtensions.Docs.Examples +@using MudBlazor.Extensions + + + + + + + + + + @foreach (DateView item in Enum.GetValues()) + { + @item.ToDescriptionString() + } + + + @foreach (Variant item in Enum.GetValues()) + { + @item.ToDescriptionString() + } + + + + + + + + @foreach (Color item in Enum.GetValues()) + { + @item.ToDescriptionString() + } + + + Set Today + + + + +@code { + private DateTime? _date = DateTime.Now; + private DateView _dateView = DateView.Date; + private bool _editable = false; + private bool _showToolbar = true; + private bool _showHeader = true; + private bool _submitOnClose = true; + private Color _color = Color.Primary; + private string? _dateFormat; + private bool _isAdornmentEnd = true; + private bool _clearable = false; + private Variant _variant = Variant.Outlined; +} diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/SelectExtended/Examples/SelectExtendedExample1.razor b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/SelectExtended/Examples/SelectExtendedExample1.razor index c2707c04..9715f6b7 100644 --- a/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/SelectExtended/Examples/SelectExtendedExample1.razor +++ b/docs/CodeBeam.MudBlazor.Extensions.Docs/Pages/Components/SelectExtended/Examples/SelectExtendedExample1.razor @@ -12,6 +12,8 @@ + + @@ -28,4 +30,6 @@ private bool _modal = true; private string[] _collection = new string[] { "Foo", "Bar", "Fizz", "Buzz" }; private string? _nullItemText = "None"; + + private DateTimeOffset _selectedDate = DateTimeOffset.Now; } \ No newline at end of file diff --git a/docs/CodeBeam.MudBlazor.Extensions.Docs/Services/MudExtensionsDocsService.cs b/docs/CodeBeam.MudBlazor.Extensions.Docs/Services/MudExtensionsDocsService.cs index 223769ee..55087eb4 100644 --- a/docs/CodeBeam.MudBlazor.Extensions.Docs/Services/MudExtensionsDocsService.cs +++ b/docs/CodeBeam.MudBlazor.Extensions.Docs/Services/MudExtensionsDocsService.cs @@ -15,6 +15,7 @@ public class MudExtensionsDocsService new MudExtensionComponentInfo() {Title = "MudComboBox", Component = typeof(MudComboBox<>), RelatedComponents = new List() {typeof(MudComboBoxItem)}, Usage = ComponentUsage.Input, IsUnique = true, Description = "Unites MudSelect and MudAutocomplete features."}, new MudExtensionComponentInfo() {Title = "MudCssManager", Component = typeof(MudCssManager), Usage = ComponentUsage.Utility, IsUnique = true, IsUtility = true, Description = "Directly and dynamically get or set component's css property."}, new MudExtensionComponentInfo() {Title = "MudCsvMapper", Component = typeof(MudCsvMapper), Usage = ComponentUsage.Display, IsUnique = true, Description = "A .csv file uploader that matches the .csv file headers to supplied / expected headers."}, + new MudExtensionComponentInfo() {Title = "MudDateTimePicker", Component = typeof(MudDateTimePicker<>), Usage = ComponentUsage.Input, IsUnique = true, Description = "Unified generic date and time picker component."}, new MudExtensionComponentInfo() {Title = "MudDateWheelPicker", Component = typeof(MudDateWheelPicker), Usage = ComponentUsage.Input, IsUnique = true, Description = "A date time picker with MudWheels."}, new MudExtensionComponentInfo() {Title = "MudGallery", Component = typeof(MudGallery), Usage = ComponentUsage.Display, IsUnique = true, Description = "Mobile friendly image gallery component."}, new MudExtensionComponentInfo() {Title = "MudInputStyler", Component = typeof(MudInputStyler), Usage = ComponentUsage.Utility, IsUnique = true, Description = "Applies colors or other CSS styles easily for mud inputs like MudTextField and MudSelect."}, diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudBaseDatePickerX.cs b/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudBaseDatePickerX.cs new file mode 100644 index 00000000..b3ffb906 --- /dev/null +++ b/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudBaseDatePickerX.cs @@ -0,0 +1,261 @@ +using Microsoft.AspNetCore.Components; +using MudBlazor; +using MudBlazor.Extensions; +using MudBlazor.State; +using MudBlazor.Utilities; +using System.Globalization; +using System.Runtime.Serialization; + +namespace MudExtensions +{ + public abstract partial class MudBaseDatePickerX : MudPicker + { + internal readonly string _mudPickerCalendarContentElementId; + private readonly ParameterState _formatState; + + protected MudBaseDatePickerX() + { + _mudPickerCalendarContentElementId = Identifier.Create(); + Culture = CultureInfo.CurrentCulture; + + using var registerScope = CreateRegisterScope(); + _formatState = registerScope.RegisterParameter(nameof(Format)) + .WithParameter(() => Format) + .WithChangeHandler(FormatChangedAsync); + } + + // 🔥 GENERIC CONVERSION LAYER + protected DateTime? ToDateTime(T? value) + { + if (value == null) + return null; + + if (value is DateTime dt) + return dt; + + if (value is DateTimeOffset dto) + return dto.LocalDateTime; + + throw new NotSupportedException($"Type {typeof(T)} not supported"); + } + + protected T? FromDateTime(DateTime? date) + { + if (date == null) + return default; + + var t = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + + if (t == typeof(DateTime)) + return (T)(object)date.Value; + + if (t == typeof(DateTimeOffset)) + { + var offset = TimeZoneInfo.Local.GetUtcOffset(date.Value); + return (T)(object)new DateTimeOffset(date.Value, offset); + } + + throw new NotSupportedException($"Type {typeof(T)} not supported"); + } + + protected void ValidateType() + { + var t = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); + + if (t != typeof(DateTime) && t != typeof(DateTimeOffset)) + throw new NotSupportedException($"Type {typeof(T)} not supported."); + } + + protected override void OnInitialized() + { + ValidateType(); + base.OnInitialized(); + } + + [Inject] protected IScrollManager ScrollManager { get; set; } = null!; + [Inject] private IJsApiService JsApiService { get; set; } = null!; + [Inject] protected TimeProvider TimeProvider { get; set; } = null!; + + [Parameter] public DateTime? MaxDate { get; set; } + [Parameter] public DateTime? MinDate { get; set; } + [Parameter] public OpenTo OpenTo { get; set; } = OpenTo.Date; + + [Parameter, ParameterState] + public string? Format { get; set; } + + protected virtual Task FormatChangedAsync(string? newFormat) => Task.CompletedTask; + + private Task FormatChangedAsync(ParameterChangedEventArgs args) + => FormatChangedAsync(args.Value); + + [Parameter] public DayOfWeek? FirstDayOfWeek { get; set; } + + internal DateTime? _picker_month; + + [Parameter] + public DateTime? PickerMonth + { + get => _picker_month; + set + { + if (value == _picker_month) + return; + _picker_month = value; + InvokeAsync(StateHasChanged); + PickerMonthChanged.InvokeAsync(value); + } + } + + protected internal DateTime? HighlightedDate { get; set; } + + [Parameter] public EventCallback PickerMonthChanged { get; set; } + + [Parameter] public int ClosingDelay { get; set; } = 100; + [Parameter] public int DisplayMonths { get; set; } = 1; + [Parameter] public int? MaxMonthColumns { get; set; } + [Parameter] public DateTime? StartMonth { get; set; } + [Parameter] public bool ShowWeekNumbers { get; set; } + [Parameter] public string TitleDateFormat { get; set; } = "ddd, dd MMM"; + [Parameter] public bool AutoClose { get; set; } + + [Parameter] + public Func IsDateDisabledFunc { get; set; } = _ => false; + + [Parameter] public Func? AdditionalDateClassesFunc { get; set; } + [Parameter] public string PreviousIcon { get; set; } = Icons.Material.Filled.ChevronLeft; + [Parameter] public string NextIcon { get; set; } = Icons.Material.Filled.ChevronRight; + + [Parameter] public int? FixYear { get; set; } + [Parameter] public int? FixMonth { get; set; } + [Parameter] public int? FixDay { get; set; } + + protected OpenTo CurrentView; + + protected override async Task OnPickerOpenedAsync() + { + await base.OnPickerOpenedAsync(); + + var dateTime = ToDateTime(_value); + + if (dateTime.HasValue) + { + var culture = GetCulture(); + var calendar = culture.Calendar; + PickerMonth = new DateTime( + calendar.GetYear(dateTime.Value), + calendar.GetMonth(dateTime.Value), + 1, + calendar); + } + + CurrentView = OpenTo; + } + + protected DateTime GetMonthStart(int month) + { + var culture = GetCulture(); + var calendar = culture.Calendar; + var baseDate = _picker_month ?? DateTime.Today; + + return calendar.AddMonths(new DateTime(baseDate.Year, baseDate.Month, 1), month); + } + + protected IEnumerable GetWeek(int month, int index) + { + if (index is < 0 or > 5) + throw new ArgumentException("Index must be between 0 and 5", nameof(index)); + + var culture = GetCulture(); + var monthFirst = GetMonthStart(month); + + var weekFirst = monthFirst + .AddDays(index * 7) + .StartOfWeek(GetFirstDayOfWeek(), culture); + + for (var i = 0; i < 7; i++) + yield return weekFirst.AddDays(i); + } + + protected virtual bool IsDayDisabled(DateTime date) + { + return date < MinDate || + date > MaxDate || + IsDateDisabledFunc(date); + } + + protected abstract string GetDayClasses(int month, DateTime day); + protected abstract Task OnDayClickedAsync(DateTime dateTime); + + protected string FormatTitleDate(DateTime? date) + { + return date?.ToString(TitleDateFormat, GetCulture()) ?? ""; + } + + protected string GetFormattedYearString() + { + var selectedYear = HighlightedDate ?? GetMonthStart(0); + return selectedYear.Year.ToString(); + } + + protected IEnumerable GetAbbreviatedDayNames() + { + var culture = GetCulture(); + var names = culture.DateTimeFormat.AbbreviatedDayNames; + + var firstDay = (int)GetFirstDayOfWeek(); + + return Enumerable.Range(0, 7).Select(i => names[(i + firstDay) % 7]); + } + + protected override IConverter GetDefaultConverter() + { + return new DefaultConverter + { + Culture = GetCulture, + Format = GetFormat + }; + } + + protected override string? ConvertSet(T? value) + { + var dt = ToDateTime(value); + + if (dt == null) + return null; + + return dt.Value.ToString(GetFormat(), GetCulture()); + } + + protected override string GetFormat() + { + if (!string.IsNullOrWhiteSpace(_formatState.Value)) + return _formatState.Value; + + return $"{CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern} HH:mm"; + } + + protected abstract DateTime GetCalendarStartOfMonth(); + protected abstract int GetCalendarYear(DateTime yearDate); + + protected DayOfWeek GetFirstDayOfWeek() + { + return FirstDayOfWeek ?? GetCulture().DateTimeFormat.FirstDayOfWeek; + } + + protected DateTime GetMonthEnd(int month) + { + var culture = GetCulture(); + var calendar = culture.Calendar; + var monthStartDate = PickerMonth ?? DateTime.Today.StartOfMonth(culture); + + return calendar + .AddMonths(monthStartDate, month) + .EndOfMonth(culture); + } + + //private ValueTask HandleMouseoverOnPickerCalendarDayButton(int tempId) + //{ + // return JsApiService.UpdateStyleProperty(_mudPickerCalendarContentElementId, "--selected-day", tempId); + //} + } +} \ No newline at end of file diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudDateTimePicker.razor b/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudDateTimePicker.razor new file mode 100644 index 00000000..eecded7f --- /dev/null +++ b/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudDateTimePicker.razor @@ -0,0 +1,343 @@ +@typeparam T +@namespace MudExtensions +@inherits MudBaseDatePickerX + +@Render + +@code { + + protected override RenderFragment PickerContent => + @ + + @GetFormattedYearString() + @GetTitleDateString() + + + + + + @if (_mode == PickerMode.Date) + { +
+ + @{ + int dayId = 0; + var culture = GetCulture(); + var calendar = culture.Calendar; + + if (_picker_month.HasValue && calendar.GetYear(_picker_month.Value) == 1 && calendar.GetMonth(_picker_month.Value) == 1) + { + dayId = -1; + } + } + + @for (int displayMonth = 0; displayMonth < DisplayMonths; ++displayMonth) + { + int tempMonth = displayMonth; + +
+ + @* 🔥 YEAR VIEW *@ + @if (tempMonth == 0 && CurrentView == OpenTo.Year) + { +
+ @for (int i = GetMinYear(); i <= GetMaxYear(); i++) + { + var year = i; + +
+ + @year +
+ } +
+ } + + @* 🔥 MONTH VIEW *@ + else if (tempMonth == 0 && CurrentView == OpenTo.Month) + { + var calendarYear = GetCalendarYear(PickerMonth ?? DateTime.Today); + +
+
+ + @if (!FixYear.HasValue) + { + + + + + + } + else + { + + @calendarYear + + } +
+
+ +
+ @foreach (var month in GetAllMonths()) + { + + } +
+ } + + @* 🔥 DATE VIEW *@ + else if (CurrentView == OpenTo.Date || tempMonth > 0) + { +
+
+ + @if (!FixMonth.HasValue) + { + + + + + + } + else + { + + @GetMonthName(tempMonth) + + } +
+ +
+ + @if (ShowWeekNumbers) + { +
+ +
+ } + + @foreach (var dayName in GetAbbreviatedDayNames()) + { + + @dayName + + } +
+
+ +
+
+ + @for (int week = 0; week < 6; week++) + { + int tempWeek = week; + + var firstMonthFirstYear = + _picker_month.HasValue && + calendar.GetYear(_picker_month.Value) == 1 && + calendar.GetMonth(_picker_month.Value) == 1; + + @if (ShowWeekNumbers) + { +
+ + @GetWeekNumber(tempMonth, tempWeek) + +
+ } + + var wasMaxValue = false; + + @foreach (var day in GetWeek(tempMonth, tempWeek)) + { + var tempId = ++dayId; + + @if ((tempId != 0 || !firstMonthFirstYear) && !wasMaxValue) + { + var selectedDay = !firstMonthFirstYear ? day : day.AddDays(-1); + + // onpointerover = "@(async () => await HandleMouseoverOnPickerCalendarDayButton(tempId))" + + } + else + { + + } + + wasMaxValue = day == calendar.MaxSupportedDateTime; + } + } +
+
+ } +
+ } +
+ } + else + { +
+
+ +
+
+ } + +
+ +
; +} diff --git a/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudDateTimePicker.razor.cs b/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudDateTimePicker.razor.cs new file mode 100644 index 00000000..bde97c40 --- /dev/null +++ b/src/CodeBeam.MudBlazor.Extensions/Components/DateTimePicker/MudDateTimePicker.razor.cs @@ -0,0 +1,764 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using MudBlazor; +using MudBlazor.Extensions; +using MudBlazor.Utilities; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace MudExtensions; + +public partial class MudDateTimePicker : MudBaseDatePickerX +{ + [Inject] private IJSRuntime JsRuntime { get; set; } + + [DynamicDependency(nameof(OnStickClick))] + [DynamicDependency(nameof(SelectTimeFromStick))] + public MudDateTimePicker() + { + _dotNetReferenceLazy = new Lazy>>(CreateDotNetObjectReference); + } + + private DateTime? _selectedDate; + private readonly string _componentId = Identifier.Create(); + private string? _clockElementReferenceId; + private readonly Lazy>> _dotNetReferenceLazy; + + private DotNetObjectReference> CreateDotNetObjectReference() => DotNetObjectReference.Create(this); + + private DateTime? _workingValue; + + private readonly SetTime _timeSet = new(); + + private record SetTime + { + public int Hour { get; set; } + public int Minute { get; set; } + } + + public bool PointerMoving { get; set; } + + protected ElementReference ClockElementReference { get; private set; } + private bool _amPm = false; + + private enum TimeView + { + Hours, + Minutes + } + + private TimeView _timeView = TimeView.Hours; + + protected override void OnInitialized() + { + base.OnInitialized(); + _workingValue = ToDateTime(Value); + } + + protected override void OnParametersSet() + { + base.OnParametersSet(); + _workingValue = ToDateTime(Value); + SyncTimeFromValue(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + // Initialize the pointer events for the clock every time it's created (ex: popover opening and closing). + if (ClockElementReference.Id != _clockElementReferenceId) + { + _clockElementReferenceId = ClockElementReference.Id; + + await JsRuntime.InvokeVoidAsyncWithErrorHandling("mudTimePicker.initPointerEvents", ClockElementReference, _dotNetReferenceLazy.Value); + } + } + + private void SyncTimeFromValue() + { + if (_workingValue == null) + { + _timeSet.Hour = 0; + _timeSet.Minute = 0; + return; + } + + _timeSet.Hour = _workingValue.Value.Hour; + _timeSet.Minute = _workingValue.Value.Minute; + } + + protected PickerMode _mode = PickerMode.Date; + + [Parameter] + public T? Value + { + get => _value; + set => SetDateAsync(ToDateTime(value), true); + } + + [Parameter] + public EventCallback ValueChanged { get; set; } + + [Parameter] + public bool AmPm + { + get => _amPm; + set + { + if (_amPm == value) + return; + + _amPm = value; + + Touched = true; + _ = SetTextAsync(ConvertSet(_value), false); + } + } + + [Parameter] + public int MinuteSelectionStep { get; set; } = 1; + + [Parameter] + [Category(CategoryTypes.FormComponent.PickerBehavior)] + public TimeEditMode TimeEditMode { get; set; } = TimeEditMode.Normal; + + private int RoundToStepInterval(int value) + { + if (MinuteSelectionStep > 1) + { + var interval = MinuteSelectionStep % 60; + value = (value + (interval / 2)) / interval * interval; + + if (value == 60) + value = 0; + } + + return value; + } + + protected override async Task WriteTextAsync(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + _workingValue = null; + _value = default; + + await ValueChanged.InvokeAsync(_value); + return; + } + + var culture = GetCulture(); + + if (DateTime.TryParseExact(text, GetFormat(), culture, DateTimeStyles.None, out var parsed)) + { + _workingValue = parsed; + _value = FromDateTime(parsed); + + SyncTimeFromValue(); + + PickerMonth = new DateTime(parsed.Year, parsed.Month, 1); + + await ValueChanged.InvokeAsync(_value); + await BeginValidateAsync(); + FieldChanged(_value); + } + else + { + await SetTextAsync(ConvertSet(_value), false); + } + } + + private bool IsAm => _timeSet.Hour >= 0 && _timeSet.Hour < 12; + private bool IsPm => _timeSet.Hour >= 12 && _timeSet.Hour < 24; + + private async Task OnAmClickedAsync() + { + _timeSet.Hour %= 12; + await UpdateTimeAsync(); + } + + private async Task OnPmClickedAsync() + { + if (_timeSet.Hour < 12) + _timeSet.Hour += 12; + + _timeSet.Hour %= 24; + + await UpdateTimeAsync(); + } + + private DateTimeOffset _lastSetTime = DateTimeOffset.MinValue; + private const int DebounceTimeoutMs = 100; + + protected async Task SetDateAsync(DateTime? date, bool updateValue) + { + var current = ToDateTime(_value); + + if (current != null && date != null && date.Value.Kind == DateTimeKind.Unspecified) + { + date = DateTime.SpecifyKind(date.Value, current.Value.Kind); + } + + var now = TimeProvider.GetUtcNow(); + + if (current == date && (now - _lastSetTime).TotalMilliseconds < DebounceTimeoutMs) + return; + + _lastSetTime = now; + + if (current != date || (date is null && Text != null)) + { + Touched = true; + + HighlightedDate = date; + + if (date is not null && IsDateDisabledFunc(date.Value.Date)) + { + await SetTextAsync(null, false); + return; + } + + if (date is not null) + { + var culture = GetCulture(); + PickerMonth = new DateTime( + culture.Calendar.GetYear(date.Value), + culture.Calendar.GetMonth(date.Value), + 1, + culture.Calendar); + } + + var converted = FromDateTime(date); + _value = converted; + + if (updateValue) + { + ResetConverterErrors(); + await SetTextAsync(ConvertSet(_value), false); + } + + await ValueChanged.InvokeAsync(_value); + await BeginValidateAsync(); + FieldChanged(_value); + } + } + + private async Task UpdateTimeAsync() + { + if (_workingValue == null) + _workingValue = TimeProvider.GetLocalNow().Date; + + _workingValue = new DateTime( + _workingValue.Value.Year, + _workingValue.Value.Month, + _workingValue.Value.Day, + _timeSet.Hour, + _timeSet.Minute, + 0 + ); + + //if ((PickerVariant == PickerVariant.Static && PickerActions == null) || + // (PickerActions != null && AutoClose)) + //{ + // await SubmitAsync(); + //} + + _value = FromDateTime(_workingValue); + + await SetTextAsync(ConvertSet(_value), false); + } + + private void SetDatePart(DateTime date) + { + var current = _workingValue ?? TimeProvider.GetLocalNow().Date; + + _workingValue = new DateTime( + date.Year, + date.Month, + date.Day, + current.Hour, + current.Minute, + current.Second + ); + } + + private void SetTimePart(int hour, int minute) + { + var current = _workingValue ?? TimeProvider.GetLocalNow().Date; + + _workingValue = new DateTime( + current.Year, + current.Month, + current.Day, + hour, + minute, + 0 + ); + } + + protected override string GetDayClasses(int month, DateTime day) + { + var b = new CssBuilder("mud-day"); + + b.AddClass(AdditionalDateClassesFunc?.Invoke(day) ?? string.Empty); + + if (day < GetMonthStart(month) || day > GetMonthEnd(month)) + return b.AddClass("mud-hidden").Build(); + + var current = ToDateTime(Value); + + if ((current?.Date == day.Date && _selectedDate == null) || _selectedDate?.Date == day.Date) + return b.AddClass("mud-selected") + .AddClass($"mud-theme-{Color.ToStringFast(true)}") + .Build(); + + if (day.Date == TimeProvider.GetLocalNow().Date) + return b.AddClass("mud-current mud-button-outlined") + .AddClass($"mud-button-outlined-{Color.ToStringFast(true)} mud-{Color.ToStringFast(true)}-text") + .Build(); + + return b.Build(); + } + + protected override async Task OnDayClickedAsync(DateTime dateTime) + { + await FocusAsync(); + + _selectedDate = dateTime; + + if (PickerActions == null || AutoClose || PickerVariant == PickerVariant.Static) + { + await Task.Run(() => InvokeAsync(SubmitAsync)); + + if (PickerVariant != PickerVariant.Static) + { + await Task.Delay(TimeSpan.FromMilliseconds(ClosingDelay), TimeProvider); + await CloseAsync(false); + } + } + } + + protected override async Task SubmitAsync() + { + if (GetReadOnlyState()) + return; + + if (_selectedDate != null) + { + SetDatePart(_selectedDate.Value); + _selectedDate = null; + } + + if (_workingValue == null) + return; + + var converted = FromDateTime(_workingValue); + + _value = converted; + + await ValueChanged.InvokeAsync(_value); + await SetTextAsync(ConvertSet(_value), false); + await BeginValidateAsync(); + FieldChanged(_value); + } + + public override async Task ClearAsync(bool close = true) + { + _selectedDate = null; + await SetDateAsync(null, true); + + if (AutoClose) + await CloseAsync(false); + } + + protected virtual string GetTitleDateString() + { + return FormatTitleDate(_selectedDate ?? ToDateTime(Value)); + } + + protected override DateTime GetCalendarStartOfMonth() + { + var date = ToDateTime(Value) ?? HighlightedDate ?? TimeProvider.GetLocalNow().Date; + return date.StartOfMonth(GetCulture()); + } + + protected override int GetCalendarYear(DateTime yearDate) + { + var date = ToDateTime(Value) ?? TimeProvider.GetLocalNow().Date; + var diff = GetCulture().Calendar.GetYear(date) - GetCulture().Calendar.GetYear(yearDate); + + return GetCulture().Calendar.GetYear(date) - diff; + } + + protected string GetMonthName(int month) + { + var date = GetMonthStart(month); + return date.ToString("MMMM yyyy", GetCulture()); + } + + protected Task OnPreviousMonthClick() + { + PickerMonth = GetMonthStart(0).AddMonths(-1); + return Task.CompletedTask; + } + + protected Task OnNextMonthClick() + { + PickerMonth = GetMonthStart(0).AddMonths(1); + return Task.CompletedTask; + } + + private void GoToSelectedYear() + { + PickerMonth = HighlightedDate; + OnYearClick(); + } + + private void OnYearClick() + { + if (!FixYear.HasValue) + { + CurrentView = OpenTo.Year; + StateHasChanged(); + //_scrollToYearAfterRender = true; + } + } + + protected int GetMinYear() + { + return MinDate?.Year ?? 1900; + } + + protected int GetMaxYear() + { + return MaxDate?.Year ?? 2100; + } + + protected Task OnYearClickedAsync(int year) + { + var current = ToDateTime(Value) ?? TimeProvider.GetLocalNow().Date; + PickerMonth = new DateTime(year, current.Month, 1); + CurrentView = OpenTo.Month; + return Task.CompletedTask; + } + + protected Typo GetYearTypo(int year) + { + var current = ToDateTime(Value); + return current?.Year == year ? Typo.h5 : Typo.body1; + } + + protected string GetYearClasses(int year) + { + var current = ToDateTime(Value); + + return new CssBuilder("mud-picker-year-text") + .AddClass("mud-selected", current?.Year == year) + .Build(); + } + + protected Task OnPreviousYearClick() + { + PickerMonth = (PickerMonth ?? DateTime.Today).AddYears(-1); + return Task.CompletedTask; + } + + protected Task OnNextYearClick() + { + PickerMonth = (PickerMonth ?? DateTime.Today).AddYears(1); + return Task.CompletedTask; + } + + protected IEnumerable GetAllMonths() + { + return Enumerable.Range(1, 12); + } + + protected Task OnMonthSelectedAsync(int month) + { + var current = ToDateTime(Value) ?? TimeProvider.GetLocalNow().Date; + PickerMonth = new DateTime(current.Year, month, 1); + CurrentView = OpenTo.Date; + return Task.CompletedTask; + } + + protected bool IsMonthDisabled(int month) + { + if (!MinDate.HasValue && !MaxDate.HasValue) + return false; + + var year = (PickerMonth ?? DateTime.Today).Year; + + var start = new DateTime(year, month, 1); + var end = start.AddMonths(1).AddDays(-1); + + return (MinDate.HasValue && end < MinDate.Value) + || (MaxDate.HasValue && start > MaxDate.Value); + } + + protected Typo GetMonthTypo(int month) + { + var current = ToDateTime(Value); + return current?.Month == month ? Typo.h6 : Typo.body2; + } + + protected string GetMonthClasses(int month) + { + var current = ToDateTime(Value); + + return new CssBuilder() + .AddClass("mud-selected", current?.Month == month) + .Build(); + } + + protected string GetAbbreviatedMonthName(int month) + { + return GetCulture().DateTimeFormat.AbbreviatedMonthNames[month - 1]; + } + + protected int GetWeekNumber(int month, int week) + { + var firstDay = GetWeek(month, week).First(); + + return GetCulture().Calendar.GetWeekOfYear( + firstDay, + CalendarWeekRule.FirstFourDayWeek, + GetFirstDayOfWeek()); + } + + protected string GetCalendarDayOfMonth(DateTime date) + { + return date.Day.ToString(GetCulture()); + } + + protected void OnFormattedDateClick() + { + CurrentView = OpenTo.Month; + } + + protected void OnMonthClicked(int month) + { + CurrentView = OpenTo.Month; + } + + private string GetCalendarHeaderClasses(int month) + { + return new CssBuilder("mud-picker-calendar-header") + .AddClass($"mud-picker-calendar-header-{month + 1}") + .AddClass($"mud-picker-calendar-header-last", month == DisplayMonths - 1) + .Build(); + } + + private string HourDialClassname => + new CssBuilder("mud-time-picker-dial") + .AddClass("mud-time-picker-hour") + .AddClass("mud-time-picker-dial-out", _timeView != TimeView.Hours) + .AddClass("mud-time-picker-dial-hidden", _timeView != TimeView.Hours) + .Build(); + + private string MinuteDialClassname => + new CssBuilder("mud-time-picker-dial") + .AddClass("mud-time-picker-minute") + .AddClass("mud-time-picker-dial-out", _timeView != TimeView.Minutes) + .AddClass("mud-time-picker-dial-hidden", _timeView != TimeView.Minutes) + .Build(); + + private string GetPointerRotation() + { + return $"rotateZ({GetDeg()}deg);"; + } + + private double GetDeg() + { + double deg = 0; + + if (_timeView == TimeView.Hours) + { + deg = _timeSet.Hour * 30 % 360; + } + + if (_timeView == TimeView.Minutes) + { + deg = _timeSet.Minute * 6 % 360; + } + + return deg; + } + + private string GetPointerHeight() + { + var height = 40; + + if (_timeView == TimeView.Minutes) + { + height = 40; + } + + if (_timeView == TimeView.Hours) + { + if (!AmPm && _timeSet.Hour > 0 && _timeSet.Hour < 13) + { + height = 26; + } + else + { + height = 40; + } + } + + return $"{height}%;"; + } + + private string GetNumberColor(int value) + { + if (_timeView == TimeView.Hours) + { + var h = _timeSet.Hour; + + if (AmPm) + { + h = _timeSet.Hour % 12; + if (_timeSet.Hour % 12 == 0) + { + h = 12; + } + } + + if (h == value) + { + return $"mud-clock-number mud-theme-{Color.ToStringFast(true)}"; + } + } + else if (_timeView == TimeView.Minutes && _timeSet.Minute == value) + { + return $"mud-clock-number mud-theme-{Color.ToStringFast(true)}"; + } + + return "mud-clock-number"; + } + + private async Task OnHourSelected(int hour) + { + _timeSet.Hour = hour % 24; + SetTimePart(_timeSet.Hour, _timeSet.Minute); + _timeView = TimeView.Minutes; + await InvokeAsync(StateHasChanged); + } + + private async Task OnMinuteSelected(int minute) + { + _timeSet.Minute = minute; + + SetTimePart(_timeSet.Hour, _timeSet.Minute); + + await InvokeAsync(StateHasChanged); + } + + private void ToggleMode() + { + _mode = _mode == PickerMode.Date ? PickerMode.Time : PickerMode.Date; + + if (_mode == PickerMode.Time) + SyncTimeFromValue(); + } + + private string GetClockPointerColor() + { + return PointerMoving + ? $"mud-picker-time-clock-pointer mud-{Color.ToStringFast(true)}" + : $"mud-picker-time-clock-pointer mud-picker-time-clock-pointer-animation mud-{Color.ToStringFast(true)}"; + } + + private string GetClockPinColor() + { + return $"mud-picker-time-clock-pin mud-{Color.ToStringFast(true)}"; + } + + private string GetClockPointerThumbColor() + { + var deg = GetDeg(); + return deg % 30 == 0 + ? $"mud-picker-time-clock-pointer-thumb mud-onclock-text mud-onclock-primary mud-{Color.ToStringFast(true)}" + : $"mud-picker-time-clock-pointer-thumb mud-onclock-minute mud-{Color.ToStringFast(true)}-text"; + } + + private static string GetTransform(double angle, double radius, double offsetX, double offsetY) + { + angle = angle / 180 * Math.PI; + var x = ((Math.Sin(angle) * radius) + offsetX).ToString("F3", CultureInfo.InvariantCulture); + var y = (((Math.Cos(angle) + 1) * radius) + offsetY).ToString("F3", CultureInfo.InvariantCulture); + return $"transform: translate({x}px, {y}px);"; + } + + [JSInvokable] + public async Task SelectTimeFromStick(int value, bool pointerMoving) + { + PointerMoving = pointerMoving; + + if (_timeView == TimeView.Minutes) + _timeSet.Minute = RoundToStepInterval(value); + else + _timeSet.Hour = value; + + await UpdateTimeAsync(); + + StateHasChanged(); + } + + [JSInvokable] + public async Task OnStickClick(int value) + { + // The pointer is up and not moving so animations can be enabled again. + PointerMoving = false; + + // Clicking a stick will submit the time. + if (_timeView == TimeView.Minutes) + { + await SubmitAndCloseAsync(); + } + else if (_timeView == TimeView.Hours) + { + if (TimeEditMode == TimeEditMode.Normal) + { + _timeView = TimeView.Minutes; + } + else if (TimeEditMode == TimeEditMode.OnlyHours) + { + await SubmitAndCloseAsync(); + } + } + + // Manually update because the event won't do it from JavaScript. + StateHasChanged(); + } + + protected async Task SubmitAndCloseAsync() + { + if (PickerActions == null || AutoClose) + { + await SubmitAsync(); + + if (PickerVariant != PickerVariant.Static) + { + await Task.Delay(TimeSpan.FromMilliseconds(ClosingDelay), TimeProvider); + await CloseAsync(false); + } + } + } + + protected override async ValueTask DisposeAsyncCore() + { + await base.DisposeAsyncCore(); + + if (IsJSRuntimeAvailable) + { + await JsRuntime.InvokeVoidAsyncWithErrorHandling("mudTimePicker.destroyPointerEvents", ClockElementReference); + } + + if (_dotNetReferenceLazy.IsValueCreated) + { + _dotNetReferenceLazy.Value.Dispose(); + } + } +} diff --git a/src/CodeBeam.MudBlazor.Extensions/Enums/PickerMode.cs b/src/CodeBeam.MudBlazor.Extensions/Enums/PickerMode.cs new file mode 100644 index 00000000..875d2307 --- /dev/null +++ b/src/CodeBeam.MudBlazor.Extensions/Enums/PickerMode.cs @@ -0,0 +1,8 @@ +namespace MudExtensions +{ + public enum PickerMode + { + Date, + Time + } +}