diff --git a/ShellUI.Tests/PackageRenderTests.cs b/ShellUI.Tests/PackageRenderTests.cs new file mode 100644 index 0000000..260d626 --- /dev/null +++ b/ShellUI.Tests/PackageRenderTests.cs @@ -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(); + 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(); + + try + { + ctx.Render(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); +} diff --git a/ShellUI.Tests/ShellUI.Tests.csproj b/ShellUI.Tests/ShellUI.Tests.csproj index c32e66e..f7fee2a 100644 --- a/ShellUI.Tests/ShellUI.Tests.csproj +++ b/ShellUI.Tests/ShellUI.Tests.csproj @@ -8,6 +8,7 @@ + @@ -24,6 +25,7 @@ + diff --git a/src/ShellUI.Components/Component1.razor b/src/ShellUI.Components/Component1.razor deleted file mode 100644 index b3b7215..0000000 --- a/src/ShellUI.Components/Component1.razor +++ /dev/null @@ -1,3 +0,0 @@ -
- This component is defined in the ShellUI.Components library. -
diff --git a/src/ShellUI.Components/Component1.razor.css b/src/ShellUI.Components/Component1.razor.css deleted file mode 100644 index c6afca4..0000000 --- a/src/ShellUI.Components/Component1.razor.css +++ /dev/null @@ -1,6 +0,0 @@ -.my-component { - border: 2px dashed red; - padding: 1em; - margin: 1em 0; - background-image: url('background.png'); -} diff --git a/src/ShellUI.Components/Components/Calendar.razor b/src/ShellUI.Components/Components/Calendar.razor index 37400f4..d5240d7 100644 --- a/src/ShellUI.Components/Components/Calendar.razor +++ b/src/ShellUI.Components/Components/Calendar.razor @@ -65,7 +65,7 @@ public DateTime? MaxDate { get; set; } [Parameter] - public EventCallback SelectedDateChanged { get; set; } + public EventCallback SelectedDateChanged { get; set; } private DateTime _currentMonth = DateTime.Today; private int _daysInMonth; diff --git a/src/ShellUI.Components/Components/DataPicker.razor b/src/ShellUI.Components/Components/DataPicker.razor index e2f597b..2b6ae44 100644 --- a/src/ShellUI.Components/Components/DataPicker.razor +++ b/src/ShellUI.Components/Components/DataPicker.razor @@ -63,7 +63,7 @@ @code { - private static readonly List _instances = new(); + private readonly string _handle = Guid.NewGuid().ToString("N"); + private DotNetObjectReference? _selfRef; + private bool _observing; private bool _isDark = true; [Parameter] public string Size { get; set; } = "default"; @@ -33,25 +35,38 @@ [Parameter(CaptureUnmatchedValues = true)] public Dictionary? 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("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("ShellUI.observeTheme", _handle, _selfRef); + _observing = true; } catch { - _isDark = true; + try + { + var theme = await JSRuntime.InvokeAsync("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() @@ -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 @@ -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(); } } diff --git a/src/ShellUI.Components/ExampleJsInterop.cs b/src/ShellUI.Components/ExampleJsInterop.cs deleted file mode 100644 index a8a0861..0000000 --- a/src/ShellUI.Components/ExampleJsInterop.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Microsoft.JSInterop; - -namespace ShellUI.Components; - -// This class provides an example of how JavaScript functionality can be wrapped -// in a .NET class for easy consumption. The associated JavaScript module is -// loaded on demand when first needed. -// -// This class can be registered as scoped DI service and then injected into Blazor -// components for use. - -public class ExampleJsInterop : IAsyncDisposable -{ - private readonly Lazy> moduleTask; - - public ExampleJsInterop(IJSRuntime jsRuntime) - { - moduleTask = new (() => jsRuntime.InvokeAsync( - "import", "./_content/ShellUI.Components/exampleJsInterop.js").AsTask()); - } - - public async ValueTask Prompt(string message) - { - var module = await moduleTask.Value; - return await module.InvokeAsync("showPrompt", message); - } - - public async ValueTask DisposeAsync() - { - if (moduleTask.IsValueCreated) - { - var module = await moduleTask.Value; - await module.DisposeAsync(); - } - } -} diff --git a/src/ShellUI.Components/wwwroot/ShellUI.Components.lib.module.js b/src/ShellUI.Components/wwwroot/ShellUI.Components.lib.module.js new file mode 100644 index 0000000..fec66c0 --- /dev/null +++ b/src/ShellUI.Components/wwwroot/ShellUI.Components.lib.module.js @@ -0,0 +1,2 @@ +// Blazor loads this initializer automatically; importing shellui.js defines window.ShellUI for every package component. +import "./shellui.js"; diff --git a/src/ShellUI.Components/wwwroot/background.png b/src/ShellUI.Components/wwwroot/background.png deleted file mode 100644 index e15a3bd..0000000 Binary files a/src/ShellUI.Components/wwwroot/background.png and /dev/null differ diff --git a/src/ShellUI.Components/wwwroot/exampleJsInterop.js b/src/ShellUI.Components/wwwroot/exampleJsInterop.js deleted file mode 100644 index ea8d76a..0000000 --- a/src/ShellUI.Components/wwwroot/exampleJsInterop.js +++ /dev/null @@ -1,6 +0,0 @@ -// This is a JavaScript module that is loaded on demand. It can export any number of -// functions, and may import other JavaScript modules if required. - -export function showPrompt(message) { - return prompt(message, 'Type anything here'); -} diff --git a/src/ShellUI.Components/wwwroot/shellui.js b/src/ShellUI.Components/wwwroot/shellui.js index fa94a46..53fcaca 100644 --- a/src/ShellUI.Components/wwwroot/shellui.js +++ b/src/ShellUI.Components/wwwroot/shellui.js @@ -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; @@ -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); } diff --git a/src/ShellUI.Templates/Templates/CalendarTemplate.cs b/src/ShellUI.Templates/Templates/CalendarTemplate.cs index b71358f..a0f2b3f 100644 --- a/src/ShellUI.Templates/Templates/CalendarTemplate.cs +++ b/src/ShellUI.Templates/Templates/CalendarTemplate.cs @@ -81,7 +81,7 @@ @using System.Globalization public DateTime? MaxDate { get; set; } [Parameter] - public EventCallback SelectedDateChanged { get; set; } + public EventCallback SelectedDateChanged { get; set; } private DateTime _currentMonth = DateTime.Today; private int _daysInMonth; diff --git a/src/ShellUI.Templates/Templates/DataPickerTemplate.cs b/src/ShellUI.Templates/Templates/DataPickerTemplate.cs index 96e023a..34d48e6 100644 --- a/src/ShellUI.Templates/Templates/DataPickerTemplate.cs +++ b/src/ShellUI.Templates/Templates/DataPickerTemplate.cs @@ -78,7 +78,7 @@ @inject IJSRuntime JS @code { - private static readonly List _instances = new(); + private readonly string _handle = Guid.NewGuid().ToString(""N""); + private DotNetObjectReference? _selfRef; + private bool _observing; private bool _isDark = true; [Parameter] public string Size { get; set; } = ""default""; @@ -51,25 +53,38 @@ @implements IAsyncDisposable [Parameter(CaptureUnmatchedValues = true)] public Dictionary? 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(""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(""ShellUI.observeTheme"", _handle, _selfRef); + _observing = true; } catch { - _isDark = true; + try + { + var theme = await JSRuntime.InvokeAsync(""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() @@ -83,16 +98,6 @@ private async Task ToggleTheme() await JSRuntime.InvokeVoidAsync( _isDark ? ""ShellUI.addClassToDocument"" : ""ShellUI.removeClassFromDocument"", ""dark""); - - foreach (var instance in _instances) - { - if (instance != this) - { - instance._isDark = _isDark; - instance.StateHasChanged(); - } - } - StateHasChanged(); } catch @@ -103,8 +108,11 @@ await JSRuntime.InvokeVoidAsync( public async ValueTask DisposeAsync() { - _instances.Remove(this); - await ValueTask.CompletedTask; + if (_observing) + { + try { await JSRuntime.InvokeVoidAsync(""ShellUI.unobserveTheme"", _handle); } catch { } + } + _selfRef?.Dispose(); } } ";