diff --git a/src/components/ToggleDarkMode.tsx b/src/components/ToggleDarkMode.tsx new file mode 100644 index 00000000..a2bab9cc --- /dev/null +++ b/src/components/ToggleDarkMode.tsx @@ -0,0 +1,33 @@ +import React, { useEffect, useState } from "react"; +import { ToggleDarkModeContainer } from "../styles/components/ToggleDarkMode"; +import DarkModeToggle from "react-dark-mode-toggle"; +import useAppStore from "../store/store"; + +const ToggleDarkMode: React.FC = () => { + const { backgroundColor, toggleDarkMode } = useAppStore(); + const [isDarkMode, setIsDarkMode] = useState(backgroundColor === "#121212"); + + useEffect(() => { + setIsDarkMode(backgroundColor === "#121212"); + }, [backgroundColor]); + + const handleChange = () => { + toggleDarkMode(); + setIsDarkMode((prev) => !prev); + const newTheme = !isDarkMode ? "dark" : "light"; + document.documentElement.setAttribute("data-theme", newTheme); + }; + + return ( + + + + ); +}; + +export default ToggleDarkMode; diff --git a/src/styles/components/ToggleDarkMode.ts b/src/styles/components/ToggleDarkMode.ts new file mode 100644 index 00000000..c5ca01b3 --- /dev/null +++ b/src/styles/components/ToggleDarkMode.ts @@ -0,0 +1,17 @@ +import styled from "styled-components"; + +export const ToggleDarkModeContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + + /* Prevent clicks on the outer container */ + pointer-events: none; + + .dark-mode-toggle { + pointer-events: auto; /* Enable clicks only inside the actual toggle */ + outline: 0.125rem solid white; + border-radius: 1.25rem; + } +`; diff --git a/src/tests/components/ToggleDarkMode.test.tsx b/src/tests/components/ToggleDarkMode.test.tsx new file mode 100644 index 00000000..c5e329cd --- /dev/null +++ b/src/tests/components/ToggleDarkMode.test.tsx @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +// ---------- Mutable store state ---------- +let mockBackgroundColor = '#ffffff'; +const mockToggleDarkMode = vi.fn(); + +vi.mock('../../store/store', () => { + // Return a function that, when called with no selector, returns the store object. + // When called with a selector, passes the store object through the selector. + const useAppStore = (...args: unknown[]) => { + const storeState = { + backgroundColor: mockBackgroundColor, + toggleDarkMode: mockToggleDarkMode, + }; + if (typeof args[0] === 'function') { + return (args[0] as (s: typeof storeState) => unknown)(storeState); + } + return storeState; + }; + return { default: useAppStore }; +}); + +// ---------- Mock react-dark-mode-toggle ---------- +vi.mock('react-dark-mode-toggle', () => ({ + default: ({ + checked, + onChange, + size, + className, + }: { + checked: boolean; + onChange: () => void; + size: number; + className: string; + }) => ( + + ), +})); + +// ---------- Mock styled-component ---------- +vi.mock('../../styles/components/ToggleDarkMode', () => ({ + ToggleDarkModeContainer: ({ + children, + ...props + }: React.PropsWithChildren>) => ( +
{children}
+ ), +})); + +// ---------- Import after mocks ---------- +import ToggleDarkMode from '../../components/ToggleDarkMode'; + +// ---------- Tests ---------- +describe('ToggleDarkMode', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockBackgroundColor = '#ffffff'; + document.documentElement.removeAttribute('data-theme'); + }); + + it('renders the toggle component', () => { + render(); + expect(screen.getByTestId('toggle-dark-mode')).toBeInTheDocument(); + }); + + it('initializes isDarkMode to false when backgroundColor is #ffffff', () => { + mockBackgroundColor = '#ffffff'; + render(); + const btn = screen.getByTestId('dark-mode-toggle'); + expect(btn.getAttribute('data-checked')).toBe('false'); + }); + + it('initializes isDarkMode to true when backgroundColor is #121212', () => { + mockBackgroundColor = '#121212'; + render(); + const btn = screen.getByTestId('dark-mode-toggle'); + expect(btn.getAttribute('data-checked')).toBe('true'); + }); + + it('calls toggleDarkMode when clicked', () => { + render(); + fireEvent.click(screen.getByTestId('dark-mode-toggle')); + expect(mockToggleDarkMode).toHaveBeenCalledTimes(1); + }); + + it('sets data-theme to dark when toggled from light mode', () => { + mockBackgroundColor = '#ffffff'; + render(); + fireEvent.click(screen.getByTestId('dark-mode-toggle')); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + }); + + it('sets data-theme to light when toggled from dark mode', () => { + mockBackgroundColor = '#121212'; + render(); + fireEvent.click(screen.getByTestId('dark-mode-toggle')); + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + }); + + it('updates isDarkMode when backgroundColor prop changes', () => { + mockBackgroundColor = '#ffffff'; + const { rerender } = render(); + + const btn = screen.getByTestId('dark-mode-toggle'); + expect(btn.getAttribute('data-checked')).toBe('false'); + + // Simulate store change + mockBackgroundColor = '#121212'; + rerender(); + + expect(btn.getAttribute('data-checked')).toBe('true'); + }); + + it('passes size={60} to the toggle', () => { + render(); + const btn = screen.getByTestId('dark-mode-toggle'); + expect(btn.getAttribute('data-size')).toBe('60'); + }); + + it('applies className dark-mode-toggle', () => { + render(); + const btn = screen.getByTestId('dark-mode-toggle'); + expect(btn.className).toBe('dark-mode-toggle'); + }); + + it('handles multiple sequential toggles correctly', () => { + mockBackgroundColor = '#ffffff'; + render(); + const btn = screen.getByTestId('dark-mode-toggle'); + + // First click: light → dark + fireEvent.click(btn); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + expect(mockToggleDarkMode).toHaveBeenCalledTimes(1); + + // Second click: dark → light + fireEvent.click(btn); + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + expect(mockToggleDarkMode).toHaveBeenCalledTimes(2); + }); +});