fix: Broken header in public Help Center portal (#11704)

Fixes https://linear.app/chatwoot/issue/CW-4473/broken-header-in-help-center-portal
This commit is contained in:
Sivin Varghese
2025-06-12 00:37:24 +05:30
committed by GitHub
parent f627dbe42d
commit bf3b1676dd
11 changed files with 256 additions and 120 deletions

View File

@@ -59,17 +59,13 @@ export const openExternalLinksInNewTab = () => {
export const InitializationHelpers = {
navigateToLocalePage: () => {
const allLocaleSwitcher = document.querySelector('.locale-switcher');
document.addEventListener('change', e => {
const localeSwitcher = e.target.closest('.locale-switcher');
if (!localeSwitcher) return;
if (!allLocaleSwitcher) {
return false;
}
const { portalSlug } = allLocaleSwitcher.dataset;
allLocaleSwitcher.addEventListener('change', event => {
window.location = `/hc/${portalSlug}/${event.target.value}/`;
const { portalSlug } = localeSwitcher.dataset;
window.location.href = `/hc/${encodeURIComponent(portalSlug)}/${encodeURIComponent(localeSwitcher.value)}/`;
});
return false;
},
initializeSearch: () => {

View File

@@ -1,16 +1,20 @@
import { adjustColorForContrast } from '../shared/helpers/colorHelper.js';
const getResolvedTheme = theme => {
// Helper to get resolved theme (handles 'system' -> 'dark'/'light')
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
return theme;
};
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 resolvedTheme = getResolvedTheme(theme);
const portalColor = window.portalConfig.portalColor;
const bgColor = theme === 'dark' ? '#151718' : 'white';
const bgColor = resolvedTheme === 'dark' ? '#151718' : 'white';
const hoverColor = adjustColorForContrast(portalColor, bgColor);
// Set hover color for border and text dynamically
@@ -36,67 +40,80 @@ export const removeQueryParamsFromUrl = (queryParam = 'theme') => {
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);
const allThemeButtons = themeToggleButton.querySelectorAll('.theme-button');
if (!allThemeButtons.length) return;
allThemeButtons.forEach(button => {
const isActive = button.dataset.theme === theme;
button.classList.toggle('hidden', !isActive);
button.classList.toggle('flex', isActive);
});
};
export const switchTheme = theme => {
// Update localStorage
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);
}
const resolvedTheme = getResolvedTheme(theme);
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(resolvedTheme);
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';
}
// Update both dropdown data attributes
document.querySelectorAll('.appearance-menu').forEach(menu => {
menu.dataset.currentTheme = theme;
});
};
export const initializeToggleButton = () => {
const themeToggleButton = document.getElementById('toggle-appearance');
export const initializeThemeHandlers = () => {
const toggle = document.getElementById('toggle-appearance');
const dropdown = document.getElementById('appearance-dropdown');
if (!toggle || !dropdown) return;
themeToggleButton?.addEventListener('click', () => {
const appearanceDropdown = document.getElementById('appearance-dropdown');
// Toggle appearance dropdown
toggle.addEventListener('click', e => {
e.stopPropagation();
dropdown.dataset.dropdownOpen = String(
dropdown.dataset.dropdownOpen !== 'true'
);
});
const isCurrentlyHidden = appearanceDropdown.style.display === 'none';
// Toggle the appearanceDropdown
appearanceDropdown.style.display = isCurrentlyHidden ? 'flex' : 'none';
document.addEventListener('click', ({ target }) => {
if (toggle.contains(target)) return;
const themeBtn = target.closest('.appearance-menu button[data-theme]');
const menu = themeBtn?.closest('.appearance-menu');
if (themeBtn && menu) {
switchTheme(themeBtn.dataset.theme);
menu.dataset.dropdownOpen = 'false';
if (menu.id === 'mobile-appearance-dropdown') {
// Set the mobile menu toggle to false after a delay to ensure the transition is completed
setTimeout(() => {
const mobileToggle = document.getElementById('mobile-menu-toggle');
if (mobileToggle) mobileToggle.checked = false;
}, 300);
}
return;
}
// Close the desktop appearance dropdown if clicked outside
if (
dropdown.dataset.dropdownOpen === 'true' &&
!dropdown.contains(target)
) {
dropdown.dataset.dropdownOpen = 'false';
}
});
};
@@ -114,13 +131,12 @@ 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');
switchTheme(localStorage.theme || 'system');
window.updateThemeInHeader = updateThemeInHeader;
updateThemeInHeader(localStorage.theme || 'system');
// add the event listeners for the dropdown toggle and theme buttons
initializeToggleButton();
initializeThemeSwitchButtons();
initializeThemeHandlers();
// add the media query listener to update the theme when the system theme changes
initializeMediaQueryListener();

View File

@@ -29,22 +29,21 @@ describe('InitializationHelpers.navigateToLocalePage', () => {
delete global.window;
});
it('should return false if .locale-switcher is not found', () => {
it('sets up document event listener regardless of locale-switcher existence', () => {
document.querySelector('.locale-switcher').remove();
const result = InitializationHelpers.navigateToLocalePage();
expect(result).toBe(false);
const documentSpy = vi.spyOn(document, 'addEventListener');
InitializationHelpers.navigateToLocalePage();
expect(documentSpy).toHaveBeenCalledWith('change', expect.any(Function));
documentSpy.mockRestore();
});
it('should add change event listener to .locale-switcher', () => {
const localeSwitcher = document.querySelector('.locale-switcher');
const addEventListenerSpy = vi.spyOn(localeSwitcher, 'addEventListener');
it('adds document-level event listener to handle locale switching', () => {
const documentSpy = vi.spyOn(document, 'addEventListener');
InitializationHelpers.navigateToLocalePage();
expect(addEventListenerSpy).toHaveBeenCalledWith(
'change',
expect.any(Function)
);
expect(documentSpy).toHaveBeenCalledWith('change', expect.any(Function));
documentSpy.mockRestore();
});
});

View File

@@ -3,8 +3,7 @@ import {
removeQueryParamsFromUrl,
updateThemeInHeader,
switchTheme,
initializeThemeSwitchButtons,
initializeToggleButton,
initializeThemeHandlers,
initializeMediaQueryListener,
initializeTheme,
} from '../portalThemeHelper.js';
@@ -21,6 +20,7 @@ describe('portalThemeHelper', () => {
appearanceDropdown = document.createElement('div');
appearanceDropdown.id = 'appearance-dropdown';
appearanceDropdown.classList.add('appearance-menu');
document.body.appendChild(appearanceDropdown);
window.matchMedia = vi.fn().mockImplementation(query => ({
@@ -142,7 +142,7 @@ describe('portalThemeHelper', () => {
});
});
describe('#initializeThemeSwitchButtons', () => {
describe('#initializeThemeHandlers', () => {
beforeEach(() => {
appearanceDropdown.innerHTML = `
<button data-theme="light"><span class="check-mark-icon light-theme"></span></button>
@@ -153,43 +153,58 @@ describe('portalThemeHelper', () => {
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');
expect(() => initializeThemeHandlers()).not.toThrow();
});
it('sets the current theme to the light theme', () => {
localStorage.theme = 'light';
appearanceDropdown.dataset.currentTheme = 'light';
initializeThemeSwitchButtons();
it('should handle theme button clicks', () => {
initializeThemeHandlers();
// Simulate clicking a theme button
const lightButton = appearanceDropdown.querySelector(
'button[data-theme="light"]'
);
const clickEvent = new Event('click', { bubbles: true });
Object.defineProperty(clickEvent, 'target', {
value: lightButton,
enumerable: true,
});
document.dispatchEvent(clickEvent);
expect(localStorage.theme).toBe('light');
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');
});
});
it('should toggle dropdown visibility on toggle button click', () => {
initializeThemeHandlers();
describe('#initializeToggleButton', () => {
it('does nothing if the theme toggle button is not found', () => {
themeToggleButton.remove();
initializeToggleButton();
expect(appearanceDropdown.style.display).toBe('');
// Initially closed
expect(appearanceDropdown.dataset.dropdownOpen).toBeUndefined();
// Click to open
themeToggleButton.click();
expect(appearanceDropdown.dataset.dropdownOpen).toBe('true');
// Click to close
themeToggleButton.click();
expect(appearanceDropdown.dataset.dropdownOpen).toBe('false');
});
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');
it('should close dropdown when clicking outside', () => {
initializeThemeHandlers();
// Open dropdown
appearanceDropdown.dataset.dropdownOpen = 'true';
// Click outside
const outsideClick = new Event('click', { bubbles: true });
Object.defineProperty(outsideClick, 'target', {
value: document.body,
enumerable: true,
});
document.dispatchEvent(outsideClick);
expect(appearanceDropdown.dataset.dropdownOpen).toBe('false');
});
});