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
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
{
try { await JSRuntime.InvokeVoidAsync("ShellUI.disposeSidebar", _handle); }
catch (JSException) { }
catch (JSDisconnectedException) { }
}

_dotnetRef?.Dispose();
Expand Down
17 changes: 17 additions & 0 deletions ShellUI.Tests/InitBootstrapTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,23 @@ public void RewriteAppRazor_IsIdempotent()
Assert.Equal(once, twice);
}

[Theory]
[InlineData("standalone")]
[InlineData("npm")]
public void TargetsFile_IsValidMsBuildXml(string method)
{
var targets = InitService.GetTargetsFileContent(method);

var xml = System.Xml.Linq.XDocument.Parse(targets);
Assert.Equal("Project", xml.Root!.Name.LocalName);
if (method == "standalone")
{
Assert.Contains($"<TailwindTag>{ShellUI.Core.TailwindConstants.GitHubTag}</TailwindTag>", targets);
Assert.Contains("<DownloadFile ", targets);
Assert.Contains("WarnMissingTailwindCLI", targets);
}
}

[Fact]
public void RewriteAppRazor_KeepsIdentityPagesStatic()
{
Expand Down
65 changes: 65 additions & 0 deletions ShellUI.Tests/PackageBehaviorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using ShellUI.Components;
using ShellUI.Components.Models;

namespace ShellUI.Tests;

public class PackageBehaviorTests
{
[Fact]
public void Command_RunsTheItemActionThenRaisesCommandSelected()
{
using var ctx = new BunitContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
var calls = new List<string>();
var item = new CommandItem { Title = "Go home", Action = () => { calls.Add("action"); return Task.CompletedTask; } };

var command = ctx.Render<Command>(p => p
.Add(c => c.IsOpen, true)
.Add(c => c.Commands, new List<CommandItem> { item })
.Add(c => c.CommandSelected, EventCallback.Factory.Create<CommandItem>(this, _ => calls.Add("selected"))));

command.Find("[role=option]").Click();

Assert.Equal(new[] { "action", "selected" }, calls);
}

[Fact]
public void Popover_ClosesOnEscape()
{
using var ctx = new BunitContext();
bool? open = null;

var popover = ctx.Render<Popover>(p => p
.Add(c => c.IsOpen, true)
.Add(c => c.IsOpenChanged, EventCallback.Factory.Create<bool>(this, v => open = v))
.Add(c => c.Trigger, "Open")
.Add(c => c.ChildContent, "Body"));

popover.Find("div.relative").KeyDown(new KeyboardEventArgs { Key = "Escape" });

Assert.False(open);
Assert.DoesNotContain("Body", popover.Markup);
}

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

var dropdown = ctx.Render<Dropdown>(p => p
.Add(c => c.IsOpen, true)
.Add(c => c.IsOpenChanged, EventCallback.Factory.Create<bool>(this, v => changes.Add(v)))
.Add(c => c.Trigger, "Menu")
.Add(c => c.ChildContent, "Item"));

dropdown.Find("div.relative").KeyDown(new KeyboardEventArgs { Key = "a" });
Assert.Empty(changes);

dropdown.Find("div.relative").KeyDown(new KeyboardEventArgs { Key = "Escape" });
Assert.Equal(new[] { false }, changes);
}
}
2 changes: 1 addition & 1 deletion docs/CLI_SYNTAX.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Options:

