mirror of
https://github.com/unraid/api.git
synced 2026-01-06 08:39:54 -06:00
fix: enhance dark mode support in theme handling (#1808)
- Added PHP logic to determine if the current theme is dark and set a
CSS variable accordingly.
- Introduced a new function to retrieve the dark mode state from the CSS
variable in JavaScript.
- Updated the theme store to initialize dark mode based on the CSS
variable, ensuring consistent theme application across the application.
This improves user experience by ensuring the correct theme is applied
based on user preferences.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Server-persisted theme mutation and client action to fetch/apply
themes
* **Improvements**
* Safer theme parsing and multi-source initialization (CSS var, storage,
cookie, server)
* Robust dark-mode detection and propagation across document, modals and
teleport containers
* Responsive banner/header gradient handling with tunable CSS variables
and fallbacks
* **Tests**
* Expanded tests for theme flows, dark-mode detection, banner gradients
and manifest robustness
<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
import useTeleport from '@/composables/useTeleport';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent } from 'vue';
|
||||
import { defineComponent, nextTick } from 'vue';
|
||||
|
||||
describe('useTeleport', () => {
|
||||
beforeEach(() => {
|
||||
// Reset modules before each test to ensure fresh state
|
||||
vi.resetModules();
|
||||
// Clear the DOM before each test
|
||||
document.body.innerHTML = '';
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.body.classList.remove('dark');
|
||||
document.documentElement.style.removeProperty('--theme-dark-mode');
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -16,16 +20,19 @@ describe('useTeleport', () => {
|
||||
if (virtualContainer) {
|
||||
virtualContainer.remove();
|
||||
}
|
||||
// Reset the module to clear the virtualModalContainer variable
|
||||
vi.resetModules();
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.body.classList.remove('dark');
|
||||
document.documentElement.style.removeProperty('--theme-dark-mode');
|
||||
});
|
||||
|
||||
it('should return teleportTarget ref with correct value', () => {
|
||||
it('should return teleportTarget ref with correct value', async () => {
|
||||
const useTeleport = (await import('@/composables/useTeleport')).default;
|
||||
const { teleportTarget } = useTeleport();
|
||||
expect(teleportTarget.value).toBe('#unraid-api-modals-virtual');
|
||||
});
|
||||
|
||||
it('should create virtual container element on mount with correct properties', () => {
|
||||
it('should create virtual container element on mount with correct properties', async () => {
|
||||
const useTeleport = (await import('@/composables/useTeleport')).default;
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { teleportTarget } = useTeleport();
|
||||
@@ -39,6 +46,7 @@ describe('useTeleport', () => {
|
||||
|
||||
// Mount the component
|
||||
mount(TestComponent);
|
||||
await nextTick();
|
||||
|
||||
// After mount, virtual container should be created with correct properties
|
||||
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
|
||||
@@ -49,7 +57,8 @@ describe('useTeleport', () => {
|
||||
expect(virtualContainer?.parentElement).toBe(document.body);
|
||||
});
|
||||
|
||||
it('should reuse existing virtual container within same test', () => {
|
||||
it('should reuse existing virtual container within same test', async () => {
|
||||
const useTeleport = (await import('@/composables/useTeleport')).default;
|
||||
// Manually create the container first
|
||||
const manualContainer = document.createElement('div');
|
||||
manualContainer.id = 'unraid-api-modals-virtual';
|
||||
@@ -68,10 +77,128 @@ describe('useTeleport', () => {
|
||||
|
||||
// Mount component - should not create a new container
|
||||
mount(TestComponent);
|
||||
await nextTick();
|
||||
|
||||
// Should still have only one container
|
||||
const containers = document.querySelectorAll('#unraid-api-modals-virtual');
|
||||
expect(containers.length).toBe(1);
|
||||
expect(containers[0]).toBe(manualContainer);
|
||||
});
|
||||
|
||||
it('should apply dark class when dark mode is active via CSS variable', async () => {
|
||||
const useTeleport = (await import('@/composables/useTeleport')).default;
|
||||
const originalGetComputedStyle = window.getComputedStyle;
|
||||
const getComputedStyleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '1';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { teleportTarget } = useTeleport();
|
||||
return { teleportTarget };
|
||||
},
|
||||
template: '<div>{{ teleportTarget }}</div>',
|
||||
});
|
||||
|
||||
const wrapper = mount(TestComponent);
|
||||
await nextTick();
|
||||
|
||||
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
|
||||
expect(virtualContainer).toBeTruthy();
|
||||
expect(virtualContainer?.classList.contains('dark')).toBe(true);
|
||||
|
||||
wrapper.unmount();
|
||||
getComputedStyleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not apply dark class when dark mode is inactive via CSS variable', async () => {
|
||||
const useTeleport = (await import('@/composables/useTeleport')).default;
|
||||
const originalGetComputedStyle = window.getComputedStyle;
|
||||
const getComputedStyleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '0';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { teleportTarget } = useTeleport();
|
||||
return { teleportTarget };
|
||||
},
|
||||
template: '<div>{{ teleportTarget }}</div>',
|
||||
});
|
||||
|
||||
const wrapper = mount(TestComponent);
|
||||
await nextTick();
|
||||
|
||||
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
|
||||
expect(virtualContainer).toBeTruthy();
|
||||
expect(virtualContainer?.classList.contains('dark')).toBe(false);
|
||||
|
||||
wrapper.unmount();
|
||||
getComputedStyleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should apply dark class when dark mode is active via documentElement class', async () => {
|
||||
const useTeleport = (await import('@/composables/useTeleport')).default;
|
||||
document.documentElement.classList.add('dark');
|
||||
|
||||
const originalGetComputedStyle = window.getComputedStyle;
|
||||
const getComputedStyleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { teleportTarget } = useTeleport();
|
||||
return { teleportTarget };
|
||||
},
|
||||
template: '<div>{{ teleportTarget }}</div>',
|
||||
});
|
||||
|
||||
const wrapper = mount(TestComponent);
|
||||
await nextTick();
|
||||
|
||||
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
|
||||
expect(virtualContainer).toBeTruthy();
|
||||
expect(virtualContainer?.classList.contains('dark')).toBe(true);
|
||||
|
||||
wrapper.unmount();
|
||||
getComputedStyleSpy.mockRestore();
|
||||
document.documentElement.classList.remove('dark');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { isDarkModeActive } from '@/lib/utils';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
let virtualModalContainer: HTMLDivElement | null = null;
|
||||
|
||||
const ensureVirtualContainer = () => {
|
||||
if (!virtualModalContainer) {
|
||||
virtualModalContainer = document.createElement('div');
|
||||
virtualModalContainer.id = 'unraid-api-modals-virtual';
|
||||
virtualModalContainer.className = 'unapi';
|
||||
virtualModalContainer.style.position = 'relative';
|
||||
virtualModalContainer.style.zIndex = '999999';
|
||||
document.body.appendChild(virtualModalContainer);
|
||||
const existing = document.getElementById('unraid-api-modals-virtual');
|
||||
if (existing) {
|
||||
virtualModalContainer = existing as HTMLDivElement;
|
||||
} else {
|
||||
virtualModalContainer = document.createElement('div');
|
||||
virtualModalContainer.id = 'unraid-api-modals-virtual';
|
||||
virtualModalContainer.className = 'unapi';
|
||||
virtualModalContainer.style.position = 'relative';
|
||||
virtualModalContainer.style.zIndex = '999999';
|
||||
if (isDarkModeActive()) {
|
||||
virtualModalContainer.classList.add('dark');
|
||||
}
|
||||
document.body.appendChild(virtualModalContainer);
|
||||
}
|
||||
}
|
||||
return virtualModalContainer;
|
||||
};
|
||||
|
||||
193
unraid-ui/src/lib/utils.test.ts
Normal file
193
unraid-ui/src/lib/utils.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { isDarkModeActive } from '@/lib/utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('isDarkModeActive', () => {
|
||||
const originalGetComputedStyle = window.getComputedStyle;
|
||||
const originalDocumentElement = document.documentElement;
|
||||
const originalBody = document.body;
|
||||
|
||||
beforeEach(() => {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.body.classList.remove('dark');
|
||||
document.documentElement.style.removeProperty('--theme-dark-mode');
|
||||
document.querySelectorAll('.unapi').forEach((el) => el.classList.remove('dark'));
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.body.classList.remove('dark');
|
||||
document.documentElement.style.removeProperty('--theme-dark-mode');
|
||||
document.querySelectorAll('.unapi').forEach((el) => el.classList.remove('dark'));
|
||||
});
|
||||
|
||||
describe('CSS variable detection', () => {
|
||||
it('should return true when CSS variable is set to "1"', () => {
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '1';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
expect(isDarkModeActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when CSS variable is set to "0"', () => {
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '0';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
expect(isDarkModeActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when CSS variable is explicitly "0" even if dark class exists', () => {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.body.classList.add('dark');
|
||||
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '0';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
expect(isDarkModeActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClassList detection fallback', () => {
|
||||
it('should return true when documentElement has dark class and CSS variable is not set', () => {
|
||||
document.documentElement.classList.add('dark');
|
||||
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
expect(isDarkModeActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when body has dark class and CSS variable is not set', () => {
|
||||
document.body.classList.add('dark');
|
||||
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
expect(isDarkModeActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when .unapi.dark element exists and CSS variable is not set', () => {
|
||||
const unapiElement = document.createElement('div');
|
||||
unapiElement.className = 'unapi dark';
|
||||
document.body.appendChild(unapiElement);
|
||||
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
expect(isDarkModeActive()).toBe(true);
|
||||
|
||||
unapiElement.remove();
|
||||
});
|
||||
|
||||
it('should return false when no dark indicators are present', () => {
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
expect(isDarkModeActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSR/Node environment', () => {
|
||||
it('should return false when document is undefined', () => {
|
||||
const originalDocument = global.document;
|
||||
// @ts-expect-error - intentionally removing document for SSR test
|
||||
global.document = undefined;
|
||||
|
||||
expect(isDarkModeActive()).toBe(false);
|
||||
|
||||
global.document = originalDocument;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,3 +54,17 @@ export class Markdown {
|
||||
return Markdown.instance.parse(markdownContent);
|
||||
}
|
||||
}
|
||||
|
||||
export const isDarkModeActive = (): boolean => {
|
||||
if (typeof document === 'undefined') return false;
|
||||
|
||||
const cssVar = getComputedStyle(document.documentElement).getPropertyValue('--theme-dark-mode').trim();
|
||||
if (cssVar === '1') return true;
|
||||
if (cssVar === '0') return false;
|
||||
|
||||
if (document.documentElement.classList.contains('dark')) return true;
|
||||
if (document.body?.classList.contains('dark')) return true;
|
||||
if (document.querySelector('.unapi.dark')) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user