Skip to content
Draft
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
22 changes: 22 additions & 0 deletions frontend/documentation/components/ThemeToggle.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from 'storybook'

import ThemeToggle from 'components/ThemeToggle'

const meta: Meta<typeof ThemeToggle> = {
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<typeof ThemeToggle>

export const Default: Story = {}
18 changes: 8 additions & 10 deletions frontend/web/components/DarkModeSwitch.tsx
Original file line number Diff line number Diff line change
@@ -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<DarkModeSwitchType> = ({}) => {
const [darkModeLocal, setDarkModeLocal] = useState(getDarkMode())
const { isDark } = useTheme()

const toggleDarkMode = () => {
const newDarkMode = !getDarkMode()
setDarkModeLocal(newDarkMode)
persistDarkMode(newDarkMode)
}
return (
<Setting
title='Dark Mode'
description='Adjust the theme you see when using Flagsmith.'
checked={darkModeLocal}
onChange={toggleDarkMode}
checked={isDark}
onChange={() => setDarkMode(!isDark)}
/>
)
}
Expand Down
24 changes: 24 additions & 0 deletions frontend/web/components/ThemeToggle/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
theme='icon'
onClick={() => setDarkMode(!isDark)}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
<Icon name={isDark ? 'sun' : 'moon'} width={18} />
</Button>
)
}

export default ThemeToggle
Comment on lines +1 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the required component-folder layout.

Move this to ThemeToggle/ThemeToggle.tsx, add co-located ThemeToggle/ThemeToggle.scss, and re-export it from the existing barrel.

As per coding guidelines, each new component requires a barrel, ComponentName/ComponentName.tsx, and co-located SCSS.

Source: Coding guidelines

6 changes: 6 additions & 0 deletions frontend/web/components/navigation/navbars/TopNavbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +26,11 @@ const TopNavbar: FC<TopNavType> = ({ activeProject, projectId }) => {
<div className='me-3'>
<GithubStar />
</div>
{Utils.getFlagsmithHasFeature('dark_mode_nav_toggle') && (
<div className='me-3'>
<ThemeToggle />
</div>
)}
<NavLink
activeClassName='active'
to={'/getting-started'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom'
import Button from 'components/base/forms/Button'
import Icon from 'components/icons/Icon'
import OnboardingHeader from 'components/pages/onboarding/OnboardingHeader'
import ThemeToggle from 'components/pages/onboarding/ThemeToggle'
import ThemeToggle from 'components/ThemeToggle'
import OnboardingConnectPanel from 'components/pages/onboarding/OnboardingConnectPanel'
import OnboardingTerminal from 'components/pages/onboarding/OnboardingTerminal'
import OnboardingFlagsTable from 'components/pages/onboarding/OnboardingFlagsTable'
Expand Down

This file was deleted.

87 changes: 77 additions & 10 deletions frontend/web/project/darkMode.ts
Original file line number Diff line number Diff line change
@@ -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()
Comment on lines +50 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep the selected theme in memory when storage is unavailable.

storageSet swallows privacy/quota failures; apply() then re-reads no choice and reapplies the OS theme. A user whose browser blocks local storage therefore cannot change theme at all. Update in-memory theme state before applying, while still attempting persistence.

Based on the supplied safe-storage contract, storageSet catches write failures without persisting.

}

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()
}
Comment on lines +79 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle cross-tab localStorage.clear().

A storage event from localStorage.clear() has e.key === null; this condition ignores it, leaving other tabs on the previous explicit theme instead of reverting to their OS preference.

-    if (e.key === THEME_KEY || e.key === LEGACY_KEY) {
+    if (e.key === null || e.key === THEME_KEY || e.key === LEGACY_KEY) {
       apply()
     }

Based on the PR objective, theme changes must synchronise across open browser tabs.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
window.addEventListener('storage', (e) => {
if (e.key === THEME_KEY || e.key === LEGACY_KEY) {
apply()
}
window.addEventListener('storage', (e) => {
if (e.key === null || e.key === THEME_KEY || e.key === LEGACY_KEY) {
apply()
}

})
}

apply()
Loading