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)
}
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])

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