diff --git a/frontend/common/stores/account-store.js b/frontend/common/stores/account-store.js index 2deb58f93073..7f8a6f01b723 100644 --- a/frontend/common/stores/account-store.js +++ b/frontend/common/stores/account-store.js @@ -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 } diff --git a/frontend/web/components/DarkModeSwitch.tsx b/frontend/web/components/DarkModeSwitch.tsx index c4e1aef475cc..86f36cae1c19 100644 --- a/frontend/web/components/DarkModeSwitch.tsx +++ b/frontend/web/components/DarkModeSwitch.tsx @@ -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 = ({}) => { - 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 ( + <> + +
Theme
+ ({ label, value }))} + size='small' + value={preference} + onChange={setPreference} + /> +
+

+ Choose a light or dark theme, or follow your system setting. +

+ + ) +} + +export const ThemeModeDropdown: FC = () => { + const { preference, resolvedDarkMode, setPreference } = useThemePreference() + const [isOpen, setIsOpen] = useState(false) + const btnRef = useRef(null) + const dropDownRef = useRef(null) + const activeOption = getThemeOption(preference) + const activeIcon = getActiveThemeIcon(preference, resolvedDarkMode) + + useOutsideClick(dropDownRef as React.RefObject, () => + 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 ( - +
+ + + {isOpen && + createPortal( +
+
+ Theme +
+ {themeOptions.map((option) => { + const isSelected = preference === option.value + return ( + + ) + })} +
, + document.body, + )} +
) } diff --git a/frontend/web/components/navigation/Nav.tsx b/frontend/web/components/navigation/Nav.tsx index c34ad048577d..02132edf0759 100644 --- a/frontend/web/components/navigation/Nav.tsx +++ b/frontend/web/components/navigation/Nav.tsx @@ -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 } @@ -73,10 +76,15 @@ const Nav: FC = ({
{!!AccountStore.getUser() && ( - + <> + +
+ +
+ )}
diff --git a/frontend/web/components/navigation/navbars/TopNavbar.tsx b/frontend/web/components/navigation/navbars/TopNavbar.tsx index 8eb7f5b2c147..e56a05a829e8 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 { ThemeModeDropdown } from 'components/DarkModeSwitch' type TopNavType = { activeProject: Project | undefined @@ -45,6 +46,7 @@ const TopNavbar: FC = ({ activeProject, projectId }) => { Docs + {Utils.getFlagsmithHasFeature('persona_based_views') ? ( diff --git a/frontend/web/project/darkMode.test.ts b/frontend/web/project/darkMode.test.ts new file mode 100644 index 000000000000..62f17807ec2d --- /dev/null +++ b/frontend/web/project/darkMode.test.ts @@ -0,0 +1,205 @@ +const listeners: Record void)[]> = {} +let systemDarkMode = false +let systemListener: (() => void) | undefined + +const createClassList = () => { + const classes = new Set() + return { + add: (className: string) => classes.add(className), + contains: (className: string) => classes.has(className), + remove: (className: string) => classes.delete(className), + } +} + +const loadDarkMode = async () => { + jest.resetModules() + return import('./darkMode') +} + +const dispatchStorageEvent = (key: string) => { + window.dispatchEvent({ + key, + type: 'storage', + } as StorageEvent) +} + +describe('darkMode', () => { + beforeEach(() => { + const storage = new Map() + const documentElementAttributes = new Map() + systemDarkMode = false + systemListener = undefined + Object.keys(listeners).forEach((key) => { + listeners[key] = [] + }) + + Object.defineProperty(global, 'localStorage', { + configurable: true, + value: { + getItem: jest.fn((key: string) => storage.get(key) ?? null), + setItem: jest.fn((key: string, value: string) => { + storage.set(key, value) + }), + }, + }) + + Object.defineProperty(global, 'document', { + configurable: true, + value: { + body: { + classList: createClassList(), + }, + documentElement: { + getAttribute: jest.fn( + (name: string) => documentElementAttributes.get(name) ?? null, + ), + removeAttribute: jest.fn((name: string) => { + documentElementAttributes.delete(name) + }), + setAttribute: jest.fn((name: string, value: string) => { + documentElementAttributes.set(name, value) + }), + }, + }, + }) + + Object.defineProperty(global, 'window', { + configurable: true, + value: { + addEventListener: jest.fn( + (eventName: string, callback: (event: Event) => void) => { + listeners[eventName] = listeners[eventName] ?? [] + listeners[eventName].push(callback) + }, + ), + dispatchEvent: jest.fn((event: Event) => { + listeners[event.type]?.forEach((callback) => callback(event)) + return true + }), + matchMedia: jest.fn(() => ({ + addEventListener: jest.fn( + (_eventName: string, callback: () => void) => { + systemListener = callback + }, + ), + addListener: jest.fn((callback: () => void) => { + systemListener = callback + }), + get matches() { + return systemDarkMode + }, + })), + removeEventListener: jest.fn( + (eventName: string, callback: (event: Event) => void) => { + listeners[eventName] = (listeners[eventName] ?? []).filter( + (listener) => listener !== callback, + ) + }, + ), + }, + }) + }) + + it('uses the legacy dark mode value when no theme preference exists', async () => { + localStorage.setItem('dark_mode', 'true') + + const { getDarkMode, getThemePreference } = await loadDarkMode() + + expect(getThemePreference()).toBe('dark') + expect(getDarkMode()).toBe(true) + expect(document.body.classList.contains('dark')).toBe(true) + expect(document.documentElement.getAttribute('data-bs-theme')).toBe('dark') + }) + + it('stores explicit light and dark preferences', async () => { + const { getDarkMode, getThemePreference, setThemePreference } = + await loadDarkMode() + + setThemePreference('dark') + expect(getThemePreference()).toBe('dark') + expect(getDarkMode()).toBe(true) + expect(localStorage.getItem('theme_preference')).toBe('dark') + expect(localStorage.getItem('dark_mode')).toBe('true') + + setThemePreference('light') + expect(getThemePreference()).toBe('light') + expect(getDarkMode()).toBe(false) + expect(localStorage.getItem('dark_mode')).toBe('false') + expect(document.body.classList.contains('dark')).toBe(false) + expect(document.documentElement.getAttribute('data-bs-theme')).toBeNull() + }) + + it('resolves the system preference from prefers-color-scheme', async () => { + systemDarkMode = true + const { getDarkMode, getThemePreference, setThemePreference } = + await loadDarkMode() + + setThemePreference('system') + + expect(getThemePreference()).toBe('system') + expect(getDarkMode()).toBe(true) + expect(localStorage.getItem('theme_preference')).toBe('system') + expect(localStorage.getItem('dark_mode')).toBe('true') + }) + + it('updates when another tab changes the preference', async () => { + const { getDarkMode, listenToThemePreference } = await loadDarkMode() + const callback = jest.fn() + + listenToThemePreference(callback) + localStorage.setItem('theme_preference', 'dark') + dispatchStorageEvent('theme_preference') + + expect(callback).toHaveBeenCalledTimes(1) + expect(getDarkMode()).toBe(true) + expect(document.body.classList.contains('dark')).toBe(true) + }) + + it('ignores legacy storage events once a theme preference exists', async () => { + systemDarkMode = true + const { getDarkMode, listenToThemePreference } = await loadDarkMode() + const callback = jest.fn() + + listenToThemePreference(callback) + localStorage.setItem('theme_preference', 'system') + dispatchStorageEvent('theme_preference') + + expect(callback).toHaveBeenCalledTimes(1) + expect(getDarkMode()).toBe(true) + expect(document.body.classList.contains('dark')).toBe(true) + + localStorage.setItem('dark_mode', 'false') + dispatchStorageEvent('dark_mode') + + expect(callback).toHaveBeenCalledTimes(1) + expect(getDarkMode()).toBe(true) + expect(document.body.classList.contains('dark')).toBe(true) + }) + + it('still updates from legacy storage when no theme preference exists', async () => { + const { getDarkMode, listenToThemePreference } = await loadDarkMode() + const callback = jest.fn() + + listenToThemePreference(callback) + localStorage.setItem('dark_mode', 'true') + dispatchStorageEvent('dark_mode') + + expect(callback).toHaveBeenCalledTimes(1) + expect(getDarkMode()).toBe(true) + expect(document.body.classList.contains('dark')).toBe(true) + }) + + it('reacts to system colour scheme changes while using the system preference', async () => { + systemDarkMode = false + const { getDarkMode, setThemePreference } = await loadDarkMode() + + setThemePreference('system') + expect(getDarkMode()).toBe(false) + + systemDarkMode = true + systemListener?.() + + expect(getDarkMode()).toBe(true) + expect(document.body.classList.contains('dark')).toBe(true) + }) +}) diff --git a/frontend/web/project/darkMode.ts b/frontend/web/project/darkMode.ts index d342dee08647..04194e2f5220 100644 --- a/frontend/web/project/darkMode.ts +++ b/frontend/web/project/darkMode.ts @@ -1,20 +1,139 @@ import { storageGet, storageSet } from 'common/safeLocalStorage' +const DARK_MODE_KEY = 'dark_mode' +const THEME_PREFERENCE_KEY = 'theme_preference' +const THEME_PREFERENCE_EVENT = 'flagsmith-theme-preference-change' + +export type ThemePreference = 'light' | 'dark' | 'system' + +const themePreferences: ThemePreference[] = ['light', 'dark', 'system'] + +const isThemePreference = (value: string | null): value is ThemePreference => + !!value && themePreferences.includes(value as ThemePreference) + +const getSystemDarkMode = () => + typeof window !== 'undefined' && + !!window.matchMedia?.('(prefers-color-scheme: dark)').matches + +const canUseDOM = () => + typeof window !== 'undefined' && typeof document !== 'undefined' + +const dispatchThemePreferenceChange = () => { + if (!canUseDOM()) { + return + } + + if (typeof CustomEvent === 'function') { + window.dispatchEvent(new CustomEvent(THEME_PREFERENCE_EVENT)) + } else { + window.dispatchEvent(new Event(THEME_PREFERENCE_EVENT)) + } +} + +export const getThemePreference = (): ThemePreference => { + const storedPreference = storageGet(THEME_PREFERENCE_KEY) + if (isThemePreference(storedPreference)) { + return storedPreference + } + + return storageGet(DARK_MODE_KEY) === 'true' ? 'dark' : 'light' +} + +export const getResolvedDarkMode = ( + preference: ThemePreference = getThemePreference(), +) => { + if (preference === 'system') { + return getSystemDarkMode() + } + + return preference === 'dark' +} + export const getDarkMode = () => { - return storageGet('dark_mode') === 'true' + return getResolvedDarkMode() } -export const setDarkMode = (enabled: boolean) => { + +const applyDarkMode = (enabled: boolean) => { + if (!canUseDOM()) { + return + } + if (enabled) { - storageSet('dark_mode', 'true') document.body.classList.add('dark') document.documentElement.setAttribute('data-bs-theme', 'dark') } else { - storageSet('dark_mode', 'false') document.body.classList.remove('dark') document.documentElement.removeAttribute('data-bs-theme') } } -if (storageGet('dark_mode')) { - setDarkMode(getDarkMode()) +const applyThemePreference = ( + preference = getThemePreference(), + { persistLegacy = false } = {}, +) => { + const enabled = getResolvedDarkMode(preference) + if (persistLegacy) { + storageSet(DARK_MODE_KEY, enabled ? 'true' : 'false') + } + applyDarkMode(enabled) +} + +export const setThemePreference = (preference: ThemePreference) => { + storageSet(THEME_PREFERENCE_KEY, preference) + applyThemePreference(preference, { persistLegacy: true }) + dispatchThemePreferenceChange() +} + +export const setDarkMode = (enabled: boolean) => { + setThemePreference(enabled ? 'dark' : 'light') +} + +export const listenToThemePreference = (callback: () => void) => { + if (!canUseDOM()) { + return () => {} + } + + const handlePreferenceChange = () => { + applyThemePreference() + callback() + } + + const handleStorage = (event: StorageEvent) => { + if ( + event.key === THEME_PREFERENCE_KEY || + (event.key === DARK_MODE_KEY && + !isThemePreference(storageGet(THEME_PREFERENCE_KEY))) + ) { + handlePreferenceChange() + } + } + + window.addEventListener(THEME_PREFERENCE_EVENT, handlePreferenceChange) + window.addEventListener('storage', handleStorage) + + return () => { + window.removeEventListener(THEME_PREFERENCE_EVENT, handlePreferenceChange) + window.removeEventListener('storage', handleStorage) + } +} + +const systemDarkMode = canUseDOM() + ? window.matchMedia?.('(prefers-color-scheme: dark)') + : undefined + +const handleSystemDarkModeChange = () => { + if (getThemePreference() === 'system') { + applyThemePreference('system') + dispatchThemePreferenceChange() + } +} + +if (systemDarkMode?.addEventListener) { + systemDarkMode.addEventListener('change', handleSystemDarkModeChange) +} else { + systemDarkMode?.addListener?.(handleSystemDarkModeChange) +} + +if (storageGet(THEME_PREFERENCE_KEY) || storageGet(DARK_MODE_KEY)) { + applyThemePreference() } diff --git a/frontend/web/styles/project/_FeaturesPage.scss b/frontend/web/styles/project/_FeaturesPage.scss index d39170aa71bb..c3f6276ca340 100644 --- a/frontend/web/styles/project/_FeaturesPage.scss +++ b/frontend/web/styles/project/_FeaturesPage.scss @@ -28,7 +28,6 @@ &.placed-bottom { top: 100%; } - } &__item { @@ -57,6 +56,18 @@ background: $bg-light200; } } + + .theme-option { + width: 100%; + border: 0; + background: transparent; + text-align: left; + + &:focus-visible { + outline: none; + background: $bg-light200; + } + } } .dark { @@ -72,4 +83,12 @@ background: $primary; } } + + .theme-option { + background: transparent; + + &:focus-visible { + background: $primary; + } + } }