From 91e94ccd19b994b807a55a78b309a56b7f18a746 Mon Sep 17 00:00:00 2001 From: dumdev13 Date: Mon, 25 May 2026 17:02:51 -0400 Subject: [PATCH 1/5] Remove system icon and state from theme toggle in Header --- src/components/header/Header.test.tsx | 81 +++++++++++++++++++++++---- src/components/header/Header.tsx | 14 ++--- 2 files changed, 74 insertions(+), 21 deletions(-) diff --git a/src/components/header/Header.test.tsx b/src/components/header/Header.test.tsx index 51a427a..e4016fb 100644 --- a/src/components/header/Header.test.tsx +++ b/src/components/header/Header.test.tsx @@ -52,7 +52,7 @@ describe('Header', () => { render(
); }); expect(screen.getByText('Bonfire')).toBeInTheDocument(); - expect(screen.getByTitle('Current theme: dark')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '🌙' })).toBeInTheDocument(); }); it('should render login button by default', async () => { @@ -81,7 +81,7 @@ describe('Header', () => { expect(mockedUserManager.getUser).toHaveBeenCalled(); }); - it('should display different theme icons based on current theme', async () => { + it('should display moon icon for dark theme and sun icon for light theme', async () => { mockedUseTheme.mockReturnValue({ theme: Theme.LIGHT, setTheme: vi.fn(), @@ -94,19 +94,49 @@ describe('Header', () => { rerender = result.rerender; }); - expect(screen.getByText('☀️')).toBeInTheDocument(); // Light theme icon + expect(screen.getByText('☀️')).toBeInTheDocument(); mockedUseTheme.mockReturnValue({ - theme: Theme.SYSTEM, + theme: Theme.DARK, setTheme: vi.fn(), - getEffectiveTheme: vi.fn(() => Theme.SYSTEM), + getEffectiveTheme: vi.fn(() => Theme.DARK), }); await act(async () => { rerender(
); }); - expect(screen.getByText('⚙️')).toBeInTheDocument(); // System theme icon + expect(screen.getByText('🌙')).toBeInTheDocument(); + }); + + it('should display moon icon when in system mode and effective theme is dark', async () => { + mockedUseTheme.mockReturnValue({ + theme: Theme.SYSTEM, + setTheme: vi.fn(), + getEffectiveTheme: vi.fn(() => Theme.DARK), + }); + + await act(async () => { + render(
); + }); + + expect(screen.getByText('🌙')).toBeInTheDocument(); + expect(screen.queryByText('☀️️')).not.toBeInTheDocument(); + }); + + it('should display sun icon when in system mode and effective theme is light', async () => { + mockedUseTheme.mockReturnValue({ + theme: Theme.SYSTEM, + setTheme: vi.fn(), + getEffectiveTheme: vi.fn(() => Theme.LIGHT), + }); + + await act(async () => { + render(
); + }); + + expect(screen.getByText('☀️')).toBeInTheDocument(); + expect(screen.queryByText('🌙')).not.toBeInTheDocument(); }); }); @@ -174,7 +204,7 @@ describe('Header', () => { }); describe('Interactions', () => { - it('should toggle theme when theme button is clicked', async () => { + it('should toggle from dark to light', async () => { const setTheme = vi.fn(); mockedUseTheme.mockReturnValue({ @@ -187,9 +217,13 @@ describe('Header', () => { render(
); }); - fireEvent.click(screen.getByTitle('Current theme: dark')); + fireEvent.click(screen.getByRole('button', { name: '🌙' })); expect(setTheme).toHaveBeenCalledWith(Theme.LIGHT); + }); + + it('should toggle from light to dark (not to system)', async () => { + const setTheme = vi.fn(); mockedUseTheme.mockReturnValue({ theme: Theme.LIGHT, @@ -201,9 +235,14 @@ describe('Header', () => { render(
); }); - fireEvent.click(screen.getByTitle('Current theme: light')); + fireEvent.click(screen.getByRole('button', { name: '☀️' })); - expect(setTheme).toHaveBeenCalledWith(Theme.SYSTEM); + expect(setTheme).toHaveBeenCalledWith(Theme.DARK); + expect(setTheme).not.toHaveBeenCalledWith(Theme.SYSTEM); + }); + + it('should toggle from system mode (effective dark) to light', async () => { + const setTheme = vi.fn(); mockedUseTheme.mockReturnValue({ theme: Theme.SYSTEM, @@ -215,9 +254,29 @@ describe('Header', () => { render(
); }); - fireEvent.click(screen.getByTitle('Current theme: system')); + fireEvent.click(screen.getByRole('button', { name: '🌙' })); + + expect(setTheme).toHaveBeenCalledWith(Theme.LIGHT); + expect(setTheme).not.toHaveBeenCalledWith(Theme.SYSTEM); + }); + + it('should toggle from system mode (effective light) to dark', async () => { + const setTheme = vi.fn(); + + mockedUseTheme.mockReturnValue({ + theme: Theme.SYSTEM, + setTheme, + getEffectiveTheme: vi.fn(() => Theme.LIGHT), + }); + + await act(async () => { + render(
); + }); + + fireEvent.click(screen.getByRole('button', { name: '☀️' })); expect(setTheme).toHaveBeenCalledWith(Theme.DARK); + expect(setTheme).not.toHaveBeenCalledWith(Theme.SYSTEM); }); it('should call signinRedirect when login button is clicked', async () => { diff --git a/src/components/header/Header.tsx b/src/components/header/Header.tsx index 3d265d1..63fd49c 100644 --- a/src/components/header/Header.tsx +++ b/src/components/header/Header.tsx @@ -5,7 +5,7 @@ import {useTheme} from "../../context/hooks.tsx"; import BonfireLogo from '../../../public/bonfire.svg'; export function Header() { - const { theme, setTheme } = useTheme(); + const { setTheme, getEffectiveTheme } = useTheme(); const [authAction, setAuthAction] = useState(loginButton); useEffect(() => { getUserManager().events.addUserLoaded(() => { @@ -35,16 +35,10 @@ export function Header() { }, [setAuthAction]) const toggleTheme = () => { - if (theme === Theme.DARK) { - setTheme(Theme.LIGHT); - } else if (theme === Theme.LIGHT) { - setTheme(Theme.SYSTEM); - } else { - setTheme(Theme.DARK); - } + setTheme(getEffectiveTheme() === Theme.DARK ? Theme.LIGHT : Theme.DARK); }; - const themeIcon = theme === Theme.DARK ? '🌙' : theme === Theme.LIGHT ? '☀️' : '⚙️'; + const themeIcon = getEffectiveTheme() === Theme.DARK ? '🌙' : '☀️'; return (
@@ -54,7 +48,7 @@ export function Header() { From 70a59bbf8bf9758f4eb5d83d3275786bf3fdae2c Mon Sep 17 00:00:00 2001 From: dumdev13 Date: Sun, 31 May 2026 16:32:41 -0400 Subject: [PATCH 2/5] Move toggle to a component, include dropdown for selection, adds a constant --- src/components/header/Header.test.tsx | 163 +----------------- src/components/header/Header.tsx | 25 +-- .../theme-toggle/ThemeToggle.module.css | 64 +++++++ .../theme-toggle/ThemeToggle.test.tsx | 109 ++++++++++++ src/components/theme-toggle/ThemeToggle.tsx | 53 ++++++ src/contants/Theme.ts | 7 + src/styles/global.css | 21 --- 7 files changed, 247 insertions(+), 195 deletions(-) create mode 100644 src/components/theme-toggle/ThemeToggle.module.css create mode 100644 src/components/theme-toggle/ThemeToggle.test.tsx create mode 100644 src/components/theme-toggle/ThemeToggle.tsx create mode 100644 src/contants/Theme.ts diff --git a/src/components/header/Header.test.tsx b/src/components/header/Header.test.tsx index e4016fb..37d40d8 100644 --- a/src/components/header/Header.test.tsx +++ b/src/components/header/Header.test.tsx @@ -1,14 +1,10 @@ import {render, screen, fireEvent, waitFor, act} from '@testing-library/react'; import {Header} from './Header.tsx'; import {getUserManager} from '../../pages/user/UserContext.ts'; -import {Theme} from '../../context/theme/ThemeContextTypes.ts'; import '@testing-library/jest-dom'; -import {useTheme} from "../../context/hooks.tsx"; import type {User, UserManager} from 'oidc-client-ts'; import {Mock} from "vitest"; -const mockedUseTheme = vi.mocked(useTheme); - const mockedUserManager = { events: { addUserLoaded: vi.fn(), @@ -25,39 +21,31 @@ vi.mock('../../pages/user/UserContext.ts', () => ({ getUserManager: vi.fn(), })); +vi.mock('../theme-toggle/ThemeToggle.tsx', () => ({ + ThemeToggle: () =>
, +})); + vi.mocked(getUserManager).mockReturnValue(mockedUserManager); const mockedAddUserLoaded = vi.mocked(mockedUserManager.events.addUserLoaded); const mockedAddUserUnloaded = vi.mocked(mockedUserManager.events.addUserUnloaded); -vi.mock('../../context/hooks.tsx', () => ({ - Theme: {DARK: 'dark', LIGHT: 'light', SYSTEM: 'system'}, - useTheme: vi.fn() -})); - describe('Header', () => { beforeEach(() => { vi.clearAllMocks(); - (mockedUserManager.getUser as Mock).mockResolvedValue(null); // Default to no user - mockedUseTheme.mockReturnValue({ - theme: Theme.DARK, - setTheme: vi.fn(), - getEffectiveTheme: vi.fn(() => Theme.DARK), - }); + (mockedUserManager.getUser as Mock).mockResolvedValue(null); }); describe('Rendering', () => { - it('should render the header with title and theme toggle button', async () => { + it('should render the title and ThemeToggle', async () => { await act(async () => { render(
); }); expect(screen.getByText('Bonfire')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: '🌙' })).toBeInTheDocument(); + expect(screen.getByTestId('theme-toggle')).toBeInTheDocument(); }); it('should render login button by default', async () => { - (mockedUserManager.getUser as Mock).mockResolvedValue(null); - await act(async () => { render(
); }); @@ -80,64 +68,6 @@ describe('Header', () => { }); expect(mockedUserManager.getUser).toHaveBeenCalled(); }); - - it('should display moon icon for dark theme and sun icon for light theme', async () => { - mockedUseTheme.mockReturnValue({ - theme: Theme.LIGHT, - setTheme: vi.fn(), - getEffectiveTheme: vi.fn(() => Theme.LIGHT), - }); - - let rerender: (component: React.ReactElement) => void; - await act(async () => { - const result = render(
); - rerender = result.rerender; - }); - - expect(screen.getByText('☀️')).toBeInTheDocument(); - - mockedUseTheme.mockReturnValue({ - theme: Theme.DARK, - setTheme: vi.fn(), - getEffectiveTheme: vi.fn(() => Theme.DARK), - }); - - await act(async () => { - rerender(
); - }); - - expect(screen.getByText('🌙')).toBeInTheDocument(); - }); - - it('should display moon icon when in system mode and effective theme is dark', async () => { - mockedUseTheme.mockReturnValue({ - theme: Theme.SYSTEM, - setTheme: vi.fn(), - getEffectiveTheme: vi.fn(() => Theme.DARK), - }); - - await act(async () => { - render(
); - }); - - expect(screen.getByText('🌙')).toBeInTheDocument(); - expect(screen.queryByText('☀️️')).not.toBeInTheDocument(); - }); - - it('should display sun icon when in system mode and effective theme is light', async () => { - mockedUseTheme.mockReturnValue({ - theme: Theme.SYSTEM, - setTheme: vi.fn(), - getEffectiveTheme: vi.fn(() => Theme.LIGHT), - }); - - await act(async () => { - render(
); - }); - - expect(screen.getByText('☀️')).toBeInTheDocument(); - expect(screen.queryByText('🌙')).not.toBeInTheDocument(); - }); }); describe('User Authentication Events', () => { @@ -204,84 +134,7 @@ describe('Header', () => { }); describe('Interactions', () => { - it('should toggle from dark to light', async () => { - const setTheme = vi.fn(); - - mockedUseTheme.mockReturnValue({ - theme: Theme.DARK, - setTheme, - getEffectiveTheme: vi.fn(() => Theme.DARK), - }); - - await act(async () => { - render(
); - }); - - fireEvent.click(screen.getByRole('button', { name: '🌙' })); - - expect(setTheme).toHaveBeenCalledWith(Theme.LIGHT); - }); - - it('should toggle from light to dark (not to system)', async () => { - const setTheme = vi.fn(); - - mockedUseTheme.mockReturnValue({ - theme: Theme.LIGHT, - setTheme, - getEffectiveTheme: vi.fn(() => Theme.LIGHT), - }); - - await act(async () => { - render(
); - }); - - fireEvent.click(screen.getByRole('button', { name: '☀️' })); - - expect(setTheme).toHaveBeenCalledWith(Theme.DARK); - expect(setTheme).not.toHaveBeenCalledWith(Theme.SYSTEM); - }); - - it('should toggle from system mode (effective dark) to light', async () => { - const setTheme = vi.fn(); - - mockedUseTheme.mockReturnValue({ - theme: Theme.SYSTEM, - setTheme, - getEffectiveTheme: vi.fn(() => Theme.DARK), - }); - - await act(async () => { - render(
); - }); - - fireEvent.click(screen.getByRole('button', { name: '🌙' })); - - expect(setTheme).toHaveBeenCalledWith(Theme.LIGHT); - expect(setTheme).not.toHaveBeenCalledWith(Theme.SYSTEM); - }); - - it('should toggle from system mode (effective light) to dark', async () => { - const setTheme = vi.fn(); - - mockedUseTheme.mockReturnValue({ - theme: Theme.SYSTEM, - setTheme, - getEffectiveTheme: vi.fn(() => Theme.LIGHT), - }); - - await act(async () => { - render(
); - }); - - fireEvent.click(screen.getByRole('button', { name: '☀️' })); - - expect(setTheme).toHaveBeenCalledWith(Theme.DARK); - expect(setTheme).not.toHaveBeenCalledWith(Theme.SYSTEM); - }); - it('should call signinRedirect when login button is clicked', async () => { - (mockedUserManager.getUser as Mock).mockResolvedValue(null); - await act(async () => { render(
); }); @@ -311,4 +164,4 @@ describe('Header', () => { expect(mockedUserManager.signoutRedirect).toHaveBeenCalled(); }); }); -}); +}); \ No newline at end of file diff --git a/src/components/header/Header.tsx b/src/components/header/Header.tsx index 63fd49c..6395567 100644 --- a/src/components/header/Header.tsx +++ b/src/components/header/Header.tsx @@ -1,17 +1,16 @@ import {ReactElement, useEffect, useState} from "react"; import {getUserManager} from "../../pages/user/UserContext.ts"; -import {Theme} from "../../context/theme/ThemeContextTypes.ts"; -import {useTheme} from "../../context/hooks.tsx"; import BonfireLogo from '../../../public/bonfire.svg'; +import {ThemeToggle} from "../theme-toggle/ThemeToggle.tsx"; export function Header() { - const { setTheme, getEffectiveTheme } = useTheme(); const [authAction, setAuthAction] = useState(loginButton); + useEffect(() => { getUserManager().events.addUserLoaded(() => { setAuthAction(logoutButton); }); - + return () => { getUserManager().events.removeUserLoaded(() => {}); }; @@ -32,26 +31,14 @@ export function Header() { .then((result) => { return result ? setAuthAction(logoutButton) : setAuthAction(loginButton) }) - }, [setAuthAction]) - - const toggleTheme = () => { - setTheme(getEffectiveTheme() === Theme.DARK ? Theme.LIGHT : Theme.DARK); - }; - - const themeIcon = getEffectiveTheme() === Theme.DARK ? '🌙' : '☀️'; + }, [setAuthAction]); return (
-

Bonfire

+

Bonfire

- + {authAction}
diff --git a/src/components/theme-toggle/ThemeToggle.module.css b/src/components/theme-toggle/ThemeToggle.module.css new file mode 100644 index 0000000..8e160a2 --- /dev/null +++ b/src/components/theme-toggle/ThemeToggle.module.css @@ -0,0 +1,64 @@ +.themeToggle { + background: none; + border: 1px solid var(--border-color); + border-radius: 50%; + width: 2.5rem; + height: 2.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + transition: all 0.3s ease; + padding: 0; +} + +.themeToggle:hover { + background-color: var(--background-secondary); + transform: scale(1.1); +} + +.themeMenu { + position: relative; +} + +.themeDropdown { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + background-color: var(--header-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px var(--shadow-color); + min-width: 130px; + padding-bottom: 5px; +} + +.themeOption { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.5rem 0.75rem; + background: none; + border: none; + color: var(--text-primary); + font-family: inherit; + font-size: 0.9rem; + cursor: pointer; +} + +.themeOption:hover { + background-color: var(--background-secondary); + transform: none; +} + +.themeOption--active { + font-weight: 600; + background-color: var(--background-secondary); +} + +.themeOption-check { + margin-left: auto; + color: var(--accent-color); +} diff --git a/src/components/theme-toggle/ThemeToggle.test.tsx b/src/components/theme-toggle/ThemeToggle.test.tsx new file mode 100644 index 0000000..c2b7373 --- /dev/null +++ b/src/components/theme-toggle/ThemeToggle.test.tsx @@ -0,0 +1,109 @@ +import {Theme} from "../../context/theme/ThemeContextTypes.ts"; +import {act, fireEvent, render, screen} from "@testing-library/react"; +import {useTheme} from "../../context/hooks.tsx"; +import {ThemeToggle} from "./ThemeToggle.tsx"; + +const mockedUseTheme = vi.mocked(useTheme); + +vi.mock('../../context/hooks.tsx', () => ({ + Theme: {DARK: 'dark', LIGHT: 'light', SYSTEM: 'system'}, + useTheme: vi.fn() +})); + +describe("ThemeToggle component", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedUseTheme.mockReturnValue({ + theme: Theme.DARK, + setTheme: vi.fn(), + getEffectiveTheme: vi.fn(() => Theme.DARK), + }); + }); + + it('should display correct icon for dark and light themes', async () => { + mockedUseTheme.mockReturnValue({ + theme: Theme.LIGHT, + setTheme: vi.fn(), + getEffectiveTheme: vi.fn(() => Theme.LIGHT), + }); + + let rerender: (component: React.ReactElement) => void; + await act(async () => { + const result = render(); + rerender = result.rerender; + }); + + expect(screen.getByText('☀️')).toBeInTheDocument(); + + mockedUseTheme.mockReturnValue({ + theme: Theme.DARK, + setTheme: vi.fn(), + getEffectiveTheme: vi.fn(() => Theme.DARK), + }); + + await act(async () => { + rerender(); + }); + + expect(screen.getByText('🌙')).toBeInTheDocument(); + }); + + + it('should display dark icon when in system mode and effective theme is dark', async () => { + mockedUseTheme.mockReturnValue({ + theme: Theme.SYSTEM, + setTheme: vi.fn(), + getEffectiveTheme: vi.fn(() => Theme.DARK), + }); + + await act(async () => { + render(); + }); + + expect(screen.getByText('🌙')).toBeInTheDocument(); + expect(screen.queryByText('☀️️')).not.toBeInTheDocument(); + }); + + it('should display light icon when in system mode and effective theme is light', async () => { + mockedUseTheme.mockReturnValue({ + theme: Theme.SYSTEM, + setTheme: vi.fn(), + getEffectiveTheme: vi.fn(() => Theme.LIGHT), + }); + + await act(async () => { + render(); + }); + + expect(screen.getByText('☀️')).toBeInTheDocument(); + expect(screen.queryByText('🌙')).not.toBeInTheDocument(); + }); + + it('should show all options when the dropdown is opened', async () => { + await act(async () => { + render(); + }); + + fireEvent.click(screen.getByRole('button', { name: '🌙' })); + + expect(screen.getByText('Light')).toBeInTheDocument(); + expect(screen.getByText('Dark')).toBeInTheDocument(); + expect(screen.getByText('System')).toBeInTheDocument(); + }); + + it('should only show the current theme as active when the dropdown is opened', async () => { + await act(async () => { + render(); + }); + + fireEvent.click(screen.getByRole('button', { name: '🌙' })); + + const darkOption = screen.getByText('Dark').closest('button'); + const lightOption = screen.getByText('Light').closest('button'); + const systemOption = screen.getByText('System').closest('button'); + + expect(darkOption).toHaveClass('themeOption--active'); + expect(lightOption).not.toHaveClass('themeOption--active'); + expect(systemOption).not.toHaveClass('themeOption--active'); + }); +}); diff --git a/src/components/theme-toggle/ThemeToggle.tsx b/src/components/theme-toggle/ThemeToggle.tsx new file mode 100644 index 0000000..bafe35f --- /dev/null +++ b/src/components/theme-toggle/ThemeToggle.tsx @@ -0,0 +1,53 @@ +import {useEffect, useRef, useState} from "react"; +import {useTheme} from "../../context/hooks.tsx"; +import {Theme} from "../../context/theme/ThemeContextTypes.ts"; +import {THEME_OPTIONS} from "../../contants/Theme.ts"; +import styles from "./ThemeToggle.module.css" + +export function ThemeToggle() { + const { theme, setTheme, getEffectiveTheme } = useTheme(); + const [themeMenuOpen, setThemeMenuOpen] = useState(false); + const themeMenuRef = useRef(null); + const themeIcon = getEffectiveTheme() === Theme.DARK ? '🌙' : '☀️'; + + useEffect(() => { + if (!themeMenuOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (themeMenuRef.current && !themeMenuRef.current.contains(e.target as Node)) { + setThemeMenuOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [themeMenuOpen]); + + return ( +
+ + {themeMenuOpen && ( +
+ {THEME_OPTIONS.map(option => ( + + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/contants/Theme.ts b/src/contants/Theme.ts new file mode 100644 index 0000000..7c2ebc5 --- /dev/null +++ b/src/contants/Theme.ts @@ -0,0 +1,7 @@ +import {Theme} from "../context/theme/ThemeContextTypes.ts"; + +export const THEME_OPTIONS: { label: string; icon: string; value: Theme }[] = [ + { label: 'Light', icon: '☀️', value: Theme.LIGHT }, + { label: 'Dark', icon: '🌙', value: Theme.DARK }, + { label: 'System', icon: '💻', value: Theme.SYSTEM }, +]; \ No newline at end of file diff --git a/src/styles/global.css b/src/styles/global.css index c4e23ff..223694e 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -126,27 +126,6 @@ body { align-items: center; } -/* Theme toggle button */ -.theme-toggle { - background: none; - border: 1px solid var(--border-color); - border-radius: 50%; - width: 2.5rem; - height: 2.5rem; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.2rem; - transition: all 0.3s ease; - padding: 0; -} - -.theme-toggle:hover { - background-color: var(--background-secondary); - transform: scale(1.1); -} - button { cursor: pointer; transition: all 0.2s ease; From 02371023e4085aa8d23b22b9441737a232378c24 Mon Sep 17 00:00:00 2001 From: dumdev13 Date: Tue, 2 Jun 2026 18:24:37 -0400 Subject: [PATCH 3/5] Fixed typo in constants folder name, fixed github no newline EOF issues --- src/components/theme-toggle/ThemeToggle.tsx | 4 ++-- src/{contants => constants}/Theme.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/{contants => constants}/Theme.ts (99%) diff --git a/src/components/theme-toggle/ThemeToggle.tsx b/src/components/theme-toggle/ThemeToggle.tsx index bafe35f..7c825a4 100644 --- a/src/components/theme-toggle/ThemeToggle.tsx +++ b/src/components/theme-toggle/ThemeToggle.tsx @@ -1,7 +1,7 @@ import {useEffect, useRef, useState} from "react"; import {useTheme} from "../../context/hooks.tsx"; import {Theme} from "../../context/theme/ThemeContextTypes.ts"; -import {THEME_OPTIONS} from "../../contants/Theme.ts"; +import {THEME_OPTIONS} from "../../constants/Theme.ts"; import styles from "./ThemeToggle.module.css" export function ThemeToggle() { @@ -50,4 +50,4 @@ export function ThemeToggle() { )}
); -} \ No newline at end of file +} diff --git a/src/contants/Theme.ts b/src/constants/Theme.ts similarity index 99% rename from src/contants/Theme.ts rename to src/constants/Theme.ts index 7c2ebc5..87cab49 100644 --- a/src/contants/Theme.ts +++ b/src/constants/Theme.ts @@ -4,4 +4,4 @@ export const THEME_OPTIONS: { label: string; icon: string; value: Theme }[] = [ { label: 'Light', icon: '☀️', value: Theme.LIGHT }, { label: 'Dark', icon: '🌙', value: Theme.DARK }, { label: 'System', icon: '💻', value: Theme.SYSTEM }, -]; \ No newline at end of file +]; From 58b8aba458f91052dcc47d0e280a088b747f05f9 Mon Sep 17 00:00:00 2001 From: dumdev13 Date: Wed, 3 Jun 2026 18:21:17 -0400 Subject: [PATCH 4/5] Added system back in to the themeToggle button, improved the tests --- .../theme-toggle/ThemeToggle.test.tsx | 75 ++++++++++++++----- src/components/theme-toggle/ThemeToggle.tsx | 6 +- 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/src/components/theme-toggle/ThemeToggle.test.tsx b/src/components/theme-toggle/ThemeToggle.test.tsx index c2b7373..2a1e6e7 100644 --- a/src/components/theme-toggle/ThemeToggle.test.tsx +++ b/src/components/theme-toggle/ThemeToggle.test.tsx @@ -49,7 +49,7 @@ describe("ThemeToggle component", () => { }); - it('should display dark icon when in system mode and effective theme is dark', async () => { + it('should display system icon when theme is system', async () => { mockedUseTheme.mockReturnValue({ theme: Theme.SYSTEM, setTheme: vi.fn(), @@ -60,23 +60,9 @@ describe("ThemeToggle component", () => { render(); }); - expect(screen.getByText('🌙')).toBeInTheDocument(); - expect(screen.queryByText('☀️️')).not.toBeInTheDocument(); - }); - - it('should display light icon when in system mode and effective theme is light', async () => { - mockedUseTheme.mockReturnValue({ - theme: Theme.SYSTEM, - setTheme: vi.fn(), - getEffectiveTheme: vi.fn(() => Theme.LIGHT), - }); - - await act(async () => { - render(); - }); - - expect(screen.getByText('☀️')).toBeInTheDocument(); + expect(screen.getByText('💻')).toBeInTheDocument(); expect(screen.queryByText('🌙')).not.toBeInTheDocument(); + expect(screen.queryByText('☀️')).not.toBeInTheDocument(); }); it('should show all options when the dropdown is opened', async () => { @@ -106,4 +92,59 @@ describe("ThemeToggle component", () => { expect(lightOption).not.toHaveClass('themeOption--active'); expect(systemOption).not.toHaveClass('themeOption--active'); }); + + it('should not show the dropdown on initial render', async () => { + await act(async () => { + render(); + }); + + expect(screen.queryByText('Light')).not.toBeInTheDocument(); + expect(screen.queryByText('Dark')).not.toBeInTheDocument(); + expect(screen.queryByText('System')).not.toBeInTheDocument(); + }); + + it('should call setTheme with the selected value and close the dropdown', async () => { + const setTheme = vi.fn(); + mockedUseTheme.mockReturnValue({ + theme: Theme.DARK, + setTheme, + getEffectiveTheme: vi.fn(() => Theme.DARK), + }); + + await act(async () => { + render(); + }); + + fireEvent.click(screen.getByRole('button', { name: '🌙' })); + fireEvent.click(screen.getByText('Light').closest('button')!); + + expect(setTheme).toHaveBeenCalledWith(Theme.LIGHT); + expect(screen.queryByText('Light')).not.toBeInTheDocument(); + }); + + it('should close the dropdown when clicking outside', async () => { + await act(async () => { + render(); + }); + + fireEvent.click(screen.getByRole('button', { name: '🌙' })); + expect(screen.getByText('Light')).toBeInTheDocument(); + + fireEvent.mouseDown(document.body); + expect(screen.queryByText('Light')).not.toBeInTheDocument(); + }); + + it('should show a checkmark only on the active theme option', async () => { + await act(async () => { + render(); + }); + + fireEvent.click(screen.getByRole('button', { name: '🌙' })); + + const darkOption = screen.getByText('Dark').closest('button')!; + const lightOption = screen.getByText('Light').closest('button')!; + + expect(darkOption).toHaveTextContent('✓'); + expect(lightOption).not.toHaveTextContent('✓'); + }); }); diff --git a/src/components/theme-toggle/ThemeToggle.tsx b/src/components/theme-toggle/ThemeToggle.tsx index 7c825a4..319f442 100644 --- a/src/components/theme-toggle/ThemeToggle.tsx +++ b/src/components/theme-toggle/ThemeToggle.tsx @@ -1,14 +1,14 @@ import {useEffect, useRef, useState} from "react"; import {useTheme} from "../../context/hooks.tsx"; -import {Theme} from "../../context/theme/ThemeContextTypes.ts"; import {THEME_OPTIONS} from "../../constants/Theme.ts"; import styles from "./ThemeToggle.module.css" export function ThemeToggle() { - const { theme, setTheme, getEffectiveTheme } = useTheme(); + const { theme, setTheme } = useTheme(); const [themeMenuOpen, setThemeMenuOpen] = useState(false); const themeMenuRef = useRef(null); - const themeIcon = getEffectiveTheme() === Theme.DARK ? '🌙' : '☀️'; + const themeIcon = THEME_OPTIONS.find(o => o.value === theme)?.icon; + useEffect(() => { if (!themeMenuOpen) return; From efecc42a934a216549d13695669c354298cd1634 Mon Sep 17 00:00:00 2001 From: dumdev13 Date: Wed, 3 Jun 2026 18:26:26 -0400 Subject: [PATCH 5/5] Merged main --- src/components/theme-toggle/ThemeToggle.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/theme-toggle/ThemeToggle.test.tsx b/src/components/theme-toggle/ThemeToggle.test.tsx index 435b327..4e3ca4e 100644 --- a/src/components/theme-toggle/ThemeToggle.test.tsx +++ b/src/components/theme-toggle/ThemeToggle.test.tsx @@ -60,7 +60,7 @@ describe("ThemeToggle component", () => { render(); }); expect(screen.getByText('💻')).toBeInTheDocument(); - expect(screen.getByText('🌙')).toBeInTheDocument(); + expect(screen.queryByText('🌙')).not.toBeInTheDocument(); expect(screen.queryByText('☀️️')).not.toBeInTheDocument(); });