Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion config/i18n/locales/english/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
260 changes: 260 additions & 0 deletions cypress/e2e/english/landing/dark-mode.cy.ts
Original file line number Diff line number Diff line change
@@ -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 <button> element, not an anchor', () => {
cy.get(selectors.darkModeButton).then($el => {
expect($el[0].tagName.toLowerCase()).to.equal('button');
});
});

it('should have aria-pressed attribute', () => {
cy.get(selectors.darkModeButton).should('have.attr', 'aria-pressed');
});

it('should set aria-pressed to "false" in light mode', () => {
visitWithPreference(false);
cy.get(selectors.darkModeButton).should(
'have.attr',
'aria-pressed',
'false'
);
});

it('should set aria-pressed to "true" in dark mode', () => {
visitWithPreference(true);
cy.get(selectors.darkModeButton).should(
'have.attr',
'aria-pressed',
'true'
);
});

it('should toggle aria-pressed when clicked', () => {
visitWithPreference(false);
cy.get(selectors.darkModeButton).should(
'have.attr',
'aria-pressed',
'false'
);
openMenuAndToggleDarkMode();
cy.get(selectors.darkModeButton).should(
'have.attr',
'aria-pressed',
'true'
);
});
});

context('Theme class on <html>', () => {
beforeEach(() => {
cy.visit('/');
loadAllPosts();
});

it('should add dark-mode class to <html> when toggled on', () => {
visitWithPreference(false);
cy.get('html').should('not.have.class', 'dark-mode');
openMenuAndToggleDarkMode();
cy.get('html').should('have.class', 'dark-mode');
});

it('should remove dark-mode class from <html> when toggled off', () => {
visitWithPreference(true);
cy.get('html').should('have.class', 'dark-mode');
openMenuAndToggleDarkMode();
cy.get('html').should('not.have.class', 'dark-mode');
});

it('should never place dark-mode class on <body>', () => {
visitWithPreference(false);
openMenuAndToggleDarkMode();
cy.get('body').should('not.have.class', 'dark-mode');
});
});

context('localStorage persistence', () => {
beforeEach(() => {
cy.clearLocalStorage();
visitWithPreference(false);
loadAllPosts();
});

it('should set localStorage theme to "dark" when toggling to dark mode', () => {
openMenuAndToggleDarkMode();
cy.window().then(win => {
expect(win.localStorage.getItem('theme')).to.equal('dark');
});
});

it('should set localStorage theme to "light" when toggling back to light mode', () => {
openMenuAndToggleDarkMode();
// Menu stays open after dark mode click; click dark mode button again directly
cy.get(selectors.darkModeButton).click();
cy.window().then(win => {
expect(win.localStorage.getItem('theme')).to.equal('light');
});
});

it('should persist dark mode across page navigation', () => {
openMenuAndToggleDarkMode();
cy.visit('/');
cy.get('html').should('have.class', 'dark-mode');
});

it('should persist light mode across page navigation', () => {
openMenuAndToggleDarkMode();
// Menu stays open; click dark mode button again directly
cy.get(selectors.darkModeButton).click();
cy.visit('/');
cy.get('html').should('not.have.class', 'dark-mode');
});
});

context('System preference detection (FOUC prevention)', () => {
it('should apply dark mode when system prefers dark and no localStorage', () => {
cy.clearLocalStorage();
visitWithPreference(true);
cy.get('html').should('have.class', 'dark-mode');
});

it('should not apply dark mode when system prefers light and no localStorage', () => {
cy.clearLocalStorage();
visitWithPreference(false);
cy.get('html').should('not.have.class', 'dark-mode');
});

it('should respect localStorage over system preference (localStorage=light, system=dark)', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('theme', 'light');
cy.stub(win, 'matchMedia')
.withArgs('(prefers-color-scheme: dark)')
.returns({ matches: true });
}
});
cy.get('html').should('not.have.class', 'dark-mode');
});

it('should respect localStorage over system preference (localStorage=dark, system=light)', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('theme', 'dark');
cy.stub(win, 'matchMedia')
.withArgs('(prefers-color-scheme: dark)')
.returns({ matches: false });
}
});
cy.get('html').should('have.class', 'dark-mode');
});
});

