diff --git a/README.md b/README.md index d1c68b57..24c7bc60 100644 --- a/README.md +++ b/README.md @@ -1 +1,50 @@ -# Todo \ No newline at end of file +https://react-zustand-todo.netlify.app/ + +# Todo App + +A responsive and accessible Todo application built with **React**, **Zustand**, and **Material UI**. + +The app allows users to manage tasks efficiently with global state management, persistent storage, and a clean, responsive user interface. + +--- + +## Usage + +1. Enter a task in the input field and click **Add** to create a new todo. +2. Tasks are displayed under **Uncompleted** or **Completed** depending on their status. +3. Use the checkbox to mark a task as completed or uncompleted. +4. Click the trash icon to delete a task. +5. Use the **Complete all** button to mark all uncompleted tasks as completed. +6. Task counters update automatically to show: + - Total tasks + - Completed tasks + - Uncompleted tasks + +Tasks are saved automatically and persist after refreshing the page. + +--- + +## Features + +- Add and remove tasks +- Mark tasks as completed or uncompleted +- Complete all tasks with a single action +- Global state management using Zustand (no prop-drilling) +- Persistent storage using localStorage +- Live task counters (total / completed / uncompleted) +- Responsive layout (mobile β†’ desktop) +- Accessible UI (labels, aria-labels, semantic HTML) +- Empty states for improved user experience +- Custom Material UI theme + +--- + +## Accessibility & Performance + +- Built following accessibility best practices +- Semantic headings and labelled form elements +- aria-labels for interactive components +- Responsive design using Material UI layout components +- Tested with Lighthouse (Accessibility score β‰₯ 95) + +--- diff --git a/index.html b/index.html index f7ac4e42..c9d905dd 100644 --- a/index.html +++ b/index.html @@ -4,13 +4,14 @@ + Todo
- + diff --git a/package.json b/package.json index caf62893..118d144d 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,15 @@ "preview": "vite preview" }, "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.7", + "@mui/material": "^7.3.7", + "@mui/styled-engine-sc": "^7.3.7", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "styled-components": "^6.3.6", + "zustand": "^5.0.10" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/src/App.jsx b/src/App.jsx index 54275400..26d0d6a0 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,61 @@ +import { AddTodoForm } from "./components/AddTodoForm.jsx"; +import { CountTodos } from "./components/CountTodos.jsx"; +import { TodoList } from "./components/TodoList.jsx"; +import { + Container, + Paper, + Typography, + Box, + Button, + Stack, +} from "@mui/material"; +import { useTodoStore } from "./stores/useTodoStore.jsx"; + export const App = () => { + const completeAll = useTodoStore((state) => state.completeAll); + const hasTodos = useTodoStore((state) => state.todos.length > 0); + const hasUncompletedTodos = useTodoStore((state) => + state.todos.some((todo) => !todo.completed) + ); + return ( -

React Boilerplate

- ) -} + + + + + Todo + + + + {hasTodos && + (hasUncompletedTodos ? ( + + ) : ( + + All tasks are completed πŸŽ‰ + + ))} + + + + + + + ); +}; diff --git a/src/components/AddTodoForm.jsx b/src/components/AddTodoForm.jsx new file mode 100644 index 00000000..6f2b7a00 --- /dev/null +++ b/src/components/AddTodoForm.jsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import { useTodoStore } from "../stores/useTodoStore"; +import { TextField, Button, Stack } from "@mui/material"; + +export const AddTodoForm = () => { + const [text, setText] = useState(""); + const addTodo = useTodoStore((state) => state.addTodo); + + const handleSubmit = (e) => { + e.preventDefault(); + if (!text.trim()) return; + addTodo(text.trim()); + setText(""); + }; + + return ( +
+ + setText(e.target.value)} + label="New todo" + placeholder="Add a new todo" + fullWidth + slotProps={{ + input: { + maxLength: 120, + }, + }} + /> + + + +
+ ); +}; diff --git a/src/components/CountTodos.jsx b/src/components/CountTodos.jsx new file mode 100644 index 00000000..a93f3225 --- /dev/null +++ b/src/components/CountTodos.jsx @@ -0,0 +1,43 @@ +import { useTodoStore } from "../stores/useTodoStore"; +import { Paper, Typography, Stack } from "@mui/material"; + +const StatPill = ({ label, value }) => ( + + + {label} + + + {value} + + +); + +export const CountTodos = () => { + const total = useTodoStore((s) => s.todos.length); + const completed = useTodoStore( + (s) => s.todos.filter((t) => t.completed).length + ); + const uncompleted = useTodoStore( + (s) => s.todos.filter((t) => !t.completed).length + ); + + return ( + + + + + + ); +}; diff --git a/src/components/EmptyState.jsx b/src/components/EmptyState.jsx new file mode 100644 index 00000000..da53398b --- /dev/null +++ b/src/components/EmptyState.jsx @@ -0,0 +1,39 @@ +import { Box, Typography } from "@mui/material"; + +export const EmptyState = ({ + title = "Nothing here", + description = "Add something to get started.", + icon = "πŸ“", +}) => { + return ( + + + + + {title} + + + + {description} + + + ); +}; diff --git a/src/components/TodoItem.jsx b/src/components/TodoItem.jsx new file mode 100644 index 00000000..bd7ac1e8 --- /dev/null +++ b/src/components/TodoItem.jsx @@ -0,0 +1,44 @@ +import { useTodoStore } from "../stores/useTodoStore"; +import { ListItem, ListItemText, IconButton, Checkbox } from "@mui/material"; +import DeleteIcon from "@mui/icons-material/Delete"; + +export const TodoItem = ({ todo }) => { + const removeTodo = useTodoStore((state) => state.removeTodo); + const toggleTodo = useTodoStore((state) => state.toggleTodo); + + const label = todo.completed ? "Mark as uncompleted" : "Mark as completed"; + + return ( + removeTodo(todo.id)} + aria-label={`Delete todo: ${todo.text}`} + > + + + } + > + toggleTodo(todo.id)} + slotProps={{ + input: { + "aria-label": `${label}: ${todo.text}`, + }, + }} + /> + + + + ); +}; diff --git a/src/components/TodoList.jsx b/src/components/TodoList.jsx new file mode 100644 index 00000000..45dbba04 --- /dev/null +++ b/src/components/TodoList.jsx @@ -0,0 +1,68 @@ +import { useTodoStore } from "../stores/useTodoStore"; +import { TodoItem } from "./TodoItem"; +import { Typography, List, Divider, Box, Paper } from "@mui/material"; +import { EmptyState } from "./EmptyState"; + +export const TodoList = () => { + const todos = useTodoStore((state) => state.todos); + + const uncompletedTodos = todos.filter((t) => !t.completed); + const completedTodos = todos.filter((t) => t.completed); + + // Empty state fΓΆr hela appen + if (todos.length === 0) { + return ( + + + + ); + } + + return ( + + + + Uncompleted ({uncompletedTodos.length}) + + + {uncompletedTodos.length === 0 ? ( + + ) : ( + + {uncompletedTodos.map((todo) => ( + + ))} + + )} + + + + + Completed ({completedTodos.length}) + + + {completedTodos.length === 0 ? ( + + ) : ( + + {completedTodos.map((todo) => ( + + ))} + + )} + + + ); +}; diff --git a/src/index.css b/src/index.css index f7c0aef5..e69de29b 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +0,0 @@ -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; -} diff --git a/src/main.jsx b/src/main.jsx index 1b8ffe9b..377fbcab 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,12 +1,18 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App.jsx"; -import { App } from './App.jsx' +import { ThemeProvider } from "@mui/material/styles"; +import { CssBaseline } from "@mui/material"; +import { theme } from "./theme"; -import './index.css' +import "./index.css"; -ReactDOM.createRoot(document.getElementById('root')).render( +ReactDOM.createRoot(document.getElementById("root")).render( - + + + + -) +); diff --git a/src/stores/useTodoStore.jsx b/src/stores/useTodoStore.jsx new file mode 100644 index 00000000..96011e79 --- /dev/null +++ b/src/stores/useTodoStore.jsx @@ -0,0 +1,31 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export const useTodoStore = create( + persist((set) => ({ + todos: [], + addTodo: (text) => + set((state) => ({ + todos: [...state.todos, { id: Date.now(), text, completed: false }], + })), + + removeTodo: (id) => + set((state) => ({ + todos: state.todos.filter((t) => t.id !== id), + })), + toggleTodo: (id) => + set((state) => ({ + todos: state.todos.map((t) => + t.id === id ? { ...t, completed: !t.completed } : t + ), + })), + + clearTodos: () => set({ todos: [] }), + + completeAll: () => + set((state) => ({ + todos: state.todos.map((t) => ({ ...t, completed: true })), + })), + })), + { name: "todo-storage" } +); diff --git a/src/theme.js b/src/theme.js new file mode 100644 index 00000000..b59eddf7 --- /dev/null +++ b/src/theme.js @@ -0,0 +1,58 @@ +import { createTheme } from "@mui/material/styles"; + +export const theme = createTheme({ + palette: { + mode: "light", + primary: { + main: "#1976d2", + }, + background: { + default: "#f5f5f5", + paper: "#ffffff", + }, + }, + + shape: { + borderRadius: 14, + }, + + typography: { + fontFamily: ["Roboto", "Helvetica", "Arial", "sans-serif"].join(","), + h4: { fontWeight: 700 }, + h6: { fontWeight: 700 }, + }, + + components: { + MuiPaper: { + styleOverrides: { + root: { + borderRadius: 16, + }, + }, + }, + MuiButton: { + defaultProps: { + disableElevation: true, + }, + styleOverrides: { + root: { + borderRadius: 12, + textTransform: "none", + fontWeight: 600, + }, + }, + }, + MuiTextField: { + defaultProps: { + size: "medium", + }, + }, + MuiListItem: { + styleOverrides: { + root: { + borderRadius: 12, + }, + }, + }, + }, +}); diff --git a/vite.config.js b/vite.config.js index ba242447..b5a3e5d8 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,12 @@ -import react from '@vitejs/plugin-react' -import { defineConfig } from 'vite' +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()] -}) + resolve: { + alias: { + "@mui/styled-engine": "@mui/styled-engine-sc", + }, + }, + plugins: [react()], +});