- `--force` reinitializes a project that already has `shellui.json`.
- `--style <style>` selects `default`, `new-york`, or `minimal`.
- `--tailwind standalone|npm` selects the Tailwind setup method.
- `--tailwind standalone|npm` selects the Tailwind setup method and skips the prompt. With `--yes` and no `--tailwind`, `init` uses standalone.
- `--yes` runs without prompts and uses the selected defaults. Without an explicit method, the default is `standalone`.
- `--dashboard 01|02|none` sets up a dashboard layout: `02` has a sticky header, `01` a scrolling one. Without the option, `init` asks; with `--yes` alone, no dashboard is added.
- `--replace-layout` makes the dashboard the default layout even when the app already uses a custom layout. See [Dashboard layouts](#dashboard-layouts).
Expand Down
8 changes: 3 additions & 5 deletions src/ShellUI.CLI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ static Command CreateThemeInitCommand()
var forceOpt = new Option<bool>("--force", "Reinitialize even if already initialized");
var styleOpt = new Option<string>("--style", getDefaultValue: () => "default",
"Component style: default, new-york, minimal");
var tailwindOpt = new Option<string>("--tailwind", getDefaultValue: () => "standalone",
"Tailwind method: standalone, npm");
var tailwindOpt = new Option<string?>("--tailwind", "Tailwind method: standalone (default with --yes) or npm");
var yesOpt = new Option<bool>("--yes", "Non-interactive mode with default options");
var dashboardOpt = CreateDashboardOption();
var replaceLayoutOpt = CreateReplaceLayoutOption();
Expand Down Expand Up @@ -188,10 +187,9 @@ static Command CreateInitCommand()
"--style",
getDefaultValue: () => "default",
"Choose component style (default, new-york, minimal)");
var tailwindOption = new Option<string>(
var tailwindOption = new Option<string?>(
"--tailwind",
getDefaultValue: () => "standalone",
"Choose Tailwind method (standalone, npm)");
"Choose Tailwind method: standalone (default with --yes) or npm");
var nonInteractiveOption = new Option<bool>(
"--yes",
"Run in non-interactive mode with default options");
Expand Down
5 changes: 3 additions & 2 deletions src/ShellUI.CLI/Services/ComponentInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,13 @@ public static Task<bool> InstallComponentForInitAsync(
return Task.FromResult(result != InstallResult.Failed);
}

public static void InstallComponent(string componentName, ComponentMetadata metadata, bool force, bool skipConfig = false)
public static bool InstallComponent(string componentName, ComponentMetadata metadata, bool force, bool skipConfig = false)
{
var configPath = Path.Combine(Directory.GetCurrentDirectory(), "shellui.json");
var configJson = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize<ShellUIConfig>(configJson);

if (config == null) return;
if (config == null) return false;

var projectInfo = ProjectDetector.DetectProject();
var result = InstallComponentInternal(componentName, config, projectInfo, force);
Expand All @@ -159,6 +159,7 @@ public static void InstallComponent(string componentName, ComponentMetadata meta
var updatedJson = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(configPath, updatedJson);
}
return result != InstallResult.Failed;
}

public static bool EnsureShellUiJs()
Expand Down
25 changes: 22 additions & 3 deletions src/ShellUI.CLI/Services/ComponentManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,10 @@ public static void UpdateComponents(string[] componentNames, bool updateAll)
AnsiConsole.Write(new Rule("[blue]Updating Components[/]").RuleStyle("blue dim"));
AnsiConsole.WriteLine();

var updated = 0;
var skipped = 0;
var failed = new List<string>();

Loaders.SnakeStatus().Start("Updating components...", ctx =>
{
foreach (var componentName in toUpdate)
Expand All @@ -197,12 +201,14 @@ public static void UpdateComponents(string[] componentNames, bool updateAll)
if (!ComponentRegistry.Components.TryGetValue(normalizedName, out var metadata))
{
AnsiConsole.MarkupLine($"[yellow]Warning:[/] Unknown component '{componentName}'");
skipped++;
continue;
}

if (!installed.Contains(normalizedName))
{
AnsiConsole.MarkupLine($"[yellow]Skipped:[/] Component '{metadata.DisplayName}' is not installed");
skipped++;
continue;
}

Expand All @@ -211,16 +217,29 @@ public static void UpdateComponents(string[] componentNames, bool updateAll)
!ComponentInstaller.EnsureShellUiJs())
{
AnsiConsole.MarkupLine($"[yellow]Skipped:[/] Component '{metadata.DisplayName}' requires a compatible wwwroot/shellui.js.");
skipped++;
continue;
}

ComponentInstaller.InstallComponent(normalizedName, metadata, force: true, skipConfig: false);
AnsiConsole.MarkupLine($"[green]Updated:[/] {metadata.DisplayName} to v{metadata.Version}");
if (ComponentInstaller.InstallComponent(normalizedName, metadata, force: true, skipConfig: false))
{
AnsiConsole.MarkupLine($"[green]Updated:[/] {metadata.DisplayName} to v{metadata.Version}");
updated++;
}
else
{
failed.Add(metadata.DisplayName);
}
}
});

