fix: 按审查报告修复 P2/P3 问题并升级 1.0.5
This commit is contained in:
@@ -7,13 +7,19 @@ const { isDark, toggle } = useDarkMode()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-toggle" @click="toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="theme-toggle"
|
||||
:aria-pressed="isDark"
|
||||
:aria-label="isDark ? '切换到浅色模式' : '切换到深色模式'"
|
||||
@click="toggle"
|
||||
>
|
||||
<IconSunLine v-if="isDark" class="theme-toggle__icon" />
|
||||
<IconMoonLine v-else class="theme-toggle__icon" />
|
||||
<span class="theme-toggle__label">
|
||||
{{ isDark ? '浅色模式' : '深色模式' }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -21,9 +27,13 @@ const { isDark, toggle } = useDarkMode()
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--halo-text-secondary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
user-select: none;
|
||||
@@ -34,6 +44,11 @@ const { isDark, toggle } = useDarkMode()
|
||||
color: var(--halo-text-primary);
|
||||
}
|
||||
|
||||
.theme-toggle:focus-visible {
|
||||
outline: 2px solid var(--halo-accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.theme-toggle__icon {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
@@ -44,4 +59,4 @@ const { isDark, toggle } = useDarkMode()
|
||||
font-size: 0.8125rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'halo-dark-mode-theme'
|
||||
|
||||
function mockMatchMedia(matches: boolean): void {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
}
|
||||
|
||||
describe('useDarkMode', () => {
|
||||
let useDarkMode: typeof import('../useDarkMode').useDarkMode
|
||||
|
||||
async function load(systemDark = false): Promise<void> {
|
||||
vi.resetModules()
|
||||
localStorage.clear()
|
||||
mockMatchMedia(systemDark)
|
||||
const mod = await import('../useDarkMode')
|
||||
useDarkMode = mod.useDarkMode
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await load(false)
|
||||
})
|
||||
|
||||
it('默认 auto 且系统为深色时 isDark 为 true', async () => {
|
||||
await load(true)
|
||||
const { theme, isDark } = useDarkMode()
|
||||
expect(theme.value).toBe('auto')
|
||||
expect(isDark.value).toBe(true)
|
||||
})
|
||||
|
||||
it('auto 模式下点击切换变为显式深色并持久化', async () => {
|
||||
const { theme, toggle } = useDarkMode()
|
||||
toggle()
|
||||
expect(theme.value).toBe('dark')
|
||||
await nextTick()
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark')
|
||||
})
|
||||
|
||||
it('auto 且系统为深色时点击切换变为显式浅色', async () => {
|
||||
await load(true)
|
||||
const { theme, toggle } = useDarkMode()
|
||||
toggle()
|
||||
expect(theme.value).toBe('light')
|
||||
await nextTick()
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('light')
|
||||
})
|
||||
|
||||
it('dark 与 light 之间往返切换', async () => {
|
||||
const { theme, toggle } = useDarkMode()
|
||||
theme.value = 'dark'
|
||||
toggle()
|
||||
expect(theme.value).toBe('light')
|
||||
toggle()
|
||||
expect(theme.value).toBe('dark')
|
||||
await nextTick()
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark')
|
||||
})
|
||||
|
||||
it('setTheme 会持久化到 localStorage', async () => {
|
||||
const { setTheme } = useDarkMode()
|
||||
setTheme('dark')
|
||||
await nextTick()
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark')
|
||||
})
|
||||
|
||||
it('localStorage 非法值回退到 auto', async () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'not-a-theme')
|
||||
await load(false)
|
||||
const { theme } = useDarkMode()
|
||||
expect(theme.value).toBe('auto')
|
||||
})
|
||||
|
||||
it('storage 事件会同步其他标签页的主题', () => {
|
||||
const { theme, setTheme } = useDarkMode()
|
||||
setTheme('light')
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: STORAGE_KEY,
|
||||
newValue: 'dark',
|
||||
}),
|
||||
)
|
||||
expect(theme.value).toBe('dark')
|
||||
})
|
||||
|
||||
it('storage 事件忽略非法值', () => {
|
||||
const { theme, setTheme } = useDarkMode()
|
||||
setTheme('light')
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: STORAGE_KEY,
|
||||
newValue: 'invalid',
|
||||
}),
|
||||
)
|
||||
expect(theme.value).toBe('light')
|
||||
})
|
||||
})
|
||||
@@ -26,13 +26,19 @@ function persistTheme(theme: ThemeMode): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 data-halo-theme 属性应用到 <html> 元素 */
|
||||
/**
|
||||
* 将主题状态同步到 <html>。
|
||||
* data-halo-theme 为兼容性遗留标记,当前已无 CSS 消费方,保留给外部脚本与验证工具;
|
||||
* color-scheme 用于在 Dark Reader 异步注入前同步浏览器控件外观,缓解 FOUC。
|
||||
*/
|
||||
function applyHtmlAttribute(isDark: boolean): void {
|
||||
const root = document.documentElement
|
||||
if (isDark) {
|
||||
document.documentElement.setAttribute('data-halo-theme', 'dark')
|
||||
root.setAttribute('data-halo-theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-halo-theme')
|
||||
root.removeAttribute('data-halo-theme')
|
||||
}
|
||||
root.style.colorScheme = isDark ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
// 模块级单例 — 所有组件共享同一个状态
|
||||
@@ -52,6 +58,15 @@ watch(isDark, (dark) => applyHtmlAttribute(dark), { immediate: true })
|
||||
// 监听 theme 变化 → 持久化
|
||||
watch(theme, (t) => persistTheme(t))
|
||||
|
||||
// 多标签页同步:其他标签页修改主题时跟随更新
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key !== STORAGE_KEY) return
|
||||
const value = event.newValue
|
||||
if (value === 'light' || value === 'dark' || value === 'auto') {
|
||||
theme.value = value
|
||||
}
|
||||
})
|
||||
|
||||
// 监听系统偏好变化 → 仅在 'auto' 模式下响应
|
||||
onChange((systemDark) => {
|
||||
if (theme.value === 'auto') {
|
||||
@@ -83,4 +98,4 @@ export function useDarkMode() {
|
||||
setTheme,
|
||||
toggle,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useDarkMode } from './composables/useDarkMode'
|
||||
* Dark Reader 引擎参数。
|
||||
* 纯 Dark Reader 策略下不再维护手工 CSS 覆盖;
|
||||
* 这里只调整通用观感参数,让核心与第三方插件页面转换后风格统一。
|
||||
* 注意:DR 注入为异步过程,刷新闪烁只能由 useDarkMode 的 color-scheme 同步缓解。
|
||||
*/
|
||||
const DARK_READER_THEME = {
|
||||
brightness: 100,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
配色策略: OKLCH 颜色空间 | 中性色微蓝着色调 | 亮度层次替代阴影
|
||||
============================================================ */
|
||||
|
||||
/* 说明:data-halo-theme 为兼容性遗留标记,当前已无 CSS 消费方,保留给外部脚本与验证工具。 */
|
||||
/* ===== 浅色模式(Halo 默认,此处定义为显式回退) ===== */
|
||||
:root {
|
||||
--halo-bg-body: oklch(97% 0.005 250);
|
||||
|
||||
@@ -31,17 +31,24 @@ const modeOptions: { value: ThemeMode; label: string; description: string }[] =
|
||||
当前生效:<strong>{{ currentEffectiveMode }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="dark-mode-settings__options">
|
||||
<div
|
||||
<div
|
||||
class="dark-mode-settings__options"
|
||||
role="radiogroup"
|
||||
aria-label="主题模式"
|
||||
>
|
||||
<button
|
||||
v-for="option in modeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="radio"
|
||||
class="dark-mode-settings__option"
|
||||
:class="{ 'is-active': theme === option.value }"
|
||||
:aria-checked="theme === option.value"
|
||||
@click="setTheme(option.value)"
|
||||
>
|
||||
<div class="dark-mode-settings__option-label">{{ option.label }}</div>
|
||||
<div class="dark-mode-settings__option-desc">{{ option.description }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,10 +102,15 @@ const modeOptions: { value: ThemeMode; label: string; description: string }[] =
|
||||
}
|
||||
|
||||
.dark-mode-settings__option {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--halo-border-base);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--halo-border-base);
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
@@ -106,6 +118,11 @@ const modeOptions: { value: ThemeMode; label: string; description: string }[] =
|
||||
background-color: var(--halo-bg-hover);
|
||||
}
|
||||
|
||||
.dark-mode-settings__option:focus-visible {
|
||||
outline: 2px solid var(--halo-accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dark-mode-settings__option.is-active {
|
||||
background-color: var(--halo-menu-item-active);
|
||||
border-color: var(--halo-accent-primary);
|
||||
@@ -122,4 +139,4 @@ const modeOptions: { value: ThemeMode; label: string; description: string }[] =
|
||||
font-size: 0.8125rem;
|
||||
color: var(--halo-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
Reference in New Issue
Block a user