2026-04-22 11:07:30 +02:00
|
|
|
@using System.Globalization
|
2026-04-22 23:58:55 +02:00
|
|
|
@implements IAsyncDisposable
|
|
|
|
|
@inject IJSRuntime JS
|
2026-04-22 11:07:30 +02:00
|
|
|
|
2026-04-22 23:58:55 +02:00
|
|
|
<div class="localized-date-input" @ref="rootElement" @onkeydown="HandleKeyDown">
|
2026-04-22 11:07:30 +02:00
|
|
|
<div class="input-group">
|
|
|
|
|
<input id="@InputId"
|
|
|
|
|
data-testid="@GetInputTestId()"
|
|
|
|
|
type="text"
|
|
|
|
|
class="form-control"
|
|
|
|
|
value="@displayValue"
|
|
|
|
|
@onchange="OnTextChangedAsync"
|
|
|
|
|
@onfocus="OpenPopup"
|
|
|
|
|
placeholder="dd/MM/yyyy"
|
|
|
|
|
inputmode="numeric"
|
|
|
|
|
autocomplete="off"
|
|
|
|
|
disabled="@Disabled" />
|
|
|
|
|
<button type="button"
|
|
|
|
|
class="btn btn-outline-secondary localized-date-input-toggle"
|
|
|
|
|
aria-label="Open calendar"
|
|
|
|
|
@onclick="TogglePopup"
|
|
|
|
|
disabled="@Disabled">
|
|
|
|
|
Cal
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
@if (isOpen && !Disabled)
|
|
|
|
|
{
|
|
|
|
|
<div class="localized-date-input-popover" data-testid="@GetPopoverTestId()" @onclick:stopPropagation="true">
|
|
|
|
|
<div class="localized-date-input-header">
|
|
|
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="ShowPreviousMonth" aria-label="Previous month">‹</button>
|
|
|
|
|
<div class="localized-date-input-month">@visibleMonth.ToDateTime(TimeOnly.MinValue).ToString("MMMM yyyy", ItalianCulture)</div>
|
|
|
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="ShowNextMonth" aria-label="Next month">›</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="localized-date-input-weekdays">
|
|
|
|
|
@foreach (var weekday in mondayFirstWeekdays)
|
|
|
|
|
{
|
|
|
|
|
<div class="localized-date-input-weekday" data-testid="date-picker-weekday">@weekday</div>
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="localized-date-input-grid">
|
|
|
|
|
@foreach (var day in calendarDays)
|
|
|
|
|
{
|
|
|
|
|
<button type="button"
|
|
|
|
|
class="localized-date-input-day @(day.IsCurrentMonth ? string.Empty : "localized-date-input-day-outside") @(day.Date == Value ? "localized-date-input-day-selected" : string.Empty)"
|
|
|
|
|
data-testid="@GetDayTestId(day.Date)"
|
|
|
|
|
@onclick="() => SelectDateAsync(day.Date)">
|
|
|
|
|
@day.Date.Day
|
|
|
|
|
</button>
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
@if (AllowEmpty)
|
|
|
|
|
{
|
|
|
|
|
<div class="localized-date-input-actions">
|
|
|
|
|
<button type="button" class="btn btn-sm btn-outline-secondary ms-auto" @onclick="ClearAsync">Clear</button>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
@code {
|
|
|
|
|
private static readonly CultureInfo ItalianCulture = CultureInfo.GetCultureInfo("it-IT");
|
|
|
|
|
private static readonly string[] SupportedFormats = ["dd/MM/yyyy", "d/M/yyyy", "dd/M/yyyy", "d/MM/yyyy"];
|
|
|
|
|
private static readonly string[] mondayFirstWeekdays = BuildMondayFirstWeekdays();
|
|
|
|
|
|
|
|
|
|
[Parameter] public DateOnly? Value { get; set; }
|
|
|
|
|
[Parameter] public EventCallback<DateOnly?> ValueChanged { get; set; }
|
|
|
|
|
[Parameter] public bool Disabled { get; set; }
|
|
|
|
|
[Parameter] public bool AllowEmpty { get; set; }
|
|
|
|
|
[Parameter] public string? InputId { get; set; }
|
|
|
|
|
[Parameter] public string? TestId { get; set; }
|
|
|
|
|
|
|
|
|
|
private DateOnly? lastValue;
|
|
|
|
|
private string displayValue = string.Empty;
|
|
|
|
|
private bool isOpen;
|
2026-04-22 23:58:55 +02:00
|
|
|
private bool outsideClickListenerActive;
|
2026-04-22 11:07:30 +02:00
|
|
|
private DateOnly visibleMonth;
|
|
|
|
|
private IReadOnlyList<CalendarDayCell> calendarDays = [];
|
2026-04-22 23:58:55 +02:00
|
|
|
private ElementReference rootElement;
|
|
|
|
|
private DotNetObjectReference<LocalizedDateInput>? dotNetReference;
|
|
|
|
|
|
|
|
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
|
|
|
{
|
|
|
|
|
if (isOpen && !outsideClickListenerActive)
|
|
|
|
|
{
|
|
|
|
|
dotNetReference ??= DotNetObjectReference.Create(this);
|
|
|
|
|
await JS.InvokeVoidAsync("workTrackerDateInput.registerOutsideClick", rootElement, dotNetReference);
|
|
|
|
|
outsideClickListenerActive = true;
|
|
|
|
|
}
|
|
|
|
|
else if (!isOpen && outsideClickListenerActive)
|
|
|
|
|
{
|
|
|
|
|
await JS.InvokeVoidAsync("workTrackerDateInput.unregisterOutsideClick", rootElement);
|
|
|
|
|
outsideClickListenerActive = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await base.OnAfterRenderAsync(firstRender);
|
|
|
|
|
}
|
2026-04-22 11:07:30 +02:00
|
|
|
|
|
|
|
|
protected override void OnParametersSet()
|
|
|
|
|
{
|
|
|
|
|
if (visibleMonth == default)
|
|
|
|
|
{
|
|
|
|
|
visibleMonth = ToFirstOfMonth(Value ?? DateOnly.FromDateTime(DateTime.Today));
|
|
|
|
|
calendarDays = BuildCalendarDays(visibleMonth);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Value != lastValue)
|
|
|
|
|
{
|
|
|
|
|
lastValue = Value;
|
|
|
|
|
displayValue = FormatValue(Value);
|
|
|
|
|
visibleMonth = ToFirstOfMonth(Value ?? DateOnly.FromDateTime(DateTime.Today));
|
|
|
|
|
calendarDays = BuildCalendarDays(visibleMonth);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void OpenPopup(FocusEventArgs _)
|
|
|
|
|
{
|
|
|
|
|
if (Disabled)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isOpen = true;
|
|
|
|
|
visibleMonth = ToFirstOfMonth(Value ?? DateOnly.FromDateTime(DateTime.Today));
|
|
|
|
|
calendarDays = BuildCalendarDays(visibleMonth);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void TogglePopup()
|
|
|
|
|
{
|
|
|
|
|
if (Disabled)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isOpen = !isOpen;
|
|
|
|
|
if (isOpen)
|
|
|
|
|
{
|
|
|
|
|
visibleMonth = ToFirstOfMonth(Value ?? DateOnly.FromDateTime(DateTime.Today));
|
|
|
|
|
calendarDays = BuildCalendarDays(visibleMonth);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task OnTextChangedAsync(ChangeEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
displayValue = e.Value?.ToString() ?? string.Empty;
|
|
|
|
|
|
|
|
|
|
if (TryParseInput(displayValue, out var parsedDate))
|
|
|
|
|
{
|
|
|
|
|
await SetValueAsync(parsedDate, closePopup: false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (AllowEmpty && string.IsNullOrWhiteSpace(displayValue))
|
|
|
|
|
{
|
|
|
|
|
await SetValueAsync(null, closePopup: false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
displayValue = FormatValue(Value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task SelectDateAsync(DateOnly date)
|
|
|
|
|
{
|
|
|
|
|
await SetValueAsync(date, closePopup: true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task ClearAsync()
|
|
|
|
|
{
|
|
|
|
|
await SetValueAsync(null, closePopup: true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task SetValueAsync(DateOnly? date, bool closePopup)
|
|
|
|
|
{
|
|
|
|
|
lastValue = date;
|
|
|
|
|
Value = date;
|
|
|
|
|
displayValue = FormatValue(date);
|
|
|
|
|
if (date.HasValue)
|
|
|
|
|
{
|
|
|
|
|
visibleMonth = ToFirstOfMonth(date.Value);
|
|
|
|
|
calendarDays = BuildCalendarDays(visibleMonth);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (closePopup)
|
|
|
|
|
{
|
|
|
|
|
isOpen = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await ValueChanged.InvokeAsync(date);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void ShowPreviousMonth()
|
|
|
|
|
{
|
|
|
|
|
visibleMonth = visibleMonth.AddMonths(-1);
|
|
|
|
|
calendarDays = BuildCalendarDays(visibleMonth);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void ShowNextMonth()
|
|
|
|
|
{
|
|
|
|
|
visibleMonth = visibleMonth.AddMonths(1);
|
|
|
|
|
calendarDays = BuildCalendarDays(visibleMonth);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void HandleKeyDown(KeyboardEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
if (e.Key == "Escape")
|
|
|
|
|
{
|
|
|
|
|
isOpen = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 23:58:55 +02:00
|
|
|
[JSInvokable]
|
|
|
|
|
public Task ClosePopupFromOutsideClickAsync()
|
|
|
|
|
{
|
|
|
|
|
if (!isOpen)
|
|
|
|
|
{
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isOpen = false;
|
|
|
|
|
return InvokeAsync(StateHasChanged);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async ValueTask DisposeAsync()
|
|
|
|
|
{
|
|
|
|
|
if (outsideClickListenerActive)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await JS.InvokeVoidAsync("workTrackerDateInput.unregisterOutsideClick", rootElement);
|
|
|
|
|
}
|
|
|
|
|
catch (JSDisconnectedException)
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dotNetReference?.Dispose();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 11:07:30 +02:00
|
|
|
private string GetInputTestId() => string.IsNullOrWhiteSpace(TestId) ? "localized-date-input" : $"{TestId}-input";
|
|
|
|
|
|
|
|
|
|
private string GetPopoverTestId() => string.IsNullOrWhiteSpace(TestId) ? "localized-date-popover" : $"{TestId}-popover";
|
|
|
|
|
|
|
|
|
|
private string GetDayTestId(DateOnly date) => string.IsNullOrWhiteSpace(TestId)
|
|
|
|
|
? $"localized-date-day-{date:yyyy-MM-dd}"
|
|
|
|
|
: $"{TestId}-day-{date:yyyy-MM-dd}";
|
|
|
|
|
|
|
|
|
|
private static string FormatValue(DateOnly? date)
|
|
|
|
|
{
|
|
|
|
|
return date?.ToString("dd/MM/yyyy", ItalianCulture) ?? string.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static bool TryParseInput(string? value, out DateOnly date)
|
|
|
|
|
{
|
|
|
|
|
foreach (var format in SupportedFormats)
|
|
|
|
|
{
|
|
|
|
|
if (DateOnly.TryParseExact(value, format, ItalianCulture, DateTimeStyles.None, out date))
|
|
|
|
|
{
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return DateOnly.TryParse(value, ItalianCulture, DateTimeStyles.None, out date);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static DateOnly ToFirstOfMonth(DateOnly date) => new(date.Year, date.Month, 1);
|
|
|
|
|
|
|
|
|
|
private static IReadOnlyList<CalendarDayCell> BuildCalendarDays(DateOnly month)
|
|
|
|
|
{
|
|
|
|
|
var firstDayOfMonth = ToFirstOfMonth(month);
|
|
|
|
|
var lastDayOfMonth = firstDayOfMonth.AddMonths(1).AddDays(-1);
|
|
|
|
|
var gridStart = firstDayOfMonth.AddDays(-(((int)firstDayOfMonth.DayOfWeek + 6) % 7));
|
|
|
|
|
var gridEnd = lastDayOfMonth.AddDays((7 - (((int)lastDayOfMonth.DayOfWeek + 6) % 7) - 1 + 7) % 7);
|
|
|
|
|
var days = new List<CalendarDayCell>();
|
|
|
|
|
|
|
|
|
|
for (var date = gridStart; date <= gridEnd; date = date.AddDays(1))
|
|
|
|
|
{
|
|
|
|
|
days.Add(new CalendarDayCell(date, date.Month == month.Month && date.Year == month.Year));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return days;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string[] BuildMondayFirstWeekdays()
|
|
|
|
|
{
|
|
|
|
|
return Enumerable.Range(0, 7)
|
|
|
|
|
.Select(index => ItalianCulture.DateTimeFormat.AbbreviatedDayNames[(index + 1) % 7])
|
|
|
|
|
.Select(dayName => ItalianCulture.TextInfo.ToTitleCase(dayName))
|
|
|
|
|
.ToArray();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed record CalendarDayCell(DateOnly Date, bool IsCurrentMonth);
|
|
|
|
|
}
|