diff --git a/public/docs/css/main.css b/public/docs/css/main.css index 0fff3ee935..1c51f17543 100644 --- a/public/docs/css/main.css +++ b/public/docs/css/main.css @@ -706,26 +706,33 @@ nav.skip-links a:focus { z-index: 2; } -.theme-switcher.dark-mode .switch-slider { +html[data-theme='dark'] .theme-switcher .switch-slider { transform: translateX(100%); } -.theme-switcher.dark-mode .theme-switcher__icon--light { +html[data-theme='dark'] .theme-switcher__icon--light { color: #7c98b4; } -.theme-switcher.dark-mode .theme-switcher__icon--light svg path { +html[data-theme='dark'] .theme-switcher__icon--light svg path { fill: #7c98b4; } -.theme-switcher:not(.dark-mode) .theme-switcher__icon--dark { +html[data-theme='light'] .theme-switcher__icon--dark { color: #557999; } -.theme-switcher:not(.dark-mode) .theme-switcher__icon--dark svg path { +html[data-theme='light'] .theme-switcher__icon--dark svg path { fill: #557999; } +/* The input is visually hidden but still the focusable control, so the ring + has to be drawn on the label. */ +.theme-switcher input:focus-visible + .theme-switcher__label { + outline: 0.125rem solid var(--octo-blue); + outline-offset: 0.125rem; +} + .theme-switcher input:disabled + .theme-switcher__label { opacity: 0.5; cursor: not-allowed; diff --git a/public/docs/js/modules/theme-switcher.js b/public/docs/js/modules/theme-switcher.js deleted file mode 100644 index 2c83dfead5..0000000000 --- a/public/docs/js/modules/theme-switcher.js +++ /dev/null @@ -1,49 +0,0 @@ -class ThemeSwitcher { - constructor(el) { - this.themeSwitcher = el; - this.checkbox = this.themeSwitcher.querySelector( - '[data-theme-toggle-checkbox]' - ); - this.html = document.documentElement; - this.isDarkMode = this.html.getAttribute('data-theme') === 'dark'; - - this.syncCheckboxWithTheme(); - this.addEventListeners(); - } - - addEventListeners() { - this.themeSwitcher.addEventListener('click', (e) => { - if (e.target === this.checkbox) { - this.toggleTheme(); - } - }); - // Handle keydown event for keyboard accessibility - this.themeSwitcher.addEventListener('keydown', (e) => { - if (e.keyCode === 13 || e.keyCode === 32) { - e.preventDefault(); - this.toggleTheme(); - } - }); - } - - toggleTheme() { - this.isDarkMode = !this.isDarkMode; - const newTheme = this.isDarkMode ? 'dark' : 'light'; - this.html.setAttribute('data-theme', newTheme); - localStorage.setItem('theme', newTheme); - - this.syncCheckboxWithTheme(); - } - - syncCheckboxWithTheme() { - this.checkbox.checked = this.isDarkMode; - this.checkbox.setAttribute('aria-checked', this.isDarkMode.toString()); - if (this.isDarkMode) { - this.themeSwitcher.classList.add('dark-mode'); - } else { - this.themeSwitcher.classList.remove('dark-mode'); - } - } -} - -export { ThemeSwitcher }; diff --git a/src/components/Header.astro b/src/components/Header.astro index 1015018d24..fb75e3aa70 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -6,8 +6,7 @@ import { SITE } from '@config'; import Search from '../themes/octopus/components/Search.astro'; import MobileMenu from './MobileMenu.astro'; import OctopusLogo from '../../public/docs/img/OctopusLogo.astro'; -import SunIcon from '../../public/docs/img/SunIcon.astro'; -import MoonIcon from '../../public/docs/img/MoonIcon.astro'; +import ThemeSwitcher from './ThemeSwitcher.astro'; const stats = new accelerator.statistics('components/Header.astro'); stats.start(); @@ -34,34 +33,7 @@ stats.stop(); Docs -
- - -
+ {showSearch && } @@ -71,12 +43,3 @@ stats.stop(); > - - diff --git a/src/components/MobileMenu.astro b/src/components/MobileMenu.astro index a42123a31a..b01e906f37 100644 --- a/src/components/MobileMenu.astro +++ b/src/components/MobileMenu.astro @@ -1,7 +1,6 @@ --- // Drawer body is populated client-side by nav-mobile.js cloning #site-nav. -import SunIcon from '../../public/docs/img/SunIcon.astro'; -import MoonIcon from '../../public/docs/img/MoonIcon.astro'; +import ThemeSwitcher from './ThemeSwitcher.astro'; ---
diff --git a/src/components/ThemeSwitcher.astro b/src/components/ThemeSwitcher.astro new file mode 100644 index 0000000000..e65844799c --- /dev/null +++ b/src/components/ThemeSwitcher.astro @@ -0,0 +1,44 @@ +--- +import SunIcon from '../../public/docs/img/SunIcon.astro'; +import MoonIcon from '../../public/docs/img/MoonIcon.astro'; + +type Props = { + /** Unique per instance - the label needs its own `for` target. */ + id: string; +}; + +const { id } = Astro.props satisfies Props; +--- + +
+ + +
+ + diff --git a/src/config.ts b/src/config.ts index 31990c4037..350c802660 100644 --- a/src/config.ts +++ b/src/config.ts @@ -74,14 +74,6 @@ export const HEADER_SCRIPTS = ` - - - diff --git a/tests/theme.spec.ts b/tests/theme.spec.ts new file mode 100644 index 0000000000..92a30c2e87 --- /dev/null +++ b/tests/theme.spec.ts @@ -0,0 +1,147 @@ +import { test, expect, type Page } from '@playwright/test'; + +const home = '/docs/'; +const otherPage = '/docs/getting-started/'; + +const headerInput = '#theme-switcher'; +const headerLabel = 'label[for="theme-switcher"]'; +const mobileInput = '#theme-switcher-mobile'; +const slider = '#theme-switcher ~ .theme-switcher__label .switch-slider'; + +// Auto-retrying so a slow style/attribute write can never make these flaky. +function expectTheme(page: Page, value: string) { + return expect(page.locator('html')).toHaveAttribute('data-theme', value); +} + +function expectPreference(page: Page, value: string) { + return expect(page.locator('html')).toHaveAttribute( + 'data-theme-preference', + value + ); +} + +function sliderTransform(page: Page) { + return page.locator(slider).evaluate((el) => getComputedStyle(el).transform); +} + +test.describe('theme', () => { + test.describe('with a dark OS preference', () => { + test.use({ colorScheme: 'dark' }); + + test('follows the OS when nothing is stored', async ({ page }) => { + await page.goto(home); + + await expectTheme(page, 'dark'); + await expectPreference(page, 'system'); + await expect(page.locator(headerInput)).toBeChecked(); + }); + + test('an explicit choice wins and survives navigation', async ({ + page, + }) => { + await page.goto(home); + await page.locator(headerLabel).click(); + + await expectTheme(page, 'light'); + await expectPreference(page, 'light'); + + await page.goto(otherPage); + await expectTheme(page, 'light'); + await expect(page.locator(headerInput)).not.toBeChecked(); + }); + + test('falls back to the OS when storage throws', async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window, 'localStorage', { + get() { + throw new Error('storage blocked'); + }, + }); + }); + await page.goto(home); + + await expectTheme(page, 'dark'); + + // Still usable for the life of the page, just not persisted. + await page.locator(headerLabel).click(); + await expectTheme(page, 'light'); + }); + }); + + test.describe('with a light OS preference', () => { + test.use({ colorScheme: 'light' }); + + test('follows the OS when nothing is stored', async ({ page }) => { + await page.goto(home); + + await expectTheme(page, 'light'); + await expect(page.locator(headerInput)).not.toBeChecked(); + }); + + test('both switchers stay in sync', async ({ page }) => { + await page.goto(home); + + await page.locator(headerLabel).click(); + await expectTheme(page, 'dark'); + await expect(page.locator(headerInput)).toBeChecked(); + await expect(page.locator(mobileInput)).toBeChecked(); + + await page.locator(headerLabel).click(); + await expectTheme(page, 'light'); + await expect(page.locator(mobileInput)).not.toBeChecked(); + }); + + // Space is the native checkbox key; Enter is optional for role="switch" + // but the control supported it before, so both have to keep working. + for (const key of ['Space', 'Enter']) { + test(`is operable with ${key}`, async ({ page }) => { + await page.goto(home); + await page.locator(headerInput).focus(); + + await page.keyboard.press(key); + await expectTheme(page, 'dark'); + await expect(page.locator(headerInput)).toBeChecked(); + + await page.keyboard.press(key); + await expectTheme(page, 'light'); + await expect(page.locator(headerInput)).not.toBeChecked(); + }); + } + }); + + // The design token stylesheets define their custom properties only under + // [data-theme='light'] / [data-theme='dark'] and nothing at :root, so a page + // with no attribute resolves no tokens at all. The server-rendered default in + // layouts/Default.astro is what keeps that from happening without scripting. + test.describe('with scripting disabled', () => { + test.use({ javaScriptEnabled: false }); + + test('still carries a data-theme for the tokens to key off', async ({ + page, + }) => { + await page.goto(home); + + await expectTheme(page, 'light'); + }); + }); + + // Regression guard: the knob used to be driven by a JS-applied class, so it + // painted on the light side and slid across after hydration. Driving it from + // data-theme means the attribute alone decides the position. + test('the knob position is driven by data-theme, not by script', async ({ + page, + }) => { + await page.goto(home); + + // Polled because the slider transitions between the two positions. + await page.evaluate(() => + document.documentElement.setAttribute('data-theme', 'dark') + ); + await expect.poll(() => sliderTransform(page)).not.toBe('none'); + + await page.evaluate(() => + document.documentElement.setAttribute('data-theme', 'light') + ); + await expect.poll(() => sliderTransform(page)).toBe('none'); + }); +});