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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
### Fixed
- EscrowForm now rejects self-escrow (beneficiary === depositor) and
arbiter addresses that match the depositor or beneficiary (#23)
- `useTheme` now subscribes to live OS theme changes when set to
`'system'`, instead of reading `matchMedia(...).matches` once and
going stale until `theme` itself changes (#31)

### Removed
- Removed the orphaned `useStellarAccount` hook: it had no call sites,
Expand Down
86 changes: 86 additions & 0 deletions src/hooks/useMediaQuery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, afterEach } from 'vitest'
import { useMediaQuery, useIsDarkMode } from './useMediaQuery'

function createMockMatchMedia(initialMatches: boolean) {
let matches = initialMatches
const listeners = new Set<(e: MediaQueryListEvent) => void>()

const mql = {
get matches() {
return matches
},
media: '',
onchange: null,
addEventListener: (_: 'change', handler: (e: MediaQueryListEvent) => void) => {
listeners.add(handler)
},
removeEventListener: (_: 'change', handler: (e: MediaQueryListEvent) => void) => {
listeners.delete(handler)
},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}

return {
matchMedia: () => mql as unknown as MediaQueryList,
fireChange: (nextMatches: boolean) => {
matches = nextMatches
const event = { matches: nextMatches } as MediaQueryListEvent
listeners.forEach((handler) => handler(event))
},
}
}

describe('useMediaQuery / useIsDarkMode', () => {
const originalMatchMedia = window.matchMedia

afterEach(() => {
window.matchMedia = originalMatchMedia
})

it('returns the initial matches value synchronously', () => {
const mock = createMockMatchMedia(true)
window.matchMedia = mock.matchMedia

const { result } = renderHook(() => useMediaQuery('(prefers-color-scheme: dark)'))

expect(result.current).toBe(true)
})

it('updates when the underlying media query fires a change event', () => {
const mock = createMockMatchMedia(false)
window.matchMedia = mock.matchMedia

const { result } = renderHook(() => useMediaQuery('(prefers-color-scheme: dark)'))
expect(result.current).toBe(false)

act(() => {
mock.fireChange(true)
})

expect(result.current).toBe(true)
})

it('removes its change listener on unmount', () => {
const mock = createMockMatchMedia(false)
window.matchMedia = mock.matchMedia

const { unmount } = renderHook(() => useMediaQuery('(prefers-color-scheme: dark)'))
unmount()

// Firing a change after unmount should not throw (no dangling listener
// calling setState on an unmounted component).
expect(() => mock.fireChange(true)).not.toThrow()
})

it('useIsDarkMode reflects the prefers-color-scheme: dark query', () => {
const mock = createMockMatchMedia(true)
window.matchMedia = mock.matchMedia

const { result } = renderHook(() => useIsDarkMode())

expect(result.current).toBe(true)
})
})
109 changes: 109 additions & 0 deletions src/hooks/useTheme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { useTheme } from './useTheme'

function createMockMatchMedia(initialMatches: boolean) {
let matches = initialMatches
const listeners = new Set<(e: MediaQueryListEvent) => void>()

const mql = {
get matches() {
return matches
},
media: '(prefers-color-scheme: dark)',
onchange: null,
addEventListener: (_: 'change', handler: (e: MediaQueryListEvent) => void) => {
listeners.add(handler)
},
removeEventListener: (_: 'change', handler: (e: MediaQueryListEvent) => void) => {
listeners.delete(handler)
},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}

return {
matchMedia: () => mql as unknown as MediaQueryList,
fireChange: (nextMatches: boolean) => {
matches = nextMatches
const event = { matches: nextMatches } as MediaQueryListEvent
listeners.forEach((handler) => handler(event))
},
}
}

describe('useTheme', () => {
const originalMatchMedia = window.matchMedia

beforeEach(() => {
localStorage.clear()
document.documentElement.classList.remove('dark')
})

afterEach(() => {
window.matchMedia = originalMatchMedia
localStorage.clear()
document.documentElement.classList.remove('dark')
})

it('reacts live to an OS theme change while theme is "system", with no change to theme itself', () => {
const mock = createMockMatchMedia(false)
window.matchMedia = mock.matchMedia

const { result } = renderHook(() => useTheme())
expect(result.current.theme).toBe('system')
expect(document.documentElement.classList.contains('dark')).toBe(false)

act(() => {
mock.fireChange(true)
})

expect(document.documentElement.classList.contains('dark')).toBe(true)
expect(result.current.theme).toBe('system')
})

it('toggles the dark class back off when the OS switches back to light', () => {
const mock = createMockMatchMedia(true)
window.matchMedia = mock.matchMedia

renderHook(() => useTheme())
expect(document.documentElement.classList.contains('dark')).toBe(true)

act(() => {
mock.fireChange(false)
})

expect(document.documentElement.classList.contains('dark')).toBe(false)
})

it('does not react to OS theme changes when explicitly set to "light"', () => {
localStorage.setItem('ss-theme', JSON.stringify('light'))
const mock = createMockMatchMedia(false)
window.matchMedia = mock.matchMedia

renderHook(() => useTheme())
expect(document.documentElement.classList.contains('dark')).toBe(false)

act(() => {
mock.fireChange(true)
})

expect(document.documentElement.classList.contains('dark')).toBe(false)
})

it('does not react to OS theme changes when explicitly set to "dark"', () => {
localStorage.setItem('ss-theme', JSON.stringify('dark'))
const mock = createMockMatchMedia(true)
window.matchMedia = mock.matchMedia

renderHook(() => useTheme())
expect(document.documentElement.classList.contains('dark')).toBe(true)

act(() => {
mock.fireChange(false)
})

expect(document.documentElement.classList.contains('dark')).toBe(true)
})
})
6 changes: 4 additions & 2 deletions src/hooks/useTheme.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { useLocalStorage } from './useLocalStorage'
import { useIsDarkMode } from './useMediaQuery'
import { useEffect } from 'react'
type Theme = 'light' | 'dark' | 'system'
export function useTheme() {
const [theme, setTheme] = useLocalStorage<Theme>('ss-theme', 'system')
const systemIsDark = useIsDarkMode()
useEffect(() => {
const root = document.documentElement
if (theme === 'dark') root.classList.add('dark')
else if (theme === 'light') root.classList.remove('dark')
else root.classList.toggle('dark', matchMedia('(prefers-color-scheme: dark)').matches)
}, [theme])
else root.classList.toggle('dark', systemIsDark)
}, [theme, systemIsDark])
return { theme, setTheme }
}
Loading