diff --git a/apps/site/components/withNavBar.tsx b/apps/site/components/withNavBar.tsx
index daa874993991e..4b370fe48fd9b 100644
--- a/apps/site/components/withNavBar.tsx
+++ b/apps/site/components/withNavBar.tsx
@@ -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';
@@ -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 });
@@ -76,8 +69,14 @@ const WithNavBar: FC = () => {
@@ -23,11 +22,6 @@ const getTheme = (page: Page) =>
() => 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,
@@ -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 }) => {
diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json
index 940491462ec30..a794c5f164efc 100644
--- a/packages/i18n/src/locales/en.json
+++ b/packages/i18n/src/locales/en.json
@@ -1,5 +1,13 @@
{
"components": {
+ "header": {
+ "buttons": {
+ "theme": "Select theme",
+ "themeSystem": "System",
+ "themeLightMode": "Light",
+ "themeDarkMode": "Dark"
+ }
+ },
"containers": {
"footer": {
"legal": "Copyright OpenJS Foundation and Node.js contributors. All rights reserved. The OpenJS Foundation has registered trademarks and uses trademarks. For a list of trademarks of the OpenJS Foundation, please see our Trademark Policy and Trademark List. Trademarks and logos not indicated on the list of OpenJS Foundation trademarks are trademarks™ or registered® trademarks of their respective holders. Use of them does not imply any affiliation with or endorsement by them.",
@@ -270,10 +278,6 @@
"languageDropdown": {
"label": "Choose Language"
},
- "themeToggle": {
- "light": "Switch to Light Mode",
- "dark": "Switch to Dark Mode"
- },
"skipToContent": "Skip to content"
},
"metabar": {
diff --git a/packages/ui-components/src/Common/ThemeToggle/__tests__/index.test.jsx b/packages/ui-components/src/Common/ThemeToggle/__tests__/index.test.jsx
index fefa39f4044d1..91acf689ec00c 100644
--- a/packages/ui-components/src/Common/ThemeToggle/__tests__/index.test.jsx
+++ b/packages/ui-components/src/Common/ThemeToggle/__tests__/index.test.jsx
@@ -1,4 +1,4 @@
-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';
@@ -6,30 +6,102 @@ 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(
+
+ );
- render();
- 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(
+
+ );
+
+ 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(
+ {
+ 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(
+ {
+ 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(
+ {
+ selected = theme;
+ }}
+ themeLabels={defaultLabels}
+ />
+ );
+
+ await userEvent.click(screen.getByRole('button', { name: 'Select theme' }));
+ await userEvent.click(screen.getByText('System'));
+
+ assert.equal(selected, 'system');
});
});
diff --git a/packages/ui-components/src/Common/ThemeToggle/index.module.css b/packages/ui-components/src/Common/ThemeToggle/index.module.css
index 4bbf21b7bd44a..d1480cf221026 100644
--- a/packages/ui-components/src/Common/ThemeToggle/index.module.css
+++ b/packages/ui-components/src/Common/ThemeToggle/index.module.css
@@ -5,6 +5,7 @@
rounded-md
p-2
text-neutral-700
+ motion-safe:transition-colors
dark:text-neutral-300;
&:hover {
@@ -12,3 +13,37 @@
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;
+}
diff --git a/packages/ui-components/src/Common/ThemeToggle/index.stories.tsx b/packages/ui-components/src/Common/ThemeToggle/index.stories.tsx
index 617a4b31c7ece..ca2e2419c547b 100644
--- a/packages/ui-components/src/Common/ThemeToggle/index.stories.tsx
+++ b/packages/ui-components/src/Common/ThemeToggle/index.stories.tsx
@@ -5,6 +5,33 @@ import type { Meta as MetaObj, StoryObj } from '@storybook/react-webpack5';
type Story = StoryObj;
type Meta = MetaObj;
-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;
diff --git a/packages/ui-components/src/Common/ThemeToggle/index.tsx b/packages/ui-components/src/Common/ThemeToggle/index.tsx
index a1ddeca5644c4..923aba5f90d16 100644
--- a/packages/ui-components/src/Common/ThemeToggle/index.tsx
+++ b/packages/ui-components/src/Common/ThemeToggle/index.tsx
@@ -1,15 +1,76 @@
-import { MoonIcon, SunIcon } from '@heroicons/react/24/outline';
+import {
+ ComputerDesktopIcon,
+ MoonIcon,
+ SunIcon,
+} from '@heroicons/react/24/outline';
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
+import classNames from 'classnames';
-import type { FC, ButtonHTMLAttributes } from 'react';
+import type { FC } from 'react';
import styles from './index.module.css';
-const ThemeToggle: FC> = props => {
+export type Theme = 'system' | 'light' | 'dark';
+
+type ThemeToggleProps = {
+ onChange?: (theme: Theme) => void;
+ currentTheme?: Theme;
+ ariaLabel?: string;
+ themeLabels?: { system: string; light: string; dark: string };
+};
+
+const themeIcons: Record = {
+ system: ComputerDesktopIcon,
+ light: SunIcon,
+ dark: MoonIcon,
+};
+
+const themes: Array = ['system', 'light', 'dark'];
+
+const ThemeToggle: FC = ({
+ onChange = () => {},
+ currentTheme = 'system',
+ ariaLabel,
+ themeLabels = { system: 'System', light: 'Light', dark: 'Dark' },
+}) => {
+ const TriggerIcon = themeIcons[currentTheme];
+
return (
-
+
+
+
+
+
+
+
+ {themes.map(theme => {
+ const Icon = themeIcons[theme];
+ return (
+ onChange(theme)}
+ className={classNames(styles.dropDownItem, {
+ [styles.activeItem]: theme === currentTheme,
+ })}
+ >
+
+ {themeLabels[theme]}
+
+ );
+ })}
+
+
+
);
};