diff --git a/BlazorBootstrap.Demo.RCL/Components/Layout/MainLayout.razor b/BlazorBootstrap.Demo.RCL/Components/Layout/MainLayout.razor
index 2298ef346..25b9d8b0a 100644
--- a/BlazorBootstrap.Demo.RCL/Components/Layout/MainLayout.razor
+++ b/BlazorBootstrap.Demo.RCL/Components/Layout/MainLayout.razor
@@ -1,40 +1,73 @@
@namespace BlazorBootstrap.Demo.RCL
@inherits MainLayoutBase
-
+
+
+
+
+
+
+
-
+
+ @Body
-
-
+
+
diff --git a/blazorbootstrap/Components/Core/BlazorBootstrapLayoutComponentBase.cs b/blazorbootstrap/Components/Core/BlazorBootstrapLayoutComponentBase.cs
new file mode 100644
index 000000000..d4fd797d5
--- /dev/null
+++ b/blazorbootstrap/Components/Core/BlazorBootstrapLayoutComponentBase.cs
@@ -0,0 +1,165 @@
+using Microsoft.Extensions.Configuration;
+
+namespace BlazorBootstrap;
+
+public abstract class BlazorBootstrapLayoutComponentBase : LayoutComponentBase, IDisposable, IAsyncDisposable
+{
+ #region Fields and Constants
+
+ private bool isAsyncDisposed;
+
+ private bool isDisposed;
+
+ #endregion
+
+ #region Methods
+
+ ///
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ IsRenderComplete = true;
+
+ await base.OnAfterRenderAsync(firstRender);
+ }
+
+ ///
+ protected override void OnInitialized()
+ {
+ Id ??= IdUtility.GetNextId();
+
+ base.OnInitialized();
+ }
+
+ public static string BuildClassNames(params (string? cssClass, bool when)[] cssClassList)
+ {
+ var list = new HashSet
();
+
+ if (cssClassList is not null && cssClassList.Any())
+ foreach (var (cssClass, when) in cssClassList)
+ if (!string.IsNullOrWhiteSpace(cssClass) && when)
+ list.Add(cssClass);
+
+ if (list.Any())
+ return string.Join(" ", list);
+
+ return string.Empty;
+ }
+
+ public static string BuildClassNames(string? userDefinedCssClass, params (string? cssClass, bool when)[] cssClassList)
+ {
+ var list = new HashSet();
+
+ if (cssClassList is not null && cssClassList.Any())
+ foreach (var (cssClass, when) in cssClassList)
+ if (!string.IsNullOrWhiteSpace(cssClass) && when)
+ list.Add(cssClass);
+
+ if (!string.IsNullOrWhiteSpace(userDefinedCssClass))
+ list.Add(userDefinedCssClass.Trim());
+
+ if (list.Any())
+ return string.Join(" ", list);
+
+ return string.Empty;
+ }
+
+ public static string BuildStyleNames(string? userDefinedCssStyle, params (string? cssStyle, bool when)[] cssStyleList)
+ {
+ var list = new HashSet();
+
+ if (cssStyleList is not null && cssStyleList.Any())
+ foreach (var (cssStyle, when) in cssStyleList)
+ if (!string.IsNullOrWhiteSpace(cssStyle) && when)
+ list.Add(cssStyle);
+
+ if (!string.IsNullOrWhiteSpace(userDefinedCssStyle))
+ list.Add(userDefinedCssStyle.Trim());
+
+ if (list.Any())
+ return string.Join(';', list);
+
+ return string.Empty;
+ }
+
+ ///
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await DisposeAsyncCore(true).ConfigureAwait(false);
+
+ Dispose(false);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!isDisposed)
+ {
+ if (disposing)
+ {
+ // cleanup
+ }
+
+ isDisposed = true;
+ }
+ }
+
+ protected virtual ValueTask DisposeAsyncCore(bool disposing)
+ {
+ if (!isAsyncDisposed)
+ {
+ if (disposing)
+ {
+ // cleanup
+ }
+
+ isAsyncDisposed = true;
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ #endregion
+
+ #region Properties, Indexers
+
+ [Parameter(CaptureUnmatchedValues = true)] public Dictionary AdditionalAttributes { get; set; } = default!;
+
+ [Parameter] public string? Class { get; set; }
+
+ protected virtual string? ClassNames => Class;
+
+ [Inject] protected IConfiguration Configuration { get; set; } = default!;
+
+ public ElementReference Element { get; set; }
+
+ [Parameter] public string? Id { get; set; }
+
+ protected bool IsRenderComplete { get; private set; }
+
+ [Inject] protected IJSRuntime JSRuntime { get; set; } = default!;
+
+ [Parameter] public string? Style { get; set; }
+
+ protected virtual string? StyleNames => Style;
+
+ #endregion
+
+ #region Other
+
+ ~BlazorBootstrapLayoutComponentBase()
+ {
+ Dispose(false);
+ }
+
+ #endregion
+}
diff --git a/blazorbootstrap/Components/Layout/BlazorBootstrapLayout.razor b/blazorbootstrap/Components/Layout/BlazorBootstrapLayout.razor
new file mode 100644
index 000000000..fbc86571f
--- /dev/null
+++ b/blazorbootstrap/Components/Layout/BlazorBootstrapLayout.razor
@@ -0,0 +1,27 @@
+@namespace BlazorBootstrap
+@inherits BlazorBootstrapLayoutComponentBase
+
+
+
+ @SidebarSection
+
+
+ @if (HeaderSection is not null)
+ {
+
+ }
+
+
+ @ContentSection
+
+
+ @if (FooterSection is not null)
+ {
+
+ }
+
+
\ No newline at end of file
diff --git a/blazorbootstrap/Components/Layout/BlazorBootstrapLayout.razor.cs b/blazorbootstrap/Components/Layout/BlazorBootstrapLayout.razor.cs
new file mode 100644
index 000000000..f9dc56f8c
--- /dev/null
+++ b/blazorbootstrap/Components/Layout/BlazorBootstrapLayout.razor.cs
@@ -0,0 +1,24 @@
+namespace BlazorBootstrap;
+
+public partial class BlazorBootstrapLayout : BlazorBootstrapLayoutComponentBase
+{
+ #region Properties, Indexers
+
+ protected override string? ClassNames => BuildClassNames(Class, ("bb-page", true));
+
+ [Parameter] public RenderFragment? ContentSection { get; set; }
+ [Parameter] public string? ContentSectionCssClass { get; set; }
+ protected string? ContentSectionCssClassNames => BuildClassNames(ContentSectionCssClass, ("p-4", true));
+
+ [Parameter] public RenderFragment? FooterSection { get; set; }
+ [Parameter] public string? FooterSectionCssClass { get; set; } = "bg-body-tertiary";
+ protected string? FooterSectionCssClassNames => BuildClassNames(FooterSectionCssClass, ("bb-footer p-4", true));
+
+ [Parameter] public RenderFragment? HeaderSection { get; set; }
+ [Parameter] public string? HeaderSectionCssClass { get; set; } = "d-flex justify-content-end";
+ protected string? HeaderSectionCssClassNames => BuildClassNames(HeaderSectionCssClass, ("bb-top-row px-4", true));
+
+ [Parameter] public RenderFragment? SidebarSection { get; set; }
+
+ #endregion
+}
diff --git a/blazorbootstrap/Components/Layout/BlazorBootstrapLayout.razor.css b/blazorbootstrap/Components/Layout/BlazorBootstrapLayout.razor.css
new file mode 100644
index 000000000..e69de29bb
diff --git a/blazorbootstrap/Components/ThemeSwitcher/ThemeSwitcher.razor b/blazorbootstrap/Components/ThemeSwitcher/ThemeSwitcher.razor
new file mode 100644
index 000000000..ebbd52192
--- /dev/null
+++ b/blazorbootstrap/Components/ThemeSwitcher/ThemeSwitcher.razor
@@ -0,0 +1,24 @@
+@namespace BlazorBootstrap
+@inherits BlazorBootstrapComponentBase
+
+
+
\ No newline at end of file
diff --git a/blazorbootstrap/Components/ThemeSwitcher/ThemeSwitcher.razor.cs b/blazorbootstrap/Components/ThemeSwitcher/ThemeSwitcher.razor.cs
new file mode 100644
index 000000000..07ef82085
--- /dev/null
+++ b/blazorbootstrap/Components/ThemeSwitcher/ThemeSwitcher.razor.cs
@@ -0,0 +1,28 @@
+namespace BlazorBootstrap;
+
+public partial class ThemeSwitcher : BlazorBootstrapComponentBase
+{
+ #region Methods
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ await ThemeSwitcherJsInterop.InitializeAsync();
+
+ await base.OnAfterRenderAsync(firstRender);
+ }
+
+ internal Task SetAutoTheme() => ThemeSwitcherJsInterop.SetAutoThemeAsync();
+
+ internal Task SetDarkTheme() => ThemeSwitcherJsInterop.SetDarkThemeAsync();
+
+ internal Task SetLightTheme() => ThemeSwitcherJsInterop.SetLightThemeAsync();
+
+ #endregion
+
+ #region Properties, Indexers
+
+ [Inject] private ThemeSwitcherJsInterop ThemeSwitcherJsInterop { get; set; } = default!;
+
+ #endregion
+}
diff --git a/blazorbootstrap/Components/ThemeSwitcher/ThemeSwitcher.razor.css b/blazorbootstrap/Components/ThemeSwitcher/ThemeSwitcher.razor.css
new file mode 100644
index 000000000..e69de29bb
diff --git a/blazorbootstrap/Components/ThemeSwitcher/ThemeSwitcherJsInterop.cs b/blazorbootstrap/Components/ThemeSwitcher/ThemeSwitcherJsInterop.cs
new file mode 100644
index 000000000..6bef7b702
--- /dev/null
+++ b/blazorbootstrap/Components/ThemeSwitcher/ThemeSwitcherJsInterop.cs
@@ -0,0 +1,50 @@
+namespace BlazorBootstrap;
+
+public class ThemeSwitcherJsInterop : IAsyncDisposable
+{
+ #region Fields and Constants
+
+ private readonly Lazy> moduleTask;
+
+ #endregion
+
+ #region Constructors
+
+ public ThemeSwitcherJsInterop(IJSRuntime jsRuntime)
+ {
+ moduleTask = new Lazy>(() => jsRuntime.InvokeAsync("import", "./_content/Blazor.Bootstrap/blazor.bootstrap.theme-switcher.js").AsTask());
+ }
+
+ #endregion
+
+ #region Methods
+
+ public async ValueTask DisposeAsync()
+ {
+ if (moduleTask.IsValueCreated)
+ {
+ var module = await moduleTask.Value;
+ await module.DisposeAsync();
+ }
+ }
+
+ public async Task InitializeAsync()
+ {
+ var module = await moduleTask.Value;
+ await module.InvokeVoidAsync("initializeTheme");
+ }
+
+ internal Task SetAutoThemeAsync() => SetThemeAsync("system");
+
+ internal Task SetDarkThemeAsync() => SetThemeAsync("dark");
+
+ internal Task SetLightThemeAsync() => SetThemeAsync("light");
+
+ internal async Task SetThemeAsync(string themeName)
+ {
+ var module = await moduleTask.Value;
+ await module.InvokeVoidAsync("setTheme", themeName);
+ }
+
+ #endregion
+}
diff --git a/blazorbootstrap/Config.cs b/blazorbootstrap/Config.cs
index fe8146e37..0a26e404a 100644
--- a/blazorbootstrap/Config.cs
+++ b/blazorbootstrap/Config.cs
@@ -21,6 +21,7 @@ public static IServiceCollection AddBlazorBootstrap(this IServiceCollection serv
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
return services;
}
diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.css b/blazorbootstrap/wwwroot/blazor.bootstrap.css
index 5e532d082..047277b75 100644
--- a/blazorbootstrap/wwwroot/blazor.bootstrap.css
+++ b/blazorbootstrap/wwwroot/blazor.bootstrap.css
@@ -627,3 +627,14 @@ main {
.pdf-viewer-dropdown-toggle::after {
content: inherit !important;
}
+
+/* layout */
+.bb-footer a {
+ color: var(--bs-body-color);
+ text-decoration: none
+}
+
+.bb-footer a:hover, .bb-footer a:focus {
+ color: var(--bs-link-hover-color);
+ text-decoration: underline;
+}
diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.theme-switcher.js b/blazorbootstrap/wwwroot/blazor.bootstrap.theme-switcher.js
new file mode 100644
index 000000000..67e9c9710
--- /dev/null
+++ b/blazorbootstrap/wwwroot/blazor.bootstrap.theme-switcher.js
@@ -0,0 +1,99 @@
+// THEMES
+const STORAGE_KEY = "blazorbootstrap-theme";
+const DEFAULT_THEME = "light";
+const SYSTEM_THEME = "system";
+
+const state = {
+ chosenTheme: SYSTEM_THEME, // light|dark|system
+ appliedTheme: DEFAULT_THEME // light|dark
+};
+
+const showActiveTheme = () => {
+ let $themeIndicator = document.querySelector(".blazorbootstrap-theme-indicator>i");
+ if ($themeIndicator) {
+ if (state.appliedTheme === "light") {
+ $themeIndicator.className = "bi bi-sun-fill";
+ } else if (state.appliedTheme === "dark") {
+ $themeIndicator.className = "bi bi-moon-stars-fill";
+ } else {
+ $themeIndicator.className = "bi bi-circle-half";
+ }
+ }
+
+ let $themeSwitchers = document.querySelectorAll(".blazorbootstrap-theme-item>button");
+ if ($themeSwitchers) {
+ $themeSwitchers.forEach((el) => {
+ const bsThemeValue = el.dataset.bsThemeValue;
+ const iEl = el.querySelector(".bi.bi-check2");
+ if (state.chosenTheme === bsThemeValue) {
+ el.classList.add("active");
+ if (iEl)
+ iEl.classList.remove("d-none");
+ } else {
+ el.classList.remove("active");
+ if (iEl)
+ iEl.classList.add("d-none");
+ }
+ });
+ }
+};
+
+export function setTheme(theme, save = true) {
+ state.chosenTheme = theme;
+ state.appliedTheme = theme;
+
+ if (theme === SYSTEM_THEME) {
+ state.appliedTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ }
+
+ document.documentElement.setAttribute("data-bs-theme", state.appliedTheme);
+ if (save) {
+ window.localStorage.setItem(STORAGE_KEY, state.chosenTheme);
+ }
+ showActiveTheme();
+ updateDemoCodeThemeCss(state.appliedTheme);
+};
+
+export function initializeTheme() {
+ const localTheme = window.localStorage.getItem(STORAGE_KEY);
+ if (localTheme) {
+ setTheme(localTheme, false);
+ } else {
+ setTheme(SYSTEM_THEME);
+ }
+
+ // register events
+ window
+ .matchMedia("(prefers-color-scheme: dark)")
+ .addEventListener("change", (event) => {
+ const theme = event.matches ? "dark" : "light";
+ setTheme(theme);
+ });
+}
+
+export function updateDemoCodeThemeCss(theme) {
+ if (theme === "dark") {
+ let prismThemeLightLinkEl = document.getElementById('prismThemeLightLink');
+ if (prismThemeLightLinkEl)
+ prismThemeLightLinkEl?.remove();
+
+ let prismThemeDarkLinkEl = document.createElement("link");
+ prismThemeDarkLinkEl.setAttribute("rel", "stylesheet");
+ prismThemeDarkLinkEl.setAttribute("href", "/_content/BlazorBootstrap.Demo.RCL/css/prism-vsc-dark-plus.min.css");
+ prismThemeDarkLinkEl.setAttribute("id", "prismThemeDarkLink");
+
+ document.head.append(prismThemeDarkLinkEl);
+ }
+ else if (theme === "light") {
+ let prismThemeDarkLinkEl = document.getElementById('prismThemeDarkLink');
+ if (prismThemeDarkLinkEl)
+ prismThemeDarkLinkEl?.remove();
+
+ let prismThemeLightLinkEl = document.createElement("link");
+ prismThemeLightLinkEl.setAttribute("rel", "stylesheet");
+ prismThemeLightLinkEl.setAttribute("href", "/_content/BlazorBootstrap.Demo.RCL/css/prism-vs.min.css");
+ prismThemeLightLinkEl.setAttribute("id", "prismThemeLightLink");
+
+ document.head.append(prismThemeLightLinkEl);
+ }
+}
\ No newline at end of file