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]} + + ); + })} + + + ); };