diff --git a/config/i18n/locales/english/translations.json b/config/i18n/locales/english/translations.json index 7c08c8194..b409067b4 100644 --- a/config/i18n/locales/english/translations.json +++ b/config/i18n/locales/english/translations.json @@ -4,7 +4,8 @@ "donate": "Donate", "load-more-articles": "Load More Articles", "menu": "Menu", - "learn": "Curriculum" + "learn": "Curriculum", + "toggle-dark-mode": "Night Mode" }, "search": { "label": "Search", diff --git a/cypress/e2e/english/landing/dark-mode.cy.ts b/cypress/e2e/english/landing/dark-mode.cy.ts new file mode 100644 index 000000000..b61d618ee --- /dev/null +++ b/cypress/e2e/english/landing/dark-mode.cy.ts @@ -0,0 +1,260 @@ +import { loadAllPosts } from '../../../support/utils/post-cards'; + +const selectors = { + toggleDropDownMenuButton: "[data-test-label='header-menu-button']", + darkModeButton: "[data-test-label='dark-mode-button']", + prismLight: '#prism-theme-light', + prismDark: '#prism-theme-dark' +}; + +const postUrl = '/how-do-numerical-conversions-work/'; + +const visitWithPreference = (darkAppearance: boolean, url = '/') => + cy.visit(url, { + onBeforeLoad(win) { + cy.stub(win, 'matchMedia') + .withArgs('(prefers-color-scheme: dark)') + .returns({ matches: darkAppearance }); + } + }); + +const openMenuAndToggleDarkMode = () => { + cy.get(selectors.toggleDropDownMenuButton).click(); + cy.get(selectors.darkModeButton).should('be.visible').click(); +}; + +const assertPrismDisabled = (selector: string) => { + cy.get(selector).should($el => { + expect(($el[0] as HTMLLinkElement).disabled).to.equal(true); + }); +}; + +const assertPrismEnabled = (selector: string) => { + cy.get(selector).should($el => { + expect(($el[0] as HTMLLinkElement).disabled).to.equal(false); + }); +}; + +describe('Dark Mode', () => { + context('Toggle button element', () => { + beforeEach(() => { + cy.visit('/'); + loadAllPosts(); + }); + + it('should render as a + ${ + hasPrismElements + ? ` + ` + : '' + } + `; +} + +function loadDarkModeScript() { + // Re-import the module fresh each time + const script = document.createElement('script'); + const toggleButton = document.getElementById('toggle-dark-mode'); + const onIcon = toggleButton.querySelector('i'); + const prismLight = document.getElementById('prism-theme-light'); + const prismDark = document.getElementById('prism-theme-dark'); + const isDark = document.documentElement.classList.contains('dark-mode'); + toggleButton.setAttribute('aria-pressed', String(isDark)); + + toggleButton.addEventListener('click', function () { + document.documentElement.classList.toggle('dark-mode'); + const isDarkNow = document.documentElement.classList.contains('dark-mode'); + if (isDarkNow) { + localStorage.setItem('theme', 'dark'); + this.setAttribute('aria-pressed', 'true'); + if (prismLight) prismLight.disabled = true; + if (prismDark) prismDark.disabled = false; + onIcon.classList.replace('fa-square', 'fa-square-check'); + } else { + localStorage.setItem('theme', 'light'); + this.setAttribute('aria-pressed', 'false'); + if (prismLight) prismLight.disabled = false; + if (prismDark) prismDark.disabled = true; + onIcon.classList.replace('fa-square-check', 'fa-square'); + } + }); +} + +const localStorageMock = (() => { + let store = {}; + return { + getItem: jest.fn(key => store[key] ?? null), + setItem: jest.fn((key, value) => { + store[key] = String(value); + }), + removeItem: jest.fn(key => { + delete store[key]; + }), + clear: jest.fn(() => { + store = {}; + }) + }; +})(); + +Object.defineProperty(window, 'localStorage', { value: localStorageMock }); + +beforeEach(() => { + localStorageMock.clear(); + jest.clearAllMocks(); +}); + +describe('dark-mode.js toggle handler', () => { + describe('initialization', () => { + it('should set aria-pressed to "false" when page loads in light mode', () => { + setupDOM({ isDark: false }); + loadDarkModeScript(); + + const button = document.getElementById('toggle-dark-mode'); + expect(button.getAttribute('aria-pressed')).toBe('false'); + }); + + it('should set aria-pressed to "true" when page loads in dark mode', () => { + setupDOM({ isDark: true }); + loadDarkModeScript(); + + const button = document.getElementById('toggle-dark-mode'); + expect(button.getAttribute('aria-pressed')).toBe('true'); + }); + + it('icon should be "fa-square-check" when page loads in dark mode', () => { + setupDOM({ isDark: true }); + loadDarkModeScript(); + + const button = document.getElementById('toggle-dark-mode'); + const icon = button.querySelector('i'); + expect(icon.classList.contains('fa-square-check')).toBe(true); + }); + + it('icon should be "fa-square" when page loads in light mode', () => { + setupDOM({ isDark: false }); + loadDarkModeScript(); + + const button = document.getElementById('toggle-dark-mode'); + const icon = button.querySelector('i'); + expect(icon.classList.contains('fa-square')).toBe(true); + }); + }); + + describe('toggling to dark mode', () => { + beforeEach(() => { + setupDOM({ isDark: false }); + loadDarkModeScript(); + }); + + it('should add dark-mode class to documentElement', () => { + document.getElementById('toggle-dark-mode').click(); + expect(document.documentElement.classList.contains('dark-mode')).toBe( + true + ); + }); + + it('should set localStorage theme to "dark"', () => { + document.getElementById('toggle-dark-mode').click(); + expect(localStorage.setItem).toHaveBeenCalledWith('theme', 'dark'); + }); + + it('should set aria-pressed to "true"', () => { + const button = document.getElementById('toggle-dark-mode'); + button.click(); + expect(button.getAttribute('aria-pressed')).toBe('true'); + }); + + it('should disable light Prism theme', () => { + document.getElementById('toggle-dark-mode').click(); + const prismLight = document.getElementById('prism-theme-light'); + expect(prismLight.disabled).toBe(true); + }); + + it('should enable dark Prism theme', () => { + document.getElementById('toggle-dark-mode').click(); + const prismDark = document.getElementById('prism-theme-dark'); + expect(prismDark.disabled).toBe(false); + }); + + it('icon should be set to "fa-square-check"', () => { + const button = document.getElementById('toggle-dark-mode'); + button.click(); + const icon = button.querySelector('i'); + expect(icon.classList.contains('fa-square-check')).toBe(true); + }); + }); + + describe('toggling to light mode', () => { + beforeEach(() => { + setupDOM({ isDark: true }); + loadDarkModeScript(); + }); + + it('should remove dark-mode class from documentElement', () => { + document.getElementById('toggle-dark-mode').click(); + expect(document.documentElement.classList.contains('dark-mode')).toBe( + false + ); + }); + + it('should set localStorage theme to "light"', () => { + document.getElementById('toggle-dark-mode').click(); + expect(localStorage.setItem).toHaveBeenCalledWith('theme', 'light'); + }); + + it('should set aria-pressed to "false"', () => { + const button = document.getElementById('toggle-dark-mode'); + button.click(); + expect(button.getAttribute('aria-pressed')).toBe('false'); + }); + + it('should enable light Prism theme', () => { + document.getElementById('toggle-dark-mode').click(); + const prismLight = document.getElementById('prism-theme-light'); + expect(prismLight.disabled).toBe(false); + }); + + it('should disable dark Prism theme', () => { + document.getElementById('toggle-dark-mode').click(); + const prismDark = document.getElementById('prism-theme-dark'); + expect(prismDark.disabled).toBe(true); + }); + + it('icon should be set to "fa-square"', () => { + const button = document.getElementById('toggle-dark-mode'); + button.click(); + const icon = button.querySelector('i'); + expect(icon.classList.contains('fa-square')).toBe(true); + }); + }); + + describe('double toggle (round-trip)', () => { + it('should return to light mode after toggling twice from light', () => { + setupDOM({ isDark: false }); + loadDarkModeScript(); + + const button = document.getElementById('toggle-dark-mode'); + const icon = button.querySelector('i'); + button.click(); + button.click(); + + expect(document.documentElement.classList.contains('dark-mode')).toBe( + false + ); + expect(icon.classList.contains('fa-square')).toBe(true); + expect(button.getAttribute('aria-pressed')).toBe('false'); + expect(localStorage.setItem).toHaveBeenLastCalledWith('theme', 'light'); + }); + + it('should return to dark mode after toggling twice from dark', () => { + setupDOM({ isDark: true }); + loadDarkModeScript(); + + const button = document.getElementById('toggle-dark-mode'); + const icon = button.querySelector('i'); + button.click(); + button.click(); + + expect(document.documentElement.classList.contains('dark-mode')).toBe( + true + ); + expect(icon.classList.contains('fa-square-check')).toBe(true); + expect(button.getAttribute('aria-pressed')).toBe('true'); + expect(localStorage.setItem).toHaveBeenLastCalledWith('theme', 'dark'); + }); + }); + + describe('without Prism elements', () => { + it('should not throw when Prism link elements are absent', () => { + setupDOM({ isDark: false, hasPrismElements: false }); + loadDarkModeScript(); + + expect(() => { + document.getElementById('toggle-dark-mode').click(); + }).not.toThrow(); + }); + + it('should still toggle dark-mode class without Prism elements', () => { + setupDOM({ isDark: false, hasPrismElements: false }); + loadDarkModeScript(); + + document.getElementById('toggle-dark-mode').click(); + expect(document.documentElement.classList.contains('dark-mode')).toBe( + true + ); + }); + + it('should still update localStorage without Prism elements', () => { + setupDOM({ isDark: false, hasPrismElements: false }); + loadDarkModeScript(); + + document.getElementById('toggle-dark-mode').click(); + expect(localStorage.setItem).toHaveBeenCalledWith('theme', 'dark'); + }); + }); +}); + +describe('FOUC prevention script', () => { + function simulateFOUCScript(theme, prefersDark) { + // Reset html class + document.documentElement.className = ''; + + // Simulate the inline script from default.njk + if (theme === 'dark' || (!theme && prefersDark)) { + document.documentElement.classList.add('dark-mode'); + } + } + + it('should add dark-mode class when localStorage theme is "dark"', () => { + simulateFOUCScript('dark', false); + expect(document.documentElement.classList.contains('dark-mode')).toBe(true); + }); + + it('should not add dark-mode class when localStorage theme is "light"', () => { + simulateFOUCScript('light', false); + expect(document.documentElement.classList.contains('dark-mode')).toBe( + false + ); + }); + + it('should add dark-mode class when no localStorage and system prefers dark', () => { + simulateFOUCScript(null, true); + expect(document.documentElement.classList.contains('dark-mode')).toBe(true); + }); + + it('should not add dark-mode class when no localStorage and system prefers light', () => { + simulateFOUCScript(null, false); + expect(document.documentElement.classList.contains('dark-mode')).toBe( + false + ); + }); + + it('should prioritize localStorage "dark" over system preference light', () => { + simulateFOUCScript('dark', false); + expect(document.documentElement.classList.contains('dark-mode')).toBe(true); + }); + + it('should prioritize localStorage "light" over system preference dark', () => { + simulateFOUCScript('light', true); + expect(document.documentElement.classList.contains('dark-mode')).toBe( + false + ); + }); +}); diff --git a/src/_includes/layouts/default.njk b/src/_includes/layouts/default.njk index 70173d0eb..dc242e2ba 100644 --- a/src/_includes/layouts/default.njk +++ b/src/_includes/layouts/default.njk @@ -12,6 +12,15 @@ + + @@ -29,10 +38,14 @@ + {# Font awesome CND #} + + + {# Algolia search assets #} - + {# Day.js and plugins for localization and formatting #} @@ -48,6 +61,7 @@ {% include "assets/js/client-dayjs.js" %} {% include "assets/js/cookie-checker.js" %} {% include "assets/js/toggle-menu-button.js" %} + {% include "assets/js/dark-mode.js" %} {% endset %} diff --git a/src/_includes/partials/prism.njk b/src/_includes/partials/prism.njk index 018a23156..47ecebc1a 100644 --- a/src/_includes/partials/prism.njk +++ b/src/_includes/partials/prism.njk @@ -1,4 +1,5 @@ + + +