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
2 changes: 1 addition & 1 deletion ShellUI.Tests/ClassParameterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class ClassParameterTests
{
private static readonly Regex DeclaresClassName = new(@"public string\?? ClassName\b");
private static readonly Regex DeclaresClass = new(@"public string\? Class \{");
private static readonly Regex RendersClass = new(@"@Class\b|[,+]\s*Class\)");
private static readonly Regex RendersClass = new(@"@Class\b|[,+]\s*Class\s*\)");

[Fact]
public void EveryTemplateWithClassName_AlsoAcceptsAndRendersClass()
Expand Down
18 changes: 18 additions & 0 deletions ShellUI.Tests/PackageBehaviorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,24 @@ public void LegacySheet_TrapsFocusAndReleasesItWhenEscapeCloses()
ctx.JSInterop.VerifyInvoke("ShellUI.releaseFocus");
}

[Fact]
public void RadioGroup_SelectsTheClickedItemButNotADisabledOne()
{
using var ctx = new BunitContext();
var changes = new List<string>();

var group = ctx.Render<RadioGroup>(p => p
.Add(c => c.ValueChanged, EventCallback.Factory.Create<string>(this, v => changes.Add(v)))
.AddChildContent<RadioGroupItem>(i => i.Add(c => c.Value, "a").AddChildContent("A"))
.AddChildContent<RadioGroupItem>(i => i.Add(c => c.Value, "b").Add(c => c.Disabled, true).AddChildContent("B")));

var buttons = group.FindAll("button");
buttons[1].Click();
buttons[0].Click();

Assert.Equal(new[] { "a" }, changes);
}

