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))); 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 && 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.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 @@ - + @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); } } "; 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(); } } ";