diff --git a/.changeset/strong-owls-cut.md b/.changeset/strong-owls-cut.md new file mode 100644 index 000000000..702e19152 --- /dev/null +++ b/.changeset/strong-owls-cut.md @@ -0,0 +1,5 @@ +--- +'@doc-kit/generator-react': minor +--- + +Add keyboard shortcuts for switching themes: ⌘+L/Ctrl+L selects the light theme and ⌘+D/Ctrl+D the dark one, from anywhere on the page, with the shortcuts labeled in the theme dropdown menu diff --git a/e2e/theme-shortcut.spec.js b/e2e/theme-shortcut.spec.js new file mode 100644 index 000000000..d9df5c230 --- /dev/null +++ b/e2e/theme-shortcut.spec.js @@ -0,0 +1,102 @@ +import { expect, test } from '@playwright/test'; + +const REMOTE_CONFIG_URL = 'https://nodejs.org/site.json'; + +/** + * The theme applied to the document root. + * + * @param {import('@playwright/test').Page} page + */ +const currentTheme = page => + page.evaluate(() => document.documentElement.getAttribute('data-theme')); + +/** + * Resolves after every pending passive effect has been applied, so a theme + * that a press should not have changed can be asserted without a grace + * period: had the press gone through, the flip would land by then. + * + * @param {import('@playwright/test').Page} page + */ +const afterEffectsSettle = page => + page.evaluate( + () => + new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(resolve)) + ) + ); + +test.describe('Theme shortcuts', () => { + test.beforeEach(async ({ page }) => { + await page.route(REMOTE_CONFIG_URL, route => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ websiteBanners: {} }), + }) + ); + + await page.goto('/assert.html'); + + // The shortcuts arrive with the ThemeToggle island's effects, which can + // settle after the island is marked ready, so the first press of a test + // is retried until it takes hold (a press before that is simply lost). + await expect( + page.locator('is-land[data-island-name="ThemeToggle"][ready]') + ).toBeAttached(); + }); + + test('switches themes and persists the choice', async ({ page }) => { + await expect + .poll(async () => { + await page.keyboard.press('Meta+d'); + return currentTheme(page); + }) + .toBe('dark'); + expect(await page.evaluate(() => localStorage.getItem('theme'))).toBe( + 'dark' + ); + + await page.keyboard.press('Meta+l'); + await expect.poll(() => currentTheme(page)).toBe('light'); + expect(await page.evaluate(() => localStorage.getItem('theme'))).toBe( + 'light' + ); + }); + + test('accepts Ctrl in place of Meta', async ({ page }) => { + await expect + .poll(async () => { + await page.keyboard.press('Control+l'); + return currentTheme(page); + }) + .toBe('light'); + + await page.keyboard.press('Control+d'); + await expect.poll(() => currentTheme(page)).toBe('dark'); + }); + + test('ignores presses without or with extra modifiers', async ({ page }) => { + await expect + .poll(async () => { + await page.keyboard.press('Meta+d'); + return currentTheme(page); + }) + .toBe('dark'); + + // The listener just handled the press above, so these would switch the + // theme back if they were not filtered out + await page.keyboard.press('l'); + await page.keyboard.press('Meta+Shift+d'); + await afterEffectsSettle(page); + expect(await currentTheme(page)).toBe('dark'); + }); + + test('labels the shortcuts in the dropdown menu', async ({ page }) => { + await page.getByLabel('Select theme').click(); + + await expect(page.getByRole('menuitem')).toHaveText([ + 'System', + 'Light ⌘+L', + 'Dark ⌘+D', + ]); + }); +}); diff --git a/packages/react/src/html/ui/components/ThemeToggle.jsx b/packages/react/src/html/ui/components/ThemeToggle.jsx index 2e9737557..77583dd7e 100644 --- a/packages/react/src/html/ui/components/ThemeToggle.jsx +++ b/packages/react/src/html/ui/components/ThemeToggle.jsx @@ -1,16 +1,59 @@ import ThemeToggle from '@node-core/ui-components/Common/ThemeToggle'; +import { useEffect } from 'react'; import { useTheme } from '../hooks/useTheme.mjs'; import withIsland from '../islands/withIsland.jsx'; +const themeLabels = { + system: 'System', + light: 'Light ⌘+L', + dark: 'Dark ⌘+D', +}; + +/** + * Maps shortcut keys (used with ⌘/Ctrl) to the theme preference they select. + * @type {Record} + */ +const themeShortcuts = { + l: 'light', + d: 'dark', +}; + /** * Theme switcher. */ const Toggle = () => { const [themePreference, setThemePreference] = useTheme(); + useEffect(() => { + /** + * Switches the theme when a registered shortcut is pressed. + * @param {KeyboardEvent} event + */ + const onKeyDown = event => { + if (!(event.metaKey || event.ctrlKey) || event.shiftKey || event.altKey) { + return; + } + + const nextTheme = themeShortcuts[event.key.toLowerCase()]; + if (!nextTheme) { + return; + } + + event.preventDefault(); + setThemePreference(nextTheme); + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [setThemePreference]); + return ( - + ); };