Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5b96cbf
ci: publish to NuGet with Trusted Publishing
Oct 20, 2026
c4ef1ad
fix: keep local changes and install new dependencies on update
Oct 13, 2026
dbf129e
fix: find files for remove the way install writes them
Oct 13, 2026
1fc4126
fix: never downgrade a package the project already references
Oct 14, 2026
36b8b43
fix: use one Tailwind CLI per machine and survive slow downloads
Oct 13, 2026
ad8054f
docs: drop a layout that 0.3 does not have from the remove notes
Oct 20, 2026
ffadfd8
fix: support Razor class libraries in init and document the setup
Oct 20, 2026
55f94d3
fix: let a component's Class override its own conflicting classes
Oct 14, 2026
005dd40
fix: trap focus inside Dialog, AlertDialog, Sheet and Drawer
Shewart Oct 17, 2026
b51ffb9
fix: focus the legacy Sheet and Drawer panels, trap focus and close o…
Shewart Oct 17, 2026
1bc643c
test: cover the overlay focus trap
Shewart Oct 17, 2026
9daaac8
fix: pass the RadioGroup template to its items so clicking an item se…
Shewart Oct 18, 2026
5b50db2
fix: add Disabled to the RadioGroupItem template and close the DateRa…
Shewart Oct 18, 2026
d0625d7
fix: apply Class and extra attributes in the EmptyState template
Shewart Oct 18, 2026
e89ffb6
fix: scroll the inner element in ScrollArea
Shewart Oct 18, 2026
bb7d5e0
fix: place the first of the month under its weekday in DatePicker
Shewart Oct 18, 2026
dc342e8
fix: merge the TimePicker package component and template
Shewart Oct 18, 2026
51c475a
fix: render Label's For parameter as the for attribute
Shewart Oct 12, 2026
f9fae3f
fix: use an inline SVG for the FileUpload icon instead of the Materia…
Shewart Oct 12, 2026
ae7c08c
fix: stop clicks inside Sheet and Drawer panels reaching parent click…
Shewart Oct 12, 2026
0a4ac2a
fix: show the Sheet close button when only Description is set
Shewart Oct 12, 2026
4a53888
fix: guard the CarouselItem template against zero slides
Shewart Oct 12, 2026
9163719
chore: regenerate the component safelist
Oct 20, 2026
fb3cb0c
chore(release): v0.3.5
Oct 20, 2026
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
25 changes: 18 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ on:
tags:
- 'v*'

permissions:
contents: write

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
Expand All @@ -16,6 +13,11 @@ jobs:
publish:
runs-on: ubuntu-latest
if: github.event.repository.fork == false
# Must match the environment in the nuget.org Trusted Publishing policy (see docs/RELEASING.md).
environment: release
permissions:
contents: write # GitHub Release
id-token: write # OIDC token exchanged for a short-lived NuGet key

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -74,16 +76,25 @@ jobs:
- name: Pack NuGet packages
run: dotnet pack ShellUI.slnx --no-build --configuration Release -p:ContinuousIntegrationBuild=true

# Trusted Publishing: no stored key; the key this returns expires after an hour.
- name: NuGet login
id: nuget-login
uses: NuGet/login@v1
with:
user: ${{ secrets.NUGET_USER }}

