From 389a715e8bfc343fcc0d8126f7fe79f40d9508ec Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sun, 18 Oct 2026 17:31:07 +0200 Subject: [PATCH 1/8] fix: merge the Pagination package component and template The package gains the template's responsive Previous/Next labels and padding; the template gains CurrentPageChanged (for @bind-CurrentPage) with OnPageChange kept as a deprecated alias. Both now ignore clicks on the current page or outside the range. Part of #52 --- .../Components/Pagination.razor | 35 ++++------- .../build/ShellUI.Components.targets | 1 + .../wwwroot/shellui-classes.txt | 1 + .../Templates/PaginationTemplate.cs | 59 ++++++++----------- 4 files changed, 38 insertions(+), 58 deletions(-) diff --git a/src/ShellUI.Components/Components/Pagination.razor b/src/ShellUI.Components/Components/Pagination.razor index a254589..f6505fe 100644 --- a/src/ShellUI.Components/Components/Pagination.razor +++ b/src/ShellUI.Components/Components/Pagination.razor @@ -12,11 +12,11 @@ @@ -36,8 +36,8 @@ @@ -44,7 +44,7 @@ public class PaginationTemplate
  • @@ -52,10 +52,10 @@ public class PaginationTemplate
  • @@ -31,26 +33,67 @@ [Parameter] public EventCallback IsVisibleChanged { get; set; } [Parameter] public string? Title { get; set; } [Parameter] public string? Description { get; set; } + // "default", "destructive", "success" or "warning". [Parameter] public string Variant { get; set; } = "default"; + // "top-left", "top-right", "bottom-left" or "bottom-right". [Parameter] public string Position { get; set; } = "bottom-right"; + // Milliseconds before the toast hides itself; 0 keeps it open. + [Parameter] public int Duration { get; set; } = 5000; [Parameter] public RenderFragment? ChildContent { get; set; } [Parameter] public string? Class { get; set; } [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } - + + private CancellationTokenSource? _dismiss; + private bool _wasVisible; + + protected override void OnAfterRender(bool firstRender) + { + if (IsVisible && !_wasVisible && Duration > 0) + { + _dismiss?.Cancel(); + _dismiss?.Dispose(); + _dismiss = new CancellationTokenSource(); + _ = AutoDismissAsync(_dismiss.Token); + } + else if (!IsVisible && _wasVisible) + { + _dismiss?.Cancel(); + } + _wasVisible = IsVisible; + } + + private async Task AutoDismissAsync(CancellationToken token) + { + try + { + await Task.Delay(Duration, token); + await InvokeAsync(async () => + { + if (!IsVisible) return; + IsVisible = false; + await IsVisibleChanged.InvokeAsync(IsVisible); + StateHasChanged(); + }); + } + catch (TaskCanceledException) { } + } + private async Task Close() { + _dismiss?.Cancel(); IsVisible = false; await IsVisibleChanged.InvokeAsync(IsVisible); } - + private string GetVariantClass() => Variant switch { "destructive" => "border-destructive bg-destructive text-destructive-foreground", "success" => "border-green-500 bg-green-50 text-green-900 dark:bg-green-900/20 dark:text-green-100", - _ => "bg-background text-foreground" + "warning" => "border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-900/20 dark:text-yellow-100", + _ => "border-border bg-background text-foreground" }; - + private string GetPositionClass() => Position switch { "top-left" => "top-0 left-0", @@ -58,4 +101,10 @@ "bottom-left" => "bottom-0 left-0", _ => "bottom-0 right-0" }; + + public void Dispose() + { + _dismiss?.Cancel(); + _dismiss?.Dispose(); + } } diff --git a/src/ShellUI.Templates/Templates/ToastTemplate.cs b/src/ShellUI.Templates/Templates/ToastTemplate.cs index cf29b27..7ce60c8 100644 --- a/src/ShellUI.Templates/Templates/ToastTemplate.cs +++ b/src/ShellUI.Templates/Templates/ToastTemplate.cs @@ -17,12 +17,12 @@ public class ToastTemplate }; public static string Content => @"@namespace YourProjectNamespace.Components.UI -@implements IAsyncDisposable +@implements IDisposable @if (IsVisible) { -
    -
    +
    @if (!string.IsNullOrEmpty(Title)) @@ -35,103 +35,97 @@ @implements IAsyncDisposable } @ChildContent
    -
    } @code { - [Parameter] - public bool IsVisible { get; set; } - - [Parameter] - public EventCallback IsVisibleChanged { get; set; } - - [Parameter] - public string? Title { get; set; } - - [Parameter] - public string? Description { get; set; } - - [Parameter] - public string Variant { get; set; } = ""default""; - - [Parameter] - public string Position { get; set; } = ""bottom-right""; - - [Parameter] - public int Duration { get; set; } = 5000; - - [Parameter] - public RenderFragment? ChildContent { get; set; } - - [Parameter] - public string? Class { get; set; } - + [Parameter] public bool IsVisible { get; set; } + [Parameter] public EventCallback IsVisibleChanged { get; set; } + [Parameter] public string? Title { get; set; } + [Parameter] public string? Description { get; set; } + // ""default"", ""destructive"", ""success"" or ""warning"". + [Parameter] public string Variant { get; set; } = ""default""; + // ""top-left"", ""top-right"", ""bottom-left"" or ""bottom-right"". + [Parameter] public string Position { get; set; } = ""bottom-right""; + // Milliseconds before the toast hides itself; 0 keeps it open. + [Parameter] public int Duration { get; set; } = 5000; + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public string? Class { get; set; } // Deprecated: use Class. - [Parameter] - public string ClassName { get; set; } = """"; - + [Parameter] public string ClassName { get; set; } = """"; [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } - private CancellationTokenSource? _cancellationTokenSource; - private bool _previousVisible; + private CancellationTokenSource? _dismiss; + private bool _wasVisible; - protected override async Task OnAfterRenderAsync(bool firstRender) + protected override void OnAfterRender(bool firstRender) { - if (IsVisible && !_previousVisible && Duration > 0) + if (IsVisible && !_wasVisible && Duration > 0) { - _cancellationTokenSource?.Cancel(); - _cancellationTokenSource?.Dispose(); - _cancellationTokenSource = new CancellationTokenSource(); - _ = StartAutoDismiss(_cancellationTokenSource.Token); + _dismiss?.Cancel(); + _dismiss?.Dispose(); + _dismiss = new CancellationTokenSource(); + _ = AutoDismissAsync(_dismiss.Token); } - else if (!IsVisible && _previousVisible) + else if (!IsVisible && _wasVisible) { - _cancellationTokenSource?.Cancel(); + _dismiss?.Cancel(); } - - _previousVisible = IsVisible; + _wasVisible = IsVisible; } - private async Task StartAutoDismiss(CancellationToken cancellationToken) + private async Task AutoDismissAsync(CancellationToken token) { try { - await Task.Delay(Duration, cancellationToken); - if (!cancellationToken.IsCancellationRequested && IsVisible) + await Task.Delay(Duration, token); + await InvokeAsync(async () => { - await InvokeAsync(async () => - { - IsVisible = false; - await IsVisibleChanged.InvokeAsync(IsVisible); - StateHasChanged(); - }); - } - } - catch (TaskCanceledException) - { + if (!IsVisible) return; + IsVisible = false; + await IsVisibleChanged.InvokeAsync(IsVisible); + StateHasChanged(); + }); } + catch (TaskCanceledException) { } } - + private async Task Close() { - _cancellationTokenSource?.Cancel(); + _dismiss?.Cancel(); IsVisible = false; await IsVisibleChanged.InvokeAsync(IsVisible); } - public async ValueTask DisposeAsync() + private string GetVariantClass() => Variant switch + { + ""destructive"" => ""border-destructive bg-destructive text-destructive-foreground"", + ""success"" => ""border-green-500 bg-green-50 text-green-900 dark:bg-green-900/20 dark:text-green-100"", + ""warning"" => ""border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-900/20 dark:text-yellow-100"", + _ => ""border-border bg-background text-foreground"" + }; + + private string GetPositionClass() => Position switch + { + ""top-left"" => ""top-0 left-0"", + ""top-right"" => ""top-0 right-0"", + ""bottom-left"" => ""bottom-0 left-0"", + _ => ""bottom-0 right-0"" + }; + + public void Dispose() { - _cancellationTokenSource?.Cancel(); - _cancellationTokenSource?.Dispose(); - await Task.CompletedTask; + _dismiss?.Cancel(); + _dismiss?.Dispose(); } } "; From 4daa5093c7ae6fb439d3e598bbb7f479b4b020b2 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sun, 18 Oct 2026 17:31:08 +0200 Subject: [PATCH 5/8] fix: merge the TimePicker package component and template Value/ValueChanged is the binding in both, with SelectedTime kept as a deprecated alias. Step now filters the minute list (it was unused), the picker opens on the current value, and Cancel or an outside click closes it. Part of #52 --- .../Components/TimePicker.razor | 54 ++++--- .../Templates/TimePickerTemplate.cs | 144 +++++++++--------- 2 files changed, 110 insertions(+), 88 deletions(-) diff --git a/src/ShellUI.Components/Components/TimePicker.razor b/src/ShellUI.Components/Components/TimePicker.razor index f0615c8..7482f0e 100644 --- a/src/ShellUI.Components/Components/TimePicker.razor +++ b/src/ShellUI.Components/Components/TimePicker.razor @@ -5,21 +5,21 @@ @onclick="Toggle" disabled="@Disabled" class="@Shell.Cn("flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", Disabled ? "cursor-not-allowed opacity-50" : "")"> - - @(Value?.ToString(@"hh\:mm") ?? Placeholder) + + @(Current?.ToString(@"hh\:mm") ?? Placeholder) - + @if (IsOpen) { -
    +
    - +
    - + - @for (int h = 0; h < 24; h++) - { - - } - +
    +
    +
    +
    + + +
    +
    + + +
    -
    - - +
    + +
    -
    }
    -@code { - [Parameter] - public TimeSpan? SelectedTime { get; set; } - - [Parameter] - public EventCallback SelectedTimeChanged { get; set; } - - [Parameter] - public string Placeholder { get; set; } = ""Pick a time""; - - [Parameter] - public int Step { get; set; } = 15; - - [Parameter] - public bool Disabled { get; set; } - - [Parameter] - public string? Class { get; set; } +@if (IsOpen) +{ +
    +} +@code { + [Parameter] public TimeSpan? Value { get; set; } + [Parameter] public EventCallback ValueChanged { get; set; } + // Deprecated: use Value or @bind-Value. + [Parameter] public TimeSpan? SelectedTime { get; set; } + // Deprecated: use ValueChanged. + [Parameter] public EventCallback SelectedTimeChanged { get; set; } + [Parameter] public string Placeholder { get; set; } = ""Pick a time""; + // Minutes between the options in the minute list. + [Parameter] public int Step { get; set; } = 5; + [Parameter] public bool Disabled { get; set; } + [Parameter] public string? Class { get; set; } // Deprecated: use Class. - [Parameter] - public string ClassName { get; set; } = """"; - + [Parameter] public string ClassName { get; set; } = """"; [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } - - private bool _isOpen; - private string selectedHour = ""0""; - private string selectedMinute = ""0""; - - protected override void OnParametersSet() + + private bool IsOpen { get; set; } + private int selectedHour; + private int selectedMinute; + + private TimeSpan? Current => Value ?? SelectedTime; + + private IEnumerable MinuteOptions => + Enumerable.Range(0, 60).Where(m => m % Math.Max(1, Step) == 0 || m == selectedMinute); + + private void Toggle() { - if (SelectedTime.HasValue) + if (!IsOpen) { - selectedHour = SelectedTime.Value.Hours.ToString(); - selectedMinute = SelectedTime.Value.Minutes.ToString(); + selectedHour = Current?.Hours ?? 0; + selectedMinute = Current?.Minutes ?? 0; } + IsOpen = !IsOpen; } - - private void TogglePicker() => _isOpen = !_isOpen; - + + private void Close() => IsOpen = false; + private async Task ApplyTime() { - var hour = int.Parse(selectedHour); - var minute = int.Parse(selectedMinute); - SelectedTime = new TimeSpan(hour, minute, 0); - _isOpen = false; - await SelectedTimeChanged.InvokeAsync(SelectedTime); + var time = new TimeSpan(selectedHour, selectedMinute, 0); + Value = time; + SelectedTime = time; + IsOpen = false; + await ValueChanged.InvokeAsync(time); + await SelectedTimeChanged.InvokeAsync(time); } } "; From d670cf67b02d77d7f87409078ccd0eea94e0d3bc Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sun, 18 Oct 2026 17:31:08 +0200 Subject: [PATCH 6/8] fix: place the first of the month under its weekday in DatePicker The package always drew day 1 under Sunday. Leading blank cells now offset it, the picker opens on the selected month, and SelectedDate is kept as a deprecated alias of Value so template users don't break. Part of #52 --- .../Components/DatePicker.razor | 26 ++- .../Templates/DatePickerTemplate.cs | 216 +++++++++--------- 2 files changed, 129 insertions(+), 113 deletions(-) diff --git a/src/ShellUI.Components/Components/DatePicker.razor b/src/ShellUI.Components/Components/DatePicker.razor index 71063d9..53f0ac5 100644 --- a/src/ShellUI.Components/Components/DatePicker.razor +++ b/src/ShellUI.Components/Components/DatePicker.razor @@ -9,11 +9,11 @@ @onclick="Toggle" disabled="@Disabled" class="@Shell.Cn("flex h-10 w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", Disabled ? "cursor-not-allowed opacity-50" : "")"> - - @(Value?.ToString("MMM dd, yyyy") ?? Placeholder) + + @(Current?.ToString("MMM dd, yyyy") ?? Placeholder)
    - @if (AllowClear && Value.HasValue && !Disabled) + @if (AllowClear && Current.HasValue && !Disabled) {
    @@ -55,11 +55,15 @@
    Sa
    + @for (int i = 0; i < LeadingBlankDays(); i++) + { +
    + } @for (int i = 0; i < GetDaysInMonth(); i++) { var day = i + 1; var date = new DateTime(currentMonth.Year, currentMonth.Month, day); - var isSelected = Value?.Date == date; + var isSelected = Current?.Date == date; - } - - - -
    - +
    +
    + +
    - @if (_isOpen) + @if (IsOpen) { -
    -
    - -
    @_currentMonth.ToString(""MMMM yyyy"")
    - -
    -
    -
    Su
    -
    Mo
    -
    Tu
    -
    We
    -
    Th
    -
    Fr
    -
    Sa
    - @foreach (var day in GetCalendarDays()) - { - @if (day.HasValue) +
    +
    +
    + +
    @currentMonth.ToString(""MMMM yyyy"")
    + +
    +
    +
    Su
    +
    Mo
    +
    Tu
    +
    We
    +
    Th
    +
    Fr
    +
    Sa
    +
    +
    + @for (int i = 0; i < LeadingBlankDays(); i++) { - +
    } - else + @for (int i = 0; i < GetDaysInMonth(); i++) { -
    + var day = i + 1; + var date = new DateTime(currentMonth.Year, currentMonth.Month, day); + var isSelected = Current?.Date == date; + } - } +
    }
    -@code { - [Parameter] - public DateTime? SelectedDate { get; set; } - - [Parameter] - public EventCallback SelectedDateChanged { get; set; } - - [Parameter] - public string Placeholder { get; set; } = ""Pick a date""; - - [Parameter] - public bool AllowClear { get; set; } = true; - - [Parameter] - public bool Disabled { get; set; } +@if (IsOpen) +{ +
    +} +@code { + [Parameter] public DateTime? Value { get; set; } + [Parameter] public EventCallback ValueChanged { get; set; } + // Deprecated: use Value or @bind-Value. + [Parameter] public DateTime? SelectedDate { get; set; } + // Deprecated: use ValueChanged. + [Parameter] public EventCallback SelectedDateChanged { get; set; } + [Parameter] public string Placeholder { get; set; } = ""Pick a date""; + [Parameter] public bool Disabled { get; set; } + [Parameter] public bool AllowClear { get; set; } = true; /// Close the calendar when the page scrolls; set false to keep it open. - [Parameter] - public bool CloseOnScroll { get; set; } - - [Parameter] - public string? Class { get; set; } - + [Parameter] public bool CloseOnScroll { get; set; } + [Parameter] public string? Class { get; set; } // Deprecated: use Class. - [Parameter] - public string ClassName { get; set; } = """"; - + [Parameter] public string ClassName { get; set; } = """"; [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } - private bool _isOpen; - private DateTime _currentMonth = DateTime.Now; + private bool IsOpen { get; set; } + private DateTime currentMonth = DateTime.Now; private DotNetObjectReference? _selfRef; private readonly string _dismissHandle = Guid.NewGuid().ToString(""N""); private bool _dismissRegistered; - private async Task ToggleCalendar() + private DateTime? Current => Value ?? SelectedDate; + + private async Task Toggle() { - _isOpen = !_isOpen; - if (_isOpen) await RegisterDismissAsync(); + IsOpen = !IsOpen; + if (IsOpen) currentMonth = Current ?? DateTime.Today; + if (IsOpen) await RegisterDismissAsync(); else await UnregisterDismissAsync(); } + private async Task Close() + { + IsOpen = false; + await UnregisterDismissAsync(); + } + [JSInvokable] public async Task OnDismissEvent() { - if (_isOpen) + if (IsOpen) { - _isOpen = false; + IsOpen = false; await UnregisterDismissAsync(); StateHasChanged(); } @@ -163,43 +168,36 @@ private async Task UnregisterDismissAsync() _dismissRegistered = false; } - private void PreviousMonth() => _currentMonth = _currentMonth.AddMonths(-1); - private void NextMonth() => _currentMonth = _currentMonth.AddMonths(1); - private async Task SelectDate(DateTime date) { + Value = date; SelectedDate = date; - _isOpen = false; + IsOpen = false; await UnregisterDismissAsync(); + await ValueChanged.InvokeAsync(date); await SelectedDateChanged.InvokeAsync(date); } - private async Task ClearDate() + private async Task Clear() { + Value = null; SelectedDate = null; - _isOpen = false; + IsOpen = false; await UnregisterDismissAsync(); + await ValueChanged.InvokeAsync(null); await SelectedDateChanged.InvokeAsync(null); } + private void PreviousMonth() => currentMonth = currentMonth.AddMonths(-1); + private void NextMonth() => currentMonth = currentMonth.AddMonths(1); + private int GetDaysInMonth() => DateTime.DaysInMonth(currentMonth.Year, currentMonth.Month); + private int LeadingBlankDays() => (int)new DateTime(currentMonth.Year, currentMonth.Month, 1).DayOfWeek; + public async ValueTask DisposeAsync() { await UnregisterDismissAsync(); _selfRef?.Dispose(); } - - private List GetCalendarDays() - { - var days = new List(); - var firstDay = new DateTime(_currentMonth.Year, _currentMonth.Month, 1); - var lastDay = firstDay.AddMonths(1).AddDays(-1); - var startDayOfWeek = (int)firstDay.DayOfWeek; - - for (int i = 0; i < startDayOfWeek; i++) days.Add(null); - for (int day = 1; day <= lastDay.Day; day++) days.Add(new DateTime(_currentMonth.Year, _currentMonth.Month, day)); - - return days; - } } "; } From 315c720c51d2bbb7976bf19884b5e1914efdc91b Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sun, 18 Oct 2026 17:31:09 +0200 Subject: [PATCH 7/8] fix: merge the NavigationMenuItem package component and template Set Title for a dropdown item (the package's behaviour); leave it empty for a plain item that renders a link when Href is set (with aria-current when IsActive) or a button otherwise (the template's). Part of #52 --- .../Components/NavigationMenuItem.razor | 67 +++++++++++++------ .../Templates/NavigationMenuItemTemplate.cs | 67 ++++++++++++------- 2 files changed, 91 insertions(+), 43 deletions(-) diff --git a/src/ShellUI.Components/Components/NavigationMenuItem.razor b/src/ShellUI.Components/Components/NavigationMenuItem.razor index 8a73496..eb201a1 100644 --- a/src/ShellUI.Components/Components/NavigationMenuItem.razor +++ b/src/ShellUI.Components/Components/NavigationMenuItem.razor @@ -1,37 +1,66 @@ @namespace ShellUI.Components -
    - + + @if (IsOpen && HasContent) { - - - +
    + @ChildContent +
    } +
    +} +else if (!string.IsNullOrEmpty(Href)) +{ + + @ChildContent + +} +else +{ + - - @if (IsOpen && HasContent) - { -
    - @ChildContent -
    - } -
    +} @code { + // Set Title for a dropdown item: Title is the trigger and ChildContent the dropdown. + // Leave it empty for a plain item: ChildContent is the label, rendered as a link when Href is set. [Parameter] public string Title { get; set; } = ""; [Parameter] public RenderFragment? ChildContent { get; set; } [Parameter] public bool HasContent { get; set; } = true; + [Parameter] public string? Href { get; set; } + [Parameter] public bool IsActive { get; set; } + [Parameter] public bool Disabled { get; set; } + [Parameter] public EventCallback OnClick { get; set; } [Parameter] public string? Class { get; set; } // Deprecated: use Class. [Parameter] public string ClassName { get; set; } = ""; [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } - + private bool IsOpen { get; set; } - + private void Toggle() => IsOpen = !IsOpen; } diff --git a/src/ShellUI.Templates/Templates/NavigationMenuItemTemplate.cs b/src/ShellUI.Templates/Templates/NavigationMenuItemTemplate.cs index 2734559..fd8ec93 100644 --- a/src/ShellUI.Templates/Templates/NavigationMenuItemTemplate.cs +++ b/src/ShellUI.Templates/Templates/NavigationMenuItemTemplate.cs @@ -17,10 +17,35 @@ public class NavigationMenuItemTemplate public static string Content => @"@namespace YourProjectNamespace.Components.UI -@if (!string.IsNullOrEmpty(Href)) +@if (!string.IsNullOrEmpty(Title)) +{ +
    + + + @if (IsOpen && HasContent) + { +
    + @ChildContent +
    + } +
    +} +else if (!string.IsNullOrEmpty(Href)) { @ChildContent @@ -30,37 +55,31 @@ public class NavigationMenuItemTemplate } @code { - [Parameter] - public RenderFragment? ChildContent { get; set; } - - [Parameter] - public string? Href { get; set; } - - [Parameter] - public bool IsActive { get; set; } - - [Parameter] - public bool Disabled { get; set; } - - [Parameter] - public EventCallback OnClick { get; set; } - - [Parameter] - public string? Class { get; set; } - + // Set Title for a dropdown item: Title is the trigger and ChildContent the dropdown. + // Leave it empty for a plain item: ChildContent is the label, rendered as a link when Href is set. + [Parameter] public string Title { get; set; } = """"; + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public bool HasContent { get; set; } = true; + [Parameter] public string? Href { get; set; } + [Parameter] public bool IsActive { get; set; } + [Parameter] public bool Disabled { get; set; } + [Parameter] public EventCallback OnClick { get; set; } + [Parameter] public string? Class { get; set; } // Deprecated: use Class. - [Parameter] - public string ClassName { get; set; } = """"; - + [Parameter] public string ClassName { get; set; } = """"; [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } + + private bool IsOpen { get; set; } + + private void Toggle() => IsOpen = !IsOpen; } "; } From fe8c11ce62d11c306778009a0a8f4d1e34ba4b62 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sun, 18 Oct 2026 17:31:09 +0200 Subject: [PATCH 8/8] test: sync-check the merged components and record the by-design drift Covers Pagination, Skeleton, ScrollArea, Toast, TimePicker, DatePicker and NavigationMenuItem in both sync theories, and records CopyButton, SidebarProvider, AppSidebar and Resizable as allowed drift. Adds behaviour tests for the Pagination guard and callbacks, the DatePicker weekday offset and ScrollArea sizing. Part of #52 --- ShellUI.Tests/PackageBehaviorTests.cs | 52 +++++++++++++++++++++++++++ ShellUI.Tests/TemplateSyncTests.cs | 24 ++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/ShellUI.Tests/PackageBehaviorTests.cs b/ShellUI.Tests/PackageBehaviorTests.cs index 49b24a8..0b77dee 100644 --- a/ShellUI.Tests/PackageBehaviorTests.cs +++ b/ShellUI.Tests/PackageBehaviorTests.cs @@ -134,6 +134,58 @@ public void RadioGroup_SelectsTheClickedItemButNotADisabledOne() Assert.Equal(new[] { "a" }, changes); } + [Fact] + public void Pagination_RaisesBothCallbacksAndIgnoresTheCurrentPage() + { + using var ctx = new BunitContext(); + var bound = new List(); + var legacy = new List(); + + var pagination = ctx.Render(p => p + .Add(c => c.CurrentPage, 2) + .Add(c => c.TotalPages, 3) + .Add(c => c.CurrentPageChanged, EventCallback.Factory.Create(this, v => bound.Add(v))) + .Add(c => c.OnPageChange, EventCallback.Factory.Create(this, v => legacy.Add(v)))); + + pagination.FindAll("button").Single(b => b.TextContent.Trim() == "2").Click(); + pagination.FindAll("button").Single(b => b.TextContent.Trim() == "3").Click(); + + Assert.Equal(new[] { 3 }, bound); + Assert.Equal(new[] { 3 }, legacy); + } + + [Fact] + public void DatePicker_PlacesTheFirstOfTheMonthUnderItsWeekday() + { + using var ctx = new BunitContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + // 1 October 2026 is a Thursday, so four blank cells precede it. + var picker = ctx.Render(p => p.Add(c => c.Value, new DateTime(2026, 10, 15))); + + picker.Find("button").Click(); + + var grid = picker.FindAll("div.grid.grid-cols-7")[1]; + var cells = grid.Children; + Assert.Equal(4, cells.TakeWhile(c => c.TagName == "DIV").Count()); + Assert.Equal("1", cells[4].TextContent.Trim()); + } + + [Theory] + [InlineData(null, null, "max-height: 400px;")] + [InlineData("200px", null, "height: 200px; max-height: none;")] + [InlineData(null, "300px", "max-height: 300px;")] + public void ScrollArea_SizesTheScrollingElement(string? height, string? maxHeight, string expected) + { + using var ctx = new BunitContext(); + + var area = ctx.Render(p => p + .Add(c => c.Height, height) + .Add(c => c.MaxHeight, maxHeight) + .AddChildContent("Body")); + + Assert.Equal(expected, area.Find("div.overflow-auto").GetAttribute("style")); + } + [Fact] public void TimePicker_ShowsASetValue() { diff --git a/ShellUI.Tests/TemplateSyncTests.cs b/ShellUI.Tests/TemplateSyncTests.cs index b179a86..a687499 100644 --- a/ShellUI.Tests/TemplateSyncTests.cs +++ b/ShellUI.Tests/TemplateSyncTests.cs @@ -16,7 +16,11 @@ public class TemplateSyncTests { // The package imports the shellui.js module; the template calls window.ShellUI. ["command-palette"] = "JS-interop path differs between package (module import) and CLI template (window.ShellUI global).", - ["input-otp"] = "JS-interop path differs between package (module import + fallback) and CLI template (window.ShellUI global)." + ["input-otp"] = "JS-interop path differs between package (module import + fallback) and CLI template (window.ShellUI global).", + ["copy-button"] = "JS-interop path differs between package (module import) and CLI template (window.ShellUI global).", + ["sidebar-provider"] = "JS-interop path differs: the package imports shellui-sidebar.js; the template calls window.ShellUI.initSidebar, since a dynamic import 404s once compiled into a consumer's class library.", + ["app-sidebar"] = "The template is the dashboard block that init rewrites with the app's routes; the package copy is a sample.", + ["resizable"] = "Neither copy resizes panels; both are to be replaced by a real resizable panel group." }; [Theory] @@ -152,6 +156,17 @@ public class TemplateSyncTests [InlineData("radio-group", "RadioGroup.razor")] [InlineData("radio-group-item", "RadioGroupItem.razor")] [InlineData("empty-state", "EmptyState.razor")] + [InlineData("pagination", "Pagination.razor")] + [InlineData("skeleton", "Skeleton.razor")] + [InlineData("scroll-area", "ScrollArea.razor")] + [InlineData("toast", "Toast.razor")] + [InlineData("time-picker", "TimePicker.razor")] + [InlineData("date-picker", "DatePicker.razor")] + [InlineData("navigation-menu-item", "NavigationMenuItem.razor")] + [InlineData("copy-button", "CopyButton.razor")] + [InlineData("sidebar-provider", "SidebarProvider.razor")] + [InlineData("app-sidebar", "AppSidebar.razor")] + [InlineData("resizable", "Resizable.razor")] public void TemplateCodeBlock_MatchesLiveLibrary(string templateName, string razorFileName) { if (AllowedDrift.ContainsKey(templateName)) return; @@ -200,6 +215,13 @@ public void TemplateCodeBlock_MatchesLiveLibrary(string templateName, string raz [InlineData("radio-group", "RadioGroup.razor")] [InlineData("radio-group-item", "RadioGroupItem.razor")] [InlineData("empty-state", "EmptyState.razor")] + [InlineData("pagination", "Pagination.razor")] + [InlineData("skeleton", "Skeleton.razor")] + [InlineData("scroll-area", "ScrollArea.razor")] + [InlineData("toast", "Toast.razor")] + [InlineData("time-picker", "TimePicker.razor")] + [InlineData("date-picker", "DatePicker.razor")] + [InlineData("navigation-menu-item", "NavigationMenuItem.razor")] public void TemplateMarkup_MatchesLiveLibrary(string templateName, string razorFileName) { var liveContent = WithoutClassNameAlias(File.ReadAllText(GetLiveRazorPath(razorFileName)));