Skip to content

Commit 6477628

Browse files
authoredMar 13, 2025··
feat(reactotron-app): add auto light/dark mode switching (#1547 by @arkthur)
## Please verify the following: - [x] `yarn build-and-test:local` passes - [x] I have added tests for any new features, if relevant - [x] `README.md` (or relevant documentation) has been updated with your changes ## Describe your PR Added a light theme and logic that switches themes according to system preferences.
1 parent 9d60052 commit 6477628

File tree

8 files changed

+236
-38
lines changed

8 files changed

+236
-38
lines changed
 

‎lib/reactotron-core-ui/src/components/ReactotronAppProvider/index.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import React from "react"
22
import styled, { ThemeProvider } from "styled-components"
33

4-
import theme from "../../theme"
4+
import useColorScheme from "../../hooks/useColorScheme"
5+
import { themes } from "../../themes"
56

67
const ReactotronContainer = styled.div`
78
font-family: ${(props) => props.theme.fontFamily};
@@ -12,8 +13,10 @@ const ReactotronContainer = styled.div`
1213
`
1314

1415
const ReactotronAppProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
16+
const colorScheme = useColorScheme()
17+
1518
return (
16-
<ThemeProvider theme={theme}>
19+
<ThemeProvider theme={themes[colorScheme]}>
1720
<ReactotronContainer>{children}</ReactotronContainer>
1821
</ThemeProvider>
1922
)

‎lib/reactotron-core-ui/src/components/Tooltip/index.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ function ToolTip(props: TooltipProps) {
1616
border
1717
borderColor={theme.highlight}
1818
textColor={theme.foreground}
19+
backgroundColor={theme.backgroundSubtleLight}
1920
{...props}
2021
/>
2122
)

‎lib/reactotron-core-ui/src/components/TreeView/index.tsx

+7-4
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import React from "react"
22
import { JSONTree } from "react-json-tree"
33
import styled from "styled-components"
44

5-
import baseTheme from "../../theme"
5+
import useColorScheme from "../../hooks/useColorScheme"
6+
import { ReactotronTheme, themes } from "../../themes"
67

78
// TODO: Ripping this right from reactotron right now... should probably be better.
89
const theme = {
@@ -30,11 +31,11 @@ const MutedContainer = styled.span`
3031
color: ${(props) => props.theme.highlight};
3132
`
3233

33-
const treeTheme = {
34+
const getTreeTheme = (baseTheme: ReactotronTheme) => ({
3435
tree: { backgroundColor: "transparent", marginTop: -3 },
3536
...theme,
3637
base0B: baseTheme.foreground,
37-
}
38+
})
3839

3940
interface Props {
4041
// value: object
@@ -43,12 +44,14 @@ interface Props {
4344
}
4445

4546
export default function TreeView({ value, level = 1 }: Props) {
47+
const colorScheme = useColorScheme()
48+
4649
return (
4750
<JSONTree
4851
data={value}
4952
hideRoot
5053
shouldExpandNodeInitially={(keyName, data, minLevel) => minLevel <= level}
51-
theme={treeTheme}
54+
theme={getTreeTheme(themes[colorScheme])}
5255
getItemString={(type, data, itemType, itemString) => {
5356
if (type === "Object") {
5457
return <MutedContainer>{itemType}</MutedContainer>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { renderHook, act } from "@testing-library/react"
2+
import useColorScheme from "./useColorScheme"
3+
4+
describe("useColorScheme", () => {
5+
const addEventListener = jest.fn()
6+
const removeEventListener = jest.fn()
7+
8+
const mockMatchMedia = jest.fn().mockImplementation((query) => ({
9+
matches: false,
10+
media: query,
11+
addEventListener,
12+
removeEventListener
13+
}))
14+
15+
const originalMatchMedia = window.matchMedia
16+
17+
afterEach(() => {
18+
jest.resetAllMocks()
19+
window.matchMedia = originalMatchMedia
20+
})
21+
22+
it("should return dark when window.matchMedia is undefined", () => {
23+
window.matchMedia = undefined
24+
25+
const { result } = renderHook(() => useColorScheme())
26+
expect(result.current).toBe("dark")
27+
})
28+
29+
it("should return light when system preference is light", () => {
30+
window.matchMedia = mockMatchMedia
31+
mockMatchMedia.mockReturnValue({
32+
matches: false,
33+
addEventListener,
34+
removeEventListener
35+
})
36+
37+
const { result } = renderHook(() => useColorScheme())
38+
expect(result.current).toBe("light")
39+
})
40+
41+
it("should return dark when system preference is dark", () => {
42+
window.matchMedia = mockMatchMedia
43+
mockMatchMedia.mockReturnValue({
44+
matches: true,
45+
addEventListener,
46+
removeEventListener
47+
})
48+
49+
const { result } = renderHook(() => useColorScheme())
50+
expect(result.current).toBe("dark")
51+
})
52+
53+
it("should update when system preference changes", () => {
54+
let colorSchemeChangeHandler: (e: { matches: boolean }) => void
55+
const _addEventListener = jest.fn().mockImplementation((_, handler) => {
56+
colorSchemeChangeHandler = handler
57+
})
58+
59+
window.matchMedia = mockMatchMedia
60+
mockMatchMedia.mockReturnValue({
61+
matches: false,
62+
addEventListener: _addEventListener,
63+
removeEventListener,
64+
})
65+
66+
const { result } = renderHook(() => useColorScheme())
67+
expect(result.current).toBe("light")
68+
69+
act(() => {
70+
colorSchemeChangeHandler({ matches: true })
71+
})
72+
73+
expect(result.current).toBe("dark")
74+
})
75+
76+
it("should clean up event listener on unmount", () => {
77+
window.matchMedia = mockMatchMedia
78+
79+
mockMatchMedia.mockReturnValue({
80+
matches: false,
81+
addEventListener,
82+
removeEventListener,
83+
})
84+
85+
const { unmount } = renderHook(() => useColorScheme())
86+
unmount()
87+
88+
expect(removeEventListener).toHaveBeenCalledWith("change", expect.any(Function))
89+
})
90+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React from "react"
2+
import { ColorScheme } from "../themes"
3+
4+
function getColorScheme({ matches }: MediaQueryList | MediaQueryListEvent): ColorScheme {
5+
return matches ? "dark" : "light"
6+
}
7+
8+
function useColorScheme(): ColorScheme {
9+
const mediaQueryRef = React.useRef<MediaQueryList | null>(window?.matchMedia?.("(prefers-color-scheme: dark)") || null)
10+
11+
const [colorScheme, setColorScheme] = React.useState<ColorScheme>(() => {
12+
if (typeof window === "undefined" || !mediaQueryRef.current) return "dark"
13+
return getColorScheme(mediaQueryRef.current)
14+
})
15+
16+
React.useEffect(() => {
17+
const mediaQuery = mediaQueryRef.current
18+
19+
if (!mediaQuery) return () => {}
20+
21+
const handleChange = (e: MediaQueryListEvent) => {
22+
setColorScheme(getColorScheme(e))
23+
}
24+
25+
mediaQuery.addEventListener("change", handleChange)
26+
return () => mediaQuery.removeEventListener("change", handleChange)
27+
}, [])
28+
29+
return colorScheme
30+
}
31+
32+
export default useColorScheme

‎lib/reactotron-core-ui/src/index.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Styles
2-
import theme from "./theme"
2+
import { themes } from "./themes"
33

44
// Components
55
import ActionButton from "./components/ActionButton"
@@ -57,7 +57,7 @@ export {
5757
StateContext,
5858
StateProvider,
5959
SubscriptionAddModal,
60-
theme,
60+
themes,
6161
TimelineCommand,
6262
timelineCommandResolver,
6363
TimelineCommandTabButton,

‎lib/reactotron-core-ui/src/theme.ts

-30
This file was deleted.

‎lib/reactotron-core-ui/src/themes.ts

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
interface ReactotronTheme {
2+
fontFamily: string
3+
background: string
4+
backgroundDarker: string
5+
backgroundHighlight: string
6+
backgroundLight: string
7+
backgroundLighter: string
8+
backgroundSubtleDark: string
9+
backgroundSubtleLight: string
10+
bold: string
11+
chrome: string
12+
chromeLine: string
13+
constant: string
14+
foreground: string
15+
foregroundDark: string
16+
foregroundLight: string
17+
glow: string
18+
heading: string
19+
highlight: string
20+
keyword: string
21+
line: string
22+
modalOverlay: string
23+
string: string
24+
subtleLine: string
25+
support: string
26+
tag: string
27+
tagComplement: string
28+
warning: string
29+
}
30+
31+
const colorSchemes = ["dark", "light"] as const
32+
33+
type ColorScheme = (typeof colorSchemes)[number]
34+
35+
const themes: Record<ColorScheme, ReactotronTheme> = {
36+
dark: {
37+
fontFamily:
38+
'"Fira Code", "SF Mono", "Consolas", "Segoe UI", "Roboto", "-apple-system", "Helvetica Neue", sans-serif',
39+
background: "#1e1e1e",
40+
backgroundDarker: "hsl(0, 0%, 10.6%)",
41+
backgroundHighlight: "#464b50",
42+
backgroundLight: "#ffffff",
43+
backgroundLighter: "#323537",
44+
backgroundSubtleDark: "hsl(0, 0%, 8.2%)",
45+
backgroundSubtleLight: "hsl(0, 0%, 12.4%)",
46+
bold: "#f9ee98",
47+
chrome: "hsl(0, 0%, 12.9%)",
48+
chromeLine: "hsl(0, 0%, 14.7%)",
49+
constant: "#cda869",
50+
foreground: "#a7a7a7",
51+
foregroundDark: "#838184",
52+
foregroundLight: "#c3c3c3",
53+
glow: "hsla(0, 0%, 9.4%, 0.8)",
54+
heading: "#7587a6",
55+
highlight: "hsl(290, 3.2%, 47.4%)",
56+
keyword: "#9b859d",
57+
line: "hsl(204, 4.8%, 18.5%)",
58+
modalOverlay: "hsla(0, 0%, 7.1%, 0.95)",
59+
string: "#8f9d6a",
60+
subtleLine: "hsl(204, 4.8%, 16.5%)",
61+
support: "#afc4db",
62+
tag: "#cf6a4c",
63+
tagComplement: "hsl(13.699999999999989, 57.7%, 91.6%)",
64+
warning: "#9b703f",
65+
},
66+
light: {
67+
fontFamily:
68+
'"Fira Code", "SF Mono", "Consolas", "Segoe UI", "Roboto", "-apple-system", "Helvetica Neue", sans-serif',
69+
background: "#ffffff",
70+
backgroundDarker: "hsl(0, 0%, 90%)",
71+
backgroundHighlight: "#f0f0f0",
72+
backgroundLight: "#f9f9f9",
73+
backgroundLighter: "#e6e6e6",
74+
backgroundSubtleDark: "hsl(0, 0%, 95%)",
75+
backgroundSubtleLight: "hsl(0, 0%, 97%)",
76+
bold: "#222222",
77+
chrome: "hsl(0, 0%, 90%)",
78+
chromeLine: "hsl(0, 0%, 85%)",
79+
constant: "#d17d00",
80+
foreground: "#333333",
81+
foregroundDark: "#555555",
82+
foregroundLight: "#666666",
83+
glow: "hsla(0, 0%, 90%, 0.8)",
84+
heading: "#4b5f85",
85+
highlight: "hsl(210, 10%, 70%)",
86+
keyword: "#9b0000",
87+
line: "hsl(204, 4.8%, 95%)",
88+
modalOverlay: "hsla(0, 0%, 100%, 0.95)",
89+
string: "#718c00",
90+
subtleLine: "hsl(204, 4.8%, 90%)",
91+
support: "#597ab8",
92+
tag: "#d9484f",
93+
tagComplement: "hsl(13.7, 57.7%, 45%)",
94+
warning: "#b35900",
95+
},
96+
}
97+
98+
export { themes }
99+
export type { ColorScheme, ReactotronTheme }

0 commit comments

Comments
 (0)
Please sign in to comment.