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
77 changes: 77 additions & 0 deletions ShellUI.Tests/PackageRenderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ShellUI.Components;
using ShellUI.Components.Services;

namespace ShellUI.Tests;

public class PackageRenderTests
{
public sealed class Row
{
public string Name { get; set; } = "";
}

// Other render failures come from a missing parent or required parameter; these two are always bugs.
private static readonly string[] BindingErrors =
{
"does not have a property matching the name",
"declares more than one parameter matching the name",
};

[Fact]
public void EveryPackageComponent_RendersWithoutParameterBindingErrors()
{
var types = typeof(Shell).Assembly.GetExportedTypes()
.Where(t => typeof(IComponent).IsAssignableFrom(t) && !t.IsAbstract && t.Namespace == "ShellUI.Components")
.ToList();
Assert.NotEmpty(types);

var offenders = new List<string>();
foreach (var type in types)
{
var closed = type.IsGenericTypeDefinition ? Close(type) : type;
if (closed is null)
{
offenders.Add($"{type.Name}: could not choose type arguments");
continue;
}

using var ctx = new BunitContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
ctx.Services.AddShellUISonner();
ctx.Services.AddScoped<IThemeService, ThemeService>();

try
{
ctx.Render<DynamicComponent>(p => p.Add(c => c.Type, closed));
}
catch (Exception ex)
{
var message = Flatten(ex);
if (BindingErrors.Any(message.Contains)) offenders.Add($"{type.Name}: {message}");
}
}

Assert.True(offenders.Count == 0, "Package components that fail parameter binding:\n " + string.Join("\n ", offenders));
}

private static Type? Close(Type definition)
{
foreach (var candidate in new[] { typeof(Row), typeof(string), typeof(int) })
{
try
{
return definition.MakeGenericType(definition.GetGenericArguments().Select(_ => candidate).ToArray());
}
catch (ArgumentException) { }
}
return null;
}

private static string Flatten(Exception ex) =>
ex is AggregateException agg
? string.Join(" | ", agg.Flatten().InnerExceptions.Select(Flatten))
: ex.InnerException is null ? ex.Message : ex.Message + " | " + Flatten(ex.InnerException);
}
2 changes: 2 additions & 0 deletions ShellUI.Tests/ShellUI.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="bunit" Version="2.11.3" />
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
Expand All @@ -24,6 +25,7 @@
<ProjectReference Include="..\src\ShellUI.Templates\ShellUI.Templates.csproj" />
<ProjectReference Include="..\src\ShellUI.CLI\ShellUI.CLI.csproj" />
<ProjectReference Include="..\tools\ShellUI.SafelistGenerator\ShellUI.SafelistGenerator.csproj" />
<ProjectReference Include="..\src\ShellUI.Components\ShellUI.Components.csproj" />
</ItemGroup>

</Project>
3 changes: 0 additions & 3 deletions src/ShellUI.Components/Component1.razor

This file was deleted.

6 changes: 0 additions & 6 deletions src/ShellUI.Components/Component1.razor.css

This file was deleted.

2 changes: 1 addition & 1 deletion src/ShellUI.Components/Components/Calendar.razor
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
public DateTime? MaxDate { get; set; }

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