# Core and Templates are internal; only the CLI and Components are published.
- name: Publish to NuGet
env:
NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }}
run: |
dotnet nuget push src/ShellUI.CLI/bin/Release/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
dotnet nuget push src/ShellUI.CLI/bin/Release/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
if ls src/ShellUI.CLI/bin/Release/*.snupkg 1> /dev/null 2>&1; then
dotnet nuget push src/ShellUI.CLI/bin/Release/*.snupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
dotnet nuget push src/ShellUI.CLI/bin/Release/*.snupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
fi
dotnet nuget push src/ShellUI.Components/bin/Release/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
dotnet nuget push src/ShellUI.Components/bin/Release/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
if ls src/ShellUI.Components/bin/Release/*.snupkg 1> /dev/null 2>&1; then
dotnet nuget push src/ShellUI.Components/bin/Release/*.snupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
dotnet nuget push src/ShellUI.Components/bin/Release/*.snupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
fi

- name: Create GitHub Release
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<Project>
<!-- One version for every ShellUI package -->
<PropertyGroup>
<ShellUIVersion>0.3.4</ShellUIVersion>
<ShellUIVersion>0.3.5</ShellUIVersion>
<ShellUIVersionSuffix></ShellUIVersionSuffix>
</PropertyGroup>

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

| Channel | Version | Notes |
|---|---|---|
| Latest stable (recommended) | `0.3.4` | Tailwind CSS `4.3.2` |
| Latest stable (recommended) | `0.3.5` | Tailwind CSS `4.3.2` |
| Previous stable | `0.2.1` | Superseded by `0.3.0` |

The CLI needs the .NET 10 SDK and works in Blazor projects on .NET 8, 9 and 10. The `ShellUI.Components` NuGet package targets .NET 10 only; on .NET 8 or 9, use the CLI.
Expand Down Expand Up @@ -171,6 +171,7 @@ ShellUI uses semantic Blazor and Tailwind patterns, but accessibility depends on
- [Quick start](docs/QUICKSTART.md)
- [CLI syntax](docs/CLI_SYNTAX.md)
- [Tailwind setup](docs/tailwind-setup.md)
- [Using ShellUI from a Razor class library](docs/razor-class-library.md)
- [Contributing](docs/CONTRIBUTING.md)
- [Release notes](docs/RELEASE_NOTES.md)

Expand Down
23 changes: 23 additions & 0 deletions ShellUI.Tests/Fixtures/Targets/ShellUI-0.3.4.targets.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<ShellUIBinPath Condition="'$(ShellUIBinPath)' == ''">$(MSBuildProjectDirectory)\.shellui\bin</ShellUIBinPath>
<TailwindExecutable Condition="'$(OS)' == 'Windows_NT'">$(ShellUIBinPath)\tailwindcss.exe</TailwindExecutable>
<TailwindExecutable Condition="'$(OS)' != 'Windows_NT'">$(ShellUIBinPath)/tailwindcss</TailwindExecutable>
<TailwindInputCss Condition="'$(TailwindInputCss)' == ''">$(MSBuildProjectDirectory)\wwwroot\input.css</TailwindInputCss>
<TailwindOutputCss Condition="'$(TailwindOutputCss)' == ''">$(MSBuildProjectDirectory)\wwwroot\app.css</TailwindOutputCss>
<TailwindMinify Condition="'$(Configuration)' == 'Release'">--minify</TailwindMinify>
<TailwindMinify Condition="'$(Configuration)' != 'Release'"></TailwindMinify>
</PropertyGroup>

<Target Name="BuildTailwindCSS" BeforeTargets="BeforeBuild" Condition="Exists('$(TailwindExecutable)') AND Exists('$(TailwindInputCss)')">
<Message Importance="high" Text="Building Tailwind CSS..." />
<Exec Command="&quot;$(TailwindExecutable)&quot; -i &quot;$(TailwindInputCss)&quot; -o &quot;$(TailwindOutputCss)&quot; $(TailwindMinify)" />
<Message Importance="high" Text="Tailwind CSS built successfully!" />
</Target>

<Target Name="CleanTailwindCSS" AfterTargets="Clean" Condition="Exists('$(TailwindOutputCss)')">
<Message Importance="high" Text="Cleaning Tailwind CSS output..." />
<Delete Files="$(TailwindOutputCss)" />
</Target>
</Project>
38 changes: 38 additions & 0 deletions ShellUI.Tests/Fixtures/Targets/ShellUI-0.4.0-alpha.1.targets.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<ShellUIBinPath Condition="'$(ShellUIBinPath)' == ''">$(MSBuildProjectDirectory)\.shellui\bin</ShellUIBinPath>
<TailwindExecutable Condition="'$(OS)' == 'Windows_NT'">$(ShellUIBinPath)\tailwindcss.exe</TailwindExecutable>
<TailwindExecutable Condition="'$(OS)' != 'Windows_NT'">$(ShellUIBinPath)/tailwindcss</TailwindExecutable>
<TailwindInputCss Condition="'$(TailwindInputCss)' == ''">$(MSBuildProjectDirectory)\wwwroot\input.css</TailwindInputCss>
<TailwindOutputCss Condition="'$(TailwindOutputCss)' == ''">$(MSBuildProjectDirectory)\wwwroot\app.css</TailwindOutputCss>
<TailwindMinify Condition="'$(Configuration)' == 'Release'">--minify</TailwindMinify>
<TailwindMinify Condition="'$(Configuration)' != 'Release'"></TailwindMinify>
<TailwindTag>v4.3.2</TailwindTag>
<TailwindArch>$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant())</TailwindArch>
<TailwindPlatform Condition="$([MSBuild]::IsOSPlatform('Windows'))">windows-$(TailwindArch).exe</TailwindPlatform>
<TailwindPlatform Condition="$([MSBuild]::IsOSPlatform('Linux'))">linux-$(TailwindArch)</TailwindPlatform>
<TailwindPlatform Condition="$([MSBuild]::IsOSPlatform('OSX'))">macos-$(TailwindArch)</TailwindPlatform>
</PropertyGroup>

<Target Name="DownloadTailwindCLI" BeforeTargets="BeforeBuild" Condition="!Exists('$(TailwindExecutable)') AND Exists('$(TailwindInputCss)') AND '$(TailwindPlatform)' != ''">
<Message Importance="high" Text="Downloading the Tailwind CLI $(TailwindTag)..." />
<DownloadFile SourceUrl="https://github.com/tailwindlabs/tailwindcss/releases/download/$(TailwindTag)/tailwindcss-$(TailwindPlatform)" DestinationFolder="$(ShellUIBinPath)" DestinationFileName="$([System.IO.Path]::GetFileName('$(TailwindExecutable)'))" ContinueOnError="true" />
<Exec Command="chmod +x &quot;$(TailwindExecutable)&quot;" Condition="'$(OS)' != 'Windows_NT' AND Exists('$(TailwindExecutable)')" />
</Target>

<Target Name="WarnMissingTailwindCLI" BeforeTargets="BeforeBuild" Condition="!Exists('$(TailwindExecutable)') AND Exists('$(TailwindInputCss)')">
<Warning Text="ShellUI: the Tailwind CLI is missing from $(ShellUIBinPath) and could not be downloaded, so wwwroot/app.css was not rebuilt." />
</Target>

<Target Name="BuildTailwindCSS" BeforeTargets="BeforeBuild" Condition="Exists('$(TailwindExecutable)') AND Exists('$(TailwindInputCss)')">
<Message Importance="high" Text="Building Tailwind CSS..." />
<Exec Command="&quot;$(TailwindExecutable)&quot; -i &quot;$(TailwindInputCss)&quot; -o &quot;$(TailwindOutputCss)&quot; $(TailwindMinify)" />
<Message Importance="high" Text="Tailwind CSS built successfully!" />
</Target>

<Target Name="CleanTailwindCSS" AfterTargets="Clean" Condition="Exists('$(TailwindOutputCss)')">
<Message Importance="high" Text="Cleaning Tailwind CSS output..." />
<Delete Files="$(TailwindOutputCss)" />
</Target>
</Project>
53 changes: 53 additions & 0 deletions ShellUI.Tests/PackageBehaviorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,59 @@ public void Dropdown_ClosesOnEscapeButNotOnOtherKeys()
Assert.Equal(new[] { false }, changes);
}

[Fact]
public void DialogContent_TrapsFocusWhileOpenAndReleasesItOnClose()
{
using var ctx = new BunitContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;

var dialog = ctx.Render<Dialog>(p => p
.Add(c => c.Open, true)
.AddChildContent<DialogContent>(c => c.AddChildContent("Body")));

var trap = ctx.JSInterop.VerifyInvoke("ShellUI.trapFocus");
ctx.JSInterop.VerifyNotInvoke("ShellUI.releaseFocus");

dialog.Render(p => p.Add(c => c.Open, false));

var release = ctx.JSInterop.VerifyInvoke("ShellUI.releaseFocus");
Assert.Equal(trap.Arguments[0], release.Arguments[0]);
}

[Fact]
public void SheetContent_TrapsFocusWhileOpen()
{
using var ctx = new BunitContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;

ctx.Render<Sheet>(p => p
.Add(c => c.Open, true)
.AddChildContent<SheetContent>(c => c.AddChildContent("Body")));

ctx.JSInterop.VerifyInvoke("ShellUI.trapFocus");
}

[Fact]
public void LegacySheet_TrapsFocusAndReleasesItWhenEscapeCloses()
{
using var ctx = new BunitContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
bool? open = null;

var sheet = ctx.Render<Sheet>(p => p
.Add(c => c.Open, true)
.Add(c => c.Title, "Settings")
.Add(c => c.OpenChanged, EventCallback.Factory.Create<bool>(this, v => open = v))
.AddChildContent("Body"));

ctx.JSInterop.VerifyInvoke("ShellUI.trapFocus");

sheet.Find("[role=dialog]").KeyDown(new KeyboardEventArgs { Key = "Escape" });

Assert.False(open);
ctx.JSInterop.VerifyInvoke("ShellUI.releaseFocus");
}

[Fact]
public void TimePicker_ShowsASetValue()
{
Expand Down
74 changes: 74 additions & 0 deletions ShellUI.Tests/PackageVersionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using ShellUI.CLI.Services;
using Xunit;

namespace ShellUI.Tests;

public class PackageVersionTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), $"ShellUIPackages_{Guid.NewGuid():N}");

public PackageVersionTests() => Directory.CreateDirectory(_dir);

public void Dispose() => Directory.Delete(_dir, true);

[Theory]
[InlineData("7.0.0", "6.0.2", true)]
[InlineData("6.0.2", "6.0.2", true)]
[InlineData("6.1", "6.0.2", true)]
[InlineData("6.0.1", "6.0.2", false)]
[InlineData("5.9.9", "6.0.2", false)]
[InlineData("6.0.2-beta.1", "6.0.2", false)]
[InlineData("6.0.2", "6.0.2-beta.1", true)]
[InlineData("6.0.2-rc.1", "6.0.2-beta.1", true)]
[InlineData("7.*", "6.0.2", true)]
[InlineData("[6.0,8.0)", "6.0.2", true)]
[InlineData("$(ApexVersion)", "6.0.2", true)]
[InlineData("", "6.0.2", true)]
public void SatisfiesMinimum_OnlyAddsOrRaises(string existing, string minimum, bool satisfied) =>
Assert.Equal(satisfied, ComponentInstaller.SatisfiesMinimum(existing, minimum));

[Fact]
public void ReadsTheVersionAttribute_CaseInsensitively()
{
var project = Project(@"<PackageReference Include=""blazor-apexcharts"" Version=""7.0.0"" />");

Assert.Equal("7.0.0", ComponentInstaller.ExistingPackageVersion(project, "Blazor-ApexCharts"));
Assert.Null(ComponentInstaller.ExistingPackageVersion(project, "QRCoder"));
}

[Fact]
public void ReadsAVersionElement()
{
var project = Project(@"<PackageReference Include=""QRCoder""><Version>1.9.0</Version></PackageReference>");

Assert.Equal("1.9.0", ComponentInstaller.ExistingPackageVersion(project, "QRCoder"));
}

[Fact]
public void ReadsCentralPackageManagement()
{
File.WriteAllText(Path.Combine(_dir, "Directory.Packages.props"),
@"<Project><ItemGroup><PackageVersion Include=""Blazor-ApexCharts"" Version=""7.1.0"" /></ItemGroup></Project>");
var project = Project(@"<PackageReference Include=""Blazor-ApexCharts"" />", subfolder: "src");

Assert.Equal("7.1.0", ComponentInstaller.ExistingPackageVersion(project, "Blazor-ApexCharts"));
}

[Fact]
public void ReferenceWithoutAFindableVersion_IsKeptAsIs()
{
var project = Project(@"<PackageReference Include=""QRCoder"" />");

Assert.Equal("", ComponentInstaller.ExistingPackageVersion(project, "QRCoder"));
Assert.True(ComponentInstaller.SatisfiesMinimum("", "1.8.0"));
}

private string Project(string references, string? subfolder = null)
{
var dir = subfolder == null ? _dir : Path.Combine(_dir, subfolder);
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "App.csproj");
File.WriteAllText(path, $@"<Project Sdk=""Microsoft.NET.Sdk.Web""><ItemGroup>{references}</ItemGroup></Project>");
return path;
}
}
78 changes: 78 additions & 0 deletions ShellUI.Tests/RazorClassLibraryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using ShellUI.CLI.Services;
using Xunit;

namespace ShellUI.Tests;

public class RazorClassLibraryTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), $"ShellUIRcl_{Guid.NewGuid():N}");

public RazorClassLibraryTests() => Directory.CreateDirectory(_dir);

public void Dispose() => Directory.Delete(_dir, true);

[Fact]
public void RazorClassLibrary_IsServedUnderItsName()
{
var project = Project("MyApp.UI", "Microsoft.NET.Sdk.Razor");

Assert.Equal("_content/MyApp.UI", InitService.RazorClassLibraryAssetBase(project));
}

[Theory]
[InlineData("<PackageId>Acme.Ui</PackageId>", "_content/Acme.Ui")]
[InlineData("<AssemblyName>Acme.Components</AssemblyName>", "_content/Acme.Components")]
[InlineData("<StaticWebAssetBasePath>/ui</StaticWebAssetBasePath>", "ui")]
public void RazorClassLibrary_HonoursItsAssetName(string property, string expected)
{
var project = Project("MyApp.UI", "Microsoft.NET.Sdk.Razor", property);

Assert.Equal(expected, InitService.RazorClassLibraryAssetBase(project));
}

[Fact]
public void WebAppsAreNotLibraries()
{
Assert.Null(InitService.RazorClassLibraryAssetBase(Project("MyApp.Web", "Microsoft.NET.Sdk.Web")));

var razorSdkWithProgram = Project("Hosted", "Microsoft.NET.Sdk.Razor");
File.WriteAllText(Path.Combine(Path.GetDirectoryName(razorSdkWithProgram)!, "Program.cs"), "");
Assert.Null(InitService.RazorClassLibraryAssetBase(razorSdkWithProgram));
}

[Fact]
public void HostSteps_UseTheLibrarysAssetPath()
{
var steps = InitService.RazorClassLibraryHostSteps("_content/MyApp.UI");

Assert.Contains(steps, s => s.Contains("_content/MyApp.UI/app.css") && s.Contains("_content/MyApp.UI/shellui.js"));
Assert.Contains(steps, s => s.Contains("@source"));
Assert.Contains(steps, s => s.Contains("prerender: false"));
Assert.Contains(steps, s => s.Contains(InitService.RazorClassLibraryGuide));
}

[Fact]
public void Guide_ShowsTheThemeScriptInitWrites()
{
var root = new DirectoryInfo(AppContext.BaseDirectory);
while (!File.Exists(Path.Combine(root.FullName, "ShellUI.slnx"))) root = root.Parent!;
var guide = File.ReadAllText(Path.Combine(root.FullName, "docs", "razor-class-library.md")).Replace("\r\n", "\n");
var appRazor = InitService.RewriteAppRazor("<html><head>\n</head><body></body></html>").Replace("\r\n", "\n");

var start = appRazor.IndexOf("// ShellUI theme bootstrap", StringComparison.Ordinal);
var script = appRazor[start..appRazor.IndexOf("</script>", start, StringComparison.Ordinal)];
Assert.Contains(Unindent(script), Unindent(guide));
}

private static string Unindent(string text) =>
string.Join("\n", text.Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0));

private string Project(string name, string sdk, string properties = "")
{
var dir = Path.Combine(_dir, name);
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, name + ".csproj");
File.WriteAllText(path, $@"<Project Sdk=""{sdk}""><PropertyGroup>{properties}</PropertyGroup></Project>");
return path;
}
}
Loading
Loading