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

This is a Todo application built with React to demonstrate clean state management, accessibility, and maintainable frontend architecture.

Features

Create, toggle, and remove tasks

Persist tasks using localStorage

Display task count and creation date

Toggle all tasks with a single action

Accessible and keyboard-friendly UI

Responsive layout

Technical Overview

Global state managed with Zustand (no prop-drilling)

State persistence via localStorage

Logic separated into stores, components, and utility modules
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,22 @@
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"styled-components": "^6.3.5",
"zustand": "^5.0.10"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.23",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"vite": "^6.2.0"
}
}
35 changes: 32 additions & 3 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
export const App = () => {
import { TodoList } from "./components/TodoList";
import { TodoForm } from "./components/TodoForm";
import { TodoCount } from "./components/TodoCount";
import { useTodosStore } from "./stores/useTodosStore";
import { AppWrapper, Header, BulkButton } from "./styles/App.styles";
import { GlobalStyle } from "./styles/GlobalStyles";

export default function App() {
//Get only the action from the global Zustand store
const completeAll = useTodosStore((state) => state.completeAll);
return (
<h1>React Boilerplate</h1>
)
<>
{/*Global styles fpr the entire app */}
<GlobalStyle />
{/* Main app container */}
<AppWrapper>
<Header>
{/* App title */}
<h1>Todo App</h1>
</Header>
{/* Form to add new todos */}
<TodoForm />
{/* Displays total task */}
<TodoCount />
{/* Bulk action to toggle all todos */}
<BulkButton type="button" onClick={completeAll}>
Select all/deselect all{" "}
</BulkButton>
{/* List of all todos */}
<TodoList />
</AppWrapper>
</>
);
}
8 changes: 8 additions & 0 deletions src/components/TodoCount.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { useTodosStore } from "../stores/useTodosStore";

export function TodoCount() {
// Read todos from the global Zustand store
const todos = useTodosStore((state) => state.todos);
// Display the total number of tasks
return <p>Number of tasks: {todos.length}</p>;
}
39 changes: 39 additions & 0 deletions src/components/TodoForm.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useState } from "react";
import { useTodosStore } from "../stores/useTodosStore";
import { Form, Input, Submit } from "../styles/TodoForm.styles";

export function TodoForm() {
// Local state for the input value
const [title, setTitle] = useState("");

// Get the addTodo action from the global store
const addTodo = useTodosStore((state) => state.addTodo);

function handleSumbit(e) {
// Prevent default form submission (page reload)
e.preventDefault();

// Add new todo using global state
addTodo(title);

// Clear the input after submit
setTitle("");
}

return (
<Form onSubmit={handleSumbit}>
{/* Accessible label connected to the input */}
<label htmlFor="TodoTitle"> New Task </label>

{/* Controlled input field */}
<Input
id="TodoTitle"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>

{/* Submit button triggers form submit */}
<Submit type="submit"> Add Task </Submit>
</Form>
);
}
56 changes: 56 additions & 0 deletions src/components/TodoList.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useTodosStore } from "../stores/useTodosStore";
import { formatDate } from "../utils/formatDate";
import {
List,
Item,
TodoButton,
RemoveButton,
Paragraf,
} from "../styles/TodoList.styles";

export function TodoList() {
// Read state + actions from the global Zustand store (no prop drilling)
const todos = useTodosStore((state) => state.todos);
const toggleTodo = useTodosStore((state) => state.toggleTodo);
const removeTodo = useTodosStore((state) => state.removeTodo);

// Empty state UX when there are no tasks
if (todos.length === 0) {
return <Paragraf>No tasks yet. Add your first one above.</Paragraf>;
}

return (
<List>
{todos.map((todo) => (
<Item key={todo.id}>
{/* Click the todo to toggle completed/uncompleted */}
<TodoButton
type="button"
onClick={() => toggleTodo(todo.id)}
aria-pressed={todo.completed}
>
<span>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
onClick={(e) => e.stopPropagation()}
/>
{todo.title}
</span>

<small>Created: {formatDate(todo.createdAt)}</small>
</TodoButton>
{/* Separate remove action with an accessible label */}
<RemoveButton
type="button"
aria-label={`Remove ${todo.title}`}
onClick={() => removeTodo(todo.id)}
>
✕ Remove
</RemoveButton>
</Item>
))}
</List>
);
}
12 changes: 6 additions & 6 deletions src/main.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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 './index.css'
import "./index.css";