private DateTime _currentMonth = DateTime.Today;
private int _daysInMonth;
Expand Down
2 changes: 1 addition & 1 deletion src/ShellUI.Components/Components/DataPicker.razor
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
<button type="button"
@onclick="() => SelectItem(item)"
@onmouseenter="() => _highlightedIndex = localIdx"
class="@Shell.Cn("relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none", isHighlighted ? "bg-accent text-accent-foreground" : "", isSelected && !isHighlighted ? "bg-accent/50" : "")">
class="@Shell.Cn("relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 text-left pl-2 pr-8 text-sm outline-none", isHighlighted ? "bg-accent text-accent-foreground" : "", isSelected && !isHighlighted ? "bg-accent/50" : "")">
@if (OptionTemplate is not null)
{
@OptionTemplate(item)
Expand Down
2 changes: 1 addition & 1 deletion src/ShellUI.Components/Components/DatePicker.razor
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

@if (IsOpen)
{
<div class="absolute z-50 mt-1 rounded-md border border-border bg-popover p-4 shadow-md animate-in fade-in-0 zoom-in-95">
<div class="absolute z-50 mt-1 w-max rounded-md border border-border bg-popover p-4 shadow-md animate-in fade-in-0 zoom-in-95">
<div class="space-y-4">
<div class="flex items-center justify-between">
<button type="button" @onclick="PreviousMonth" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground h-7 w-7">
Expand Down
2 changes: 1 addition & 1 deletion src/ShellUI.Components/Components/DateRangePicker.razor
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@

@if (_isOpen)
{
<div class="absolute z-50 mt-1 rounded-md border bg-popover p-3 text-popover-foreground shadow-md">
<div class="absolute z-50 mt-1 w-max rounded-md border bg-popover p-3 text-popover-foreground shadow-md">
<div class="flex items-center justify-between mb-2">
<button type="button" @onclick="PreviousMonth" class="p-1 hover:bg-accent rounded">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
Expand Down
2 changes: 1 addition & 1 deletion src/ShellUI.Components/Components/MultiSelect.razor
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
<button type="button"
@onclick="() => ToggleItem(item)"
@onmouseenter="() => _highlightedIndex = localIdx"
class="@Shell.Cn("relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none", isHighlighted ? "bg-accent text-accent-foreground" : "")">
class="@Shell.Cn("relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 text-left pl-8 pr-2 text-sm outline-none", isHighlighted ? "bg-accent text-accent-foreground" : "")">
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
@if (isSelected)
{
Expand Down
21 changes: 9 additions & 12 deletions src/ShellUI.Components/Components/MultiSeriesChart.razor
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,12 @@
@inherits Chart<TItem>
@typeparam TItem where TItem : class

<ApexChart TItem="TItem"
Title="@Title"
Subtitle="@Subtitle"
Options="@ChartOptions"
Height="@Height"
Width="@Width">
@ChildContent
</ApexChart>

@code {
[Parameter] public new RenderFragment? ChildContent { get; set; }
}
<div class="@ComputedClass" data-chart-theme="@Theme.ToString().ToLower()">
<ApexChart TItem="TItem"
Title="@Title"
Options="@ChartOptions"
Height="@Height"
Width="@Width">
@ChildContent
</ApexChart>
</div>
52 changes: 30 additions & 22 deletions src/ShellUI.Components/Components/ThemeToggle.razor
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
</button>

@code {
private static readonly List<ThemeToggle> _instances = new();
private readonly string _handle = Guid.NewGuid().ToString("N");
private DotNetObjectReference<ThemeToggle>? _selfRef;
private bool _observing;
private bool _isDark = true;

[Parameter] public string Size { get; set; } = "default";
Expand All @@ -33,25 +35,38 @@
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }

protected override void OnInitialized()
{
_instances.Add(this);
}

// JSRuntime is unavailable during prerender.
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
try
{
var theme = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "theme");
_isDark = string.IsNullOrEmpty(theme) ? true : theme == "dark";
StateHasChanged();
// Reports the page's current theme and keeps every toggle in sync, whoever changes it.
_selfRef = DotNetObjectReference.Create(this);
_isDark = await JSRuntime.InvokeAsync<bool>("ShellUI.observeTheme", _handle, _selfRef);
_observing = true;
}
catch
{
_isDark = true;
try
{
var theme = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "theme");
_isDark = string.IsNullOrEmpty(theme) || theme == "dark";
}
catch
{
return;
}
}
StateHasChanged();
}

[JSInvokable]
public Task OnThemeChanged(bool isDark)
{
if (_isDark == isDark) return Task.CompletedTask;
_isDark = isDark;
return InvokeAsync(StateHasChanged);
}

private async Task ToggleTheme()
Expand All @@ -65,16 +80,6 @@
await JSRuntime.InvokeVoidAsync(
_isDark ? "ShellUI.addClassToDocument" : "ShellUI.removeClassFromDocument",
"dark");

foreach (var instance in _instances)
{
if (instance != this)
{
instance._isDark = _isDark;
instance.StateHasChanged();
}
}

StateHasChanged();
}
catch
Expand All @@ -85,7 +90,10 @@

public async ValueTask DisposeAsync()
{
_instances.Remove(this);
await ValueTask.CompletedTask;
if (_observing)
{
try { await JSRuntime.InvokeVoidAsync("ShellUI.unobserveTheme", _handle); } catch { }
}
_selfRef?.Dispose();
}
}
36 changes: 0 additions & 36 deletions src/ShellUI.Components/ExampleJsInterop.cs

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Blazor loads this initializer automatically; importing shellui.js defines window.ShellUI for every package component.
import "./shellui.js";
Binary file removed src/ShellUI.Components/wwwroot/background.png
Binary file not shown.
6 changes: 0 additions & 6 deletions src/ShellUI.Components/wwwroot/exampleJsInterop.js

