diff --git a/src/BootstrapBlazor.Server/Components/Components/Header.razor b/src/BootstrapBlazor.Server/Components/Components/Header.razor index f54206d314c..e550c49293b 100644 --- a/src/BootstrapBlazor.Server/Components/Components/Header.razor +++ b/src/BootstrapBlazor.Server/Components/Components/Header.razor @@ -41,9 +41,6 @@ @DownloadText -
- - -
+ diff --git a/src/BootstrapBlazor.Server/Components/Components/Header.razor.css b/src/BootstrapBlazor.Server/Components/Components/Header.razor.css index 87b876895b4..41e576bee06 100644 --- a/src/BootstrapBlazor.Server/Components/Components/Header.razor.css +++ b/src/BootstrapBlazor.Server/Components/Components/Header.razor.css @@ -46,6 +46,18 @@ white-space: nowrap; } +::deep .bb-theme-mode .dropdown-toggle { + color: var(--bs-navbar-color); +} + +::deep .bb-theme-mode .dropdown-menu li { + padding: 0 4px; +} + + ::deep .bb-theme-mode .dropdown-menu li button { + border-radius: var(--bs-border-radius); + } + @media (min-width: 768px) { .navbar-header { position: sticky; diff --git a/src/BootstrapBlazor.Server/Components/Components/Header.razor.js b/src/BootstrapBlazor.Server/Components/Components/Header.razor.js index 75ecc64f202..7ff62f2fdc6 100644 --- a/src/BootstrapBlazor.Server/Components/Components/Header.razor.js +++ b/src/BootstrapBlazor.Server/Components/Components/Header.razor.js @@ -1,5 +1,4 @@ -import { getPreferredTheme, setTheme } from "../../_content/BootstrapBlazor/modules/theme.js" -import EventHandler from "../../_content/BootstrapBlazor/modules/event-handler.js" +import EventHandler from "../../_content/BootstrapBlazor/modules/event-handler.js" export function init() { const scrollTop = () => (document.documentElement && document.documentElement.scrollTop) || document.body.scrollTop @@ -9,27 +8,12 @@ export function init() { const currentScrollTop = scrollTop() if (currentScrollTop > prevScrollTop) { items.forEach(item => item.classList.add('hide')) - } else { + } + else { items.forEach(item => item.classList.remove('hide')) } prevScrollTop = currentScrollTop }) - - const themeElements = document.querySelectorAll('.icon-theme'); - if (themeElements) { - themeElements.forEach(el => { - EventHandler.on(el, 'click', e => { - let theme = getPreferredTheme(); - if (theme === 'dark') { - theme = 'light'; - } - else { - theme = 'dark'; - } - setTheme(theme); - }); - }); - } } export function dispose() { diff --git a/src/BootstrapBlazor.Server/wwwroot/lib/theme.js b/src/BootstrapBlazor.Server/wwwroot/lib/theme.js index a49a799de70..dedda5164c0 100644 --- a/src/BootstrapBlazor.Server/wwwroot/lib/theme.js +++ b/src/BootstrapBlazor.Server/wwwroot/lib/theme.js @@ -1,3 +1,3 @@ -import { getPreferredTheme, setTheme } from "../../_content/BootstrapBlazor/modules/theme.js" +import { getPreferredTheme, setTheme } from "../../_content/BootstrapBlazor/modules/utility.js" -setTheme(getPreferredTheme()) +setTheme(getPreferredTheme(), false) diff --git a/src/BootstrapBlazor/BootstrapBlazor.csproj b/src/BootstrapBlazor/BootstrapBlazor.csproj index 57ea9b6f887..edfd31e7a67 100644 --- a/src/BootstrapBlazor/BootstrapBlazor.csproj +++ b/src/BootstrapBlazor/BootstrapBlazor.csproj @@ -1,7 +1,7 @@ - 8.5.2 + 8.5.3 diff --git a/src/BootstrapBlazor/Components/ThemeProvider/ThemeProvider.razor b/src/BootstrapBlazor/Components/ThemeProvider/ThemeProvider.razor new file mode 100644 index 00000000000..e471eab25bd --- /dev/null +++ b/src/BootstrapBlazor/Components/ThemeProvider/ThemeProvider.razor @@ -0,0 +1,32 @@ +@namespace BootstrapBlazor.Components +@inherits BootstrapModuleComponentBase +@attribute [BootstrapModuleAutoLoader] + +
+ + +
diff --git a/src/BootstrapBlazor/Components/ThemeProvider/ThemeProvider.razor.cs b/src/BootstrapBlazor/Components/ThemeProvider/ThemeProvider.razor.cs new file mode 100644 index 00000000000..ddf8b80d715 --- /dev/null +++ b/src/BootstrapBlazor/Components/ThemeProvider/ThemeProvider.razor.cs @@ -0,0 +1,82 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +using Microsoft.Extensions.Localization; + +namespace BootstrapBlazor.Components; + +/// +/// ThemeProvider 组件 +/// +public partial class ThemeProvider +{ + /// + /// 获得/设置 自动模式图标 默认 null + /// + [Parameter] + public string? AutoModeIcon { get; set; } + + /// + /// 获得/设置 自动模式文本 默认 null 未设置使用本地化资源 + /// + [Parameter] + public string? AutoModeText { get; set; } + + /// + /// 获得/设置 暗黑模式图标 默认 null + /// + [Parameter] + public string? DarkModeIcon { get; set; } + + /// + /// 获得/设置 暗黑模式文本 默认 null 未设置使用本地化资源 + /// + [Parameter] + public string? DarkModeText { get; set; } + + /// + /// 获得/设置 明亮模式图标 默认 null + /// + [Parameter] + public string? LightModeIcon { get; set; } + + /// + /// 获得/设置 明亮模式文本 默认 null 未设置使用本地化资源 + /// + [Parameter] + public string? LightModeText { get; set; } + + /// + /// 获得/设置 当前选中模式图标 默认 null + /// + [Parameter] + public string? ActiveIcon { get; set; } + + [Inject, NotNull] + private IIconTheme? IconTheme { get; set; } + + [Inject, NotNull] + private IStringLocalizer? Localizer { get; set; } + + private string? ClassString => CssBuilder.Default("dropdown bb-theme-mode") + .AddClassFromAttributes(AdditionalAttributes) + .Build(); + + /// + /// + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + AutoModeIcon ??= IconTheme.GetIconByKey(ComponentIcons.ThemeProviderAutoModeIcon); + DarkModeIcon ??= IconTheme.GetIconByKey(ComponentIcons.ThemeProviderDarkModeIcon); + LightModeIcon ??= IconTheme.GetIconByKey(ComponentIcons.ThemeProviderLightModeIcon); + ActiveIcon ??= IconTheme.GetIconByKey(ComponentIcons.ThemeProviderActiveModeIcon); + + AutoModeText ??= Localizer[nameof(AutoModeText)]; + DarkModeText ??= Localizer[nameof(DarkModeText)]; + LightModeText ??= Localizer[nameof(LightModeText)]; + } +} diff --git a/src/BootstrapBlazor/Components/ThemeProvider/ThemeProvider.razor.js b/src/BootstrapBlazor/Components/ThemeProvider/ThemeProvider.razor.js new file mode 100644 index 00000000000..090107e4913 --- /dev/null +++ b/src/BootstrapBlazor/Components/ThemeProvider/ThemeProvider.razor.js @@ -0,0 +1,35 @@ +import { getAutoThemeValue, getPreferredTheme, setActiveTheme, setTheme, saveTheme } from "../../modules/utility.js" +import EventHandler from "../../modules/event-handler.js" + +export function init(id) { + const el = document.getElementById(id); + if (el) { + const currentTheme = getPreferredTheme(); + const activeItem = el.querySelector(`.dropdown-item[data-bb-theme-value="${currentTheme}"]`); + if (activeItem) { + setActiveTheme(el, activeItem); + } + + const items = el.querySelectorAll('.dropdown-item'); + items.forEach(item => { + EventHandler.on(item, 'click', () => { + setActiveTheme(el, item); + + let theme = item.getAttribute('data-bb-theme-value'); + if (theme === 'auto') { + theme = getAutoThemeValue(); + } + setTheme(theme, false); + saveTheme(theme); + }); + }); + } +} + +export function dispose(id) { + const el = document.getElementById(id); + const items = el.querySelectorAll('.dropdown-item'); + items.forEach(item => { + EventHandler.off(item, 'click'); + }); +} diff --git a/src/BootstrapBlazor/Components/ThemeProvider/ThemeProvider.razor.scss b/src/BootstrapBlazor/Components/ThemeProvider/ThemeProvider.razor.scss new file mode 100644 index 00000000000..40e8677f986 --- /dev/null +++ b/src/BootstrapBlazor/Components/ThemeProvider/ThemeProvider.razor.scss @@ -0,0 +1,33 @@ +.bb-theme-mode { + --bb-theme-mode-width: 128px; + --bb-theme-mode-label-width: 40px; + --bb-theme-mode-icon-width: 14px; + --bb-theme-mode-item-margin-top: 2px; + + .bb-theme-mode-active { + width: var(--bb-theme-mode-icon-width); + display: inline-block; + } + + .dropdown-menu { + --bs-dropdown-min-width: var(--bb-theme-mode-width); + + li:not(:first-child) { + margin-top: var(--bb-theme-mode-item-margin-top); + } + + .dropdown-item { + span { + width: var(--bb-theme-mode-label-width); + } + + i { + width: var(--bb-theme-mode-icon-width); + } + + &:not(.active) .bb-theme-mode-check { + display: none; + } + } + } +} diff --git a/src/BootstrapBlazor/Enums/ComponentIcons.cs b/src/BootstrapBlazor/Enums/ComponentIcons.cs index e56483dbd4a..5bd161aa5ad 100644 --- a/src/BootstrapBlazor/Enums/ComponentIcons.cs +++ b/src/BootstrapBlazor/Enums/ComponentIcons.cs @@ -782,5 +782,25 @@ public enum ComponentIcons /// /// QueryBuilder 组件 移除按钮图标 /// - QueryBuilderRemoveIcon + QueryBuilderRemoveIcon, + + /// + /// ThemeProvider 组件 自动模式图标 + /// + ThemeProviderAutoModeIcon, + + /// + /// ThemeProvider 组件 暗黑模式图标 + /// + ThemeProviderDarkModeIcon, + + /// + /// ThemeProvider 组件 明亮模式图标 + /// + ThemeProviderLightModeIcon, + + /// + /// ThemeProvider 组件 明亮模式图标 + /// + ThemeProviderActiveModeIcon } diff --git a/src/BootstrapBlazor/Extensions/BootstrapBlazorServiceCollectionExtensions.cs b/src/BootstrapBlazor/Extensions/BootstrapBlazorServiceCollectionExtensions.cs index 7687f036e82..5ffc71db1e9 100644 --- a/src/BootstrapBlazor/Extensions/BootstrapBlazorServiceCollectionExtensions.cs +++ b/src/BootstrapBlazor/Extensions/BootstrapBlazorServiceCollectionExtensions.cs @@ -43,6 +43,9 @@ public static IServiceCollection AddBootstrapBlazor(this IServiceCollection serv // Table 导出服务 services.TryAddScoped(); + // 主题服务 + services.TryAddScoped(); + // IP 地理位置定位服务 services.TryAddSingleton(); services.AddSingleton(); diff --git a/src/BootstrapBlazor/Extensions/JSModuleExtensions.cs b/src/BootstrapBlazor/Extensions/JSModuleExtensions.cs index 09e196220ed..10b9437f066 100644 --- a/src/BootstrapBlazor/Extensions/JSModuleExtensions.cs +++ b/src/BootstrapBlazor/Extensions/JSModuleExtensions.cs @@ -185,4 +185,19 @@ public static string GetTypeModuleName(this Type type) /// /// Returns a formatted element ID public static ValueTask GetHtml(this JSModule module, string? id = null, string? selector = null) => module.InvokeAsync("getHtml", new { id, selector }); + + /// + /// 设置主题方法 + /// + /// An instance of + /// theme name + /// + public static ValueTask SetTheme(this JSModule module, string themeName) => module.InvokeVoidAsync("setTheme", themeName, true); + + /// + /// 设置主题方法 + /// + /// An instance of + /// + public static ValueTask GetTheme(this JSModule module) => module.InvokeAsync("getTheme"); } diff --git a/src/BootstrapBlazor/Locales/en.json b/src/BootstrapBlazor/Locales/en.json index 9303cc5391b..21d20f2dd2e 100644 --- a/src/BootstrapBlazor/Locales/en.json +++ b/src/BootstrapBlazor/Locales/en.json @@ -356,5 +356,10 @@ "BootstrapBlazor.Components.ClockPicker": { "AMText": "AM", "PMText": "PM" + }, + "BootstrapBlazor.Components.ThemeProvider": { + "AutoModeText": "Auto", + "DarkModeText": "Dark", + "LightModeText": "Light" } } diff --git a/src/BootstrapBlazor/Locales/zh.json b/src/BootstrapBlazor/Locales/zh.json index 6580d5041b0..9cb887b99b7 100644 --- a/src/BootstrapBlazor/Locales/zh.json +++ b/src/BootstrapBlazor/Locales/zh.json @@ -356,5 +356,10 @@ "BootstrapBlazor.Components.ClockPicker": { "AMText": "上午", "PMText": "下午" + }, + "BootstrapBlazor.Components.ThemeProvider": { + "AutoModeText": "自动", + "DarkModeText": "暗黑", + "LightModeText": "明亮" } } diff --git a/src/BootstrapBlazor/Options/IconThemeOptions.cs b/src/BootstrapBlazor/Options/IconThemeOptions.cs index ce2409459f6..4e18869fd96 100644 --- a/src/BootstrapBlazor/Options/IconThemeOptions.cs +++ b/src/BootstrapBlazor/Options/IconThemeOptions.cs @@ -234,5 +234,10 @@ public IconThemeOptions() { ComponentIcons.QueryBuilderPlusIcon, "fa-solid fa-plus" }, { ComponentIcons.QueryBuilderMinusIcon, "fa-solid fa-minus" }, { ComponentIcons.QueryBuilderRemoveIcon, "fa-solid fa-xmark" }, + + { ComponentIcons.ThemeProviderAutoModeIcon, "fa-solid fa-circle-half-stroke" }, + { ComponentIcons.ThemeProviderLightModeIcon, "fa-solid fa-sun" }, + { ComponentIcons.ThemeProviderDarkModeIcon, "fa-solid fa-moon" }, + { ComponentIcons.ThemeProviderActiveModeIcon, "fa-solid fa-check" } }; } diff --git a/src/BootstrapBlazor/Services/DefaultThemeProvider.cs b/src/BootstrapBlazor/Services/DefaultThemeProvider.cs new file mode 100644 index 00000000000..b8afdaf06dc --- /dev/null +++ b/src/BootstrapBlazor/Services/DefaultThemeProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + + +namespace BootstrapBlazor.Components; + +class DefaultThemeProvider(IJSRuntime jsRuntime) : IThemeProvider +{ + /// + /// + /// + /// + public async ValueTask SetThemeAsync(string themeName) + { + var module = await jsRuntime.LoadUtility(); + await module.SetTheme(themeName); + } + + /// + /// + /// + public async ValueTask GetThemeAsync() + { + var module = await jsRuntime.LoadUtility(); + return await module.GetTheme(); + } +} diff --git a/src/BootstrapBlazor/Services/IThemeProvider.cs b/src/BootstrapBlazor/Services/IThemeProvider.cs new file mode 100644 index 00000000000..e05eb659c5f --- /dev/null +++ b/src/BootstrapBlazor/Services/IThemeProvider.cs @@ -0,0 +1,22 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +namespace BootstrapBlazor.Components; + +/// +/// 主题提供器接口 +/// +public interface IThemeProvider +{ + /// + /// 设置主题方法 + /// + /// + ValueTask SetThemeAsync(string themeName); + + /// + /// 获得当前主题方法 + /// + ValueTask GetThemeAsync(); +} diff --git a/src/BootstrapBlazor/wwwroot/modules/theme.js b/src/BootstrapBlazor/wwwroot/modules/theme.js deleted file mode 100644 index bedfef5580c..00000000000 --- a/src/BootstrapBlazor/wwwroot/modules/theme.js +++ /dev/null @@ -1,20 +0,0 @@ -const getStoredTheme = () => localStorage.getItem('theme') -const setStoredTheme = theme => localStorage.setItem('theme', theme) - -export function getPreferredTheme() { - const storedTheme = getStoredTheme() - if (storedTheme) { - return storedTheme - } - - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' -} - -export function setTheme(theme) { - if (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) { - document.documentElement.setAttribute('data-bs-theme', 'dark') - } else { - document.documentElement.setAttribute('data-bs-theme', theme); - } - setStoredTheme(theme); -} diff --git a/src/BootstrapBlazor/wwwroot/modules/utility.js b/src/BootstrapBlazor/wwwroot/modules/utility.js index bc00d3d30fd..e640dc2dfb1 100644 --- a/src/BootstrapBlazor/wwwroot/modules/utility.js +++ b/src/BootstrapBlazor/wwwroot/modules/utility.js @@ -674,6 +674,67 @@ export function getHtml(options) { return html; } + +export function getPreferredTheme() { + const storedTheme = getTheme() + if (storedTheme) { + return storedTheme + } + + return getAutoThemeValue(); +} + +export function getTheme() { + return localStorage.getItem('theme') || document.documentElement.getAttribute('data-bs-theme') || 'light'; +} + +export function saveTheme(theme) { + localStorage.setItem('theme', theme) +} + +export function getAutoThemeValue() { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +} + +export function setTheme(theme, sync) { + if (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) { + document.documentElement.setAttribute('data-bs-theme', 'dark') + } + else { + document.documentElement.setAttribute('data-bs-theme', theme); + } + + if (sync === true) { + const providers = document.querySelectorAll('.bb-theme-mode'); + providers.forEach(p => { + const activeItem = p.querySelector(`.dropdown-item[data-bb-theme-value="${theme}"]`); + setActiveTheme(p, activeItem) + }) + saveTheme(theme); + } +} + +export function setActiveTheme(el, activeItem) { + const currentTheme = el.querySelector('.active'); + if (currentTheme) { + currentTheme.classList.remove('active'); + } + + if (activeItem) { + activeItem.classList.add('active'); + const iconItem = activeItem.querySelector('[data-bb-theme-icon]'); + if (iconItem) { + const icon = iconItem.getAttribute('data-bb-theme-icon'); + if (icon) { + const toggleIcon = el.querySelector('.bb-theme-mode-active'); + if (toggleIcon) { + toggleIcon.outerHTML = ``; + } + } + } + } +} + export { autoAdd, autoRemove, diff --git a/src/BootstrapBlazor/wwwroot/scss/components.scss b/src/BootstrapBlazor/wwwroot/scss/components.scss index 2ef89a3288e..c108cc95fad 100644 --- a/src/BootstrapBlazor/wwwroot/scss/components.scss +++ b/src/BootstrapBlazor/wwwroot/scss/components.scss @@ -90,6 +90,7 @@ @import "../../Components/Table/Table.razor.scss"; @import "../../Components/Table/TableAdvancedSortDialog.razor.scss"; @import "../../Components/Tag/Tag.razor.scss"; +@import "../../Components/ThemeProvider/ThemeProvider.razor.scss"; @import "../../Components/Timeline/Timeline.razor.scss"; @import "../../Components/Timer/Timer.razor.scss"; @import "../../Components/TimePicker/TimePicker.razor.scss"; diff --git a/test/UnitTest/Components/ThemeProviderTest.cs b/test/UnitTest/Components/ThemeProviderTest.cs new file mode 100644 index 00000000000..8f22f423308 --- /dev/null +++ b/test/UnitTest/Components/ThemeProviderTest.cs @@ -0,0 +1,15 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +namespace UnitTest.Components; + +public class ThemeProviderTest : BootstrapBlazorTestBase +{ + [Fact] + public void ThemeProvider_Ok() + { + var cut = Context.RenderComponent(); + cut.Contains("dropdown bb-theme-mode"); + } +} diff --git a/test/UnitTest/Services/ThemeProviderTest.cs b/test/UnitTest/Services/ThemeProviderTest.cs new file mode 100644 index 00000000000..de29419dd44 --- /dev/null +++ b/test/UnitTest/Services/ThemeProviderTest.cs @@ -0,0 +1,26 @@ +// Copyright (c) Argo Zhang (argo@163.com). All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +// Website: https://www.blazor.zone or https://argozhang.github.io/ + +using Microsoft.Extensions.DependencyInjection; + +namespace UnitTest.Services; + +public class ThemeProviderTest : BootstrapBlazorTestBase +{ + [Fact] + public async Task SetTheme_Ok() + { + var themeProviderService = Context.Services.GetRequiredService(); + await themeProviderService.SetThemeAsync("light"); + } + + [Fact] + public async Task GetTheme_Ok() + { + Context.JSInterop.Setup("getTheme").SetResult("light"); + var themeProviderService = Context.Services.GetRequiredService(); + var theme = await themeProviderService.GetThemeAsync(); + Assert.Equal("light", theme); + } +}