WorkTracker/Components/Shared/LocalizedDateInput.razor
2026-04-22 23:58:55 +02:00

296 lines
No EOL
9.7 KiB
Text

@using System.Globalization
@implements IAsyncDisposable
@inject IJSRuntime JS
<div class="localized-date-input" @ref="rootElement" @onkeydown="HandleKeyDown">
<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">&lsaquo;</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">&rsaquo;</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;
private bool outsideClickListenerActive;
private DateOnly visibleMonth;
private IReadOnlyList<CalendarDayCell> calendarDays = [];
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);
}
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;
}
}
[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();
}
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);
}