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
4 changes: 4 additions & 0 deletions frontend/common/stores/account-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -360,10 +360,14 @@ const controller = {
} else if (!user) {
store.ephemeral_token = null
const darkMode = storageGet('dark_mode')
const themePreference = storageGet('theme_preference')
AsyncStorage.clear()
if (darkMode) {
storageSet('dark_mode', darkMode)
}
if (themePreference) {
storageSet('theme_preference', themePreference)
}
Comment on lines 364 to +370
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Since AsyncStorage.clear() is an asynchronous operation that returns a Promise, executing synchronous storageSet calls immediately after it creates a race condition. The storage might be cleared after the theme preferences are restored, causing them to be lost on logout. Wrapping the restoration in the .then() callback of AsyncStorage.clear() ensures they are safely preserved.

      AsyncStorage.clear().then(() => {
        if (darkMode) {
          storageSet('dark_mode', darkMode)
        }
        if (themePreference) {
          storageSet('theme_preference', themePreference)
        }
      })

if (!data.token) {
return
}
Expand Down
186 changes: 170 additions & 16 deletions frontend/web/components/DarkModeSwitch.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,179 @@
import React, { FC, useState } from 'react'
import React, { FC, useEffect, useLayoutEffect, useRef, useState } from 'react'
import classNames from 'classnames'
import ConfigProvider from 'common/providers/ConfigProvider'
import Setting from './Setting'
import { getDarkMode, setDarkMode as persistDarkMode } from 'project/darkMode'
import { calculateListPosition } from 'common/utils/calculateListPosition'
import useOutsideClick from 'common/useOutsideClick'
import InlinePillToggle from './base/forms/InlinePillToggle'
import Icon, { type IconName } from './icons/Icon'
import {
getResolvedDarkMode,
getThemePreference,
listenToThemePreference,
setThemePreference,
type ThemePreference,
} from 'project/darkMode'
import { createPortal } from 'react-dom'

type DarkModeSwitchType = {}
const themeOptions: {
icon: IconName
label: string
value: ThemePreference
}[] = [
{ icon: 'sun', label: 'Light', value: 'light' },
{ icon: 'moon', label: 'Dark', value: 'dark' },
{ icon: 'options-2', label: 'System', value: 'system' },
]

const DarkModeSwitch: FC<DarkModeSwitchType> = ({}) => {
const [darkModeLocal, setDarkModeLocal] = useState(getDarkMode())
const getThemeOption = (preference: ThemePreference) =>
themeOptions.find((option) => option.value === preference) ?? themeOptions[0]

const toggleDarkMode = () => {
const newDarkMode = !getDarkMode()
setDarkModeLocal(newDarkMode)
persistDarkMode(newDarkMode)
const getActiveThemeIcon = (
preference: ThemePreference,
resolvedDarkMode: boolean,
) => {
if (preference === 'system') {
return resolvedDarkMode ? 'moon' : 'sun'
}

return getThemeOption(preference).icon
}

const getThemeState = () => ({
preference: getThemePreference(),
resolvedDarkMode: getResolvedDarkMode(),
})

const useThemePreference = () => {
const [themeState, setThemeState] = useState(getThemeState)

useEffect(
() =>
listenToThemePreference(() => {
setThemeState(getThemeState())
}),
[],
)

return {
...themeState,
setPreference: setThemePreference,
}
}

const DarkModeSwitch: FC = () => {
const { preference, setPreference } = useThemePreference()

return (
<>
<Row className='mb-2 align-items-center justify-content-between'>
<h5 className='mb-0'>Theme</h5>
<InlinePillToggle
data-test='theme-preference-setting'
options={themeOptions.map(({ label, value }) => ({ label, value }))}
size='small'
value={preference}
onChange={setPreference}
/>
</Row>
<p className='fs-small lh-sm'>
Choose a light or dark theme, or follow your system setting.
</p>
</>
)
}

export const ThemeModeDropdown: FC = () => {
const { preference, resolvedDarkMode, setPreference } = useThemePreference()
const [isOpen, setIsOpen] = useState(false)
const btnRef = useRef<HTMLButtonElement>(null)
const dropDownRef = useRef<HTMLDivElement>(null)
const activeOption = getThemeOption(preference)
const activeIcon = getActiveThemeIcon(preference, resolvedDarkMode)

useOutsideClick(dropDownRef as React.RefObject<HTMLElement>, () =>
setIsOpen(false),
)

useLayoutEffect(() => {
if (!isOpen || !dropDownRef.current || !btnRef.current) return
const listPosition = calculateListPosition(
btnRef.current,
dropDownRef.current,
)
dropDownRef.current.style.top = `${listPosition.top}px`
dropDownRef.current.style.left = `${listPosition.left}px`
}, [isOpen])
Comment on lines +97 to +105
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The dropdown position is currently calculated only once when isOpen changes. Since the dropdown is rendered via a portal to document.body, scrolling the page or resizing the window while the dropdown is open will cause it to detach and become misaligned from the trigger button. Adding event listeners for resize and scroll (using capture phase to catch scrolling in any container) ensures the dropdown remains correctly positioned.

  useLayoutEffect(() => {
    if (!isOpen || !dropDownRef.current || !btnRef.current) return

    const updatePosition = () => {
      if (!btnRef.current || !dropDownRef.current) return
      const listPosition = calculateListPosition(
        btnRef.current,
        dropDownRef.current,
      )
      dropDownRef.current.style.top = `${listPosition.top}px`
      dropDownRef.current.style.left = `${listPosition.left}px`
    }

    updatePosition()

    window.addEventListener('resize', updatePosition)
    window.addEventListener('scroll', updatePosition, true)

    return () => {
      window.removeEventListener('resize', updatePosition)
      window.removeEventListener('scroll', updatePosition, true)
    }
  }, [isOpen])


return (
<Setting
title='Dark Mode'
description='Adjust the theme you see when using Flagsmith.'
checked={darkModeLocal}
onChange={toggleDarkMode}
/>
<div className='feature-action' tabIndex={-1}>
<button
aria-expanded={isOpen}
aria-label='Theme'
className='account-dropdown-trigger d-flex ps-3 lh-1 align-items-center text-default'
data-test='theme-preference-trigger'
onClick={(e) => {
e.stopPropagation()
setIsOpen(!isOpen)
}}
ref={btnRef}
title='Theme'
type='button'
>
<span className='mr-1 icon-secondary'>
<Icon name={activeIcon} width={18} />
</span>
<span className='d-none d-lg-block'>{activeOption.label}</span>
</button>

{isOpen &&
createPortal(
<div ref={dropDownRef} className='feature-action__list'>
<div
className='feature-action__item feature-action__header'
style={{
color: '#656D7B',
cursor: 'default',
fontSize: '12px',
fontWeight: 600,
padding: '8px 16px',
}}
>
Theme
</div>
{themeOptions.map((option) => {
const isSelected = preference === option.value
return (
<button
aria-pressed={isSelected}
className={classNames('feature-action__item theme-option', {
'feature-action__item--selected': isSelected,
})}
data-test={`theme-preference-${option.value}`}
key={option.value}
onClick={(e) => {
e.stopPropagation()
setPreference(option.value)
setIsOpen(false)
}}
type='button'
>
<Icon name={option.icon} width={18} fill='#9DA4AE' />
<span>{option.label}</span>
{isSelected && (
<Icon
className='ms-auto'
name='checkmark'
width={16}
fill='#6837FC'
/>
)}
</button>
)
})}
</div>,
document.body,
)}
</div>
)
}

Expand Down
16 changes: 12 additions & 4 deletions frontend/web/components/navigation/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ import { useHistory, useLocation } from 'react-router-dom'
import AccountStore from 'common/stores/account-store'
import EnvironmentAside from './EnvironmentAside'
import { Project as ProjectType } from 'common/types/responses'
// @ts-ignore
import { AsyncStorage } from 'polyfill-react-native'
import ProjectNavbar from './navbars/ProjectNavbar'
import OrganisationNavbar from './navbars/OrganisationNavbar'
import TopNavbar from './navbars/TopNavbar'
import { appLevelPaths } from './constants'
import { ThemeModeDropdown } from 'components/DarkModeSwitch'

type NavType = {
environmentId: string | undefined
projectId: number
children?: ReactNode
header?: ReactNode
activeProject: ProjectType | undefined
}
Expand Down Expand Up @@ -73,10 +76,15 @@ const Nav: FC<NavType> = ({
<div className='d-flex bg-faint pt-1 py-0'>
<Flex className='flex-row px-2 '>
{!!AccountStore.getUser() && (
<TopNavbar
activeProject={activeProject}
projectId={projectId}
/>
<>
<TopNavbar
activeProject={activeProject}
projectId={projectId}
/>
<div className='d-flex d-sm-none justify-content-end full-width py-2'>
<ThemeModeDropdown />
</div>
</>
)}
</Flex>
</div>
Expand Down
2 changes: 2 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 { ThemeModeDropdown } from 'components/DarkModeSwitch'

type TopNavType = {
activeProject: Project | undefined
Expand Down Expand Up @@ -45,6 +46,7 @@ const TopNavbar: FC<TopNavType> = ({ activeProject, projectId }) => {
<span className='d-none d-md-block'>Docs</span>
</a>
<Headway className='cursor-pointer ps-3' />
<ThemeModeDropdown />

{Utils.getFlagsmithHasFeature('persona_based_views') ? (
<AccountDropdown />
Expand Down
Loading
Loading