ReactDOM.createRoot(document.getElementById('root')).render(
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
);
66 changes: 66 additions & 0 deletions src/stores/useTodosStore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { create } from "zustand";
import { getInitialTodos, saveTodos } from "../utils/todosStorage";

// Global todo store using Zustand
export const useTodosStore = create((set) => ({
// Initial state loaded from local storage
todos: getInitialTodos(),

// Add a new todo item
addTodo: (title) => {
set((state) => {
const newTodos = [
...state.todos,
{
id: Date.now().toString(), // simple unique id
title,
completed: false,
createdAt: Date.now(),
},
];

// Persist updated todos
saveTodos(newTodos);
return { todos: newTodos };
});
},

// Remove a todo by id
removeTodo: (id) => {
set((state) => {
const newTodos = state.todos.filter((todo) => todo.id !== id);

// Persist updated todos
saveTodos(newTodos);
return { todos: newTodos };
});
},

// Toggle all todos as completed / uncompleted
completeAll: () => {
set((state) => {
const allCompleted = state.todos.every((todo) => todo.completed);
const newTodos = state.todos.map((todo) => ({
...todo,
completed: !allCompleted,
}));

// Persist updated todos
saveTodos(newTodos);
return { todos: newTodos };
});
},

// Toggle completed state for a single todo
toggleTodo: (id) => {
set((state) => {
const newTodos = state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
);

// Persist updated todos
saveTodos(newTodos);
return { todos: newTodos };
});
},
}));
45 changes: 45 additions & 0 deletions src/styles/App.styles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import styled from "styled-components";
import { BaseButton } from "./Buttons.styles";

export const BulkButton = styled(BaseButton)`
margin: 12px 0 20px;
padding: 10px 14px;
`;

export const AppWrapper = styled.main`
width: min(600px, 100%);
max-width: 100%;
margin: 30px auto;
padding: 0 16px;
box-sizing: border-box;
min-width: 0;

overflow-x: clip;

background: #ffffff;
border-radius: 12px;
box-shadow: 0 12px 30px rgba(17, 24, 39, 0.12);

@media (min-width: 640px) {
margin: 40px auto;
padding: 40px;
}
`;
export const Header = styled.header`
margin-top: 10px;
margin-bottom: 10px;

h1 {
padding-top: 15px;
padding-bottom: 10px;
margin: 0;
font-size: 24px;
text-align: center;
}

@media (min-width: 640px) {
h1 {
font-size: 28px;
}
}
`;
40 changes: 40 additions & 0 deletions src/styles/Buttons.styles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import styled from "styled-components";

export const BaseButton = styled.button`
border-radius: 10px;
padding: 10px 14px;

background: #f7f8fc;
border: 1px solid #d7dbe7;
color: #111827;

font-size: 14px;
font-weight: 500;
line-height: 1.4;

cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease,
transform 0.05s ease, box-shadow 0.2s ease;

@media (hover: hover) and (pointer: fine) {
&:hover {
background: #eef1ff;
border-color: #2563eb;
}
}

&:focus-visible {
outline: 3px solid #93c5fd;
outline-offset: 2px;
border-color: #2563eb;
}

&:active {
transform: scale(0.98);
}

&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
`;
19 changes: 19 additions & 0 deletions src/styles/GlobalStyles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createGlobalStyle } from "styled-components";

export const GlobalStyle = createGlobalStyle`
*, *::before, *::after {
box-sizing: border-box;
}

html, body, #root {
margin: 0;
padding: 0;
width: 100%;
overflow-x: hidden;
overscroll-behavior-x: none;
}

body {
touch-action: pan-y;
}
`;
Loading