Skip to content
Merged
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 thread
talissoncosta marked this conversation as resolved.
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.

92 changes: 82 additions & 10 deletions frontend/web/project/darkMode.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,92 @@
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 thread
coderabbitai[bot] marked this conversation as resolved.
}

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) {
// A clear (e.key null) or removal (e.newValue null) means no explicit
// choice remains; drop the in-memory fallback so we fall back to the OS.
if (e.key === null || e.newValue === null) {
inMemoryChoice = null
}
apply()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
}

apply()
Loading