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
5 changes: 5 additions & 0 deletions .changeset/strong-owls-cut.md
Original file line number Diff line number Diff line change
@@ -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
102 changes: 102 additions & 0 deletions e2e/theme-shortcut.spec.js
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});
45 changes: 44 additions & 1 deletion packages/react/src/html/ui/components/ThemeToggle.jsx
Original file line number Diff line number Diff line change
@@ -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<string, 'light'|'dark'>}
*/
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 (
<ThemeToggle onChange={setThemePreference} currentTheme={themePreference} />
<ThemeToggle
onChange={setThemePreference}
currentTheme={themePreference}
themeLabels={themeLabels}
/>
);
};

Expand Down
Loading