release: v0.1.5 — theme system usability, extensibility, external theme following
feat(themes): comprehensive theme system enhancement
- Fix resolveTheme('auto') always resolves to system theme (no stale override)
- Add per-instance theme isolation via createInstanceTheme()
- Add MarkdownEditor.setTheme()/getTheme()/getThemeContext() instance methods
- Add 'themeChange' instance event with { theme, resolved, config } payload
- Multi-instance independent themes (no cross-contamination)
feat(themes): external theme following (v0.1.5)
- followExternalTheme() — follow theme via data attr, CSS class, or callback
- adoptFromParent() — inherit theme from parent container
- watch() — watch external theme source (function or selector)
feat(themes): extensibility (v0.1.5)
- registerTheme() supports 'extends' option for theme inheritance
- exportCSSVars() — export all CSS variable values from element
- getCSSVariable() — query single CSS variable
- applyThemeToElement() — apply theme to any DOM element
chore: bump version 0.1.4 → 0.1.5 across all files
test(themes): 30 new tests covering all v0.1.5 theme APIs (499 total)
This commit is contained in:
@@ -34,8 +34,16 @@ import {
|
||||
setThemeVariables,
|
||||
presetThemes,
|
||||
createThemeManager,
|
||||
exportCSSVars,
|
||||
getCSSVariable,
|
||||
applyThemeToElement,
|
||||
followExternalTheme,
|
||||
adoptFromParent,
|
||||
watch,
|
||||
createInstanceTheme,
|
||||
} from '../src/themes.js';
|
||||
import { THEMES } from '../src/constants.js';
|
||||
import { MarkdownEditor } from '../src/core.js';
|
||||
|
||||
// 确保 localStorage 存在(jsdom 兼容)
|
||||
if (typeof global.localStorage === 'undefined') {
|
||||
@@ -431,3 +439,384 @@ describe('createThemeManager', () => {
|
||||
expect(m.getCurrentTheme()).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
// ============ v0.1.5 新增测试 ============
|
||||
|
||||
describe('v0.1.5 resolveTheme auto 修复', () => {
|
||||
beforeEach(() => {
|
||||
clearThemeListeners();
|
||||
resetToAuto();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test('auto 始终解析为系统主题,不受上次手动设置污染', () => {
|
||||
switchTheme('dark');
|
||||
expect(getCurrentTheme()).toBe('dark');
|
||||
// 即使手动设为 dark,resolveTheme('auto') 仍应返回系统主题
|
||||
const sys = getSystemTheme();
|
||||
expect(resolveTheme('auto')).toBe(sys);
|
||||
});
|
||||
|
||||
test('resolveTheme 传入具体主题名原样返回', () => {
|
||||
expect(resolveTheme('light')).toBe('light');
|
||||
expect(resolveTheme('dark')).toBe('dark');
|
||||
expect(resolveTheme('warm')).toBe('warm');
|
||||
});
|
||||
|
||||
test('resolveTheme 无参/空值返回系统主题', () => {
|
||||
const sys = getSystemTheme();
|
||||
expect(resolveTheme()).toBe(sys);
|
||||
expect(resolveTheme(null)).toBe(sys);
|
||||
expect(resolveTheme('')).toBe(sys);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 exportCSSVars / getCSSVariable', () => {
|
||||
test('exportCSSVars 返回 CSS 变量对象', () => {
|
||||
applyTheme('light');
|
||||
const vars = exportCSSVars();
|
||||
expect(vars).toHaveProperty('--md-bg');
|
||||
expect(vars).toHaveProperty('--md-accent');
|
||||
});
|
||||
|
||||
test('exportCSSVars 从指定元素读取', () => {
|
||||
const div = document.createElement('div');
|
||||
div.style.setProperty('--md-bg', '#fff');
|
||||
document.body.appendChild(div);
|
||||
const vars = exportCSSVars(div);
|
||||
expect(vars['--md-bg']).toBe('#fff');
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('getCSSVariable 获取单个变量', () => {
|
||||
applyTheme('light');
|
||||
const bg = getCSSVariable('--md-bg');
|
||||
expect(typeof bg).toBe('string');
|
||||
expect(bg.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('getCSSVariable 自动补全 --md- 前缀', () => {
|
||||
applyTheme('light');
|
||||
const accent = getCSSVariable('accent');
|
||||
expect(typeof accent).toBe('string');
|
||||
expect(accent.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 applyThemeToElement', () => {
|
||||
test('应用主题到指定元素不改变全局状态', () => {
|
||||
switchTheme('light');
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
|
||||
applyThemeToElement('dark', div);
|
||||
expect(div.getAttribute('data-md-theme')).toBe('dark');
|
||||
// 全局主题不变
|
||||
expect(getCurrentTheme()).toBe('light');
|
||||
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('应用 auto 主题正确解析', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
applyThemeToElement('auto', div);
|
||||
const resolved = resolveTheme('auto');
|
||||
expect(div.getAttribute('data-md-theme')).toBe(resolved);
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 主题继承 registerTheme extends', () => {
|
||||
test('extends 继承基础主题', () => {
|
||||
registerTheme('ocean', {
|
||||
extends: 'dark',
|
||||
accent: '#00ddff',
|
||||
});
|
||||
expect(hasTheme('ocean')).toBe(true);
|
||||
const cfg = getThemeConfig('ocean');
|
||||
expect(cfg.accent).toBe('#00ddff');
|
||||
// 从 dark 继承
|
||||
expect(cfg.bg).toBe(THEMES.dark.bg);
|
||||
expect(cfg.text).toBe(THEMES.dark.text);
|
||||
unregisterTheme('ocean');
|
||||
});
|
||||
|
||||
test('默认 extends light', () => {
|
||||
registerTheme('minimal', { accent: '#ff0000' });
|
||||
const cfg = getThemeConfig('minimal');
|
||||
expect(cfg.bg).toBe(THEMES.light.bg);
|
||||
unregisterTheme('minimal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 createInstanceTheme', () => {
|
||||
test('创建实例主题上下文', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
const mockEditor = {
|
||||
el: div,
|
||||
config: { theme: 'dark' },
|
||||
_emit: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
};
|
||||
const ctx = createInstanceTheme(mockEditor);
|
||||
expect(ctx.get()).toBe('dark');
|
||||
expect(ctx.getResolved()).toBe('dark');
|
||||
expect(typeof ctx.set).toBe('function');
|
||||
expect(typeof ctx.toggle).toBe('function');
|
||||
expect(typeof ctx.getConfig).toBe('function');
|
||||
expect(typeof ctx.getVars).toBe('function');
|
||||
expect(typeof ctx.syncWithElement).toBe('function');
|
||||
expect(typeof ctx.adopt).toBe('function');
|
||||
expect(typeof ctx.watch).toBe('function');
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('实例主题 set 触发 _emit themeChange', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
const mockEditor = {
|
||||
el: div,
|
||||
config: { theme: 'light' },
|
||||
_emit: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
};
|
||||
const ctx = createInstanceTheme(mockEditor);
|
||||
ctx.set('dark');
|
||||
expect(mockEditor._emit).toHaveBeenCalledWith('themeChange', expect.objectContaining({
|
||||
theme: 'dark',
|
||||
resolved: 'dark',
|
||||
}));
|
||||
expect(mockEditor.refresh).toHaveBeenCalled();
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('实例主题 toggle 切换 light/dark', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
const mockEditor = {
|
||||
el: div,
|
||||
config: { theme: 'light' },
|
||||
_emit: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
};
|
||||
const ctx = createInstanceTheme(mockEditor);
|
||||
ctx.toggle();
|
||||
expect(ctx.get()).toBe('dark');
|
||||
ctx.toggle();
|
||||
expect(ctx.get()).toBe('light');
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('getConfig 返回主题配置对象', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
const mockEditor = {
|
||||
el: div,
|
||||
config: { theme: 'light' },
|
||||
_emit: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
};
|
||||
const ctx = createInstanceTheme(mockEditor);
|
||||
const cfg = ctx.getConfig();
|
||||
expect(cfg).toHaveProperty('bg');
|
||||
expect(cfg).toHaveProperty('accent');
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('getVars 返回实例 CSS 变量', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
const mockEditor = {
|
||||
el: div,
|
||||
config: { theme: 'light' },
|
||||
_emit: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
};
|
||||
const ctx = createInstanceTheme(mockEditor);
|
||||
applyThemeToElement('light', div);
|
||||
const vars = ctx.getVars();
|
||||
expect(vars).toHaveProperty('--md-bg');
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 followExternalTheme', () => {
|
||||
test('通过 data-theme 属性跟随', () => {
|
||||
const div = document.createElement('div');
|
||||
div.setAttribute('data-theme', 'dark');
|
||||
document.body.appendChild(div);
|
||||
|
||||
let detected = null;
|
||||
const unsub = followExternalTheme({ element: div }, (theme) => { detected = theme; });
|
||||
expect(detected).toBe('dark');
|
||||
|
||||
div.setAttribute('data-theme', 'light');
|
||||
// 手动触发 MutationObserver(jsdom 中需手动 dispatch)
|
||||
const observer = new MutationObserver(() => {});
|
||||
// 注:jsdom 环境 MutationObserver 行为受限,此处只验证初始化回调
|
||||
unsub();
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('通过 classMap 跟随', () => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'theme-dark';
|
||||
document.body.appendChild(div);
|
||||
|
||||
let detected = null;
|
||||
followExternalTheme(
|
||||
{ element: div, classMap: { 'theme-dark': 'dark', 'theme-light': 'light' } },
|
||||
(theme) => { detected = theme; },
|
||||
);
|
||||
expect(detected).toBe('dark');
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('通过 callback 跟随', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
|
||||
let detected = null;
|
||||
followExternalTheme(
|
||||
{ element: div, callback: () => 'warm' },
|
||||
(theme) => { detected = theme; },
|
||||
);
|
||||
expect(detected).toBe('warm');
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
|
||||
test('返回取消跟随函数', () => {
|
||||
const div = document.createElement('div');
|
||||
document.body.appendChild(div);
|
||||
const unsub = followExternalTheme({ element: div }, () => {});
|
||||
expect(typeof unsub).toBe('function');
|
||||
unsub();
|
||||
document.body.removeChild(div);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 adoptFromParent', () => {
|
||||
test('从父容器 data-md-theme 继承', () => {
|
||||
const parent = document.createElement('div');
|
||||
parent.setAttribute('data-md-theme', 'dark');
|
||||
const child = document.createElement('div');
|
||||
parent.appendChild(child);
|
||||
document.body.appendChild(parent);
|
||||
|
||||
let detected = null;
|
||||
const unsub = adoptFromParent(child, (theme) => { detected = theme; });
|
||||
expect(detected).toBe('dark');
|
||||
unsub();
|
||||
document.body.removeChild(parent);
|
||||
});
|
||||
|
||||
test('从父容器 class 继承', () => {
|
||||
const parent = document.createElement('div');
|
||||
parent.className = 'dark';
|
||||
const child = document.createElement('div');
|
||||
parent.appendChild(child);
|
||||
document.body.appendChild(parent);
|
||||
|
||||
let detected = null;
|
||||
adoptFromParent(child, (theme) => { detected = theme; });
|
||||
expect(detected).toBe('dark');
|
||||
document.body.removeChild(parent);
|
||||
});
|
||||
|
||||
test('容器无父元素不抛错', () => {
|
||||
const orphan = document.createElement('div');
|
||||
let detected = null;
|
||||
expect(() => adoptFromParent(orphan, (t) => { detected = t; })).not.toThrow();
|
||||
});
|
||||
|
||||
test('返回取消函数', () => {
|
||||
const parent = document.createElement('div');
|
||||
const child = document.createElement('div');
|
||||
parent.appendChild(child);
|
||||
document.body.appendChild(parent);
|
||||
const unsub = adoptFromParent(child, () => {});
|
||||
expect(typeof unsub).toBe('function');
|
||||
unsub();
|
||||
document.body.removeChild(parent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 watch 外部主题源', () => {
|
||||
test('函数源:回调被调用', () => {
|
||||
let detected = null;
|
||||
const unsub = watch(() => 'dark', (theme) => { detected = theme; });
|
||||
expect(detected).toBe('dark');
|
||||
unsub();
|
||||
});
|
||||
|
||||
test('返回取消函数', () => {
|
||||
const unsub = watch(() => 'light', () => {});
|
||||
expect(typeof unsub).toBe('function');
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.1.5 MarkdownEditor 实例主题 API', () => {
|
||||
test('setTheme / getTheme 实例方法', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, { theme: 'light' });
|
||||
|
||||
expect(ed.getTheme()).toBe('light');
|
||||
ed.setTheme('dark');
|
||||
expect(ed.getTheme()).toBe('dark');
|
||||
// el 类名应更新
|
||||
expect(ed.el.className).toContain('me-theme-dark');
|
||||
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('getThemeContext 返回实例主题上下文', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
|
||||
const ctx = ed.getThemeContext();
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(typeof ctx.set).toBe('function');
|
||||
expect(typeof ctx.get).toBe('function');
|
||||
|
||||
ed.destroy();
|
||||
});
|
||||
|
||||
test('多实例独立主题互不干扰', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c1 = document.createElement('div');
|
||||
const c2 = document.createElement('div');
|
||||
document.body.appendChild(c1);
|
||||
document.body.appendChild(c2);
|
||||
const ed1 = new MarkdownEditor(c1, { theme: 'light' });
|
||||
const ed2 = new MarkdownEditor(c2, { theme: 'dark' });
|
||||
|
||||
expect(ed1.getTheme()).toBe('light');
|
||||
expect(ed2.getTheme()).toBe('dark');
|
||||
|
||||
ed1.setTheme('warm');
|
||||
expect(ed1.getTheme()).toBe('warm');
|
||||
expect(ed2.getTheme()).toBe('dark'); // ed2 不受影响
|
||||
|
||||
ed1.destroy();
|
||||
ed2.destroy();
|
||||
});
|
||||
|
||||
test('setTheme 触发 themeChange 事件', () => {
|
||||
document.body.innerHTML = '';
|
||||
const c = document.createElement('div');
|
||||
document.body.appendChild(c);
|
||||
const ed = new MarkdownEditor(c, {});
|
||||
const handler = jest.fn();
|
||||
ed.on('themeChange', handler);
|
||||
ed.setTheme('dark');
|
||||
expect(handler).toHaveBeenCalled();
|
||||
ed.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user