feat: Update public portal colors with new design (#8230)

This commit is contained in:
Sivin Varghese
2023-11-23 08:16:52 +05:30
committed by GitHub
parent 4fc5f765de
commit 2d1f70eb79
25 changed files with 889 additions and 597 deletions

View File

@@ -3,6 +3,7 @@ import Vue from 'vue';
import PublicArticleSearch from './components/PublicArticleSearch.vue';
import TableOfContents from './components/TableOfContents.vue';
import { initializeTheme } from './portalThemeHelper.js';
export const getHeadingsfromTheArticle = () => {
const rows = [];
@@ -21,53 +22,6 @@ export const getHeadingsfromTheArticle = () => {
return rows;
};
export const generatePortalBgColor = (portalColor, theme) => {
const baseColor = theme === 'dark' ? 'black' : 'white';
return `color-mix(in srgb, ${portalColor} 20%, ${baseColor})`;
};
export const generatePortalBg = (portalColor, theme) => {
const bgImage = theme === 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg';
return `background: url(/assets/images/hc/${bgImage}) ${generatePortalBgColor(
portalColor,
theme
)}`;
};
export const generateGradientToBottom = theme => {
return `background-image: linear-gradient(to bottom, transparent, ${
theme === 'dark' ? '#151718' : 'white'
})`;
};
export const setPortalStyles = theme => {
const portalColor = window.portalConfig.portalColor;
const portalBgDiv = document.querySelector('#portal-bg');
const portalBgGradientDiv = document.querySelector('#portal-bg-gradient');
if (portalBgDiv) {
// Set background for #portal-bg
portalBgDiv.setAttribute('style', generatePortalBg(portalColor, theme));
}
if (portalBgGradientDiv) {
// Set gradient background for #portal-bg-gradient
portalBgGradientDiv.setAttribute('style', generateGradientToBottom(theme));
}
};
export const setPortalClass = theme => {
const portalDiv = document.querySelector('#portal');
portalDiv.classList.remove('light', 'dark');
if (!portalDiv) return;
portalDiv.classList.add(theme);
};
export const updateThemeStyles = theme => {
setPortalStyles(theme);
setPortalClass(theme);
};
export const openExternalLinksInNewTab = () => {
const { customDomain, hostURL } = window.portalConfig;
const isSameHost =
@@ -106,37 +60,6 @@ export const openExternalLinksInNewTab = () => {
});
};
export const toggleAppearanceDropdown = () => {
const dropdown = document.getElementById('appearance-dropdown');
if (!dropdown) return;
dropdown.style.display =
dropdown.style.display === 'none' || !dropdown.style.display
? 'flex'
: 'none';
};
export const updateURLParameter = (param, paramVal) => {
const urlObj = new URL(window.location);
urlObj.searchParams.set(param, paramVal);
return urlObj.toString();
};
export const removeURLParameter = parameter => {
const urlObj = new URL(window.location);
urlObj.searchParams.delete(parameter);
return urlObj.toString();
};
export const switchTheme = theme => {
updateThemeStyles(theme);
const newUrl =
theme !== 'system'
? updateURLParameter('theme', theme)
: removeURLParameter('theme');
window.location.href = newUrl;
toggleAppearanceDropdown();
};
export const InitializationHelpers = {
navigateToLocalePage: () => {
const allLocaleSwitcher = document.querySelector('.locale-switcher');
@@ -184,53 +107,17 @@ export const InitializationHelpers = {
});
},
initializeTheme: () => {
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
const getThemePreference = () =>
mediaQueryList.matches ? 'dark' : 'light';
const themeFromServer = window.portalConfig.theme;
if (themeFromServer === 'system') {
// Handle dynamic theme changes for system theme
mediaQueryList.addEventListener('change', event => {
const newTheme = event.matches ? 'dark' : 'light';
updateThemeStyles(newTheme);
});
const themePreference = getThemePreference();
updateThemeStyles(themePreference);
}
},
initializeToggleButton: () => {
const toggleButton = document.getElementById('toggle-appearance');
if (toggleButton) {
toggleButton.addEventListener('click', toggleAppearanceDropdown);
}
},
initializeThemeSwitchButtons: () => {
const appearanceDropdown = document.getElementById('appearance-dropdown');
if (!appearanceDropdown) return;
appearanceDropdown.addEventListener('click', event => {
const target = event.target.closest('button[data-theme]');
if (target) {
const theme = target.getAttribute('data-theme');
switchTheme(theme);
}
});
},
initializeThemesInPortal: initializeTheme,
initialize: () => {
openExternalLinksInNewTab();
if (window.portalConfig.isPlainLayoutEnabled === 'true') {
InitializationHelpers.appendPlainParamToURLs();
} else {
InitializationHelpers.initializeThemesInPortal();
InitializationHelpers.navigateToLocalePage();
InitializationHelpers.initializeSearch();
InitializationHelpers.initializeTableOfContents();
InitializationHelpers.initializeTheme();
InitializationHelpers.initializeToggleButton();
InitializationHelpers.initializeThemeSwitchButtons();
}
},

View File

@@ -0,0 +1,127 @@
import { adjustColorForContrast } from '../shared/helpers/colorHelper.js';
export const setPortalHoverColor = theme => {
// This function is to set the hover color for the portal
if (theme === 'system') {
const prefersDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
theme = prefersDarkMode ? 'dark' : 'light';
}
const portalColor = window.portalConfig.portalColor;
const bgColor = theme === 'dark' ? '#151718' : 'white';
const hoverColor = adjustColorForContrast(portalColor, bgColor);
// Set hover color for border and text dynamically
document.documentElement.style.setProperty(
'--dynamic-hover-color',
hoverColor
);
};
export const removeQueryParamsFromUrl = (queryParam = 'theme') => {
// This function is to remove the theme query param from the URL
// This is done so that the theme is not persisted in the URL
// This is called when the theme is switched from the dropdown
const url = new URL(window.location.href);
const param = url.searchParams.get(queryParam);
if (param) {
url.searchParams.delete(queryParam);
window.history.replaceState({}, '', url.toString()); // Convert URL to string
}
};
export const updateThemeInHeader = theme => {
// This function is to update the theme selection in the header in real time
const themeToggleButton = document.getElementById('toggle-appearance');
if (!themeToggleButton) return;
const allElementInButton =
themeToggleButton.querySelectorAll('.theme-button');
if (!allElementInButton) return;
allElementInButton.forEach(button => {
button.classList.toggle('hidden', button.dataset.theme !== theme);
button.classList.toggle('flex', button.dataset.theme === theme);
});
};
export const switchTheme = theme => {
if (theme === 'system') {
localStorage.removeItem('theme');
const prefersDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
// remove this so that the system theme is used
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(prefersDarkMode ? 'dark' : 'light');
} else {
localStorage.theme = theme;
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(theme);
}
setPortalHoverColor(theme);
updateThemeInHeader(theme);
removeQueryParamsFromUrl();
};
export const initializeThemeSwitchButtons = () => {
const appearanceDropdown = document.getElementById('appearance-dropdown');
appearanceDropdown.dataset.currentTheme = localStorage.theme || 'system';
appearanceDropdown.addEventListener('click', event => {
const target = event.target.closest('button[data-theme]');
if (target) {
const { theme } = target.dataset;
// setting this data property will automatically toggle the checkmark using CSS
appearanceDropdown.dataset.currentTheme = theme;
switchTheme(theme);
// wait for a bit before hiding the dropdown
appearanceDropdown.style.display = 'none';
}
});
};
export const initializeToggleButton = () => {
const themeToggleButton = document.getElementById('toggle-appearance');
themeToggleButton?.addEventListener('click', () => {
const appearanceDropdown = document.getElementById('appearance-dropdown');
const isCurrentlyHidden = appearanceDropdown.style.display === 'none';
// Toggle the appearanceDropdown
appearanceDropdown.style.display = isCurrentlyHidden ? 'flex' : 'none';
});
};
export const initializeMediaQueryListener = () => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => {
if (['light', 'dark'].includes(localStorage.theme)) return;
switchTheme('system');
});
};
export const initializeTheme = () => {
if (window.portalConfig.isPlainLayoutEnabled === 'true') return;
// start with updating the theme in the header, this will set the current theme on the button
// and set the hover color at the start of init, this is set again when the theme is switched
setPortalHoverColor(localStorage.theme || 'system');
window.updateThemeInHeader = updateThemeInHeader;
updateThemeInHeader(localStorage.theme || 'system');
// add the event listeners for the dropdown toggle and theme buttons
initializeToggleButton();
initializeThemeSwitchButtons();
// add the media query listener to update the theme when the system theme changes
initializeMediaQueryListener();
};