context('Prism syntax theme toggling', () => {
beforeEach(() => {
cy.clearLocalStorage();
});

it('should have both prism theme link elements on post pages', () => {
cy.visit(postUrl);
cy.get(selectors.prismLight).should('exist');
cy.get(selectors.prismDark).should('exist');
});

it('should disable the dark prism theme in light mode', () => {
visitWithPreference(false, postUrl);
assertPrismDisabled(selectors.prismDark);
assertPrismEnabled(selectors.prismLight);
});

it('should disable the light prism theme in dark mode', () => {
visitWithPreference(true, postUrl);
assertPrismDisabled(selectors.prismLight);
assertPrismEnabled(selectors.prismDark);
});

it('should swap prism themes when toggling dark mode on', () => {
visitWithPreference(false, postUrl);
assertPrismDisabled(selectors.prismDark);
openMenuAndToggleDarkMode();
assertPrismDisabled(selectors.prismLight);
assertPrismEnabled(selectors.prismDark);
});

it('should swap prism themes when toggling dark mode off', () => {
visitWithPreference(true, postUrl);
assertPrismDisabled(selectors.prismLight);
openMenuAndToggleDarkMode();
assertPrismDisabled(selectors.prismDark);
assertPrismEnabled(selectors.prismLight);
});
});

context('Visual styling', () => {
beforeEach(() => {
cy.visit('/');
loadAllPosts();
});

it('should change body background color when dark mode is active', () => {
visitWithPreference(false);
cy.get('body').then($body => {
const lightBg = $body.css('background-color');
openMenuAndToggleDarkMode();
cy.get('body').should($darkBody => {
expect($darkBody.css('background-color')).to.not.equal(lightBg);
});
});
});

it('should change text color when dark mode is active', () => {
visitWithPreference(false);
cy.get('body').then($body => {
const lightColor = $body.css('color');
openMenuAndToggleDarkMode();
cy.get('body').should($darkBody => {
expect($darkBody.css('color')).to.not.equal(lightColor);
});
});
});
});
});
41 changes: 40 additions & 1 deletion cypress/e2e/english/landing/landing.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ const selectors = {
postPublishedTime: "[data-test-label='post-published-time']",
banner: "[data-test-label='banner']",
dropDownMenu: "[data-test-label='header-menu']",
toggleDropDownMenuButton: "[data-test-label='header-menu-button']"
toggleDropDownMenuButton: "[data-test-label='header-menu-button']",
darkModeButton: "[data-test-label='dark-mode-button']"
};

describe('Landing (Hashnode sourced)', () => {
Expand All @@ -37,6 +38,44 @@ describe('Landing (Hashnode sourced)', () => {
);
});

const visit = (darkAppearance: boolean) =>
cy.visit('/', {
onBeforeLoad(win) {
cy.stub(win, 'matchMedia')
.withArgs('(prefers-color-scheme: dark)')
.returns({
matches: darkAppearance
});
}
});

it('The dark mode button should be able to change the theme to dark mode from light mode', function () {
visit(false);
cy.get(selectors.toggleDropDownMenuButton).click();
cy.get(selectors.darkModeButton).click();

cy.get('html', { timeout: 1000 }).should('have.class', 'dark-mode');
});

it('The dark mode button should be able to change the theme to light mode from dark mode', function () {
visit(true);
cy.get(selectors.toggleDropDownMenuButton).click();
cy.get(selectors.darkModeButton).click();

cy.get('html', { timeout: 1000 }).should('not.have.class', 'dark-mode');
});

it('The theme should be set to dark and update the value in localStorage to dark', function () {
cy.clearLocalStorage();
cy.clearCookies();
cy.get(selectors.toggleDropDownMenuButton).click();
cy.get(selectors.darkModeButton).click();
cy.window().then(win => {
expect(win.localStorage.getItem('theme')).to.equal('dark');
});
visit(false);
});

// Because all templates readers see use `default.njk` as a base,
// we can test the favicon here
it('should have a favicon', () => {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"i18next": "25.8.14",
"i18next-fs-backend": "2.6.1",
"jest": "30.2.0",
"jest-environment-jsdom": "^30.2.0",
"jest-json-schema-extended": "1.0.1",
"joi": "18.0.2",
"js-yaml": "4.1.1",
Expand Down
Loading
Loading