[Fact]
public void TimePicker_ShowsASetValue()
{
Expand Down
51 changes: 45 additions & 6 deletions ShellUI.Tests/TemplateSyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,29 @@ public class TemplateSyncTests
[InlineData("sheet-content", "SheetContent.razor")]
[InlineData("drawer-content", "DrawerContent.razor")]
[InlineData("sidebar-menu-button", "SidebarMenuButton.razor")]
[InlineData("table", "Table.razor")]
[InlineData("table-body", "TableBody.razor")]
[InlineData("table-cell", "TableCell.razor")]
[InlineData("table-head", "TableHead.razor")]
[InlineData("table-header", "TableHeader.razor")]
[InlineData("table-row", "TableRow.razor")]
[InlineData("separator", "Separator.razor")]
[InlineData("slider", "Slider.razor")]
[InlineData("navbar", "Navbar.razor")]
[InlineData("checkbox", "Checkbox.razor")]
[InlineData("switch", "Switch.razor")]
[InlineData("tooltip", "Tooltip.razor")]
[InlineData("date-range-picker", "DateRangePicker.razor")]
[InlineData("radio-group", "RadioGroup.razor")]
[InlineData("radio-group-item", "RadioGroupItem.razor")]
[InlineData("empty-state", "EmptyState.razor")]
public void TemplateCodeBlock_MatchesLiveLibrary(string templateName, string razorFileName)
{
if (AllowedDrift.ContainsKey(templateName)) return;

var liveContent = File.ReadAllText(GetLiveRazorPath(razorFileName));
var templateContent = ComponentRegistry.GetComponentContent(templateName)
?? throw new InvalidOperationException($"Template '{templateName}' not found in registry");
var liveContent = WithoutClassNameAlias(File.ReadAllText(GetLiveRazorPath(razorFileName)));
var templateContent = WithoutClassNameAlias(ComponentRegistry.GetComponentContent(templateName)
?? throw new InvalidOperationException($"Template '{templateName}' not found in registry"));

var liveCode = ExtractCodeBlock(liveContent)
?? throw new InvalidOperationException($"Live {razorFileName} has no @code block");
Expand All @@ -168,11 +184,27 @@ public void TemplateCodeBlock_MatchesLiveLibrary(string templateName, string raz
[InlineData("drawer-content", "DrawerContent.razor")]
[InlineData("sidebar-menu-button", "SidebarMenuButton.razor")]
[InlineData("theme-toggle", "ThemeToggle.razor")]
[InlineData("table", "Table.razor")]
[InlineData("table-body", "TableBody.razor")]
[InlineData("table-cell", "TableCell.razor")]
[InlineData("table-head", "TableHead.razor")]
[InlineData("table-header", "TableHeader.razor")]
[InlineData("table-row", "TableRow.razor")]
[InlineData("separator", "Separator.razor")]
[InlineData("slider", "Slider.razor")]
[InlineData("navbar", "Navbar.razor")]
[InlineData("checkbox", "Checkbox.razor")]
[InlineData("switch", "Switch.razor")]
[InlineData("tooltip", "Tooltip.razor")]
[InlineData("date-range-picker", "DateRangePicker.razor")]
[InlineData("radio-group", "RadioGroup.razor")]
[InlineData("radio-group-item", "RadioGroupItem.razor")]
[InlineData("empty-state", "EmptyState.razor")]
public void TemplateMarkup_MatchesLiveLibrary(string templateName, string razorFileName)
{
var liveContent = File.ReadAllText(GetLiveRazorPath(razorFileName));
var templateContent = ComponentRegistry.GetComponentContent(templateName)
?? throw new InvalidOperationException($"Template '{templateName}' not found in registry");
var liveContent = WithoutClassNameAlias(File.ReadAllText(GetLiveRazorPath(razorFileName)));
var templateContent = WithoutClassNameAlias(ComponentRegistry.GetComponentContent(templateName)
?? throw new InvalidOperationException($"Template '{templateName}' not found in registry"));

var normalizedLive = Normalize(ExtractMarkup(liveContent));
var normalizedTemplate = Normalize(ExtractMarkup(templateContent));
Expand Down Expand Up @@ -202,6 +234,13 @@ private static string GetLiveRazorPath(string razorFileName, [CallerFilePath] st
return Path.Combine(repoRoot, "src", "ShellUI.Components", "Components", razorFileName);
}

// CLI templates keep the deprecated ClassName alias for existing projects; the package may not have it.
private static string WithoutClassNameAlias(string content)
{
var withoutParameter = Regex.Replace(content, @"^[ \t]*\[Parameter\]\s*public string ClassName \{ get; set; \} = ""\s*"";[ \t]*\r?\n", string.Empty, RegexOptions.Multiline);
return Regex.Replace(withoutParameter, @"\bClassName,\s*", string.Empty);
}

private static string Normalize(string code)
{
var withoutBlock = Regex.Replace(code, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline);
Expand Down
28 changes: 12 additions & 16 deletions src/ShellUI.Templates/Templates/CheckboxTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public static class CheckboxTemplate
aria-checked=""@Checked""
@onclick=""HandleClick""
disabled=""@Disabled""
class=""peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 @(Checked ? ""bg-primary text-primary-foreground"" : ""bg-background"") @ClassName @Class""
class=""@ComputedClass""
@attributes=""AdditionalAttributes"">
@if (Checked)
{
Expand All @@ -34,25 +34,21 @@ public static class CheckboxTemplate
</button>

@code {
[Parameter]
public bool Checked { get; set; }

[Parameter]
public EventCallback<bool> CheckedChanged { get; set; }

[Parameter]
public bool Disabled { get; set; }

[Parameter]
public string? Class { get; set; }

[Parameter] public bool Checked { get; set; }
[Parameter] public EventCallback<bool> CheckedChanged { get; set; }
[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<string, object>? AdditionalAttributes { get; set; }

private string ComputedClass => Shell.Cn(
""peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"",
Checked ? ""bg-primary text-primary-foreground"" : ""bg-background"",
ClassName, Class
);

private async Task HandleClick()
{
if (!Disabled)
Expand Down
57 changes: 24 additions & 33 deletions src/ShellUI.Templates/Templates/DateRangePickerTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ @using Microsoft.JSInterop
@implements IAsyncDisposable
@inject IJSRuntime JS

<div class=""@(""relative "" + ClassName + "" "" + Class)"" @attributes=""AdditionalAttributes"">
<div class=""@Shell.Cn(""relative"", ClassName, Class)"" @attributes=""AdditionalAttributes"">
<button type=""button""
@onclick=""ToggleCalendar""
disabled=""@Disabled""
class=""@(""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 ? ""opacity-50 cursor-not-allowed"" : """"))"">
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 ? ""opacity-50 cursor-not-allowed"" : """")"">
<span class=""@(""flex-1 text-left "" + (StartDate == null && EndDate == null ? ""text-muted-foreground"" : """"))"">
@GetDisplayText()
</span>
Expand Down Expand Up @@ -90,39 +90,24 @@ @inject IJSRuntime JS
}
</div>

@code {
[Parameter]
public DateTime? StartDate { get; set; }

[Parameter]
public EventCallback<DateTime?> StartDateChanged { get; set; }

[Parameter]
public DateTime? EndDate { get; set; }

[Parameter]
public EventCallback<DateTime?> EndDateChanged { get; set; }

[Parameter]
public string Placeholder { get; set; } = ""Pick a date range"";

[Parameter]
public bool AllowClear { get; set; } = true;

[Parameter]
public bool Disabled { get; set; }
@if (_isOpen)
{
<div class=""fixed inset-0 z-40"" @onclick=""CloseBackdrop""></div>
}

@code {
[Parameter] public DateTime? StartDate { get; set; }
[Parameter] public EventCallback<DateTime?> StartDateChanged { get; set; }
[Parameter] public DateTime? EndDate { get; set; }
[Parameter] public EventCallback<DateTime?> EndDateChanged { get; set; }
[Parameter] public string Placeholder { get; set; } = ""Pick a date range"";
[Parameter] public bool AllowClear { get; set; } = true;
[Parameter] public bool Disabled { get; set; }
/// 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<string, object>? AdditionalAttributes { get; set; }

Expand Down Expand Up @@ -253,13 +238,19 @@ private async Task ClearRange()
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;
}

private async Task CloseBackdrop()
{
_isOpen = false;
await UnregisterDismissAsync();
}

public async ValueTask DisposeAsync()
{
await UnregisterDismissAsync();
Expand Down
8 changes: 5 additions & 3 deletions src/ShellUI.Templates/Templates/EmptyStateTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ public static class EmptyStateTemplate
};

public const string Content = """
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="@Shell.Cn("flex flex-col items-center justify-center py-12 text-center", Class)" @attributes="AdditionalAttributes">
@if (!string.IsNullOrEmpty(Icon))
{
<div class="mb-4">
<div class="mb-4 flex items-center justify-center rounded-full bg-muted/30 p-6">
@((MarkupString)Icon)
</div>
}
Expand Down Expand Up @@ -55,7 +55,9 @@ public static class EmptyStateTemplate
[Parameter] public string? Description { get; set; }
[Parameter] public RenderFragment? Action { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public string Class { get; set; } = string.Empty;
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
}
""";
}
Expand Down
14 changes: 4 additions & 10 deletions src/ShellUI.Templates/Templates/NavbarTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,17 @@ public static class NavbarTemplate

public static string Content => @"@namespace YourProjectNamespace.Components.UI

<nav class=""sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 @ClassName @Class"" @attributes=""AdditionalAttributes"">
<nav class=""@Shell.Cn(""sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4"", ClassName, Class)"" @attributes=""AdditionalAttributes"">
<div class=""container flex h-14 items-center"">
@ChildContent
</div>
</nav>

@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }

[Parameter]
public string? Class { get; set; }

[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<string, object>? AdditionalAttributes { get; set; }
}
Expand Down
28 changes: 10 additions & 18 deletions src/ShellUI.Templates/Templates/RadioGroupItemTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ public class RadioGroupItemTemplate

public static string Content => @"@namespace YourProjectNamespace.Components.UI

<div class=""flex items-center space-x-2 @ClassName @Class"" @attributes=""AdditionalAttributes"">
<div class=""@Shell.Cn(""flex items-center space-x-2 py-1"", ClassName, Class)"" @attributes=""AdditionalAttributes"">
<button type=""button""
@onclick=""OnClick""
role=""radio""
aria-checked=""@IsChecked""
class=""aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 @(IsChecked ? ""bg-primary"" : ""bg-background"")"">
disabled=""@Disabled""
class=""@Shell.Cn(""aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"", IsChecked ? ""bg-primary"" : ""bg-background"")"">
@if (IsChecked)
{
<span class=""flex items-center justify-center"">
Expand All @@ -41,30 +42,21 @@ public class RadioGroupItemTemplate
</div>

@code {
[CascadingParameter]
private RadioGroup? RadioGroup { get; set; }

[Parameter]
public string Value { get; set; } = """";

[Parameter]
public RenderFragment? ChildContent { get; set; }

[Parameter]
public string? Class { get; set; }

[CascadingParameter] private RadioGroup? RadioGroup { get; set; }
[Parameter] public string Value { get; set; } = """";
[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] public bool Disabled { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }

private bool IsChecked => RadioGroup?.Value == Value;

private async Task OnClick()
{
if (RadioGroup != null)
if (RadioGroup != null && !Disabled)
{
await RadioGroup.SetValue(Value);
}
Expand Down
27 changes: 10 additions & 17 deletions src/ShellUI.Templates/Templates/RadioGroupTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,19 @@ public class RadioGroupTemplate

public static string Content => @"@namespace YourProjectNamespace.Components.UI

<div class=""@ClassName @Class"" @attributes=""AdditionalAttributes"">
@ChildContent
<div class=""@Shell.Cn(ClassName, Class)"" @attributes=""AdditionalAttributes"">
<CascadingValue Value=""this"">
@ChildContent
</CascadingValue>
</div>

@code {
[Parameter]
public string? Value { get; set; }

[Parameter]
public EventCallback<string> ValueChanged { get; set; }

[Parameter]
public RenderFragment? ChildContent { get; set; }

[Parameter]
public string? Class { get; set; }

[Parameter] public string? Value { get; set; }
[Parameter] public EventCallback<string> ValueChanged { get; set; }
[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<string, object>? AdditionalAttributes { get; set; }

Expand All @@ -48,6 +40,7 @@ public async Task SetValue(string value)
{
Value = value;
await ValueChanged.InvokeAsync(value);
StateHasChanged();
}
}
}
Expand Down
Loading
Loading