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
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,50 @@
# Todo
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)

---
9 changes: 5 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="A simple and accessible todo app built with React, Zustand and Material UI."
/>
<title>Todo</title>
</head>
<body>
<div id="root"></div>
<script
type="module"
src="./src/main.jsx">
</script>
<script type="module" src="./src/main.jsx"></script>
</body>
</html>
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 59 additions & 3 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<h1>React Boilerplate</h1>
)
}
<Box
sx={{
minHeight: "100vh",
bgcolor: "background.default",
py: { xs: 3, sm: 6 },
}}
>
<Container maxWidth="sm">
<Paper elevation={2} sx={{ p: { xs: 2, sm: 3 } }}>
<Typography variant="h4" component="h1" sx={{ mb: 2 }}>
Todo
</Typography>
<Stack spacing={1.5} sx={{ mb: 2 }}>
<CountTodos />
{hasTodos &&
(hasUncompletedTodos ? (
<Button
variant="outlined"
onClick={completeAll}
aria-label="Complete all todos"
>
Complete All
</Button>
) : (
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: "center" }}
>
All tasks are completed 🎉
</Typography>
))}
</Stack>
<AddTodoForm />
<TodoList />
</Paper>
</Container>
</Box>
);
};
47 changes: 47 additions & 0 deletions src/components/AddTodoForm.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<form onSubmit={handleSubmit} aria-label="Add new todo form">
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1}
sx={{ alignItems: { sm: "center" } }}
>
<TextField
value={text}
onChange={(e) => setText(e.target.value)}
label="New todo"
placeholder="Add a new todo"
fullWidth
slotProps={{
input: {
maxLength: 120,
},
}}
/>

<Button
type="submit"
variant="contained"
sx={{ width: { xs: "100%", sm: "auto" } }}
aria-label="Add new todo"
>
Add
</Button>
</Stack>
</form>
);
};
43 changes: 43 additions & 0 deletions src/components/CountTodos.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useTodoStore } from "../stores/useTodoStore";
import { Paper, Typography, Stack } from "@mui/material";

const StatPill = ({ label, value }) => (
<Paper
variant="outlined"
sx={{
px: 1.25,
py: 0.5,
borderRadius: 999,
display: "flex",
alignItems: "center",
gap: 0.75,
minWidth: 64,
justifyContent: "center",
}}
>
<Typography variant="caption" color="text.secondary">
{label}
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{value}
</Typography>
</Paper>
);

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 (
<Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: "wrap" }}>
<StatPill label="Total" value={total} />
<StatPill label="Completed" value={completed} />
<StatPill label="Uncompleted" value={uncompleted} />
</Stack>
);
};
39 changes: 39 additions & 0 deletions src/components/EmptyState.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Box, Typography } from "@mui/material";

export const EmptyState = ({
title = "Nothing here",
description = "Add something to get started.",
icon = "📝",
}) => {
return (
<Box
role="status"
aria-live="polite"
sx={{
textAlign: "center",
py: { xs: 4, sm: 5 },
px: { xs: 2, sm: 3 },
borderRadius: 3,
border: "1px dashed",
borderColor: "divider",
bgcolor: "background.paper",
}}
>
<Typography
component="div"
sx={{ fontSize: { xs: 40, sm: 48 }, mb: 1 }}
aria-hidden="true"
>
{icon}
</Typography>

<Typography variant="h6" sx={{ fontWeight: 800, mb: 0.5 }}>
{title}
</Typography>

<Typography color="text.secondary" sx={{ maxWidth: 420, mx: "auto" }}>
{description}
</Typography>
</Box>
);
};
44 changes: 44 additions & 0 deletions src/components/TodoItem.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<ListItem
disablePadding
secondaryAction={
<IconButton
edge="end"
onClick={() => removeTodo(todo.id)}
aria-label={`Delete todo: ${todo.text}`}
>
<DeleteIcon />
</IconButton>
}
>
<Checkbox
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
slotProps={{
input: {
"aria-label": `${label}: ${todo.text}`,
},
}}
/>

<ListItemText
primary={todo.text}
sx={{
textDecoration: todo.completed ? "line-through" : "none",
opacity: todo.completed ? 0.75 : 1,
pr: 1,
}}
/>
</ListItem>
);
};
68 changes: 68 additions & 0 deletions src/components/TodoList.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box sx={{ mt: 2 }}>
<EmptyState
icon="✅"
title="No todos yet"
description="Add your first todo above to get started."
/>
</Box>
);
}

return (
<Box sx={{ mt: 2 }}>
<Paper variant="outlined" sx={{ p: { xs: 1.5, sm: 2 } }}>
<Typography component="h2" variant="h6" sx={{ mb: 1 }}>
Uncompleted ({uncompletedTodos.length})
</Typography>

{uncompletedTodos.length === 0 ? (
<EmptyState
icon="🎉"
title="All caught up"
description="You have no uncompleted todos right now."
/>
) : (
<List dense aria-label="Uncompleted todos list">
{uncompletedTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</List>
)}

<Divider sx={{ my: 2 }} />

<Typography component="h2" variant="h6" sx={{ mb: 1 }}>
Completed ({completedTodos.length})
</Typography>

{completedTodos.length === 0 ? (
<EmptyState
icon="⏳"
title="No completed todos yet"
description="Complete a todo to see it here."
/>
) : (
<List dense aria-label="Completed todos list">
{completedTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</List>
)}
</Paper>
</Box>
);
};
Loading