View File

@@ -1,15 +1,4 @@
import {
InitializationHelpers,
generatePortalBgColor,
generatePortalBg,
generateGradientToBottom,
setPortalStyles,
setPortalClass,
updateThemeStyles,
toggleAppearanceDropdown,
updateURLParameter,
removeURLParameter,
} from '../portalHelpers';
import { InitializationHelpers } from '../portalHelpers';
describe('#navigateToLocalePage', () => {
it('returns correct cookie name', () => {
@@ -32,303 +21,3 @@ describe('#navigateToLocalePage', () => {
);
});
});
describe('Theme Functions', () => {
describe('#generatePortalBgColor', () => {
it('returns mixed color for dark theme', () => {
const result = generatePortalBgColor('#FF5733', 'dark');
expect(result).toBe('color-mix(in srgb, #FF5733 20%, black)');
});
it('returns mixed color for light theme', () => {
const result = generatePortalBgColor('#FF5733', 'light');
expect(result).toBe('color-mix(in srgb, #FF5733 20%, white)');
});
});
describe('#generatePortalBg', () => {
it('returns background for dark theme', () => {
const result = generatePortalBg('#FF5733', 'dark');
expect(result).toBe(
'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #FF5733 20%, black)'
);
});
it('returns background for light theme', () => {
const result = generatePortalBg('#FF5733', 'light');
expect(result).toBe(
'background: url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #FF5733 20%, white)'
);
});
});
describe('#generateGradientToBottom', () => {
it('returns gradient for dark theme', () => {
const result = generateGradientToBottom('dark');
expect(result).toBe(
'background-image: linear-gradient(to bottom, transparent, #151718)'
);
});
it('returns gradient for light theme', () => {
const result = generateGradientToBottom('light');
expect(result).toBe(
'background-image: linear-gradient(to bottom, transparent, white)'
);
});
});
describe('#setPortalStyles', () => {
let mockPortalBgDiv;
let mockPortalBgGradientDiv;
beforeEach(() => {
// Mocking portal background div
mockPortalBgDiv = document.createElement('div');
mockPortalBgDiv.id = 'portal-bg';
document.body.appendChild(mockPortalBgDiv);
// Mocking portal background gradient div
mockPortalBgGradientDiv = document.createElement('div');
mockPortalBgGradientDiv.id = 'portal-bg-gradient';
document.body.appendChild(mockPortalBgGradientDiv);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('sets styles for portal background based on theme', () => {
window.portalConfig = { portalColor: '#FF5733' };
setPortalStyles('dark');
const expectedPortalBgStyle =
'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #FF5733 20%, black)';
const expectedGradientStyle =
'background-image: linear-gradient(to bottom, transparent, #151718)';
expect(mockPortalBgDiv.getAttribute('style')).toBe(expectedPortalBgStyle);
expect(mockPortalBgGradientDiv.getAttribute('style')).toBe(
expectedGradientStyle
);
});
});
describe('#setPortalClass', () => {
let mockPortalDiv;
beforeEach(() => {
// Mocking portal div
mockPortalDiv = document.createElement('div');
mockPortalDiv.id = 'portal';
mockPortalDiv.classList.add('light');
document.body.appendChild(mockPortalDiv);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('sets portal class to "dark" based on theme', () => {
setPortalClass('dark');
expect(mockPortalDiv.classList.contains('dark')).toBe(true);
expect(mockPortalDiv.classList.contains('light')).toBe(false);
});
it('sets portal class to "light" based on theme', () => {
setPortalClass('light');
expect(mockPortalDiv.classList.contains('light')).toBe(true);
expect(mockPortalDiv.classList.contains('dark')).toBe(false);
});
});
describe('toggleAppearanceDropdown', () => {
it('sets dropdown display to flex if initially none', () => {
document.body.innerHTML = `<div id="appearance-dropdown" style="display: none;"></div>`;
toggleAppearanceDropdown();
const dropdown = document.getElementById('appearance-dropdown');
expect(dropdown.style.display).toBe('flex');
});
it('sets dropdown display to none if initially flex', () => {
document.body.innerHTML = `<div id="appearance-dropdown" style="display: flex;"></div>`;
toggleAppearanceDropdown();
const dropdown = document.getElementById('appearance-dropdown');
expect(dropdown.style.display).toBe('none');
});
it('does nothing if dropdown element does not exist', () => {
document.body.innerHTML = ``;
expect(() => toggleAppearanceDropdown()).not.toThrow();
});
});
describe('updateURLParameter', () => {
it('updates a given parameter with a new value', () => {
const originalUrl = 'http://example.com?param=oldValue';
delete window.location;
window.location = new URL(originalUrl);
const updatedUrl = updateURLParameter('param', 'newValue');
expect(updatedUrl).toContain('param=newValue');
});
it('adds a new parameter if it does not exist', () => {
const originalUrl = 'http://example.com';
delete window.location;
window.location = new URL(originalUrl);
const updatedUrl = updateURLParameter('newParam', 'value');
expect(updatedUrl).toContain('newParam=value');
});
});
describe('removeURLParameter', () => {
it('removes an existing parameter', () => {
const originalUrl = 'http://example.com?param=value';
delete window.location;
window.location = new URL(originalUrl);
const updatedUrl = removeURLParameter('param');
expect(updatedUrl).not.toContain('param=value');
});
it('does nothing if the parameter does not exist', () => {
const originalUrl = 'http://example.com/';
delete window.location;
window.location = new URL(originalUrl);
const updatedUrl = removeURLParameter('param');
expect(updatedUrl).toBe(originalUrl);
});
});
describe('#updateThemeStyles', () => {
let mockPortalDiv;
let mockPortalBgDiv;
let mockPortalBgGradientDiv;
beforeEach(() => {
// Mocking portal div
mockPortalDiv = document.createElement('div');
mockPortalDiv.id = 'portal';
document.body.appendChild(mockPortalDiv);
// Mocking portal background div
mockPortalBgDiv = document.createElement('div');
mockPortalBgDiv.id = 'portal-bg';
document.body.appendChild(mockPortalBgDiv);
// Mocking portal background gradient div
mockPortalBgGradientDiv = document.createElement('div');
mockPortalBgGradientDiv.id = 'portal-bg-gradient';
document.body.appendChild(mockPortalBgGradientDiv);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('updates theme styles based on theme', () => {
window.portalConfig = { portalColor: '#FF5733' };
updateThemeStyles('dark');
const expectedPortalBgStyle =
'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #FF5733 20%, black)';
const expectedGradientStyle =
'background-image: linear-gradient(to bottom, transparent, #151718)';
expect(mockPortalDiv.classList.contains('dark')).toBe(true);
expect(mockPortalBgDiv.getAttribute('style')).toBe(expectedPortalBgStyle);
expect(mockPortalBgGradientDiv.getAttribute('style')).toBe(
expectedGradientStyle
);
});
});
describe('#initializeTheme', () => {
let mockPortalDiv;
beforeEach(() => {
mockPortalDiv = document.createElement('div');
mockPortalDiv.id = 'portal';
document.body.appendChild(mockPortalDiv);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('updates theme based on system preferences', () => {
const mediaQueryList = {
matches: true,
addEventListener: jest.fn(),
};
window.matchMedia = jest.fn().mockReturnValue(mediaQueryList);
window.portalConfig = { theme: 'system' };
InitializationHelpers.initializeTheme();
expect(mediaQueryList.addEventListener).toBeCalledWith(
'change',
expect.any(Function)
);
expect(mockPortalDiv.classList.contains('dark')).toBe(true);
});
it('does not update theme if themeFromServer is not "system"', () => {
const mediaQueryList = {
matches: true,
addEventListener: jest.fn(),
};
window.matchMedia = jest.fn().mockReturnValue(mediaQueryList);
window.portalConfig = { theme: 'dark' };
InitializationHelpers.initializeTheme();
expect(mediaQueryList.addEventListener).not.toBeCalled();
expect(mockPortalDiv.classList.contains('dark')).toBe(false);
expect(mockPortalDiv.classList.contains('light')).toBe(false);
});
});
describe('initializeToggleButton', () => {
it('adds a click listener to the toggle button', () => {
document.body.innerHTML = `<button id="toggle-appearance"></button>`;
InitializationHelpers.initializeToggleButton();
const toggleButton = document.getElementById('toggle-appearance');
expect(toggleButton.onclick).toBeDefined();
});
it('does nothing if the toggle button is not present', () => {
document.body.innerHTML = ``;
expect(() =>
InitializationHelpers.initializeToggleButton()
).not.toThrow();
});
});
describe('initializeThemeSwitchButtons', () => {
it('adds click listeners to theme switch buttons', () => {
document.body.innerHTML = `<div id="appearance-dropdown"><button data-theme="dark"></button><button data-theme="light"></button></div>`;
InitializationHelpers.initializeThemeSwitchButtons();
const buttons = document.querySelectorAll('button[data-theme]');
buttons.forEach(button => expect(button.onclick).toBeDefined());
});
it('does nothing if theme switch buttons are not present', () => {
document.body.innerHTML = ``;
expect(() =>
InitializationHelpers.initializeThemeSwitchButtons()
).not.toThrow();
});
it('does nothing if appearance-dropdown is not present', () => {
document.body.innerHTML = ``;
expect(() =>
InitializationHelpers.initializeThemeSwitchButtons()
).not.toThrow();
});
});
});

View File

@@ -0,0 +1,279 @@
import {
setPortalHoverColor,
removeQueryParamsFromUrl,
updateThemeInHeader,
switchTheme,
initializeThemeSwitchButtons,
initializeToggleButton,
initializeMediaQueryListener,
initializeTheme,
} from '../portalThemeHelper.js';
import { adjustColorForContrast } from '../../shared/helpers/colorHelper.js';
describe('portalThemeHelper', () => {
let themeToggleButton;
let appearanceDropdown;
beforeEach(() => {
themeToggleButton = document.createElement('div');
themeToggleButton.id = 'toggle-appearance';
document.body.appendChild(themeToggleButton);
appearanceDropdown = document.createElement('div');
appearanceDropdown.id = 'appearance-dropdown';
document.body.appendChild(appearanceDropdown);
window.matchMedia = jest.fn().mockImplementation(query => ({
matches: query === '(prefers-color-scheme: dark)',
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
window.portalConfig = { portalColor: '#ff5733' };
document.documentElement.style.setProperty = jest.fn();
document.documentElement.classList.remove('dark', 'light');
jest.clearAllMocks();
});
afterEach(() => {
themeToggleButton.remove();
appearanceDropdown.remove();
delete window.portalConfig;
document.documentElement.style.setProperty.mockRestore();
document.documentElement.classList.remove('dark', 'light');
localStorage.clear();
});
describe('#setPortalHoverColor', () => {
it('should apply dark hover color in dark theme', () => {
const hoverColor = adjustColorForContrast('#ff5733', '#151718');
setPortalHoverColor('dark');
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--dynamic-hover-color',
hoverColor
);
});
it('should apply light hover color in light theme', () => {
const hoverColor = adjustColorForContrast('#ff5733', '#ffffff');
setPortalHoverColor('light');
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--dynamic-hover-color',
hoverColor
);
});
});
describe('#removeQueryParamsFromUrl', () => {
let originalLocation;
beforeEach(() => {
originalLocation = window.location;
delete window.location;
window.location = new URL('http://localhost:3000/');
window.history.replaceState = jest.fn();
});
afterEach(() => {
window.location = originalLocation;
});
it('should not remove query params if theme is not in the URL', () => {
removeQueryParamsFromUrl();
expect(window.history.replaceState).not.toHaveBeenCalled();
});
it('should remove theme query param from the URL', () => {
window.location = new URL(
'http://localhost:3000/?theme=light&show_plain_layout=true'
);
removeQueryParamsFromUrl('theme');
expect(window.history.replaceState).toHaveBeenCalledWith(
{},
'',
'http://localhost:3000/?show_plain_layout=true'
);
});
});
describe('#updateThemeInHeader', () => {
beforeEach(() => {
themeToggleButton.innerHTML = `
<div class="theme-button" data-theme="light"></div>
<div class="theme-button" data-theme="dark"></div>
<div class="theme-button" data-theme="system"></div>
`;
});
it('should not update header if theme toggle button is not found', () => {
themeToggleButton.remove();
updateThemeInHeader('light');
expect(document.querySelector('.theme-button')).toBeNull();
});
it('should show the theme button for the selected theme', () => {
updateThemeInHeader('light');
const lightButton = themeToggleButton.querySelector(
'.theme-button[data-theme="light"]'
);
expect(lightButton.classList).toContain('flex');
});
});
describe('#switchTheme', () => {
it('should set theme to system theme and update classes', () => {
window.matchMedia = jest.fn().mockReturnValue({ matches: true });
switchTheme('system');
expect(localStorage.theme).toBeUndefined();
expect(document.documentElement.classList).toContain('dark');
});
it('should set theme to light theme and update classes', () => {
switchTheme('light');
expect(localStorage.theme).toBe('light');
expect(document.documentElement.classList).toContain('light');
});
it('should set theme to dark theme and update classes', () => {
switchTheme('dark');
expect(localStorage.theme).toBe('dark');
expect(document.documentElement.classList).toContain('dark');
});
});
describe('#initializeThemeSwitchButtons', () => {
beforeEach(() => {
appearanceDropdown.innerHTML = `
<button data-theme="light"><span class="check-mark-icon light-theme"></span></button>
<button data-theme="dark"><span class="check-mark-icon dark-theme"></span></button>
<button data-theme="system"><span class="check-mark-icon system-theme"></span></button>
`;
});
it('does nothing if the appearance dropdown is not found', () => {
appearanceDropdown.remove();
expect(appearanceDropdown.dataset.currentTheme).toBeUndefined();
});
it('should set current theme to system if no theme in localStorage', () => {
localStorage.removeItem('theme');
initializeThemeSwitchButtons();
expect(appearanceDropdown.dataset.currentTheme).toBe('system');
});
it('sets the current theme to the light theme', () => {
localStorage.theme = 'light';
appearanceDropdown.dataset.currentTheme = 'light';
initializeThemeSwitchButtons();
expect(appearanceDropdown.dataset.currentTheme).toBe('light');
});
it('sets the current theme to the dark theme', () => {
localStorage.theme = 'dark';
appearanceDropdown.dataset.currentTheme = 'dark';
initializeThemeSwitchButtons();
expect(appearanceDropdown.dataset.currentTheme).toBe('dark');
});
});
describe('#initializeToggleButton', () => {
it('does nothing if the theme toggle button is not found', () => {
themeToggleButton.remove();
initializeToggleButton();
expect(appearanceDropdown.style.display).toBe('');
});
it('toggles the appearance dropdown show/hide', () => {
themeToggleButton.click();
appearanceDropdown.style.display = 'flex';
expect(appearanceDropdown.style.display).toBe('flex');
themeToggleButton.click();
appearanceDropdown.style.display = 'none';
expect(appearanceDropdown.style.display).toBe('none');
});
});
describe('#initializeMediaQueryListener', () => {
let mediaQuery;
beforeEach(() => {
mediaQuery = {
addEventListener: jest.fn(),
matches: false,
};
window.matchMedia = jest.fn().mockReturnValue(mediaQuery);
});
it('adds a listener to the media query', () => {
initializeMediaQueryListener();
expect(window.matchMedia).toHaveBeenCalledWith(
'(prefers-color-scheme: dark)'
);
expect(mediaQuery.addEventListener).toHaveBeenCalledWith(
'change',
expect.any(Function)
);
});
it('does not switch theme if local storage theme is light or dark', () => {
localStorage.theme = 'light';
initializeMediaQueryListener();
mediaQuery.matches = true;
mediaQuery.addEventListener.mock.calls[0][1]();
expect(localStorage.theme).toBe('light');
});
it('switches to dark theme if system preference changes to dark and no theme is set in local storage', () => {
localStorage.removeItem('theme');
initializeMediaQueryListener();
mediaQuery.matches = true;
mediaQuery.addEventListener.mock.calls[0][1]();
expect(document.documentElement.classList).toContain('dark');
});
it('switches to light theme if system preference changes to light and no theme is set in local storage', () => {
localStorage.removeItem('theme');
initializeMediaQueryListener();
mediaQuery.matches = false;
mediaQuery.addEventListener.mock.calls[0][1]();
expect(document.documentElement.classList).toContain('light');
});
});
describe('#initializeTheme', () => {
it('should not initialize theme if plain layout is enabled', () => {
window.portalConfig.isPlainLayoutEnabled = 'true';
initializeTheme();
expect(localStorage.theme).toBeUndefined();
expect(document.documentElement.classList).not.toContain('light');
expect(document.documentElement.classList).not.toContain('dark');
});
it('sets the theme to the system theme', () => {
initializeTheme();
expect(localStorage.theme).toBeUndefined();
const prefersDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
expect(document.documentElement.classList.contains('light')).toBe(
!prefersDarkMode
);
});
it('sets the theme to the light theme', () => {
localStorage.theme = 'light';
document.documentElement.classList.add('light');
initializeTheme();
expect(localStorage.theme).toBe('light');
expect(document.documentElement.classList.contains('light')).toBe(true);
});
it('sets the theme to the dark theme', () => {
localStorage.theme = 'dark';
document.documentElement.classList.add('dark');
initializeTheme();
expect(localStorage.theme).toBe('dark');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
});

View File

@@ -1,3 +1,5 @@
import { toHex, mix, getLuminance, getContrast } from 'color2k';
export const isWidgetColorLighter = color => {
const colorToCheck = color.replace('#', '');
const c_r = parseInt(colorToCheck.substr(0, 2), 16);
@@ -6,3 +8,21 @@ export const isWidgetColorLighter = color => {
const brightness = (c_r * 299 + c_g * 587 + c_b * 114) / 1000;
return brightness > 225;
};
export const adjustColorForContrast = (color, backgroundColor) => {
const targetRatio = 3.1;
const MAX_ITERATIONS = 20;
let adjustedColor = color;
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration += 1) {
const currentRatio = getContrast(adjustedColor, backgroundColor);
if (currentRatio >= targetRatio) {
break;
}
const adjustmentDirection =
getLuminance(adjustedColor) < 0.5 ? '#fff' : '#151718';
adjustedColor = mix(adjustedColor, adjustmentDirection, 0.05);
}
return toHex(adjustedColor);
};

View File

@@ -1,4 +1,8 @@
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
import { toHex, getContrast } from 'color2k';
import {
isWidgetColorLighter,
adjustColorForContrast,
} from 'shared/helpers/colorHelper';
describe('#isWidgetColorLighter', () => {
it('returns true if color is lighter', () => {
@@ -8,3 +12,56 @@ describe('#isWidgetColorLighter', () => {
expect(isWidgetColorLighter('#000000')).toEqual(false);
});
});
describe('#adjustColorForContrast', () => {
const targetRatio = 3.1;
const getContrastRatio = (color1, color2) => {
// getContrast from 'color2k'
return getContrast(color1, color2);
};
it('adjusts a color to meet the contrast ratio against a light background', () => {
const color = '#ff0000';
const backgroundColor = '#ffffff';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
const ratio = getContrastRatio(adjustedColor, backgroundColor);
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
});
it('adjusts a color to meet the contrast ratio against a dark background', () => {
const color = '#00ff00';
const backgroundColor = '#000000';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
const ratio = getContrastRatio(adjustedColor, backgroundColor);
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
});
it('returns a string representation of the color', () => {
const color = '#00ff00';
const backgroundColor = '#000000';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
expect(typeof adjustedColor).toEqual('string');
});
it('handles cases where the color already meets the contrast ratio', () => {
const color = '#000000';
const backgroundColor = '#ffffff';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
const ratio = getContrastRatio(adjustedColor, backgroundColor);
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
expect(adjustedColor).toEqual(toHex(color));
});
it('does not modify a color that already exceeds the contrast ratio', () => {
const color = '#000000';
const backgroundColor = '#ffffff';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
expect(adjustedColor).toEqual(toHex(color));
});
});