diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index df9526a69..e003a62ba 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -11,11 +11,7 @@ class Public::Api::V1::Portals::BaseController < PublicController end def set_color_scheme - @theme = if %w[dark light].include?(params[:theme]) - params[:theme] - else - 'system' - end + @theme_from_params = params[:theme] if %w[dark light].include?(params[:theme]) end def portal diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 72d4eeda1..8bc234415 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -6,7 +6,17 @@ module PortalHelper def generate_portal_bg(portal_color, theme) bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg' - "background: url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}" + "url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}" + end + + def generate_gradient_to_bottom(theme) + base_color = theme == 'dark' ? '#151718' : 'white' + "linear-gradient(to bottom, transparent, #{base_color})" + end + + def generate_portal_hover_color(portal_color, theme) + base_color = theme == 'dark' ? '#1B1B1B' : '#F9F9F9' + "color-mix(in srgb, #{portal_color} 5%, #{base_color})" end def language_name(locale) @@ -14,32 +24,38 @@ module PortalHelper language_map[locale] || locale end - def get_theme_names(theme) - if theme == 'light' - I18n.t('public_portal.header.appearance.light') - elsif theme == 'dark' - I18n.t('public_portal.header.appearance.dark') + def theme_query_string(theme) + theme.present? && theme != 'system' ? "?theme=#{theme}" : '' + end + + def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled) + if is_plain_layout_enabled + "/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}" else - I18n.t('public_portal.header.appearance.system') + "/hc/#{portal_slug}/#{portal_locale}" end end - def get_theme_icon(theme) - if theme == 'light' - 'icons/sun' - elsif theme == 'dark' - 'icons/moon' + def generate_category_link(params) + portal_slug = params[:portal_slug] + category_locale = params[:category_locale] + category_slug = params[:category_slug] + theme = params[:theme] + is_plain_layout_enabled = params[:is_plain_layout_enabled] + + if is_plain_layout_enabled + "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}" else - 'icons/monitor' + "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}" end end - def generate_gradient_to_bottom(theme) - "background-image: linear-gradient(to bottom, transparent, #{theme == 'dark' ? '#151718' : 'white'})" - end - - def generate_article_link(portal_slug, article_slug, theme) - "/hc/#{portal_slug}/articles/#{article_slug}#{theme.present? && theme != 'system' ? "?theme=#{theme}" : ''}" + def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled) + if is_plain_layout_enabled + "/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}" + else + "/hc/#{portal_slug}/articles/#{article_slug}" + end end def render_category_content(content) diff --git a/app/javascript/portal/portalHelpers.js b/app/javascript/portal/portalHelpers.js index 1f0f24f66..f0b8af986 100644 --- a/app/javascript/portal/portalHelpers.js +++ b/app/javascript/portal/portalHelpers.js @@ -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(); } }, diff --git a/app/javascript/portal/portalThemeHelper.js b/app/javascript/portal/portalThemeHelper.js new file mode 100644 index 000000000..b0c9eaca5 --- /dev/null +++ b/app/javascript/portal/portalThemeHelper.js @@ -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(); +}; diff --git a/app/javascript/portal/specs/portal.spec.js b/app/javascript/portal/specs/portal.spec.js index e203fdd53..cd4347bad 100644 --- a/app/javascript/portal/specs/portal.spec.js +++ b/app/javascript/portal/specs/portal.spec.js @@ -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 = `
`; - 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 = ``; - 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 = ``; - 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 = ``; - 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(); - }); - }); -}); diff --git a/app/javascript/portal/specs/portalTheme.spec.js b/app/javascript/portal/specs/portalTheme.spec.js new file mode 100644 index 000000000..cc4a766e0 --- /dev/null +++ b/app/javascript/portal/specs/portalTheme.spec.js @@ -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 = ` + + + + `; + }); + + 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 = ` + + + + `; + }); + + 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); + }); + }); +}); diff --git a/app/javascript/shared/helpers/colorHelper.js b/app/javascript/shared/helpers/colorHelper.js index e3053be6b..660df1423 100644 --- a/app/javascript/shared/helpers/colorHelper.js +++ b/app/javascript/shared/helpers/colorHelper.js @@ -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); +}; diff --git a/app/javascript/shared/helpers/specs/colorHelper.spec.js b/app/javascript/shared/helpers/specs/colorHelper.spec.js index 6345eb74e..c88c0384f 100644 --- a/app/javascript/shared/helpers/specs/colorHelper.spec.js +++ b/app/javascript/shared/helpers/specs/colorHelper.spec.js @@ -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)); + }); +}); diff --git a/app/views/icons/_chatwoot-logo.html.erb b/app/views/icons/_chatwoot-logo.html.erb index 16059f9ae..5377a0a02 100644 --- a/app/views/icons/_chatwoot-logo.html.erb +++ b/app/views/icons/_chatwoot-logo.html.erb @@ -1,9 +1,11 @@ - + + \ No newline at end of file diff --git a/app/views/layouts/portal.html.erb b/app/views/layouts/portal.html.erb index 19295aa95..483ca3809 100644 --- a/app/views/layouts/portal.html.erb +++ b/app/views/layouts/portal.html.erb @@ -10,6 +10,7 @@ By default, it renders: (if provided by a `content_for` block in a nested page) - Flashes - Links to stylesheets and JavaScripts +- The appearance dropdown styles are added to the top to prevent FOUC %> @@ -21,16 +22,43 @@ By default, it renders: <%= javascript_pack_tag 'portal' %> <%= stylesheet_pack_tag 'portal' %> + <%= csrf_meta_tags %> <% if content_for?(:head) %> <%= yield(:head) %> <% else %>