AnsiConsole.WriteLine();
AnsiConsole.MarkupLine($"[green]Successfully updated {toUpdate.Length} component(s)[/]");
if (updated > 0)
AnsiConsole.MarkupLine($"[green]Updated {updated} component(s)[/]");
if (skipped > 0)
AnsiConsole.MarkupLine($"[yellow]Skipped {skipped} component(s)[/]");
if (failed.Count > 0)
AnsiConsole.MarkupLine($"[red]Failed: {Markup.Escape(string.Join(", ", failed))}[/]");
}

private static void UpdateConfig(List<string> installedComponentNames)
Expand Down
35 changes: 29 additions & 6 deletions src/ShellUI.CLI/Services/InitService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ namespace ShellUI.CLI.Services;

public class InitService
{
public static async Task InitializeAsync(string style, bool force, string tailwindMethod = "standalone", bool nonInteractive = false,
public static async Task InitializeAsync(string style, bool force, string? tailwindMethod = null, bool nonInteractive = false,
string? dashboard = null, LayoutSwitch layoutSwitch = LayoutSwitch.Ask)
{
var configPath = Path.Combine(Directory.GetCurrentDirectory(), "shellui.json");
var dashboardChoice = DashboardSetup.ParseDashboardOption(dashboard) ?? (nonInteractive ? "none" : null);
var tailwindChoice = tailwindMethod?.Trim().ToLowerInvariant();
if (tailwindChoice is not (null or "standalone" or "npm"))
throw new ArgumentException($"Unknown --tailwind value '{tailwindMethod}'. Use standalone or npm.");

if (File.Exists(configPath) && !force)
{
Expand All @@ -31,10 +34,15 @@ public static async Task InitializeAsync(string style, bool force, string tailwi
AnsiConsole.MarkupLine("[cyan]Setting up Tailwind CSS...[/]");
string method;

if (nonInteractive)
if (tailwindChoice != null)
{
method = tailwindMethod;
AnsiConsole.MarkupLine($"[green]✅ Selected:[/] {method} (non-interactive mode)");
method = tailwindChoice;
AnsiConsole.MarkupLine($"[green]✅ Tailwind:[/] {method}");
}
else if (nonInteractive)
{
method = "standalone";
AnsiConsole.MarkupLine("[green]✅ Tailwind:[/] standalone [dim](default with --yes; pass --tailwind npm to use npm)[/]");
}
else
{
Expand Down Expand Up @@ -356,7 +364,7 @@ private static async Task RunTailwindBuildAsync(string tailwindPath, string inpu
}
}

private static string GetTargetsFileContent(string method)
internal static string GetTargetsFileContent(string method)
{
if (method == "npm")
{
Expand Down Expand Up @@ -394,8 +402,23 @@ private static string GetTargetsFileContent(string method)
<TailwindOutputCss Condition=""'$(TailwindOutputCss)' == ''"">$(MSBuildProjectDirectory)\wwwroot\app.css</TailwindOutputCss>
<TailwindMinify Condition=""'$(Configuration)' == 'Release'"">--minify</TailwindMinify>
<TailwindMinify Condition=""'$(Configuration)' != 'Release'""></TailwindMinify>
<TailwindTag>__TAILWIND_TAG__</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)"" />
Expand All @@ -406,7 +429,7 @@ private static string GetTargetsFileContent(string method)
<Message Importance=""high"" Text=""Cleaning Tailwind CSS output..."" />
<Delete Files=""$(TailwindOutputCss)"" />
</Target>
</Project>";
</Project>".Replace("__TAILWIND_TAG__", TailwindConstants.GitHubTag);
}
}

Expand Down
1 change: 1 addition & 0 deletions src/ShellUI.Components/Components/Command.razor
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ else if (IsOpen)
private async Task SelectAsync(CommandItem command)
{
await CloseAsync();
if (command.Action is not null) await command.Action();
await CommandSelected.InvokeAsync(command);
}

Expand Down
7 changes: 6 additions & 1 deletion src/ShellUI.Components/Components/Dropdown.razor
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
@namespace ShellUI.Components

<CascadingValue Value="this" IsFixed="true">
<div class="@Shell.Cn("relative inline-block text-left", ClassName, Class)" @attributes="AdditionalAttributes">
<div class="@Shell.Cn("relative inline-block text-left", ClassName, Class)" @attributes="AdditionalAttributes" @onkeydown="OnKeyDown">
@if (UseCompositional)
{
@ChildContent
Expand Down Expand Up @@ -58,4 +58,9 @@
{
await ToggleAsync();
}

private async Task OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape" && IsOpen) await CloseAsync();
}
}
7 changes: 6 additions & 1 deletion src/ShellUI.Components/Components/Popover.razor
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
@namespace ShellUI.Components

<CascadingValue Value="this" IsFixed="true">
<div class="@Shell.Cn("relative inline-block", ClassName, Class)" @attributes="AdditionalAttributes">
<div class="@Shell.Cn("relative inline-block", ClassName, Class)" @attributes="AdditionalAttributes" @onkeydown="OnKeyDown">
@if (Trigger is null)
{
@ChildContent
Expand Down Expand Up @@ -45,4 +45,9 @@
public async Task CloseAsync() { IsOpen = false; await IsOpenChanged.InvokeAsync(IsOpen); StateHasChanged(); }
private async Task Toggle() => await ToggleAsync();
private async Task Close() => await CloseAsync();

private async Task OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape" && IsOpen) await CloseAsync();
}
}
1 change: 1 addition & 0 deletions src/ShellUI.Templates/Templates/CommandTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ private async Task OnKeyDownAsync(KeyboardEventArgs e)
private async Task SelectAsync(CommandItem command)
{
await CloseAsync();
if (command.Action is not null) await command.Action();
await CommandSelected.InvokeAsync(command);
}

Expand Down
7 changes: 6 additions & 1 deletion src/ShellUI.Templates/Templates/DropdownTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public static class DropdownTemplate
public static string Content => @"@namespace YourProjectNamespace.Components.UI

<CascadingValue Value=""this"" IsFixed=""true"">
<div class=""@Shell.Cn(""relative inline-block text-left"", ClassName, Class)"" @attributes=""AdditionalAttributes"">
<div class=""@Shell.Cn(""relative inline-block text-left"", ClassName, Class)"" @attributes=""AdditionalAttributes"" @onkeydown=""OnKeyDown"">
@if (UseCompositional)
{
@ChildContent
Expand Down Expand Up @@ -75,6 +75,11 @@ private async Task ToggleOpen()
{
await ToggleAsync();
}

private async Task OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == ""Escape"" && IsOpen) await CloseAsync();
}
}
";
}
Expand Down
7 changes: 6 additions & 1 deletion src/ShellUI.Templates/Templates/PopoverTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public class PopoverTemplate
public static string Content => @"@namespace YourProjectNamespace.Components.UI

<CascadingValue Value=""this"" IsFixed=""true"">
<div class=""@Shell.Cn(""relative inline-block"", ClassName, Class)"" @attributes=""AdditionalAttributes"">
<div class=""@Shell.Cn(""relative inline-block"", ClassName, Class)"" @attributes=""AdditionalAttributes"" @onkeydown=""OnKeyDown"">
@if (Trigger is null)
{
@ChildContent
Expand Down Expand Up @@ -63,6 +63,11 @@ public class PopoverTemplate
public async Task CloseAsync() { IsOpen = false; await IsOpenChanged.InvokeAsync(IsOpen); StateHasChanged(); }
private async Task Toggle() => await ToggleAsync();
private async Task Close() => await CloseAsync();

private async Task OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == ""Escape"" && IsOpen) await CloseAsync();
}
}
";
}
Expand Down
1 change: 1 addition & 0 deletions src/ShellUI.Templates/Templates/SidebarProviderTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ public async ValueTask DisposeAsync()
{
try { await JSRuntime.InvokeVoidAsync(""ShellUI.disposeSidebar"", _handle); }
catch (JSException) { }
catch (JSDisconnectedException) { }
}

_dotnetRef?.Dispose();
Expand Down
Loading