feat: Update public portal colors with new design (#8230)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
127
app/javascript/portal/portalThemeHelper.js
Normal file
127
app/javascript/portal/portalThemeHelper.js
Normal 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();
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
279
app/javascript/portal/specs/portalTheme.spec.js
Normal file
279
app/javascript/portal/specs/portalTheme.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user