diff --git a/frontend/documentation/components/ThemeToggle.stories.tsx b/frontend/documentation/components/ThemeToggle.stories.tsx new file mode 100644 index 000000000000..b604cf60c2ac --- /dev/null +++ b/frontend/documentation/components/ThemeToggle.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from 'storybook' + +import ThemeToggle from 'components/ThemeToggle' + +const meta: Meta = { + component: ThemeToggle, + parameters: { + docs: { + description: { + component: + 'One-click light/dark toggle (the icon shows what you switch to). With no stored choice the theme follows the OS; the first click pins an explicit preference. Clicking flips this Storybook canvas live.', + }, + }, + layout: 'centered', + }, + title: 'Components/ThemeToggle', +} +export default meta + +type Story = StoryObj + +export const Default: Story = {} diff --git a/frontend/web/components/DarkModeSwitch.tsx b/frontend/web/components/DarkModeSwitch.tsx index c4e1aef475cc..987a4024b3f8 100644 --- a/frontend/web/components/DarkModeSwitch.tsx +++ b/frontend/web/components/DarkModeSwitch.tsx @@ -1,24 +1,22 @@ -import React, { FC, useState } from 'react' +import React, { FC } from 'react' import ConfigProvider from 'common/providers/ConfigProvider' import Setting from './Setting' -import { getDarkMode, setDarkMode as persistDarkMode } from 'project/darkMode' +import { setDarkMode, useTheme } from 'project/darkMode' type DarkModeSwitchType = {} +// Shares theme state with the nav ThemeToggle via useTheme, so neither +// control goes stale when the other flips the theme. Toggling here sets an +// explicit light/dark preference (leaving 'system' mode). const DarkModeSwitch: FC = ({}) => { - const [darkModeLocal, setDarkModeLocal] = useState(getDarkMode()) + const { isDark } = useTheme() - const toggleDarkMode = () => { - const newDarkMode = !getDarkMode() - setDarkModeLocal(newDarkMode) - persistDarkMode(newDarkMode) - } return ( setDarkMode(!isDark)} /> ) } diff --git a/frontend/web/components/ThemeToggle/ThemeToggle.tsx b/frontend/web/components/ThemeToggle/ThemeToggle.tsx new file mode 100644 index 000000000000..649e6b97f549 --- /dev/null +++ b/frontend/web/components/ThemeToggle/ThemeToggle.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import Button from 'components/base/forms/Button' +import Icon from 'components/icons/Icon' +import { useTheme } from 'project/darkMode' + +// One-click light/dark flip (the icon shows what you switch to). With no +// stored choice the theme follows the OS; the first click pins an explicit +// preference. State is shared via useTheme, so the account-settings switch +// and other tabs stay in sync. +const ThemeToggle = () => { + const { isDark, setDarkMode } = useTheme() + + return ( + + ) +} + +export default ThemeToggle diff --git a/frontend/web/components/pages/onboarding/ThemeToggle/index.ts b/frontend/web/components/ThemeToggle/index.ts similarity index 100% rename from frontend/web/components/pages/onboarding/ThemeToggle/index.ts rename to frontend/web/components/ThemeToggle/index.ts diff --git a/frontend/web/components/navigation/navbars/TopNavbar.tsx b/frontend/web/components/navigation/navbars/TopNavbar.tsx index 8eb7f5b2c147..060b22bf5e32 100644 --- a/frontend/web/components/navigation/navbars/TopNavbar.tsx +++ b/frontend/web/components/navigation/navbars/TopNavbar.tsx @@ -7,6 +7,7 @@ import Icon from 'components/icons/Icon' import Headway from 'components/Headway' import { Project } from 'common/types/responses' import AccountDropdown from 'components/navigation/AccountDropdown' +import ThemeToggle from 'components/ThemeToggle' type TopNavType = { activeProject: Project | undefined @@ -25,6 +26,11 @@ const TopNavbar: FC = ({ activeProject, projectId }) => {
+ {Utils.getFlagsmithHasFeature('dark_mode_nav_toggle') && ( +
+ +
+ )} { - const [dark, setDark] = useState(getDarkMode()) - - const toggle = () => { - const next = !dark - setDark(next) - setDarkMode(next) - } - - return ( - - ) -} - -export default ThemeToggle diff --git a/frontend/web/project/darkMode.ts b/frontend/web/project/darkMode.ts index d342dee08647..3a107f6fa625 100644 --- a/frontend/web/project/darkMode.ts +++ b/frontend/web/project/darkMode.ts @@ -1,20 +1,87 @@ +import { useSyncExternalStore } from 'react' import { storageGet, storageSet } from 'common/safeLocalStorage' -export const getDarkMode = () => { - return storageGet('dark_mode') === 'true' +type ThemeChoice = 'light' | 'dark' + +// A ThemeChoice once the user has explicitly chosen. +const THEME_KEY = 'theme' +// Pre-theme storage: 'true' | 'false' under 'dark_mode'. +const LEGACY_KEY = 'dark_mode' + +// Fallback when localStorage is unavailable (private mode, quota, blocked): +// hold the explicit choice in memory so the toggle still works this session. +let inMemoryChoice: ThemeChoice | null = null + +const media = + typeof window !== 'undefined' && window.matchMedia + ? window.matchMedia('(prefers-color-scheme: dark)') + : null + +const getStoredChoice = (): ThemeChoice | null => { + const stored = storageGet(THEME_KEY) + if (stored === 'light' || stored === 'dark') return stored + const legacy = storageGet(LEGACY_KEY) + if (legacy === 'true') return 'dark' + if (legacy === 'false') return 'light' + // Storage wins when present so cross-tab changes propagate; fall back to the + // in-memory choice only when storage has nothing (e.g. writes are blocked). + return inMemoryChoice } -export const setDarkMode = (enabled: boolean) => { - if (enabled) { - storageSet('dark_mode', 'true') - document.body.classList.add('dark') + +// An explicit choice wins; without one the OS preference decides. +export const getDarkMode = (): boolean => { + const choice = getStoredChoice() + return choice ? choice === 'dark' : !!media?.matches +} + +const listeners = new Set<() => void>() + +const apply = () => { + const dark = getDarkMode() + document.body.classList.toggle('dark', dark) + if (dark) { document.documentElement.setAttribute('data-bs-theme', 'dark') } else { - storageSet('dark_mode', 'false') - document.body.classList.remove('dark') document.documentElement.removeAttribute('data-bs-theme') } + listeners.forEach((listener) => listener()) +} + +export const setDarkMode = (enabled: boolean) => { + inMemoryChoice = enabled ? 'dark' : 'light' + storageSet(THEME_KEY, inMemoryChoice) + apply() +} + +const subscribe = (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } } -if (storageGet('dark_mode')) { - setDarkMode(getDarkMode()) +// Reactive theme state; all theme UI (nav toggle, settings switch) shares it +// so no control goes stale when another one changes the theme. +export const useTheme = () => { + const isDark = useSyncExternalStore(subscribe, getDarkMode) + return { isDark, setDarkMode } } + +// Follow OS appearance changes until the user makes an explicit choice. +media?.addEventListener?.('change', () => { + if (!getStoredChoice()) { + apply() + } +}) + +// Reflect theme changes made in other browser tabs immediately. +if (typeof window !== 'undefined') { + window.addEventListener('storage', (e) => { + // e.key is null on localStorage.clear() — revert to OS theme then too. + if (e.key === null || e.key === THEME_KEY || e.key === LEGACY_KEY) { + apply() + } + }) +} + +apply()