This file was deleted.

51 changes: 51 additions & 0 deletions src/ShellUI.Components/wwwroot/shellui.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,52 @@ Object.assign(window.ShellUI, {
if (element) element.focus();
},

addClassToDocument: function (className) {
document.documentElement.classList.add(className);
},

removeClassFromDocument: function (className) {
document.documentElement.classList.remove(className);
},

_themeObservers: new Map(),
observeTheme: function (handle, dotNetRef) {
this.unobserveTheme(handle);
const root = document.documentElement;
const observer = new MutationObserver(() => {
dotNetRef.invokeMethodAsync("OnThemeChanged", root.classList.contains("dark")).catch(() => {});
});
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
this._themeObservers.set(handle, observer);
return root.classList.contains("dark");
},
unobserveTheme: function (handle) {
const observer = this._themeObservers.get(handle);
if (observer) {
observer.disconnect();
this._themeObservers.delete(handle);
}
},

setupFileDrop: function (dropZoneId, inputElementId) {
const dropZone = document.getElementById(dropZoneId);
const input = document.getElementById(inputElementId);
if (!dropZone || !input) return false;
if (dropZone._shelluiDrop) dropZone.removeEventListener("drop", dropZone._shelluiDrop);
dropZone._shelluiDrop = (e) => {
e.preventDefault();
e.stopPropagation();
const files = e.dataTransfer && e.dataTransfer.files;
if (!files || files.length === 0) return;
const dt = new DataTransfer();
for (let i = 0; i < files.length; i++) dt.items.add(files[i]);
input.files = dt.files;
input.dispatchEvent(new Event("change", { bubbles: true }));
};
dropZone.addEventListener("drop", dropZone._shelluiDrop);
return true;
},

registerShortcut: function (handle, key, ctrl, meta, shift, alt, dotNetRef) {
const listener = (e) => {
if (e.key.toLowerCase() !== key.toLowerCase()) return;
Expand Down Expand Up @@ -73,6 +119,11 @@ Object.assign(window.ShellUI, {
// ES module re-exports so consumers who import this file dynamically still work.
export function copyToClipboard(text) { return window.ShellUI.copyToClipboard(text); }
export function focusElement(elementId) { return window.ShellUI.focusElement(elementId); }
export function addClassToDocument(className) { return window.ShellUI.addClassToDocument(className); }
export function removeClassFromDocument(className) { return window.ShellUI.removeClassFromDocument(className); }
export function observeTheme(handle, dotNetRef) { return window.ShellUI.observeTheme(handle, dotNetRef); }
export function unobserveTheme(handle) { return window.ShellUI.unobserveTheme(handle); }
export function setupFileDrop(dropZoneId, inputElementId) { return window.ShellUI.setupFileDrop(dropZoneId, inputElementId); }
export function registerShortcut(handle, key, ctrl, meta, shift, alt, dotNetRef) {
return window.ShellUI.registerShortcut(handle, key, ctrl, meta, shift, alt, dotNetRef);
}
Expand Down
2 changes: 1 addition & 1 deletion src/ShellUI.Templates/Templates/CalendarTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ @using System.Globalization
public DateTime? MaxDate { get; set; }

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

private DateTime _currentMonth = DateTime.Today;
private int _daysInMonth;
Expand Down
2 changes: 1 addition & 1 deletion src/ShellUI.Templates/Templates/DataPickerTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ @inject IJSRuntime JS
<button type=""button""
@onclick=""() => SelectItem(item)""
@onmouseenter=""() => _highlightedIndex = localIdx""
class=""@Shell.Cn(""relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none"", isHighlighted ? ""bg-accent text-accent-foreground"" : """", isSelected && !isHighlighted ? ""bg-accent/50"" : """")"">
class=""@Shell.Cn(""relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 text-left pl-2 pr-8 text-sm outline-none"", isHighlighted ? ""bg-accent text-accent-foreground"" : """", isSelected && !isHighlighted ? ""bg-accent/50"" : """")"">
@if (OptionTemplate is not null)
{
@OptionTemplate(item)
Expand Down
Loading
Loading