feat: Add richer card theme support (#1809)

This commit is contained in:
Dermot Duffy
2025-01-07 19:54:13 -08:00
committed by GitHub
parent f7ecb91578
commit 278560b5ac
65 changed files with 1187 additions and 352 deletions
@@ -98,7 +98,7 @@ describe('ConfigManager', () => {
expect(api.getViewManager().reset).toBeCalled();
expect(api.getMessageManager().reset).toBeCalled();
expect(api.getAutomationsManager().addAutomations).toBeCalled();
expect(api.getStyleManager().setPerformance).toBeCalled();
expect(api.getStyleManager().updateFromConfig).toBeCalled();
expect(api.getCardElementManager().update).toBeCalled();
});
@@ -175,7 +175,7 @@ describe('ConfigManager', () => {
manager.computeOverrideConfig();
expect(manager.getConfig()).toBeNull();
expect(api.getStyleManager().setMinMaxHeight).not.toBeCalled();
expect(api.getStyleManager().updateFromConfig).not.toBeCalled();
});
it('should ignore overrides with same config', () => {
@@ -188,12 +188,12 @@ describe('ConfigManager', () => {
vi.mocked(getOverriddenConfig).mockReturnValue(config);
manager.setConfig(config);
expect(api.getStyleManager().setMinMaxHeight).toBeCalled();
expect(api.getStyleManager().updateFromConfig).toBeCalled();
vi.mocked(api.getStyleManager().setMinMaxHeight).mockClear();
vi.mocked(api.getStyleManager().updateFromConfig).mockClear();
manager.computeOverrideConfig();
expect(api.getStyleManager().setMinMaxHeight).not.toBeCalled();
expect(api.getStyleManager().updateFromConfig).not.toBeCalled();
});
it('should override', () => {
@@ -204,7 +204,7 @@ describe('ConfigManager', () => {
cameras: [{ camera_entity: 'camera.office' }],
};
manager.setConfig(config_1);
vi.mocked(api.getStyleManager().setMinMaxHeight).mockClear();
vi.mocked(api.getStyleManager().updateFromConfig).mockClear();
const config_2 = {
type: 'custom:frigate-card',
@@ -213,7 +213,7 @@ describe('ConfigManager', () => {
vi.mocked(getOverriddenConfig).mockReturnValue(config_2);
manager.computeOverrideConfig();
expect(api.getStyleManager().setMinMaxHeight).toBeCalled();
expect(api.getStyleManager().updateFromConfig).toBeCalled();
expect(api.getCardElementManager().update).toBeCalled();
expect(manager.getConfig()).not.toEqual(manager.getNonOverriddenConfig());
});
@@ -38,13 +38,13 @@ describe('HASSManager', () => {
expect(manager.hasHASS()).toBeTruthy();
});
it('should set light or dark mode upon setting hass', () => {
it('should update theme upon setting hass', () => {
const api = createCardAPI();
const manager = new HASSManager(api);
manager.setHASS(createHASS());
expect(api.getStyleManager().setLightOrDarkMode).toBeCalled();
expect(api.getStyleManager().applyTheme).toBeCalled();
});
describe('should set condition manager state', () => {
@@ -1,7 +1,7 @@
import { add } from 'date-fns';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { InteractionManager } from '../../src/card-controller/interaction-manager';
import { createCardAPI, createConfig } from '../test-utils';
import { createCardAPI, createConfig, createLitElement } from '../test-utils';
vi.mock('lodash-es/throttle', () => ({
default: vi.fn((fn) => fn),
@@ -17,14 +17,20 @@ describe('InteractionManager', () => {
it('should initialize', () => {
const api = createCardAPI();
const element = createLitElement();
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
const manager = new InteractionManager(api);
manager.initialize();
expect(api.getConditionsManager().setState).toBeCalledWith({ interaction: false });
expect(element.getAttribute('interaction')).toBeNull();
});
it('should not take action without an interaction timeout', () => {
it('should still report interaction without an interaction timeout', () => {
const api = createCardAPI();
const element = createLitElement();
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
@@ -36,11 +42,15 @@ describe('InteractionManager', () => {
manager.reportInteraction();
expect(manager.hasInteraction()).toBeFalsy();
expect(element.getAttribute('interaction')).not.toBeNull();
expect(manager.hasInteraction()).toBeTruthy();
});
it('should set condition state', () => {
const api = createCardAPI();
const element = createLitElement();
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
@@ -55,12 +65,14 @@ describe('InteractionManager', () => {
expect(api.getConditionsManager().setState).not.toBeCalled();
manager.reportInteraction();
expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith(
expect.objectContaining({
interaction: true,
}),
);
expect(manager.hasInteraction()).toBeTruthy();
expect(element.getAttribute('interaction')).not.toBeNull();
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
@@ -71,5 +83,6 @@ describe('InteractionManager', () => {
}),
);
expect(manager.hasInteraction()).toBeFalsy();
expect(element.getAttribute('interaction')).toBeNull();
});
});
+125 -70
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { StyleManager } from '../../src/card-controller/style-manager';
import { FrigateCardView } from '../../src/config/types';
import { createCardAPI, createConfig, createHASS, createView } from '../test-utils';
import { FrigateCardView, ThemeName } from '../../src/config/types';
import { createCardAPI, createConfig, createView } from '../test-utils';
// @vitest-environment jsdom
describe('StyleManager', () => {
@@ -9,97 +9,45 @@ describe('StyleManager', () => {
vi.resetAllMocks();
});
describe('setLightOrDarkMode', () => {
it('dark mode unspecified', () => {
const api = createCardAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
const manager = new StyleManager(api);
manager.setLightOrDarkMode();
expect(element.getAttribute('dark')).toBeNull();
});
it('dark mode explicitly off', () => {
describe('should set dimmable', () => {
it('should be dimmable when dim is true', () => {
const api = createCardAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
dark_mode: 'off',
dim: true,
},
}),
);
const manager = new StyleManager(api);
manager.setLightOrDarkMode();
manager.updateFromConfig();
expect(element.getAttribute('dark')).toBeNull();
expect(element.getAttribute('dimmable')).not.toBeNull();
});
it('dark mode explicitly set', () => {
it('should not be dimmable when dim is false', () => {
const api = createCardAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
dark_mode: 'on',
dim: false,
},
}),
);
const manager = new StyleManager(api);
manager.setLightOrDarkMode();
manager.updateFromConfig();
expect(element.getAttribute('dark')).not.toBeNull();
});
it('dark mode auto without interaction', () => {
const api = createCardAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
dark_mode: 'auto',
},
}),
);
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false);
const manager = new StyleManager(api);
manager.setLightOrDarkMode();
expect(element.getAttribute('dark')).not.toBeNull();
});
it('dark mode auto with HA dark mode', () => {
const api = createCardAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
dark_mode: 'auto',
},
}),
);
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(true);
const hass = createHASS();
hass.themes.darkMode = true;
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const manager = new StyleManager(api);
manager.setLightOrDarkMode();
expect(element.getAttribute('dark')).not.toBeNull();
expect(element.getAttribute('dimmable')).toBeNull();
});
});
describe('setExpandedMode', () => {
describe('should set expanded mode', () => {
it('with no view or known media', () => {
const api = createCardAPI();
const element = document.createElement('div');
@@ -189,14 +137,14 @@ describe('StyleManager', () => {
});
});
describe('setMinMaxHeight', () => {
describe('should set min and max height', () => {
it('without a config', () => {
const api = createCardAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
const manager = new StyleManager(api);
manager.setMinMaxHeight();
manager.updateFromConfig();
expect(element.style.getPropertyValue('--frigate-card-max-height')).toBeFalsy();
expect(element.style.getPropertyValue('--frigate-card-expand-height')).toBeFalsy();
@@ -215,13 +163,13 @@ describe('StyleManager', () => {
);
const manager = new StyleManager(api);
manager.setMinMaxHeight();
manager.updateFromConfig();
expect(element.style.getPropertyValue('--frigate-card-height')).toBe('800px');
});
});
describe('setPerformance', () => {
describe('should set performance', () => {
it('no styles set', () => {
const api = createCardAPI();
const element = document.createElement('div');
@@ -229,7 +177,7 @@ describe('StyleManager', () => {
vi.mocked(api.getConfigManager().getCardWideConfig).mockReturnValue({});
const manager = new StyleManager(api);
manager.setPerformance();
manager.updateFromConfig();
expect(
element.style.getPropertyValue('--frigate-card-css-box-shadow'),
@@ -255,7 +203,7 @@ describe('StyleManager', () => {
);
const manager = new StyleManager(api);
manager.setPerformance();
manager.updateFromConfig();
expect(element.style.getPropertyValue('--frigate-card-css-box-shadow')).toEqual(
'none',
@@ -411,4 +359,111 @@ describe('StyleManager', () => {
expect(manager.getAspectRatioStyle()).toEqual({ 'aspect-ratio': '4 / 3' });
});
});
describe('should apply themes', () => {
it('should not apply themes without a config', () => {
const api = createCardAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
const manager = new StyleManager(api);
manager.applyTheme();
expect(element.getAttribute('themes')).toBeNull();
});
describe('should apply named theme', () => {
it.each([
['light' as const],
['dark' as const],
['traditional' as const],
['ha' as const],
])('%s', (theme: ThemeName) => {
const api = createCardAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
theme: {
themes: [theme],
},
},
}),
);
const manager = new StyleManager(api);
manager.applyTheme();
expect(element.getAttribute('themes')).toBe(theme);
});
});
it('should apply multiple themes', () => {
const api = createCardAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
theme: {
themes: ['light', 'traditional'],
},
},
}),
);
const manager = new StyleManager(api);
manager.applyTheme();
expect(element.getAttribute('themes')).toBe('light traditional');
});
it('should treat empty themes list as default', () => {
const api = createCardAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
theme: {
themes: [],
},
},
}),
);
const manager = new StyleManager(api);
manager.applyTheme();
expect(element.getAttribute('themes')).toBe('traditional');
});
it('should apply overrides', () => {
const api = createCardAPI();
const element = document.createElement('div');
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
theme: {
overrides: {
'--test-key': 'test-value',
},
},
},
}),
);
const manager = new StyleManager(api);
manager.applyTheme();
expect(element.style.getPropertyValue('--test-key')).toBe('test-value');
});
});
});
@@ -22,6 +22,10 @@ describe('IconController', () => {
);
});
it('should return iris SVG for iris icon', () => {
expect(new IconController().getCustomIcon({ icon: 'iris' })).toMatch(/iris.svg$/);
});
it('should return null for mdi icon', () => {
expect(new IconController().getCustomIcon({ icon: 'mdi:car' })).toBeNull();
});
@@ -288,7 +288,7 @@ describe('MenuButtonController', () => {
expect(buttons).toContainEqual({
icon: 'mdi:video-input-component',
style: { color: 'var(--primary-color, white)' },
style: { color: 'var(--frigate-card-menu-button-active-color)' },
title: 'Substream(s)',
enabled: true,
priority: 50,
@@ -442,7 +442,7 @@ describe('MenuButtonController', () => {
expect(buttons).toContainEqual({
icon: 'mdi:video-input-component',
title: 'Substream(s)',
style: { color: 'var(--primary-color, white)' },
style: { color: 'var(--frigate-card-menu-button-active-color)' },
enabled: true,
priority: 50,
type: 'custom:frigate-card-menu-submenu',
@@ -508,7 +508,7 @@ describe('MenuButtonController', () => {
priority: 50,
type: 'custom:frigate-card-menu-icon',
title: 'Live view',
style: { color: 'var(--primary-color, white)' },
style: { color: 'var(--frigate-card-menu-button-active-color)' },
tap_action: { action: 'fire-dom-event', frigate_card_action: 'live' },
});
});
@@ -562,7 +562,7 @@ describe('MenuButtonController', () => {
priority: 50,
type: 'custom:frigate-card-menu-icon',
title: 'Clips gallery',
style: { color: 'var(--primary-color, white)' },
style: { color: 'var(--frigate-card-menu-button-active-color)' },
tap_action: { action: 'fire-dom-event', frigate_card_action: 'clips' },
hold_action: { action: 'fire-dom-event', frigate_card_action: 'clip' },
});
@@ -615,7 +615,7 @@ describe('MenuButtonController', () => {
priority: 50,
type: 'custom:frigate-card-menu-icon',
title: 'Snapshots gallery',
style: { color: 'var(--primary-color, white)' },
style: { color: 'var(--frigate-card-menu-button-active-color)' },
tap_action: { action: 'fire-dom-event', frigate_card_action: 'snapshots' },
hold_action: { action: 'fire-dom-event', frigate_card_action: 'snapshot' },
});
@@ -670,7 +670,7 @@ describe('MenuButtonController', () => {
priority: 50,
type: 'custom:frigate-card-menu-icon',
title: 'Recordings gallery',
style: { color: 'var(--primary-color, white)' },
style: { color: 'var(--frigate-card-menu-button-active-color)' },
tap_action: { action: 'fire-dom-event', frigate_card_action: 'recordings' },
hold_action: { action: 'fire-dom-event', frigate_card_action: 'recording' },
});
@@ -726,7 +726,7 @@ describe('MenuButtonController', () => {
priority: 50,
type: 'custom:frigate-card-menu-icon',
title: 'Static image',
style: { color: 'var(--primary-color, white)' },
style: { color: 'var(--frigate-card-menu-button-active-color)' },
tap_action: { action: 'fire-dom-event', frigate_card_action: 'image' },
});
});
@@ -781,7 +781,7 @@ describe('MenuButtonController', () => {
priority: 50,
type: 'custom:frigate-card-menu-icon',
title: 'Timeline view',
style: { color: 'var(--primary-color, white)' },
style: { color: 'var(--frigate-card-menu-button-active-color)' },
tap_action: { action: 'fire-dom-event', frigate_card_action: 'timeline' },
});
});
@@ -938,7 +938,7 @@ describe('MenuButtonController', () => {
title: 'Microphone',
style: {
animation: 'pulse 3s infinite',
color: 'var(--error-color, white)',
color: 'var(--frigate-card-menu-button-critical-color)',
},
start_tap_action: {
action: 'fire-dom-event',
@@ -1110,7 +1110,7 @@ describe('MenuButtonController', () => {
title: 'Microphone',
style: {
animation: 'pulse 3s infinite',
color: 'var(--error-color, white)',
color: 'var(--frigate-card-menu-button-critical-color)',
},
tap_action: {
action: 'fire-dom-event',
@@ -1148,7 +1148,7 @@ describe('MenuButtonController', () => {
type: 'custom:frigate-card-menu-icon',
title: 'Fullscreen',
tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' },
style: { color: 'var(--primary-color, white)' },
style: { color: 'var(--frigate-card-menu-button-active-color)' },
});
});
});
@@ -1178,7 +1178,7 @@ describe('MenuButtonController', () => {
type: 'custom:frigate-card-menu-icon',
title: 'Expand',
tap_action: { action: 'fire-dom-event', frigate_card_action: 'expand' },
style: { color: 'var(--primary-color, white)' },
style: { color: 'var(--frigate-card-menu-button-active-color)' },
});
});
});
@@ -1413,7 +1413,10 @@ describe('MenuButtonController', () => {
displayMode === 'grid'
? 'Show single media viewer'
: 'Show media viewer for each camera in a grid',
style: displayMode === 'grid' ? { color: 'var(--primary-color, white)' } : {},
style:
displayMode === 'grid'
? { color: 'var(--frigate-card-menu-button-active-color)' }
: {},
tap_action: {
action: 'fire-dom-event',
frigate_card_action: 'display_mode_select',
@@ -1481,7 +1484,7 @@ describe('MenuButtonController', () => {
icon: 'mdi:pan',
priority: 50,
style: {
color: 'var(--primary-color, white)',
color: 'var(--frigate-card-menu-button-active-color)',
},
tap_action: {
action: 'fire-dom-event',
@@ -1554,7 +1557,7 @@ describe('MenuButtonController', () => {
icon: 'mdi:pan',
priority: 50,
style: {
color: 'var(--primary-color, white)',
color: 'var(--frigate-card-menu-button-active-color)',
},
tap_action: {
action: 'fire-dom-event',
@@ -1701,7 +1704,7 @@ describe('MenuButtonController', () => {
expect(calculateButtons(controller, { view: view })).toContainEqual({
...button,
style: { color: 'var(--primary-color, white)' },
style: { color: 'var(--frigate-card-menu-button-active-color)' },
});
});
@@ -1714,7 +1717,7 @@ describe('MenuButtonController', () => {
expect(calculateButtons(controller)).toContainEqual({
...button,
style: { color: 'var(--primary-color, white)' },
style: { color: 'var(--frigate-card-menu-button-active-color)' },
});
});
@@ -1727,7 +1730,7 @@ describe('MenuButtonController', () => {
expect(calculateButtons(controller, { inFullscreenMode: true })).toContainEqual({
...button,
style: { color: 'var(--primary-color, white)' },
style: { color: 'var(--frigate-card-menu-button-active-color)' },
});
});
@@ -1745,7 +1748,7 @@ describe('MenuButtonController', () => {
expect(calculateButtons(controller, { view: view })).toContainEqual({
...button,
style: { color: 'var(--primary-color, white)' },
style: { color: 'var(--frigate-card-menu-button-active-color)' },
});
});
@@ -14,14 +14,14 @@ describe('StatusBarController', () => {
it('should set config', () => {
const host = createLitElement();
const controller = new StatusBarController(host);
const config = createConfig({
position: 'top',
style: 'hover',
height: 50,
});
controller.setConfig(config);
controller.setConfig(
createConfig({
position: 'top',
style: 'hover',
height: 50,
}),
);
expect(controller.getConfig()).toEqual(config);
expect(host.style.getPropertyValue('--frigate-card-status-bar-height')).toBe(
'50px',
);
+27
View File
@@ -3406,4 +3406,31 @@ describe('should handle version specific upgrades', () => {
postUpgradeChecks(config);
});
});
describe('v6.1.2+', () => {
describe('view.dark_mode -> view.dim', () => {
it.each([
['on' as const, true],
['auto' as const, false],
['off' as const, false],
])('%s', (darkMode: 'on' | 'off' | 'auto', expected: boolean) => {
const config = {
type: 'custom:frigate-card',
cameras: [{ camera_entity: 'camera.office' }],
view: {
dark_mode: darkMode,
},
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config).toEqual({
type: 'custom:frigate-card',
cameras: [{ camera_entity: 'camera.office' }],
view: {
dim: expected,
},
});
postUpgradeChecks(config);
});
});
});
});
+5 -2
View File
@@ -285,7 +285,7 @@ describe('config defaults', () => {
},
},
status_bar: {
height: 30,
height: 40,
items: {
engine: {
enabled: true,
@@ -329,7 +329,6 @@ describe('config defaults', () => {
type: 'frigate-hass-card',
view: {
camera_select: 'current',
dark_mode: 'off',
default: 'live',
keyboard_shortcuts: {
enabled: true,
@@ -355,6 +354,9 @@ describe('config defaults', () => {
key: '-',
},
},
theme: {
themes: ['traditional'],
},
triggers: {
show_trigger_status: false,
untrigger_seconds: 0,
@@ -366,6 +368,7 @@ describe('config defaults', () => {
filter_selected_camera: true,
},
interaction_seconds: 300,
dim: false,
default_cycle_camera: false,
default_reset: {
after_interaction: false,