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
21 changes: 10 additions & 11 deletions apps/site/components/withNavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import WithNodejsLogo from '#site/components/withNodejsLogo';
import { useSiteNavigation } from '#site/hooks/generic';
import { useRouter, usePathname } from '#site/navigation.mjs';

import type { Theme } from '@node-core/ui-components/Common/ThemeToggle';
import type { SimpleLocaleConfig } from '@node-core/ui-components/types';
import type { FC } from 'react';

Expand All @@ -34,21 +35,13 @@ const ThemeToggle = dynamic(

const WithNavBar: FC = () => {
const { navigationItems } = useSiteNavigation();
const { resolvedTheme, setTheme } = useTheme();
const { theme, setTheme } = useTheme();
const { replace } = useRouter();
const pathname = usePathname();
const t = useTranslations();

const locale = useLocale();

const toggleCurrentTheme = () =>
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');

const themeToggleAriaLabel =
resolvedTheme === 'dark'
? t('components.common.themeToggle.light')
: t('components.common.themeToggle.dark');

const changeLanguage = (locale: SimpleLocaleConfig) =>
replace(pathname!, { locale: locale.code });

Expand Down Expand Up @@ -76,8 +69,14 @@ const WithNavBar: FC = () => {
<SearchButton />

<ThemeToggle
onClick={toggleCurrentTheme}
aria-label={themeToggleAriaLabel}
onChange={setTheme}
currentTheme={(theme as Theme) ?? 'system'}
ariaLabel={t('components.header.buttons.theme')}
themeLabels={{
system: t('components.header.buttons.themeSystem'),
light: t('components.header.buttons.themeLightMode'),
dark: t('components.header.buttons.themeDarkMode'),
}}
/>

<LanguageDropdown
Expand Down
55 changes: 32 additions & 23 deletions apps/site/tests/e2e/general-behavior.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,16 @@ const locators = {
navLinksLocator: `[aria-label="${englishLocale.components.containers.navBar.controls.toggle}"] + div`,
// Global UI controls
languageDropdownName: englishLocale.components.common.languageDropdown.label,
themeToggleAriaLabels: {
light: englishLocale.components.common.themeToggle.light,
dark: englishLocale.components.common.themeToggle.dark,
},
themeToggleName: englishLocale.components.header.buttons.theme,
themeDarkLabel: englishLocale.components.header.buttons.themeDarkMode,
themeLightLabel: englishLocale.components.header.buttons.themeLightMode,
};

const getTheme = (page: Page) =>
page.evaluate(
() => document.documentElement.dataset.theme as 'light' | 'dark'
);

const getCurrentAriaLabel = (theme: string) =>
theme === 'dark'
? locators.themeToggleAriaLabels.light
: locators.themeToggleAriaLabels.dark;

const openLanguageMenu = async (page: Page) => {
const button = page.getByRole('button', {
name: locators.languageDropdownName,
Expand Down Expand Up @@ -73,35 +67,50 @@ test.describe('Node.js Website', () => {
});

test.describe('Theme', () => {
test('should toggle between light/dark themes', async ({ page }) => {
test('should change to dark theme via dropdown', async ({ page }) => {
const themeToggle = page.getByRole('button', {
name: /Switch to (Light|Dark) Mode/i,
name: locators.themeToggleName,
});

const initialTheme = await getTheme(page);
const initialAriaLabel = getCurrentAriaLabel(initialTheme);
let currentAriaLabel = await themeToggle.getAttribute('aria-label');
expect(currentAriaLabel).toBe(initialAriaLabel);
await themeToggle.click();
await page
.getByRole('menuitem', { name: locators.themeDarkLabel })
.click();

expect(await getTheme(page)).toBe('dark');
});

test('should change to light theme via dropdown', async ({ page }) => {
const themeToggle = page.getByRole('button', {
name: locators.themeToggleName,
});

// Set dark first, then switch to light
await themeToggle.click();
await page
.getByRole('menuitem', { name: locators.themeDarkLabel })
.click();

const newTheme = await getTheme(page);
const newAriaLabel = getCurrentAriaLabel(newTheme);
currentAriaLabel = await themeToggle.getAttribute('aria-label');
await themeToggle.click();
await page
.getByRole('menuitem', { name: locators.themeLightLabel })
.click();

expect(newTheme).not.toBe(initialTheme);
expect(currentAriaLabel).toBe(newAriaLabel);
expect(await getTheme(page)).toBe('light');
});

test('should persist theme across page navigation', async ({ page }) => {
const themeToggle = page.getByRole('button', {
name: /Switch to (Light|Dark) Mode/i,
name: locators.themeToggleName,
});

await themeToggle.click();
const selectedTheme = await getTheme(page);
await page
.getByRole('menuitem', { name: locators.themeDarkLabel })
.click();

await page.reload();
expect(await getTheme(page)).toBe(selectedTheme);
expect(await getTheme(page)).toBe('dark');
});

test('should respect system preference initially', async ({ browser }) => {
Expand Down
12 changes: 8 additions & 4 deletions packages/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
{
"components": {
"header": {
"buttons": {
"theme": "Select theme",
"themeSystem": "System",
"themeLightMode": "Light",
"themeDarkMode": "Dark"
}
},
"containers": {
"footer": {
"legal": "Copyright <foundationName>OpenJS Foundation</foundationName> and Node.js contributors. All rights reserved. The <foundationName>OpenJS Foundation</foundationName> has registered trademarks and uses trademarks. For a list of trademarks of the <foundationName>OpenJS Foundation</foundationName>, please see our <trademarkPolicy>Trademark Policy</trademarkPolicy> and <trademarkList>Trademark List</trademarkList>. Trademarks and logos not indicated on the <trademarkList>list of OpenJS Foundation trademarks</trademarkList> are trademarks™ or registered® trademarks of their respective holders. Use of them does not imply any affiliation with or endorsement by them.",
Expand Down Expand Up @@ -270,10 +278,6 @@
"languageDropdown": {
"label": "Choose Language"
},
"themeToggle": {
"light": "Switch to Light Mode",
"dark": "Switch to Dark Mode"
},
"skipToContent": "Skip to content"
},
"metabar": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,35 +1,107 @@
import { describe, it, beforeEach } from 'node:test';
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import ThemeToggle from '../';

let mockCurrentTheme = 'light';
const noop = () => {};

const toggleTheme = () => {
mockCurrentTheme = mockCurrentTheme === 'light' ? 'dark' : 'light';
};
const defaultLabels = { system: 'System', light: 'Light', dark: 'Dark' };

describe('ThemeToggle', () => {
let toggle;
global.ResizeObserver = class {
observe = noop;
unobserve = noop;
disconnect = noop;
};

beforeEach(() => {
mockCurrentTheme = 'light';
it('renders the trigger button with the given aria-label', () => {
render(
<ThemeToggle
ariaLabel="Select theme"
currentTheme="system"
themeLabels={defaultLabels}
/>
);

render(<ThemeToggle onClick={toggleTheme} />);
toggle = screen.getByRole('button');
assert.ok(screen.getByRole('button', { name: 'Select theme' }));
});

it('switches dark theme to light theme', async () => {
mockCurrentTheme = 'dark';
await userEvent.click(toggle);
assert.equal(mockCurrentTheme, 'light');
it('opens the dropdown when the trigger is clicked', async () => {
render(
<ThemeToggle
ariaLabel="Select theme"
currentTheme="system"
themeLabels={defaultLabels}
/>
);

await userEvent.click(screen.getByRole('button', { name: 'Select theme' }));

assert.ok(screen.getByText('System'));
assert.ok(screen.getByText('Light'));
assert.ok(screen.getByText('Dark'));
});

it('calls onChange with "light" when the Light option is clicked', async () => {
let selected = null;

render(
<ThemeToggle
ariaLabel="Select theme"
currentTheme="system"
onChange={theme => {
selected = theme;
}}
themeLabels={defaultLabels}
/>
);

await userEvent.click(screen.getByRole('button', { name: 'Select theme' }));
await userEvent.click(screen.getByText('Light'));

assert.equal(selected, 'light');
});

it('switches light theme to dark theme', async () => {
await userEvent.click(toggle);
assert.equal(mockCurrentTheme, 'dark');
it('calls onChange with "dark" when the Dark option is clicked', async () => {
let selected = null;

render(
<ThemeToggle
ariaLabel="Select theme"
currentTheme="system"
onChange={theme => {
selected = theme;
}}
themeLabels={defaultLabels}
/>
);

await userEvent.click(screen.getByRole('button', { name: 'Select theme' }));
await userEvent.click(screen.getByText('Dark'));

assert.equal(selected, 'dark');
});

it('calls onChange with "system" when the System option is clicked', async () => {
let selected = null;

render(
<ThemeToggle
ariaLabel="Select theme"
currentTheme="light"
onChange={theme => {
selected = theme;
}}
themeLabels={defaultLabels}
/>
);

await userEvent.click(screen.getByRole('button', { name: 'Select theme' }));
await userEvent.click(screen.getByText('System'));

assert.equal(selected, 'system');
});
});
35 changes: 35 additions & 0 deletions packages/ui-components/src/Common/ThemeToggle/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,45 @@
rounded-md
p-2
text-neutral-700
motion-safe:transition-colors
dark:text-neutral-300;

&:hover {
@apply bg-neutral-100
dark:bg-neutral-900;
}
}

.dropDownContent {
@apply max-h-80
w-36
overflow-hidden
rounded-sm
border
border-neutral-200
bg-white
shadow-lg
dark:border-neutral-900
dark:bg-neutral-950;
}

.dropDownItem {
@apply flex
cursor-pointer
items-center
gap-2
px-2.5
py-1.5
text-sm
font-medium
text-neutral-800
outline-hidden
data-highlighted:bg-green-600
data-highlighted:text-white
dark:text-white;
}

.activeItem {
@apply bg-green-600
text-white;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@ import type { Meta as MetaObj, StoryObj } from '@storybook/react-webpack5';
type Story = StoryObj<typeof ThemeToggle>;
type Meta = MetaObj<typeof ThemeToggle>;

export const Default: Story = {};
const defaultLabels = { system: 'System', light: 'Light', dark: 'Dark' };

export const Default: Story = {
args: {
ariaLabel: 'Select theme',
currentTheme: 'system',
themeLabels: defaultLabels,
onChange: () => {},
},
};

export const LightSelected: Story = {
args: {
ariaLabel: 'Select theme',
currentTheme: 'light',
themeLabels: defaultLabels,
onChange: () => {},
},
};

export const DarkSelected: Story = {
args: {
ariaLabel: 'Select theme',
currentTheme: 'dark',
themeLabels: defaultLabels,
onChange: () => {},
},
};

export default { component: ThemeToggle } as Meta;
Loading
Loading