ScriptManager Component
+
+The ScriptManager component is a migration stub that exists solely for markup compatibility when migrating from ASP.NET Web Forms. It renders no visible output.
+
+
+
Migration Note
+
In Web Forms, ScriptManager was required on every page that used AJAX features (UpdatePanel, Timer, etc.). It managed JavaScript resources, partial rendering, and web service proxies.
+
In Blazor, these responsibilities are handled by the framework itself:
+
+ Partial rendering — Blazor's diff-based rendering handles this automatically
+ Script management — Use <script> tags or JS interop
+ Page methods — Use Blazor component methods or API controllers
+ Globalization/Localization — Use .NET localization middleware
+
+
You can safely remove <ScriptManager> after migration is complete, or leave it in place — it has no effect on rendering or performance.
+
+
+
+
+Usage (renders nothing)
+The component below is present on this page but produces no HTML output:
+
+
+
+↑ A ScriptManager is rendered above this line. Inspect the page source to confirm it produces no HTML.
+
+<ScriptManager EnablePartialRendering="true" ScriptMode="ScriptMode.Auto" />
+
+
+
+Supported Properties (for migration compatibility)
+
+
+
+ Property
+ Type
+ Default
+ Notes
+
+
+
+ EnablePartialRendering bool false No effect in Blazor
+ EnablePageMethods bool false No effect in Blazor
+ ScriptMode ScriptMode Auto No effect in Blazor
+ AsyncPostBackTimeout int 90 No effect in Blazor
+ EnableCdn bool false No effect in Blazor
+ EnableScriptGlobalization bool false No effect in Blazor
+ EnableScriptLocalization bool false No effect in Blazor
+
+
+
+
+
+Web Forms Equivalent
+<!-- Web Forms — required on every AJAX-enabled page -->
+<asp:ScriptManager ID="ScriptManager1" runat="server"
+ EnablePartialRendering="true"
+ EnablePageMethods="true"
+ AsyncPostBackTimeout="90" />
diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Substitution/Default.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Substitution/Default.razor
new file mode 100644
index 000000000..363b54939
--- /dev/null
+++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Substitution/Default.razor
@@ -0,0 +1,78 @@
+@page "/ControlSamples/Substitution"
+@using Microsoft.AspNetCore.Http
+
+Substitution Component Samples
+
+The Substitution component emulates the Web Forms asp:Substitution control, which was used to inject dynamic content into output-cached pages. In Blazor, this component invokes a Func<HttpContext, string> callback and renders the returned markup.
+
+
+
+Basic Callback
+The SubstitutionCallback parameter accepts a function that receives the current HttpContext and returns an HTML string.
+
+
+
+<Substitution SubstitutionCallback="@@GetTimestamp" />
+
+@@code {
+ private string GetTimestamp(HttpContext? context) =>
+ $"<em>Rendered at: {DateTime.Now:h:mm:ss tt}</em>";
+}
+
+
+
+Using HttpContext
+The callback receives the current HttpContext, allowing access to request information such as headers, query strings, or the user agent.
+
+
+
+<Substitution SubstitutionCallback="@@GetRequestInfo" />
+
+@@code {
+ private string GetRequestInfo(HttpContext? context)
+ {
+ if (context == null) return "<em>No HttpContext available</em>";
+ var method = context.Request.Method;
+ var path = context.Request.Path;
+ return $"<span>Request: {method} {path}</span>";
+ }
+}
+
+
+
+MethodName Property
+The MethodName property is preserved for migration reference. In Web Forms, this specified the name of a static callback method. In Blazor, use the SubstitutionCallback parameter instead.
+
+
+
+<Substitution MethodName="GetDynamicContent" SubstitutionCallback="@@GetDynamicContent" />
+
+
+
+Web Forms Equivalent
+<!-- Web Forms — used inside output-cached pages -->
+<%@@ OutputCache Duration="60" VaryByParam="None" %>
+
+<asp:Substitution ID="Sub1" runat="server" MethodName="GetCurrentTime" />
+
+<!-- Code-behind -->
+public static string GetCurrentTime(HttpContext context)
+{
+ return DateTime.Now.ToString("h:mm:ss tt");
+}
+
+@code {
+ private string GetTimestamp(HttpContext? context) =>
+ $"Rendered at: {DateTime.Now:h:mm:ss tt} ";
+
+ private string GetRequestInfo(HttpContext? context)
+ {
+ if (context == null) return "No HttpContext available ";
+ var method = context.Request.Method;
+ var path = context.Request.Path;
+ return $"Request: {method} {path} ";
+ }
+
+ private string GetDynamicContent(HttpContext? context) =>
+ $"Dynamic content generated at {DateTime.Now:h:mm:ss tt} ";
+}
diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Theming/Index.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Theming/Index.razor
index 05ed8b799..8556c9070 100644
--- a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Theming/Index.razor
+++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Theming/Index.razor
@@ -5,54 +5,134 @@
Skins & Themes PoC
-This sample demonstrates the Skins & Themes system, which emulates ASP.NET Web Forms'
-.skin file behavior using Blazor's cascading values.
+This sample demonstrates the Skins & Themes system, which emulates
+ASP.NET Web Forms' .skin file behavior using Blazor's cascading values.
+A ThemeProvider wraps components and delivers a ThemeConfiguration
+via CascadingValue. Each control automatically picks up its matching skin.
-Themed Components
-These components receive their visual properties from a ThemeConfiguration
-provided via ThemeProvider.
+@* ───────────────────────── Section 1: Default Skins ───────────────────────── *@
-
- Default Skin
- Button and Label with default theme skin applied:
-
-
-
+
+
1. Default Skins
+
All controls inside the ThemeProvider receive the default skin for their
+ control type — no extra attributes needed.
-
Named Skin (SkinID)
-
Button with SkinID="Danger" gets a different skin:
-
+
+
+
+
+
+
+
+
- EnableTheming = false
- This button opts out of theming entirely:
-
-
+@* ───────────────────────── Section 2: Named Skins (SkinID) ───────────────── *@
-Without ThemeProvider
-Components outside the ThemeProvider keep their default appearance:
-
+
+
2. Named Skins (SkinID)
+
Set SkinID on a control to select a named skin instead of the default.
+ This maps directly to the Web Forms SkinID attribute.
+
+
+
+
+
+
+
+
+
+
+@* ─────────────────── Section 3: Explicit Overrides (StyleSheetTheme) ─────── *@
+
+
+
3. Explicit Values Override Theme
+
The theme uses StyleSheetTheme semantics: properties you set explicitly
+ in markup always win. The theme only fills in defaults for properties left unset.
+
+
+
+
+
+
+
+
+
+
+
+@* ───────────────────── Section 4: EnableTheming = false ──────────────────── *@
+
+
+
4. EnableTheming Opt-Out
+
Set EnableTheming="false" on any control to skip theme application entirely.
+ The control renders with only its explicit properties.
+
+
+
+
+
+
+
+
+
+
+@* ───────────────────── Section 5: Nested ThemeProviders ──────────────────── *@
+
+
+
5. Nested ThemeProviders
+
An inner ThemeProvider overrides the outer theme for its subtree.
+ This lets you apply different themes to different sections of a page.
+
+
+ Outer theme (blue buttons, navy labels):
+
+
+
+
+
+
+ Inner theme (teal buttons, dark-green labels):
+
+
+
+
+
+
+
+
+@* ───────────────────── Section 6: Outside ThemeProvider ──────────────────── *@
+
+
+
6. Without ThemeProvider
+
Components outside any ThemeProvider receive no theme and render
+ with their default appearance.
+
+
+
+
+
+
+
+@* ───────────────────── Migration Guide ───────────────────────────────────── *@
+
Migration Guide — Before & After
-The Skins & Themes system maps directly from ASP.NET Web Forms concepts.
-Here is a concrete example showing how to migrate themed controls.
+The Skins & Themes system maps directly from ASP.NET Web Forms concepts.
Before (Web Forms)
App_Themes/Corporate/controls.skin:
<asp:Button runat="server"
- BackColor="#336699"
- ForeColor="White"
- Font-Names="Segoe UI"
- Font-Size="9pt"
+ BackColor="#336699" ForeColor="White"
+ Font-Names="Segoe UI" Font-Size="9pt"
BorderStyle="None" />
<asp:Button runat="server" SkinID="danger"
- BackColor="#CC3333"
- ForeColor="White"
+ BackColor="#CC3333" ForeColor="White"
Font-Bold="True" />
web.config:
@@ -62,28 +142,135 @@ Products.aspx:
<asp:Button ID="btnSave" runat="server" Text="Save" />
<asp:Button ID="btnDelete" runat="server" Text="Delete" SkinID="danger" />
-Migration Steps
+After (Blazor)
+
+// CorporateTheme.cs
+var theme = new ThemeConfiguration();
+theme.AddSkin("Button", new ControlSkin
+{
+ BackColor = WebColor.FromHtml("#336699"),
+ ForeColor = WebColor.FromName("White"),
+ Font = new FontInfo { Name = "Segoe UI", Size = new FontUnit("9pt") }
+});
+theme.AddSkin("Button", new ControlSkin
+{
+ BackColor = WebColor.FromHtml("#CC3333"),
+ ForeColor = WebColor.FromName("White"),
+ Font = new FontInfo { Bold = true }
+}, skinId: "danger");
+
+@@* App.razor or layout *@@
+<ThemeProvider Theme="@@corporateTheme">
+ <Router ... />
+</ThemeProvider>
+
+@@* Products.razor — markup barely changes *@@
+<Button Text="Save" />
+<Button Text="Delete" SkinID="danger" />
+Migration Steps
- Create a ThemeConfiguration in C# matching your .skin file
- Wrap your app (or page) in a ThemeProvider
+ Convert each .skin file entry to a ThemeConfiguration.AddSkin() call
+ Wrap your app (or page section) in a <ThemeProvider>
Remove asp: prefix and runat="server" from markup
- Keep SkinID attributes — they work the same way
+ Keep SkinID attributes — they work identically
+ Use EnableTheming="false" for controls that should ignore the theme
-After (Blazor)
+
-@@* Program.cs or App.razor *@@
-<ThemeProvider Theme="@@corporateTheme">
- <Router ... />
+@* ───────────────────── Source Code ───────────────────────────────────────── *@
+
+
+
Source Code
+
+
@@page "/ControlSamples/Theming"
+@@using BlazorWebFormsComponents.Theming
+
+<ThemeProvider Theme="@@SampleTheme">
+ <!-- Default skins applied automatically -->
+ <Button Text="Themed Button" />
+ <Label Text="Themed Label" />
+ <TextBox Text="Themed TextBox" />
+
+ <!-- Named skins via SkinID -->
+ <Button Text="Danger" SkinID="Danger" />
+ <Button Text="Success" SkinID="Success" />
+
+ <!-- Explicit values override theme -->
+ <Button Text="Explicit Green" BackColor="WebColor.Green" ForeColor="WebColor.White" />
+
+ <!-- Opt out of theming entirely -->
+ <Button Text="Opted Out" EnableTheming="false" />
</ThemeProvider>
-@@* Products.razor — markup barely changes *@@
-<Button ID="btnSave" Text="Save" />
-<Button ID="btnDelete" Text="Delete" SkinID="danger" />
+<!-- Nested ThemeProvider overrides outer theme -->
+<ThemeProvider Theme="@@SampleTheme">
+ <ThemeProvider Theme="@@AlternateTheme">
+ <Button Text="Inner Theme" />
+ </ThemeProvider>
+</ThemeProvider>
+
+@@code {
+ private ThemeConfiguration SampleTheme;
+ private ThemeConfiguration AlternateTheme;
+
+ protected override void OnInitialized()
+ {
+ SampleTheme = new ThemeConfiguration();
+
+ // Default Button skin
+ SampleTheme.AddSkin("Button", new ControlSkin
+ {
+ BackColor = WebColor.Blue,
+ ForeColor = WebColor.White
+ });
+
+ // Named skins
+ SampleTheme.AddSkin("Button", new ControlSkin
+ {
+ BackColor = WebColor.Red,
+ ForeColor = WebColor.White
+ }, "Danger");
+
+ SampleTheme.AddSkin("Button", new ControlSkin
+ {
+ BackColor = WebColor.FromHtml("#006633"),
+ ForeColor = WebColor.White
+ }, "Success");
+
+ // Label and TextBox skins
+ SampleTheme.AddSkin("Label", new ControlSkin
+ {
+ ForeColor = WebColor.Navy,
+ Font = new FontInfo { Bold = true }
+ });
+
+ SampleTheme.AddSkin("TextBox", new ControlSkin
+ {
+ BorderColor = WebColor.Blue,
+ BorderStyle = BorderStyle.Solid
+ });
+
+ // Alternate theme for nesting demo
+ AlternateTheme = new ThemeConfiguration();
+ AlternateTheme.AddSkin("Button", new ControlSkin
+ {
+ BackColor = WebColor.Teal,
+ ForeColor = WebColor.White
+ });
+ AlternateTheme.AddSkin("Label", new ControlSkin
+ {
+ ForeColor = WebColor.FromHtml("#006633"),
+ Font = new FontInfo { Italic = true }
+ });
+ }
+}
+
@code {
private ThemeConfiguration SampleTheme;
+ private ThemeConfiguration AlternateTheme;
protected override void OnInitialized()
{
@@ -93,23 +280,50 @@ Products.aspx:
SampleTheme.AddSkin("Button", new ControlSkin
{
BackColor = WebColor.Blue,
- ForeColor = WebColor.White,
- CssClass = "themed-button"
+ ForeColor = WebColor.White
});
// Named "Danger" Button skin — red background
SampleTheme.AddSkin("Button", new ControlSkin
{
BackColor = WebColor.Red,
- ForeColor = WebColor.White,
- CssClass = "themed-button-danger"
+ ForeColor = WebColor.White
}, "Danger");
+ // Named "Success" Button skin — green background
+ SampleTheme.AddSkin("Button", new ControlSkin
+ {
+ BackColor = WebColor.FromHtml("#006633"),
+ ForeColor = WebColor.White
+ }, "Success");
+
// Default Label skin — bold navy text
SampleTheme.AddSkin("Label", new ControlSkin
{
ForeColor = WebColor.Navy,
Font = new FontInfo { Bold = true }
});
+
+ // Default TextBox skin — blue border
+ SampleTheme.AddSkin("TextBox", new ControlSkin
+ {
+ BorderColor = WebColor.Blue,
+ BorderStyle = BlazorWebFormsComponents.Enums.BorderStyle.Solid
+ });
+
+ // Alternate theme for nested ThemeProvider demo
+ AlternateTheme = new ThemeConfiguration();
+
+ AlternateTheme.AddSkin("Button", new ControlSkin
+ {
+ BackColor = WebColor.Teal,
+ ForeColor = WebColor.White
+ });
+
+ AlternateTheme.AddSkin("Label", new ControlSkin
+ {
+ ForeColor = WebColor.FromHtml("#006633"),
+ Font = new FontInfo { Italic = true }
+ });
}
}
diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Timer/Default.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Timer/Default.razor
new file mode 100644
index 000000000..0e4798dc9
--- /dev/null
+++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Timer/Default.razor
@@ -0,0 +1,80 @@
+@page "/ControlSamples/Timer"
+
+Timer Component Samples
+
+The Timer component triggers a callback at a specified interval, similar to the ASP.NET Web Forms asp:Timer control. In Web Forms, Timer was typically used with UpdatePanel for periodic partial-page updates. In Blazor, all UI updates are already partial, so the Timer simply invokes its OnTick callback.
+
+
+
+Auto-Incrementing Counter (2-Second Interval)
+This timer ticks every 2 seconds and increments a counter automatically.
+
+
+
+
+ Tick count: @tickCount
+
+
+<Timer Interval="2000" OnTick="OnCounterTick" />
+
+@@code {
+ private int tickCount = 0;
+ private void OnCounterTick() => tickCount++;
+}
+
+
+
+Timer with Start/Stop Toggle
+Use the Enabled property to start and stop the timer at runtime.
+
+
+
+
+
+ @(timerEnabled ? "Stop Timer" : "Start Timer")
+
+
+
+
+ Status: @(timerEnabled ? "Running" : "Stopped") —
+ Seconds elapsed: @secondsElapsed
+
+
+<Timer Interval="1000" Enabled="@@timerEnabled" OnTick="OnToggleTick" />
+
+<button @@onclick="ToggleTimer">
+ @@(timerEnabled ? "Stop Timer" : "Start Timer")
+</button>
+
+@@code {
+ private bool timerEnabled = false;
+ private int secondsElapsed = 0;
+
+ private void ToggleTimer() => timerEnabled = !timerEnabled;
+ private void OnToggleTick() => secondsElapsed++;
+}
+
+
+
+Web Forms Equivalent
+<!-- Web Forms -->
+<asp:ScriptManager runat="server" />
+<asp:Timer ID="Timer1" runat="server" Interval="2000" OnTick="Timer1_Tick" />
+<asp:UpdatePanel runat="server">
+ <ContentTemplate>
+ <asp:Label ID="lblCount" runat="server" />
+ </ContentTemplate>
+ <Triggers>
+ <asp:AsyncPostBackTrigger ControlID="Timer1" EventName="Tick" />
+ </Triggers>
+</asp:UpdatePanel>
+
+@code {
+ private int tickCount = 0;
+ private bool timerEnabled = false;
+ private int secondsElapsed = 0;
+
+ private void OnCounterTick() => tickCount++;
+ private void ToggleTimer() => timerEnabled = !timerEnabled;
+ private void OnToggleTick() => secondsElapsed++;
+}
diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/UpdatePanel/Default.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/UpdatePanel/Default.razor
new file mode 100644
index 000000000..710cca102
--- /dev/null
+++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/UpdatePanel/Default.razor
@@ -0,0 +1,83 @@
+@page "/ControlSamples/UpdatePanel"
+@using BlazorWebFormsComponents.Enums
+
+UpdatePanel Component Samples
+
+The UpdatePanel component emulates the Web Forms asp:UpdatePanel for migration compatibility. In Web Forms, UpdatePanel enabled partial-page updates via AJAX. In Blazor, all updates are already partial — Blazor's diff-based rendering only updates changed DOM elements. The UpdatePanel component renders a <div> (Block mode) or <span> (Inline mode) wrapper around its content.
+
+
+
+Block Mode (Default)
+Renders as a <div> element. This is the default RenderMode.
+
+
+
+ This content is inside an UpdatePanel in Block mode (renders as a <div>).
+ Click count: @blockClickCount
+ Click Me
+
+
+
+<UpdatePanel>
+ <ChildContent>
+ <p>Content inside Block mode UpdatePanel.</p>
+ <button @@onclick="IncrementBlock">Click Me</button>
+ </ChildContent>
+</UpdatePanel>
+
+
+
+Inline Mode
+Renders as a <span> element, useful for inline content.
+
+
+ The time is:
+
+
+ @currentTime
+
+
+ Refresh
+
+
+<p>
+ The time is:
+ <UpdatePanel RenderMode="UpdatePanelRenderMode.Inline">
+ <ChildContent>
+ <strong>@@currentTime</strong>
+ </ChildContent>
+ </UpdatePanel>
+</p>
+
+
+
+UpdatePanel Properties
+The component also supports UpdateMode and ChildrenAsTriggers properties for migration compatibility, though in Blazor these have no behavioral effect since rendering is always differential.
+
+
+
+
+ This UpdatePanel has UpdateMode="Conditional" and ChildrenAsTriggers="false".
+ In Web Forms these controlled when the panel refreshed. In Blazor, the component simply preserves these properties for migration compatibility.
+
+
+
+
+
+
+Web Forms Equivalent
+<!-- Web Forms -->
+<asp:UpdatePanel ID="UpdatePanel1" runat="server" UpdateMode="Always">
+ <ContentTemplate>
+ <asp:Label ID="lblMessage" runat="server" />
+ <asp:Button ID="btnClick" runat="server" Text="Click Me" OnClick="btnClick_Click" />
+ </ContentTemplate>
+</asp:UpdatePanel>
+
+@code {
+ private int blockClickCount = 0;
+ private string currentTime = DateTime.Now.ToString("h:mm:ss tt");
+
+ private void IncrementBlock() => blockClickCount++;
+ private void RefreshTime() => currentTime = DateTime.Now.ToString("h:mm:ss tt");
+}
diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/UpdateProgress/Default.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/UpdateProgress/Default.razor
new file mode 100644
index 000000000..5d708caac
--- /dev/null
+++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/UpdateProgress/Default.razor
@@ -0,0 +1,86 @@
+@page "/ControlSamples/UpdateProgress"
+
+UpdateProgress Component Samples
+
+The UpdateProgress component emulates the Web Forms asp:UpdateProgress control, which displayed a loading indicator during asynchronous postbacks. The component renders its ProgressTemplate content, hidden by default using either display:none (DynamicLayout=true) or visibility:hidden (DynamicLayout=false).
+
+
+
+DynamicLayout = true (Default)
+When DynamicLayout is true (the default), the progress content is rendered with display:none, which removes it from the page layout entirely.
+
+
+
+
+
+ Loading, please wait...
+
+
+
+
+<UpdateProgress DynamicLayout="true">
+ <ProgressTemplate>
+ <div class="alert alert-info">
+ <span class="spinner-border spinner-border-sm"></span>
+ Loading, please wait...
+ </div>
+ </ProgressTemplate>
+</UpdateProgress>
+
+Note: The content above is hidden with display:none — it takes no space in the layout.
+
+
+
+DynamicLayout = false
+When DynamicLayout is false, the progress content is rendered with visibility:hidden, which hides it but reserves its layout space.
+
+
+
+
+
+ Processing your request...
+
+
+
+
+<UpdateProgress DynamicLayout="false">
+ <ProgressTemplate>
+ <div class="alert alert-warning">
+ Processing your request...
+ </div>
+ </ProgressTemplate>
+</UpdateProgress>
+
+Note: The content above is hidden with visibility:hidden — it still occupies space in the layout.
+
+
+
+Associated with an UpdatePanel
+Use AssociatedUpdatePanelID to link the progress indicator to a specific UpdatePanel. The DisplayAfter property sets the delay in milliseconds before the progress content appears (default is 500ms).
+
+
+
+
+
+ Updating panel content...
+
+
+
+
+<UpdateProgress AssociatedUpdatePanelID="myPanel" DisplayAfter="200">
+ <ProgressTemplate>
+ Updating panel content...
+ </ProgressTemplate>
+</UpdateProgress>
+
+
+
+Web Forms Equivalent
+<!-- Web Forms -->
+<asp:UpdateProgress ID="UpdateProgress1" runat="server"
+ AssociatedUpdatePanelID="UpdatePanel1" DisplayAfter="500">
+ <ProgressTemplate>
+ <img src="loading.gif" alt="Loading..." />
+ Please wait...
+ </ProgressTemplate>
+</asp:UpdateProgress>
diff --git a/scripts/normalize-html.mjs b/scripts/normalize-html.mjs
index 1fadd12da..86d1a4db0 100644
--- a/scripts/normalize-html.mjs
+++ b/scripts/normalize-html.mjs
@@ -184,6 +184,40 @@ function normalizeWhitespace(html) {
return result;
}
+/**
+ * Normalize boolean HTML attributes so that both the empty-string form
+ * (e.g., selected="") and the self-named form (e.g., selected="selected")
+ * collapse to the bare attribute (e.g., selected).
+ */
+function normalizeBooleanAttributes(html) {
+ const boolAttrs = ['selected', 'checked', 'disabled', 'readonly', 'multiple', 'nowrap'];
+ for (const attr of boolAttrs) {
+ const re = new RegExp(`\\b${attr}=(?:"(?:${attr}|)"|'(?:${attr}|)')`, 'gi');
+ html = html.replace(re, attr);
+ }
+ return html;
+}
+
+/**
+ * Strip empty style="" attributes that add no styling information.
+ */
+function stripEmptyStyles(html) {
+ return html.replace(/\bstyle=""\s*/gi, '');
+}
+
+/**
+ * Replace GUID patterns inside id attribute values with a stable placeholder
+ * so that auto-generated IDs (CheckBox, RadioButtonList, FileUpload) don't
+ * cause false divergences.
+ */
+function normalizeGuidIds(html) {
+ const guidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
+ return html.replace(/\bid="([^"]*)"/gi, (_match, idVal) => {
+ const normalized = idVal.replace(guidPattern, 'GUID');
+ return `id="${normalized}"`;
+ });
+}
+
/**
* Clean up residual empty attribute artifacts left by regex stripping.
* e.g., double-spaces from removed attributes.
@@ -203,6 +237,9 @@ function cleanupArtifacts(html) {
function normalizeHtml(html, rules) {
html = applyRegexRules(html, rules);
html = normalizeStyleAttributes(html);
+ html = stripEmptyStyles(html);
+ html = normalizeBooleanAttributes(html);
+ html = normalizeGuidIds(html);
html = sortAttributes(html);
html = cleanupArtifacts(html);
html = normalizeWhitespace(html);
@@ -347,16 +384,21 @@ function runCompare(dirA, dirB, reportPath) {
const filesA = collectHtmlFiles(dirA).map(f => relative(dirA, f));
const filesB = collectHtmlFiles(dirB).map(f => relative(dirB, f));
- const allFiles = [...new Set([...filesA, ...filesB])].sort();
- // Group files by control (first path segment)
+ // Case-insensitive file pairing to avoid HyperLink/Hyperlink dupes
+ const mapA = new Map(filesA.map(f => [f.toLowerCase(), f]));
+ const mapB = new Map(filesB.map(f => [f.toLowerCase(), f]));
+ const allKeys = [...new Set([...mapA.keys(), ...mapB.keys()])].sort();
+
+ // Group files by control (first path segment), prefer source A casing
const controls = {};
- for (const f of allFiles) {
- const parts = f.split(sep);
+ for (const key of allKeys) {
+ const displayRel = mapA.get(key) || mapB.get(key);
+ const parts = displayRel.split(sep);
const control = parts.length > 1 ? parts[0] : '(root)';
- const variant = basename(f, extname(f));
+ const variant = basename(displayRel, extname(displayRel));
if (!controls[control]) controls[control] = [];
- controls[control].push({ rel: f, variant });
+ controls[control].push({ relA: mapA.get(key), relB: mapB.get(key), variant });
}
let totalCompared = 0;
@@ -376,17 +418,17 @@ function runCompare(dirA, dirB, reportPath) {
const rows = [];
const diffSections = [];
- for (const { rel, variant } of variants) {
+ for (const { relA, relB, variant } of variants) {
totalCompared++;
- const pathA = join(dirA, rel);
- const pathB = join(dirB, rel);
+ const pathA = relA ? join(dirA, relA) : null;
+ const pathB = relB ? join(dirB, relB) : null;
- if (!existsSync(pathA)) {
+ if (!pathA) {
divergences++;
rows.push(`| ${variant} | ❌ Missing in source A | File only exists in second directory |`);
continue;
}
- if (!existsSync(pathB)) {
+ if (!pathB) {
divergences++;
rows.push(`| ${variant} | ❌ Missing in source B | File only exists in first directory |`);
continue;
diff --git a/scripts/normalize-rules.json b/scripts/normalize-rules.json
index 11aab2361..02946473a 100644
--- a/scripts/normalize-rules.json
+++ b/scripts/normalize-rules.json
@@ -152,6 +152,30 @@
"pattern": "CUSTOM_FUNCTION",
"replacement": "Handled by normalizeWhitespace() in code",
"flags": ""
+ },
+ {
+ "id": "normalize-boolean-attrs",
+ "description": "Collapse boolean attributes (selected=\"selected\", selected=\"\") to bare form (selected)",
+ "enabled": true,
+ "pattern": "CUSTOM_FUNCTION",
+ "replacement": "Handled by normalizeBooleanAttributes() in code",
+ "flags": ""
+ },
+ {
+ "id": "strip-empty-styles",
+ "description": "Remove empty style=\"\" attributes that add no styling information",
+ "enabled": true,
+ "pattern": "CUSTOM_FUNCTION",
+ "replacement": "Handled by stripEmptyStyles() in code",
+ "flags": ""
+ },
+ {
+ "id": "normalize-guid-ids",
+ "description": "Replace GUID patterns in id values with GUID placeholder to avoid false divergences from auto-generated IDs",
+ "enabled": true,
+ "pattern": "CUSTOM_FUNCTION",
+ "replacement": "Handled by normalizeGuidIds() in code",
+ "flags": ""
}
]
}
diff --git a/src/BlazorWebFormsComponents.Test/CheckBox/TextAlign.razor b/src/BlazorWebFormsComponents.Test/CheckBox/TextAlign.razor
index c48a2473e..1478c232e 100644
--- a/src/BlazorWebFormsComponents.Test/CheckBox/TextAlign.razor
+++ b/src/BlazorWebFormsComponents.Test/CheckBox/TextAlign.razor
@@ -38,4 +38,42 @@
elements[1].TagName.ShouldBe("LABEL");
}
+ // --- Bug fix #382: Combined alignment + no-span-wrapper verification ---
+
+ [Fact]
+ public void CheckBox_TextAlignRight_RendersInputThenLabelWithNoSpanWrapper()
+ {
+ // Bug #382: TextAlign=Right must render then with NO wrapping
+ var cut = Render(@ );
+
+ var elements = cut.FindAll("input, label");
+ elements.Count.ShouldBe(2);
+ elements[0].TagName.ShouldBe("INPUT");
+ elements[1].TagName.ShouldBe("LABEL");
+ Should.Throw(() => cut.Find("span"));
+ }
+
+ [Fact]
+ public void CheckBox_TextAlignLeft_RendersLabelThenInputWithNoSpanWrapper()
+ {
+ // Bug #382: TextAlign=Left must render then with NO wrapping
+ var cut = Render(@ );
+
+ var elements = cut.FindAll("label, input");
+ elements.Count.ShouldBe(2);
+ elements[0].TagName.ShouldBe("LABEL");
+ elements[1].TagName.ShouldBe("INPUT");
+ Should.Throw(() => cut.Find("span"));
+ }
+
+ [Fact]
+ public void CheckBox_WithText_TopLevelElementIsNotSpan()
+ {
+ // Bug #382: The top-level rendered element should NOT be a wrapper
+ var cut = Render(@ );
+
+ var firstElement = cut.Nodes.OfType().First();
+ firstElement.TagName.ShouldNotBe("SPAN");
+ }
+
}
diff --git a/src/BlazorWebFormsComponents.Test/ClientIDMode/ClientIDModeTests.razor b/src/BlazorWebFormsComponents.Test/ClientIDMode/ClientIDModeTests.razor
new file mode 100644
index 000000000..c4460671e
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Test/ClientIDMode/ClientIDModeTests.razor
@@ -0,0 +1,196 @@
+@inherits BlazorWebFormsTestContext
+@using BlazorWebFormsComponents.Enums
+
+@code {
+ // ===== Static Mode Tests =====
+
+ [Fact]
+ public void StaticMode_RendersRawID()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+ );
+
+ // Assert
+ var button = cut.Find("input");
+ button.GetAttribute("id").ShouldBe("MyButton");
+ }
+
+ [Fact]
+ public void StaticMode_InsideNamingContainer_IgnoresParentPrefix()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+ );
+
+ // Assert
+ var button = cut.Find("input");
+ button.GetAttribute("id").ShouldBe("MyButton");
+ }
+
+ [Fact]
+ public void StaticMode_NestedNamingContainers_RendersOnlyRawID()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+
+
+ );
+
+ // Assert
+ var button = cut.Find("input");
+ button.GetAttribute("id").ShouldBe("MyButton");
+ }
+
+ // ===== Predictable Mode Tests =====
+
+ [Fact]
+ public void PredictableMode_InsideNamingContainer_GetsParentChildPattern()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+ );
+
+ // Assert
+ var button = cut.Find("input");
+ button.GetAttribute("id").ShouldBe("Parent_MyButton");
+ }
+
+ [Fact]
+ public void PredictableMode_NestedNamingContainers_GetsFullPath()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+
+
+ );
+
+ // Assert
+ var button = cut.Find("input");
+ button.GetAttribute("id").ShouldBe("Outer_Inner_MyButton");
+ }
+
+ [Fact]
+ public void PredictableMode_DoesNotIncludeCtl00_EvenWhenUseCtl00PrefixIsTrue()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+ );
+
+ // Assert — Predictable mode never includes ctl00
+ var button = cut.Find("input");
+ button.GetAttribute("id").ShouldBe("Parent_MyButton");
+ }
+
+ // ===== AutoID Mode Tests =====
+
+ [Fact]
+ public void AutoIDMode_InsideNamingContainerWithCtl00_GetsCtl00Prefix()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+ );
+
+ // Assert — AutoID includes ctl00 prefix from NamingContainer
+ var button = cut.Find("input");
+ button.GetAttribute("id").ShouldBe("ctl00_MainContent_MyButton");
+ }
+
+ [Fact]
+ public void AutoIDMode_IsLegacyMode_IncludesCtl00()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+
+
+ );
+
+ // Assert — Only NamingContainers with UseCtl00Prefix=true add ctl00
+ var button = cut.Find("input");
+ button.GetAttribute("id").ShouldBe("ctl00_Outer_Inner_MyButton");
+ }
+
+ // ===== Inherit Mode Tests =====
+
+ [Fact]
+ public void InheritMode_DefaultResolvesToPredictable()
+ {
+ // Arrange & Act — Inherit is the default, which resolves to Predictable
+ var cut = Render(
+ @
+
+
+ );
+
+ // Assert — Same as Predictable: Parent_Child pattern, no ctl00
+ var button = cut.Find("input");
+ button.GetAttribute("id").ShouldBe("Parent_MyButton");
+ }
+
+ [Fact]
+ public void InheritMode_WalksUpToParentMode()
+ {
+ // Arrange & Act — NamingContainer sets Static mode, child inherits it
+ var cut = Render(
+ @
+
+
+ );
+
+ // Assert — Child inherits Static from parent, so renders raw ID
+ var button = cut.Find("input");
+ button.GetAttribute("id").ShouldBe("MyButton");
+ }
+
+ // ===== Edge Case Tests =====
+
+ [Fact]
+ public void NoIDSet_ReturnsNullClientID_RegardlessOfMode()
+ {
+ // Arrange & Act — No ID parameter set
+ var cut = Render(
+ @
+
+
+ );
+
+ // Assert — No id attribute should be rendered (ClientID returns null)
+ var button = cut.Find("input");
+ button.GetAttribute("id").ShouldBeNull();
+ }
+
+ [Fact]
+ public void StaticMode_NoNamingContainer_ReturnsRawID()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+ );
+
+ // Assert
+ var button = cut.Find("input");
+ button.GetAttribute("id").ShouldBe("MyButton");
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Test/FileUpload/Render.razor b/src/BlazorWebFormsComponents.Test/FileUpload/Render.razor
index 3fed98bd8..3f24d78e3 100644
--- a/src/BlazorWebFormsComponents.Test/FileUpload/Render.razor
+++ b/src/BlazorWebFormsComponents.Test/FileUpload/Render.razor
@@ -1,5 +1,6 @@
@inherits BlazorWebFormsTestContext
@using Shouldly
+@using System.Text.RegularExpressions
@code {
@@ -14,4 +15,46 @@
input.ShouldNotBeNull();
}
+ // --- Bug fix #383: No stray GUID attributes ---
+
+ [Fact]
+ public void FileUpload_Render_NoStrayGuidAttributes()
+ {
+ // Bug #383: FileUpload should not render attributes with GUID patterns
+ var cut = Render(@ );
+ var input = cut.Find("input[type='file']");
+
+ var guidPattern = new Regex(
+ @"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
+
+ foreach (var attr in input.Attributes)
+ {
+ guidPattern.IsMatch(attr.Name).ShouldBeFalse(
+ $"Stray GUID in attribute name: '{attr.Name}'");
+ }
+ }
+
+ [Fact]
+ public void FileUpload_Render_OnlyExpectedAttributes()
+ {
+ // Bug #383: A default FileUpload should only have recognized HTML attributes
+ var cut = Render(@ );
+ var input = cut.Find("input[type='file']");
+
+ var allowed = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "type", "id", "name", "class", "style", "multiple",
+ "accept", "disabled", "title"
+ };
+
+ foreach (var attr in input.Attributes)
+ {
+ // Skip Blazor framework-internal attributes (e.g. blazor:elementreference)
+ if (attr.Name.StartsWith("blazor:")) continue;
+
+ allowed.ShouldContain(attr.Name,
+ $"Unexpected attribute on FileUpload: '{attr.Name}'=\"{attr.Value}\"");
+ }
+ }
+
}
diff --git a/src/BlazorWebFormsComponents.Test/LinkButton/CssClass.razor b/src/BlazorWebFormsComponents.Test/LinkButton/CssClass.razor
new file mode 100644
index 000000000..f95a77c67
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Test/LinkButton/CssClass.razor
@@ -0,0 +1,72 @@
+
+@code {
+
+ [Fact]
+ public void SingleClass_RendersAsClassAttributeOnAnchor()
+ {
+ var cut = Render(@ );
+ var a = cut.Find("a");
+ a.GetAttribute("class").ShouldBe("my-btn");
+ }
+
+ [Fact]
+ public void MultipleClasses_RendersSpaceSeparatedOnAnchor()
+ {
+ var cut = Render(@ );
+ var a = cut.Find("a");
+ a.GetAttribute("class").ShouldBe("btn btn-primary active");
+ }
+
+ [Fact]
+ public void NoCssClass_OmitsClassAttribute()
+ {
+ var cut = Render(@ );
+ var a = cut.Find("a");
+ a.HasAttribute("class").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void EmptyCssClass_OmitsClassAttribute()
+ {
+ var cut = Render(@ );
+ var a = cut.Find("a");
+ a.HasAttribute("class").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void CssClass_WithIdAttribute_BothRenderOnAnchor()
+ {
+ var cut = Render(@ );
+ var a = cut.Find("a");
+ a.GetAttribute("class").ShouldBe("highlight");
+ a.Id.ShouldNotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public void CssClass_WithPostBackUrl_RendersOnAnchor()
+ {
+ var cut = Render(@ );
+ var a = cut.Find("a");
+ a.GetAttribute("class").ShouldBe("nav-link");
+ a.GetAttribute("href").ShouldBe("https://example.com");
+ }
+
+ [Fact]
+ public void Disabled_NoCssClass_RendersAspNetDisabledOnly()
+ {
+ var cut = Render(@ );
+ var a = cut.Find("a");
+ a.GetAttribute("class").ShouldBe("aspNetDisabled");
+ }
+
+ [Fact]
+ public void Disabled_WithCssClass_RendersBothClasses()
+ {
+ var cut = Render(@ );
+ var a = cut.Find("a");
+ var classValue = a.GetAttribute("class");
+ classValue.ShouldContain("btn");
+ classValue.ShouldContain("aspNetDisabled");
+ }
+
+}
diff --git a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/AnonymusUser.razor b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/AnonymusUser.razor
index decaae9e5..ade4c832e 100644
--- a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/AnonymusUser.razor
+++ b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/AnonymusUser.razor
@@ -35,6 +35,6 @@
);
- cut.Markup.Trim().ShouldBe("Anonymous");
+ cut.Find("div").TextContent.Trim().ShouldBe("Anonymous");
}
}
diff --git a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/AnonymusUserWithNoRoleGroup.razor b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/AnonymusUserWithNoRoleGroup.razor
index 93417e5b3..e13e7bbdc 100644
--- a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/AnonymusUserWithNoRoleGroup.razor
+++ b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/AnonymusUserWithNoRoleGroup.razor
@@ -26,6 +26,6 @@
);
- cut.Markup.Trim().ShouldBe("Anonymous");
+ cut.Find("div").TextContent.Trim().ShouldBe("Anonymous");
}
}
diff --git a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/DisplayNoContentWhenNothingHaveBeenSet.razor b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/DisplayNoContentWhenNothingHaveBeenSet.razor
index 728d63bbd..79e80c5e7 100644
--- a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/DisplayNoContentWhenNothingHaveBeenSet.razor
+++ b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/DisplayNoContentWhenNothingHaveBeenSet.razor
@@ -18,6 +18,6 @@
var cut = Render(@ );
- cut.Markup.Trim().ShouldBeEmpty();
+ cut.Find("div").TextContent.Trim().ShouldBeEmpty();
}
}
diff --git a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/LoggedInUserWithNoRoleGroup.razor b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/LoggedInUserWithNoRoleGroup.razor
index ae626da4f..0d8df1c82 100644
--- a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/LoggedInUserWithNoRoleGroup.razor
+++ b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/LoggedInUserWithNoRoleGroup.razor
@@ -32,6 +32,6 @@
);
- cut.Markup.Trim().ShouldBe("LoggedIn");
+ cut.Find("div").TextContent.Trim().ShouldBe("LoggedIn");
}
}
diff --git a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/OuterStyle.razor b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/OuterStyle.razor
new file mode 100644
index 000000000..3489b4755
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/OuterStyle.razor
@@ -0,0 +1,189 @@
+@using System.Security.Claims
+@using Microsoft.AspNetCore.Components.Authorization
+@using BlazorWebFormsComponents.LoginControls
+@using static BlazorWebFormsComponents.WebColor
+@using Moq
+
+@code {
+
+ private void SetupLoginViewServices()
+ {
+ var principal = new ClaimsPrincipal();
+ var authMock = new Mock();
+ authMock.Setup(x => x.GetAuthenticationStateAsync())
+ .Returns(Task.FromResult(new AuthenticationState(principal)));
+ Services.AddSingleton(authMock.Object);
+ }
+
+ [Fact]
+ public void LoginView_CssClass_RendersOnOuterElement()
+ {
+ // Arrange
+ SetupLoginViewServices();
+
+ // Act
+ var cut = Render(
+ @
+ Anonymous
+
+ );
+
+ // Assert
+ var outer = cut.Find("#lv1");
+ outer.ShouldNotBeNull();
+ outer.GetAttribute("class").ShouldBe("login-view-panel");
+ }
+
+ [Fact]
+ public void LoginView_WithoutCssClass_NoClassOnOuterElement()
+ {
+ // Arrange
+ SetupLoginViewServices();
+
+ // Act
+ var cut = Render(
+ @
+ Anonymous
+
+ );
+
+ // Assert
+ var outer = cut.Find("#lv2");
+ outer.HasAttribute("class").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void LoginView_ToolTip_RendersAsTitleAttribute()
+ {
+ // Arrange
+ SetupLoginViewServices();
+
+ // Act
+ var cut = Render(
+ @
+ Anonymous
+
+ );
+
+ // Assert
+ var outer = cut.Find("#lv3");
+ outer.GetAttribute("title").ShouldBe("Login view tooltip");
+ }
+
+ [Fact]
+ public void LoginView_WithoutToolTip_NoTitleAttribute()
+ {
+ // Arrange
+ SetupLoginViewServices();
+
+ // Act
+ var cut = Render(
+ @
+ Anonymous
+
+ );
+
+ // Assert
+ var outer = cut.Find("#lv4");
+ var title = outer.GetAttribute("title");
+ (string.IsNullOrEmpty(title)).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void LoginView_BackColor_RendersInlineStyle()
+ {
+ // Arrange
+ SetupLoginViewServices();
+
+ // Act
+ var cut = Render(
+ @
+ Anonymous
+
+ );
+
+ // Assert
+ var outer = cut.Find("#lv5");
+ var style = outer.GetAttribute("style");
+ style.ShouldNotBeNull();
+ style.ShouldContain("background-color");
+ }
+
+ [Fact]
+ public void LoginView_ForeColor_RendersInlineStyle()
+ {
+ // Arrange
+ SetupLoginViewServices();
+
+ // Act
+ var cut = Render(
+ @
+ Anonymous
+
+ );
+
+ // Assert
+ var outer = cut.Find("#lv6");
+ var style = outer.GetAttribute("style");
+ style.ShouldNotBeNull();
+ style.ShouldContain("color");
+ }
+
+ [Fact]
+ public void LoginView_FontBold_RendersInlineStyle()
+ {
+ // Arrange
+ SetupLoginViewServices();
+
+ // Act
+ var cut = Render(
+ @
+ Anonymous
+
+ );
+
+ // Assert
+ var outer = cut.Find("#lv7");
+ var style = outer.GetAttribute("style");
+ style.ShouldNotBeNull();
+ style.ShouldContain("font-weight:bold");
+ }
+
+ [Fact]
+ public void LoginView_CssClassAndStyle_BothRender()
+ {
+ // Arrange
+ SetupLoginViewServices();
+
+ // Act
+ var cut = Render(
+ @
+ Anonymous
+
+ );
+
+ // Assert
+ var outer = cut.Find("#lv8");
+ outer.GetAttribute("class").ShouldBe("themed");
+ outer.GetAttribute("title").ShouldBe("Styled view");
+ outer.GetAttribute("style").ShouldContain("background-color");
+ }
+
+ [Fact]
+ public void LoginView_StillRendersTemplateContent()
+ {
+ // Arrange — verify style wrapper doesn't break template rendering
+ SetupLoginViewServices();
+
+ // Act
+ var cut = Render(
+ @
+ Anonymous Content
+
+ );
+
+ // Assert
+ cut.Markup.ShouldContain("Anonymous Content");
+ }
+
+}
diff --git a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/RoleGroupFirstGroupOnMultipleMatch.razor b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/RoleGroupFirstGroupOnMultipleMatch.razor
index 2401f1ea5..dcabc5308 100644
--- a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/RoleGroupFirstGroupOnMultipleMatch.razor
+++ b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/RoleGroupFirstGroupOnMultipleMatch.razor
@@ -41,6 +41,6 @@
);
- cut.Markup.Trim().ShouldBe("Agent");
+ cut.Find("div").TextContent.Trim().ShouldBe("Agent");
}
}
diff --git a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/RoleGroupFirstMatch.razor b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/RoleGroupFirstMatch.razor
index c537c95e2..7725138fe 100644
--- a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/RoleGroupFirstMatch.razor
+++ b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/RoleGroupFirstMatch.razor
@@ -41,6 +41,6 @@
);
- cut.Markup.Trim().ShouldBe("Spy,Agent");
+ cut.Find("div").TextContent.Trim().ShouldBe("Spy,Agent");
}
}
diff --git a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/RoleGroupNoMatchWithLoggedInTemplate.razor b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/RoleGroupNoMatchWithLoggedInTemplate.razor
index 95d0c63a5..27e69c1e1 100644
--- a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/RoleGroupNoMatchWithLoggedInTemplate.razor
+++ b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/RoleGroupNoMatchWithLoggedInTemplate.razor
@@ -40,6 +40,6 @@
);
- cut.Markup.Trim().ShouldBe("LoggedIn");
+ cut.Find("div").TextContent.Trim().ShouldBe("LoggedIn");
}
}
diff --git a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/RoleGroupNoMatchWithoutLoggedInTemplate.razor b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/RoleGroupNoMatchWithoutLoggedInTemplate.razor
index 82cbf475b..94f9b4778 100644
--- a/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/RoleGroupNoMatchWithoutLoggedInTemplate.razor
+++ b/src/BlazorWebFormsComponents.Test/LoginControls/LoginView/RoleGroupNoMatchWithoutLoggedInTemplate.razor
@@ -37,6 +37,6 @@
);
- cut.Markup.Trim().ShouldBeEmpty();
+ cut.Find("div").TextContent.Trim().ShouldBeEmpty();
}
}
diff --git a/src/BlazorWebFormsComponents.Test/LoginControls/PasswordRecovery/OuterStyle.razor b/src/BlazorWebFormsComponents.Test/LoginControls/PasswordRecovery/OuterStyle.razor
new file mode 100644
index 000000000..dd98d7626
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Test/LoginControls/PasswordRecovery/OuterStyle.razor
@@ -0,0 +1,161 @@
+@using BlazorWebFormsComponents.LoginControls
+@using static BlazorWebFormsComponents.WebColor
+@using Moq
+
+@code {
+
+ [Fact]
+ public void PasswordRecovery_CssClass_RendersOnOuterTable()
+ {
+ // Arrange
+ Services.AddSingleton(new Mock().Object);
+
+ // Act
+ var cut = Render(
+ @
+ );
+
+ // Assert
+ var table = cut.Find("table#pr1");
+ table.ShouldNotBeNull();
+ table.GetAttribute("class").ShouldBe("recovery-panel");
+ }
+
+ [Fact]
+ public void PasswordRecovery_WithoutCssClass_NoClassOnOuterTable()
+ {
+ // Arrange
+ Services.AddSingleton(new Mock().Object);
+
+ // Act
+ var cut = Render(
+ @
+ );
+
+ // Assert
+ var table = cut.Find("table#pr2");
+ table.HasAttribute("class").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void PasswordRecovery_ToolTip_RendersAsTitleAttribute()
+ {
+ // Arrange
+ Services.AddSingleton(new Mock().Object);
+
+ // Act
+ var cut = Render(
+ @
+ );
+
+ // Assert
+ var table = cut.Find("table#pr3");
+ table.GetAttribute("title").ShouldBe("Recover your password");
+ }
+
+ [Fact]
+ public void PasswordRecovery_WithoutToolTip_NoTitleAttribute()
+ {
+ // Arrange
+ Services.AddSingleton(new Mock().Object);
+
+ // Act
+ var cut = Render(
+ @
+ );
+
+ // Assert
+ var table = cut.Find("table#pr4");
+ var title = table.GetAttribute("title");
+ (string.IsNullOrEmpty(title)).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void PasswordRecovery_BackColor_RendersInlineStyle()
+ {
+ // Arrange
+ Services.AddSingleton(new Mock().Object);
+
+ // Act
+ var cut = Render(
+ @
+ );
+
+ // Assert
+ var table = cut.Find("table#pr5");
+ var style = table.GetAttribute("style");
+ style.ShouldNotBeNull();
+ style.ShouldContain("background-color");
+ }
+
+ [Fact]
+ public void PasswordRecovery_ForeColor_RendersInlineStyle()
+ {
+ // Arrange
+ Services.AddSingleton(new Mock().Object);
+
+ // Act
+ var cut = Render(
+ @
+ );
+
+ // Assert
+ var table = cut.Find("table#pr6");
+ var style = table.GetAttribute("style");
+ style.ShouldNotBeNull();
+ style.ShouldContain("color");
+ }
+
+ [Fact]
+ public void PasswordRecovery_FontBold_RendersInlineStyle()
+ {
+ // Arrange
+ Services.AddSingleton(new Mock().Object);
+
+ // Act
+ var cut = Render(
+ @
+ );
+
+ // Assert
+ var table = cut.Find("table#pr7");
+ var style = table.GetAttribute("style");
+ style.ShouldNotBeNull();
+ style.ShouldContain("font-weight:bold");
+ }
+
+ [Fact]
+ public void PasswordRecovery_CssClassAndStyle_BothRender()
+ {
+ // Arrange
+ Services.AddSingleton(new Mock().Object);
+
+ // Act
+ var cut = Render(
+ @
+ );
+
+ // Assert
+ var table = cut.Find("table#pr8");
+ table.GetAttribute("class").ShouldBe("themed");
+ table.GetAttribute("title").ShouldBe("Recovery form");
+ table.GetAttribute("style").ShouldContain("background-color");
+ }
+
+ [Fact]
+ public void PasswordRecovery_StyleIncludesBorderCollapse()
+ {
+ // PasswordRecovery outer table always includes border-collapse:collapse
+ Services.AddSingleton(new Mock().Object);
+
+ var cut = Render(
+ @
+ );
+
+ var table = cut.Find("table#pr9");
+ var style = table.GetAttribute("style");
+ style.ShouldContain("border-collapse:collapse");
+ style.ShouldContain("background-color");
+ }
+
+}
diff --git a/src/BlazorWebFormsComponents.Test/Menu/MenuStyleContent.razor b/src/BlazorWebFormsComponents.Test/Menu/MenuStyleContent.razor
new file mode 100644
index 000000000..a176ab9cd
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Test/Menu/MenuStyleContent.razor
@@ -0,0 +1,268 @@
+@using static BlazorWebFormsComponents.WebColor
+@using static BlazorWebFormsComponents.Enums.BorderStyle
+@using System.Text.RegularExpressions
+
+@code {
+
+ [Fact]
+ public void Menu_StaticMenuStyleContent_AppliesBackgroundAndForeColor()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+
+ var cut = Render(
+ @
+ );
+
+ var theMarkup = cut.Markup;
+
+ // StaticMenuStyle applies to ul.level1 CSS rule
+ var reTest = new Regex(@"ul\.level1\s*\{([^}]+)\}");
+ reTest.Match(theMarkup).Captures.Count.ShouldBe(1);
+ var theStyleRules = reTest.Match(theMarkup).Groups[1].Value;
+
+ theStyleRules.ShouldContain("background-color:");
+ theStyleRules.ShouldContain("color:");
+ }
+
+ [Fact]
+ public void Menu_DynamicMenuStyleContent_AppliesBackgroundAndForeColor()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+
+ var cut = Render(
+ @
+ );
+
+ var theMarkup = cut.Markup;
+
+ // DynamicMenuStyle applies to ul.dynamic CSS rule
+ var reTest = new Regex(@"ul\.dynamic\s*\{([^}]+)\}");
+ reTest.Match(theMarkup).Captures.Count.ShouldBe(1);
+ var theStyleRules = reTest.Match(theMarkup).Groups[1].Value;
+
+ theStyleRules.ShouldContain("background-color:");
+ theStyleRules.ShouldContain("color:");
+ }
+
+ [Fact]
+ public void Menu_StaticMenuItemStyleContent_AppliesStyles()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+
+ var cut = Render(
+ @
+ );
+
+ var theMarkup = cut.Markup;
+
+ // StaticMenuItemStyle applies to a.static CSS rule
+ var reTest = new Regex(@"a\.static\s*\{([^}]+)\}");
+ reTest.Match(theMarkup).Captures.Count.ShouldBe(1);
+ var theStyleRules = reTest.Match(theMarkup).Groups[1].Value;
+
+ theStyleRules.ShouldContain("background-color:");
+ theStyleRules.ShouldContain("color:");
+ }
+
+ [Fact]
+ public void Menu_DynamicMenuItemStyleContent_AppliesStyles()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+
+ var cut = Render(
+ @
+ );
+
+ var theMarkup = cut.Markup;
+
+ // DynamicMenuItemStyle applies to a.dynamic CSS rule
+ var reTest = new Regex(@"a\.dynamic\s*\{([^}]+)\}");
+ reTest.Match(theMarkup).Captures.Count.ShouldBe(1);
+ var theStyleRules = reTest.Match(theMarkup).Groups[1].Value;
+
+ theStyleRules.ShouldContain("background-color:");
+ theStyleRules.ShouldContain("color:");
+ }
+
+ [Fact]
+ public void Menu_StaticMenuStyle_WithBorder_RendersBorderStyle()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+
+ var cut = Render(
+ @
+ );
+
+ var theMarkup = cut.Markup;
+
+ var reTest = new Regex(@"ul\.level1\s*\{([^}]+)\}");
+ var theStyleRules = reTest.Match(theMarkup).Groups[1].Value;
+
+ theStyleRules.ShouldContain("border:");
+ theStyleRules.ShouldContain("background-color:");
+ }
+
+ [Fact]
+ public void Menu_StaticMenuItemStyle_FontBold_RendersFontWeight()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+
+ var cut = Render(
+ @
+ );
+
+ var theMarkup = cut.Markup;
+
+ var reTest = new Regex(@"a\.static\s*\{([^}]+)\}");
+ var theStyleRules = reTest.Match(theMarkup).Groups[1].Value;
+
+ theStyleRules.ShouldContain("font-weight:bold");
+ theStyleRules.ShouldContain("color:");
+ }
+
+ [Fact]
+ public void Menu_CombinedStyles_StaticAndDynamic_BothApply()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+
+ var cut = Render(
+ @
+ );
+
+ var theMarkup = cut.Markup;
+
+ // Verify all four style rules are present in the emitted CSS
+ var staticMenuRe = new Regex(@"ul\.level1\s*\{([^}]+)\}");
+ staticMenuRe.Match(theMarkup).Groups[1].Value.ShouldContain("background-color:");
+
+ var dynamicMenuRe = new Regex(@"ul\.dynamic\s*\{([^}]+)\}");
+ dynamicMenuRe.Match(theMarkup).Groups[1].Value.ShouldContain("background-color:");
+
+ var staticItemRe = new Regex(@"a\.static\s*\{([^}]+)\}");
+ staticItemRe.Match(theMarkup).Groups[1].Value.ShouldContain("color:");
+
+ var dynamicItemRe = new Regex(@"a\.dynamic\s*\{([^}]+)\}");
+ dynamicItemRe.Match(theMarkup).Groups[1].Value.ShouldContain("color:");
+ }
+
+ [Fact]
+ public void Menu_StaticMenuItemStyle_CssClass_IsAccessibleOnInstance()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+
+ var cut = Render(
+ @
+ );
+
+ // Verify the style was set on the Menu instance
+ var menu = cut.FindComponent().Instance;
+ menu.StaticMenuItemStyle.CssClass.ShouldBe("my-static-item");
+ }
+
+ [Fact]
+ public void Menu_DynamicMenuStyle_CssClass_IsAccessibleOnInstance()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+
+ var cut = Render(
+ @
+ );
+
+ var menu = cut.FindComponent().Instance;
+ menu.DynamicMenuStyle.CssClass.ShouldBe("my-dynamic-menu");
+ }
+
+}
diff --git a/src/BlazorWebFormsComponents.Test/RadioButtonList/StableIds.razor b/src/BlazorWebFormsComponents.Test/RadioButtonList/StableIds.razor
new file mode 100644
index 000000000..754b146d0
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Test/RadioButtonList/StableIds.razor
@@ -0,0 +1,161 @@
+@using BlazorWebFormsComponents.Enums
+
+@code {
+
+ [Fact]
+ public void RadioButtonList_WithID_RadioButtonIdsFollowPattern()
+ {
+ // Arrange — When ID="MyList", each radio id should be MyList_0, MyList_1, MyList_2
+ var items = new ListItemCollection
+ {
+ new ListItem("Small", "S"),
+ new ListItem("Medium", "M"),
+ new ListItem("Large", "L")
+ };
+
+ // Act
+ var cut = Render(@ );
+
+ // Assert
+ var radios = cut.FindAll("input[type='radio']");
+ radios.Count.ShouldBe(3);
+ radios[0].GetAttribute("id").ShouldBe("MyList_0");
+ radios[1].GetAttribute("id").ShouldBe("MyList_1");
+ radios[2].GetAttribute("id").ShouldBe("MyList_2");
+ }
+
+ [Fact]
+ public void RadioButtonList_WithID_NameAttributeEqualsControlID()
+ {
+ // Arrange — The name attribute on all radio buttons should equal the control ID
+ var items = new ListItemCollection
+ {
+ new ListItem("Option A", "A"),
+ new ListItem("Option B", "B"),
+ new ListItem("Option C", "C")
+ };
+
+ // Act
+ var cut = Render(@ );
+
+ // Assert
+ var radios = cut.FindAll("input[type='radio']");
+ radios.Count.ShouldBe(3);
+ foreach (var radio in radios)
+ {
+ radio.GetAttribute("name").ShouldBe("MyList");
+ }
+ }
+
+ [Fact]
+ public void RadioButtonList_WithoutID_StillRenders()
+ {
+ // Arrange — When no ID is provided, component should still render with fallback IDs
+ var items = new ListItemCollection
+ {
+ new ListItem("Yes", "Y"),
+ new ListItem("No", "N")
+ };
+
+ // Act
+ var cut = Render(@ );
+
+ // Assert
+ var radios = cut.FindAll("input[type='radio']");
+ radios.Count.ShouldBe(2);
+
+ // Each radio should have an id attribute (generated fallback)
+ foreach (var radio in radios)
+ {
+ radio.HasAttribute("id").ShouldBeTrue();
+ radio.GetAttribute("id").ShouldNotBeNullOrEmpty();
+ }
+
+ // All radios should share the same name for mutual exclusion
+ var name = radios[0].GetAttribute("name");
+ name.ShouldNotBeNullOrEmpty();
+ radios[1].GetAttribute("name").ShouldBe(name);
+ }
+
+ [Fact]
+ public void RadioButtonList_WithID_LabelForMatchesInputId()
+ {
+ // Arrange — Labels must reference the correct input IDs for accessibility
+ var items = new ListItemCollection
+ {
+ new ListItem("Red", "R"),
+ new ListItem("Blue", "B")
+ };
+
+ // Act
+ var cut = Render(@ );
+
+ // Assert
+ var labels = cut.FindAll("label");
+ labels.Count.ShouldBe(2);
+ labels[0].GetAttribute("for").ShouldBe("Colors_0");
+ labels[1].GetAttribute("for").ShouldBe("Colors_1");
+ }
+
+ [Fact]
+ public void RadioButtonList_WithID_FlowLayout_IdsFollowPattern()
+ {
+ // Arrange — Stable IDs should work across all RepeatLayout modes
+ var items = new ListItemCollection
+ {
+ new ListItem("A", "a"),
+ new ListItem("B", "b")
+ };
+
+ // Act
+ var cut = Render(
+ @
+ );
+
+ // Assert
+ var radios = cut.FindAll("input[type='radio']");
+ radios[0].GetAttribute("id").ShouldBe("FlowList_0");
+ radios[1].GetAttribute("id").ShouldBe("FlowList_1");
+ radios[0].GetAttribute("name").ShouldBe("FlowList");
+ radios[1].GetAttribute("name").ShouldBe("FlowList");
+ }
+
+ [Fact]
+ public void CheckBox_WithID_UsesIDDirectlyOnInput()
+ {
+ // Arrange — CheckBox with a provided ID should use that ID directly on the input element
+ // Act
+ var cut = Render(@ );
+
+ // Assert
+ var input = cut.Find("input[type='checkbox']");
+ input.GetAttribute("id").ShouldBe("myCheck");
+ }
+
+ [Fact]
+ public void RadioButtonList_WithID_UnorderedListLayout_IdsFollowPattern()
+ {
+ // Arrange — Verify pattern across UnorderedList layout too
+ var items = new ListItemCollection
+ {
+ new ListItem("X", "x"),
+ new ListItem("Y", "y"),
+ new ListItem("Z", "z")
+ };
+
+ // Act
+ var cut = Render(
+ @
+ );
+
+ // Assert
+ var radios = cut.FindAll("input[type='radio']");
+ radios.Count.ShouldBe(3);
+ radios[0].GetAttribute("id").ShouldBe("UlList_0");
+ radios[1].GetAttribute("id").ShouldBe("UlList_1");
+ radios[2].GetAttribute("id").ShouldBe("UlList_2");
+ }
+
+}
diff --git a/src/BlazorWebFormsComponents.Test/ScriptManager/ScriptManagerTests.razor b/src/BlazorWebFormsComponents.Test/ScriptManager/ScriptManagerTests.razor
new file mode 100644
index 000000000..327098164
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Test/ScriptManager/ScriptManagerTests.razor
@@ -0,0 +1,140 @@
+@inherits BlazorWebFormsTestContext
+@using BlazorWebFormsComponents.Enums
+
+@code {
+
+ [Fact]
+ public void ScriptManager_RendersNoVisibleOutput()
+ {
+ // Arrange & Act
+ var cut = Render(@ );
+
+ // Assert
+ cut.Markup.Trim().ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void ScriptManager_DefaultEnablePartialRendering_IsTrue()
+ {
+ var cut = Render(@ );
+ var sm = cut.FindComponent();
+ sm.Instance.EnablePartialRendering.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void ScriptManager_DefaultEnablePageMethods_IsFalse()
+ {
+ var cut = Render(@ );
+ var sm = cut.FindComponent();
+ sm.Instance.EnablePageMethods.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ScriptManager_DefaultScriptMode_IsAuto()
+ {
+ var cut = Render(@ );
+ var sm = cut.FindComponent();
+ sm.Instance.ScriptMode.ShouldBe(ScriptMode.Auto);
+ }
+
+ [Fact]
+ public void ScriptManager_DefaultAsyncPostBackTimeout_Is90()
+ {
+ var cut = Render(@ );
+ var sm = cut.FindComponent();
+ sm.Instance.AsyncPostBackTimeout.ShouldBe(90);
+ }
+
+ [Fact]
+ public void ScriptManager_DefaultEnableCdn_IsFalse()
+ {
+ var cut = Render(@ );
+ var sm = cut.FindComponent();
+ sm.Instance.EnableCdn.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ScriptManager_DefaultEnableScriptGlobalization_IsFalse()
+ {
+ var cut = Render(@ );
+ var sm = cut.FindComponent();
+ sm.Instance.EnableScriptGlobalization.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ScriptManager_DefaultEnableScriptLocalization_IsFalse()
+ {
+ var cut = Render(@ );
+ var sm = cut.FindComponent();
+ sm.Instance.EnableScriptLocalization.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ScriptManager_SetProperties_DoesNotThrow()
+ {
+ ScriptMode mode = ScriptMode.Release;
+
+ // Arrange & Act & Assert — no exception
+ var cut = Render(
+ @
+ );
+
+ var sm = cut.FindComponent();
+ sm.Instance.EnablePartialRendering.ShouldBeTrue();
+ sm.Instance.ScriptMode.ShouldBe(ScriptMode.Release);
+ sm.Instance.AsyncPostBackTimeout.ShouldBe(120);
+ }
+
+ [Fact]
+ public void ScriptManager_DefaultScripts_IsInitialized()
+ {
+ var cut = Render(@ );
+ var sm = cut.FindComponent();
+ sm.Instance.Scripts.ShouldNotBeNull();
+ sm.Instance.Scripts.Count.ShouldBe(0);
+ }
+
+ [Fact]
+ public void ScriptManager_Scripts_CanHoldScriptReferences()
+ {
+ var scripts = new System.Collections.Generic.List
+ {
+ new ScriptReference { Name = "jquery", Path = "~/scripts/jquery.js" },
+ new ScriptReference { Name = "app", Path = "~/scripts/app.js" }
+ };
+
+ var cut = Render(@ );
+ var sm = cut.FindComponent();
+ sm.Instance.Scripts.Count.ShouldBe(2);
+ sm.Instance.Scripts[0].Name.ShouldBe("jquery");
+ sm.Instance.Scripts[1].Path.ShouldBe("~/scripts/app.js");
+ }
+
+ [Fact]
+ public void ScriptReference_DefaultScriptMode_IsAuto()
+ {
+ var scriptRef = new ScriptReference();
+ scriptRef.ScriptMode.ShouldBe(ScriptMode.Auto);
+ }
+
+ [Fact]
+ public void ScriptReference_DefaultNotifyScriptLoaded_IsTrue()
+ {
+ var scriptRef = new ScriptReference();
+ scriptRef.NotifyScriptLoaded.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void ScriptReference_DefaultResourceUICultures_IsNull()
+ {
+ var scriptRef = new ScriptReference();
+ scriptRef.ResourceUICultures.ShouldBeNull();
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Test/ScriptManagerProxy/ScriptManagerProxyTests.razor b/src/BlazorWebFormsComponents.Test/ScriptManagerProxy/ScriptManagerProxyTests.razor
new file mode 100644
index 000000000..7d3f5f2ee
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Test/ScriptManagerProxy/ScriptManagerProxyTests.razor
@@ -0,0 +1,40 @@
+@inherits BlazorWebFormsTestContext
+
+@code {
+
+ [Fact]
+ public void ScriptManagerProxy_RendersNoVisibleOutput()
+ {
+ // Arrange & Act
+ var cut = Render(@ );
+
+ // Assert
+ cut.Markup.Trim().ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void ScriptManagerProxy_CanBeRenderedWithoutErrors()
+ {
+ // Arrange & Act & Assert — no exception thrown
+ var cut = Render(@ );
+ cut.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void ScriptManagerProxy_DefaultScripts_IsEmptyList()
+ {
+ var cut = Render(@ );
+ var proxy = cut.FindComponent();
+ proxy.Instance.Scripts.ShouldNotBeNull();
+ proxy.Instance.Scripts.Count.ShouldBe(0);
+ }
+
+ [Fact]
+ public void ScriptManagerProxy_DefaultServices_IsEmptyList()
+ {
+ var cut = Render(@ );
+ var proxy = cut.FindComponent();
+ proxy.Instance.Services.ShouldNotBeNull();
+ proxy.Instance.Services.Count.ShouldBe(0);
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Test/Style/FontInfoSyncTests.cs b/src/BlazorWebFormsComponents.Test/Style/FontInfoSyncTests.cs
new file mode 100644
index 000000000..bfef2ff8f
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Test/Style/FontInfoSyncTests.cs
@@ -0,0 +1,110 @@
+using Shouldly;
+using Xunit;
+
+namespace BlazorWebFormsComponents.Test.Style;
+
+///
+/// Tests for FontInfo Name/Names auto-sync behavior.
+/// In ASP.NET Web Forms, setting Font.Name also sets Font.Names and vice versa.
+/// These tests validate that contract after the auto-sync fix.
+///
+public class FontInfoSyncTests
+{
+ // 1. Setting Name also updates Names
+ [Fact]
+ public void SettingName_UpdatesNames()
+ {
+ var font = new FontInfo();
+ font.Name = "Arial";
+
+ font.Names.ShouldBe("Arial");
+ }
+
+ // 2. Setting Names also updates Name (first font)
+ [Fact]
+ public void SettingNames_UpdatesName_ToFirstFont()
+ {
+ var font = new FontInfo();
+ font.Names = "Verdana";
+
+ font.Name.ShouldBe("Verdana");
+ }
+
+ // 3. Setting Names with multiple fonts sets Name to first font
+ [Fact]
+ public void SettingNames_WithMultipleFonts_SetsNameToFirst()
+ {
+ var font = new FontInfo();
+ font.Names = "Arial, sans-serif";
+
+ font.Name.ShouldBe("Arial");
+ }
+
+ // 4. Setting Name to null clears Names
+ [Fact]
+ public void SettingName_ToNull_ClearsNames()
+ {
+ var font = new FontInfo { Name = "Arial" };
+
+ font.Name = null;
+
+ font.Names.ShouldBeNullOrEmpty();
+ }
+
+ // 5. Setting Name to empty clears Names
+ [Fact]
+ public void SettingName_ToEmpty_ClearsNames()
+ {
+ var font = new FontInfo { Name = "Arial" };
+
+ font.Name = "";
+
+ font.Names.ShouldBeNullOrEmpty();
+ }
+
+ // 6. Setting Names to null clears Name
+ [Fact]
+ public void SettingNames_ToNull_ClearsName()
+ {
+ var font = new FontInfo { Names = "Arial" };
+
+ font.Names = null;
+
+ font.Name.ShouldBeNullOrEmpty();
+ }
+
+ // 7. Setting Names to empty clears Name
+ [Fact]
+ public void SettingNames_ToEmpty_ClearsName()
+ {
+ var font = new FontInfo { Names = "Arial" };
+
+ font.Names = "";
+
+ font.Name.ShouldBeNullOrEmpty();
+ }
+
+ // 8. Setting Names then Name — Name wins for both properties
+ [Fact]
+ public void SettingNames_ThenName_NameWins()
+ {
+ var font = new FontInfo();
+ font.Names = "Verdana, sans-serif";
+ font.Name = "Arial";
+
+ font.Name.ShouldBe("Arial");
+ font.Names.ShouldBe("Arial");
+ }
+
+ // 9. Setting Name then Names — Names wins for both properties
+ [Fact]
+ public void SettingName_ThenNames_NamesWins()
+ {
+ var font = new FontInfo();
+ font.Name = "Arial";
+ font.Names = "Verdana, sans-serif";
+
+ font.Name.ShouldBe("Verdana");
+ font.Names.ShouldBe("Verdana, sans-serif");
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Test/Substitution/SubstitutionTests.razor b/src/BlazorWebFormsComponents.Test/Substitution/SubstitutionTests.razor
new file mode 100644
index 000000000..e43b8a588
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Test/Substitution/SubstitutionTests.razor
@@ -0,0 +1,82 @@
+@inherits BlazorWebFormsTestContext
+@using Microsoft.AspNetCore.Http
+
+@code {
+
+ [Fact]
+ public void Substitution_WithCallback_RendersCallbackOutput()
+ {
+ // Arrange
+ Func callback = (ctx) => "Dynamic Content ";
+
+ // Act
+ var cut = Render(@ );
+
+ // Assert
+ cut.Markup.ShouldContain("Dynamic Content ");
+ }
+
+ [Fact]
+ public void Substitution_NullCallback_RendersNothing()
+ {
+ // Arrange & Act
+ var cut = Render(@ );
+
+ // Assert
+ cut.Markup.Trim().ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Substitution_MethodName_CanBeSet()
+ {
+ // Arrange
+ Func callback = (ctx) => "test";
+
+ // Act
+ var cut = Render(@ );
+
+ // Assert
+ var sub = cut.FindComponent();
+ sub.Instance.MethodName.ShouldBe("GetContent");
+ }
+
+ [Fact]
+ public void Substitution_RendersNoWrapperElement()
+ {
+ // Arrange
+ Func callback = (ctx) => "Plain text output";
+
+ // Act
+ var cut = Render(@ );
+
+ // Assert — the output should be raw text, no wrapping div/span
+ cut.Markup.Trim().ShouldBe("Plain text output");
+ }
+
+ [Fact]
+ public void Substitution_CallbackReturnsHtml_RendersAsMarkup()
+ {
+ // Arrange
+ Func callback = (ctx) => "Italic and Bold ";
+
+ // Act
+ var cut = Render(@ );
+
+ // Assert
+ cut.Find("em").ShouldNotBeNull();
+ cut.Find("b").ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void Substitution_CallbackReturnsEmpty_RendersEmpty()
+ {
+ // Arrange
+ Func callback = (ctx) => "";
+
+ // Act
+ var cut = Render(@ );
+
+ // Assert
+ cut.Markup.Trim().ShouldBeEmpty();
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Test/Theming/ThemeConfigurationFluentTests.cs b/src/BlazorWebFormsComponents.Test/Theming/ThemeConfigurationFluentTests.cs
new file mode 100644
index 000000000..b84c63e63
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Test/Theming/ThemeConfigurationFluentTests.cs
@@ -0,0 +1,205 @@
+using BlazorWebFormsComponents.Enums;
+using BlazorWebFormsComponents.Theming;
+using Shouldly;
+using Xunit;
+
+namespace BlazorWebFormsComponents.Test.Theming;
+
+///
+/// Unit tests for the ThemeConfiguration fluent API and SkinBuilder (Issue #364).
+///
+public class ThemeConfigurationFluentTests
+{
+ [Fact]
+ public void ForControl_DefaultSkin_SetsBackColor()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.BackColor, WebColor.FromHtml("#FFDEAD")));
+
+ var skin = theme.GetSkin("Button");
+ skin.ShouldNotBeNull();
+ skin.BackColor.ToHtml().ShouldBe("#FFDEAD");
+ }
+
+ [Fact]
+ public void ForControl_NamedSkin_SetsProperties()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", "goButton", skin => skin
+ .Set(s => s.BackColor, WebColor.FromHtml("#006633"))
+ .Set(s => s.Width, new Unit("120px")));
+
+ var skin = theme.GetSkin("Button", "goButton");
+ skin.ShouldNotBeNull();
+ skin.BackColor.ToHtml().ShouldBe("#006633");
+ skin.Width.ShouldNotBeNull();
+ skin.Width.Value.Value.ShouldBe(120);
+ }
+
+ [Fact]
+ public void ForControl_DefaultAndNamed_AreSeparate()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.BackColor, WebColor.FromHtml("#FFDEAD")))
+ .ForControl("Button", "goButton", skin => skin
+ .Set(s => s.BackColor, WebColor.FromHtml("#006633")));
+
+ var defaultSkin = theme.GetSkin("Button");
+ var namedSkin = theme.GetSkin("Button", "goButton");
+
+ defaultSkin.ShouldNotBeNull();
+ namedSkin.ShouldNotBeNull();
+ defaultSkin.BackColor.ToHtml().ShouldBe("#FFDEAD");
+ namedSkin.BackColor.ToHtml().ShouldBe("#006633");
+ }
+
+ [Fact]
+ public void ForControl_NestedFontBold_SetsValue()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.Font.Bold, true));
+
+ var skin = theme.GetSkin("Button");
+ skin.ShouldNotBeNull();
+ skin.Font.ShouldNotBeNull();
+ skin.Font.Bold.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void ForControl_NestedFontName_SetsValue()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Label", skin => skin
+ .Set(s => s.Font.Name, "Arial")
+ .Set(s => s.Font.Italic, true));
+
+ var skin = theme.GetSkin("Label");
+ skin.Font.ShouldNotBeNull();
+ skin.Font.Name.ShouldBe("Arial");
+ skin.Font.Italic.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void ForControl_MultipleProperties_AllSet()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("GridView", skin => skin
+ .Set(s => s.BackColor, WebColor.FromHtml("#FFFFFF"))
+ .Set(s => s.ForeColor, WebColor.FromHtml("#000000"))
+ .Set(s => s.CssClass, "grid-theme")
+ .Set(s => s.BorderStyle, BorderStyle.Solid)
+ .Set(s => s.BorderWidth, new Unit(1))
+ .Set(s => s.Height, new Unit("200px"))
+ .Set(s => s.Width, new Unit("100%"))
+ .Set(s => s.ToolTip, "Themed GridView"));
+
+ var skin = theme.GetSkin("GridView");
+ skin.ShouldNotBeNull();
+ skin.CssClass.ShouldBe("grid-theme");
+ skin.BorderStyle.ShouldBe(BorderStyle.Solid);
+ skin.BorderWidth.ShouldNotBeNull();
+ skin.Height.ShouldNotBeNull();
+ skin.Width.ShouldNotBeNull();
+ skin.ToolTip.ShouldBe("Themed GridView");
+ }
+
+ [Fact]
+ public void ForControl_ChainingMultipleControlTypes_Works()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.BackColor, WebColor.Red))
+ .ForControl("Label", skin => skin
+ .Set(s => s.ForeColor, WebColor.Blue))
+ .ForControl("GridView", skin => skin
+ .Set(s => s.CssClass, "themed"));
+
+ theme.GetSkin("Button").ShouldNotBeNull();
+ theme.GetSkin("Label").ShouldNotBeNull();
+ theme.GetSkin("GridView").ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void ForControl_CaseInsensitiveLookup_Works()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.BackColor, WebColor.Red));
+
+ theme.GetSkin("button").ShouldNotBeNull();
+ theme.GetSkin("BUTTON").ShouldNotBeNull();
+ theme.GetSkin("Button").ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void GetSkin_MissingSkinID_ReturnsNull()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.BackColor, WebColor.Red));
+
+ theme.GetSkin("Button", "nonExistent").ShouldBeNull();
+ }
+
+ [Fact]
+ public void GetSkin_MissingControlType_ReturnsNull()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.BackColor, WebColor.Red));
+
+ theme.GetSkin("TextBox").ShouldBeNull();
+ }
+
+ [Fact]
+ public void HasSkins_ReturnsTrueForRegisteredControl()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.BackColor, WebColor.Red));
+
+ theme.HasSkins("Button").ShouldBeTrue();
+ theme.HasSkins("TextBox").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void WebColor_FromHtml_CreatesColor()
+ {
+ var color = WebColor.FromHtml("#FFDEAD");
+ color.ShouldNotBeNull();
+ color.IsEmpty.ShouldBeFalse();
+ color.ToHtml().ShouldBe("#FFDEAD");
+ }
+
+ [Fact]
+ public void WebColor_FromHtml_NamedColor()
+ {
+ var color = WebColor.FromHtml("Red");
+ color.ShouldNotBeNull();
+ color.IsEmpty.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void FullFluentExample_MatchesSpecSignature()
+ {
+ // This test validates the exact API signature from the POC plan
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.BackColor, WebColor.FromHtml("#FFDEAD"))
+ .Set(s => s.Font.Bold, true))
+ .ForControl("Button", "goButton", skin => skin
+ .Set(s => s.BackColor, WebColor.FromHtml("#006633"))
+ .Set(s => s.Width, new Unit("120px")));
+
+ var defaultSkin = theme.GetSkin("Button");
+ defaultSkin.BackColor.ToHtml().ShouldBe("#FFDEAD");
+ defaultSkin.Font.Bold.ShouldBeTrue();
+
+ var goSkin = theme.GetSkin("Button", "goButton");
+ goSkin.BackColor.ToHtml().ShouldBe("#006633");
+ goSkin.Width.Value.Value.ShouldBe(120);
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Test/Theming/ThemingPipelineTests.razor b/src/BlazorWebFormsComponents.Test/Theming/ThemingPipelineTests.razor
new file mode 100644
index 000000000..aa3d68ec7
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Test/Theming/ThemingPipelineTests.razor
@@ -0,0 +1,315 @@
+@using BlazorWebFormsComponents.Theming
+@using BlazorWebFormsComponents.Enums
+@using static BlazorWebFormsComponents.WebColor
+
+@code {
+
+ ///
+ /// End-to-end theming pipeline tests (Issue #368 / WI-5).
+ /// Validates that ThemeProvider → BaseWebFormsComponent → BaseStyledComponent
+ /// pipeline works correctly with real components (Button, Label, Panel).
+ ///
+
+ // 1. Default skin applies — Button inside ThemeProvider gets theme BackColor
+ [Fact]
+ public void DefaultSkin_AppliesBackColor_ToButton()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.BackColor, FromHtml("#FFDEAD")));
+
+ var cut = Render(
+ @
+
+
+ );
+
+ var input = cut.Find("input");
+ input.GetAttribute("style").ShouldContain("background-color:#FFDEAD");
+ }
+
+ // 2. Named skin applies — Button with SkinID="highlight" gets the named skin
+ [Fact]
+ public void NamedSkin_AppliesVia_SkinID()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.BackColor, Blue))
+ .ForControl("Button", "highlight", skin => skin
+ .Set(s => s.BackColor, Red)
+ .Set(s => s.Font.Bold, true));
+
+ var cut = Render(
+ @
+
+
+ );
+
+ var input = cut.Find("input");
+ var style = input.GetAttribute("style");
+ style.ShouldContain("background-color:Red");
+ style.ShouldNotContain("background-color:Blue");
+ style.ShouldContain("font-weight:bold");
+ }
+
+ // 3. Explicit value overrides theme — Button with explicit BackColor keeps its value
+ [Fact]
+ public void ExplicitValue_OverridesTheme_StyleSheetThemeSemantics()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.BackColor, Blue));
+
+ var cut = Render(
+ @
+
+
+ );
+
+ var input = cut.Find("input");
+ var style = input.GetAttribute("style");
+ style.ShouldContain("background-color:Red");
+ style.ShouldNotContain("background-color:Blue");
+ }
+
+ // 4. EnableTheming=false — Button ignores the theme entirely
+ [Fact]
+ public void EnableThemingFalse_IgnoresTheme()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.BackColor, Blue)
+ .Set(s => s.CssClass, "themed"));
+
+ var cut = Render(
+ @
+
+
+ );
+
+ var input = cut.Find("input");
+ input.HasAttribute("style").ShouldBeFalse();
+ input.HasAttribute("class").ShouldBeFalse();
+ }
+
+ // 5. No ThemeProvider — Button outside any ThemeProvider works normally
+ [Fact]
+ public void NoThemeProvider_WorksNormally()
+ {
+ var cut = Render(@ );
+
+ var input = cut.Find("input");
+ input.GetAttribute("value").ShouldBe("Plain");
+ input.HasAttribute("style").ShouldBeFalse();
+ }
+
+ // 6. Missing SkinID — Button with SkinID="nonexistent" doesn't throw, uses default skin
+ [Fact]
+ public void MissingSkinID_DoesNotThrow_FallsBackGracefully()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.BackColor, Blue));
+
+ var cut = Render(
+ @
+
+
+ );
+
+ // Named skin "nonexistent" not found → no skin applied (not even default)
+ var input = cut.Find("input");
+ input.HasAttribute("style").ShouldBeFalse();
+ }
+
+ // 7. Nested ThemeProvider — Inner overrides outer for its children
+ [Fact]
+ public void NestedThemeProvider_InnerOverridesOuter()
+ {
+ var outerTheme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.BackColor, Red));
+ var innerTheme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.BackColor, Blue));
+
+ var cut = Render(
+ @
+
+
+
+
+ );
+
+ var input = cut.Find("input");
+ var style = input.GetAttribute("style");
+ style.ShouldContain("background-color:Blue");
+ style.ShouldNotContain("background-color:Red");
+ }
+
+ // 8. Theme applies ForeColor to Panel
+ [Fact]
+ public void Theme_AppliesForeColor_ToPanel()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Panel", skin => skin
+ .Set(s => s.ForeColor, FromHtml("#333333")));
+
+ var cut = Render(
+ @
+ Content
+
+ );
+
+ var div = cut.Find("div");
+ div.GetAttribute("style").ShouldContain("color:#333333");
+ }
+
+ // 9. Theme applies CssClass to Label
+ [Fact]
+ public void Theme_AppliesCssClass_ToLabel()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Label", skin => skin
+ .Set(s => s.CssClass, "theme-label"));
+
+ var cut = Render(
+ @
+
+
+ );
+
+ var span = cut.Find("span");
+ span.GetAttribute("class").ShouldBe("theme-label");
+ }
+
+ // 10. Theme applies Width and Height to Button
+ [Fact]
+ public void Theme_AppliesWidthAndHeight_ToButton()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.Width, new Unit("200px"))
+ .Set(s => s.Height, new Unit("40px")));
+
+ var cut = Render(
+ @
+
+
+ );
+
+ var input = cut.Find("input");
+ var style = input.GetAttribute("style");
+ style.ShouldContain("width:200px");
+ style.ShouldContain("height:40px");
+ }
+
+ // 11. Theme applies Font properties to Label
+ [Fact]
+ public void Theme_AppliesFontProperties_ToLabel()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Label", skin => skin
+ .Set(s => s.Font.Bold, true)
+ .Set(s => s.Font.Italic, true)
+ .Set(s => s.Font.Underline, true));
+
+ var cut = Render(
+ @
+
+
+ );
+
+ var span = cut.Find("span");
+ var style = span.GetAttribute("style");
+ style.ShouldContain("font-weight:bold");
+ style.ShouldContain("font-style:italic");
+ style.ShouldContain("text-decoration:underline");
+ }
+
+ // 12. Multiple control types themed in same ThemeProvider
+ [Fact]
+ public void MultipleControlTypes_ThemedSimultaneously()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.BackColor, Red))
+ .ForControl("Label", skin => skin
+ .Set(s => s.ForeColor, Blue))
+ .ForControl("Panel", skin => skin
+ .Set(s => s.BackColor, FromHtml("#EEEEEE")));
+
+ var cut = Render(
+ @
+
+
+ Pnl
+
+ );
+
+ cut.Find("input").GetAttribute("style").ShouldContain("background-color:Red");
+ cut.Find("span").GetAttribute("style").ShouldContain("color:Blue");
+ cut.Find("div").GetAttribute("style").ShouldContain("background-color:#EEEEEE");
+ }
+
+ // 14. Theme Font.Name propagates through auto-sync to Font.Names → font-family in rendered HTML
+ [Fact]
+ public void Theme_FontName_RendersFontFamily_ViaAutoSync()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Button", skin => skin
+ .Set(s => s.Font.Name, "Arial"));
+
+ var cut = Render(
+ @
+
+
+ );
+
+ var input = cut.Find("input");
+ var style = input.GetAttribute("style");
+ style.ShouldNotBeNull();
+ style.ShouldContain("font-family");
+ style.ShouldContain("Arial");
+ }
+
+ // 15. Theme Font.Name with multiple fonts via Names renders font-family
+ [Fact]
+ public void Theme_FontName_MultipleViaNames_RendersFontFamily()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Label", skin => skin
+ .Set(s => s.Font.Name, "Verdana"));
+
+ var cut = Render(
+ @
+
+
+ );
+
+ var span = cut.Find("span");
+ var style = span.GetAttribute("style");
+ style.ShouldNotBeNull();
+ style.ShouldContain("font-family");
+ style.ShouldContain("Verdana");
+ }
+
+ // 13. Explicit CssClass overrides theme CssClass
+ [Fact]
+ public void ExplicitCssClass_OverridesThemeCssClass()
+ {
+ var theme = new ThemeConfiguration()
+ .ForControl("Label", skin => skin
+ .Set(s => s.CssClass, "theme-class"));
+
+ var cut = Render(
+ @
+
+
+ );
+
+ var span = cut.Find("span");
+ span.GetAttribute("class").ShouldBe("my-class");
+ }
+
+}
diff --git a/src/BlazorWebFormsComponents.Test/Theming/ThemingShould.razor b/src/BlazorWebFormsComponents.Test/Theming/ThemingShould.razor
index 4bb9d507f..7a729af6a 100644
--- a/src/BlazorWebFormsComponents.Test/Theming/ThemingShould.razor
+++ b/src/BlazorWebFormsComponents.Test/Theming/ThemingShould.razor
@@ -131,4 +131,70 @@
span.HasAttribute("style").ShouldBeFalse();
}
+ [Fact]
+ public void NestedThemeProviderOverridesOuter()
+ {
+ // Arrange
+ var outerTheme = new ThemeConfiguration()
+ .ForControl("Label", skin => skin
+ .Set(s => s.BackColor, Red));
+ var innerTheme = new ThemeConfiguration()
+ .ForControl("Label", skin => skin
+ .Set(s => s.BackColor, Blue));
+
+ // Act
+ var cut = Render(
+ @
+
+
+
+
+ );
+
+ // Assert — inner theme should win
+ var span = cut.Find("span");
+ span.GetAttribute("style").ShouldContain("background-color:Blue");
+ span.GetAttribute("style").ShouldNotContain("background-color:Red");
+ }
+
+ [Fact]
+ public void ThemeProviderRendersNoExtraHtmlElements()
+ {
+ // Arrange
+ var theme = new ThemeConfiguration();
+
+ // Act
+ var cut = Render(
+ @
+
+
+ );
+
+ // Assert — only the label span, no wrapper divs
+ cut.FindAll("div").Count.ShouldBe(0);
+ cut.Find("span").ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void FluentApiWorksWithThemeProvider()
+ {
+ // Arrange — use fluent API to build the theme
+ var theme = new ThemeConfiguration()
+ .ForControl("Label", skin => skin
+ .Set(s => s.BackColor, Blue)
+ .Set(s => s.CssClass, "themed-label"));
+
+ // Act
+ var cut = Render(
+ @
+
+
+ );
+
+ // Assert
+ var span = cut.Find("span");
+ span.GetAttribute("style").ShouldContain("background-color:Blue");
+ span.GetAttribute("class").ShouldBe("themed-label");
+ }
+
}
diff --git a/src/BlazorWebFormsComponents.Test/Timer/TimerTests.razor b/src/BlazorWebFormsComponents.Test/Timer/TimerTests.razor
new file mode 100644
index 000000000..da7fa9159
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Test/Timer/TimerTests.razor
@@ -0,0 +1,94 @@
+@inherits BlazorWebFormsTestContext
+
+@code {
+
+ // Timer uses `new Enabled` which shadows BaseWebFormsComponent.Enabled,
+ // preventing Razor-template rendering. Use Render with C# parameter builder.
+
+ [Fact]
+ public void Timer_DefaultInterval_Is60000()
+ {
+ var cut = Render();
+ cut.Instance.Interval.ShouldBe(60000);
+ }
+
+ [Fact]
+ public void Timer_DefaultEnabled_IsTrue()
+ {
+ var cut = Render();
+ cut.Instance.Enabled.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Timer_RendersNoVisibleOutput()
+ {
+ var cut = Render();
+ cut.Markup.Trim().ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Timer_CanSetInterval()
+ {
+ var cut = Render(p => p.Add(t => t.Interval, 5000));
+ cut.Instance.Interval.ShouldBe(5000);
+ }
+
+ [Fact]
+ public void Timer_EnabledFalse_CanBeSet()
+ {
+ var cut = Render(p => p.Add(t => t.Enabled, false));
+ cut.Instance.Enabled.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task Timer_OnTick_IsInvokedAfterInterval()
+ {
+ var tickCount = 0;
+ var tcs = new TaskCompletionSource();
+
+ var cut = Render(p => p
+ .Add(t => t.Interval, 50)
+ .Add(t => t.OnTick, () =>
+ {
+ tickCount++;
+ if (tickCount >= 1)
+ tcs.TrySetResult(true);
+ })
+ );
+
+ // Wait for at least one tick
+ var completed = await Task.WhenAny(tcs.Task, Task.Delay(5000));
+ completed.ShouldBe(tcs.Task, "Timer should have ticked within 5 seconds");
+ tickCount.ShouldBeGreaterThanOrEqualTo(1);
+ }
+
+ [Fact]
+ public async Task Timer_EnabledFalse_DoesNotTick()
+ {
+ var tickCount = 0;
+
+ var cut = Render(p => p
+ .Add(t => t.Interval, 50)
+ .Add(t => t.Enabled, false)
+ .Add(t => t.OnTick, () => { tickCount++; })
+ );
+
+ await Task.Delay(200);
+ tickCount.ShouldBe(0);
+ }
+
+ [Fact]
+ public void Timer_ImplementsIDisposable()
+ {
+ var cut = Render(p => p.Add(t => t.Interval, 100));
+ // Disposing should not throw
+ ((IDisposable)cut.Instance).Dispose();
+ }
+
+ [Fact]
+ public void Timer_RendersWithoutErrors()
+ {
+ var cut = Render();
+ cut.ShouldNotBeNull();
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Test/UpdatePanel/UpdatePanelTests.razor b/src/BlazorWebFormsComponents.Test/UpdatePanel/UpdatePanelTests.razor
new file mode 100644
index 000000000..5f1b5c7cb
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Test/UpdatePanel/UpdatePanelTests.razor
@@ -0,0 +1,124 @@
+@inherits BlazorWebFormsTestContext
+@using BlazorWebFormsComponents.Enums
+
+@code {
+
+ [Fact]
+ public void UpdatePanel_DefaultRenderMode_IsBlock()
+ {
+ var cut = Render(@Content
);
+ var panel = cut.FindComponent();
+ panel.Instance.RenderMode.ShouldBe(UpdatePanelRenderMode.Block);
+ }
+
+ [Fact]
+ public void UpdatePanel_BlockMode_RendersDiv()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+ Content
+
+ );
+
+ // Assert
+ var div = cut.Find("div");
+ div.ShouldNotBeNull();
+ div.InnerHtml.ShouldContain("Content
");
+ }
+
+ [Fact]
+ public void UpdatePanel_InlineMode_RendersSpan()
+ {
+ UpdatePanelRenderMode mode = UpdatePanelRenderMode.Inline;
+
+ // Arrange & Act
+ var cut = Render(
+ @
+ Inner
+
+ );
+
+ // Assert
+ var span = cut.Find("span#upInline");
+ span.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void UpdatePanel_BlockMode_RendersChildContent()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+ Hello World
+
+ );
+
+ // Assert
+ cut.Markup.ShouldContain("Hello World");
+ }
+
+ [Fact]
+ public void UpdatePanel_DefaultUpdateMode_IsAlways()
+ {
+ var cut = Render(@Content
);
+ var panel = cut.FindComponent();
+ panel.Instance.UpdateMode.ShouldBe(UpdatePanelUpdateMode.Always);
+ }
+
+ [Fact]
+ public void UpdatePanel_ConditionalUpdateMode_Accepted()
+ {
+ UpdatePanelUpdateMode mode = UpdatePanelUpdateMode.Conditional;
+
+ var cut = Render(@Content
);
+ var panel = cut.FindComponent();
+ panel.Instance.UpdateMode.ShouldBe(UpdatePanelUpdateMode.Conditional);
+ }
+
+ [Fact]
+ public void UpdatePanel_DefaultChildrenAsTriggers_IsTrue()
+ {
+ var cut = Render(@Content
);
+ var panel = cut.FindComponent();
+ panel.Instance.ChildrenAsTriggers.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void UpdatePanel_ChildrenAsTriggersFalse_Accepted()
+ {
+ var cut = Render(@Content
);
+ var panel = cut.FindComponent();
+ panel.Instance.ChildrenAsTriggers.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void UpdatePanel_WithID_RendersIDOnWrapper()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+ Content
+
+ );
+
+ // Assert
+ var div = cut.Find("div#myPanel");
+ div.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void UpdatePanel_InlineWithID_RendersIDOnSpan()
+ {
+ UpdatePanelRenderMode mode = UpdatePanelRenderMode.Inline;
+
+ var cut = Render(
+ @
+ Content
+
+ );
+
+ var span = cut.Find("span#inlinePanel");
+ span.ShouldNotBeNull();
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Test/UpdateProgress/UpdateProgressTests.razor b/src/BlazorWebFormsComponents.Test/UpdateProgress/UpdateProgressTests.razor
new file mode 100644
index 000000000..03cec293f
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Test/UpdateProgress/UpdateProgressTests.razor
@@ -0,0 +1,171 @@
+@inherits BlazorWebFormsTestContext
+
+@code {
+
+ [Fact]
+ public void UpdateProgress_RendersDiv()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+ Loading...
+
+
+ );
+
+ // Assert
+ var div = cut.Find("div#prog1");
+ div.ShouldNotBeNull();
+ div.InnerHtml.ShouldContain("Loading...");
+ }
+
+ [Fact]
+ public void UpdateProgress_DynamicLayoutTrue_UsesDisplayNone()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+ Loading
+
+
+ );
+
+ // Assert
+ var div = cut.Find("div#dynTrue");
+ div.GetAttribute("style").ShouldContain("display:none");
+ }
+
+ [Fact]
+ public void UpdateProgress_DynamicLayoutFalse_UsesDisplayBlockVisibilityHidden()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+ Loading
+
+
+ );
+
+ // Assert
+ var div = cut.Find("div#dynFalse");
+ div.GetAttribute("style").ShouldContain("display:block");
+ div.GetAttribute("style").ShouldContain("visibility:hidden");
+ }
+
+ [Fact]
+ public void UpdateProgress_DefaultDynamicLayout_IsTrue()
+ {
+ var cut = Render(
+ @
+ X
+
+ );
+
+ var prog = cut.FindComponent();
+ prog.Instance.DynamicLayout.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void UpdateProgress_DefaultDisplayAfter_Is500()
+ {
+ var cut = Render(
+ @
+ X
+
+ );
+
+ var prog = cut.FindComponent();
+ prog.Instance.DisplayAfter.ShouldBe(500);
+ }
+
+ [Fact]
+ public void UpdateProgress_DisplayAfter_CanBeSet()
+ {
+ var cut = Render(
+ @
+ X
+
+ );
+
+ var prog = cut.FindComponent();
+ prog.Instance.DisplayAfter.ShouldBe(1000);
+ }
+
+ [Fact]
+ public void UpdateProgress_AssociatedUpdatePanelID_CanBeSet()
+ {
+ var cut = Render(
+ @
+ X
+
+ );
+
+ var prog = cut.FindComponent();
+ prog.Instance.AssociatedUpdatePanelID.ShouldBe("upMain");
+ }
+
+ [Fact]
+ public void UpdateProgress_DefaultAssociatedUpdatePanelID_IsNull()
+ {
+ var cut = Render(
+ @
+ X
+
+ );
+
+ var prog = cut.FindComponent();
+ prog.Instance.AssociatedUpdatePanelID.ShouldBeNull();
+ }
+
+ [Fact]
+ public void UpdateProgress_RendersProgressTemplateContent()
+ {
+ var cut = Render(
+ @
+
+ Please wait...
+
+
+ );
+
+ cut.Markup.ShouldContain("Please wait...");
+ cut.Find(".spinner").ShouldNotBeNull();
+ }
+
+ [Fact]
+ public void UpdateProgress_CssClass_AppliedToDiv()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+ Loading...
+
+
+ );
+
+ // Assert
+ var div = cut.Find("div#styled");
+ div.GetAttribute("class").ShouldBe("progress-overlay");
+ }
+
+ [Fact]
+ public void UpdateProgress_NoCssClass_NoClassAttribute()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+ Loading...
+
+
+ );
+
+ // Assert
+ var div = cut.Find("div#unstyled");
+ div.GetAttribute("class").ShouldBeNull();
+ }
+}
diff --git a/src/BlazorWebFormsComponents/BaseStyledComponent.cs b/src/BlazorWebFormsComponents/BaseStyledComponent.cs
index 18a2706e9..a73b9d22f 100644
--- a/src/BlazorWebFormsComponents/BaseStyledComponent.cs
+++ b/src/BlazorWebFormsComponents/BaseStyledComponent.cs
@@ -1,4 +1,4 @@
-using BlazorComponentUtilities;
+using BlazorComponentUtilities;
using BlazorWebFormsComponents.Enums;
using BlazorWebFormsComponents.Theming;
using Microsoft.AspNetCore.Components;
@@ -37,28 +37,13 @@ public abstract class BaseStyledComponent : BaseWebFormsComponent, IStyle
[Parameter]
public string ToolTip { get; set; }
- [CascadingParameter]
- public ThemeConfiguration Theme { get; set; }
-
protected string Style => this.ToStyle().Build().NullIfEmpty();
- protected override void OnParametersSet()
- {
- base.OnParametersSet();
-
- if (!EnableTheming || Theme == null) return;
-
- var skin = Theme.GetSkin(GetType().Name, SkinID);
- if (skin == null) return;
-
- ApplySkin(skin);
- }
-
///
/// Applies skin properties using StyleSheetTheme semantics:
/// the theme sets defaults, but explicit component values take precedence.
///
- private void ApplySkin(ControlSkin skin)
+ protected override void ApplyThemeSkin(ControlSkin skin)
{
if (BackColor == default && skin.BackColor != default)
BackColor = skin.BackColor;
@@ -92,7 +77,7 @@ private void ApplySkin(ControlSkin skin)
if (Font == null)
Font = new FontInfo();
- if (string.IsNullOrEmpty(Font.Name) && !string.IsNullOrEmpty(skin.Font.Name))
+ if (string.IsNullOrEmpty(Font.Name) && string.IsNullOrEmpty(Font.Names) && !string.IsNullOrEmpty(skin.Font.Name))
Font.Name = skin.Font.Name;
if (Font.Size == FontUnit.Empty && skin.Font.Size != FontUnit.Empty)
diff --git a/src/BlazorWebFormsComponents/BaseWebFormsComponent.cs b/src/BlazorWebFormsComponents/BaseWebFormsComponent.cs
index a82e3d3c5..e77b883fd 100644
--- a/src/BlazorWebFormsComponents/BaseWebFormsComponent.cs
+++ b/src/BlazorWebFormsComponents/BaseWebFormsComponent.cs
@@ -1,4 +1,6 @@
-using Microsoft.AspNetCore.Components;
+using BlazorWebFormsComponents.Enums;
+using BlazorWebFormsComponents.Theming;
+using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
@@ -102,11 +104,28 @@ void ParentWrappingBuildRenderTree(RenderTreeBuilder builder)
[Parameter]
public string SkinID { get; set; } = "";
+ ///
+ /// Gets or sets the algorithm used to generate the ClientID property value.
+ /// Matches System.Web.UI.Control.ClientIDMode from Web Forms.
+ /// Default is Inherit, which resolves to Predictable if no parent specifies a mode.
+ ///
+ [Parameter]
+ public ClientIDMode ClientIDMode { get; set; } = ClientIDMode.Inherit;
+
#endregion
[Parameter]
public bool Enabled { get; set; } = true;
+ ///
+ /// The theme configuration cascaded from a parent .
+ /// Null when no ThemeProvider wraps this component.
+ /// Named CascadedTheme to avoid conflict with components like WebFormsPage
+ /// that accept ThemeConfiguration as an explicit [Parameter] named Theme.
+ ///
+ [CascadingParameter]
+ public ThemeConfiguration CascadedTheme { get; set; }
+
[CascadingParameter(Name = PARENTCOMPONENTNAME)]
public virtual BaseWebFormsComponent Parent { get; set; }
@@ -220,6 +239,25 @@ protected BlazorWebFormsJsInterop JsInterop
}
}
+ protected override void OnParametersSet()
+ {
+ base.OnParametersSet();
+
+ if (!EnableTheming || CascadedTheme == null) return;
+
+ var skin = CascadedTheme.GetSkin(GetType().Name, SkinID);
+ if (skin == null) return;
+
+ ApplyThemeSkin(skin);
+ }
+
+ ///
+ /// Override in derived classes to apply skin properties from the theme.
+ /// Called during OnParametersSet when theming is enabled and a matching skin is found.
+ /// The base implementation does nothing — subclasses apply properties relevant to them.
+ ///
+ protected virtual void ApplyThemeSkin(ControlSkin skin) { }
+
protected override async Task OnInitializedAsync()
{
diff --git a/src/BlazorWebFormsComponents/CheckBox.razor b/src/BlazorWebFormsComponents/CheckBox.razor
index bf5325124..5de450ad2 100644
--- a/src/BlazorWebFormsComponents/CheckBox.razor
+++ b/src/BlazorWebFormsComponents/CheckBox.razor
@@ -17,6 +17,6 @@
}
else
{
-
+
}
}
diff --git a/src/BlazorWebFormsComponents/ComponentIdGenerator.cs b/src/BlazorWebFormsComponents/ComponentIdGenerator.cs
index a596e53b8..ef8bc62b7 100644
--- a/src/BlazorWebFormsComponents/ComponentIdGenerator.cs
+++ b/src/BlazorWebFormsComponents/ComponentIdGenerator.cs
@@ -1,3 +1,4 @@
+using BlazorWebFormsComponents.Enums;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -10,6 +11,23 @@ namespace BlazorWebFormsComponents
///
public static class ComponentIdGenerator
{
+ ///
+ /// Resolves the effective ClientIDMode for a component by walking up the parent hierarchy.
+ /// If no ancestor specifies a non-Inherit mode, defaults to Predictable (matches Web Forms page-level default).
+ ///
+ public static ClientIDMode GetEffectiveClientIDMode(BaseWebFormsComponent component)
+ {
+ var current = component;
+ while (current != null)
+ {
+ if (current.ClientIDMode != ClientIDMode.Inherit)
+ return current.ClientIDMode;
+ current = current.Parent;
+ }
+ // No ancestor specified a mode — default to Predictable (Web Forms page default)
+ return ClientIDMode.Predictable;
+ }
+
///
/// Generates a client-side ID for a component based on its ID and parent hierarchy.
/// Follows Web Forms naming container pattern: ParentID_ChildID
@@ -26,9 +44,46 @@ public static string GetClientID(BaseWebFormsComponent component, string suffix
if (string.IsNullOrEmpty(component.ID))
return null;
+ var effectiveMode = GetEffectiveClientIDMode(component);
+
+ string clientId;
+
+ switch (effectiveMode)
+ {
+ case ClientIDMode.Static:
+ // Static: raw ID, no parent walking
+ clientId = component.ID;
+ break;
+
+ case ClientIDMode.AutoID:
+ // AutoID: walk parents, include ctl00 prefixes from NamingContainers
+ clientId = BuildAutoID(component);
+ break;
+
+ case ClientIDMode.Predictable:
+ default:
+ // Predictable: walk parents, join with underscore, skip ctl00 prefixes
+ clientId = BuildPredictableID(component);
+ break;
+ }
+
+ // Add suffix if provided (for child elements)
+ if (!string.IsNullOrEmpty(suffix))
+ {
+ clientId = $"{clientId}_{suffix}";
+ }
+
+ return clientId;
+ }
+
+ ///
+ /// Builds the AutoID-style client ID: walks parents and includes ctl00 prefixes
+ /// from NamingContainers that have UseCtl00Prefix set.
+ ///
+ private static string BuildAutoID(BaseWebFormsComponent component)
+ {
var parts = new List();
- // Walk up the parent hierarchy to build the full ID path
var current = component;
while (current != null)
{
@@ -36,7 +91,7 @@ public static string GetClientID(BaseWebFormsComponent component, string suffix
{
parts.Insert(0, current.ID);
}
- // If this is a NamingContainer with UseCtl00Prefix, prepend "ctl00"
+ // ctl00 prefix only applies in AutoID mode
if (current is NamingContainer nc && nc.UseCtl00Prefix)
{
parts.Insert(0, "ctl00");
@@ -44,15 +99,28 @@ public static string GetClientID(BaseWebFormsComponent component, string suffix
current = current.Parent;
}
- var clientId = string.Join("_", parts);
+ return string.Join("_", parts);
+ }
- // Add suffix if provided (for child elements)
- if (!string.IsNullOrEmpty(suffix))
+ ///
+ /// Builds the Predictable-style client ID: walks parents and joins IDs with underscores,
+ /// but does not include ctl00/ctlxxx prefixes.
+ ///
+ private static string BuildPredictableID(BaseWebFormsComponent component)
+ {
+ var parts = new List();
+
+ var current = component;
+ while (current != null)
{
- clientId = $"{clientId}_{suffix}";
+ if (!string.IsNullOrEmpty(current.ID))
+ {
+ parts.Insert(0, current.ID);
+ }
+ current = current.Parent;
}
- return clientId;
+ return string.Join("_", parts);
}
///
diff --git a/src/BlazorWebFormsComponents/Enums/ClientIDMode.cs b/src/BlazorWebFormsComponents/Enums/ClientIDMode.cs
new file mode 100644
index 000000000..af4682767
--- /dev/null
+++ b/src/BlazorWebFormsComponents/Enums/ClientIDMode.cs
@@ -0,0 +1,29 @@
+namespace BlazorWebFormsComponents.Enums
+{
+ ///
+ /// Specifies how ASP.NET generates the ClientID for a control.
+ /// Matches System.Web.UI.ClientIDMode from Web Forms.
+ ///
+ public enum ClientIDMode
+ {
+ ///
+ /// Inherit the ClientIDMode from the parent control. If no parent specifies a mode, defaults to Predictable.
+ ///
+ Inherit = 0,
+
+ ///
+ /// Legacy algorithm: concatenates parent naming container IDs with "ctl00" prefixes and sequential numbering.
+ ///
+ AutoID = 1,
+
+ ///
+ /// ClientID equals the raw ID value with no parent prefixing. Resets the naming hierarchy.
+ ///
+ Static = 2,
+
+ ///
+ /// Parent IDs concatenated with underscores, no "ctlxxx" numbering.
+ ///
+ Predictable = 3
+ }
+}
diff --git a/src/BlazorWebFormsComponents/Enums/ScriptMode.cs b/src/BlazorWebFormsComponents/Enums/ScriptMode.cs
new file mode 100644
index 000000000..c1693622b
--- /dev/null
+++ b/src/BlazorWebFormsComponents/Enums/ScriptMode.cs
@@ -0,0 +1,10 @@
+namespace BlazorWebFormsComponents.Enums
+{
+ public enum ScriptMode
+ {
+ Auto = 0,
+ Inherit = 1,
+ Debug = 2,
+ Release = 3
+ }
+}
diff --git a/src/BlazorWebFormsComponents/Enums/UpdatePanelRenderMode.cs b/src/BlazorWebFormsComponents/Enums/UpdatePanelRenderMode.cs
new file mode 100644
index 000000000..1f44102dd
--- /dev/null
+++ b/src/BlazorWebFormsComponents/Enums/UpdatePanelRenderMode.cs
@@ -0,0 +1,8 @@
+namespace BlazorWebFormsComponents.Enums
+{
+ public enum UpdatePanelRenderMode
+ {
+ Block = 0,
+ Inline = 1
+ }
+}
diff --git a/src/BlazorWebFormsComponents/Enums/UpdatePanelUpdateMode.cs b/src/BlazorWebFormsComponents/Enums/UpdatePanelUpdateMode.cs
new file mode 100644
index 000000000..92eba0499
--- /dev/null
+++ b/src/BlazorWebFormsComponents/Enums/UpdatePanelUpdateMode.cs
@@ -0,0 +1,8 @@
+namespace BlazorWebFormsComponents.Enums
+{
+ public enum UpdatePanelUpdateMode
+ {
+ Always = 0,
+ Conditional = 1
+ }
+}
diff --git a/src/BlazorWebFormsComponents/LoginControls/LoginView.razor b/src/BlazorWebFormsComponents/LoginControls/LoginView.razor
index 0884337d1..cc31725c8 100644
--- a/src/BlazorWebFormsComponents/LoginControls/LoginView.razor
+++ b/src/BlazorWebFormsComponents/LoginControls/LoginView.razor
@@ -1,7 +1,9 @@
@inherits BaseStyledComponent
-
- @ChildContent
-
+
+
+ @ChildContent
+
-@GetView()
+ @GetView()
+
diff --git a/src/BlazorWebFormsComponents/MenuItemStyle.razor.cs b/src/BlazorWebFormsComponents/MenuItemStyle.razor.cs
index edd0a762e..8ec041efa 100644
--- a/src/BlazorWebFormsComponents/MenuItemStyle.razor.cs
+++ b/src/BlazorWebFormsComponents/MenuItemStyle.razor.cs
@@ -64,6 +64,7 @@ protected void SetPropertiesFromUnknownAttributes() {
protected override void OnInitialized() {
SetPropertiesFromUnknownAttributes();
+ this.SetFontsFromAttributes(OtherAttributes);
base.OnInitialized();
}
diff --git a/src/BlazorWebFormsComponents/NamingContainer.razor.cs b/src/BlazorWebFormsComponents/NamingContainer.razor.cs
index 62daf4ba9..b7ddd3039 100644
--- a/src/BlazorWebFormsComponents/NamingContainer.razor.cs
+++ b/src/BlazorWebFormsComponents/NamingContainer.razor.cs
@@ -1,3 +1,4 @@
+using BlazorWebFormsComponents.Enums;
using Microsoft.AspNetCore.Components;
namespace BlazorWebFormsComponents;
@@ -16,7 +17,19 @@ public partial class NamingContainer : BaseWebFormsComponent
/// When true, prepends "ctl00" to the naming hierarchy for full Web Forms compatibility.
/// For example, a Button with ID="MyButton" inside a NamingContainer with ID="MainContent"
/// and UseCtl00Prefix=true would get ClientID="ctl00_MainContent_MyButton".
+ /// Automatically sets ClientIDMode to AutoID for backward compatibility.
///
[Parameter]
public bool UseCtl00Prefix { get; set; }
+
+ protected override void OnParametersSet()
+ {
+ base.OnParametersSet();
+
+ // UseCtl00Prefix implies AutoID mode so children inherit ctl00 behavior
+ if (UseCtl00Prefix && ClientIDMode == ClientIDMode.Inherit)
+ {
+ ClientIDMode = ClientIDMode.AutoID;
+ }
+ }
}
diff --git a/src/BlazorWebFormsComponents/ScriptManager.razor b/src/BlazorWebFormsComponents/ScriptManager.razor
new file mode 100644
index 000000000..862ac8c31
--- /dev/null
+++ b/src/BlazorWebFormsComponents/ScriptManager.razor
@@ -0,0 +1 @@
+@inherits BaseWebFormsComponent
diff --git a/src/BlazorWebFormsComponents/ScriptManager.razor.cs b/src/BlazorWebFormsComponents/ScriptManager.razor.cs
new file mode 100644
index 000000000..a6547aff8
--- /dev/null
+++ b/src/BlazorWebFormsComponents/ScriptManager.razor.cs
@@ -0,0 +1,37 @@
+using System.Collections.Generic;
+using BlazorWebFormsComponents.Enums;
+using Microsoft.AspNetCore.Components;
+
+namespace BlazorWebFormsComponents
+{
+ ///
+ /// Migration stub for System.Web.UI.ScriptManager.
+ /// Renders no visible output — exists for markup compatibility during migration.
+ ///
+ public partial class ScriptManager : BaseWebFormsComponent
+ {
+ [Parameter]
+ public bool EnablePartialRendering { get; set; } = true;
+
+ [Parameter]
+ public bool EnablePageMethods { get; set; }
+
+ [Parameter]
+ public ScriptMode ScriptMode { get; set; } = ScriptMode.Auto;
+
+ [Parameter]
+ public int AsyncPostBackTimeout { get; set; } = 90;
+
+ [Parameter]
+ public bool EnableCdn { get; set; }
+
+ [Parameter]
+ public bool EnableScriptGlobalization { get; set; }
+
+ [Parameter]
+ public bool EnableScriptLocalization { get; set; }
+
+ [Parameter]
+ public List Scripts { get; set; } = new();
+ }
+}
diff --git a/src/BlazorWebFormsComponents/ScriptManagerProxy.razor b/src/BlazorWebFormsComponents/ScriptManagerProxy.razor
new file mode 100644
index 000000000..862ac8c31
--- /dev/null
+++ b/src/BlazorWebFormsComponents/ScriptManagerProxy.razor
@@ -0,0 +1 @@
+@inherits BaseWebFormsComponent
diff --git a/src/BlazorWebFormsComponents/ScriptManagerProxy.razor.cs b/src/BlazorWebFormsComponents/ScriptManagerProxy.razor.cs
new file mode 100644
index 000000000..c8d9c73a5
--- /dev/null
+++ b/src/BlazorWebFormsComponents/ScriptManagerProxy.razor.cs
@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Components;
+
+namespace BlazorWebFormsComponents
+{
+ ///
+ /// Migration stub for System.Web.UI.ScriptManagerProxy.
+ /// Used in content pages that reference a ScriptManager in a master page.
+ /// Renders no visible output.
+ ///
+ public partial class ScriptManagerProxy : BaseWebFormsComponent
+ {
+ [Parameter]
+ public List Scripts { get; set; } = new();
+
+ [Parameter]
+ public List Services { get; set; } = new();
+ }
+}
diff --git a/src/BlazorWebFormsComponents/ScriptReference.cs b/src/BlazorWebFormsComponents/ScriptReference.cs
new file mode 100644
index 000000000..8979dd326
--- /dev/null
+++ b/src/BlazorWebFormsComponents/ScriptReference.cs
@@ -0,0 +1,22 @@
+using BlazorWebFormsComponents.Enums;
+
+namespace BlazorWebFormsComponents
+{
+ ///
+ /// Represents a reference to a script file, used by ScriptManager and ScriptManagerProxy for migration compatibility.
+ ///
+ public class ScriptReference
+ {
+ public string Name { get; set; }
+
+ public string Path { get; set; }
+
+ public string Assembly { get; set; }
+
+ public ScriptMode ScriptMode { get; set; } = ScriptMode.Auto;
+
+ public bool NotifyScriptLoaded { get; set; } = true;
+
+ public string ResourceUICultures { get; set; }
+ }
+}
diff --git a/src/BlazorWebFormsComponents/ServiceReference.cs b/src/BlazorWebFormsComponents/ServiceReference.cs
new file mode 100644
index 000000000..9fc23a2de
--- /dev/null
+++ b/src/BlazorWebFormsComponents/ServiceReference.cs
@@ -0,0 +1,12 @@
+namespace BlazorWebFormsComponents
+{
+ ///
+ /// Represents a reference to a web service, used by ScriptManagerProxy for migration compatibility.
+ ///
+ public class ServiceReference
+ {
+ public string Path { get; set; }
+
+ public bool InlineScript { get; set; }
+ }
+}
diff --git a/src/BlazorWebFormsComponents/Style/Fonts/FontInfo.cs b/src/BlazorWebFormsComponents/Style/Fonts/FontInfo.cs
index bca9b4c5f..3a932c343 100644
--- a/src/BlazorWebFormsComponents/Style/Fonts/FontInfo.cs
+++ b/src/BlazorWebFormsComponents/Style/Fonts/FontInfo.cs
@@ -2,13 +2,51 @@
{
public sealed class FontInfo
{
+ private string _name;
+ private string _names;
+
public bool Bold { get; set; }
public bool Italic { get; set; }
- public string Name { get; set; }
+ ///
+ /// Auto-syncs with : setting Name also sets Names,
+ /// matching ASP.NET Web Forms FontInfo behavior.
+ ///
+ public string Name
+ {
+ get => _name;
+ set
+ {
+ _name = value;
+ if (!string.IsNullOrEmpty(value))
+ _names = value;
+ else
+ _names = null;
+ }
+ }
- public string Names { get; set; }
+ ///
+ /// Comma-separated font names. Auto-syncs with :
+ /// setting Names also sets Name to the first entry.
+ ///
+ public string Names
+ {
+ get => _names;
+ set
+ {
+ _names = value;
+ if (!string.IsNullOrEmpty(value))
+ {
+ var idx = value.IndexOf(',');
+ _name = idx >= 0 ? value.Substring(0, idx).Trim() : value.Trim();
+ }
+ else
+ {
+ _name = null;
+ }
+ }
+ }
public bool Overline { get; set; }
diff --git a/src/BlazorWebFormsComponents/Substitution.razor b/src/BlazorWebFormsComponents/Substitution.razor
new file mode 100644
index 000000000..89d1d88a1
--- /dev/null
+++ b/src/BlazorWebFormsComponents/Substitution.razor
@@ -0,0 +1,6 @@
+@inherits BaseWebFormsComponent
+
+@if (Visible && SubstitutionCallback != null)
+{
+ @((MarkupString)SubstitutionCallback.Invoke(HttpContextAccessor?.HttpContext))
+}
diff --git a/src/BlazorWebFormsComponents/Substitution.razor.cs b/src/BlazorWebFormsComponents/Substitution.razor.cs
new file mode 100644
index 000000000..cc777b902
--- /dev/null
+++ b/src/BlazorWebFormsComponents/Substitution.razor.cs
@@ -0,0 +1,22 @@
+using System;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Http;
+
+namespace BlazorWebFormsComponents
+{
+ public partial class Substitution : BaseWebFormsComponent
+ {
+ ///
+ /// Gets or sets the name of the callback method. Preserved for migration reference only.
+ ///
+ [Parameter]
+ public string MethodName { get; set; }
+
+ ///
+ /// A callback that receives the current HttpContext and returns markup to render.
+ /// This is the Blazor equivalent of the Web Forms static callback method.
+ ///
+ [Parameter]
+ public Func SubstitutionCallback { get; set; }
+ }
+}
diff --git a/src/BlazorWebFormsComponents/Theming/SkinBuilder.cs b/src/BlazorWebFormsComponents/Theming/SkinBuilder.cs
new file mode 100644
index 000000000..155785f13
--- /dev/null
+++ b/src/BlazorWebFormsComponents/Theming/SkinBuilder.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Linq.Expressions;
+using System.Reflection;
+
+namespace BlazorWebFormsComponents.Theming
+{
+ ///
+ /// Fluent builder for configuring properties
+ /// using strongly-typed lambda expressions.
+ ///
+ public class SkinBuilder
+ {
+ internal readonly ControlSkin Skin = new ControlSkin();
+
+ ///
+ /// Sets a property value on the skin using a strongly-typed expression.
+ /// Supports direct properties (s => s.BackColor) and nested properties (s => s.Font.Bold).
+ ///
+ public SkinBuilder Set(Expression> property, TValue value)
+ {
+ if (property is null)
+ throw new ArgumentNullException(nameof(property));
+
+ SetValue(Skin, property.Body, value);
+ return this;
+ }
+
+ private static void SetValue(object root, Expression expression, object value)
+ {
+ if (expression is not MemberExpression memberExpr)
+ throw new ArgumentException("Expression must be a member access expression.");
+
+ if (memberExpr.Expression is ParameterExpression)
+ {
+ // Direct property: s => s.BackColor
+ SetProperty(root, memberExpr.Member, value);
+ }
+ else if (memberExpr.Expression is MemberExpression parentExpr)
+ {
+ // Nested property: s => s.Font.Bold
+ var parent = GetOrCreateValue(root, parentExpr);
+ SetProperty(parent, memberExpr.Member, value);
+ }
+ else
+ {
+ throw new ArgumentException("Unsupported expression structure.");
+ }
+ }
+
+ private static object GetOrCreateValue(object root, MemberExpression expression)
+ {
+ object target;
+
+ if (expression.Expression is ParameterExpression)
+ {
+ target = root;
+ }
+ else if (expression.Expression is MemberExpression parentExpr)
+ {
+ target = GetOrCreateValue(root, parentExpr);
+ }
+ else
+ {
+ throw new ArgumentException("Unsupported expression structure.");
+ }
+
+ var prop = (PropertyInfo)expression.Member;
+ var current = prop.GetValue(target);
+ if (current is null)
+ {
+ current = Activator.CreateInstance(prop.PropertyType);
+ prop.SetValue(target, current);
+ }
+ return current;
+ }
+
+ private static void SetProperty(object target, MemberInfo member, object value)
+ {
+ if (member is PropertyInfo prop)
+ {
+ prop.SetValue(target, value);
+ }
+ else
+ {
+ throw new ArgumentException($"Member '{member.Name}' is not a property.");
+ }
+ }
+ }
+}
diff --git a/src/BlazorWebFormsComponents/Theming/ThemeConfiguration.cs b/src/BlazorWebFormsComponents/Theming/ThemeConfiguration.cs
index ed6f3660a..0ecf23ddc 100644
--- a/src/BlazorWebFormsComponents/Theming/ThemeConfiguration.cs
+++ b/src/BlazorWebFormsComponents/Theming/ThemeConfiguration.cs
@@ -74,5 +74,33 @@ public bool HasSkins(string controlTypeName)
return !string.IsNullOrEmpty(controlTypeName)
&& _skins.ContainsKey(controlTypeName);
}
+
+ ///
+ /// Fluent API: registers a default skin for a control type using a builder action.
+ ///
+ public ThemeConfiguration ForControl(string controlTypeName, Action configure)
+ {
+ if (configure is null)
+ throw new ArgumentNullException(nameof(configure));
+
+ var builder = new SkinBuilder();
+ configure(builder);
+ AddSkin(controlTypeName, builder.Skin);
+ return this;
+ }
+
+ ///
+ /// Fluent API: registers a named skin for a control type using a builder action.
+ ///
+ public ThemeConfiguration ForControl(string controlTypeName, string skinId, Action configure)
+ {
+ if (configure is null)
+ throw new ArgumentNullException(nameof(configure));
+
+ var builder = new SkinBuilder();
+ configure(builder);
+ AddSkin(controlTypeName, builder.Skin, skinId);
+ return this;
+ }
}
}
diff --git a/src/BlazorWebFormsComponents/Theming/ThemeProvider.razor b/src/BlazorWebFormsComponents/Theming/ThemeProvider.razor
index 4c438b768..02a107ab9 100644
--- a/src/BlazorWebFormsComponents/Theming/ThemeProvider.razor
+++ b/src/BlazorWebFormsComponents/Theming/ThemeProvider.razor
@@ -1,4 +1,5 @@
@namespace BlazorWebFormsComponents.Theming
+@inherits Microsoft.AspNetCore.Components.ComponentBase
@ChildContent
diff --git a/src/BlazorWebFormsComponents/Timer.razor b/src/BlazorWebFormsComponents/Timer.razor
new file mode 100644
index 000000000..862ac8c31
--- /dev/null
+++ b/src/BlazorWebFormsComponents/Timer.razor
@@ -0,0 +1 @@
+@inherits BaseWebFormsComponent
diff --git a/src/BlazorWebFormsComponents/Timer.razor.cs b/src/BlazorWebFormsComponents/Timer.razor.cs
new file mode 100644
index 000000000..2176ffce6
--- /dev/null
+++ b/src/BlazorWebFormsComponents/Timer.razor.cs
@@ -0,0 +1,96 @@
+using Microsoft.AspNetCore.Components;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace BlazorWebFormsComponents
+{
+ public partial class Timer : BaseWebFormsComponent, IDisposable
+ {
+ private System.Threading.Timer _timer;
+ private readonly object _lock = new();
+ private bool _disposed;
+
+ ///
+ /// The interval, in milliseconds, between ticks. Default is 60000 (60 seconds).
+ ///
+ [Parameter]
+ public int Interval { get; set; } = 60000;
+
+ // Enabled is inherited from BaseWebFormsComponent (default: true).
+ // Timer uses it to control whether ticking occurs.
+
+ ///
+ /// Occurs when the number of milliseconds specified in the Interval property has elapsed.
+ ///
+ [Parameter]
+ public EventCallback OnTick { get; set; }
+
+ protected override void OnAfterRender(bool firstRender)
+ {
+ base.OnAfterRender(firstRender);
+
+ if (firstRender)
+ {
+ ConfigureTimer();
+ }
+ }
+
+ protected override void OnParametersSet()
+ {
+ base.OnParametersSet();
+ ConfigureTimer();
+ }
+
+ private void ConfigureTimer()
+ {
+ lock (_lock)
+ {
+ if (_disposed) return;
+
+ if (Enabled && Interval > 0)
+ {
+ if (_timer == null)
+ {
+ _timer = new System.Threading.Timer(OnTimerCallback, null, Interval, Interval);
+ }
+ else
+ {
+ _timer.Change(Interval, Interval);
+ }
+ }
+ else
+ {
+ _timer?.Change(Timeout.Infinite, Timeout.Infinite);
+ }
+ }
+ }
+
+ private async void OnTimerCallback(object state)
+ {
+ lock (_lock)
+ {
+ if (_disposed) return;
+ }
+
+ await InvokeAsync(async () =>
+ {
+ if (OnTick.HasDelegate)
+ {
+ await OnTick.InvokeAsync();
+ }
+ StateHasChanged();
+ });
+ }
+
+ void IDisposable.Dispose()
+ {
+ lock (_lock)
+ {
+ _disposed = true;
+ _timer?.Dispose();
+ _timer = null;
+ }
+ }
+ }
+}
diff --git a/src/BlazorWebFormsComponents/UpdatePanel.razor b/src/BlazorWebFormsComponents/UpdatePanel.razor
new file mode 100644
index 000000000..25e2d1495
--- /dev/null
+++ b/src/BlazorWebFormsComponents/UpdatePanel.razor
@@ -0,0 +1,14 @@
+@using BlazorWebFormsComponents.Enums
+@inherits BaseWebFormsComponent
+
+@if (Visible)
+{
+ @if (RenderMode == UpdatePanelRenderMode.Inline)
+ {
+ @ChildContent
+ }
+ else
+ {
+ @ChildContent
+ }
+}
diff --git a/src/BlazorWebFormsComponents/UpdatePanel.razor.cs b/src/BlazorWebFormsComponents/UpdatePanel.razor.cs
new file mode 100644
index 000000000..516f9884f
--- /dev/null
+++ b/src/BlazorWebFormsComponents/UpdatePanel.razor.cs
@@ -0,0 +1,32 @@
+using BlazorWebFormsComponents.Enums;
+using Microsoft.AspNetCore.Components;
+
+namespace BlazorWebFormsComponents
+{
+ public partial class UpdatePanel : BaseWebFormsComponent
+ {
+ ///
+ /// Gets or sets a value that indicates when an UpdatePanel control's content is updated.
+ ///
+ [Parameter]
+ public UpdatePanelUpdateMode UpdateMode { get; set; } = UpdatePanelUpdateMode.Always;
+
+ ///
+ /// Gets or sets a value that indicates whether child controls of the UpdatePanel cause an asynchronous postback.
+ ///
+ [Parameter]
+ public bool ChildrenAsTriggers { get; set; } = true;
+
+ ///
+ /// Gets or sets a value that indicates whether the UpdatePanel renders as a div or span element.
+ ///
+ [Parameter]
+ public UpdatePanelRenderMode RenderMode { get; set; } = UpdatePanelRenderMode.Block;
+
+ ///
+ /// Equivalent to ContentTemplate in Web Forms. Contains the child content of the UpdatePanel.
+ ///
+ [Parameter]
+ public RenderFragment ChildContent { get; set; }
+ }
+}
diff --git a/src/BlazorWebFormsComponents/UpdateProgress.razor b/src/BlazorWebFormsComponents/UpdateProgress.razor
new file mode 100644
index 000000000..9835cea65
--- /dev/null
+++ b/src/BlazorWebFormsComponents/UpdateProgress.razor
@@ -0,0 +1,13 @@
+@inherits BaseStyledComponent
+
+@if (Visible)
+{
+ @if (DynamicLayout)
+ {
+ @ProgressTemplate
+ }
+ else
+ {
+ @ProgressTemplate
+ }
+}
diff --git a/src/BlazorWebFormsComponents/UpdateProgress.razor.cs b/src/BlazorWebFormsComponents/UpdateProgress.razor.cs
new file mode 100644
index 000000000..7676600bd
--- /dev/null
+++ b/src/BlazorWebFormsComponents/UpdateProgress.razor.cs
@@ -0,0 +1,32 @@
+using Microsoft.AspNetCore.Components;
+
+namespace BlazorWebFormsComponents
+{
+ public partial class UpdateProgress : BaseStyledComponent
+ {
+ ///
+ /// Gets or sets the ID of the UpdatePanel control that this UpdateProgress displays status for.
+ ///
+ [Parameter]
+ public string AssociatedUpdatePanelID { get; set; }
+
+ ///
+ /// Gets or sets the time in milliseconds before the progress template is displayed. Default is 500 ms.
+ ///
+ [Parameter]
+ public int DisplayAfter { get; set; } = 500;
+
+ ///
+ /// When true, the progress content is rendered with display:none (removes from layout).
+ /// When false, rendered with visibility:hidden (reserves layout space).
+ ///
+ [Parameter]
+ public bool DynamicLayout { get; set; } = true;
+
+ ///
+ /// The template that is displayed during an asynchronous postback.
+ ///
+ [Parameter]
+ public RenderFragment ProgressTemplate { get; set; }
+ }
+}
diff --git a/src/BlazorWebFormsComponents/WebColor.cs b/src/BlazorWebFormsComponents/WebColor.cs
index 3164f1763..5f136c7c7 100644
--- a/src/BlazorWebFormsComponents/WebColor.cs
+++ b/src/BlazorWebFormsComponents/WebColor.cs
@@ -166,6 +166,11 @@ public WebColor(Color color)
_color = color;
}
+ ///
+ /// Creates a WebColor from an HTML color string (e.g. "#FFDEAD" or "Red").
+ ///
+ public static WebColor FromHtml(string htmlColor) => new WebColor(htmlColor);
+
public static implicit operator WebColor(string colorString) => new WebColor(colorString);
public Color ToColor() => _color;
diff --git a/src/BlazorWebFormsComponents/WebFormsPage.razor b/src/BlazorWebFormsComponents/WebFormsPage.razor
index 6c58112ce..349d4dd07 100644
--- a/src/BlazorWebFormsComponents/WebFormsPage.razor
+++ b/src/BlazorWebFormsComponents/WebFormsPage.razor
@@ -2,7 +2,7 @@
@if (Visible)
{
-
+
@ChildContent
}