create-jsc-vite-react-ts 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Cédric Rochefolle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # create-jsc-vite-react-ts
2
+
3
+ A CLI tool to scaffold modern React applications with TypeScript, Vite, Tailwind CSS, and Vitest pre-configured.
4
+
5
+ ## Quick Start
6
+
7
+ Create a new project using npm, yarn, or pnpm:
8
+
9
+ ```bash
10
+ # npm
11
+ npm create jsc-vite-react-ts my-app
12
+
13
+ # yarn
14
+ yarn create jsc-vite-react-ts my-app
15
+
16
+ # pnpm
17
+ pnpm create jsc-vite-react-ts my-app
18
+ ```
19
+
20
+ Then navigate to your project and start developing:
21
+
22
+ ```bash
23
+ cd my-app
24
+ yarn dev
25
+ ```
26
+
27
+ ## What's Included
28
+
29
+ The generated project includes:
30
+
31
+ - **React 19** with TypeScript
32
+ - **Vite 7** for blazing-fast development and builds
33
+ - **Tailwind CSS 4** for utility-first styling
34
+ - **Vitest** for unit testing with React Testing Library
35
+ - **ESLint** with TypeScript and React rules
36
+ - **Dark mode** implementation example with theme context
37
+ - **Arrow function** components by default
38
+ - **100% test coverage** example with all components tested
39
+
40
+ ## Features
41
+
42
+ ### Pre-configured Testing
43
+
44
+ - Vitest with jsdom environment
45
+ - React Testing Library setup
46
+ - Example tests for all components and hooks
47
+ - Coverage reporting with `yarn test:coverage`
48
+
49
+ ### Dark Mode Ready
50
+
51
+ Complete dark mode implementation using React Context and Tailwind CSS, including localStorage persistence.
52
+
53
+ ### TypeScript First
54
+
55
+ Strict TypeScript configuration with project references for optimal type checking and IDE performance.
56
+
57
+ ### Modern Stack
58
+
59
+ All dependencies are up-to-date with the latest stable versions of React, Vite, Tailwind CSS, and testing libraries.
60
+
61
+ ## Available Scripts
62
+
63
+ In the generated project, you can run:
64
+
65
+ - `yarn dev` - Start development server
66
+ - `yarn build` - Build for production
67
+ - `yarn preview` - Preview production build
68
+ - `yarn lint` - Run ESLint
69
+ - `yarn test` - Run tests in watch mode
70
+ - `yarn test:ui` - Run tests with interactive UI
71
+ - `yarn test:coverage` - Run tests with coverage report
72
+
73
+ ## Requirements
74
+
75
+ - Node.js 18.0.0 or higher
76
+
77
+ ## Contributing
78
+
79
+ Contributions are welcome! Please feel free to submit a Pull Request.
80
+
81
+ ## License
82
+
83
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execSync } from 'child_process'
4
+ import fs from 'fs-extra'
5
+ import path from 'path'
6
+ import { fileURLToPath } from 'url'
7
+ import prompts from 'prompts'
8
+ import chalk from 'chalk'
9
+
10
+ const __filename = fileURLToPath(import.meta.url)
11
+ const __dirname = path.dirname(__filename)
12
+
13
+ const run = async () => {
14
+ console.log(chalk.bold.cyan('\n🚀 Create React + TypeScript + Vite App\n'))
15
+
16
+ const args = process.argv.slice(2)
17
+ let projectName = args[0]
18
+
19
+ if (!projectName) {
20
+ const response = await prompts({
21
+ type: 'text',
22
+ name: 'projectName',
23
+ message: 'What is your project named?',
24
+ initial: 'my-app',
25
+ validate: (value) => {
26
+ if (!value) return 'Project name is required'
27
+ if (!/^[a-z0-9-_]+$/.test(value)) {
28
+ return 'Project name can only contain lowercase letters, numbers, hyphens, and underscores'
29
+ }
30
+ return true
31
+ },
32
+ })
33
+
34
+ if (!response.projectName) {
35
+ console.log(chalk.red('\n✖ Project creation cancelled\n'))
36
+ process.exit(1)
37
+ }
38
+
39
+ projectName = response.projectName
40
+ }
41
+
42
+ const targetDir = path.resolve(process.cwd(), projectName)
43
+
44
+ if (fs.existsSync(targetDir)) {
45
+ console.log(chalk.red(`\n✖ Directory ${projectName} already exists\n`))
46
+ process.exit(1)
47
+ }
48
+
49
+ console.log(chalk.blue(`\n📁 Creating project in ${targetDir}...\n`))
50
+
51
+ const templateDir = path.resolve(__dirname, '../template')
52
+ fs.copySync(templateDir, targetDir, {
53
+ filter: (src) => {
54
+ const basename = path.basename(src)
55
+ return !['node_modules', 'dist', 'coverage', '.claude', 'plan.md', 'CLAUDE.md', 'yarn.lock'].includes(basename)
56
+ },
57
+ })
58
+
59
+ const packageJsonPath = path.join(targetDir, 'package.json')
60
+ const packageJson = fs.readJsonSync(packageJsonPath)
61
+ packageJson.name = projectName
62
+ fs.writeJsonSync(packageJsonPath, packageJson, { spaces: 2 })
63
+
64
+ console.log(chalk.green('✓ Project files created'))
65
+
66
+ const packageManager = await detectPackageManager()
67
+
68
+ console.log(chalk.blue(`\n📦 Installing dependencies with ${packageManager}...\n`))
69
+
70
+ try {
71
+ execSync(`${packageManager} install`, {
72
+ cwd: targetDir,
73
+ stdio: 'inherit',
74
+ })
75
+ console.log(chalk.green('\n✓ Dependencies installed'))
76
+ } catch (error) {
77
+ console.log(chalk.yellow('\n⚠ Failed to install dependencies automatically'))
78
+ console.log(chalk.yellow(` Please run "${packageManager} install" manually\n`))
79
+ }
80
+
81
+ console.log(chalk.bold.green('\n✨ Success! Your project is ready.\n'))
82
+ console.log(chalk.bold('Next steps:\n'))
83
+ console.log(chalk.cyan(` cd ${projectName}`))
84
+ console.log(chalk.cyan(` ${packageManager} dev`))
85
+ console.log()
86
+ }
87
+
88
+ const detectPackageManager = async () => {
89
+ const userAgent = process.env.npm_config_user_agent || ''
90
+
91
+ if (userAgent.startsWith('yarn')) return 'yarn'
92
+ if (userAgent.startsWith('pnpm')) return 'pnpm'
93
+
94
+ const response = await prompts({
95
+ type: 'select',
96
+ name: 'packageManager',
97
+ message: 'Which package manager do you want to use?',
98
+ choices: [
99
+ { title: 'npm', value: 'npm' },
100
+ { title: 'yarn', value: 'yarn' },
101
+ { title: 'pnpm', value: 'pnpm' },
102
+ ],
103
+ initial: 0,
104
+ })
105
+
106
+ return response.packageManager || 'npm'
107
+ }
108
+
109
+ run().catch((error) => {
110
+ console.error(chalk.red('\n✖ An error occurred:\n'))
111
+ console.error(error)
112
+ process.exit(1)
113
+ })
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "create-jsc-vite-react-ts",
3
+ "version": "1.0.1",
4
+ "description": "Create a modern React app with TypeScript, Vite, Tailwind CSS, and Vitest",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-create-jsc-vite-react-ts": "./bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "template"
12
+ ],
13
+ "keywords": [
14
+ "react",
15
+ "vite",
16
+ "typescript",
17
+ "tailwindcss",
18
+ "vitest",
19
+ "starter",
20
+ "template",
21
+ "boilerplate",
22
+ "scaffolding",
23
+ "create-app"
24
+ ],
25
+ "author": "JScoobyCed",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/jscoobyced/create-jsc-vite-react-ts.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/jscoobyced/create-jsc-vite-react-ts/issues"
33
+ },
34
+ "homepage": "https://github.com/jscoobyced/create-jsc-vite-react-ts#readme",
35
+ "engines": {
36
+ "node": ">=18.0.0"
37
+ },
38
+ "dependencies": {
39
+ "chalk": "^5.4.1",
40
+ "fs-extra": "^11.2.0",
41
+ "prompts": "^2.4.2"
42
+ },
43
+ "devDependencies": {
44
+ "@types/fs-extra": "^11.0.4",
45
+ "@types/prompts": "^2.4.9"
46
+ }
47
+ }
@@ -0,0 +1,97 @@
1
+ # React + TypeScript + Vite Starter
2
+
3
+ A modern React application scaffolding with TypeScript, Vite, Tailwind CSS, and testing pre-configured.
4
+
5
+ ## What's Included
6
+
7
+ - **React 19** with TypeScript
8
+ - **Vite 7** for blazing-fast development and builds
9
+ - **Tailwind CSS 4** for utility-first styling
10
+ - **Vitest** for unit testing with React Testing Library
11
+ - **ESLint** with TypeScript and React rules
12
+ - **Dark mode** implementation example with theme context
13
+ - **Arrow function** components by default
14
+
15
+ ## Getting Started
16
+
17
+ ### Install Dependencies
18
+
19
+ ```bash
20
+ yarn install
21
+ ```
22
+
23
+ ### Development
24
+
25
+ Start the development server with hot module replacement:
26
+
27
+ ```bash
28
+ yarn dev
29
+ ```
30
+
31
+ Open [http://localhost:5173](http://localhost:5173) to view your app.
32
+
33
+ ### Build
34
+
35
+ Create a production build:
36
+
37
+ ```bash
38
+ yarn build
39
+ ```
40
+
41
+ Preview the production build locally:
42
+
43
+ ```bash
44
+ yarn preview
45
+ ```
46
+
47
+ ## Available Scripts
48
+
49
+ - `yarn dev` - Start development server
50
+ - `yarn build` - Build for production
51
+ - `yarn preview` - Preview production build
52
+ - `yarn lint` - Run ESLint
53
+ - `yarn test` - Run tests in watch mode
54
+ - `yarn test:ui` - Run tests with interactive UI
55
+ - `yarn test:coverage` - Run tests with coverage report
56
+
57
+ ## Project Structure
58
+
59
+ ```
60
+ src/
61
+ ├── components/ # React components
62
+ ├── contexts/ # React contexts and providers
63
+ ├── hooks/ # Custom React hooks
64
+ ├── test/ # Test setup and utilities
65
+ ├── App.tsx # Main application component
66
+ └── main.tsx # Application entry point
67
+ ```
68
+
69
+ ## Key Features
70
+
71
+ ### Dark Mode
72
+
73
+ The template includes a complete dark mode implementation using React Context and Tailwind CSS. Toggle between light and dark themes with the button in the top-right corner.
74
+
75
+ ### Testing
76
+
77
+ All components and hooks include example tests demonstrating best practices with Vitest and React Testing Library. Run `yarn test:coverage` to see your test coverage.
78
+
79
+ ### TypeScript
80
+
81
+ Strict TypeScript configuration with project references for optimal type checking and IDE performance.
82
+
83
+ ## Next Steps
84
+
85
+ 1. Update `package.json` with your project name and details
86
+ 2. Customize the theme colors in your Tailwind configuration
87
+ 3. Replace the example content in `App.tsx` with your application
88
+ 4. Add your routes, state management, and API integration
89
+ 5. Update this README with your project-specific information
90
+
91
+ ## Learn More
92
+
93
+ - [React Documentation](https://react.dev)
94
+ - [Vite Documentation](https://vite.dev)
95
+ - [Tailwind CSS Documentation](https://tailwindcss.com)
96
+ - [Vitest Documentation](https://vitest.dev)
97
+ - [TypeScript Documentation](https://www.typescriptlang.org)
@@ -0,0 +1,23 @@
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { defineConfig, globalIgnores } from 'eslint/config'
7
+
8
+ export default defineConfig([
9
+ globalIgnores(['dist']),
10
+ {
11
+ files: ['**/*.{ts,tsx}'],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs.flat.recommended,
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ ecmaVersion: 2020,
20
+ globals: globals.browser,
21
+ },
22
+ },
23
+ ])
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet" />
10
+ <title>hello-world</title>
11
+ </head>
12
+ <body>
13
+ <div id="root"></div>
14
+ <script type="module" src="/src/main.tsx"></script>
15
+ </body>
16
+ </html>
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "my-app",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview",
11
+ "test": "vitest",
12
+ "test:ui": "vitest --ui",
13
+ "test:coverage": "vitest --coverage"
14
+ },
15
+ "dependencies": {
16
+ "react": "^19.2.0",
17
+ "react-dom": "^19.2.0"
18
+ },
19
+ "devDependencies": {
20
+ "@eslint/js": "^9.39.1",
21
+ "@tailwindcss/vite": "^4.0.0",
22
+ "@testing-library/dom": "^10.4.1",
23
+ "@testing-library/jest-dom": "^6.9.1",
24
+ "@testing-library/react": "^16.3.1",
25
+ "@testing-library/user-event": "^14.6.1",
26
+ "@types/node": "^24.10.1",
27
+ "@types/react": "^19.2.5",
28
+ "@types/react-dom": "^19.2.3",
29
+ "@vitejs/plugin-react": "^5.1.1",
30
+ "@vitest/coverage-v8": "^4.0.16",
31
+ "@vitest/ui": "^4.0.16",
32
+ "eslint": "^9.39.1",
33
+ "eslint-plugin-react-hooks": "^7.0.1",
34
+ "eslint-plugin-react-refresh": "^0.4.24",
35
+ "globals": "^16.5.0",
36
+ "jsdom": "^27.3.0",
37
+ "tailwindcss": "^4.0.0",
38
+ "typescript": "~5.9.3",
39
+ "typescript-eslint": "^8.46.4",
40
+ "vite": "^7.2.4",
41
+ "vitest": "^4.0.16"
42
+ }
43
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
@@ -0,0 +1,29 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, it, expect } from 'vitest'
3
+ import App from './App'
4
+ import { ThemeProvider } from './contexts/ThemeContext'
5
+
6
+ const renderWithTheme = (component: React.ReactElement) => {
7
+ return render(<ThemeProvider>{component}</ThemeProvider>)
8
+ }
9
+
10
+ describe('App', () => {
11
+ it('renders Hello World heading', () => {
12
+ renderWithTheme(<App />)
13
+ const heading = screen.getByText('Hello World')
14
+ expect(heading).toBeInTheDocument()
15
+ expect(heading).toHaveClass('text-4xl')
16
+ })
17
+
18
+ it('renders ThemeToggle button', () => {
19
+ renderWithTheme(<App />)
20
+ const button = screen.getByRole('button', { name: /toggle theme/i })
21
+ expect(button).toBeInTheDocument()
22
+ })
23
+
24
+ it('renders Footer', () => {
25
+ renderWithTheme(<App />)
26
+ const footer = screen.getByText(/built with ❤️/i)
27
+ expect(footer).toBeInTheDocument()
28
+ })
29
+ })
@@ -0,0 +1,18 @@
1
+ import { ThemeToggle } from './components/ThemeToggle'
2
+ import { Footer } from './components/Footer'
3
+
4
+ const App = () => {
5
+ return (
6
+ <div className="min-h-screen flex flex-col bg-white dark:bg-gray-900">
7
+ <ThemeToggle />
8
+ <main className="grow flex items-center justify-center">
9
+ <h1 className="text-4xl text-gray-900 dark:text-white font-family-sans">
10
+ Hello World
11
+ </h1>
12
+ </main>
13
+ <Footer />
14
+ </div>
15
+ );
16
+ }
17
+
18
+ export default App
@@ -0,0 +1,7 @@
1
+ @import "tailwindcss";
2
+
3
+ @variant dark (&:where(.dark, .dark *));
4
+
5
+ @theme {
6
+ --font-family-sans: 'Inter';
7
+ }
@@ -0,0 +1,17 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, it, expect } from 'vitest'
3
+ import { Footer } from './Footer'
4
+
5
+ describe('Footer', () => {
6
+ it('renders footer text', () => {
7
+ render(<Footer />)
8
+ const footerText = screen.getByText(/built with ❤️ by IndyTheDog/i)
9
+ expect(footerText).toBeInTheDocument()
10
+ })
11
+
12
+ it('has correct styling classes', () => {
13
+ render(<Footer />)
14
+ const footer = screen.getByRole('contentinfo')
15
+ expect(footer).toHaveClass('text-center')
16
+ })
17
+ })
@@ -0,0 +1,7 @@
1
+ export const Footer = () => {
2
+ return (
3
+ <footer className="mt-5 py-4 text-center text-gray-600 dark:text-gray-400 font-family-sans">
4
+ <p>Built with ❤️ by IndyTheDog</p>
5
+ </footer>
6
+ )
7
+ }
@@ -0,0 +1,53 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { userEvent } from '@testing-library/user-event'
3
+ import { describe, it, expect, beforeEach } from 'vitest'
4
+ import { ThemeToggle } from './ThemeToggle'
5
+ import { ThemeProvider } from '../contexts/ThemeContext'
6
+
7
+ const renderWithTheme = (component: React.ReactElement) => {
8
+ return render(<ThemeProvider>{component}</ThemeProvider>)
9
+ }
10
+
11
+ describe('ThemeToggle', () => {
12
+ beforeEach(() => {
13
+ localStorage.clear()
14
+ })
15
+
16
+ it('renders theme toggle button', () => {
17
+ renderWithTheme(<ThemeToggle />)
18
+ const button = screen.getByRole('button', { name: /toggle theme/i })
19
+ expect(button).toBeInTheDocument()
20
+ })
21
+
22
+ it('displays moon icon for light theme', () => {
23
+ renderWithTheme(<ThemeToggle />)
24
+ const button = screen.getByRole('button', { name: /toggle theme/i })
25
+ expect(button).toHaveTextContent('🌙')
26
+ })
27
+
28
+ it('toggles theme when clicked', async () => {
29
+ const user = userEvent.setup()
30
+ renderWithTheme(<ThemeToggle />)
31
+ const button = screen.getByRole('button', { name: /toggle theme/i })
32
+
33
+ expect(button).toHaveTextContent('🌙')
34
+
35
+ await user.click(button)
36
+
37
+ expect(button).toHaveTextContent('☀️')
38
+ })
39
+
40
+ it('toggles theme back and forth', async () => {
41
+ const user = userEvent.setup()
42
+ renderWithTheme(<ThemeToggle />)
43
+ const button = screen.getByRole('button', { name: /toggle theme/i })
44
+
45
+ expect(button).toHaveTextContent('🌙')
46
+
47
+ await user.click(button)
48
+ expect(button).toHaveTextContent('☀️')
49
+
50
+ await user.click(button)
51
+ expect(button).toHaveTextContent('🌙')
52
+ })
53
+ })
@@ -0,0 +1,15 @@
1
+ import { useTheme } from '../hooks/useTheme'
2
+
3
+ export const ThemeToggle = () => {
4
+ const { theme, toggleTheme } = useTheme()
5
+
6
+ return (
7
+ <button
8
+ onClick={toggleTheme}
9
+ className="fixed top-4 right-4 p-3 rounded-lg bg-gray-200 dark:bg-gray-800 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
10
+ aria-label="Toggle theme"
11
+ >
12
+ {theme === 'light' ? '🌙' : '☀️'}
13
+ </button>
14
+ )
15
+ }
@@ -0,0 +1,30 @@
1
+ import { useEffect, useState, type ReactNode } from 'react'
2
+ import { ThemeContext, type Theme } from './theme'
3
+
4
+ export const ThemeProvider = ({ children }: { children: ReactNode }) => {
5
+ const [theme, setTheme] = useState<Theme>(() => {
6
+ const saved = localStorage.getItem('theme')
7
+ return (saved as Theme) || 'light'
8
+ })
9
+
10
+ useEffect(() => {
11
+ const root = document.documentElement
12
+ if (theme === 'dark') {
13
+ root.classList.add('dark')
14
+ } else {
15
+ root.classList.remove('dark')
16
+ }
17
+ localStorage.setItem('theme', theme)
18
+ }, [theme])
19
+
20
+ const toggleTheme = () => {
21
+ setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))
22
+ }
23
+
24
+ return (
25
+ <ThemeContext.Provider value={{ theme, toggleTheme }}>
26
+ {children}
27
+ </ThemeContext.Provider>
28
+ )
29
+ }
30
+
@@ -0,0 +1,10 @@
1
+ import { createContext } from 'react'
2
+
3
+ export type Theme = 'light' | 'dark'
4
+
5
+ export interface ThemeContextType {
6
+ theme: Theme
7
+ toggleTheme: () => void
8
+ }
9
+
10
+ export const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
@@ -0,0 +1,34 @@
1
+ import { renderHook } from '@testing-library/react'
2
+ import { describe, it, expect, beforeEach } from 'vitest'
3
+ import { useTheme } from './useTheme'
4
+ import { ThemeProvider } from '../contexts/ThemeContext'
5
+
6
+ describe('useTheme', () => {
7
+ beforeEach(() => {
8
+ localStorage.clear()
9
+ })
10
+
11
+ it('throws error when used outside ThemeProvider', () => {
12
+ expect(() => {
13
+ renderHook(() => useTheme())
14
+ }).toThrow('useTheme must be used within a ThemeProvider')
15
+ })
16
+
17
+ it('returns theme and toggleTheme function', () => {
18
+ const { result } = renderHook(() => useTheme(), {
19
+ wrapper: ThemeProvider,
20
+ })
21
+
22
+ expect(result.current).toHaveProperty('theme')
23
+ expect(result.current).toHaveProperty('toggleTheme')
24
+ expect(typeof result.current.toggleTheme).toBe('function')
25
+ })
26
+
27
+ it('starts with light theme by default', () => {
28
+ const { result } = renderHook(() => useTheme(), {
29
+ wrapper: ThemeProvider,
30
+ })
31
+
32
+ expect(result.current.theme).toBe('light')
33
+ })
34
+ })
@@ -0,0 +1,10 @@
1
+ import { useContext } from 'react'
2
+ import { ThemeContext } from '../contexts/theme'
3
+
4
+ export const useTheme = () => {
5
+ const context = useContext(ThemeContext)
6
+ if (context === undefined) {
7
+ throw new Error('useTheme must be used within a ThemeProvider')
8
+ }
9
+ return context
10
+ }
@@ -0,0 +1,13 @@
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './app.css'
4
+ import App from './App.tsx'
5
+ import { ThemeProvider } from './contexts/ThemeContext'
6
+
7
+ createRoot(document.getElementById('root')!).render(
8
+ <StrictMode>
9
+ <ThemeProvider>
10
+ <App />
11
+ </ThemeProvider>
12
+ </StrictMode>,
13
+ )
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom'
@@ -0,0 +1,28 @@
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2022",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "types": ["vite/client"],
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "erasableSyntaxOnly": true,
24
+ "noFallthroughCasesInSwitch": true,
25
+ "noUncheckedSideEffectImports": true
26
+ },
27
+ "include": ["src"]
28
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "types": ["node"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "erasableSyntaxOnly": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedSideEffectImports": true
24
+ },
25
+ "include": ["vite.config.ts"]
26
+ }
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from 'vitest/config'
2
+ import react from '@vitejs/plugin-react'
3
+ import tailwindcss from '@tailwindcss/vite'
4
+
5
+ // https://vite.dev/config/
6
+ export default defineConfig({
7
+ plugins: [react(), tailwindcss()],
8
+ test: {
9
+ globals: true,
10
+ environment: 'jsdom',
11
+ setupFiles: './src/test/setup.ts',
12
+ },
13
+ })