diff --git a/app/javascript/portal/portalHelpers.js b/app/javascript/portal/portalHelpers.js index 6c0a87ffc..8b36e63f9 100644 --- a/app/javascript/portal/portalHelpers.js +++ b/app/javascript/portal/portalHelpers.js @@ -4,7 +4,7 @@ import Vue from 'vue'; import PublicArticleSearch from './components/PublicArticleSearch.vue'; import TableOfContents from './components/TableOfContents.vue'; -export const getHeadingsfromTheArticle = () => { +export const getHeadingsFromTheArticle = () => { const rows = []; const articleElement = document.getElementById('cw-article-content'); articleElement.querySelectorAll('h1, h2, h3').forEach(element => { @@ -21,6 +21,53 @@ 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 InitializationHelpers = { navigateToLocalePage: () => { const allLocaleSwitcher = document.querySelector('.locale-switcher'); @@ -36,7 +83,7 @@ export const InitializationHelpers = { return false; }, - initalizeSearch: () => { + initializeSearch: () => { const isSearchContainerAvailable = document.querySelector('#search-wrap'); if (isSearchContainerAvailable) { new Vue({ @@ -51,7 +98,7 @@ export const InitializationHelpers = { if (isOnArticlePage) { new Vue({ components: { TableOfContents }, - data: { rows: getHeadingsfromTheArticle() }, + data: { rows: getHeadingsFromTheArticle() }, template: '', }).$mount('#cw-hc-toc'); } @@ -68,13 +115,30 @@ 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); + } + }, + initialize: () => { if (window.portalConfig.isPlainLayoutEnabled === 'true') { InitializationHelpers.appendPlainParamToURLs(); } else { InitializationHelpers.navigateToLocalePage(); - InitializationHelpers.initalizeSearch(); + InitializationHelpers.initializeSearch(); InitializationHelpers.initializeTableOfContents(); + // InitializationHelpers.initializeTheme(); } }, diff --git a/app/javascript/portal/specs/portal.spec.js b/app/javascript/portal/specs/portal.spec.js index cd4347bad..69e1e81f0 100644 --- a/app/javascript/portal/specs/portal.spec.js +++ b/app/javascript/portal/specs/portal.spec.js @@ -1,4 +1,12 @@ -import { InitializationHelpers } from '../portalHelpers'; +import { + InitializationHelpers, + generatePortalBgColor, + generatePortalBg, + generateGradientToBottom, + setPortalStyles, + setPortalClass, + updateThemeStyles, +} from '../portalHelpers'; describe('#navigateToLocalePage', () => { it('returns correct cookie name', () => { @@ -21,3 +29,198 @@ 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 based on theme', () => { + setPortalClass('dark'); + + expect(mockPortalDiv.classList.contains('dark')).toBe(true); + expect(mockPortalDiv.classList.contains('light')).toBe(false); + }); + }); + + 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); + }); + }); +}); diff --git a/app/views/layouts/portal.html.erb b/app/views/layouts/portal.html.erb index d446e2e6f..2ae188fc3 100644 --- a/app/views/layouts/portal.html.erb +++ b/app/views/layouts/portal.html.erb @@ -48,6 +48,8 @@ By default, it renders: