Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions ShellUI.Tests/PackageBehaviorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>();
var legacy = new List<int>();

var pagination = ctx.Render<Pagination>(p => p
.Add(c => c.CurrentPage, 2)
.Add(c => c.TotalPages, 3)
.Add(c => c.CurrentPageChanged, EventCallback.Factory.Create<int>(this, v => bound.Add(v)))
.Add(c => c.OnPageChange, EventCallback.Factory.Create<int>(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<DatePicker>(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<ScrollArea>(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()
{
Expand Down
24 changes: 23 additions & 1 deletion ShellUI.Tests/TemplateSyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)));
Expand Down
26 changes: 22 additions & 4 deletions src/ShellUI.Components/Components/DatePicker.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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" : "")">
<span class="@("flex-1 text-left " + (Value == null ? "text-muted-foreground" : ""))">
@(Value?.ToString("MMM dd, yyyy") ?? Placeholder)
<span class="@("flex-1 text-left " + (Current == null ? "text-muted-foreground" : ""))">
@(Current?.ToString("MMM dd, yyyy") ?? Placeholder)
</span>
<div class="ml-auto flex items-center gap-1 shrink-0">
@if (AllowClear && Value.HasValue && !Disabled)
@if (AllowClear && Current.HasValue && !Disabled)
{
<div role="button" @onclick:stopPropagation @onclick="Clear" class="text-muted-foreground hover:text-foreground">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
Expand Down Expand Up @@ -55,11 +55,15 @@
<div class="font-semibold text-muted-foreground">Sa</div>
</div>
<div class="grid grid-cols-7 gap-1">
@for (int i = 0; i < LeadingBlankDays(); i++)
{
<div class="h-9 w-9"></div>
}
@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;
<button type="button"
@onclick="@(() => SelectDate(date))"
class="@Shell.Cn("h-9 w-9 rounded-md text-sm transition-colors hover:bg-accent hover:text-accent-foreground", isSelected ? "bg-primary text-primary-foreground" : "")">
Expand All @@ -80,6 +84,10 @@
@code {
[Parameter] public DateTime? Value { get; set; }
[Parameter] public EventCallback<DateTime?> ValueChanged { get; set; }
// Deprecated: use Value or @bind-Value.
[Parameter] public DateTime? SelectedDate { get; set; }
// Deprecated: use ValueChanged.
[Parameter] public EventCallback<DateTime?> 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;
Expand All @@ -95,9 +103,12 @@
private readonly string _dismissHandle = Guid.NewGuid().ToString("N");
private bool _dismissRegistered;

private DateTime? Current => Value ?? SelectedDate;

private async Task Toggle()
{
IsOpen = !IsOpen;
if (IsOpen) currentMonth = Current ?? DateTime.Today;
if (IsOpen) await RegisterDismissAsync();
else await UnregisterDismissAsync();
}
Expand Down Expand Up @@ -141,20 +152,27 @@
private async Task SelectDate(DateTime date)
{
Value = date;
SelectedDate = date;
IsOpen = false;
await UnregisterDismissAsync();
await ValueChanged.InvokeAsync(date);
await SelectedDateChanged.InvokeAsync(date);
}

private async Task Clear()
{
Value = null;
SelectedDate = null;
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()
{
Expand Down
67 changes: 48 additions & 19 deletions src/ShellUI.Components/Components/NavigationMenuItem.razor
Original file line number Diff line number Diff line change
@@ -1,37 +1,66 @@
@namespace ShellUI.Components

<div class="@Shell.Cn("relative", ClassName, Class)" @attributes="AdditionalAttributes">
<button type="button"
@onclick="Toggle"
class="group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50">
@Title
@if (HasContent)
@if (!string.IsNullOrEmpty(Title))
{
<div class="@Shell.Cn("relative", ClassName, Class)" @attributes="AdditionalAttributes">
<button type="button"
@onclick="Toggle"
disabled="@Disabled"
class="group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50">
@Title
@if (HasContent)
{
<svg class="@Shell.Cn("relative top-[1px] ml-1 h-3 w-3 transition duration-200", IsOpen ? "rotate-180" : "")" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
}
</button>

@if (IsOpen && HasContent)
{
<svg class="@Shell.Cn("relative top-[1px] ml-1 h-3 w-3 transition duration-200", IsOpen ? "rotate-180" : "")" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
<div class="absolute left-0 top-full mt-1 w-56 rounded-md border border-border bg-popover p-1 shadow-lg animate-in fade-in-0 zoom-in-95">
@ChildContent
</div>
}
</div>
}
else if (!string.IsNullOrEmpty(Href))
{
<a href="@Href"
aria-current="@(IsActive ? "page" : null)"
class="@Shell.Cn("inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring h-9 px-4 py-2", IsActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground", ClassName, Class)"
@attributes="AdditionalAttributes">
@ChildContent
</a>
}
else
{
<button type="button"
@onclick="OnClick"
disabled="@Disabled"
class="@Shell.Cn("inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring h-9 px-4 py-2", Disabled ? "cursor-not-allowed opacity-50" : "", IsActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground", ClassName, Class)"
@attributes="AdditionalAttributes">
@ChildContent
</button>

@if (IsOpen && HasContent)
{
<div class="absolute left-0 top-full mt-1 w-56 rounded-md border border-border bg-popover p-1 shadow-lg animate-in fade-in-0 zoom-in-95">
@ChildContent
</div>
}
</div>
}

@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<MouseEventArgs> OnClick { get; set; }
[Parameter] public string? Class { get; set; }
// Deprecated: use Class.
[Parameter] public string ClassName { get; set; } = "";
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }

private bool IsOpen { get; set; }

private void Toggle() => IsOpen = !IsOpen;
}
35 changes: 12 additions & 23 deletions src/ShellUI.Components/Components/Pagination.razor
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
<button type="button"
@onclick="PreviousPage"
disabled="@(CurrentPage <= 1)"
class="@Shell.Cn("inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring h-9 px-4 py-2 gap-1 pl-2.5", CurrentPage <= 1 ? "pointer-events-none opacity-50" : "hover:bg-accent hover:text-accent-foreground")">
class="@Shell.Cn("inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring h-9 px-2.5 py-2 gap-1 sm:px-4 sm:pl-2.5", CurrentPage <= 1 ? "pointer-events-none opacity-50" : "hover:bg-accent hover:text-accent-foreground")">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span>Previous</span>
<span class="hidden sm:inline">Previous</span>
</button>
</li>

Expand All @@ -36,8 +36,8 @@
<button type="button"
@onclick="NextPage"
disabled="@(CurrentPage >= TotalPages)"
class="@Shell.Cn("inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring h-9 px-4 py-2 gap-1 pr-2.5", CurrentPage >= TotalPages ? "pointer-events-none opacity-50" : "hover:bg-accent hover:text-accent-foreground")">
<span>Next</span>
class="@Shell.Cn("inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring h-9 px-2.5 py-2 gap-1 sm:px-4 sm:pr-2.5", CurrentPage >= TotalPages ? "pointer-events-none opacity-50" : "hover:bg-accent hover:text-accent-foreground")">
<span class="hidden sm:inline">Next</span>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
Expand All @@ -50,6 +50,8 @@
@code {
[Parameter] public int CurrentPage { get; set; } = 1;
[Parameter] public EventCallback<int> CurrentPageChanged { get; set; }
// Deprecated: use CurrentPageChanged or @bind-CurrentPage.
[Parameter] public EventCallback<int> OnPageChange { get; set; }
[Parameter] public int TotalPages { get; set; } = 1;
// Set to build the pagination from PaginationContent, PaginationItem and PaginationLink.
[Parameter] public RenderFragment? ChildContent { get; set; }
Expand All @@ -61,25 +63,12 @@

private async Task GoToPage(int page)
{
if (page < 1 || page > TotalPages || page == CurrentPage) return;
CurrentPage = page;
await CurrentPageChanged.InvokeAsync(CurrentPage);
}

private async Task PreviousPage()
{
if (CurrentPage > 1)
{
CurrentPage--;
await CurrentPageChanged.InvokeAsync(CurrentPage);
}
}

private async Task NextPage()
{
if (CurrentPage < TotalPages)
{
CurrentPage++;
await CurrentPageChanged.InvokeAsync(CurrentPage);
}
await CurrentPageChanged.InvokeAsync(page);
await OnPageChange.InvokeAsync(page);
}

private Task PreviousPage() => GoToPage(CurrentPage - 1);
private Task NextPage() => GoToPage(CurrentPage + 1);
}
11 changes: 8 additions & 3 deletions src/ShellUI.Components/Components/ScrollArea.razor
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
@namespace ShellUI.Components

<div class="@Shell.Cn("relative overflow-hidden", Class)" style="@($"height: {Height};")" @attributes="AdditionalAttributes">
<div class="h-full w-full overflow-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<div class="@Shell.Cn("relative overflow-hidden", Class)" @attributes="AdditionalAttributes">
<div class="h-full w-full overflow-auto" style="@ScrollStyle">
@ChildContent
</div>
</div>

@code {
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public string Height { get; set; } = "400px";
// Fixed height. When neither Height nor MaxHeight is set, the area grows with its content up to 400px.
[Parameter] public string? Height { get; set; }
[Parameter] public string? MaxHeight { get; set; }
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }

private string ScrollStyle =>
(Height is null ? "" : $"height: {Height}; ") + $"max-height: {MaxHeight ?? (Height is null ? "400px" : "none")};";
}
4 changes: 3 additions & 1 deletion src/ShellUI.Components/Components/Skeleton.razor
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
@namespace ShellUI.Components

<div class="@Shell.Cn("animate-pulse rounded-md bg-muted", Class)" @attributes="AdditionalAttributes">
<div class="@Shell.Cn("animate-pulse rounded-md bg-muted", Variant == "circle" ? "rounded-full" : "", Variant == "text" ? "h-4 w-full" : "", Class)" @attributes="AdditionalAttributes">
@ChildContent
</div>

@code {
// "default", "circle" or "text".
[Parameter] public string Variant { get; set; } = "default";
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
Expand Down
Loading
Loading