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
166 changes: 166 additions & 0 deletions react-app/src/context/SettingsContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { act, render, renderHook, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { SettingsProvider, useSettings } from './SettingsContext';

type ChangeListener = (event: MediaQueryListEvent) => void;

function stubMatchMedia(matches: boolean) {
const listeners = new Set<ChangeListener>();
const media: MediaQueryList = {
matches,
media: '(prefers-color-scheme: dark)',
onchange: null,
addEventListener: (_: string, listener: ChangeListener) => listeners.add(listener),
removeEventListener: (_: string, listener: ChangeListener) => listeners.delete(listener),
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => true,
} as unknown as MediaQueryList;

vi.stubGlobal(
'matchMedia',
vi.fn(() => media)
);

return {
listeners,
emit(nextMatches: boolean) {
const event = { matches: nextMatches } as MediaQueryListEvent;
listeners.forEach((listener) => listener(event));
},
};
}

function wrapper({ children }: { children: ReactNode }) {
return <SettingsProvider>{children}</SettingsProvider>;
}

afterEach(() => {
vi.unstubAllGlobals();
localStorage.clear();
});

describe('useSettings', () => {
it('throws when used outside the provider', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => renderHook(() => useSettings())).toThrow(
'useSettings must be used within a SettingsProvider'
);
consoleError.mockRestore();
});
});

describe('SettingsProvider', () => {
it('uses the Angular service defaults when localStorage is empty', () => {
stubMatchMedia(false);

const { result } = renderHook(() => useSettings(), { wrapper });

expect(result.current.settings).toMatchObject({
showSettings: false,
openLinkInNewTab: false,
titleFontSize: '16',
listSpacing: '0',
theme: 'default',
});
});

it('reads saved values from localStorage', () => {
stubMatchMedia(true);
localStorage.setItem('openLinkInNewTab', 'true');
localStorage.setItem('titleFontSize', '20');
localStorage.setItem('listSpacing', '10');
localStorage.setItem('theme', 'dark');

const { result } = renderHook(() => useSettings(), { wrapper });

expect(result.current.settings).toMatchObject({
openLinkInNewTab: true,
titleFontSize: '20',
listSpacing: '10',
theme: 'dark',
});
});

it('persists setter results to localStorage', () => {
stubMatchMedia(false);

const { result } = renderHook(() => useSettings(), { wrapper });

act(() => result.current.toggleOpenLinksInNewTab());
act(() => result.current.setFont('22'));
act(() => result.current.setSpacing('5'));
act(() => result.current.setTheme('black'));

expect(localStorage.getItem('openLinkInNewTab')).toBe('true');
expect(localStorage.getItem('titleFontSize')).toBe('22');
expect(localStorage.getItem('listSpacing')).toBe('5');
expect(localStorage.getItem('theme')).toBe('black');
expect(result.current.settings).toMatchObject({
openLinkInNewTab: true,
titleFontSize: '22',
listSpacing: '5',
theme: 'black',
});
});

it('does not persist showSettings', () => {
stubMatchMedia(false);

const { result } = renderHook(() => useSettings(), { wrapper });

act(() => result.current.toggleSettings());

expect(result.current.settings.showSettings).toBe(true);
expect(localStorage.getItem('showSettings')).toBeNull();
});

it('applies the system dark preference when no theme is saved', () => {
stubMatchMedia(true);

const { result } = renderHook(() => useSettings(), { wrapper });

expect(result.current.settings.theme).toBe('night');
expect(localStorage.getItem('theme')).toBe('night');
});

it('keeps the saved theme even when the system prefers dark', () => {
stubMatchMedia(true);
localStorage.setItem('theme', 'default');

const { result } = renderHook(() => useSettings(), { wrapper });

expect(result.current.settings.theme).toBe('default');
});

it('reacts to prefers-color-scheme changes', () => {
const media = stubMatchMedia(false);

const { result } = renderHook(() => useSettings(), { wrapper });

act(() => media.emit(true));
expect(result.current.settings.theme).toBe('night');

act(() => media.emit(false));
expect(result.current.settings.theme).toBe('default');
});

it('removes the media listener on unmount', () => {
const media = stubMatchMedia(false);

const { unmount } = render(
<SettingsProvider>
<span>child</span>
</SettingsProvider>
);

expect(screen.getByText('child')).toBeInTheDocument();
expect(media.listeners.size).toBe(1);

unmount();

expect(media.listeners.size).toBe(0);
});
});
104 changes: 104 additions & 0 deletions react-app/src/context/SettingsContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';

import type { Settings } from '../models/settings';

export interface SettingsContextValue {
settings: Settings;
toggleSettings: () => void;
toggleOpenLinksInNewTab: () => void;
setTheme: (theme: string) => void;
setFont: (fontSize: string) => void;
setSpacing: (listSpace: string) => void;
}

const SettingsContext = createContext<SettingsContextValue | null>(null);

const DARK_COLOR_SCHEME_QUERY = '(prefers-color-scheme: dark)';

function readInitialSettings(): Settings {
const savedOpenLinkInNewTab = localStorage.getItem('openLinkInNewTab');

return {
showSettings: false,
openLinkInNewTab: savedOpenLinkInNewTab ? JSON.parse(savedOpenLinkInNewTab) : false,
theme: localStorage.getItem('theme') ?? 'default',
titleFontSize: localStorage.getItem('titleFontSize') ?? '16',
listSpacing: localStorage.getItem('listSpacing') ?? '0',
};
}

export function SettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState<Settings>(readInitialSettings);

const setTheme = useCallback((theme: string) => {
localStorage.setItem('theme', theme);
setSettings((current) => ({ ...current, theme }));
}, []);

const toggleSettings = useCallback(() => {
setSettings((current) => ({ ...current, showSettings: !current.showSettings }));
}, []);

const toggleOpenLinksInNewTab = useCallback(() => {
setSettings((current) => {
const openLinkInNewTab = !current.openLinkInNewTab;
localStorage.setItem('openLinkInNewTab', JSON.stringify(openLinkInNewTab));
return { ...current, openLinkInNewTab };
});
}, []);

const setFont = useCallback((titleFontSize: string) => {
localStorage.setItem('titleFontSize', titleFontSize);
setSettings((current) => ({ ...current, titleFontSize }));
}, []);

const setSpacing = useCallback((listSpacing: string) => {
localStorage.setItem('listSpacing', listSpacing);
setSettings((current) => ({ ...current, listSpacing }));
}, []);

useEffect(() => {
const darkColorSchemeMedia = window.matchMedia(DARK_COLOR_SCHEME_QUERY);
const handleChange = (event: MediaQueryListEvent | MediaQueryList) => {
setTheme(event.matches ? 'night' : 'default');
};

darkColorSchemeMedia.addEventListener('change', handleChange);

if (!localStorage.getItem('theme')) {
handleChange(darkColorSchemeMedia);
}

return () => darkColorSchemeMedia.removeEventListener('change', handleChange);
}, [setTheme]);

const value = useMemo<SettingsContextValue>(
() => ({
settings,
toggleSettings,
toggleOpenLinksInNewTab,
setTheme,
setFont,
setSpacing,
}),
[settings, toggleSettings, toggleOpenLinksInNewTab, setTheme, setFont, setSpacing]
);

return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
}

export function useSettings(): SettingsContextValue {
const context = useContext(SettingsContext);
if (!context) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
}